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_audio(
485 &self,
486 recording_id: Uuid,
487 chunk_index: usize,
488 ) -> Result<SpeakerReviewAudio, Error> {
489 let stored = {
490 let db = self.inner.db.lock().map_err(Error::internal)?;
491 db.query_row(
492 "SELECT original_relative_path,correction_packet_json
493 FROM audio_recordings WHERE id=?1",
494 [recording_id.to_string()],
495 |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
496 )
497 .optional()
498 .map_err(Error::internal)?
499 };
500 let Some((relative_path, packet_json)) = stored else {
501 return Err(Error::not_found());
502 };
503 let packet = packet_json
504 .ok_or_else(|| Error::conflict("Speaker review audio is not ready."))
505 .and_then(|value| StoredCorrectionPacket::decode(&value).map_err(Error::internal))?;
506 let chunk = packet
507 .packet
508 .chunks
509 .iter()
510 .find(|chunk| chunk.chunk_index == chunk_index)
511 .ok_or_else(|| Error::invalid("Speaker review chunk does not exist."))?;
512 let original =
513 fs::File::open(self.inner.root.join(relative_path)).map_err(Error::internal)?;
514 let bytes = wav_slice::interval(original, chunk.audio_start_ms, chunk.audio_end_ms)
515 .map_err(Error::internal)?;
516 Ok(SpeakerReviewAudio {
517 content_type: "audio/wav",
518 filename: format!(
519 "speaker-review-{}-chunk-{}.wav",
520 recording_id,
521 chunk_index + 1
522 ),
523 bytes,
524 })
525 }
526
527 pub fn retry(&self, recording_id: Uuid) -> Result<(), Error> {
529 let db = self.inner.db.lock().map_err(Error::internal)?;
530 let changed = db
531 .execute(
532 "UPDATE audio_recordings
533 SET status='uploaded',attempt_count=0,next_attempt_at=NULL,last_error=NULL,
534 transcription_status_json=NULL,failure_retryable=1,updated_at=?1
535 WHERE id=?2 AND status='failed'",
536 params![Utc::now().to_rfc3339(), recording_id.to_string()],
537 )
538 .map_err(Error::internal)?;
539 if changed == 1 {
540 return Ok(());
541 }
542 let exists = db
543 .query_row(
544 "SELECT 1 FROM audio_recordings WHERE id=?1",
545 [recording_id.to_string()],
546 |row| row.get::<_, i64>(0),
547 )
548 .optional()
549 .map_err(Error::internal)?
550 .is_some();
551 if exists {
552 Err(Error::conflict("Only a failed recording can be retried."))
553 } else {
554 Err(Error::not_found())
555 }
556 }
557
558 pub fn known_speakers(&self) -> Result<Vec<String>, Error> {
560 self.inner
561 .classifier
562 .known_speakers()
563 .map_err(Error::internal)
564 }
565
566 pub fn resolve_legacy_review(
573 &self,
574 recording_id: Uuid,
575 disposition: LegacyReviewDisposition,
576 ) -> Result<(), Error> {
577 let mut db = self.inner.db.lock().map_err(Error::internal)?;
578 match legacy_review::resolve(&mut db, recording_id, disposition, Utc::now())
579 .map_err(Error::internal)?
580 {
581 legacy_review::Outcome::Applied | legacy_review::Outcome::Unchanged => Ok(()),
582 legacy_review::Outcome::Missing => Err(Error::not_found()),
583 legacy_review::Outcome::Ineligible => Err(Error::conflict(
584 "Recording is not an unresolved finalized legacy review.",
585 )),
586 }
587 }
588
589 pub fn confirm_speakers(
596 &self,
597 confirmation: ChunkConfirmation,
598 ) -> Result<CorrectionPacket, Error> {
599 self.inner.confirmations.confirm(confirmation)
600 }
601
602 fn recording_id_by_sha(&self, sha256: &str) -> Result<Option<Uuid>, Error> {
603 let db = self.inner.db.lock().map_err(Error::internal)?;
604 db.query_row(
605 "SELECT id FROM audio_recordings WHERE sha256=?1",
606 [sha256],
607 |row| row.get::<_, String>(0),
608 )
609 .optional()
610 .map_err(Error::internal)?
611 .map(|id| Uuid::parse_str(&id).map_err(Error::internal))
612 .transpose()
613 }
614}
615
616fn row_recording_status(row: &rusqlite::Row<'_>) -> rusqlite::Result<RecordingStatus> {
617 let id: String = row.get(0)?;
618 let recorded_at: String = row.get(5)?;
619 let received_at: String = row.get(6)?;
620 let durable_status: String = row.get(7)?;
621 let attempts: i64 = row.get(11)?;
622 let last_error: Option<String> = row.get(12)?;
623 let retryable: i64 = row.get(13)?;
624 let progress_json: Option<String> = row.get(14)?;
625 let transcript: Option<String> = row.get(15)?;
626 let correction_packet_json: Option<String> = row.get(16)?;
627 let parse_time = |index, value: &str| {
628 DateTime::parse_from_rfc3339(value)
629 .map(|value| value.with_timezone(&Utc))
630 .map_err(|error| {
631 rusqlite::Error::FromSqlConversionFailure(
632 index,
633 rusqlite::types::Type::Text,
634 Box::new(error),
635 )
636 })
637 };
638 let state = match durable_status.as_str() {
639 "uploaded" => RecordingState::Queued,
640 "chunking" | "transcribing" | "reconciling" => {
641 let progress = progress_json
642 .as_deref()
643 .map(serde_json::from_str)
644 .transpose()
645 .map_err(|error| {
646 rusqlite::Error::FromSqlConversionFailure(
647 14,
648 rusqlite::types::Type::Text,
649 Box::new(error),
650 )
651 })?
652 .unwrap_or_else(initial_progress);
653 RecordingState::Processing {
654 attempt: attempts.clamp(0, i64::from(u8::MAX)) as u8,
655 progress: without_results(progress),
656 }
657 }
658 "ready_for_ingress" | "ingressing" | "ingress_failed" | "complete" => {
659 match transcript.filter(|value| !value.trim().is_empty()) {
660 Some(transcript) => RecordingState::Complete { transcript },
661 None => RecordingState::AwaitingReview,
662 }
663 }
664 "failed" => RecordingState::Failed {
665 attempts: attempts.clamp(0, i64::from(u8::MAX)) as u8,
666 error: last_error.unwrap_or_else(|| "Audio processing failed.".into()),
667 retryable: retryable != 0,
668 },
669 other => {
670 return Err(rusqlite::Error::FromSqlConversionFailure(
671 7,
672 rusqlite::types::Type::Text,
673 format!("unknown audio status {other:?}").into(),
674 ));
675 }
676 };
677 let correction_packet = if durable_status == "complete" {
678 None
679 } else {
680 correction_packet_json
681 .as_deref()
682 .map(StoredCorrectionPacket::decode)
683 .transpose()
684 .map_err(|error| {
685 rusqlite::Error::FromSqlConversionFailure(
686 16,
687 rusqlite::types::Type::Text,
688 Box::new(error),
689 )
690 })?
691 .map(|stored| stored.packet)
692 };
693 Ok(RecordingStatus {
694 id: Uuid::parse_str(&id).map_err(|error| {
695 rusqlite::Error::FromSqlConversionFailure(
696 0,
697 rusqlite::types::Type::Text,
698 Box::new(error),
699 )
700 })?,
701 user_id: row.get(1)?,
702 sha256: row.get(2)?,
703 original_filename: row.get(3)?,
704 size_bytes: u64::try_from(row.get::<_, i64>(4)?).map_err(|error| {
705 rusqlite::Error::FromSqlConversionFailure(
706 4,
707 rusqlite::types::Type::Integer,
708 Box::new(error),
709 )
710 })?,
711 recorded_at: parse_time(5, &recorded_at)?,
712 received_at: parse_time(6, &received_at)?,
713 transcription_model: row.get(8)?,
714 reconciliation_model: row.get(9)?,
715 reconciliation_reasoning: row.get(10)?,
716 state,
717 correction_packet,
718 })
719}
720
721#[derive(Clone, Debug)]
722struct LegacyFeatureRow {
723 chunk_position: usize,
724 speaker_position: usize,
725 value: Value,
726}
727
728#[derive(Clone, Debug)]
729struct StoredCorrectionPacket {
730 packet: CorrectionPacket,
731 legacy_feature_rows: Vec<LegacyFeatureRow>,
732}
733
734impl StoredCorrectionPacket {
735 fn decode(serialized: &str) -> serde_json::Result<Self> {
736 let mut value: Value = serde_json::from_str(serialized)?;
737 let confirmed = matches!(
738 value.get("confirmation_state").and_then(Value::as_str),
739 Some("confirmed" | "automatically_trained")
740 );
741 if let Some(object) = value.as_object_mut() {
742 object.remove("clean");
743 }
744 let mut legacy_feature_rows = Vec::new();
745 if let Some(chunks) = value.get_mut("chunks").and_then(Value::as_array_mut) {
746 for (chunk_position, chunk) in chunks.iter_mut().enumerate() {
747 if let Some(object) = chunk.as_object_mut() {
748 object.remove("clean");
749 object
750 .entry("signed_off")
751 .or_insert_with(|| Value::Bool(confirmed));
752 }
753 if let Some(observations) =
754 chunk.get_mut("observations").and_then(Value::as_array_mut)
755 {
756 for observation in observations {
757 let Some(object) = observation.as_object_mut() else {
758 continue;
759 };
760 if !object.contains_key("resolution") {
761 let name = object
762 .get("confirmed_full_name")
763 .or_else(|| object.get("identified_full_name"))
764 .and_then(Value::as_str)
765 .filter(|_| confirmed)
766 .map(str::to_owned);
767 object.insert(
768 "resolution".into(),
769 name.map_or(Value::Null, |full_name| {
770 serde_json::json!({"kind":"known","full_name":full_name})
771 }),
772 );
773 }
774 object.remove("confirmed_full_name");
775 object.remove("identified_full_name");
776 if let Some(candidate) =
777 object.get_mut("candidate").and_then(Value::as_object_mut)
778 {
779 if !candidate.contains_key("score") {
780 let score = candidate
781 .remove("cost")
782 .and_then(|value| value.as_f64())
783 .map(|cost| Value::from(-cost));
784 if let Some(score) = score {
785 candidate.insert("score".into(), score);
786 }
787 }
788 if !candidate.contains_key("runner_up_score") {
789 let runner = candidate
790 .remove("runner_up_cost")
791 .and_then(|value| value.as_f64())
792 .map(|cost| Value::from(-cost))
793 .unwrap_or(Value::Null);
794 candidate.insert("runner_up_score".into(), runner);
795 }
796 candidate.remove("confidence");
797 candidate.remove("runner_up_full_name");
798 candidate.remove("background_population_cost");
799 }
800 }
801 }
802 if let Some(speakers) = chunk
803 .pointer_mut("/parsed/speakers")
804 .and_then(Value::as_array_mut)
805 {
806 for (speaker_position, speaker) in speakers.iter_mut().enumerate() {
807 let Some(feature_row) = speaker.get_mut("feature_row") else {
808 continue;
809 };
810 if is_legacy_feature_row(feature_row) {
811 legacy_feature_rows.push(LegacyFeatureRow {
812 chunk_position,
813 speaker_position,
814 value: feature_row.take(),
815 });
816 }
817 }
818 }
819 }
820 }
821 let packet = serde_json::from_value(value)?;
822 Ok(Self {
823 packet,
824 legacy_feature_rows,
825 })
826 }
827
828 fn encode(&self) -> serde_json::Result<String> {
829 let mut value = serde_json::to_value(&self.packet)?;
830 for legacy in &self.legacy_feature_rows {
831 let feature_row = value
832 .get_mut("chunks")
833 .and_then(Value::as_array_mut)
834 .and_then(|chunks| chunks.get_mut(legacy.chunk_position))
835 .and_then(|chunk| chunk.pointer_mut("/parsed/speakers"))
836 .and_then(Value::as_array_mut)
837 .and_then(|speakers| speakers.get_mut(legacy.speaker_position))
838 .and_then(|speaker| speaker.get_mut("feature_row"))
839 .expect("decoded correction packet retains its speaker positions");
840 *feature_row = legacy.value.clone();
841 }
842 serde_json::to_string(&value)
843 }
844
845 fn legacy_observation_keys(&self) -> anyhow::Result<HashSet<ObservationKey>> {
846 let mut keys = HashSet::new();
847 for legacy in &self.legacy_feature_rows {
848 let chunk = self
849 .packet
850 .chunks
851 .get(legacy.chunk_position)
852 .context("legacy feature row is outside its correction packet")?;
853 let speaker = chunk
854 .parsed
855 .speakers
856 .get(legacy.speaker_position)
857 .context("legacy feature row is outside its parsed speakers")?;
858 let observation = chunk
859 .observations
860 .iter()
861 .find(|observation| {
862 observation.speaker_ordinal as usize == legacy.speaker_position
863 && observation.local_label == speaker.local_label
864 })
865 .context("legacy feature row has no matching correction observation")?;
866 ensure!(
867 keys.insert(observation.observation_key.clone()),
868 "legacy correction packet repeats an observation key"
869 );
870 }
871 Ok(keys)
872 }
873}
874
875fn is_legacy_feature_row(value: &Value) -> bool {
876 const FIELDS: [&str; 24] = [
877 "accent_variety",
878 "articulation_rate_syllables_per_second",
879 "breathiness",
880 "cefr",
881 "consonant_cluster_reduction_percent",
882 "creaky_phonation_percent",
883 "f0_pitch_span_semitones",
884 "filled_pauses_per_100_words",
885 "foreign_accentedness",
886 "formant_dispersion_hz",
887 "hypernasality",
888 "lateral_realization",
889 "lexical_stress_accuracy_percent",
890 "median_f0_hz",
891 "monophthongization_percent",
892 "npvi_v",
893 "perceived_age",
894 "rhotic_realization",
895 "roughness",
896 "s_realization",
897 "unstressed_vowel_reduction_percent",
898 "vai",
899 "vocal_gender_presentation",
900 "word_initial_stressed_prevocalic_t_vot_ms",
901 ];
902 value.as_object().is_some_and(|object| {
903 object.len() == FIELDS.len() && FIELDS.iter().all(|field| object.contains_key(*field))
904 })
905}
906
907async fn worker_loop(inner: Weak<Inner>) {
908 loop {
909 let Some(inner) = inner.upgrade() else {
910 return;
911 };
912 let worked = match process_next_recording(&inner).await {
913 Ok(worked) => worked,
914 Err(error) => {
915 tracing::error!(error=%error, "AudioIngress worker iteration failed");
916 false
917 }
918 };
919 drop(inner);
920 tokio::time::sleep(if worked {
921 Duration::from_millis(100)
922 } else {
923 Duration::from_secs(5)
924 })
925 .await;
926 }
927}
928
929#[derive(Debug)]
930struct WorkRecording {
931 id: Uuid,
932 user_id: String,
933 sha256: String,
934 original_filename: String,
935 size_bytes: u64,
936 recorded_at: DateTime<Utc>,
937 original_relative_path: String,
938 attempt_count: i64,
939 correction_packet_json: Option<String>,
940}
941
942async fn process_next_recording(inner: &Inner) -> anyhow::Result<bool> {
943 let recording = {
944 let db = inner
945 .db
946 .lock()
947 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
948 fetch_work_recording(&db)?
949 };
950 let Some(recording) = recording else {
951 return Ok(false);
952 };
953 poll_transcription(inner, recording).await?;
954 Ok(true)
955}
956
957fn fetch_work_recording(db: &Connection) -> anyhow::Result<Option<WorkRecording>> {
958 db.query_row(
959 "SELECT id,user_id,sha256,original_filename,size_bytes,source_created_at,
960 original_relative_path,attempt_count,correction_packet_json
961 FROM audio_recordings
962 WHERE status IN ('uploaded','chunking','transcribing','reconciling')
963 AND (next_attempt_at IS NULL OR datetime(next_attempt_at)<=datetime('now'))
964 ORDER BY datetime(received_at),id
965 LIMIT 1",
966 [],
967 |row| {
968 Ok((
969 row.get::<_, String>(0)?,
970 row.get::<_, String>(1)?,
971 row.get::<_, String>(2)?,
972 row.get::<_, String>(3)?,
973 row.get::<_, i64>(4)?,
974 row.get::<_, String>(5)?,
975 row.get::<_, String>(6)?,
976 row.get::<_, i64>(7)?,
977 row.get::<_, Option<String>>(8)?,
978 ))
979 },
980 )
981 .optional()?
982 .map(
983 |(
984 id,
985 user_id,
986 sha256,
987 original_filename,
988 size_bytes,
989 recorded_at,
990 original_relative_path,
991 attempt_count,
992 correction_packet_json,
993 )| {
994 Ok(WorkRecording {
995 id: Uuid::parse_str(&id)?,
996 user_id,
997 sha256,
998 original_filename,
999 size_bytes: u64::try_from(size_bytes).context("stored audio size is negative")?,
1000 recorded_at: DateTime::parse_from_rfc3339(&recorded_at)
1001 .context("stored recording time is invalid")?
1002 .with_timezone(&Utc),
1003 original_relative_path,
1004 attempt_count,
1005 correction_packet_json,
1006 })
1007 },
1008 )
1009 .transpose()
1010}
1011
1012async fn poll_transcription(inner: &Inner, mut recording: WorkRecording) -> anyhow::Result<()> {
1013 let existing = inner
1014 .jobs
1015 .lock()
1016 .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
1017 .get(&recording.id)
1018 .cloned();
1019 let job = if let Some(job) = existing {
1020 job
1021 } else {
1022 if recording.attempt_count >= FAILURE_LIMIT {
1023 mark_failed(
1024 inner,
1025 recording.id,
1026 recording.attempt_count,
1027 true,
1028 "Audio transcription exhausted its five automatic attempts.",
1029 None,
1030 )?;
1031 return Ok(());
1032 }
1033 let final_packet = recording
1034 .correction_packet_json
1035 .as_deref()
1036 .map(StoredCorrectionPacket::decode)
1037 .transpose()?
1038 .map(|stored| stored.packet)
1039 .filter(|packet| packet.confirmation_state == ConfirmationState::Confirmed);
1040 if let Some(packet) = final_packet {
1041 recording.attempt_count += 1;
1042 {
1043 let db = inner
1044 .db
1045 .lock()
1046 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1047 db.execute(
1048 "UPDATE audio_recordings
1049 SET status='reconciling',attempt_count=?1,next_attempt_at=NULL,
1050 last_error=NULL,failure_retryable=1,updated_at=?2
1051 WHERE id=?3",
1052 params![
1053 recording.attempt_count,
1054 Utc::now().to_rfc3339(),
1055 recording.id.to_string()
1056 ],
1057 )?;
1058 }
1059 let job = inner
1060 .transcriber
1061 .finalize_durably(recording.user_id.clone(), packet);
1062 inner
1063 .jobs
1064 .lock()
1065 .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
1066 .insert(recording.id, job.clone());
1067 job
1068 } else {
1069 let source = inner.root.join(&recording.original_relative_path);
1070 let audio = tokio::fs::read(&source)
1071 .await
1072 .with_context(|| format!("reading retained audio {}", source.display()))?;
1073 recording.attempt_count += 1;
1074 {
1075 let db = inner
1076 .db
1077 .lock()
1078 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1079 db.execute(
1080 "UPDATE audio_recordings
1081 SET status='chunking',attempt_count=?1,next_attempt_at=NULL,last_error=NULL,
1082 failure_retryable=1,updated_at=?2
1083 WHERE id=?3",
1084 params![
1085 recording.attempt_count,
1086 Utc::now().to_rfc3339(),
1087 recording.id.to_string()
1088 ],
1089 )?;
1090 }
1091
1092 let classification = ClassificationContext {
1093 recording_id: recording.id,
1094 user_id: recording.user_id.clone(),
1095 sha256: recording.sha256.clone(),
1096 original_filename: recording.original_filename.clone(),
1097 size_bytes: recording.size_bytes,
1098 recorded_at: recording.recorded_at,
1099 classifier: inner.classifier.clone(),
1100 };
1101
1102 let cache_db = inner.db.clone();
1103 let cache_recording_id = recording.id;
1104 let piece_cache: PieceCache = Arc::new(move |plan| {
1105 load_cached_transcript_piece(&cache_db, cache_recording_id, plan)
1106 });
1107
1108 let sink_db = inner.db.clone();
1109 let sink_recording_id = recording.id;
1110 let attempt_id = Uuid::new_v4().to_string();
1111 let piece_sink: PieceSink = Arc::new(move |plan, raw| {
1112 persist_transcript_piece(&sink_db, sink_recording_id, &attempt_id, plan, raw)
1113 });
1114
1115 let job = inner.transcriber.transcribe_durably(
1116 recording.user_id.clone(),
1117 audio,
1118 piece_cache,
1119 piece_sink,
1120 classification,
1121 );
1122 inner
1123 .jobs
1124 .lock()
1125 .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
1126 .insert(recording.id, job.clone());
1127 job
1128 }
1129 };
1130
1131 let snapshot = job.status();
1132 persist_progress(inner, recording.id, &snapshot)?;
1133 match snapshot.state {
1134 JobState::Queued | JobState::Running => Ok(()),
1135 JobState::Completed => {
1136 let progress = serde_json::to_string(&without_results(snapshot.clone()))?;
1137 let db = inner
1138 .db
1139 .lock()
1140 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1141 if let Some(transcript) = snapshot.transcript.as_deref() {
1142 ensure!(
1143 !transcript.trim().is_empty(),
1144 "completed transcript is empty"
1145 );
1146 db.execute(
1147 "UPDATE audio_recordings
1148 SET status='ready_for_ingress',final_transcript=?1,
1149 transcription_status_json=?2,next_attempt_at=NULL,last_error=NULL,
1150 failure_retryable=1,updated_at=?3 WHERE id=?4",
1151 params![
1152 transcript.trim(),
1153 progress,
1154 Utc::now().to_rfc3339(),
1155 recording.id.to_string()
1156 ],
1157 )?;
1158 tracing::info!(recording_id=%recording.id, "Final audio transcript completed");
1159 } else {
1160 let packet = snapshot
1161 .correction_packet
1162 .as_ref()
1163 .context("completed analysis omitted its correction packet")?;
1164 ensure!(
1165 packet.recording_id == recording.id,
1166 "correction packet belongs to another recording"
1167 );
1168 db.execute(
1169 "UPDATE audio_recordings
1170 SET status='ready_for_ingress',final_transcript=NULL,
1171 correction_packet_json=?1,transcription_status_json=?2,
1172 next_attempt_at=NULL,last_error=NULL,failure_retryable=1,updated_at=?3
1173 WHERE id=?4",
1174 params![
1175 serde_json::to_string(packet)?,
1176 progress,
1177 Utc::now().to_rfc3339(),
1178 recording.id.to_string()
1179 ],
1180 )?;
1181 tracing::info!(recording_id=%recording.id, "Audio chunks are ready for speaker review");
1182 }
1183 remove_job(inner, recording.id)?;
1184 Ok(())
1185 }
1186 JobState::Failed => {
1187 let error = snapshot
1188 .steps
1189 .iter()
1190 .find(|step| step.state == StepState::Failed)
1191 .and_then(|step| step.error.as_ref());
1192 let message = error
1193 .map(|error| error.message.clone())
1194 .unwrap_or_else(|| "Audio transcription failed without detail.".into());
1195 let retryable = error.is_none_or(|error| error.retryable);
1196 record_attempt_failure(
1197 inner,
1198 recording.id,
1199 recording.attempt_count,
1200 retryable,
1201 &message,
1202 Some(snapshot),
1203 )?;
1204 remove_job(inner, recording.id)
1205 }
1206 }
1207}
1208
1209fn record_attempt_failure(
1210 inner: &Inner,
1211 id: Uuid,
1212 attempts: i64,
1213 retryable: bool,
1214 message: &str,
1215 progress: Option<TranscriptionStatus>,
1216) -> anyhow::Result<()> {
1217 if !retryable || attempts >= FAILURE_LIMIT {
1218 return mark_failed(inner, id, attempts, retryable, message, progress);
1219 }
1220 let db = inner
1221 .db
1222 .lock()
1223 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1224 db.execute(
1225 "UPDATE audio_recordings
1226 SET status=CASE
1227 WHEN status='reconciling' AND correction_packet_json IS NOT NULL
1228 AND final_transcript IS NULL THEN 'reconciling'
1229 ELSE 'uploaded'
1230 END,
1231 next_attempt_at=?1,last_error=?2,failure_retryable=1,
1232 transcription_status_json=?3,updated_at=?4
1233 WHERE id=?5",
1234 params![
1235 (Utc::now() + ChronoDuration::seconds(RETRY_DELAY_SECONDS)).to_rfc3339(),
1236 concise(message, 2_000),
1237 progress
1238 .map(without_results)
1239 .map(|progress| serde_json::to_string(&progress))
1240 .transpose()?,
1241 Utc::now().to_rfc3339(),
1242 id.to_string()
1243 ],
1244 )?;
1245 tracing::warn!(recording_id=%id, attempt=attempts, "Audio transcription will retry");
1246 Ok(())
1247}
1248
1249fn mark_failed(
1250 inner: &Inner,
1251 id: Uuid,
1252 attempts: i64,
1253 retryable: bool,
1254 message: &str,
1255 progress: Option<TranscriptionStatus>,
1256) -> anyhow::Result<()> {
1257 let db = inner
1258 .db
1259 .lock()
1260 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1261 db.execute(
1262 "UPDATE audio_recordings
1263 SET status='failed',attempt_count=?1,next_attempt_at=NULL,last_error=?2,
1264 failure_retryable=?3,transcription_status_json=?4,updated_at=?5
1265 WHERE id=?6",
1266 params![
1267 attempts,
1268 concise(message, 2_000),
1269 i64::from(retryable),
1270 progress
1271 .map(without_results)
1272 .map(|progress| serde_json::to_string(&progress))
1273 .transpose()?,
1274 Utc::now().to_rfc3339(),
1275 id.to_string()
1276 ],
1277 )?;
1278 tracing::error!(recording_id=%id, attempts, retryable, "Audio transcription stopped");
1279 Ok(())
1280}
1281
1282fn persist_progress(inner: &Inner, id: Uuid, snapshot: &TranscriptionStatus) -> anyhow::Result<()> {
1283 let durable_status = transcription_stage(snapshot);
1284 let serialized = serde_json::to_string(&without_results(snapshot.clone()))?;
1285 let db = inner
1286 .db
1287 .lock()
1288 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1289 db.execute(
1290 "UPDATE audio_recordings
1291 SET status=?1,transcription_status_json=?2,updated_at=?3
1292 WHERE id=?4 AND (status<>?1 OR COALESCE(transcription_status_json,'')<>?2)",
1293 params![
1294 durable_status,
1295 serialized,
1296 Utc::now().to_rfc3339(),
1297 id.to_string()
1298 ],
1299 )?;
1300 Ok(())
1301}
1302
1303fn load_cached_transcript_piece(
1304 db: &Mutex<Connection>,
1305 recording_id: Uuid,
1306 plan: ChunkPlan,
1307) -> anyhow::Result<Option<String>> {
1308 let piece_index = i64::try_from(plan.index).context("piece index exceeds SQLite limits")?;
1309 let piece_count = i64::try_from(plan.total).context("piece count exceeds SQLite limits")?;
1310 let audio_start_ms =
1311 i64::try_from(plan.start_ms).context("piece start exceeds SQLite limits")?;
1312 let audio_end_ms = i64::try_from(plan.end_ms).context("piece end exceeds SQLite limits")?;
1313 let stored = {
1314 let db = db
1315 .lock()
1316 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1317 db.query_row(
1318 "SELECT raw_gemini_response
1319 FROM audio_transcript_pieces
1320 WHERE recording_id=?1
1321 AND cache_revision=?2
1322 AND piece_index=?3
1323 AND piece_count=?4
1324 AND audio_start_ms=?5
1325 AND audio_end_ms=?6
1326 ORDER BY datetime(created_at) DESC,attempt_id DESC
1327 LIMIT 1",
1328 params![
1329 recording_id.to_string(),
1330 PIECE_CACHE_REVISION,
1331 piece_index,
1332 piece_count,
1333 audio_start_ms,
1334 audio_end_ms,
1335 ],
1336 |row| row.get::<_, String>(0),
1337 )
1338 .optional()?
1339 };
1340 let Some(raw_gemini_response) = stored else {
1341 return Ok(None);
1342 };
1343 ensure!(
1344 !raw_gemini_response.trim().is_empty(),
1345 "cached piece omitted its raw Gemini response"
1346 );
1347 Ok(Some(raw_gemini_response))
1348}
1349
1350fn persist_transcript_piece(
1351 db: &Mutex<Connection>,
1352 recording_id: Uuid,
1353 attempt_id: &str,
1354 plan: ChunkPlan,
1355 raw_gemini_response: &str,
1356) -> anyhow::Result<()> {
1357 ensure!(!attempt_id.is_empty(), "piece attempt identity is empty");
1358 let piece_index = i64::try_from(plan.index).context("piece index exceeds SQLite limits")?;
1359 let piece_count = i64::try_from(plan.total).context("piece count exceeds SQLite limits")?;
1360 let audio_start_ms =
1361 i64::try_from(plan.start_ms).context("piece start exceeds SQLite limits")?;
1362 let audio_end_ms = i64::try_from(plan.end_ms).context("piece end exceeds SQLite limits")?;
1363 let db = db
1364 .lock()
1365 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1366 db.execute(
1367 "INSERT INTO audio_transcript_pieces(
1368 recording_id,attempt_id,cache_revision,piece_index,piece_count,
1369 audio_start_ms,audio_end_ms,transcript_json,raw_gemini_response,
1370 parsed_json,created_at
1371 ) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?8,?10)
1372 ON CONFLICT(recording_id,attempt_id,piece_index) DO UPDATE SET
1373 cache_revision=excluded.cache_revision,
1374 piece_count=excluded.piece_count,
1375 audio_start_ms=excluded.audio_start_ms,
1376 audio_end_ms=excluded.audio_end_ms,
1377 transcript_json=excluded.transcript_json,
1378 raw_gemini_response=excluded.raw_gemini_response,
1379 parsed_json=excluded.parsed_json",
1380 params![
1381 recording_id.to_string(),
1382 attempt_id,
1383 PIECE_CACHE_REVISION,
1384 piece_index,
1385 piece_count,
1386 audio_start_ms,
1387 audio_end_ms,
1388 raw_gemini_response,
1389 raw_gemini_response,
1390 Utc::now().to_rfc3339(),
1391 ],
1392 )?;
1393 Ok(())
1394}
1395
1396fn remove_job(inner: &Inner, id: Uuid) -> anyhow::Result<()> {
1397 inner
1398 .jobs
1399 .lock()
1400 .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
1401 .remove(&id);
1402 Ok(())
1403}
1404
1405fn transcription_stage(snapshot: &TranscriptionStatus) -> &'static str {
1406 if snapshot.steps.len() == 1 && snapshot.steps[0].step == Step::ReconcileTranscript {
1407 return "reconciling";
1408 }
1409 let plan_complete = snapshot
1410 .steps
1411 .iter()
1412 .any(|entry| entry.step == Step::PlanChunks && entry.state == StepState::Completed);
1413 if !plan_complete {
1414 return "chunking";
1415 }
1416 let chunks_complete = snapshot
1417 .steps
1418 .iter()
1419 .filter(|entry| matches!(entry.step, Step::TranscribeChunk { .. }))
1420 .all(|entry| entry.state == StepState::Completed);
1421 if chunks_complete {
1422 "reconciling"
1423 } else {
1424 "transcribing"
1425 }
1426}
1427
1428fn without_results(mut status: TranscriptionStatus) -> TranscriptionStatus {
1429 status.transcript = None;
1430 status.correction_packet = None;
1431 status
1432}
1433
1434fn initial_progress() -> TranscriptionStatus {
1435 TranscriptionStatus {
1436 state: JobState::Queued,
1437 steps: Vec::new(),
1438 transcript: None,
1439 correction_packet: None,
1440 }
1441}
1442
1443fn recover_interrupted_attempts(connection: &Connection) -> rusqlite::Result<()> {
1444 connection.execute(
1445 "UPDATE audio_recordings
1446 SET status=CASE
1447 WHEN status='reconciling' AND correction_packet_json IS NOT NULL
1448 AND final_transcript IS NULL AND attempt_count<?1 THEN 'reconciling'
1449 WHEN attempt_count>=?1 THEN 'failed'
1450 ELSE 'uploaded'
1451 END,
1452 next_attempt_at=NULL,
1453 last_error=CASE WHEN attempt_count>=?1
1454 THEN 'Audio transcription stopped after its fifth attempt was interrupted.'
1455 ELSE 'Audio transcription was interrupted and will restart automatically.'
1456 END,
1457 failure_retryable=1,
1458 updated_at=?2
1459 WHERE status IN ('chunking','transcribing','reconciling')",
1460 params![FAILURE_LIMIT, Utc::now().to_rfc3339()],
1461 )?;
1462 Ok(())
1463}
1464
1465fn apply_migrations(connection: &Connection) -> anyhow::Result<()> {
1466 let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?;
1467 ensure!(
1468 version <= LATEST_SCHEMA_VERSION,
1469 "audio-ingress database schema version {version} is newer than supported version {LATEST_SCHEMA_VERSION}"
1470 );
1471 if version == 0 {
1472 let has_recordings = connection.query_row(
1473 "SELECT EXISTS(
1474 SELECT 1 FROM sqlite_schema
1475 WHERE type='table' AND name='audio_recordings'
1476 )",
1477 [],
1478 |row| row.get::<_, i64>(0),
1479 )? == 1;
1480 if !has_recordings {
1481 connection.execute_batch(FRESH_SCHEMA)?;
1482 return Ok(());
1483 }
1484 }
1485 if version < 1 {
1486 connection.execute_batch(INITIAL_MIGRATION)?;
1487 }
1488 if version < 2 {
1489 connection.execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)?;
1490 }
1491 if version < 3 {
1492 connection.execute_batch(TRANSCRIPTION_STATUS_MIGRATION)?;
1493 }
1494 if version < 4 {
1495 connection.execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)?;
1496 }
1497 if version < 5 {
1498 connection.execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)?;
1499 }
1500 if version < 6 {
1501 connection.execute_batch(STANDALONE_LIBRARY_MIGRATION)?;
1502 }
1503 if version < 7 {
1504 connection.execute_batch(DURABLE_TRANSCRIPT_PIECES_MIGRATION)?;
1505 }
1506 if version < 8 {
1507 connection.execute_batch(UNIQUE_TRANSCRIPTION_ATTEMPTS_MIGRATION)?;
1508 }
1509 if version < 9 {
1510 connection.execute_batch(USAGE_USER_MIGRATION)?;
1511 }
1512 if version < 10 {
1513 connection.execute_batch(SPEAKER_CORRECTION_PACKETS_MIGRATION)?;
1514 }
1515 if version < 11 {
1516 connection.execute_batch(LEGACY_REVIEW_ARCHIVE_MIGRATION)?;
1517 }
1518 Ok(())
1519}
1520
1521fn ensure_private_directory(path: &Path) -> anyhow::Result<()> {
1522 fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?;
1523 #[cfg(unix)]
1524 {
1525 use std::os::unix::fs::PermissionsExt;
1526 fs::set_permissions(path, fs::Permissions::from_mode(0o700))
1527 .with_context(|| format!("setting private permissions on {}", path.display()))?;
1528 }
1529 Ok(())
1530}
1531
1532fn set_private_file(path: &Path) -> anyhow::Result<()> {
1533 #[cfg(unix)]
1534 {
1535 use std::os::unix::fs::PermissionsExt;
1536 fs::set_permissions(path, fs::Permissions::from_mode(0o600))
1537 .with_context(|| format!("setting private permissions on {}", path.display()))?;
1538 }
1539 Ok(())
1540}
1541
1542fn sync_file(path: &Path) -> anyhow::Result<()> {
1543 fs::OpenOptions::new()
1544 .read(true)
1545 .write(true)
1546 .open(path)
1547 .with_context(|| format!("opening {} for sync", path.display()))?
1548 .sync_all()
1549 .with_context(|| format!("syncing {}", path.display()))
1550}
1551
1552fn sync_directory(path: &Path) -> anyhow::Result<()> {
1553 #[cfg(unix)]
1554 fs::File::open(path)
1555 .with_context(|| format!("opening directory {} for sync", path.display()))?
1556 .sync_all()
1557 .with_context(|| format!("syncing directory {}", path.display()))?;
1558 Ok(())
1559}
1560
1561fn safe_filename(value: Option<&str>) -> String {
1562 let name = value
1563 .and_then(|value| Path::new(value).file_name())
1564 .and_then(|value| value.to_str())
1565 .unwrap_or("audio.wav");
1566 let clean = name
1567 .chars()
1568 .map(|character| {
1569 if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
1570 character
1571 } else {
1572 '_'
1573 }
1574 })
1575 .take(200)
1576 .collect::<String>();
1577 if clean.is_empty() {
1578 "audio.wav".into()
1579 } else {
1580 clean
1581 }
1582}
1583
1584fn concise(value: &str, limit: usize) -> String {
1585 let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
1586 let bounded = normalized.chars().take(limit).collect::<String>();
1587 if bounded.is_empty() {
1588 "Audio transcription failed without an error message.".into()
1589 } else {
1590 bounded
1591 }
1592}