Skip to main content

kcode_audio_session_ingress/
lib.rs

1#![forbid(unsafe_code)]
2
3use std::collections::{HashMap, HashSet};
4
5use chrono::{DateTime, Utc};
6use kcode_audio_ingress::{
7    AudioIngress, AudioInput, ConfirmationState, CorrectionPacket, ErrorKind as AudioErrorKind,
8    RecordingConfirmation, RecordingState, RecordingStatus,
9};
10use kcode_session_history::{
11    NewIngressSession, RetryIngress as HistoryRetryIngress, SessionHistory, SessionRecord,
12    chatend::SessionKind,
13};
14use serde_json::{Value, json};
15use uuid::Uuid;
16
17const ESTIMATED_CHARACTERS_PER_TOKEN: u64 = 4;
18const INGRESS_CONTEXT_DIVISOR: u64 = 4;
19
20/// Application policy needed to coordinate audio with Session History.
21#[derive(Clone, Debug)]
22pub struct Config {
23    /// Stable application user identifier attributed to accepted recordings.
24    pub user_id: String,
25    /// Effective context window available to an audio-ingress session.
26    pub effective_context_tokens: u64,
27}
28
29/// Admitted recording bytes and source metadata.
30#[derive(Clone, Debug)]
31pub struct RecordingInput {
32    /// Complete WAV bytes already accepted by the transport.
33    pub bytes: Vec<u8>,
34    /// Instant at which the original recording began.
35    pub recorded_at: DateTime<Utc>,
36    /// Original leaf filename, when known.
37    pub original_filename: Option<String>,
38}
39
40/// Result of durably submitting one recording.
41#[derive(Clone, Debug)]
42pub struct RecordingSubmission {
43    /// Current combined state after submission.
44    pub recording: Recording,
45    /// Whether AudioIngress already knew the same bytes.
46    pub deduplicated: bool,
47}
48
49/// Combined recording and memory-ingress status.
50#[derive(Clone, Debug)]
51pub struct Recording {
52    pub id: Uuid,
53    pub sha256: String,
54    pub original_filename: String,
55    pub content_type: &'static str,
56    pub size_bytes: u64,
57    pub source_created_at: String,
58    pub received_at: String,
59    pub updated_at: String,
60    pub status: String,
61    pub transcription_model: String,
62    pub reconciliation_model: String,
63    pub reconciliation_reasoning: String,
64    pub transcription_status: Option<Value>,
65    pub attempt_count: i64,
66    pub next_attempt_at: Option<String>,
67    pub last_error: Option<String>,
68    pub speaker_review: Option<SpeakerReview>,
69    pub transcript_piece_count: usize,
70    pub completed_piece_count: usize,
71}
72
73/// Bounded review summary for one classifier-aware recording.
74#[derive(Clone, Debug)]
75pub struct SpeakerReview {
76    pub clean: bool,
77    pub confirmation_state: ConfirmationState,
78    pub observation_count: usize,
79}
80
81/// One deterministic transcript piece and its Session History lifecycle.
82#[derive(Clone, Debug)]
83pub struct IngressPiece {
84    pub id: String,
85    pub recording_id: Uuid,
86    pub sha256: String,
87    pub original_filename: String,
88    pub source_created_at: String,
89    pub piece_index: u32,
90    pub piece_count: u32,
91    pub transcript_text: String,
92    pub estimated_tokens: u64,
93    pub phase: String,
94    pub provenance_id: Option<String>,
95    pub state: Value,
96    pub version: i64,
97    pub ingress_failure_count: i64,
98    pub ingress_failures: Value,
99    pub created_at: String,
100    pub updated_at: String,
101}
102
103/// Detailed state for one recording.
104#[derive(Clone, Debug)]
105pub struct RecordingHistory {
106    pub recording: Recording,
107    pub final_transcript: Option<String>,
108    pub correction_packet: Option<CorrectionPacket>,
109    pub pieces: Vec<IngressPiece>,
110}
111
112/// Request to retry one transcript piece's memory ingress.
113#[derive(Clone, Debug)]
114pub struct RetryIngress {
115    pub piece_id: String,
116    pub expected_version: i64,
117    /// Optional state retained for 0.2 wire compatibility. When supplied, it
118    /// must exactly equal the current retained Session History state.
119    pub state: Option<Value>,
120}
121
122/// Stable coordinator error category for transport mapping.
123#[derive(Clone, Copy, Debug, Eq, PartialEq)]
124pub enum ErrorKind {
125    InvalidInput,
126    NotFound,
127    Conflict,
128    Internal,
129}
130
131/// Error returned by the audio/session-ingress coordinator.
132#[derive(Debug)]
133pub struct Error {
134    kind: ErrorKind,
135    message: String,
136}
137
138impl Error {
139    fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
140        Self {
141            kind,
142            message: message.into(),
143        }
144    }
145
146    fn invalid(message: impl Into<String>) -> Self {
147        Self::new(ErrorKind::InvalidInput, message)
148    }
149
150    fn conflict(message: impl Into<String>) -> Self {
151        Self::new(ErrorKind::Conflict, message)
152    }
153
154    fn not_found() -> Self {
155        Self::new(
156            ErrorKind::NotFound,
157            "Audio recording or transcript piece not found.",
158        )
159    }
160
161    fn internal(error: impl std::fmt::Display) -> Self {
162        tracing::warn!(%error, "Audio session ingress operation failed");
163        Self::new(
164            ErrorKind::Internal,
165            "An unexpected audio session ingress error occurred.",
166        )
167    }
168
169    pub fn kind(&self) -> ErrorKind {
170        self.kind
171    }
172
173    pub fn message(&self) -> &str {
174        &self.message
175    }
176}
177
178impl std::fmt::Display for Error {
179    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        formatter.write_str(&self.message)
181    }
182}
183
184impl std::error::Error for Error {}
185
186/// Cloneable typed coordinator over AudioIngress and Session History.
187#[derive(Clone)]
188pub struct Coordinator {
189    audio: AudioIngress,
190    history: SessionHistory,
191    user_id: String,
192    effective_context_tokens: u64,
193    maximum_piece_characters: usize,
194}
195
196impl Coordinator {
197    pub fn new(
198        audio: AudioIngress,
199        history: SessionHistory,
200        config: Config,
201    ) -> Result<Self, Error> {
202        if config.user_id.trim().is_empty() {
203            return Err(Error::invalid("audio user ID must not be empty"));
204        }
205        let maximum_piece_characters = maximum_piece_characters(config.effective_context_tokens)?;
206        Ok(Self {
207            audio,
208            history,
209            user_id: config.user_id,
210            effective_context_tokens: config.effective_context_tokens,
211            maximum_piece_characters,
212        })
213    }
214
215    pub fn health(&self) -> Result<(), Error> {
216        self.audio.status().map_err(audio_error)?;
217        Ok(())
218    }
219
220    pub async fn submit(&self, input: RecordingInput) -> Result<RecordingSubmission, Error> {
221        let submission = self
222            .audio
223            .submit(AudioInput {
224                user_id: self.user_id.clone(),
225                bytes: input.bytes,
226                recorded_at: input.recorded_at,
227                original_filename: input.original_filename,
228            })
229            .await
230            .map_err(audio_error)?;
231        let recording_status = self
232            .audio
233            .status()
234            .map_err(audio_error)?
235            .recordings
236            .into_iter()
237            .find(|recording| recording.id == submission.recording_id)
238            .ok_or_else(Error::not_found)?;
239        validate_submission_owner(&recording_status, &self.user_id)?;
240
241        let histories = self.history.list().await.map_err(history_error)?;
242        let projection = ingress_projection(
243            &recording_status,
244            &histories,
245            self.effective_context_tokens,
246            self.maximum_piece_characters,
247        )?;
248        Ok(RecordingSubmission {
249            recording: Recording::from_status(recording_status, &projection),
250            deduplicated: submission.deduplicated,
251        })
252    }
253
254    pub async fn recordings(&self) -> Result<Vec<Recording>, Error> {
255        let histories = self.history.list().await.map_err(history_error)?;
256        self.audio
257            .status()
258            .map_err(audio_error)?
259            .recordings
260            .into_iter()
261            .filter(|recording| recording_belongs_to(recording, &self.user_id))
262            .map(|recording| {
263                let projection = ingress_projection(
264                    &recording,
265                    &histories,
266                    self.effective_context_tokens,
267                    self.maximum_piece_characters,
268                )?;
269                Ok(Recording::from_status(recording, &projection))
270            })
271            .collect()
272    }
273
274    pub async fn recording_by_sha256(&self, sha256: &str) -> Result<Recording, Error> {
275        if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
276            return Err(Error::invalid(
277                "audio SHA-256 must contain exactly 64 hexadecimal characters",
278            ));
279        }
280        let normalized = sha256.to_ascii_lowercase();
281        self.recordings()
282            .await?
283            .into_iter()
284            .find(|recording| recording.sha256 == normalized)
285            .ok_or_else(Error::not_found)
286    }
287
288    pub async fn recording_history(&self, recording_id: Uuid) -> Result<RecordingHistory, Error> {
289        let recording_status = self
290            .audio
291            .status()
292            .map_err(audio_error)?
293            .recordings
294            .into_iter()
295            .find(|recording| {
296                recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
297            })
298            .ok_or_else(Error::not_found)?;
299        let histories = self.history.list().await.map_err(history_error)?;
300        let projection = ingress_projection(
301            &recording_status,
302            &histories,
303            self.effective_context_tokens,
304            self.maximum_piece_characters,
305        )?;
306        let final_transcript = match &recording_status.state {
307            RecordingState::Complete { transcript } => Some(transcript.clone()),
308            _ => None,
309        };
310        let correction_packet = recording_status.correction_packet.clone();
311        let recording = Recording::from_status(recording_status, &projection);
312        Ok(RecordingHistory {
313            recording,
314            final_transcript,
315            correction_packet,
316            pieces: projection.pieces,
317        })
318    }
319
320    pub fn retry_recording(&self, recording_id: Uuid) -> Result<(), Error> {
321        let owned = self
322            .audio
323            .status()
324            .map_err(audio_error)?
325            .recordings
326            .into_iter()
327            .any(|recording| {
328                recording.id == recording_id && recording_belongs_to(&recording, &self.user_id)
329            });
330        if !owned {
331            return Err(Error::not_found());
332        }
333        self.audio.retry(recording_id).map_err(audio_error)
334    }
335
336    pub async fn confirm_speakers(
337        &self,
338        confirmation: RecordingConfirmation,
339    ) -> Result<CorrectionPacket, Error> {
340        let recording_id = confirmation.recording_id;
341        let recording = self
342            .audio
343            .status()
344            .map_err(audio_error)?
345            .recordings
346            .into_iter()
347            .find(|recording| {
348                recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
349            })
350            .ok_or_else(Error::not_found)?;
351        let histories = self.history.list().await.map_err(history_error)?;
352
353        if let Some(packet) = recording
354            .correction_packet
355            .as_ref()
356            .filter(|packet| confirmation_matches(packet, &confirmation))
357        {
358            synchronize_recordings(
359                &self.history,
360                std::slice::from_ref(&recording),
361                &self.user_id,
362                self.effective_context_tokens,
363                self.maximum_piece_characters,
364            )
365            .await?;
366            return Ok(packet.clone());
367        }
368        if recording_has_ingress(recording_id, &histories) {
369            return Err(Error::conflict(
370                "speaker labels are already bound to accepted transcript ingress",
371            ));
372        }
373
374        let packet = self
375            .audio
376            .confirm_speakers(confirmation)
377            .map_err(audio_error)?;
378        let recording = self
379            .audio
380            .status()
381            .map_err(audio_error)?
382            .recordings
383            .into_iter()
384            .find(|recording| {
385                recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
386            })
387            .ok_or_else(Error::not_found)?;
388        synchronize_recordings(
389            &self.history,
390            std::slice::from_ref(&recording),
391            &self.user_id,
392            self.effective_context_tokens,
393            self.maximum_piece_characters,
394        )
395        .await?;
396        Ok(packet)
397    }
398
399    pub async fn retry_ingress(&self, input: RetryIngress) -> Result<SessionRecord, Error> {
400        let recordings = self.audio.status().map_err(audio_error)?.recordings;
401        retry_ingress_exact(
402            &self.history,
403            &recordings,
404            &self.user_id,
405            self.effective_context_tokens,
406            self.maximum_piece_characters,
407            input,
408        )
409        .await
410    }
411
412    pub async fn synchronize_completed_transcripts(&self) -> Result<(), Error> {
413        let recordings = self.audio.status().map_err(audio_error)?.recordings;
414        synchronize_recordings(
415            &self.history,
416            &recordings,
417            &self.user_id,
418            self.effective_context_tokens,
419            self.maximum_piece_characters,
420        )
421        .await
422    }
423}
424
425fn recording_belongs_to(recording: &RecordingStatus, user_id: &str) -> bool {
426    recording.user_id == user_id
427}
428
429fn validate_submission_owner(recording: &RecordingStatus, user_id: &str) -> Result<(), Error> {
430    if recording_belongs_to(recording, user_id) {
431        Ok(())
432    } else {
433        Err(Error::conflict(
434            "identical audio is already attributed to another user",
435        ))
436    }
437}
438
439fn confirmation_matches(packet: &CorrectionPacket, confirmation: &RecordingConfirmation) -> bool {
440    if packet.confirmation_state != ConfirmationState::Confirmed {
441        return false;
442    }
443    let existing = packet
444        .chunks
445        .iter()
446        .flat_map(|chunk| &chunk.observations)
447        .filter_map(|observation| {
448            observation
449                .confirmed_full_name
450                .as_deref()
451                .map(|name| (&observation.observation_key, name))
452        })
453        .collect::<HashMap<_, _>>();
454    existing.len() == confirmation.observations.len()
455        && confirmation.observations.iter().all(|observation| {
456            existing.get(&observation.observation_key).copied()
457                == Some(observation.confirmed_full_name.trim())
458        })
459}
460
461fn recording_has_ingress(recording_id: Uuid, histories: &[SessionRecord]) -> bool {
462    let prefix = format!("audio:{recording_id}:");
463    histories.iter().any(|record| {
464        ingress_source_id(record).is_some_and(|source_id| source_id.starts_with(&prefix))
465    })
466}
467
468#[derive(Debug)]
469struct PieceSpec {
470    id: String,
471    index: u32,
472    count: u32,
473    text: String,
474}
475
476#[derive(Debug)]
477struct SegmentationPlan {
478    specs: Vec<PieceSpec>,
479}
480
481#[derive(Debug, Default)]
482struct IngressProjection {
483    pieces: Vec<IngressPiece>,
484}
485
486impl Recording {
487    fn from_status(recording: RecordingStatus, projection: &IngressProjection) -> Self {
488        let speaker_review = recording
489            .correction_packet
490            .as_ref()
491            .map(|packet| SpeakerReview {
492                clean: packet.clean,
493                confirmation_state: packet.confirmation_state,
494                observation_count: packet
495                    .chunks
496                    .iter()
497                    .map(|chunk| chunk.observations.len())
498                    .sum(),
499            });
500        let awaiting_speaker_review = speaker_review
501            .as_ref()
502            .is_some_and(|review| review.confirmation_state != ConfirmationState::Confirmed);
503        let (mut status, transcription_status, attempt_count, last_error) = match recording.state {
504            RecordingState::Queued => ("uploaded".into(), None, 0, None),
505            RecordingState::Processing { attempt, progress } => (
506                processing_stage(&progress).into(),
507                serde_json::to_value(progress).ok(),
508                i64::from(attempt),
509                None,
510            ),
511            RecordingState::Complete { .. } if awaiting_speaker_review => {
512                ("speaker_review".into(), None, 0, None)
513            }
514            RecordingState::Complete { .. } => ("ready_for_ingress".into(), None, 0, None),
515            RecordingState::Failed {
516                attempts, error, ..
517            } => ("failed".into(), None, i64::from(attempts), Some(error)),
518        };
519        if !projection.pieces.is_empty() {
520            status = if projection
521                .pieces
522                .iter()
523                .all(|piece| piece.phase == "complete")
524            {
525                "complete".into()
526            } else if projection
527                .pieces
528                .iter()
529                .any(|piece| piece.phase == "ingress_failed")
530            {
531                "ingress_failed".into()
532            } else if projection
533                .pieces
534                .iter()
535                .any(|piece| piece.phase == "ingress_in_progress")
536            {
537                "ingressing".into()
538            } else {
539                "ready_for_ingress".into()
540            };
541        }
542        let completed_piece_count = projection
543            .pieces
544            .iter()
545            .filter(|piece| piece.phase == "complete")
546            .count();
547        Self {
548            id: recording.id,
549            sha256: recording.sha256,
550            original_filename: recording.original_filename,
551            content_type: "audio/wav",
552            size_bytes: recording.size_bytes,
553            source_created_at: recording.recorded_at.to_rfc3339(),
554            received_at: recording.received_at.to_rfc3339(),
555            updated_at: recording.received_at.to_rfc3339(),
556            status,
557            transcription_model: recording.transcription_model,
558            reconciliation_model: recording.reconciliation_model,
559            reconciliation_reasoning: recording.reconciliation_reasoning,
560            transcription_status,
561            attempt_count,
562            next_attempt_at: None,
563            last_error,
564            speaker_review,
565            transcript_piece_count: projection.pieces.len(),
566            completed_piece_count,
567        }
568    }
569}
570
571impl IngressPiece {
572    fn from_record(recording: &RecordingStatus, spec: &PieceSpec, record: &SessionRecord) -> Self {
573        Self {
574            id: record.id.clone(),
575            recording_id: recording.id,
576            sha256: recording.sha256.clone(),
577            original_filename: recording.original_filename.clone(),
578            source_created_at: recording.recorded_at.to_rfc3339(),
579            piece_index: spec.index,
580            piece_count: spec.count,
581            transcript_text: spec.text.clone(),
582            estimated_tokens: estimate_tokens(&spec.text),
583            phase: record.phase.clone(),
584            provenance_id: record.provenance_id.clone(),
585            state: record.state.clone(),
586            version: record.version,
587            ingress_failure_count: record.ingress_failure_count,
588            ingress_failures: record.ingress_failures.clone(),
589            created_at: record.started_at.clone(),
590            updated_at: record.updated_at.clone(),
591        }
592    }
593}
594
595async fn retry_ingress_exact(
596    history: &SessionHistory,
597    recordings: &[RecordingStatus],
598    user_id: &str,
599    effective_context_tokens: u64,
600    maximum_piece_characters: usize,
601    input: RetryIngress,
602) -> Result<SessionRecord, Error> {
603    let current = history.get(&input.piece_id).await.map_err(history_error)?;
604    let source_id = ingress_source_id(&current).ok_or_else(Error::not_found)?;
605    let (recording_id, piece_index) =
606        parse_audio_piece_id(source_id).ok_or_else(Error::not_found)?;
607    let recording = recordings
608        .iter()
609        .find(|recording| {
610            recording.id == recording_id
611                && recording_belongs_to(recording, user_id)
612                && matches!(&recording.state, RecordingState::Complete { .. })
613        })
614        .ok_or_else(Error::not_found)?;
615
616    let plan = new_segmentation_plan(
617        recording,
618        effective_context_tokens,
619        maximum_piece_characters,
620    )?;
621    let spec = plan
622        .specs
623        .get(piece_index as usize)
624        .filter(|spec| spec.index == piece_index && spec.id == source_id)
625        .ok_or_else(|| {
626            Error::conflict(
627                "transcript piece no longer matches the authoritative audio segmentation",
628            )
629        })?;
630    validate_persisted_record(recording, spec, &current)?;
631
632    if input
633        .state
634        .as_ref()
635        .is_some_and(|state| state != &current.state)
636    {
637        return Err(Error::conflict(
638            "retry state does not match the current retained Session History state",
639        ));
640    }
641
642    history
643        .retry_ingress(
644            &input.piece_id,
645            HistoryRetryIngress {
646                expected_version: input.expected_version,
647                state: current.state,
648            },
649        )
650        .await
651        .map_err(history_error)
652}
653
654async fn synchronize_recordings(
655    history: &SessionHistory,
656    recordings: &[RecordingStatus],
657    user_id: &str,
658    effective_context_tokens: u64,
659    maximum_piece_characters: usize,
660) -> Result<(), Error> {
661    let histories = history.list().await.map_err(history_error)?;
662    let mut existing = histories
663        .iter()
664        .filter_map(ingress_source_id)
665        .map(str::to_owned)
666        .collect::<HashSet<_>>();
667
668    for recording in recordings {
669        if !recording_belongs_to(recording, user_id)
670            || !matches!(&recording.state, RecordingState::Complete { .. })
671            || !speaker_labels_authorized(recording)
672        {
673            continue;
674        }
675
676        let persisted = persisted_audio_records(recording.id, &histories)?;
677        let persisted_count = persisted_piece_count(&persisted)?;
678        if persisted_count.is_some_and(|count| persisted.len() == count as usize) {
679            continue;
680        }
681
682        let plan = new_segmentation_plan(
683            recording,
684            effective_context_tokens,
685            maximum_piece_characters,
686        )?;
687        if persisted_count.is_some_and(|count| count != plan.specs.len() as u32) {
688            continue;
689        }
690
691        for (index, summary) in &persisted {
692            let Some(spec) = plan.specs.get(*index as usize) else {
693                return Err(Error::conflict(
694                    "persisted audio piece is outside the authoritative segmentation",
695                ));
696            };
697            let current = history.get(&summary.id).await.map_err(history_error)?;
698            validate_persisted_record(recording, spec, &current)?;
699        }
700
701        for spec in &plan.specs {
702            if existing.contains(&spec.id) {
703                continue;
704            }
705            let created = history
706                .enqueue_ingress(NewIngressSession {
707                    idempotency_id: spec.id.clone(),
708                    started_at: recording.recorded_at.to_rfc3339(),
709                    source_session_type: "audio".into(),
710                    kind: SessionKind::AudioIngress,
711                    effective_context_tokens,
712                    text: format_ingress_piece(recording, spec)?,
713                    metadata: audio_piece_metadata(recording, spec),
714                })
715                .await
716                .map_err(history_error)?;
717            if !created.created {
718                validate_persisted_record(recording, spec, &created.value)?;
719            }
720            existing.insert(spec.id.clone());
721        }
722    }
723    Ok(())
724}
725
726fn speaker_labels_authorized(recording: &RecordingStatus) -> bool {
727    recording
728        .correction_packet
729        .as_ref()
730        .is_none_or(|packet| packet.confirmation_state == ConfirmationState::Confirmed)
731}
732
733fn ingress_projection(
734    recording: &RecordingStatus,
735    histories: &[SessionRecord],
736    effective_context_tokens: u64,
737    maximum_piece_characters: usize,
738) -> Result<IngressProjection, Error> {
739    if !matches!(&recording.state, RecordingState::Complete { .. }) {
740        return Ok(IngressProjection::default());
741    }
742    let persisted = persisted_audio_records(recording.id, histories)?;
743    if persisted.is_empty() {
744        return Ok(IngressProjection::default());
745    }
746    let persisted_count = persisted_piece_count(&persisted)?
747        .unwrap_or_else(|| persisted.last().map_or(0, |(index, _)| index + 1));
748    let current = new_segmentation_plan(
749        recording,
750        effective_context_tokens,
751        maximum_piece_characters,
752    )?;
753    let current_matches = current.specs.len() == persisted_count as usize;
754    let transcript = completed_transcript(recording)?.trim();
755    let mut pieces = Vec::with_capacity(persisted.len());
756    for (index, record) in persisted {
757        let text = if persisted_count == 1 {
758            transcript.to_owned()
759        } else if current_matches {
760            current
761                .specs
762                .get(index as usize)
763                .map(|spec| spec.text.clone())
764                .unwrap_or_default()
765        } else {
766            String::new()
767        };
768        let spec = PieceSpec {
769            id: audio_piece_id(recording.id, index),
770            index,
771            count: persisted_count,
772            text,
773        };
774        pieces.push(IngressPiece::from_record(recording, &spec, record));
775    }
776    Ok(IngressProjection { pieces })
777}
778
779fn new_segmentation_plan(
780    recording: &RecordingStatus,
781    effective_context_tokens: u64,
782    current_maximum_piece_characters: usize,
783) -> Result<SegmentationPlan, Error> {
784    if maximum_piece_characters(effective_context_tokens)? != current_maximum_piece_characters {
785        return Err(Error::internal(
786            "audio ingress context and piece limit are inconsistent",
787        ));
788    }
789    Ok(SegmentationPlan {
790        specs: transcript_piece_specs(recording, current_maximum_piece_characters)?,
791    })
792}
793
794fn persisted_audio_records(
795    recording_id: Uuid,
796    histories: &[SessionRecord],
797) -> Result<Vec<(u32, &SessionRecord)>, Error> {
798    let prefix = format!("audio:{recording_id}:");
799    let mut records = Vec::new();
800    let mut indexes = HashSet::new();
801    for record in histories {
802        let Some(source_id) = ingress_source_id(record) else {
803            continue;
804        };
805        let Some(suffix) = source_id.strip_prefix(&prefix) else {
806            continue;
807        };
808        let index = suffix.parse::<u32>().map_err(|_| {
809            Error::conflict("persisted audio piece has an invalid deterministic identity")
810        })?;
811        if audio_piece_id(recording_id, index) != source_id {
812            return Err(Error::conflict(
813                "persisted audio piece has a noncanonical deterministic identity",
814            ));
815        }
816        if !indexes.insert(index) {
817            return Err(Error::conflict(
818                "multiple persisted audio pieces claim the same deterministic identity",
819            ));
820        }
821        records.push((index, record));
822    }
823    records.sort_by_key(|(index, _)| *index);
824    Ok(records)
825}
826
827fn persisted_piece_count(persisted: &[(u32, &SessionRecord)]) -> Result<Option<u32>, Error> {
828    let mut declared_count = None;
829    for (id_index, record) in persisted {
830        let Some(metadata) = record
831            .state
832            .pointer("/ingressSource/metadata")
833            .and_then(Value::as_object)
834        else {
835            continue;
836        };
837        if let Some(index) = metadata.get("pieceIndex").and_then(Value::as_u64) {
838            let index = u32::try_from(index)
839                .map_err(|_| Error::conflict("persisted audio piece index exceeds u32"))?;
840            if index != *id_index {
841                return Err(Error::conflict(
842                    "persisted audio piece index conflicts with its deterministic identity",
843                ));
844            }
845        }
846        let Some(count) = metadata.get("pieceCount").and_then(Value::as_u64) else {
847            continue;
848        };
849        let count = u32::try_from(count)
850            .map_err(|_| Error::conflict("persisted audio piece count exceeds u32"))?;
851        if count == 0 || *id_index >= count {
852            return Err(Error::conflict(
853                "persisted audio piece count conflicts with its deterministic identity",
854            ));
855        }
856        if declared_count
857            .replace(count)
858            .is_some_and(|prior| prior != count)
859        {
860            return Err(Error::conflict(
861                "persisted audio pieces disagree about their piece count",
862            ));
863        }
864    }
865    Ok(declared_count)
866}
867
868fn validate_persisted_record(
869    recording: &RecordingStatus,
870    spec: &PieceSpec,
871    record: &SessionRecord,
872) -> Result<(), Error> {
873    let source_id = ingress_source_id(record).ok_or_else(|| {
874        Error::conflict("replayed audio ingress omitted its deterministic identity")
875    })?;
876    if source_id != spec.id {
877        return Err(Error::conflict(
878            "replayed audio ingress returned a different deterministic identity",
879        ));
880    }
881    if record.state.get("sessionType").and_then(Value::as_str) != Some("audio")
882        || record
883            .state
884            .pointer("/chatendMetadata/kind")
885            .and_then(Value::as_str)
886            != Some("audio_ingress")
887    {
888        return Err(Error::conflict(
889            "persisted transcript piece is not an authoritative audio ingress session",
890        ));
891    }
892    let expected_metadata = audio_piece_metadata(recording, spec);
893    if record.state.pointer("/ingressSource/metadata") != Some(&expected_metadata) {
894        return Err(Error::conflict(
895            "persisted transcript piece metadata no longer matches the authoritative audio piece",
896        ));
897    }
898    let expected_text = format_ingress_piece(recording, spec)?;
899    if record
900        .state
901        .pointer("/transcript/0/content")
902        .and_then(Value::as_str)
903        != Some(expected_text.as_str())
904    {
905        return Err(Error::conflict(
906            "persisted transcript piece text no longer matches the authoritative audio piece",
907        ));
908    }
909    Ok(())
910}
911
912fn completed_transcript(recording: &RecordingStatus) -> Result<&str, Error> {
913    let RecordingState::Complete { transcript } = &recording.state else {
914        return Err(Error::internal(
915            "audio segmentation requested for an incomplete recording",
916        ));
917    };
918    Ok(transcript)
919}
920
921fn transcript_piece_specs(
922    recording: &RecordingStatus,
923    maximum_piece_characters: usize,
924) -> Result<Vec<PieceSpec>, Error> {
925    let RecordingState::Complete { transcript } = &recording.state else {
926        return Ok(Vec::new());
927    };
928    let pieces = split_transcript(transcript, maximum_piece_characters)?;
929    let piece_count = u32::try_from(pieces.len())
930        .map_err(|_| Error::internal("audio transcript contains too many pieces"))?;
931    pieces
932        .into_iter()
933        .enumerate()
934        .map(|(index, text)| {
935            let piece_index = u32::try_from(index)
936                .map_err(|_| Error::internal("audio transcript piece index exceeds u32"))?;
937            Ok(PieceSpec {
938                id: audio_piece_id(recording.id, piece_index),
939                index: piece_index,
940                count: piece_count,
941                text,
942            })
943        })
944        .collect()
945}
946
947fn ingress_source_id(record: &SessionRecord) -> Option<&str> {
948    record
949        .state
950        .pointer("/ingressSource/idempotencyId")
951        .and_then(Value::as_str)
952}
953
954fn parse_audio_piece_id(value: &str) -> Option<(Uuid, u32)> {
955    let mut components = value.split(':');
956    if components.next()? != "audio" {
957        return None;
958    }
959    let recording_id = Uuid::parse_str(components.next()?).ok()?;
960    let piece_index = components.next()?.parse::<u32>().ok()?;
961    if components.next().is_some() || audio_piece_id(recording_id, piece_index) != value {
962        return None;
963    }
964    Some((recording_id, piece_index))
965}
966
967fn audio_piece_id(recording_id: Uuid, piece_index: u32) -> String {
968    format!("audio:{recording_id}:{piece_index}")
969}
970
971fn audio_piece_metadata(recording: &RecordingStatus, spec: &PieceSpec) -> Value {
972    json!({
973        "kind":"audio-transcript",
974        "recordingId":recording.id.to_string(),
975        "sha256":recording.sha256,
976        "originalFilename":recording.original_filename,
977        "extension":file_name_extension(&recording.original_filename),
978        "mimeType":"audio/wav",
979        "sizeBytes":recording.size_bytes,
980        "sourceCreatedAt":recording.recorded_at.to_rfc3339(),
981        "pieceIndex":spec.index,
982        "pieceCount":spec.count,
983        "speakerConfirmationState":recording.correction_packet.as_ref().map(|packet| match packet.confirmation_state {
984            ConfirmationState::Unconfirmed => "unconfirmed",
985            ConfirmationState::AutomaticallyTrained => "automatically_trained",
986            ConfirmationState::Confirmed => "confirmed",
987        }),
988    })
989}
990
991fn format_ingress_piece(recording: &RecordingStatus, spec: &PieceSpec) -> Result<String, Error> {
992    let speaker_mapping = confirmed_speaker_mapping(recording)?;
993    Ok(format!(
994        "Vnote final transcript piece\n\nRecording began: {}\nRecording SHA-256: {}\nOriginal filename: {}\nExtension: {}\nMIME type: audio/wav\nSize: {} bytes\nTranscript piece: {} of {}{}\n\n{}",
995        recording.recorded_at.to_rfc3339(),
996        recording.sha256,
997        recording.original_filename,
998        file_name_extension(&recording.original_filename),
999        recording.size_bytes,
1000        spec.index + 1,
1001        spec.count,
1002        speaker_mapping,
1003        spec.text,
1004    ))
1005}
1006
1007fn confirmed_speaker_mapping(recording: &RecordingStatus) -> Result<String, Error> {
1008    let Some(packet) = recording.correction_packet.as_ref() else {
1009        return Ok(String::new());
1010    };
1011    if packet.confirmation_state != ConfirmationState::Confirmed {
1012        return Err(Error::conflict(
1013            "audio speaker labels require exact human confirmation before ingress",
1014        ));
1015    }
1016    let mut lines = Vec::new();
1017    for chunk in &packet.chunks {
1018        for observation in &chunk.observations {
1019            let confirmed = observation.confirmed_full_name.as_deref().ok_or_else(|| {
1020                Error::internal("confirmed audio packet omitted an observation label")
1021            })?;
1022            let local_label =
1023                serde_json::to_string(&observation.local_label).map_err(Error::internal)?;
1024            let confirmed = serde_json::to_string(confirmed).map_err(Error::internal)?;
1025            let candidate = observation
1026                .identified_full_name
1027                .as_deref()
1028                .map(serde_json::to_string)
1029                .transpose()
1030                .map_err(Error::internal)?
1031                .unwrap_or_else(|| "null".into());
1032            lines.push(format!(
1033                "- chunk {}/{}, source {:.3}-{:.3}s: localLabel={}, confirmedFullName={}, classifierCandidate={}",
1034                chunk.chunk_index + 1,
1035                chunk.chunk_count,
1036                chunk.audio_start_ms as f64 / 1_000.0,
1037                chunk.audio_end_ms as f64 / 1_000.0,
1038                local_label,
1039                confirmed,
1040                candidate,
1041            ));
1042        }
1043    }
1044    if lines.is_empty() {
1045        return Err(Error::internal(
1046            "confirmed audio packet contains no speaker observations",
1047        ));
1048    }
1049    Ok(format!(
1050        "\n\nHuman-confirmed speaker-label data (authoritative; do not infer alternatives):\n{}",
1051        lines.join("\n")
1052    ))
1053}
1054
1055fn file_name_extension(file_name: &str) -> String {
1056    file_name
1057        .rsplit_once('.')
1058        .and_then(|(stem, extension)| {
1059            (!stem.is_empty() && !extension.is_empty()).then_some(extension)
1060        })
1061        .map(|extension| format!(".{extension}"))
1062        .unwrap_or_else(|| "(none)".into())
1063}
1064
1065fn processing_stage(status: &kcode_audio_ingress::TranscriptionStatus) -> &'static str {
1066    let plan_complete = status.steps.iter().any(|entry| {
1067        entry.step == kcode_audio_ingress::Step::PlanChunks
1068            && entry.state == kcode_audio_ingress::StepState::Completed
1069    });
1070    if !plan_complete {
1071        return "chunking";
1072    }
1073    let chunks_complete = status
1074        .steps
1075        .iter()
1076        .filter(|entry| {
1077            matches!(
1078                entry.step,
1079                kcode_audio_ingress::Step::TranscribeChunk { .. }
1080            )
1081        })
1082        .all(|entry| entry.state == kcode_audio_ingress::StepState::Completed);
1083    if chunks_complete {
1084        let analyses_complete = status
1085            .steps
1086            .iter()
1087            .filter(|entry| matches!(entry.step, kcode_audio_ingress::Step::ParseChunk { .. }))
1088            .all(|entry| entry.state == kcode_audio_ingress::StepState::Completed);
1089        if !analyses_complete {
1090            return "analyzing_speakers";
1091        }
1092        let training_active = status.steps.iter().any(|entry| {
1093            entry.step == kcode_audio_ingress::Step::TrainIdentities
1094                && matches!(
1095                    entry.state,
1096                    kcode_audio_ingress::StepState::Running
1097                        | kcode_audio_ingress::StepState::Retrying
1098                )
1099        });
1100        if training_active {
1101            "training_speakers"
1102        } else {
1103            "reconciling"
1104        }
1105    } else {
1106        "transcribing"
1107    }
1108}
1109
1110fn maximum_piece_characters(effective_context_tokens: u64) -> Result<usize, Error> {
1111    let piece_tokens = effective_context_tokens / INGRESS_CONTEXT_DIVISOR;
1112    if piece_tokens == 0 {
1113        return Err(Error::invalid(
1114            "effective ingress context must contain at least four tokens",
1115        ));
1116    }
1117    let characters = piece_tokens
1118        .checked_mul(ESTIMATED_CHARACTERS_PER_TOKEN)
1119        .ok_or_else(|| Error::invalid("effective ingress context is too large"))?;
1120    usize::try_from(characters)
1121        .map_err(|_| Error::invalid("effective ingress context exceeds platform limits"))
1122}
1123
1124fn split_transcript(
1125    transcript: &str,
1126    maximum_piece_characters: usize,
1127) -> Result<Vec<String>, Error> {
1128    let mut remaining = transcript.trim();
1129    if remaining.is_empty() {
1130        return Err(Error::internal("completed audio transcript is empty"));
1131    }
1132    let mut pieces = Vec::new();
1133    while remaining.chars().count() > maximum_piece_characters {
1134        let cutoff = remaining
1135            .char_indices()
1136            .nth(maximum_piece_characters)
1137            .map(|(index, _)| index)
1138            .unwrap_or(remaining.len());
1139        let prefix = &remaining[..cutoff];
1140        let minimum = prefix
1141            .char_indices()
1142            .nth(maximum_piece_characters / 2)
1143            .map(|(index, _)| index)
1144            .unwrap_or(0);
1145        let boundary = prefix
1146            .rfind("\n\n")
1147            .filter(|index| *index >= minimum)
1148            .or_else(|| prefix.rfind('\n').filter(|index| *index >= minimum))
1149            .unwrap_or(cutoff);
1150        let piece = remaining[..boundary].trim();
1151        if piece.is_empty() {
1152            return Err(Error::internal("could not split audio transcript"));
1153        }
1154        pieces.push(piece.to_owned());
1155        remaining = remaining[boundary..].trim();
1156    }
1157    if !remaining.is_empty() {
1158        pieces.push(remaining.to_owned());
1159    }
1160    Ok(pieces)
1161}
1162
1163fn estimate_tokens(value: &str) -> u64 {
1164    (value.chars().count() as u64).div_ceil(ESTIMATED_CHARACTERS_PER_TOKEN)
1165}
1166
1167fn audio_error(error: kcode_audio_ingress::Error) -> Error {
1168    match error.kind() {
1169        AudioErrorKind::InvalidInput => Error::new(ErrorKind::InvalidInput, error.to_string()),
1170        AudioErrorKind::NotFound => Error::new(ErrorKind::NotFound, error.to_string()),
1171        AudioErrorKind::Conflict => Error::new(ErrorKind::Conflict, error.to_string()),
1172        AudioErrorKind::Internal => Error::internal(error),
1173    }
1174}
1175
1176fn history_error(error: kcode_session_history::Error) -> Error {
1177    let kind = match error.kind {
1178        kcode_session_history::ErrorKind::InvalidInput => ErrorKind::InvalidInput,
1179        kcode_session_history::ErrorKind::NotFound => ErrorKind::NotFound,
1180        kcode_session_history::ErrorKind::Conflict => ErrorKind::Conflict,
1181        kcode_session_history::ErrorKind::Storage => ErrorKind::Internal,
1182    };
1183    Error::new(kind, error.message)
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188    use std::path::{Path, PathBuf};
1189
1190    use super::*;
1191
1192    struct TestRoot(PathBuf);
1193
1194    impl TestRoot {
1195        fn new() -> Self {
1196            let path = std::env::temp_dir().join(format!(
1197                "kcode-audio-session-ingress-test-{}",
1198                Uuid::new_v4()
1199            ));
1200            std::fs::create_dir(&path).unwrap();
1201            Self(path)
1202        }
1203
1204        fn path(&self) -> &Path {
1205            &self.0
1206        }
1207    }
1208
1209    impl Drop for TestRoot {
1210        fn drop(&mut self) {
1211            let _ = std::fs::remove_dir_all(&self.0);
1212        }
1213    }
1214
1215    fn history(root: &Path) -> SessionHistory {
1216        SessionHistory::open(kcode_session_history::Config {
1217            directory: root.join("active"),
1218            completed_list: root.join("completed.txt"),
1219            provider_cost_compatibility: None,
1220        })
1221        .unwrap()
1222    }
1223
1224    fn completed_recording(transcript: impl Into<String>) -> RecordingStatus {
1225        completed_recording_for_user("user", transcript)
1226    }
1227
1228    fn completed_recording_for_user(
1229        user_id: impl Into<String>,
1230        transcript: impl Into<String>,
1231    ) -> RecordingStatus {
1232        let now = Utc::now();
1233        RecordingStatus {
1234            id: Uuid::new_v4(),
1235            user_id: user_id.into(),
1236            sha256: "0".repeat(64),
1237            original_filename: "meeting.final.WAV".into(),
1238            size_bytes: 42,
1239            recorded_at: now,
1240            received_at: now,
1241            transcription_model: "transcription-model".into(),
1242            reconciliation_model: "reconciliation-model".into(),
1243            reconciliation_reasoning: "xhigh".into(),
1244            state: RecordingState::Complete {
1245                transcript: transcript.into(),
1246            },
1247            correction_packet: None,
1248        }
1249    }
1250
1251    fn with_speaker_packet(
1252        mut recording: RecordingStatus,
1253        state: ConfirmationState,
1254        confirmed_name: Option<&str>,
1255    ) -> RecordingStatus {
1256        recording.correction_packet = Some(CorrectionPacket {
1257            recording_id: recording.id,
1258            user_id: recording.user_id.clone(),
1259            sha256: recording.sha256.clone(),
1260            original_filename: recording.original_filename.clone(),
1261            size_bytes: recording.size_bytes,
1262            recorded_at: recording.recorded_at,
1263            clean: true,
1264            chunk_count: 1,
1265            chunks: vec![kcode_audio_ingress::CorrectionChunk {
1266                chunk_index: 0,
1267                chunk_count: 1,
1268                audio_start_ms: 0,
1269                audio_end_ms: 1_000,
1270                raw_gemini_response: "raw".into(),
1271                parsed: kcode_audio_ingress::ParsedChunk {
1272                    utterances: Vec::new(),
1273                    notes: Vec::new(),
1274                    clip_valid: true,
1275                    clip_validity_reason: None,
1276                    speakers: Vec::new(),
1277                },
1278                observations: vec![kcode_audio_ingress::CorrectionObservation {
1279                    local_label: "Speaker A".into(),
1280                    speaker_ordinal: 0,
1281                    observation_key: kcode_audio_ingress::ObservationKey {
1282                        object_id: format!(
1283                            "kcode-audio-ingress/recording/{}/chunk/0",
1284                            recording.id
1285                        ),
1286                        piece_index: 0,
1287                    },
1288                    candidate: Some(kcode_audio_ingress::CandidateMapping {
1289                        full_name: "Classifier Candidate".into(),
1290                        cost: 1.0,
1291                        confidence: 3.0,
1292                        runner_up_full_name: None,
1293                        runner_up_cost: None,
1294                        background_population_cost: 4.0,
1295                    }),
1296                    identified_full_name: Some("Classifier Candidate".into()),
1297                    confirmed_full_name: confirmed_name.map(str::to_owned),
1298                }],
1299                clean: true,
1300            }],
1301            confirmation_state: state,
1302        });
1303        recording
1304    }
1305
1306    async fn fail_piece(history: &SessionHistory, record: SessionRecord) -> SessionRecord {
1307        let started = history
1308            .start_ingress(
1309                &record.id,
1310                kcode_session_history::StartIngress {
1311                    expected_version: record.version,
1312                    provenance_id: format!("test:{}", record.id),
1313                },
1314            )
1315            .await
1316            .unwrap();
1317        history
1318            .fail_ingress(
1319                &record.id,
1320                kcode_session_history::IngressFailure {
1321                    expected_version: started.version,
1322                    stage: "test".into(),
1323                    code: Some("input_too_large".into()),
1324                    message: "terminal test failure".into(),
1325                    rounds_used: None,
1326                    context_tokens: None,
1327                    context_window_tokens: None,
1328                },
1329            )
1330            .await
1331            .unwrap()
1332    }
1333
1334    async fn synchronized_failed_piece(
1335        history: &SessionHistory,
1336        recording: &RecordingStatus,
1337        user_id: &str,
1338        effective_context_tokens: u64,
1339    ) -> SessionRecord {
1340        synchronize_recordings(
1341            history,
1342            std::slice::from_ref(recording),
1343            user_id,
1344            effective_context_tokens,
1345            maximum_piece_characters(effective_context_tokens).unwrap(),
1346        )
1347        .await
1348        .unwrap();
1349        let source_id = audio_piece_id(recording.id, 0);
1350        let record = history
1351            .list()
1352            .await
1353            .unwrap()
1354            .into_iter()
1355            .find(|record| ingress_source_id(record) == Some(source_id.as_str()))
1356            .unwrap();
1357        fail_piece(history, record).await
1358    }
1359
1360    #[test]
1361    fn transcript_piece_limit_is_one_quarter_of_effective_context() {
1362        let maximum_characters = maximum_piece_characters(400).unwrap();
1363        let pieces = split_transcript(&"a".repeat(801), maximum_characters).unwrap();
1364        assert_eq!(maximum_characters, 400);
1365        assert_eq!(pieces.len(), 3);
1366        assert!(pieces.iter().all(|piece| estimate_tokens(piece) <= 100));
1367    }
1368
1369    #[test]
1370    fn transcript_splitting_prefers_a_late_paragraph_boundary() {
1371        let transcript = format!("{}\n\n{}", "a".repeat(250), "b".repeat(200));
1372        assert_eq!(
1373            split_transcript(&transcript, 400).unwrap(),
1374            vec!["a".repeat(250), "b".repeat(200)]
1375        );
1376    }
1377
1378    #[test]
1379    fn transcript_splitting_counts_unicode_scalars_not_bytes() {
1380        let transcript = "😀".repeat(5);
1381        let pieces = split_transcript(&transcript, 2).unwrap();
1382        assert_eq!(pieces.concat(), transcript);
1383        assert!(pieces.iter().all(|piece| piece.chars().count() <= 2));
1384    }
1385
1386    #[test]
1387    fn audio_piece_ids_parse_only_in_canonical_form() {
1388        let recording_id = Uuid::new_v4();
1389        let id = audio_piece_id(recording_id, 2);
1390        assert_eq!(parse_audio_piece_id(&id), Some((recording_id, 2)));
1391        assert!(parse_audio_piece_id(&format!("audio:{recording_id}:02")).is_none());
1392        assert!(parse_audio_piece_id("audio:not-a-uuid:0").is_none());
1393    }
1394
1395    #[test]
1396    fn shared_status_is_filtered_to_the_configured_user() {
1397        let mut own = completed_recording_for_user("own-user", "Own transcript");
1398        own.sha256 = "a".repeat(64);
1399        let mut foreign = completed_recording_for_user("foreign-user", "Foreign transcript");
1400        foreign.sha256 = own.sha256.clone();
1401        let recordings = [own.clone(), foreign.clone()];
1402
1403        let visible = recordings
1404            .iter()
1405            .filter(|recording| recording_belongs_to(recording, "own-user"))
1406            .collect::<Vec<_>>();
1407        assert_eq!(visible.len(), 1);
1408        assert_eq!(visible[0].id, own.id);
1409        assert_eq!(
1410            validate_submission_owner(&foreign, "own-user")
1411                .unwrap_err()
1412                .kind(),
1413            ErrorKind::Conflict
1414        );
1415    }
1416
1417    #[test]
1418    fn confirmed_label_retries_must_match_the_complete_mapping() {
1419        let recording = with_speaker_packet(
1420            completed_recording("Transcript"),
1421            ConfirmationState::Confirmed,
1422            Some("Human Choice"),
1423        );
1424        let packet = recording.correction_packet.as_ref().unwrap();
1425        let key = packet.chunks[0].observations[0].observation_key.clone();
1426        let matching = RecordingConfirmation {
1427            recording_id: recording.id,
1428            observations: vec![kcode_audio_ingress::ObservationConfirmation {
1429                observation_key: key.clone(),
1430                confirmed_full_name: " Human Choice ".into(),
1431            }],
1432        };
1433        let conflicting = RecordingConfirmation {
1434            recording_id: recording.id,
1435            observations: vec![kcode_audio_ingress::ObservationConfirmation {
1436                observation_key: key,
1437                confirmed_full_name: "Different Choice".into(),
1438            }],
1439        };
1440        assert!(confirmation_matches(packet, &matching));
1441        assert!(!confirmation_matches(packet, &conflicting));
1442    }
1443
1444    #[tokio::test]
1445    async fn classifier_aware_recordings_wait_for_human_review() {
1446        let root = TestRoot::new();
1447        let history = history(root.path());
1448        let recording = with_speaker_packet(
1449            completed_recording("Transcript"),
1450            ConfirmationState::AutomaticallyTrained,
1451            None,
1452        );
1453        synchronize_recordings(&history, &[recording], "user", 400, 400)
1454            .await
1455            .unwrap();
1456        assert!(history.list().await.unwrap().is_empty());
1457    }
1458
1459    #[tokio::test]
1460    async fn confirmed_labels_accompany_the_transcript() {
1461        let root = TestRoot::new();
1462        let history = history(root.path());
1463        let recording = with_speaker_packet(
1464            completed_recording("Transcript"),
1465            ConfirmationState::Confirmed,
1466            Some("Human Choice"),
1467        );
1468        synchronize_recordings(&history, std::slice::from_ref(&recording), "user", 400, 400)
1469            .await
1470            .unwrap();
1471        let records = history.list().await.unwrap();
1472        assert_eq!(records.len(), 1);
1473        let state = serde_json::to_string(&records[0].state).unwrap();
1474        assert!(state.contains("Human-confirmed speaker-label data"));
1475        assert!(state.contains("Human Choice"));
1476        assert!(state.contains("Classifier Candidate"));
1477    }
1478
1479    #[tokio::test]
1480    async fn bulk_synchronization_creates_no_foreign_pieces() {
1481        let root = TestRoot::new();
1482        let history = history(root.path());
1483        let own = completed_recording_for_user("own-user", "Own transcript");
1484        let foreign = completed_recording_for_user("foreign-user", "Foreign transcript");
1485
1486        synchronize_recordings(
1487            &history,
1488            &[own.clone(), foreign.clone()],
1489            "own-user",
1490            400,
1491            400,
1492        )
1493        .await
1494        .unwrap();
1495
1496        let records = history.list().await.unwrap();
1497        assert_eq!(records.len(), 1);
1498        assert_eq!(
1499            ingress_source_id(&records[0]),
1500            Some(audio_piece_id(own.id, 0).as_str())
1501        );
1502        assert!(records.iter().all(
1503            |record| ingress_source_id(record) != Some(audio_piece_id(foreign.id, 0).as_str())
1504        ));
1505    }
1506
1507    #[tokio::test]
1508    async fn synchronization_is_idempotent() {
1509        let root = TestRoot::new();
1510        let history = history(root.path());
1511        let recording = completed_recording("a".repeat(801));
1512        for _ in 0..2 {
1513            synchronize_recordings(&history, std::slice::from_ref(&recording), "user", 400, 400)
1514                .await
1515                .unwrap();
1516        }
1517        assert_eq!(history.list().await.unwrap().len(), 3);
1518    }
1519
1520    #[tokio::test]
1521    async fn context_changes_leave_complete_accepted_set_untouched() {
1522        let root = TestRoot::new();
1523        let history = history(root.path());
1524        let recording = completed_recording("a".repeat(801));
1525        synchronize_recordings(&history, std::slice::from_ref(&recording), "user", 400, 400)
1526            .await
1527            .unwrap();
1528        let before = history
1529            .list()
1530            .await
1531            .unwrap()
1532            .iter()
1533            .filter_map(ingress_source_id)
1534            .map(str::to_owned)
1535            .collect::<HashSet<_>>();
1536        synchronize_recordings(&history, std::slice::from_ref(&recording), "user", 800, 800)
1537            .await
1538            .unwrap();
1539        let after = history
1540            .list()
1541            .await
1542            .unwrap()
1543            .iter()
1544            .filter_map(ingress_source_id)
1545            .map(str::to_owned)
1546            .collect::<HashSet<_>>();
1547        assert_eq!(before, after);
1548    }
1549
1550    #[tokio::test]
1551    async fn exact_current_piece_retry_accepts_equal_optional_state() {
1552        let root = TestRoot::new();
1553        let history = history(root.path());
1554        let recording = completed_recording("Transcript");
1555        let failed = synchronized_failed_piece(&history, &recording, "user", 400).await;
1556
1557        let retried = retry_ingress_exact(
1558            &history,
1559            std::slice::from_ref(&recording),
1560            "user",
1561            400,
1562            400,
1563            RetryIngress {
1564                piece_id: failed.id,
1565                expected_version: failed.version,
1566                state: Some(failed.state),
1567            },
1568        )
1569        .await
1570        .unwrap();
1571        assert_eq!(retried.phase, "ingress_pending");
1572    }
1573
1574    #[tokio::test]
1575    async fn retry_without_optional_state_remains_compatible() {
1576        let root = TestRoot::new();
1577        let history = history(root.path());
1578        let recording = completed_recording("Transcript");
1579        let failed = synchronized_failed_piece(&history, &recording, "user", 400).await;
1580
1581        let retried = retry_ingress_exact(
1582            &history,
1583            std::slice::from_ref(&recording),
1584            "user",
1585            400,
1586            400,
1587            RetryIngress {
1588                piece_id: failed.id,
1589                expected_version: failed.version,
1590                state: None,
1591            },
1592        )
1593        .await
1594        .unwrap();
1595        assert_eq!(retried.phase, "ingress_pending");
1596    }
1597
1598    #[tokio::test]
1599    async fn changed_retry_state_conflicts_without_mutation() {
1600        let root = TestRoot::new();
1601        let history = history(root.path());
1602        let recording = completed_recording("Transcript");
1603        let failed = synchronized_failed_piece(&history, &recording, "user", 400).await;
1604        let mut changed = failed.state.clone();
1605        changed["pendingTurn"] = json!(true);
1606
1607        let error = retry_ingress_exact(
1608            &history,
1609            std::slice::from_ref(&recording),
1610            "user",
1611            400,
1612            400,
1613            RetryIngress {
1614                piece_id: failed.id.clone(),
1615                expected_version: failed.version,
1616                state: Some(changed),
1617            },
1618        )
1619        .await
1620        .unwrap_err();
1621        assert_eq!(error.kind(), ErrorKind::Conflict);
1622        assert_eq!(
1623            history.get(&failed.id).await.unwrap().version,
1624            failed.version
1625        );
1626    }
1627
1628    #[tokio::test]
1629    async fn unrelated_session_history_id_cannot_retry() {
1630        let root = TestRoot::new();
1631        let history = history(root.path());
1632        let recording = completed_recording("Transcript");
1633        let unrelated = history
1634            .start(kcode_session_history::StartSession {
1635                idempotency_id: "unrelated".into(),
1636                started_at: recording.recorded_at.to_rfc3339(),
1637                session_type: "conversation".into(),
1638                duration_minutes: None,
1639                custom_prompt: None,
1640            })
1641            .await
1642            .unwrap()
1643            .value;
1644
1645        let error = retry_ingress_exact(
1646            &history,
1647            std::slice::from_ref(&recording),
1648            "user",
1649            400,
1650            400,
1651            RetryIngress {
1652                piece_id: unrelated.id.clone(),
1653                expected_version: unrelated.version,
1654                state: None,
1655            },
1656        )
1657        .await
1658        .unwrap_err();
1659        assert_eq!(error.kind(), ErrorKind::NotFound);
1660        assert_eq!(
1661            history.get(&unrelated.id).await.unwrap().version,
1662            unrelated.version
1663        );
1664    }
1665
1666    #[tokio::test]
1667    async fn malformed_audio_looking_id_cannot_retry() {
1668        let root = TestRoot::new();
1669        let history = history(root.path());
1670        let recording = completed_recording("Transcript");
1671        let malformed = history
1672            .enqueue_ingress(NewIngressSession {
1673                idempotency_id: "audio:not-a-uuid:0".into(),
1674                started_at: recording.recorded_at.to_rfc3339(),
1675                source_session_type: "audio".into(),
1676                kind: SessionKind::AudioIngress,
1677                effective_context_tokens: 400,
1678                text: "Malformed source".into(),
1679                metadata: json!({}),
1680            })
1681            .await
1682            .unwrap()
1683            .value;
1684
1685        let error = retry_ingress_exact(
1686            &history,
1687            std::slice::from_ref(&recording),
1688            "user",
1689            400,
1690            400,
1691            RetryIngress {
1692                piece_id: malformed.id.clone(),
1693                expected_version: malformed.version,
1694                state: None,
1695            },
1696        )
1697        .await
1698        .unwrap_err();
1699        assert_eq!(error.kind(), ErrorKind::NotFound);
1700        assert_eq!(
1701            history.get(&malformed.id).await.unwrap().version,
1702            malformed.version
1703        );
1704    }
1705
1706    #[tokio::test]
1707    async fn foreign_audio_piece_cannot_retry_or_mutate() {
1708        let root = TestRoot::new();
1709        let history = history(root.path());
1710        let own = completed_recording_for_user("own-user", "Own transcript");
1711        let foreign = completed_recording_for_user("foreign-user", "Foreign transcript");
1712        let failed = synchronized_failed_piece(&history, &foreign, "foreign-user", 400).await;
1713
1714        let error = retry_ingress_exact(
1715            &history,
1716            &[own, foreign],
1717            "own-user",
1718            400,
1719            400,
1720            RetryIngress {
1721                piece_id: failed.id.clone(),
1722                expected_version: failed.version,
1723                state: None,
1724            },
1725        )
1726        .await
1727        .unwrap_err();
1728        assert_eq!(error.kind(), ErrorKind::NotFound);
1729        let unchanged = history.get(&failed.id).await.unwrap();
1730        assert_eq!(unchanged.phase, "ingress_failed");
1731        assert_eq!(unchanged.version, failed.version);
1732    }
1733
1734    #[tokio::test]
1735    async fn retry_rejects_context_and_transcript_drift() {
1736        let root = TestRoot::new();
1737        let history = history(root.path());
1738        let recording = completed_recording("a".repeat(801));
1739        let failed = synchronized_failed_piece(&history, &recording, "user", 400).await;
1740
1741        let context_error = retry_ingress_exact(
1742            &history,
1743            std::slice::from_ref(&recording),
1744            "user",
1745            800,
1746            800,
1747            RetryIngress {
1748                piece_id: failed.id.clone(),
1749                expected_version: failed.version,
1750                state: None,
1751            },
1752        )
1753        .await
1754        .unwrap_err();
1755        assert_eq!(context_error.kind(), ErrorKind::Conflict);
1756
1757        let mut changed = recording.clone();
1758        changed.state = RecordingState::Complete {
1759            transcript: format!("b{}", "a".repeat(800)),
1760        };
1761        let transcript_error = retry_ingress_exact(
1762            &history,
1763            std::slice::from_ref(&changed),
1764            "user",
1765            400,
1766            400,
1767            RetryIngress {
1768                piece_id: failed.id.clone(),
1769                expected_version: failed.version,
1770                state: None,
1771            },
1772        )
1773        .await
1774        .unwrap_err();
1775        assert_eq!(transcript_error.kind(), ErrorKind::Conflict);
1776        assert_eq!(
1777            history.get(&failed.id).await.unwrap().version,
1778            failed.version
1779        );
1780    }
1781}