Skip to main content

kcode_audio_ingress/
lib.rs

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