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