Skip to main content

kcode_audio_session_ingress/
lib.rs

1#![forbid(unsafe_code)]
2
3use chrono::{DateTime, Utc};
4use kcode_audio_history_handoff::{Error as HandoffError, Handoff};
5pub use kcode_audio_ingress::SpeakerReviewAudio;
6use kcode_audio_ingress::{
7    AudioIngress, AudioInput, ChunkConfirmation, CorrectionPacket, ErrorKind as AudioErrorKind,
8    RecordingState, RecordingStatus, confirmation_matches,
9};
10pub use kcode_audio_session_view::{IngressPiece, Recording, SpeakerReview};
11use kcode_session_history::{SessionHistory, SessionRecord};
12use serde_json::Value;
13use uuid::Uuid;
14
15mod legacy_review;
16
17/// Application policy needed to coordinate audio with Session History.
18#[derive(Clone, Debug)]
19pub struct Config {
20    /// Stable application user identifier attributed to accepted recordings.
21    pub user_id: String,
22    /// Effective context window available to an audio-ingress session.
23    pub effective_context_tokens: u64,
24}
25
26/// Admitted recording bytes and source metadata.
27#[derive(Clone, Debug)]
28pub struct RecordingInput {
29    /// Complete WAV bytes already accepted by the transport.
30    pub bytes: Vec<u8>,
31    /// Instant at which the original recording began.
32    pub recorded_at: DateTime<Utc>,
33    /// Original leaf filename, when known.
34    pub original_filename: Option<String>,
35}
36
37/// Result of durably submitting one recording.
38#[derive(Clone, Debug)]
39pub struct RecordingSubmission {
40    /// Current combined state after submission.
41    pub recording: Recording,
42    /// Whether AudioIngress already knew the same bytes.
43    pub deduplicated: bool,
44}
45
46/// Detailed state for one recording.
47#[derive(Clone, Debug)]
48pub struct RecordingHistory {
49    pub recording: Recording,
50    pub final_transcript: Option<String>,
51    pub correction_packet: Option<CorrectionPacket>,
52    pub pieces: Vec<IngressPiece>,
53}
54
55/// Request to retry one transcript piece's memory ingress.
56#[derive(Clone, Debug)]
57pub struct RetryIngress {
58    pub piece_id: String,
59    pub expected_version: i64,
60    /// Optional state retained for 0.2 wire compatibility. When supplied, it
61    /// must exactly equal the current retained Session History state.
62    pub state: Option<Value>,
63}
64
65/// Stable coordinator error category for transport mapping.
66#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub enum ErrorKind {
68    InvalidInput,
69    NotFound,
70    Conflict,
71    Internal,
72}
73
74/// Error returned by the audio/session-ingress coordinator.
75#[derive(Debug)]
76pub struct Error {
77    kind: ErrorKind,
78    message: String,
79}
80
81impl Error {
82    fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
83        Self {
84            kind,
85            message: message.into(),
86        }
87    }
88
89    fn invalid(message: impl Into<String>) -> Self {
90        Self::new(ErrorKind::InvalidInput, message)
91    }
92
93    fn conflict(message: impl Into<String>) -> Self {
94        Self::new(ErrorKind::Conflict, message)
95    }
96
97    fn not_found() -> Self {
98        Self::new(
99            ErrorKind::NotFound,
100            "Audio recording or transcript piece not found.",
101        )
102    }
103
104    fn internal(error: impl std::fmt::Display) -> Self {
105        tracing::warn!(%error, "Audio session ingress operation failed");
106        Self::new(
107            ErrorKind::Internal,
108            "An unexpected audio session ingress error occurred.",
109        )
110    }
111
112    pub fn kind(&self) -> ErrorKind {
113        self.kind
114    }
115
116    pub fn message(&self) -> &str {
117        &self.message
118    }
119}
120
121impl std::fmt::Display for Error {
122    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        formatter.write_str(&self.message)
124    }
125}
126
127impl std::error::Error for Error {}
128
129/// Cloneable typed coordinator over AudioIngress and Session History.
130#[derive(Clone)]
131pub struct Coordinator {
132    audio: AudioIngress,
133    handoff: Handoff,
134    user_id: String,
135}
136
137impl Coordinator {
138    pub fn new(
139        audio: AudioIngress,
140        history: SessionHistory,
141        config: Config,
142    ) -> Result<Self, Error> {
143        let handoff = Handoff::new(
144            history,
145            config.user_id.clone(),
146            config.effective_context_tokens,
147        )
148        .map_err(handoff_error)?;
149        Ok(Self {
150            audio,
151            handoff,
152            user_id: config.user_id,
153        })
154    }
155
156    pub fn health(&self) -> Result<(), Error> {
157        self.audio.status().map_err(audio_error)?;
158        Ok(())
159    }
160
161    pub async fn submit(&self, input: RecordingInput) -> Result<RecordingSubmission, Error> {
162        let submission = self
163            .audio
164            .submit(AudioInput {
165                user_id: self.user_id.clone(),
166                bytes: input.bytes,
167                recorded_at: input.recorded_at,
168                original_filename: input.original_filename,
169            })
170            .await
171            .map_err(audio_error)?;
172        let (recordings, projections) = self.prepared_recordings().await?;
173        let (recording_status, projection) = recordings
174            .into_iter()
175            .zip(projections)
176            .find(|(recording, _)| recording.id == submission.recording_id)
177            .ok_or_else(Error::not_found)?;
178        validate_submission_owner(&recording_status, &self.user_id)?;
179        let view = kcode_audio_session_view::render(recording_status, projection);
180        Ok(RecordingSubmission {
181            recording: view.recording,
182            deduplicated: submission.deduplicated,
183        })
184    }
185
186    pub async fn recordings(&self) -> Result<Vec<Recording>, Error> {
187        let (recordings, projections) = self.prepared_recordings().await?;
188        Ok(recordings
189            .into_iter()
190            .zip(projections)
191            .map(|(recording, projection)| {
192                kcode_audio_session_view::render(recording, projection).recording
193            })
194            .collect())
195    }
196
197    pub async fn recording_by_sha256(&self, sha256: &str) -> Result<Recording, Error> {
198        if sha256.len() != 64 || !sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
199            return Err(Error::invalid(
200                "audio SHA-256 must contain exactly 64 hexadecimal characters",
201            ));
202        }
203        let normalized = sha256.to_ascii_lowercase();
204        self.recordings()
205            .await?
206            .into_iter()
207            .find(|recording| recording.sha256 == normalized)
208            .ok_or_else(Error::not_found)
209    }
210
211    pub async fn recording_history(&self, recording_id: Uuid) -> Result<RecordingHistory, Error> {
212        self.recording_history_inner(recording_id, false).await
213    }
214
215    /// Returns recording history after re-scoring every unsigned speaker chunk.
216    ///
217    /// This explicit read is intended for a human opening one audio review. It
218    /// runs synchronous classifier work on Tokio's blocking pool; routine
219    /// background observation should use [`Coordinator::recording_history`].
220    pub async fn recording_history_with_current_speaker_candidates(
221        &self,
222        recording_id: Uuid,
223    ) -> Result<RecordingHistory, Error> {
224        self.recording_history_inner(recording_id, true).await
225    }
226
227    async fn recording_history_inner(
228        &self,
229        recording_id: Uuid,
230        refresh_speaker_candidates: bool,
231    ) -> Result<RecordingHistory, Error> {
232        let (recordings, projections) = self.prepared_recordings().await?;
233        let (recording_status, projection) = recordings
234            .into_iter()
235            .zip(projections)
236            .find(|(recording, _)| {
237                recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
238            })
239            .ok_or_else(Error::not_found)?;
240        let final_transcript = match &recording_status.state {
241            RecordingState::Complete { transcript } => Some(transcript.clone()),
242            _ => None,
243        };
244        let correction_packet = if refresh_speaker_candidates
245            && recording_status
246                .correction_packet
247                .as_ref()
248                .is_some_and(|packet| packet.chunks.iter().any(|chunk| !chunk.signed_off))
249        {
250            let audio = self.audio.clone();
251            Some(run_blocking_audio(move || audio.speaker_review_packet(recording_id)).await?)
252        } else {
253            recording_status.correction_packet.clone()
254        };
255        let view = kcode_audio_session_view::render(recording_status, projection);
256        Ok(RecordingHistory {
257            recording: view.recording,
258            final_transcript,
259            correction_packet,
260            pieces: view.pieces,
261        })
262    }
263
264    /// Returns one authorized correction-packet chunk as an exact WAV interval.
265    pub async fn speaker_review_audio(
266        &self,
267        recording_id: Uuid,
268        chunk_index: usize,
269    ) -> Result<SpeakerReviewAudio, Error> {
270        let owned = self
271            .audio
272            .status()
273            .map_err(audio_error)?
274            .recordings
275            .into_iter()
276            .any(|recording| {
277                recording.id == recording_id && recording_belongs_to(&recording, &self.user_id)
278            });
279        if !owned {
280            return Err(Error::not_found());
281        }
282        let audio = self.audio.clone();
283        tokio::task::spawn_blocking(move || audio.speaker_review_audio(recording_id, chunk_index))
284            .await
285            .map_err(Error::internal)?
286            .map_err(audio_error)
287    }
288
289    /// Returns the known-speaker dropdown choices for this coordinator.
290    pub fn known_speakers(&self) -> Result<Vec<String>, Error> {
291        self.audio.known_speakers().map_err(audio_error)
292    }
293
294    pub fn retry_recording(&self, recording_id: Uuid) -> Result<(), Error> {
295        let owned = self
296            .audio
297            .status()
298            .map_err(audio_error)?
299            .recordings
300            .into_iter()
301            .any(|recording| {
302                recording.id == recording_id && recording_belongs_to(&recording, &self.user_id)
303            });
304        if !owned {
305            return Err(Error::not_found());
306        }
307        self.audio.retry(recording_id).map_err(audio_error)
308    }
309
310    pub async fn confirm_speakers(
311        &self,
312        confirmation: ChunkConfirmation,
313    ) -> Result<CorrectionPacket, Error> {
314        let recording_id = confirmation.recording_id;
315        let (recordings, _) = self.prepared_recordings().await?;
316        let recording = recordings
317            .into_iter()
318            .find(|recording| {
319                recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
320            })
321            .ok_or_else(Error::not_found)?;
322
323        if let Some(packet) = recording
324            .correction_packet
325            .as_ref()
326            .filter(|packet| confirmation_matches(packet, &confirmation))
327        {
328            self.handoff
329                .synchronize(std::slice::from_ref(&recording))
330                .await
331                .map_err(handoff_error)?;
332            return Ok(packet.clone());
333        }
334        if self
335            .handoff
336            .has_ingress(recording_id)
337            .await
338            .map_err(handoff_error)?
339        {
340            return Err(Error::conflict(
341                "speaker labels are already bound to accepted transcript ingress",
342            ));
343        }
344
345        let audio = self.audio.clone();
346        let packet = run_blocking_audio(move || audio.confirm_speakers(confirmation)).await?;
347        let recording = self
348            .audio
349            .status()
350            .map_err(audio_error)?
351            .recordings
352            .into_iter()
353            .find(|recording| {
354                recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
355            })
356            .ok_or_else(Error::not_found)?;
357        self.handoff
358            .synchronize(std::slice::from_ref(&recording))
359            .await
360            .map_err(handoff_error)?;
361        Ok(packet)
362    }
363
364    pub async fn retry_ingress(&self, input: RetryIngress) -> Result<SessionRecord, Error> {
365        let recordings = self.audio.status().map_err(audio_error)?.recordings;
366        self.handoff
367            .retry(
368                &recordings,
369                &input.piece_id,
370                input.expected_version,
371                input.state,
372            )
373            .await
374            .map_err(handoff_error)
375    }
376
377    pub async fn synchronize_completed_transcripts(&self) -> Result<(), Error> {
378        let mut recordings = self.owned_recordings()?;
379        if legacy_review::has_candidates(&recordings) {
380            let projections = self.project_recordings(&recordings).await?;
381            if self
382                .resolve_legacy_reviews(&recordings, &projections)
383                .await?
384            {
385                recordings = self.owned_recordings()?;
386            }
387        }
388        self.handoff
389            .synchronize(&recordings)
390            .await
391            .map_err(handoff_error)
392    }
393}
394
395fn recording_belongs_to(recording: &RecordingStatus, user_id: &str) -> bool {
396    recording.user_id == user_id
397}
398
399fn validate_submission_owner(recording: &RecordingStatus, user_id: &str) -> Result<(), Error> {
400    if recording_belongs_to(recording, user_id) {
401        Ok(())
402    } else {
403        Err(Error::conflict(
404            "identical audio is already attributed to another user",
405        ))
406    }
407}
408
409fn audio_error(error: kcode_audio_ingress::Error) -> Error {
410    match error.kind() {
411        AudioErrorKind::InvalidInput => Error::new(ErrorKind::InvalidInput, error.to_string()),
412        AudioErrorKind::NotFound => Error::new(ErrorKind::NotFound, error.to_string()),
413        AudioErrorKind::Conflict => Error::new(ErrorKind::Conflict, error.to_string()),
414        AudioErrorKind::Internal => Error::internal(error),
415    }
416}
417
418async fn run_blocking_audio<T, Operation>(operation: Operation) -> Result<T, Error>
419where
420    T: Send + 'static,
421    Operation: FnOnce() -> Result<T, kcode_audio_ingress::Error> + Send + 'static,
422{
423    tokio::task::spawn_blocking(operation)
424        .await
425        .map_err(Error::internal)?
426        .map_err(audio_error)
427}
428
429fn handoff_error(error: HandoffError) -> Error {
430    match error {
431        HandoffError::InvalidInput(message) => Error::new(ErrorKind::InvalidInput, message),
432        HandoffError::NotFound(message) => Error::new(ErrorKind::NotFound, message),
433        HandoffError::Conflict(message) => Error::new(ErrorKind::Conflict, message),
434        HandoffError::Internal(message) => Error::new(ErrorKind::Internal, message),
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    fn completed_recording_for_user(
443        user_id: impl Into<String>,
444        transcript: impl Into<String>,
445    ) -> RecordingStatus {
446        let now = Utc::now();
447        RecordingStatus {
448            id: Uuid::new_v4(),
449            user_id: user_id.into(),
450            sha256: "0".repeat(64),
451            original_filename: "meeting.final.WAV".into(),
452            size_bytes: 42,
453            recorded_at: now,
454            received_at: now,
455            transcription_model: "transcription-model".into(),
456            reconciliation_model: "reconciliation-model".into(),
457            reconciliation_reasoning: "xhigh".into(),
458            state: RecordingState::Complete {
459                transcript: transcript.into(),
460            },
461            correction_packet: None,
462        }
463    }
464
465    #[test]
466    fn recording_filter_is_exactly_scoped_to_the_configured_user() {
467        let own = completed_recording_for_user("own-user", "Own transcript");
468        let foreign = completed_recording_for_user("foreign-user", "Foreign transcript");
469        let visible = [own.clone(), foreign]
470            .into_iter()
471            .filter(|recording| recording_belongs_to(recording, "own-user"))
472            .collect::<Vec<_>>();
473        assert_eq!(visible.len(), 1);
474        assert_eq!(visible[0].id, own.id);
475    }
476
477    #[test]
478    fn cross_user_sha_deduplication_fails_closed() {
479        let mut own = completed_recording_for_user("own-user", "Own transcript");
480        own.sha256 = "a".repeat(64);
481        let mut foreign = completed_recording_for_user("foreign-user", "Foreign transcript");
482        foreign.sha256 = own.sha256.clone();
483
484        assert!(validate_submission_owner(&own, "own-user").is_ok());
485        let error = validate_submission_owner(&foreign, "own-user").unwrap_err();
486        assert_eq!(error.kind(), ErrorKind::Conflict);
487        assert_eq!(
488            error.message(),
489            "identical audio is already attributed to another user"
490        );
491    }
492
493    #[test]
494    fn handoff_errors_preserve_category_and_message() {
495        let cases = [
496            (
497                HandoffError::InvalidInput("invalid".into()),
498                ErrorKind::InvalidInput,
499                "invalid",
500            ),
501            (
502                HandoffError::NotFound("missing".into()),
503                ErrorKind::NotFound,
504                "missing",
505            ),
506            (
507                HandoffError::Conflict("conflict".into()),
508                ErrorKind::Conflict,
509                "conflict",
510            ),
511            (
512                HandoffError::Internal("raw storage message".into()),
513                ErrorKind::Internal,
514                "raw storage message",
515            ),
516        ];
517
518        for (source, kind, message) in cases {
519            let error = handoff_error(source);
520            assert_eq!(error.kind(), kind);
521            assert_eq!(error.message(), message);
522        }
523    }
524
525    #[test]
526    fn blocking_audio_work_does_not_occupy_the_async_runtime_thread() {
527        use std::{
528            sync::{
529                Arc,
530                atomic::{AtomicBool, Ordering},
531            },
532            time::{Duration, Instant},
533        };
534
535        let runtime = tokio::runtime::Builder::new_current_thread()
536            .enable_time()
537            .build()
538            .unwrap();
539        runtime.block_on(async {
540            let started = Arc::new(AtomicBool::new(false));
541            let worker_started = Arc::clone(&started);
542            let work = tokio::spawn(run_blocking_audio(move || {
543                worker_started.store(true, Ordering::Release);
544                std::thread::sleep(Duration::from_millis(100));
545                Ok::<_, kcode_audio_ingress::Error>(())
546            }));
547            while !started.load(Ordering::Acquire) {
548                tokio::task::yield_now().await;
549            }
550            let before = Instant::now();
551            tokio::time::sleep(Duration::from_millis(10)).await;
552            assert!(before.elapsed() < Duration::from_millis(75));
553            work.await.unwrap().unwrap();
554        });
555    }
556}