1#![deny(missing_docs)]
8#![forbid(unsafe_code)]
9
10use std::{
11 collections::{HashMap, HashSet},
12 fs,
13 path::{Path, PathBuf},
14 sync::{Arc, Mutex, Weak},
15 time::Duration,
16};
17
18use anyhow::{Context, ensure};
19use chrono::{DateTime, Duration as ChronoDuration, Utc};
20use kcode_speaker_system::SpeechClassifier;
21use rusqlite::{Connection, OptionalExtension, params};
22use serde::Serialize;
23use serde_json::Value;
24use sha2::{Digest, Sha256};
25use tokio::io::AsyncWriteExt;
26use uuid::Uuid;
27
28pub use identity::{
29 CLASSIFIER_MODEL, CLASSIFIER_PROMPT_VERSION, CLASSIFIER_PROVIDER, CLASSIFIER_SCHEMA_VERSION,
30 CandidateMapping, ChunkConfirmation, ConfirmationState, CorrectionChunk, CorrectionObservation,
31 CorrectionPacket, FeatureRow, ObservationConfirmation, ObservationKey, ParsedChunk,
32 ParsedSpeaker, SpeakerResolution,
33};
34use identity::{ClassificationContext, apply_confirmations, restore_packet_training};
35pub use transcribe::{
36 AudioChunkCall, AudioChunkRequest, AudioTranscriber, IntelligenceError, IntelligenceFuture,
37 JobState, RECONCILIATION_MODEL, RECONCILIATION_REASONING, Step, StepError, StepState,
38 StepStatus, TRANSCRIPTION_MODEL, TextGenerationCall, TextGenerationRequest, TranscriptionJob,
39 TranscriptionStatus,
40};
41use transcribe::{ChunkPlan, PIECE_CACHE_REVISION, PieceCache, PieceSink};
42
43mod identity;
44mod transcribe;
45
46const INITIAL_MIGRATION: &str = include_str!("../migrations/001_initial.sql");
47const RELEASE_DEFERRED_INGRESS_MIGRATION: &str =
48 include_str!("../migrations/002_release_deferred_ingress.sql");
49const TRANSCRIPTION_STATUS_MIGRATION: &str =
50 include_str!("../migrations/003_transcription_status.sql");
51const RETRY_ROUNDED_WAV_INTERVALS_MIGRATION: &str =
52 include_str!("../migrations/004_retry_rounded_wav_intervals.sql");
53const UNIFIED_INGRESS_QUEUE_MIGRATION: &str =
54 include_str!("../migrations/005_unified_ingress_queue.sql");
55const STANDALONE_LIBRARY_MIGRATION: &str = include_str!("../migrations/006_standalone_library.sql");
56const DURABLE_TRANSCRIPT_PIECES_MIGRATION: &str =
57 include_str!("../migrations/007_durable_transcript_pieces.sql");
58const UNIQUE_TRANSCRIPTION_ATTEMPTS_MIGRATION: &str =
59 include_str!("../migrations/008_unique_transcription_attempts.sql");
60const USAGE_USER_MIGRATION: &str = include_str!("../migrations/009_usage_user.sql");
61const SPEAKER_CORRECTION_PACKETS_MIGRATION: &str =
62 include_str!("../migrations/010_speaker_correction_packets.sql");
63
64const DATABASE_FILENAME: &str = "state.sqlite3";
65const ORIGINALS_DIRECTORY: &str = "originals";
66const FAILURE_LIMIT: i64 = 5;
67const RETRY_DELAY_SECONDS: i64 = 15;
68const LATEST_SCHEMA_VERSION: i64 = 10;
69
70const FRESH_SCHEMA: &str = r#"
71CREATE TABLE audio_recordings (
72 id TEXT PRIMARY KEY NOT NULL,
73 user_id TEXT NOT NULL CHECK(length(user_id) > 0),
74 sha256 TEXT NOT NULL UNIQUE CHECK(length(sha256) = 64),
75 original_filename TEXT NOT NULL,
76 content_type TEXT NOT NULL,
77 size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0),
78 source_created_at TEXT NOT NULL,
79 received_at TEXT NOT NULL,
80 updated_at TEXT NOT NULL,
81 original_relative_path TEXT NOT NULL,
82 status TEXT NOT NULL CHECK(status IN (
83 'uploaded', 'chunking', 'transcribing', 'reconciling',
84 'ready_for_ingress', 'ingressing', 'ingress_failed', 'complete', 'failed'
85 )),
86 gemini_model TEXT NOT NULL,
87 reconciliation_model TEXT NOT NULL,
88 reconciliation_reasoning TEXT NOT NULL,
89 final_transcript TEXT,
90 attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count >= 0),
91 next_attempt_at TEXT,
92 last_error TEXT,
93 transcription_status_json TEXT,
94 failure_retryable INTEGER NOT NULL DEFAULT 1
95 CHECK(failure_retryable IN (0, 1)),
96 correction_packet_json TEXT
97);
98
99CREATE INDEX audio_recordings_work_queue
100ON audio_recordings(status, next_attempt_at, received_at);
101
102CREATE TABLE audio_transcript_pieces (
103 recording_id TEXT NOT NULL REFERENCES audio_recordings(id) ON DELETE CASCADE,
104 attempt_id TEXT NOT NULL CHECK(length(attempt_id) > 0),
105 cache_revision TEXT NOT NULL CHECK(length(cache_revision) > 0),
106 piece_index INTEGER NOT NULL CHECK(piece_index >= 0),
107 piece_count INTEGER NOT NULL CHECK(piece_count > 0 AND piece_index < piece_count),
108 audio_start_ms INTEGER NOT NULL CHECK(audio_start_ms >= 0),
109 audio_end_ms INTEGER NOT NULL CHECK(audio_end_ms > audio_start_ms),
110 transcript_json TEXT NOT NULL,
111 raw_gemini_response TEXT NOT NULL,
112 parsed_json TEXT NOT NULL,
113 created_at TEXT NOT NULL,
114 PRIMARY KEY(recording_id, attempt_id, piece_index)
115);
116
117CREATE INDEX audio_transcript_pieces_cache_lookup
118ON audio_transcript_pieces(
119 recording_id,
120 cache_revision,
121 piece_index,
122 piece_count,
123 audio_start_ms,
124 audio_end_ms,
125 created_at
126);
127
128PRAGMA user_version = 10;
129"#;
130
131#[derive(Clone, Debug)]
133pub struct AudioInput {
134 pub user_id: String,
136 pub bytes: Vec<u8>,
138 pub recorded_at: DateTime<Utc>,
140 pub original_filename: Option<String>,
142}
143
144#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
146pub struct Submission {
147 pub recording_id: Uuid,
149 pub deduplicated: bool,
151}
152
153#[derive(Clone, Debug, Eq, PartialEq)]
155pub struct SpeakerReviewAudio {
156 pub content_type: &'static str,
158 pub filename: String,
160 pub bytes: Vec<u8>,
162}
163
164#[derive(Clone, Debug, Serialize)]
166pub struct Status {
167 pub recordings: Vec<RecordingStatus>,
169}
170
171#[derive(Clone, Debug, Serialize)]
173pub struct RecordingStatus {
174 pub id: Uuid,
176 pub user_id: String,
178 pub sha256: String,
180 pub original_filename: String,
182 pub size_bytes: u64,
184 pub recorded_at: DateTime<Utc>,
186 pub received_at: DateTime<Utc>,
188 pub transcription_model: String,
190 pub reconciliation_model: String,
192 pub reconciliation_reasoning: String,
194 pub state: RecordingState,
196 pub correction_packet: Option<CorrectionPacket>,
198}
199
200#[derive(Clone, Debug, Serialize)]
202#[serde(tag = "kind", rename_all = "snake_case")]
203pub enum RecordingState {
204 Queued,
206 Processing {
208 attempt: u8,
210 progress: TranscriptionStatus,
212 },
213 AwaitingReview,
215 Complete {
217 transcript: String,
219 },
220 Failed {
222 attempts: u8,
224 error: String,
226 retryable: bool,
228 },
229}
230
231#[derive(Clone, Copy, Debug, Eq, PartialEq)]
233pub enum ErrorKind {
234 InvalidInput,
236 NotFound,
238 Conflict,
240 Internal,
242}
243
244#[derive(Debug)]
246pub struct Error {
247 kind: ErrorKind,
248 message: String,
249}
250
251impl Error {
252 fn invalid(message: impl Into<String>) -> Self {
253 Self {
254 kind: ErrorKind::InvalidInput,
255 message: message.into(),
256 }
257 }
258
259 fn not_found() -> Self {
260 Self {
261 kind: ErrorKind::NotFound,
262 message: "Audio recording not found.".into(),
263 }
264 }
265
266 fn conflict(message: impl Into<String>) -> Self {
267 Self {
268 kind: ErrorKind::Conflict,
269 message: message.into(),
270 }
271 }
272
273 fn internal(error: impl std::fmt::Display) -> Self {
274 tracing::error!(%error, "AudioIngress operation failed");
275 Self {
276 kind: ErrorKind::Internal,
277 message: "An unexpected AudioIngress error occurred.".into(),
278 }
279 }
280
281 pub fn kind(&self) -> ErrorKind {
283 self.kind
284 }
285}
286
287impl std::fmt::Display for Error {
288 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 formatter.write_str(&self.message)
290 }
291}
292
293impl std::error::Error for Error {}
294
295struct TemporaryUpload(PathBuf);
296
297impl Drop for TemporaryUpload {
298 fn drop(&mut self) {
299 let _ = fs::remove_file(&self.0);
300 }
301}
302
303struct Inner {
304 root: PathBuf,
305 db: Arc<Mutex<Connection>>,
306 classifier: Arc<SpeechClassifier>,
307 transcriber: AudioTranscriber,
308 jobs: Mutex<HashMap<Uuid, TranscriptionJob>>,
309}
310
311#[derive(Clone)]
313pub struct AudioIngress {
314 inner: Arc<Inner>,
315}
316
317impl AudioIngress {
318 pub async fn open(
324 persistence_root: impl AsRef<Path>,
325 transcriber: AudioTranscriber,
326 classifier: Arc<SpeechClassifier>,
327 ) -> Result<Self, Error> {
328 let root = persistence_root.as_ref().to_path_buf();
329 ensure_private_directory(&root).map_err(Error::internal)?;
330 ensure_private_directory(&root.join(ORIGINALS_DIRECTORY)).map_err(Error::internal)?;
331
332 let connection = Connection::open(root.join(DATABASE_FILENAME)).map_err(Error::internal)?;
333 connection
334 .execute_batch(
335 "PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA busy_timeout=15000;",
336 )
337 .map_err(Error::internal)?;
338 apply_migrations(&connection).map_err(Error::internal)?;
339 recover_interrupted_attempts(&connection).map_err(Error::internal)?;
340
341 let inner = Arc::new(Inner {
342 root,
343 db: Arc::new(Mutex::new(connection)),
344 classifier,
345 transcriber,
346 jobs: Mutex::new(HashMap::new()),
347 });
348 tokio::spawn(worker_loop(Arc::downgrade(&inner)));
349 Ok(Self { inner })
350 }
351
352 pub async fn submit(&self, input: AudioInput) -> Result<Submission, Error> {
354 if input.user_id.trim().is_empty() || input.user_id.chars().count() > 256 {
355 return Err(Error::invalid(
356 "User ID must contain between 1 and 256 characters.",
357 ));
358 }
359 if input.bytes.is_empty() {
360 return Err(Error::invalid("Audio bytes must not be empty."));
361 }
362 let size_bytes = i64::try_from(input.bytes.len())
363 .map_err(|_| Error::invalid("Audio is too large for this platform."))?;
364 let sha256 = format!("{:x}", Sha256::digest(&input.bytes));
365 if let Some(id) = self.recording_id_by_sha(&sha256)? {
366 return Ok(Submission {
367 recording_id: id,
368 deduplicated: true,
369 });
370 }
371
372 let upload_id = Uuid::new_v4();
373 let temporary = TemporaryUpload(self.inner.root.join(format!(".upload-{upload_id}.tmp")));
374 let mut file = tokio::fs::OpenOptions::new()
375 .write(true)
376 .create_new(true)
377 .open(&temporary.0)
378 .await
379 .map_err(Error::internal)?;
380 set_private_file(&temporary.0).map_err(Error::internal)?;
381 file.write_all(&input.bytes)
382 .await
383 .map_err(Error::internal)?;
384 file.sync_all().await.map_err(Error::internal)?;
385 drop(file);
386
387 let relative_path = format!("{ORIGINALS_DIRECTORY}/{sha256}.wav");
388 let final_path = self.inner.root.join(&relative_path);
389 if final_path.exists() {
390 tokio::fs::remove_file(&temporary.0)
391 .await
392 .map_err(Error::internal)?;
393 } else {
394 tokio::fs::rename(&temporary.0, &final_path)
395 .await
396 .map_err(Error::internal)?;
397 }
398 set_private_file(&final_path).map_err(Error::internal)?;
399 sync_file(&final_path).map_err(Error::internal)?;
400 sync_directory(final_path.parent().unwrap_or(&self.inner.root)).map_err(Error::internal)?;
401 sync_directory(&self.inner.root).map_err(Error::internal)?;
402
403 let id = Uuid::new_v4();
404 let now = Utc::now().to_rfc3339();
405 let filename = safe_filename(input.original_filename.as_deref());
406 let insert = {
407 let db = self.inner.db.lock().map_err(Error::internal)?;
408 db.execute(
409 "INSERT INTO audio_recordings(
410 id,user_id,sha256,original_filename,content_type,size_bytes,
411 source_created_at,received_at,updated_at,original_relative_path,
412 status,gemini_model,reconciliation_model,reconciliation_reasoning
413 ) VALUES(?1,?2,?3,?4,'audio/wav',?5,?6,?7,?7,?8,'uploaded',?9,?10,?11)",
414 params![
415 id.to_string(),
416 input.user_id,
417 sha256,
418 filename,
419 size_bytes,
420 input.recorded_at.to_rfc3339(),
421 now,
422 relative_path,
423 TRANSCRIPTION_MODEL,
424 RECONCILIATION_MODEL,
425 RECONCILIATION_REASONING,
426 ],
427 )
428 };
429 if let Err(error) = insert {
430 if let Some(existing) = self.recording_id_by_sha(&sha256)? {
431 return Ok(Submission {
432 recording_id: existing,
433 deduplicated: true,
434 });
435 }
436 return Err(Error::internal(error));
437 }
438 tracing::info!(recording_id=%id, %sha256, bytes=size_bytes, "Durably accepted audio");
439 Ok(Submission {
440 recording_id: id,
441 deduplicated: false,
442 })
443 }
444
445 pub fn status(&self) -> Result<Status, Error> {
447 let db = self.inner.db.lock().map_err(Error::internal)?;
448 let mut statement = db
449 .prepare(
450 "SELECT id,user_id,sha256,original_filename,size_bytes,source_created_at,received_at,
451 status,gemini_model,reconciliation_model,reconciliation_reasoning,
452 attempt_count,last_error,failure_retryable,transcription_status_json,
453 final_transcript,correction_packet_json
454 FROM audio_recordings
455 ORDER BY datetime(source_created_at) DESC,datetime(received_at) DESC,id DESC",
456 )
457 .map_err(Error::internal)?;
458 let recordings = statement
459 .query_map([], row_recording_status)
460 .map_err(Error::internal)?
461 .collect::<Result<Vec<_>, _>>()
462 .map_err(Error::internal)?;
463 Ok(Status { recordings })
464 }
465
466 pub fn speaker_review_audio(
468 &self,
469 recording_id: Uuid,
470 chunk_index: usize,
471 ) -> Result<SpeakerReviewAudio, Error> {
472 let stored = {
473 let db = self.inner.db.lock().map_err(Error::internal)?;
474 db.query_row(
475 "SELECT original_relative_path,correction_packet_json
476 FROM audio_recordings WHERE id=?1",
477 [recording_id.to_string()],
478 |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
479 )
480 .optional()
481 .map_err(Error::internal)?
482 };
483 let Some((relative_path, packet_json)) = stored else {
484 return Err(Error::not_found());
485 };
486 let packet = packet_json
487 .ok_or_else(|| Error::conflict("Speaker review audio is not ready."))
488 .and_then(|value| StoredCorrectionPacket::decode(&value).map_err(Error::internal))?;
489 let chunk = packet
490 .packet
491 .chunks
492 .iter()
493 .find(|chunk| chunk.chunk_index == chunk_index)
494 .ok_or_else(|| Error::invalid("Speaker review chunk does not exist."))?;
495 let original = fs::read(self.inner.root.join(relative_path)).map_err(Error::internal)?;
496 let bytes =
497 transcribe::wav_interval_to_opus(&original, chunk.audio_start_ms, chunk.audio_end_ms)
498 .map_err(Error::internal)?;
499 Ok(SpeakerReviewAudio {
500 content_type: "audio/ogg",
501 filename: format!(
502 "speaker-review-{}-chunk-{}.ogg",
503 recording_id,
504 chunk_index + 1
505 ),
506 bytes,
507 })
508 }
509
510 pub fn retry(&self, recording_id: Uuid) -> Result<(), Error> {
512 let db = self.inner.db.lock().map_err(Error::internal)?;
513 let changed = db
514 .execute(
515 "UPDATE audio_recordings
516 SET status='uploaded',attempt_count=0,next_attempt_at=NULL,last_error=NULL,
517 transcription_status_json=NULL,failure_retryable=1,updated_at=?1
518 WHERE id=?2 AND status='failed'",
519 params![Utc::now().to_rfc3339(), recording_id.to_string()],
520 )
521 .map_err(Error::internal)?;
522 if changed == 1 {
523 return Ok(());
524 }
525 let exists = db
526 .query_row(
527 "SELECT 1 FROM audio_recordings WHERE id=?1",
528 [recording_id.to_string()],
529 |row| row.get::<_, i64>(0),
530 )
531 .optional()
532 .map_err(Error::internal)?
533 .is_some();
534 if exists {
535 Err(Error::conflict("Only a failed recording can be retried."))
536 } else {
537 Err(Error::not_found())
538 }
539 }
540
541 pub fn known_speakers(&self) -> Result<Vec<String>, Error> {
543 self.inner
544 .classifier
545 .known_speakers()
546 .map_err(Error::internal)
547 }
548
549 pub fn confirm_speakers(
556 &self,
557 confirmation: ChunkConfirmation,
558 ) -> Result<CorrectionPacket, Error> {
559 let recording_id = confirmation.recording_id;
560 let db = self.inner.db.lock().map_err(Error::internal)?;
561 let stored = db
562 .query_row(
563 "SELECT status,correction_packet_json,final_transcript
564 FROM audio_recordings WHERE id=?1",
565 [recording_id.to_string()],
566 |row| {
567 Ok((
568 row.get::<_, String>(0)?,
569 row.get::<_, Option<String>>(1)?,
570 row.get::<_, Option<String>>(2)?,
571 ))
572 },
573 )
574 .optional()
575 .map_err(Error::internal)?;
576 let Some((status, packet_json, transcript)) = stored else {
577 return Err(Error::not_found());
578 };
579 if status != "ready_for_ingress" || transcript.is_some() {
580 return Err(Error::conflict(
581 "Speaker confirmation requires a review-ready recording.",
582 ));
583 }
584 let packet_json = packet_json
585 .ok_or_else(|| Error::conflict("The completed recording has no correction packet."))?;
586 let mut stored = StoredCorrectionPacket::decode(&packet_json).map_err(Error::internal)?;
587 identity::validate_confirmation_coverage(&stored.packet, &confirmation)
588 .map_err(Error::invalid)?;
589 let legacy_observation_keys = stored.legacy_observation_keys().map_err(Error::internal)?;
590
591 let previous = stored.packet.clone();
592 apply_confirmations(
593 &self.inner.classifier,
594 &mut stored.packet,
595 &confirmation,
596 &legacy_observation_keys,
597 )
598 .map_err(Error::internal)?;
599 let updated_json = stored.encode().map_err(Error::internal)?;
600 let all_signed = stored.packet.confirmation_state == ConfirmationState::Confirmed;
601 if let Err(error) = db.execute(
602 "UPDATE audio_recordings
603 SET correction_packet_json=?1,
604 status=CASE WHEN ?2 THEN 'reconciling' ELSE status END,
605 attempt_count=CASE WHEN ?2 THEN 0 ELSE attempt_count END,
606 next_attempt_at=NULL,last_error=NULL,updated_at=?3
607 WHERE id=?4",
608 params![
609 updated_json,
610 all_signed,
611 Utc::now().to_rfc3339(),
612 recording_id.to_string()
613 ],
614 ) {
615 let rollback_errors = restore_packet_training(
616 &self.inner.classifier,
617 &previous,
618 &legacy_observation_keys,
619 );
620 if !rollback_errors.is_empty() {
621 tracing::error!(
622 recording_id=%recording_id,
623 errors=?rollback_errors,
624 "Could not fully restore classifier state after packet persistence failed"
625 );
626 }
627 return Err(Error::internal(error));
628 }
629 Ok(stored.packet)
630 }
631
632 fn recording_id_by_sha(&self, sha256: &str) -> Result<Option<Uuid>, Error> {
633 let db = self.inner.db.lock().map_err(Error::internal)?;
634 db.query_row(
635 "SELECT id FROM audio_recordings WHERE sha256=?1",
636 [sha256],
637 |row| row.get::<_, String>(0),
638 )
639 .optional()
640 .map_err(Error::internal)?
641 .map(|id| Uuid::parse_str(&id).map_err(Error::internal))
642 .transpose()
643 }
644}
645
646fn row_recording_status(row: &rusqlite::Row<'_>) -> rusqlite::Result<RecordingStatus> {
647 let id: String = row.get(0)?;
648 let recorded_at: String = row.get(5)?;
649 let received_at: String = row.get(6)?;
650 let durable_status: String = row.get(7)?;
651 let attempts: i64 = row.get(11)?;
652 let last_error: Option<String> = row.get(12)?;
653 let retryable: i64 = row.get(13)?;
654 let progress_json: Option<String> = row.get(14)?;
655 let transcript: Option<String> = row.get(15)?;
656 let correction_packet_json: Option<String> = row.get(16)?;
657 let parse_time = |index, value: &str| {
658 DateTime::parse_from_rfc3339(value)
659 .map(|value| value.with_timezone(&Utc))
660 .map_err(|error| {
661 rusqlite::Error::FromSqlConversionFailure(
662 index,
663 rusqlite::types::Type::Text,
664 Box::new(error),
665 )
666 })
667 };
668 let state = match durable_status.as_str() {
669 "uploaded" => RecordingState::Queued,
670 "chunking" | "transcribing" | "reconciling" => {
671 let progress = progress_json
672 .as_deref()
673 .map(serde_json::from_str)
674 .transpose()
675 .map_err(|error| {
676 rusqlite::Error::FromSqlConversionFailure(
677 14,
678 rusqlite::types::Type::Text,
679 Box::new(error),
680 )
681 })?
682 .unwrap_or_else(initial_progress);
683 RecordingState::Processing {
684 attempt: attempts.clamp(0, i64::from(u8::MAX)) as u8,
685 progress: without_results(progress),
686 }
687 }
688 "ready_for_ingress" | "ingressing" | "ingress_failed" | "complete" => {
689 match transcript.filter(|value| !value.trim().is_empty()) {
690 Some(transcript) => RecordingState::Complete { transcript },
691 None => RecordingState::AwaitingReview,
692 }
693 }
694 "failed" => RecordingState::Failed {
695 attempts: attempts.clamp(0, i64::from(u8::MAX)) as u8,
696 error: last_error.unwrap_or_else(|| "Audio processing failed.".into()),
697 retryable: retryable != 0,
698 },
699 other => {
700 return Err(rusqlite::Error::FromSqlConversionFailure(
701 7,
702 rusqlite::types::Type::Text,
703 format!("unknown audio status {other:?}").into(),
704 ));
705 }
706 };
707 let correction_packet = correction_packet_json
708 .as_deref()
709 .map(StoredCorrectionPacket::decode)
710 .transpose()
711 .map_err(|error| {
712 rusqlite::Error::FromSqlConversionFailure(
713 16,
714 rusqlite::types::Type::Text,
715 Box::new(error),
716 )
717 })?
718 .map(|stored| stored.packet);
719 Ok(RecordingStatus {
720 id: Uuid::parse_str(&id).map_err(|error| {
721 rusqlite::Error::FromSqlConversionFailure(
722 0,
723 rusqlite::types::Type::Text,
724 Box::new(error),
725 )
726 })?,
727 user_id: row.get(1)?,
728 sha256: row.get(2)?,
729 original_filename: row.get(3)?,
730 size_bytes: u64::try_from(row.get::<_, i64>(4)?).map_err(|error| {
731 rusqlite::Error::FromSqlConversionFailure(
732 4,
733 rusqlite::types::Type::Integer,
734 Box::new(error),
735 )
736 })?,
737 recorded_at: parse_time(5, &recorded_at)?,
738 received_at: parse_time(6, &received_at)?,
739 transcription_model: row.get(8)?,
740 reconciliation_model: row.get(9)?,
741 reconciliation_reasoning: row.get(10)?,
742 state,
743 correction_packet,
744 })
745}
746
747#[derive(Clone, Debug)]
748struct LegacyFeatureRow {
749 chunk_position: usize,
750 speaker_position: usize,
751 value: Value,
752}
753
754#[derive(Clone, Debug)]
755struct StoredCorrectionPacket {
756 packet: CorrectionPacket,
757 legacy_feature_rows: Vec<LegacyFeatureRow>,
758}
759
760impl StoredCorrectionPacket {
761 fn decode(serialized: &str) -> serde_json::Result<Self> {
762 let mut value: Value = serde_json::from_str(serialized)?;
763 let confirmed = matches!(
764 value.get("confirmation_state").and_then(Value::as_str),
765 Some("confirmed" | "automatically_trained")
766 );
767 if let Some(object) = value.as_object_mut() {
768 object.remove("clean");
769 }
770 let mut legacy_feature_rows = Vec::new();
771 if let Some(chunks) = value.get_mut("chunks").and_then(Value::as_array_mut) {
772 for (chunk_position, chunk) in chunks.iter_mut().enumerate() {
773 if let Some(object) = chunk.as_object_mut() {
774 object.remove("clean");
775 object
776 .entry("signed_off")
777 .or_insert_with(|| Value::Bool(confirmed));
778 }
779 if let Some(observations) =
780 chunk.get_mut("observations").and_then(Value::as_array_mut)
781 {
782 for observation in observations {
783 let Some(object) = observation.as_object_mut() else {
784 continue;
785 };
786 if !object.contains_key("resolution") {
787 let name = object
788 .get("confirmed_full_name")
789 .or_else(|| object.get("identified_full_name"))
790 .and_then(Value::as_str)
791 .filter(|_| confirmed)
792 .map(str::to_owned);
793 object.insert(
794 "resolution".into(),
795 name.map_or(Value::Null, |full_name| {
796 serde_json::json!({"kind":"known","full_name":full_name})
797 }),
798 );
799 }
800 object.remove("confirmed_full_name");
801 object.remove("identified_full_name");
802 if let Some(candidate) =
803 object.get_mut("candidate").and_then(Value::as_object_mut)
804 {
805 if !candidate.contains_key("score") {
806 let score = candidate
807 .remove("cost")
808 .and_then(|value| value.as_f64())
809 .map(|cost| Value::from(-cost));
810 if let Some(score) = score {
811 candidate.insert("score".into(), score);
812 }
813 }
814 if !candidate.contains_key("runner_up_score") {
815 let runner = candidate
816 .remove("runner_up_cost")
817 .and_then(|value| value.as_f64())
818 .map(|cost| Value::from(-cost))
819 .unwrap_or(Value::Null);
820 candidate.insert("runner_up_score".into(), runner);
821 }
822 candidate.remove("confidence");
823 candidate.remove("runner_up_full_name");
824 candidate.remove("background_population_cost");
825 }
826 }
827 }
828 if let Some(speakers) = chunk
829 .pointer_mut("/parsed/speakers")
830 .and_then(Value::as_array_mut)
831 {
832 for (speaker_position, speaker) in speakers.iter_mut().enumerate() {
833 let Some(feature_row) = speaker.get_mut("feature_row") else {
834 continue;
835 };
836 if is_legacy_feature_row(feature_row) {
837 legacy_feature_rows.push(LegacyFeatureRow {
838 chunk_position,
839 speaker_position,
840 value: feature_row.take(),
841 });
842 }
843 }
844 }
845 }
846 }
847 let packet = serde_json::from_value(value)?;
848 Ok(Self {
849 packet,
850 legacy_feature_rows,
851 })
852 }
853
854 fn encode(&self) -> serde_json::Result<String> {
855 let mut value = serde_json::to_value(&self.packet)?;
856 for legacy in &self.legacy_feature_rows {
857 let feature_row = value
858 .get_mut("chunks")
859 .and_then(Value::as_array_mut)
860 .and_then(|chunks| chunks.get_mut(legacy.chunk_position))
861 .and_then(|chunk| chunk.pointer_mut("/parsed/speakers"))
862 .and_then(Value::as_array_mut)
863 .and_then(|speakers| speakers.get_mut(legacy.speaker_position))
864 .and_then(|speaker| speaker.get_mut("feature_row"))
865 .expect("decoded correction packet retains its speaker positions");
866 *feature_row = legacy.value.clone();
867 }
868 serde_json::to_string(&value)
869 }
870
871 fn legacy_observation_keys(&self) -> anyhow::Result<HashSet<(String, u32)>> {
872 let mut keys = HashSet::new();
873 for legacy in &self.legacy_feature_rows {
874 let chunk = self
875 .packet
876 .chunks
877 .get(legacy.chunk_position)
878 .context("legacy feature row is outside its correction packet")?;
879 let speaker = chunk
880 .parsed
881 .speakers
882 .get(legacy.speaker_position)
883 .context("legacy feature row is outside its parsed speakers")?;
884 let observation = chunk
885 .observations
886 .iter()
887 .find(|observation| {
888 observation.speaker_ordinal as usize == legacy.speaker_position
889 && observation.local_label == speaker.local_label
890 })
891 .context("legacy feature row has no matching correction observation")?;
892 ensure!(
893 keys.insert((
894 observation.observation_key.object_id.clone(),
895 observation.observation_key.piece_index,
896 )),
897 "legacy correction packet repeats an observation key"
898 );
899 }
900 Ok(keys)
901 }
902}
903
904fn is_legacy_feature_row(value: &Value) -> bool {
905 const FIELDS: [&str; 24] = [
906 "accent_variety",
907 "articulation_rate_syllables_per_second",
908 "breathiness",
909 "cefr",
910 "consonant_cluster_reduction_percent",
911 "creaky_phonation_percent",
912 "f0_pitch_span_semitones",
913 "filled_pauses_per_100_words",
914 "foreign_accentedness",
915 "formant_dispersion_hz",
916 "hypernasality",
917 "lateral_realization",
918 "lexical_stress_accuracy_percent",
919 "median_f0_hz",
920 "monophthongization_percent",
921 "npvi_v",
922 "perceived_age",
923 "rhotic_realization",
924 "roughness",
925 "s_realization",
926 "unstressed_vowel_reduction_percent",
927 "vai",
928 "vocal_gender_presentation",
929 "word_initial_stressed_prevocalic_t_vot_ms",
930 ];
931 value.as_object().is_some_and(|object| {
932 object.len() == FIELDS.len() && FIELDS.iter().all(|field| object.contains_key(*field))
933 })
934}
935
936async fn worker_loop(inner: Weak<Inner>) {
937 loop {
938 let Some(inner) = inner.upgrade() else {
939 return;
940 };
941 let worked = match process_next_recording(&inner).await {
942 Ok(worked) => worked,
943 Err(error) => {
944 tracing::error!(error=%error, "AudioIngress worker iteration failed");
945 false
946 }
947 };
948 drop(inner);
949 tokio::time::sleep(if worked {
950 Duration::from_millis(100)
951 } else {
952 Duration::from_secs(5)
953 })
954 .await;
955 }
956}
957
958#[derive(Debug)]
959struct WorkRecording {
960 id: Uuid,
961 user_id: String,
962 sha256: String,
963 original_filename: String,
964 size_bytes: u64,
965 recorded_at: DateTime<Utc>,
966 original_relative_path: String,
967 attempt_count: i64,
968 correction_packet_json: Option<String>,
969}
970
971async fn process_next_recording(inner: &Inner) -> anyhow::Result<bool> {
972 let recording = {
973 let db = inner
974 .db
975 .lock()
976 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
977 fetch_work_recording(&db)?
978 };
979 let Some(recording) = recording else {
980 return Ok(false);
981 };
982 poll_transcription(inner, recording).await?;
983 Ok(true)
984}
985
986fn fetch_work_recording(db: &Connection) -> anyhow::Result<Option<WorkRecording>> {
987 db.query_row(
988 "SELECT id,user_id,sha256,original_filename,size_bytes,source_created_at,
989 original_relative_path,attempt_count,correction_packet_json
990 FROM audio_recordings
991 WHERE status IN ('uploaded','chunking','transcribing','reconciling')
992 AND (next_attempt_at IS NULL OR datetime(next_attempt_at)<=datetime('now'))
993 ORDER BY datetime(received_at),id
994 LIMIT 1",
995 [],
996 |row| {
997 Ok((
998 row.get::<_, String>(0)?,
999 row.get::<_, String>(1)?,
1000 row.get::<_, String>(2)?,
1001 row.get::<_, String>(3)?,
1002 row.get::<_, i64>(4)?,
1003 row.get::<_, String>(5)?,
1004 row.get::<_, String>(6)?,
1005 row.get::<_, i64>(7)?,
1006 row.get::<_, Option<String>>(8)?,
1007 ))
1008 },
1009 )
1010 .optional()?
1011 .map(
1012 |(
1013 id,
1014 user_id,
1015 sha256,
1016 original_filename,
1017 size_bytes,
1018 recorded_at,
1019 original_relative_path,
1020 attempt_count,
1021 correction_packet_json,
1022 )| {
1023 Ok(WorkRecording {
1024 id: Uuid::parse_str(&id)?,
1025 user_id,
1026 sha256,
1027 original_filename,
1028 size_bytes: u64::try_from(size_bytes).context("stored audio size is negative")?,
1029 recorded_at: DateTime::parse_from_rfc3339(&recorded_at)
1030 .context("stored recording time is invalid")?
1031 .with_timezone(&Utc),
1032 original_relative_path,
1033 attempt_count,
1034 correction_packet_json,
1035 })
1036 },
1037 )
1038 .transpose()
1039}
1040
1041async fn poll_transcription(inner: &Inner, mut recording: WorkRecording) -> anyhow::Result<()> {
1042 let existing = inner
1043 .jobs
1044 .lock()
1045 .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
1046 .get(&recording.id)
1047 .cloned();
1048 let job = if let Some(job) = existing {
1049 job
1050 } else {
1051 if recording.attempt_count >= FAILURE_LIMIT {
1052 mark_failed(
1053 inner,
1054 recording.id,
1055 recording.attempt_count,
1056 true,
1057 "Audio transcription exhausted its five automatic attempts.",
1058 None,
1059 )?;
1060 return Ok(());
1061 }
1062 let final_packet = recording
1063 .correction_packet_json
1064 .as_deref()
1065 .map(StoredCorrectionPacket::decode)
1066 .transpose()?
1067 .map(|stored| stored.packet)
1068 .filter(|packet| packet.confirmation_state == ConfirmationState::Confirmed);
1069 if let Some(packet) = final_packet {
1070 recording.attempt_count += 1;
1071 {
1072 let db = inner
1073 .db
1074 .lock()
1075 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1076 db.execute(
1077 "UPDATE audio_recordings
1078 SET status='reconciling',attempt_count=?1,next_attempt_at=NULL,
1079 last_error=NULL,failure_retryable=1,updated_at=?2
1080 WHERE id=?3",
1081 params![
1082 recording.attempt_count,
1083 Utc::now().to_rfc3339(),
1084 recording.id.to_string()
1085 ],
1086 )?;
1087 }
1088 let job = inner
1089 .transcriber
1090 .finalize_durably(recording.user_id.clone(), packet);
1091 inner
1092 .jobs
1093 .lock()
1094 .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
1095 .insert(recording.id, job.clone());
1096 job
1097 } else {
1098 let source = inner.root.join(&recording.original_relative_path);
1099 let audio = tokio::fs::read(&source)
1100 .await
1101 .with_context(|| format!("reading retained audio {}", source.display()))?;
1102 recording.attempt_count += 1;
1103 {
1104 let db = inner
1105 .db
1106 .lock()
1107 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1108 db.execute(
1109 "UPDATE audio_recordings
1110 SET status='chunking',attempt_count=?1,next_attempt_at=NULL,last_error=NULL,
1111 failure_retryable=1,updated_at=?2
1112 WHERE id=?3",
1113 params![
1114 recording.attempt_count,
1115 Utc::now().to_rfc3339(),
1116 recording.id.to_string()
1117 ],
1118 )?;
1119 }
1120
1121 let classification = ClassificationContext {
1122 recording_id: recording.id,
1123 user_id: recording.user_id.clone(),
1124 sha256: recording.sha256.clone(),
1125 original_filename: recording.original_filename.clone(),
1126 size_bytes: recording.size_bytes,
1127 recorded_at: recording.recorded_at,
1128 classifier: inner.classifier.clone(),
1129 };
1130
1131 let cache_db = inner.db.clone();
1132 let cache_recording_id = recording.id;
1133 let piece_cache: PieceCache = Arc::new(move |plan| {
1134 load_cached_transcript_piece(&cache_db, cache_recording_id, plan)
1135 });
1136
1137 let sink_db = inner.db.clone();
1138 let sink_recording_id = recording.id;
1139 let attempt_id = Uuid::new_v4().to_string();
1140 let piece_sink: PieceSink = Arc::new(move |plan, raw| {
1141 persist_transcript_piece(&sink_db, sink_recording_id, &attempt_id, plan, raw)
1142 });
1143
1144 let job = inner.transcriber.transcribe_durably(
1145 recording.user_id.clone(),
1146 audio,
1147 piece_cache,
1148 piece_sink,
1149 classification,
1150 );
1151 inner
1152 .jobs
1153 .lock()
1154 .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
1155 .insert(recording.id, job.clone());
1156 job
1157 }
1158 };
1159
1160 let snapshot = job.status();
1161 persist_progress(inner, recording.id, &snapshot)?;
1162 match snapshot.state {
1163 JobState::Queued | JobState::Running => Ok(()),
1164 JobState::Completed => {
1165 let progress = serde_json::to_string(&without_results(snapshot.clone()))?;
1166 let db = inner
1167 .db
1168 .lock()
1169 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1170 if let Some(transcript) = snapshot.transcript.as_deref() {
1171 ensure!(
1172 !transcript.trim().is_empty(),
1173 "completed transcript is empty"
1174 );
1175 db.execute(
1176 "UPDATE audio_recordings
1177 SET status='ready_for_ingress',final_transcript=?1,
1178 transcription_status_json=?2,next_attempt_at=NULL,last_error=NULL,
1179 failure_retryable=1,updated_at=?3 WHERE id=?4",
1180 params![
1181 transcript.trim(),
1182 progress,
1183 Utc::now().to_rfc3339(),
1184 recording.id.to_string()
1185 ],
1186 )?;
1187 tracing::info!(recording_id=%recording.id, "Final audio transcript completed");
1188 } else {
1189 let packet = snapshot
1190 .correction_packet
1191 .as_ref()
1192 .context("completed analysis omitted its correction packet")?;
1193 ensure!(
1194 packet.recording_id == recording.id,
1195 "correction packet belongs to another recording"
1196 );
1197 db.execute(
1198 "UPDATE audio_recordings
1199 SET status='ready_for_ingress',final_transcript=NULL,
1200 correction_packet_json=?1,transcription_status_json=?2,
1201 next_attempt_at=NULL,last_error=NULL,failure_retryable=1,updated_at=?3
1202 WHERE id=?4",
1203 params![
1204 serde_json::to_string(packet)?,
1205 progress,
1206 Utc::now().to_rfc3339(),
1207 recording.id.to_string()
1208 ],
1209 )?;
1210 tracing::info!(recording_id=%recording.id, "Audio chunks are ready for speaker review");
1211 }
1212 remove_job(inner, recording.id)?;
1213 Ok(())
1214 }
1215 JobState::Failed => {
1216 let error = snapshot
1217 .steps
1218 .iter()
1219 .find(|step| step.state == StepState::Failed)
1220 .and_then(|step| step.error.as_ref());
1221 let message = error
1222 .map(|error| error.message.clone())
1223 .unwrap_or_else(|| "Audio transcription failed without detail.".into());
1224 let retryable = error.is_none_or(|error| error.retryable);
1225 record_attempt_failure(
1226 inner,
1227 recording.id,
1228 recording.attempt_count,
1229 retryable,
1230 &message,
1231 Some(snapshot),
1232 )?;
1233 remove_job(inner, recording.id)
1234 }
1235 }
1236}
1237
1238fn record_attempt_failure(
1239 inner: &Inner,
1240 id: Uuid,
1241 attempts: i64,
1242 retryable: bool,
1243 message: &str,
1244 progress: Option<TranscriptionStatus>,
1245) -> anyhow::Result<()> {
1246 if !retryable || attempts >= FAILURE_LIMIT {
1247 return mark_failed(inner, id, attempts, retryable, message, progress);
1248 }
1249 let db = inner
1250 .db
1251 .lock()
1252 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1253 db.execute(
1254 "UPDATE audio_recordings
1255 SET status=CASE
1256 WHEN status='reconciling' AND correction_packet_json IS NOT NULL
1257 AND final_transcript IS NULL THEN 'reconciling'
1258 ELSE 'uploaded'
1259 END,
1260 next_attempt_at=?1,last_error=?2,failure_retryable=1,
1261 transcription_status_json=?3,updated_at=?4
1262 WHERE id=?5",
1263 params![
1264 (Utc::now() + ChronoDuration::seconds(RETRY_DELAY_SECONDS)).to_rfc3339(),
1265 concise(message, 2_000),
1266 progress
1267 .map(without_results)
1268 .map(|progress| serde_json::to_string(&progress))
1269 .transpose()?,
1270 Utc::now().to_rfc3339(),
1271 id.to_string()
1272 ],
1273 )?;
1274 tracing::warn!(recording_id=%id, attempt=attempts, "Audio transcription will retry");
1275 Ok(())
1276}
1277
1278fn mark_failed(
1279 inner: &Inner,
1280 id: Uuid,
1281 attempts: i64,
1282 retryable: bool,
1283 message: &str,
1284 progress: Option<TranscriptionStatus>,
1285) -> anyhow::Result<()> {
1286 let db = inner
1287 .db
1288 .lock()
1289 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1290 db.execute(
1291 "UPDATE audio_recordings
1292 SET status='failed',attempt_count=?1,next_attempt_at=NULL,last_error=?2,
1293 failure_retryable=?3,transcription_status_json=?4,updated_at=?5
1294 WHERE id=?6",
1295 params![
1296 attempts,
1297 concise(message, 2_000),
1298 i64::from(retryable),
1299 progress
1300 .map(without_results)
1301 .map(|progress| serde_json::to_string(&progress))
1302 .transpose()?,
1303 Utc::now().to_rfc3339(),
1304 id.to_string()
1305 ],
1306 )?;
1307 tracing::error!(recording_id=%id, attempts, retryable, "Audio transcription stopped");
1308 Ok(())
1309}
1310
1311fn persist_progress(inner: &Inner, id: Uuid, snapshot: &TranscriptionStatus) -> anyhow::Result<()> {
1312 let durable_status = transcription_stage(snapshot);
1313 let serialized = serde_json::to_string(&without_results(snapshot.clone()))?;
1314 let db = inner
1315 .db
1316 .lock()
1317 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1318 db.execute(
1319 "UPDATE audio_recordings
1320 SET status=?1,transcription_status_json=?2,updated_at=?3
1321 WHERE id=?4 AND (status<>?1 OR COALESCE(transcription_status_json,'')<>?2)",
1322 params![
1323 durable_status,
1324 serialized,
1325 Utc::now().to_rfc3339(),
1326 id.to_string()
1327 ],
1328 )?;
1329 Ok(())
1330}
1331
1332fn load_cached_transcript_piece(
1333 db: &Mutex<Connection>,
1334 recording_id: Uuid,
1335 plan: ChunkPlan,
1336) -> anyhow::Result<Option<String>> {
1337 let piece_index = i64::try_from(plan.index).context("piece index exceeds SQLite limits")?;
1338 let piece_count = i64::try_from(plan.total).context("piece count exceeds SQLite limits")?;
1339 let audio_start_ms =
1340 i64::try_from(plan.start_ms).context("piece start exceeds SQLite limits")?;
1341 let audio_end_ms = i64::try_from(plan.end_ms).context("piece end exceeds SQLite limits")?;
1342 let stored = {
1343 let db = db
1344 .lock()
1345 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1346 db.query_row(
1347 "SELECT raw_gemini_response
1348 FROM audio_transcript_pieces
1349 WHERE recording_id=?1
1350 AND cache_revision=?2
1351 AND piece_index=?3
1352 AND piece_count=?4
1353 AND audio_start_ms=?5
1354 AND audio_end_ms=?6
1355 ORDER BY datetime(created_at) DESC,attempt_id DESC
1356 LIMIT 1",
1357 params![
1358 recording_id.to_string(),
1359 PIECE_CACHE_REVISION,
1360 piece_index,
1361 piece_count,
1362 audio_start_ms,
1363 audio_end_ms,
1364 ],
1365 |row| row.get::<_, String>(0),
1366 )
1367 .optional()?
1368 };
1369 let Some(raw_gemini_response) = stored else {
1370 return Ok(None);
1371 };
1372 ensure!(
1373 !raw_gemini_response.trim().is_empty(),
1374 "cached piece omitted its raw Gemini response"
1375 );
1376 Ok(Some(raw_gemini_response))
1377}
1378
1379fn persist_transcript_piece(
1380 db: &Mutex<Connection>,
1381 recording_id: Uuid,
1382 attempt_id: &str,
1383 plan: ChunkPlan,
1384 raw_gemini_response: &str,
1385) -> anyhow::Result<()> {
1386 ensure!(!attempt_id.is_empty(), "piece attempt identity is empty");
1387 let piece_index = i64::try_from(plan.index).context("piece index exceeds SQLite limits")?;
1388 let piece_count = i64::try_from(plan.total).context("piece count exceeds SQLite limits")?;
1389 let audio_start_ms =
1390 i64::try_from(plan.start_ms).context("piece start exceeds SQLite limits")?;
1391 let audio_end_ms = i64::try_from(plan.end_ms).context("piece end exceeds SQLite limits")?;
1392 let db = db
1393 .lock()
1394 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1395 db.execute(
1396 "INSERT INTO audio_transcript_pieces(
1397 recording_id,attempt_id,cache_revision,piece_index,piece_count,
1398 audio_start_ms,audio_end_ms,transcript_json,raw_gemini_response,
1399 parsed_json,created_at
1400 ) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?8,?10)
1401 ON CONFLICT(recording_id,attempt_id,piece_index) DO UPDATE SET
1402 cache_revision=excluded.cache_revision,
1403 piece_count=excluded.piece_count,
1404 audio_start_ms=excluded.audio_start_ms,
1405 audio_end_ms=excluded.audio_end_ms,
1406 transcript_json=excluded.transcript_json,
1407 raw_gemini_response=excluded.raw_gemini_response,
1408 parsed_json=excluded.parsed_json",
1409 params![
1410 recording_id.to_string(),
1411 attempt_id,
1412 PIECE_CACHE_REVISION,
1413 piece_index,
1414 piece_count,
1415 audio_start_ms,
1416 audio_end_ms,
1417 raw_gemini_response,
1418 raw_gemini_response,
1419 Utc::now().to_rfc3339(),
1420 ],
1421 )?;
1422 Ok(())
1423}
1424
1425fn remove_job(inner: &Inner, id: Uuid) -> anyhow::Result<()> {
1426 inner
1427 .jobs
1428 .lock()
1429 .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
1430 .remove(&id);
1431 Ok(())
1432}
1433
1434fn transcription_stage(snapshot: &TranscriptionStatus) -> &'static str {
1435 if snapshot.steps.len() == 1 && snapshot.steps[0].step == Step::ReconcileTranscript {
1436 return "reconciling";
1437 }
1438 let plan_complete = snapshot
1439 .steps
1440 .iter()
1441 .any(|entry| entry.step == Step::PlanChunks && entry.state == StepState::Completed);
1442 if !plan_complete {
1443 return "chunking";
1444 }
1445 let chunks_complete = snapshot
1446 .steps
1447 .iter()
1448 .filter(|entry| matches!(entry.step, Step::TranscribeChunk { .. }))
1449 .all(|entry| entry.state == StepState::Completed);
1450 if chunks_complete {
1451 "reconciling"
1452 } else {
1453 "transcribing"
1454 }
1455}
1456
1457fn without_results(mut status: TranscriptionStatus) -> TranscriptionStatus {
1458 status.transcript = None;
1459 status.correction_packet = None;
1460 status
1461}
1462
1463fn initial_progress() -> TranscriptionStatus {
1464 TranscriptionStatus {
1465 state: JobState::Queued,
1466 steps: Vec::new(),
1467 transcript: None,
1468 correction_packet: None,
1469 }
1470}
1471
1472fn recover_interrupted_attempts(connection: &Connection) -> rusqlite::Result<()> {
1473 connection.execute(
1474 "UPDATE audio_recordings
1475 SET status=CASE
1476 WHEN status='reconciling' AND correction_packet_json IS NOT NULL
1477 AND final_transcript IS NULL AND attempt_count<?1 THEN 'reconciling'
1478 WHEN attempt_count>=?1 THEN 'failed'
1479 ELSE 'uploaded'
1480 END,
1481 next_attempt_at=NULL,
1482 last_error=CASE WHEN attempt_count>=?1
1483 THEN 'Audio transcription stopped after its fifth attempt was interrupted.'
1484 ELSE 'Audio transcription was interrupted and will restart automatically.'
1485 END,
1486 failure_retryable=1,
1487 updated_at=?2
1488 WHERE status IN ('chunking','transcribing','reconciling')",
1489 params![FAILURE_LIMIT, Utc::now().to_rfc3339()],
1490 )?;
1491 Ok(())
1492}
1493
1494fn apply_migrations(connection: &Connection) -> anyhow::Result<()> {
1495 let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?;
1496 ensure!(
1497 version <= LATEST_SCHEMA_VERSION,
1498 "audio-ingress database schema version {version} is newer than supported version {LATEST_SCHEMA_VERSION}"
1499 );
1500 if version == 0 {
1501 let has_recordings = connection.query_row(
1502 "SELECT EXISTS(
1503 SELECT 1 FROM sqlite_schema
1504 WHERE type='table' AND name='audio_recordings'
1505 )",
1506 [],
1507 |row| row.get::<_, i64>(0),
1508 )? == 1;
1509 if !has_recordings {
1510 connection.execute_batch(FRESH_SCHEMA)?;
1511 return Ok(());
1512 }
1513 }
1514 if version < 1 {
1515 connection.execute_batch(INITIAL_MIGRATION)?;
1516 }
1517 if version < 2 {
1518 connection.execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)?;
1519 }
1520 if version < 3 {
1521 connection.execute_batch(TRANSCRIPTION_STATUS_MIGRATION)?;
1522 }
1523 if version < 4 {
1524 connection.execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)?;
1525 }
1526 if version < 5 {
1527 connection.execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)?;
1528 }
1529 if version < 6 {
1530 connection.execute_batch(STANDALONE_LIBRARY_MIGRATION)?;
1531 }
1532 if version < 7 {
1533 connection.execute_batch(DURABLE_TRANSCRIPT_PIECES_MIGRATION)?;
1534 }
1535 if version < 8 {
1536 connection.execute_batch(UNIQUE_TRANSCRIPTION_ATTEMPTS_MIGRATION)?;
1537 }
1538 if version < 9 {
1539 connection.execute_batch(USAGE_USER_MIGRATION)?;
1540 }
1541 if version < 10 {
1542 connection.execute_batch(SPEAKER_CORRECTION_PACKETS_MIGRATION)?;
1543 }
1544 Ok(())
1545}
1546
1547fn ensure_private_directory(path: &Path) -> anyhow::Result<()> {
1548 fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?;
1549 #[cfg(unix)]
1550 {
1551 use std::os::unix::fs::PermissionsExt;
1552 fs::set_permissions(path, fs::Permissions::from_mode(0o700))
1553 .with_context(|| format!("setting private permissions on {}", path.display()))?;
1554 }
1555 Ok(())
1556}
1557
1558fn set_private_file(path: &Path) -> anyhow::Result<()> {
1559 #[cfg(unix)]
1560 {
1561 use std::os::unix::fs::PermissionsExt;
1562 fs::set_permissions(path, fs::Permissions::from_mode(0o600))
1563 .with_context(|| format!("setting private permissions on {}", path.display()))?;
1564 }
1565 Ok(())
1566}
1567
1568fn sync_file(path: &Path) -> anyhow::Result<()> {
1569 fs::OpenOptions::new()
1570 .read(true)
1571 .write(true)
1572 .open(path)
1573 .with_context(|| format!("opening {} for sync", path.display()))?
1574 .sync_all()
1575 .with_context(|| format!("syncing {}", path.display()))
1576}
1577
1578fn sync_directory(path: &Path) -> anyhow::Result<()> {
1579 #[cfg(unix)]
1580 fs::File::open(path)
1581 .with_context(|| format!("opening directory {} for sync", path.display()))?
1582 .sync_all()
1583 .with_context(|| format!("syncing directory {}", path.display()))?;
1584 Ok(())
1585}
1586
1587fn safe_filename(value: Option<&str>) -> String {
1588 let name = value
1589 .and_then(|value| Path::new(value).file_name())
1590 .and_then(|value| value.to_str())
1591 .unwrap_or("audio.wav");
1592 let clean = name
1593 .chars()
1594 .map(|character| {
1595 if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
1596 character
1597 } else {
1598 '_'
1599 }
1600 })
1601 .take(200)
1602 .collect::<String>();
1603 if clean.is_empty() {
1604 "audio.wav".into()
1605 } else {
1606 clean
1607 }
1608}
1609
1610fn concise(value: &str, limit: usize) -> String {
1611 let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
1612 let bounded = normalized.chars().take(limit).collect::<String>();
1613 if bounded.is_empty() {
1614 "Audio transcription failed without an error message.".into()
1615 } else {
1616 bounded
1617 }
1618}