Skip to main content

kcode_audio_session_ingress/
lib.rs

1#![forbid(unsafe_code)]
2
3use std::collections::HashSet;
4
5use chrono::{DateTime, Utc};
6use kcode_audio_ingress::{
7    AudioIngress, AudioInput, ErrorKind as AudioErrorKind, RecordingState, RecordingStatus,
8};
9use kcode_session_history::{
10    NewIngressSession, RetryIngress as HistoryRetryIngress, SessionHistory, SessionRecord,
11    chatend::SessionKind,
12};
13use serde_json::{Value, json};
14use uuid::Uuid;
15
16const ESTIMATED_CHARACTERS_PER_TOKEN: u64 = 4;
17const INGRESS_CONTEXT_DIVISOR: u64 = 4;
18const SEGMENTATION_VERSION: u64 = 1;
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 status: String,
60    pub transcription_model: String,
61    pub reconciliation_model: String,
62    pub reconciliation_reasoning: String,
63    /// Current or exhausted attempt count when AudioIngress exposes it.
64    pub attempt_count: Option<u8>,
65    /// Total number of pieces in the current validated transcript segmentation.
66    pub transcript_piece_count: usize,
67    /// Number of current pieces whose Session History ingress is complete.
68    pub completed_piece_count: usize,
69}
70
71/// One deterministic transcript piece and its Session History lifecycle.
72#[derive(Clone, Debug)]
73pub struct IngressPiece {
74    pub id: String,
75    pub recording_id: Uuid,
76    pub sha256: String,
77    pub original_filename: String,
78    pub source_created_at: String,
79    pub piece_index: u32,
80    pub piece_count: u32,
81    pub transcript_text: String,
82    pub estimated_tokens: u64,
83    pub phase: String,
84    pub provenance_id: Option<String>,
85    pub state: Value,
86    pub version: i64,
87    pub ingress_failure_count: i64,
88    pub ingress_failures: Value,
89    pub created_at: String,
90    pub updated_at: String,
91}
92
93/// Detailed state for one recording.
94#[derive(Clone, Debug)]
95pub struct RecordingHistory {
96    pub recording: Recording,
97    pub final_transcript: Option<String>,
98    pub pieces: Vec<IngressPiece>,
99}
100
101/// Request to retry one transcript piece's memory ingress.
102#[derive(Clone, Debug)]
103pub struct RetryIngress {
104    pub piece_id: String,
105    pub expected_version: i64,
106}
107
108/// Stable coordinator error category for transport mapping.
109#[derive(Clone, Copy, Debug, Eq, PartialEq)]
110pub enum ErrorKind {
111    InvalidInput,
112    NotFound,
113    Conflict,
114    Internal,
115}
116
117/// Error returned by the audio/session-ingress coordinator.
118#[derive(Debug)]
119pub struct Error {
120    kind: ErrorKind,
121    message: String,
122}
123
124impl Error {
125    fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
126        Self {
127            kind,
128            message: message.into(),
129        }
130    }
131
132    fn invalid(message: impl Into<String>) -> Self {
133        Self::new(ErrorKind::InvalidInput, message)
134    }
135
136    fn not_found() -> Self {
137        Self::new(
138            ErrorKind::NotFound,
139            "Audio recording or transcript piece not found.",
140        )
141    }
142
143    fn conflict(message: impl Into<String>) -> Self {
144        Self::new(ErrorKind::Conflict, message)
145    }
146
147    fn internal(error: impl std::fmt::Display) -> Self {
148        tracing::warn!(%error, "Audio session ingress operation failed");
149        Self::new(
150            ErrorKind::Internal,
151            "An unexpected audio session ingress error occurred.",
152        )
153    }
154
155    /// Returns the stable error category.
156    pub fn kind(&self) -> ErrorKind {
157        self.kind
158    }
159
160    /// Returns the sanitized actionable message.
161    pub fn message(&self) -> &str {
162        &self.message
163    }
164}
165
166impl std::fmt::Display for Error {
167    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        formatter.write_str(&self.message)
169    }
170}
171
172impl std::error::Error for Error {}
173
174/// Cloneable typed coordinator over AudioIngress and Session History.
175#[derive(Clone)]
176pub struct Coordinator {
177    audio: AudioIngress,
178    history: SessionHistory,
179    user_id: String,
180    effective_context_tokens: u64,
181    maximum_piece_characters: usize,
182}
183
184impl Coordinator {
185    /// Constructs a coordinator over already-opened capability handles.
186    pub fn new(
187        audio: AudioIngress,
188        history: SessionHistory,
189        config: Config,
190    ) -> Result<Self, Error> {
191        if config.user_id.trim().is_empty() {
192            return Err(Error::invalid("audio user ID must not be empty"));
193        }
194        let maximum_piece_characters = maximum_piece_characters(config.effective_context_tokens)?;
195        Ok(Self {
196            audio,
197            history,
198            user_id: config.user_id,
199            effective_context_tokens: config.effective_context_tokens,
200            maximum_piece_characters,
201        })
202    }
203
204    /// Checks that both underlying capabilities can read their current state.
205    pub fn health(&self) -> Result<(), Error> {
206        self.audio.status().map_err(audio_error)?;
207        self.history.health().map_err(history_error)?;
208        Ok(())
209    }
210
211    /// Durably submits one admitted recording and returns its combined state.
212    pub async fn submit(&self, input: RecordingInput) -> Result<RecordingSubmission, Error> {
213        let submission = self
214            .audio
215            .submit(AudioInput {
216                user_id: self.user_id.clone(),
217                bytes: input.bytes,
218                recorded_at: input.recorded_at,
219                original_filename: input.original_filename,
220            })
221            .await
222            .map_err(audio_error)?;
223        let recording_status = self
224            .audio
225            .status()
226            .map_err(audio_error)?
227            .recordings
228            .into_iter()
229            .find(|recording| recording.id == submission.recording_id)
230            .ok_or_else(Error::not_found)?;
231        if recording_status.user_id != self.user_id {
232            return Err(Error::conflict(
233                "The submitted audio already belongs to a different configured user.",
234            ));
235        }
236        let histories = self.history.list().await.map_err(history_error)?;
237        let projection = ingress_projection(
238            &recording_status,
239            &histories,
240            self.effective_context_tokens,
241            self.maximum_piece_characters,
242        )?;
243        let recording = Recording::from_status(recording_status, &projection);
244        Ok(RecordingSubmission {
245            recording,
246            deduplicated: submission.deduplicated,
247        })
248    }
249
250    /// Returns configured-user recordings with their correlated ingress state.
251    pub async fn recordings(&self) -> Result<Vec<Recording>, Error> {
252        let histories = self.history.list().await.map_err(history_error)?;
253        self.audio
254            .status()
255            .map_err(audio_error)?
256            .recordings
257            .into_iter()
258            .filter(|recording| recording.user_id == self.user_id)
259            .map(|recording| {
260                let projection = ingress_projection(
261                    &recording,
262                    &histories,
263                    self.effective_context_tokens,
264                    self.maximum_piece_characters,
265                )?;
266                Ok(Recording::from_status(recording, &projection))
267            })
268            .collect()
269    }
270
271    /// Finds a configured-user recording by its SHA-256 digest.
272    pub async fn recording_by_sha256(&self, sha256: &str) -> Result<Recording, Error> {
273        if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
274            return Err(Error::invalid(
275                "audio SHA-256 must contain exactly 64 hexadecimal characters",
276            ));
277        }
278        let normalized = sha256.to_ascii_lowercase();
279        self.recordings()
280            .await?
281            .into_iter()
282            .find(|recording| recording.sha256 == normalized)
283            .ok_or_else(Error::not_found)
284    }
285
286    /// Returns one configured-user recording and its correlated pieces.
287    pub async fn recording_history(&self, recording_id: Uuid) -> Result<RecordingHistory, Error> {
288        let recording_status = self
289            .audio
290            .status()
291            .map_err(audio_error)?
292            .recordings
293            .into_iter()
294            .find(|recording| recording.id == recording_id && recording.user_id == self.user_id)
295            .ok_or_else(Error::not_found)?;
296        let histories = self.history.list().await.map_err(history_error)?;
297        let projection = ingress_projection(
298            &recording_status,
299            &histories,
300            self.effective_context_tokens,
301            self.maximum_piece_characters,
302        )?;
303        let final_transcript = match &recording_status.state {
304            RecordingState::Complete { transcript } => Some(transcript.clone()),
305            _ => None,
306        };
307        let recording = Recording::from_status(recording_status, &projection);
308        Ok(RecordingHistory {
309            recording,
310            final_transcript,
311            pieces: projection.pieces,
312        })
313    }
314
315    /// Gives one failed configured-user recording a fresh processing budget.
316    pub fn retry_recording(&self, recording_id: Uuid) -> Result<(), Error> {
317        let owned = self
318            .audio
319            .status()
320            .map_err(audio_error)?
321            .recordings
322            .into_iter()
323            .any(|recording| recording.id == recording_id && recording.user_id == self.user_id);
324        if !owned {
325            return Err(Error::not_found());
326        }
327        self.audio.retry(recording_id).map_err(audio_error)
328    }
329
330    /// Retries memory ingress only for a currently correlated audio piece.
331    pub async fn retry_ingress(&self, input: RetryIngress) -> Result<SessionRecord, Error> {
332        let current = self
333            .history
334            .get(&input.piece_id)
335            .await
336            .map_err(history_error)?;
337        let recordings = self
338            .audio
339            .status()
340            .map_err(audio_error)?
341            .recordings
342            .into_iter()
343            .filter(|recording| recording.user_id == self.user_id)
344            .collect::<Vec<_>>();
345        validate_retry_target(
346            &current,
347            &recordings,
348            self.effective_context_tokens,
349            self.maximum_piece_characters,
350        )?;
351        self.history
352            .retry_ingress(
353                &input.piece_id,
354                HistoryRetryIngress {
355                    expected_version: input.expected_version,
356                    state: current.state,
357                },
358            )
359            .await
360            .map_err(history_error)
361    }
362
363    /// Submits every missing configured-user transcript piece to Session History.
364    pub async fn synchronize_completed_transcripts(&self) -> Result<(), Error> {
365        let recordings = self
366            .audio
367            .status()
368            .map_err(audio_error)?
369            .recordings
370            .into_iter()
371            .filter(|recording| recording.user_id == self.user_id)
372            .collect::<Vec<_>>();
373        synchronize_recordings(
374            &self.history,
375            &recordings,
376            self.effective_context_tokens,
377            self.maximum_piece_characters,
378        )
379        .await
380    }
381}
382
383#[derive(Debug)]
384struct PieceSpec {
385    id: String,
386    index: u32,
387    count: u32,
388    text: String,
389    fingerprint: String,
390}
391
392#[derive(Debug, Default)]
393struct IngressProjection {
394    expected_piece_count: usize,
395    pieces: Vec<IngressPiece>,
396}
397
398impl Recording {
399    fn from_status(recording: RecordingStatus, projection: &IngressProjection) -> Self {
400        let (mut status, attempt_count) = match recording.state {
401            RecordingState::Queued => ("uploaded".into(), Some(0)),
402            RecordingState::Processing { attempt, progress } => {
403                (processing_stage(&progress).into(), Some(attempt))
404            }
405            RecordingState::Complete { .. } => ("ready_for_ingress".into(), None),
406            RecordingState::Failed { attempts, .. } => ("failed".into(), Some(attempts)),
407        };
408        if projection.expected_piece_count != 0 {
409            status = if projection
410                .pieces
411                .iter()
412                .any(|piece| piece.phase == "ingress_failed")
413            {
414                "ingress_failed".into()
415            } else if projection
416                .pieces
417                .iter()
418                .any(|piece| piece.phase == "ingress_in_progress")
419            {
420                "ingressing".into()
421            } else if projection.pieces.len() == projection.expected_piece_count
422                && projection
423                    .pieces
424                    .iter()
425                    .all(|piece| piece.phase == "complete")
426            {
427                "complete".into()
428            } else {
429                "ready_for_ingress".into()
430            };
431        }
432        let completed_piece_count = projection
433            .pieces
434            .iter()
435            .filter(|piece| piece.phase == "complete")
436            .count();
437        Self {
438            id: recording.id,
439            sha256: recording.sha256,
440            original_filename: recording.original_filename,
441            content_type: "audio/wav",
442            size_bytes: recording.size_bytes,
443            source_created_at: recording.recorded_at.to_rfc3339(),
444            received_at: recording.received_at.to_rfc3339(),
445            status,
446            transcription_model: recording.transcription_model,
447            reconciliation_model: recording.reconciliation_model,
448            reconciliation_reasoning: recording.reconciliation_reasoning,
449            attempt_count,
450            transcript_piece_count: projection.expected_piece_count,
451            completed_piece_count,
452        }
453    }
454}
455
456impl IngressPiece {
457    fn from_record(recording: &RecordingStatus, spec: &PieceSpec, record: &SessionRecord) -> Self {
458        Self {
459            id: record.id.clone(),
460            recording_id: recording.id,
461            sha256: recording.sha256.clone(),
462            original_filename: recording.original_filename.clone(),
463            source_created_at: recording.recorded_at.to_rfc3339(),
464            piece_index: spec.index,
465            piece_count: spec.count,
466            estimated_tokens: estimate_tokens(&spec.text),
467            transcript_text: spec.text.clone(),
468            phase: record.phase.clone(),
469            provenance_id: record.provenance_id.clone(),
470            state: record.state.clone(),
471            version: record.version,
472            ingress_failure_count: record.ingress_failure_count,
473            ingress_failures: record.ingress_failures.clone(),
474            created_at: record.started_at.clone(),
475            updated_at: record.updated_at.clone(),
476        }
477    }
478}
479
480async fn synchronize_recordings(
481    history: &SessionHistory,
482    recordings: &[RecordingStatus],
483    effective_context_tokens: u64,
484    maximum_piece_characters: usize,
485) -> Result<(), Error> {
486    let histories = history.list().await.map_err(history_error)?;
487    for recording in recordings {
488        if !matches!(&recording.state, RecordingState::Complete { .. }) {
489            continue;
490        }
491        let specs = transcript_piece_specs(recording, maximum_piece_characters)?;
492        validate_existing_piece_records(
493            recording,
494            &histories,
495            &specs,
496            effective_context_tokens,
497            maximum_piece_characters,
498        )?;
499        let mut existing = histories
500            .iter()
501            .filter_map(ingress_source_id)
502            .map(str::to_owned)
503            .collect::<HashSet<_>>();
504        for spec in specs {
505            if existing.contains(&spec.id) {
506                continue;
507            }
508            let created = history
509                .enqueue_ingress(NewIngressSession {
510                    idempotency_id: spec.id.clone(),
511                    started_at: recording.recorded_at.to_rfc3339(),
512                    source_session_type: "audio".into(),
513                    kind: SessionKind::AudioIngress,
514                    effective_context_tokens,
515                    text: format_ingress_piece(recording, &spec),
516                    metadata: audio_piece_metadata(
517                        recording,
518                        &spec,
519                        effective_context_tokens,
520                        maximum_piece_characters,
521                    ),
522                })
523                .await
524                .map_err(history_error)?;
525            validate_piece_record(
526                &created.value,
527                recording,
528                &spec,
529                effective_context_tokens,
530                maximum_piece_characters,
531            )?;
532            existing.insert(spec.id);
533        }
534    }
535    Ok(())
536}
537
538fn ingress_projection(
539    recording: &RecordingStatus,
540    histories: &[SessionRecord],
541    effective_context_tokens: u64,
542    maximum_piece_characters: usize,
543) -> Result<IngressProjection, Error> {
544    if !matches!(&recording.state, RecordingState::Complete { .. }) {
545        return Ok(IngressProjection::default());
546    }
547    let specs = transcript_piece_specs(recording, maximum_piece_characters)?;
548    validate_existing_piece_records(
549        recording,
550        histories,
551        &specs,
552        effective_context_tokens,
553        maximum_piece_characters,
554    )?;
555    let mut pieces = Vec::with_capacity(specs.len());
556    for spec in &specs {
557        let Some(record) = histories
558            .iter()
559            .find(|record| ingress_source_id(record) == Some(spec.id.as_str()))
560        else {
561            continue;
562        };
563        pieces.push(IngressPiece::from_record(recording, spec, record));
564    }
565    Ok(IngressProjection {
566        expected_piece_count: specs.len(),
567        pieces,
568    })
569}
570
571fn transcript_piece_specs(
572    recording: &RecordingStatus,
573    maximum_piece_characters: usize,
574) -> Result<Vec<PieceSpec>, Error> {
575    let RecordingState::Complete { transcript } = &recording.state else {
576        return Ok(Vec::new());
577    };
578    let pieces = split_transcript(transcript, maximum_piece_characters)?;
579    let piece_count = u32::try_from(pieces.len())
580        .map_err(|_| Error::internal("audio transcript contains too many pieces"))?;
581    pieces
582        .into_iter()
583        .enumerate()
584        .map(|(index, text)| {
585            let piece_index = u32::try_from(index)
586                .map_err(|_| Error::internal("audio transcript piece index exceeds u32"))?;
587            Ok(PieceSpec {
588                id: audio_piece_id(recording.id, piece_index),
589                index: piece_index,
590                count: piece_count,
591                fingerprint: piece_fingerprint(&text),
592                text,
593            })
594        })
595        .collect()
596}
597
598fn validate_existing_piece_records(
599    recording: &RecordingStatus,
600    histories: &[SessionRecord],
601    specs: &[PieceSpec],
602    effective_context_tokens: u64,
603    maximum_piece_characters: usize,
604) -> Result<(), Error> {
605    for record in histories {
606        if let Some(spec) = ingress_source_id(record)
607            .and_then(|source_id| specs.iter().find(|spec| spec.id == source_id))
608        {
609            validate_piece_record(
610                record,
611                recording,
612                spec,
613                effective_context_tokens,
614                maximum_piece_characters,
615            )?;
616        } else if metadata_recording_id(record) == Some(recording.id) {
617            return Err(segmentation_conflict());
618        }
619    }
620    Ok(())
621}
622
623fn validate_retry_target(
624    record: &SessionRecord,
625    recordings: &[RecordingStatus],
626    effective_context_tokens: u64,
627    maximum_piece_characters: usize,
628) -> Result<(), Error> {
629    let Some(source_id) = ingress_source_id(record) else {
630        return Err(Error::not_found());
631    };
632    for recording in recordings {
633        if !matches!(&recording.state, RecordingState::Complete { .. }) {
634            continue;
635        }
636        let specs = transcript_piece_specs(recording, maximum_piece_characters)?;
637        if let Some(spec) = specs.iter().find(|spec| spec.id == source_id) {
638            return validate_piece_record(
639                record,
640                recording,
641                spec,
642                effective_context_tokens,
643                maximum_piece_characters,
644            );
645        }
646        if metadata_recording_id(record) == Some(recording.id) {
647            return Err(segmentation_conflict());
648        }
649    }
650    Err(Error::not_found())
651}
652
653fn validate_piece_record(
654    record: &SessionRecord,
655    recording: &RecordingStatus,
656    spec: &PieceSpec,
657    effective_context_tokens: u64,
658    maximum_piece_characters: usize,
659) -> Result<(), Error> {
660    let Some(metadata) = ingress_metadata(record) else {
661        return Err(segmentation_conflict());
662    };
663    let maximum_piece_characters = u64::try_from(maximum_piece_characters)
664        .map_err(|_| Error::internal("piece limit exceeds u64"))?;
665    let piece_characters = u64::try_from(spec.text.chars().count())
666        .map_err(|_| Error::internal("audio transcript piece exceeds u64 characters"))?;
667    let recording_id = recording.id.to_string();
668    let valid = ingress_source_id(record) == Some(spec.id.as_str())
669        && metadata.get("kind").and_then(Value::as_str) == Some("audio-transcript")
670        && metadata.get("recordingId").and_then(Value::as_str) == Some(recording_id.as_str())
671        && metadata.get("sha256").and_then(Value::as_str) == Some(recording.sha256.as_str())
672        && metadata.get("segmentationVersion").and_then(Value::as_u64)
673            == Some(SEGMENTATION_VERSION)
674        && metadata
675            .get("effectiveContextTokens")
676            .and_then(Value::as_u64)
677            == Some(effective_context_tokens)
678        && metadata
679            .get("maximumPieceCharacters")
680            .and_then(Value::as_u64)
681            == Some(maximum_piece_characters)
682        && metadata.get("pieceIndex").and_then(Value::as_u64) == Some(u64::from(spec.index))
683        && metadata.get("pieceCount").and_then(Value::as_u64) == Some(u64::from(spec.count))
684        && metadata.get("pieceCharacters").and_then(Value::as_u64) == Some(piece_characters)
685        && metadata.get("pieceFingerprint").and_then(Value::as_str)
686            == Some(spec.fingerprint.as_str());
687    if valid {
688        Ok(())
689    } else {
690        Err(segmentation_conflict())
691    }
692}
693
694fn segmentation_conflict() -> Error {
695    Error::conflict(
696        "Existing Session History audio pieces do not match the configured transcript segmentation.",
697    )
698}
699
700fn ingress_source_id(record: &SessionRecord) -> Option<&str> {
701    record
702        .state
703        .pointer("/ingressSource/idempotencyId")
704        .and_then(Value::as_str)
705}
706
707fn ingress_metadata(record: &SessionRecord) -> Option<&Value> {
708    record.state.pointer("/ingressSource/metadata")
709}
710
711fn metadata_recording_id(record: &SessionRecord) -> Option<Uuid> {
712    let metadata = ingress_metadata(record)?;
713    if metadata.get("kind").and_then(Value::as_str) != Some("audio-transcript") {
714        return None;
715    }
716    metadata
717        .get("recordingId")
718        .and_then(Value::as_str)
719        .and_then(|value| Uuid::parse_str(value).ok())
720}
721
722fn audio_piece_id(recording_id: Uuid, piece_index: u32) -> String {
723    format!("audio:{recording_id}:{piece_index}")
724}
725
726fn audio_piece_metadata(
727    recording: &RecordingStatus,
728    spec: &PieceSpec,
729    effective_context_tokens: u64,
730    maximum_piece_characters: usize,
731) -> Value {
732    json!({
733        "kind":"audio-transcript",
734        "recordingId":recording.id.to_string(),
735        "sha256":recording.sha256,
736        "originalFilename":recording.original_filename,
737        "extension":file_name_extension(&recording.original_filename),
738        "mimeType":"audio/wav",
739        "sizeBytes":recording.size_bytes,
740        "sourceCreatedAt":recording.recorded_at.to_rfc3339(),
741        "segmentationVersion":SEGMENTATION_VERSION,
742        "effectiveContextTokens":effective_context_tokens,
743        "maximumPieceCharacters":maximum_piece_characters,
744        "pieceIndex":spec.index,
745        "pieceCount":spec.count,
746        "pieceCharacters":spec.text.chars().count(),
747        "pieceFingerprint":spec.fingerprint,
748    })
749}
750
751fn format_ingress_piece(recording: &RecordingStatus, spec: &PieceSpec) -> String {
752    format!(
753        "Vnote final transcript piece\n\nRecording began: {}\nRecording SHA-256: {}\nOriginal filename: {}\nExtension: {}\nMIME type: audio/wav\nSize: {} bytes\nTranscript piece: {} of {}\n\n{}",
754        recording.recorded_at.to_rfc3339(),
755        recording.sha256,
756        recording.original_filename,
757        file_name_extension(&recording.original_filename),
758        recording.size_bytes,
759        spec.index + 1,
760        spec.count,
761        spec.text,
762    )
763}
764
765fn piece_fingerprint(value: &str) -> String {
766    let mut hash = 0xcbf29ce484222325_u64;
767    for byte in value.as_bytes() {
768        hash ^= u64::from(*byte);
769        hash = hash.wrapping_mul(0x100000001b3);
770    }
771    format!("fnv1a64:{hash:016x}")
772}
773
774fn file_name_extension(file_name: &str) -> String {
775    file_name
776        .rsplit_once('.')
777        .and_then(|(stem, extension)| {
778            (!stem.is_empty() && !extension.is_empty()).then_some(extension)
779        })
780        .map(|extension| format!(".{extension}"))
781        .unwrap_or_else(|| "(none)".into())
782}
783
784fn processing_stage(status: &kcode_audio_ingress::TranscriptionStatus) -> &'static str {
785    let plan_complete = status.steps.iter().any(|entry| {
786        entry.step == kcode_audio_ingress::Step::PlanChunks
787            && entry.state == kcode_audio_ingress::StepState::Completed
788    });
789    if !plan_complete {
790        return "chunking";
791    }
792    let mut saw_chunk = false;
793    for entry in &status.steps {
794        if matches!(
795            entry.step,
796            kcode_audio_ingress::Step::TranscribeChunk { .. }
797        ) {
798            saw_chunk = true;
799            if entry.state != kcode_audio_ingress::StepState::Completed {
800                return "transcribing";
801            }
802        }
803    }
804    if saw_chunk {
805        "reconciling"
806    } else {
807        "transcribing"
808    }
809}
810
811fn maximum_piece_characters(effective_context_tokens: u64) -> Result<usize, Error> {
812    let piece_tokens = effective_context_tokens / INGRESS_CONTEXT_DIVISOR;
813    if piece_tokens == 0 {
814        return Err(Error::invalid(
815            "effective ingress context must contain at least four tokens",
816        ));
817    }
818    let characters = piece_tokens
819        .checked_mul(ESTIMATED_CHARACTERS_PER_TOKEN)
820        .ok_or_else(|| Error::invalid("effective ingress context is too large"))?;
821    usize::try_from(characters)
822        .map_err(|_| Error::invalid("effective ingress context exceeds platform limits"))
823}
824
825fn split_transcript(
826    transcript: &str,
827    maximum_piece_characters: usize,
828) -> Result<Vec<String>, Error> {
829    let mut remaining = transcript.trim();
830    if remaining.is_empty() {
831        return Err(Error::internal("completed audio transcript is empty"));
832    }
833    let mut pieces = Vec::new();
834    while remaining.chars().count() > maximum_piece_characters {
835        let cutoff = remaining
836            .char_indices()
837            .nth(maximum_piece_characters)
838            .map(|(index, _)| index)
839            .unwrap_or(remaining.len());
840        let prefix = &remaining[..cutoff];
841        let minimum = prefix
842            .char_indices()
843            .nth(maximum_piece_characters / 2)
844            .map(|(index, _)| index)
845            .unwrap_or(0);
846        let boundary = prefix
847            .rfind("\n\n")
848            .filter(|index| *index >= minimum)
849            .or_else(|| prefix.rfind('\n').filter(|index| *index >= minimum))
850            .unwrap_or(cutoff);
851        let piece = remaining[..boundary].trim();
852        if piece.is_empty() {
853            return Err(Error::internal("could not split audio transcript"));
854        }
855        pieces.push(piece.to_owned());
856        remaining = remaining[boundary..].trim();
857    }
858    if !remaining.is_empty() {
859        pieces.push(remaining.to_owned());
860    }
861    Ok(pieces)
862}
863
864fn estimate_tokens(value: &str) -> u64 {
865    (value.chars().count() as u64).div_ceil(ESTIMATED_CHARACTERS_PER_TOKEN)
866}
867
868fn audio_error(error: kcode_audio_ingress::Error) -> Error {
869    match error.kind() {
870        AudioErrorKind::InvalidInput => Error::new(ErrorKind::InvalidInput, error.to_string()),
871        AudioErrorKind::NotFound => Error::new(ErrorKind::NotFound, error.to_string()),
872        AudioErrorKind::Conflict => Error::new(ErrorKind::Conflict, error.to_string()),
873        AudioErrorKind::Internal => Error::internal(error),
874    }
875}
876
877fn history_error(error: kcode_session_history::Error) -> Error {
878    let kind = match error.kind {
879        kcode_session_history::ErrorKind::InvalidInput => ErrorKind::InvalidInput,
880        kcode_session_history::ErrorKind::NotFound => ErrorKind::NotFound,
881        kcode_session_history::ErrorKind::Conflict => ErrorKind::Conflict,
882        kcode_session_history::ErrorKind::Storage => ErrorKind::Internal,
883    };
884    Error::new(kind, error.message)
885}
886
887#[cfg(test)]
888mod tests {
889    use std::path::{Path, PathBuf};
890
891    use super::*;
892
893    struct TestRoot(PathBuf);
894
895    impl TestRoot {
896        fn new() -> Self {
897            let path = std::env::temp_dir().join(format!(
898                "kcode-audio-session-ingress-test-{}",
899                Uuid::new_v4()
900            ));
901            std::fs::create_dir(&path).unwrap();
902            Self(path)
903        }
904
905        fn path(&self) -> &Path {
906            &self.0
907        }
908    }
909
910    impl Drop for TestRoot {
911        fn drop(&mut self) {
912            let _ = std::fs::remove_dir_all(&self.0);
913        }
914    }
915
916    fn history(root: &Path) -> SessionHistory {
917        SessionHistory::open(kcode_session_history::Config {
918            directory: root.join("active"),
919            completed_list: root.join("completed.txt"),
920        })
921        .unwrap()
922    }
923
924    fn completed_recording(transcript: impl Into<String>) -> RecordingStatus {
925        let now = Utc::now();
926        RecordingStatus {
927            id: Uuid::new_v4(),
928            user_id: "user".into(),
929            sha256: "0".repeat(64),
930            original_filename: "meeting.final.WAV".into(),
931            size_bytes: 42,
932            recorded_at: now,
933            received_at: now,
934            transcription_model: "transcription-model".into(),
935            reconciliation_model: "reconciliation-model".into(),
936            reconciliation_reasoning: "xhigh".into(),
937            state: RecordingState::Complete {
938                transcript: transcript.into(),
939            },
940        }
941    }
942
943    #[test]
944    fn transcript_piece_limit_is_one_quarter_of_effective_context() {
945        let effective_context_tokens = 400;
946        let maximum_characters = maximum_piece_characters(effective_context_tokens).unwrap();
947        let pieces = split_transcript(&"a".repeat(801), maximum_characters).unwrap();
948        assert_eq!(maximum_characters, 400);
949        assert_eq!(pieces.len(), 3);
950        assert!(pieces.iter().all(|piece| {
951            estimate_tokens(piece) <= effective_context_tokens / INGRESS_CONTEXT_DIVISOR
952        }));
953    }
954
955    #[test]
956    fn transcript_splitting_prefers_a_late_paragraph_boundary() {
957        let transcript = format!("{}\n\n{}", "a".repeat(250), "b".repeat(200));
958        let pieces = split_transcript(&transcript, 400).unwrap();
959        assert_eq!(pieces, vec!["a".repeat(250), "b".repeat(200)]);
960    }
961
962    #[test]
963    fn transcript_splitting_counts_unicode_scalars_not_bytes() {
964        let transcript = "😀".repeat(5);
965        let pieces = split_transcript(&transcript, 2).unwrap();
966        assert_eq!(pieces.concat(), transcript);
967        assert!(pieces.iter().all(|piece| piece.chars().count() <= 2));
968    }
969
970    #[test]
971    fn audio_piece_ids_are_stable_across_configuration() {
972        let recording_id = Uuid::new_v4();
973        assert_eq!(
974            audio_piece_id(recording_id, 2),
975            format!("audio:{recording_id}:2")
976        );
977    }
978
979    #[test]
980    fn ingress_exposes_the_complete_file_metadata_contract() {
981        let recording = completed_recording("Transcript");
982        let spec = transcript_piece_specs(&recording, 400).unwrap().remove(0);
983        let metadata = audio_piece_metadata(&recording, &spec, 400, 400);
984        assert_eq!(metadata["originalFilename"], "meeting.final.WAV");
985        assert_eq!(metadata["extension"], ".WAV");
986        assert_eq!(metadata["mimeType"], "audio/wav");
987        assert_eq!(metadata["sizeBytes"], 42);
988        assert_eq!(metadata["segmentationVersion"], SEGMENTATION_VERSION);
989        assert_eq!(metadata["effectiveContextTokens"], 400);
990        assert_eq!(metadata["maximumPieceCharacters"], 400);
991        assert_eq!(
992            metadata["pieceFingerprint"],
993            piece_fingerprint("Transcript")
994        );
995        let text = format_ingress_piece(&recording, &spec);
996        assert!(text.contains("Original filename: meeting.final.WAV"));
997        assert!(text.contains("Extension: .WAV"));
998        assert!(text.contains("MIME type: audio/wav"));
999        assert!(text.contains("Size: 42 bytes"));
1000    }
1001
1002    #[test]
1003    fn reads_report_expected_pieces_before_worker_synchronization() {
1004        let recording = completed_recording("a".repeat(801));
1005        let projection = ingress_projection(&recording, &[], 400, 400).unwrap();
1006        assert_eq!(projection.expected_piece_count, 3);
1007        assert!(projection.pieces.is_empty());
1008        let combined = Recording::from_status(recording, &projection);
1009        assert_eq!(combined.status, "ready_for_ingress");
1010        assert_eq!(combined.transcript_piece_count, 3);
1011        assert_eq!(combined.completed_piece_count, 0);
1012    }
1013
1014    #[tokio::test]
1015    async fn synchronization_is_idempotent_and_uses_quarter_window_pieces() {
1016        let root = TestRoot::new();
1017        let history = history(root.path());
1018        let recording = completed_recording("a".repeat(801));
1019
1020        synchronize_recordings(&history, std::slice::from_ref(&recording), 400, 400)
1021            .await
1022            .unwrap();
1023        synchronize_recordings(&history, std::slice::from_ref(&recording), 400, 400)
1024            .await
1025            .unwrap();
1026
1027        let records = history.list().await.unwrap();
1028        assert_eq!(records.len(), 3);
1029        let ids = records
1030            .iter()
1031            .filter_map(ingress_source_id)
1032            .collect::<HashSet<_>>();
1033        assert_eq!(ids.len(), 3);
1034        assert!(ids.contains(format!("audio:{}:0", recording.id).as_str()));
1035        assert!(ids.contains(format!("audio:{}:1", recording.id).as_str()));
1036        assert!(ids.contains(format!("audio:{}:2", recording.id).as_str()));
1037        assert!(records.iter().all(|record| {
1038            record
1039                .state
1040                .pointer("/ingressSource/metadata/pieceCount")
1041                .and_then(Value::as_u64)
1042                == Some(3)
1043        }));
1044    }
1045
1046    #[tokio::test]
1047    async fn changed_segmentation_conflicts_without_creating_new_work() {
1048        let root = TestRoot::new();
1049        let history = history(root.path());
1050        let recording = completed_recording("a".repeat(801));
1051
1052        synchronize_recordings(&history, std::slice::from_ref(&recording), 400, 400)
1053            .await
1054            .unwrap();
1055        let error = synchronize_recordings(&history, std::slice::from_ref(&recording), 800, 800)
1056            .await
1057            .unwrap_err();
1058
1059        assert_eq!(error.kind(), ErrorKind::Conflict);
1060        assert_eq!(history.list().await.unwrap().len(), 3);
1061    }
1062
1063    #[tokio::test]
1064    async fn retry_validation_rejects_unrelated_history_records() {
1065        let root = TestRoot::new();
1066        let history = history(root.path());
1067        let unrelated = history
1068            .enqueue_ingress(NewIngressSession {
1069                idempotency_id: "unrelated".into(),
1070                started_at: Utc::now().to_rfc3339(),
1071                source_session_type: "other".into(),
1072                kind: SessionKind::AudioIngress,
1073                effective_context_tokens: 400,
1074                text: "Unrelated".into(),
1075                metadata: json!({"kind":"other"}),
1076            })
1077            .await
1078            .unwrap()
1079            .value;
1080        let recording = completed_recording("Transcript");
1081
1082        let error = validate_retry_target(&unrelated, &[recording], 400, 400).unwrap_err();
1083        assert_eq!(error.kind(), ErrorKind::NotFound);
1084    }
1085
1086    #[tokio::test]
1087    async fn retry_validation_accepts_only_current_correlated_pieces() {
1088        let root = TestRoot::new();
1089        let history = history(root.path());
1090        let recording = completed_recording("Transcript");
1091        synchronize_recordings(&history, std::slice::from_ref(&recording), 400, 400)
1092            .await
1093            .unwrap();
1094        let record = history.list().await.unwrap().remove(0);
1095
1096        validate_retry_target(&record, &[recording], 400, 400).unwrap();
1097    }
1098}