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