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