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