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,
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#[derive(Clone, Debug)]
19pub struct Config {
20 pub user_id: String,
22 pub effective_context_tokens: u64,
24}
25
26#[derive(Clone, Debug)]
28pub struct RecordingInput {
29 pub bytes: Vec<u8>,
31 pub recorded_at: DateTime<Utc>,
33 pub original_filename: Option<String>,
35}
36
37#[derive(Clone, Debug)]
39pub struct RecordingSubmission {
40 pub recording: Recording,
42 pub deduplicated: bool,
44}
45
46#[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#[derive(Clone, Debug)]
57pub struct RetryIngress {
58 pub piece_id: String,
59 pub expected_version: i64,
60 pub state: Option<Value>,
63}
64
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
67pub enum ErrorKind {
68 InvalidInput,
69 NotFound,
70 Conflict,
71 Internal,
72}
73
74#[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#[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 let (recordings, projections) = self.prepared_recordings().await?;
213 let (recording_status, projection) = recordings
214 .into_iter()
215 .zip(projections)
216 .find(|(recording, _)| {
217 recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
218 })
219 .ok_or_else(Error::not_found)?;
220 let final_transcript = match &recording_status.state {
221 RecordingState::Complete { transcript } => Some(transcript.clone()),
222 _ => None,
223 };
224 let correction_packet = recording_status.correction_packet.clone();
225 let view = kcode_audio_session_view::render(recording_status, projection);
226 Ok(RecordingHistory {
227 recording: view.recording,
228 final_transcript,
229 correction_packet,
230 pieces: view.pieces,
231 })
232 }
233
234 pub async fn speaker_review_audio(
236 &self,
237 recording_id: Uuid,
238 chunk_index: usize,
239 ) -> Result<SpeakerReviewAudio, Error> {
240 let owned = self
241 .audio
242 .status()
243 .map_err(audio_error)?
244 .recordings
245 .into_iter()
246 .any(|recording| {
247 recording.id == recording_id && recording_belongs_to(&recording, &self.user_id)
248 });
249 if !owned {
250 return Err(Error::not_found());
251 }
252 let audio = self.audio.clone();
253 tokio::task::spawn_blocking(move || audio.speaker_review_audio(recording_id, chunk_index))
254 .await
255 .map_err(Error::internal)?
256 .map_err(audio_error)
257 }
258
259 pub fn known_speakers(&self) -> Result<Vec<String>, Error> {
261 self.audio.known_speakers().map_err(audio_error)
262 }
263
264 pub fn retry_recording(&self, recording_id: Uuid) -> Result<(), Error> {
265 let owned = self
266 .audio
267 .status()
268 .map_err(audio_error)?
269 .recordings
270 .into_iter()
271 .any(|recording| {
272 recording.id == recording_id && recording_belongs_to(&recording, &self.user_id)
273 });
274 if !owned {
275 return Err(Error::not_found());
276 }
277 self.audio.retry(recording_id).map_err(audio_error)
278 }
279
280 pub async fn confirm_speakers(
281 &self,
282 confirmation: ChunkConfirmation,
283 ) -> Result<CorrectionPacket, Error> {
284 let recording_id = confirmation.recording_id;
285 let (recordings, _) = self.prepared_recordings().await?;
286 let recording = recordings
287 .into_iter()
288 .find(|recording| {
289 recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
290 })
291 .ok_or_else(Error::not_found)?;
292
293 if let Some(packet) = recording
294 .correction_packet
295 .as_ref()
296 .filter(|packet| legacy_review::confirmation_matches(packet, &confirmation))
297 {
298 self.handoff
299 .synchronize(std::slice::from_ref(&recording))
300 .await
301 .map_err(handoff_error)?;
302 return Ok(packet.clone());
303 }
304 if self
305 .handoff
306 .has_ingress(recording_id)
307 .await
308 .map_err(handoff_error)?
309 {
310 return Err(Error::conflict(
311 "speaker labels are already bound to accepted transcript ingress",
312 ));
313 }
314
315 let packet = self
316 .audio
317 .confirm_speakers(confirmation)
318 .map_err(audio_error)?;
319 let recording = self
320 .audio
321 .status()
322 .map_err(audio_error)?
323 .recordings
324 .into_iter()
325 .find(|recording| {
326 recording.id == recording_id && recording_belongs_to(recording, &self.user_id)
327 })
328 .ok_or_else(Error::not_found)?;
329 self.handoff
330 .synchronize(std::slice::from_ref(&recording))
331 .await
332 .map_err(handoff_error)?;
333 Ok(packet)
334 }
335
336 pub async fn retry_ingress(&self, input: RetryIngress) -> Result<SessionRecord, Error> {
337 let recordings = self.audio.status().map_err(audio_error)?.recordings;
338 self.handoff
339 .retry(
340 &recordings,
341 &input.piece_id,
342 input.expected_version,
343 input.state,
344 )
345 .await
346 .map_err(handoff_error)
347 }
348
349 pub async fn synchronize_completed_transcripts(&self) -> Result<(), Error> {
350 let mut recordings = self.owned_recordings()?;
351 if legacy_review::has_candidates(&recordings) {
352 let projections = self.project_recordings(&recordings).await?;
353 if self
354 .resolve_legacy_reviews(&recordings, &projections)
355 .await?
356 {
357 recordings = self.owned_recordings()?;
358 }
359 }
360 self.handoff
361 .synchronize(&recordings)
362 .await
363 .map_err(handoff_error)
364 }
365}
366
367fn recording_belongs_to(recording: &RecordingStatus, user_id: &str) -> bool {
368 recording.user_id == user_id
369}
370
371fn validate_submission_owner(recording: &RecordingStatus, user_id: &str) -> Result<(), Error> {
372 if recording_belongs_to(recording, user_id) {
373 Ok(())
374 } else {
375 Err(Error::conflict(
376 "identical audio is already attributed to another user",
377 ))
378 }
379}
380
381fn audio_error(error: kcode_audio_ingress::Error) -> Error {
382 match error.kind() {
383 AudioErrorKind::InvalidInput => Error::new(ErrorKind::InvalidInput, error.to_string()),
384 AudioErrorKind::NotFound => Error::new(ErrorKind::NotFound, error.to_string()),
385 AudioErrorKind::Conflict => Error::new(ErrorKind::Conflict, error.to_string()),
386 AudioErrorKind::Internal => Error::internal(error),
387 }
388}
389
390fn handoff_error(error: HandoffError) -> Error {
391 match error {
392 HandoffError::InvalidInput(message) => Error::new(ErrorKind::InvalidInput, message),
393 HandoffError::NotFound(message) => Error::new(ErrorKind::NotFound, message),
394 HandoffError::Conflict(message) => Error::new(ErrorKind::Conflict, message),
395 HandoffError::Internal(message) => Error::new(ErrorKind::Internal, message),
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use kcode_audio_ingress::{
403 ChunkConfirmation, ConfirmationState, ObservationConfirmation, SpeakerResolution,
404 };
405
406 fn completed_recording_for_user(
407 user_id: impl Into<String>,
408 transcript: impl Into<String>,
409 ) -> RecordingStatus {
410 let now = Utc::now();
411 RecordingStatus {
412 id: Uuid::new_v4(),
413 user_id: user_id.into(),
414 sha256: "0".repeat(64),
415 original_filename: "meeting.final.WAV".into(),
416 size_bytes: 42,
417 recorded_at: now,
418 received_at: now,
419 transcription_model: "transcription-model".into(),
420 reconciliation_model: "reconciliation-model".into(),
421 reconciliation_reasoning: "xhigh".into(),
422 state: RecordingState::Complete {
423 transcript: transcript.into(),
424 },
425 correction_packet: None,
426 }
427 }
428
429 fn with_speaker_packet(
430 mut recording: RecordingStatus,
431 confirmed_name: Option<&str>,
432 ) -> RecordingStatus {
433 recording.correction_packet = Some(CorrectionPacket {
434 recording_id: recording.id,
435 user_id: recording.user_id.clone(),
436 sha256: recording.sha256.clone(),
437 original_filename: recording.original_filename.clone(),
438 size_bytes: recording.size_bytes,
439 recorded_at: recording.recorded_at,
440 chunk_count: 1,
441 chunks: vec![kcode_audio_ingress::CorrectionChunk {
442 chunk_index: 0,
443 chunk_count: 1,
444 audio_start_ms: 0,
445 audio_end_ms: 1_000,
446 raw_gemini_response: "raw".into(),
447 parsed: kcode_audio_ingress::ParsedChunk {
448 clip_valid: true,
449 clip_validity_reason: None,
450 speakers: Vec::new(),
451 },
452 observations: vec![kcode_audio_ingress::CorrectionObservation {
453 local_label: "Speaker A".into(),
454 speaker_ordinal: 0,
455 observation_key: kcode_audio_ingress::ObservationKey {
456 object_id: format!(
457 "kcode-audio-ingress/recording/{}/chunk/0",
458 recording.id
459 ),
460 piece_index: 0,
461 },
462 candidate: None,
463 resolution: confirmed_name.map(|name| SpeakerResolution::Known {
464 full_name: name.into(),
465 }),
466 }],
467 signed_off: true,
468 }],
469 confirmation_state: ConfirmationState::Confirmed,
470 });
471 recording
472 }
473
474 #[test]
475 fn recording_filter_is_exactly_scoped_to_the_configured_user() {
476 let own = completed_recording_for_user("own-user", "Own transcript");
477 let foreign = completed_recording_for_user("foreign-user", "Foreign transcript");
478 let visible = [own.clone(), foreign]
479 .into_iter()
480 .filter(|recording| recording_belongs_to(recording, "own-user"))
481 .collect::<Vec<_>>();
482 assert_eq!(visible.len(), 1);
483 assert_eq!(visible[0].id, own.id);
484 }
485
486 #[test]
487 fn cross_user_sha_deduplication_fails_closed() {
488 let mut own = completed_recording_for_user("own-user", "Own transcript");
489 own.sha256 = "a".repeat(64);
490 let mut foreign = completed_recording_for_user("foreign-user", "Foreign transcript");
491 foreign.sha256 = own.sha256.clone();
492
493 assert!(validate_submission_owner(&own, "own-user").is_ok());
494 let error = validate_submission_owner(&foreign, "own-user").unwrap_err();
495 assert_eq!(error.kind(), ErrorKind::Conflict);
496 assert_eq!(
497 error.message(),
498 "identical audio is already attributed to another user"
499 );
500 }
501
502 #[test]
503 fn confirmed_label_retries_must_match_the_complete_mapping() {
504 let recording = with_speaker_packet(
505 completed_recording_for_user("user", "Transcript"),
506 Some("Human Choice"),
507 );
508 let packet = recording.correction_packet.as_ref().unwrap();
509 let key = packet.chunks[0].observations[0].observation_key.clone();
510 let matching = ChunkConfirmation {
511 recording_id: recording.id,
512 chunk_index: 0,
513 observations: vec![ObservationConfirmation {
514 observation_key: key.clone(),
515 resolution: SpeakerResolution::Known {
516 full_name: "Human Choice".into(),
517 },
518 }],
519 };
520 let conflicting = ChunkConfirmation {
521 recording_id: recording.id,
522 chunk_index: 0,
523 observations: vec![ObservationConfirmation {
524 observation_key: key,
525 resolution: SpeakerResolution::Known {
526 full_name: "Different Choice".into(),
527 },
528 }],
529 };
530 let incomplete = ChunkConfirmation {
531 recording_id: recording.id,
532 chunk_index: 0,
533 observations: Vec::new(),
534 };
535
536 assert!(legacy_review::confirmation_matches(packet, &matching));
537 assert!(!legacy_review::confirmation_matches(packet, &conflicting));
538 assert!(!legacy_review::confirmation_matches(packet, &incomplete));
539 }
540
541 #[test]
542 fn handoff_errors_preserve_category_and_message() {
543 let cases = [
544 (
545 HandoffError::InvalidInput("invalid".into()),
546 ErrorKind::InvalidInput,
547 "invalid",
548 ),
549 (
550 HandoffError::NotFound("missing".into()),
551 ErrorKind::NotFound,
552 "missing",
553 ),
554 (
555 HandoffError::Conflict("conflict".into()),
556 ErrorKind::Conflict,
557 "conflict",
558 ),
559 (
560 HandoffError::Internal("raw storage message".into()),
561 ErrorKind::Internal,
562 "raw storage message",
563 ),
564 ];
565
566 for (source, kind, message) in cases {
567 let error = handoff_error(source);
568 assert_eq!(error.kind(), kind);
569 assert_eq!(error.message(), message);
570 }
571 }
572}