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