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, ConfirmationState, CorrectionChunk, CorrectionObservation, CorrectionPacket,
31    FeatureRow, ObservationConfirmation, ObservationKey, ParsedChunk, ParsedSpeaker,
32    ParsedUtterance, RecordingConfirmation,
33};
34use identity::{
35    ClassificationContext, apply_confirmations, classify_speakers, restore_packet_training,
36    validate_parsed_chunk,
37};
38pub use transcribe::{
39    AudioChunkCall, AudioChunkRequest, AudioTranscriber, IntelligenceError, IntelligenceFuture,
40    JobState, RECONCILIATION_MODEL, RECONCILIATION_REASONING, Step, StepError, StepState,
41    StepStatus, TRANSCRIPTION_MODEL, TextGenerationCall, TextGenerationRequest, TranscriptionJob,
42    TranscriptionStatus,
43};
44use transcribe::{ChunkPlan, ChunkTranscript, PIECE_CACHE_REVISION, PieceCache, PieceSink};
45
46mod identity;
47mod transcribe;
48
49const INITIAL_MIGRATION: &str = include_str!("../migrations/001_initial.sql");
50const RELEASE_DEFERRED_INGRESS_MIGRATION: &str =
51    include_str!("../migrations/002_release_deferred_ingress.sql");
52const TRANSCRIPTION_STATUS_MIGRATION: &str =
53    include_str!("../migrations/003_transcription_status.sql");
54const RETRY_ROUNDED_WAV_INTERVALS_MIGRATION: &str =
55    include_str!("../migrations/004_retry_rounded_wav_intervals.sql");
56const UNIFIED_INGRESS_QUEUE_MIGRATION: &str =
57    include_str!("../migrations/005_unified_ingress_queue.sql");
58const STANDALONE_LIBRARY_MIGRATION: &str = include_str!("../migrations/006_standalone_library.sql");
59const DURABLE_TRANSCRIPT_PIECES_MIGRATION: &str =
60    include_str!("../migrations/007_durable_transcript_pieces.sql");
61const UNIQUE_TRANSCRIPTION_ATTEMPTS_MIGRATION: &str =
62    include_str!("../migrations/008_unique_transcription_attempts.sql");
63const USAGE_USER_MIGRATION: &str = include_str!("../migrations/009_usage_user.sql");
64const SPEAKER_CORRECTION_PACKETS_MIGRATION: &str =
65    include_str!("../migrations/010_speaker_correction_packets.sql");
66
67const DATABASE_FILENAME: &str = "state.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 with a shared classifier and starts automatic processing.
309    ///
310    /// Ingress state is stored at `<root>/state.sqlite3`, originals remain
311    /// below `<root>/originals/`, and classifier state is owned by the
312    /// caller-supplied shared classifier.
313    pub async fn open(
314        persistence_root: impl AsRef<Path>,
315        transcriber: AudioTranscriber,
316        classifier: Arc<SpeechClassifier>,
317    ) -> Result<Self, Error> {
318        let root = persistence_root.as_ref().to_path_buf();
319        ensure_private_directory(&root).map_err(Error::internal)?;
320        ensure_private_directory(&root.join(ORIGINALS_DIRECTORY)).map_err(Error::internal)?;
321
322        let connection = Connection::open(root.join(DATABASE_FILENAME)).map_err(Error::internal)?;
323        connection
324            .execute_batch(
325                "PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA busy_timeout=15000;",
326            )
327            .map_err(Error::internal)?;
328        apply_migrations(&connection).map_err(Error::internal)?;
329        recover_interrupted_attempts(&connection).map_err(Error::internal)?;
330
331        let inner = Arc::new(Inner {
332            root,
333            db: Arc::new(Mutex::new(connection)),
334            classifier,
335            transcriber,
336            jobs: Mutex::new(HashMap::new()),
337        });
338        tokio::spawn(worker_loop(Arc::downgrade(&inner)));
339        Ok(Self { inner })
340    }
341
342    /// Durably accepts complete WAV bytes and schedules automatic processing.
343    pub async fn submit(&self, input: AudioInput) -> Result<Submission, Error> {
344        if input.user_id.trim().is_empty() || input.user_id.chars().count() > 256 {
345            return Err(Error::invalid(
346                "User ID must contain between 1 and 256 characters.",
347            ));
348        }
349        if input.bytes.is_empty() {
350            return Err(Error::invalid("Audio bytes must not be empty."));
351        }
352        let size_bytes = i64::try_from(input.bytes.len())
353            .map_err(|_| Error::invalid("Audio is too large for this platform."))?;
354        let sha256 = format!("{:x}", Sha256::digest(&input.bytes));
355        if let Some(id) = self.recording_id_by_sha(&sha256)? {
356            return Ok(Submission {
357                recording_id: id,
358                deduplicated: true,
359            });
360        }
361
362        let upload_id = Uuid::new_v4();
363        let temporary = TemporaryUpload(self.inner.root.join(format!(".upload-{upload_id}.tmp")));
364        let mut file = tokio::fs::OpenOptions::new()
365            .write(true)
366            .create_new(true)
367            .open(&temporary.0)
368            .await
369            .map_err(Error::internal)?;
370        set_private_file(&temporary.0).map_err(Error::internal)?;
371        file.write_all(&input.bytes)
372            .await
373            .map_err(Error::internal)?;
374        file.sync_all().await.map_err(Error::internal)?;
375        drop(file);
376
377        let relative_path = format!("{ORIGINALS_DIRECTORY}/{sha256}.wav");
378        let final_path = self.inner.root.join(&relative_path);
379        if final_path.exists() {
380            tokio::fs::remove_file(&temporary.0)
381                .await
382                .map_err(Error::internal)?;
383        } else {
384            tokio::fs::rename(&temporary.0, &final_path)
385                .await
386                .map_err(Error::internal)?;
387        }
388        set_private_file(&final_path).map_err(Error::internal)?;
389        sync_file(&final_path).map_err(Error::internal)?;
390        sync_directory(final_path.parent().unwrap_or(&self.inner.root)).map_err(Error::internal)?;
391        sync_directory(&self.inner.root).map_err(Error::internal)?;
392
393        let id = Uuid::new_v4();
394        let now = Utc::now().to_rfc3339();
395        let filename = safe_filename(input.original_filename.as_deref());
396        let insert = {
397            let db = self.inner.db.lock().map_err(Error::internal)?;
398            db.execute(
399                "INSERT INTO audio_recordings(
400                    id,user_id,sha256,original_filename,content_type,size_bytes,
401                    source_created_at,received_at,updated_at,original_relative_path,
402                    status,gemini_model,reconciliation_model,reconciliation_reasoning
403                 ) VALUES(?1,?2,?3,?4,'audio/wav',?5,?6,?7,?7,?8,'uploaded',?9,?10,?11)",
404                params![
405                    id.to_string(),
406                    input.user_id,
407                    sha256,
408                    filename,
409                    size_bytes,
410                    input.recorded_at.to_rfc3339(),
411                    now,
412                    relative_path,
413                    TRANSCRIPTION_MODEL,
414                    RECONCILIATION_MODEL,
415                    RECONCILIATION_REASONING,
416                ],
417            )
418        };
419        if let Err(error) = insert {
420            if let Some(existing) = self.recording_id_by_sha(&sha256)? {
421                return Ok(Submission {
422                    recording_id: existing,
423                    deduplicated: true,
424                });
425            }
426            return Err(Error::internal(error));
427        }
428        tracing::info!(recording_id=%id, %sha256, bytes=size_bytes, "Durably accepted audio");
429        Ok(Submission {
430            recording_id: id,
431            deduplicated: false,
432        })
433    }
434
435    /// Returns all current recording states and completed correction packets.
436    pub fn status(&self) -> Result<Status, Error> {
437        let db = self.inner.db.lock().map_err(Error::internal)?;
438        let mut statement = db
439            .prepare(
440                "SELECT id,user_id,sha256,original_filename,size_bytes,source_created_at,received_at,
441                        status,gemini_model,reconciliation_model,reconciliation_reasoning,
442                        attempt_count,last_error,failure_retryable,transcription_status_json,
443                        final_transcript,correction_packet_json
444                 FROM audio_recordings
445                 ORDER BY datetime(source_created_at) DESC,datetime(received_at) DESC,id DESC",
446            )
447            .map_err(Error::internal)?;
448        let recordings = statement
449            .query_map([], row_recording_status)
450            .map_err(Error::internal)?
451            .collect::<Result<Vec<_>, _>>()
452            .map_err(Error::internal)?;
453        Ok(Status { recordings })
454    }
455
456    /// Gives a failed recording a fresh five-attempt processing budget.
457    pub fn retry(&self, recording_id: Uuid) -> Result<(), Error> {
458        let db = self.inner.db.lock().map_err(Error::internal)?;
459        let changed = db
460            .execute(
461                "UPDATE audio_recordings
462                 SET status='uploaded',attempt_count=0,next_attempt_at=NULL,last_error=NULL,
463                     transcription_status_json=NULL,failure_retryable=1,updated_at=?1
464                 WHERE id=?2 AND status='failed'",
465                params![Utc::now().to_rfc3339(), recording_id.to_string()],
466            )
467            .map_err(Error::internal)?;
468        if changed == 1 {
469            return Ok(());
470        }
471        let exists = db
472            .query_row(
473                "SELECT 1 FROM audio_recordings WHERE id=?1",
474                [recording_id.to_string()],
475                |row| row.get::<_, i64>(0),
476            )
477            .optional()
478            .map_err(Error::internal)?
479            .is_some();
480        if exists {
481            Err(Error::conflict("Only a failed recording can be retried."))
482        } else {
483            Err(Error::not_found())
484        }
485    }
486
487    /// Applies exact observation-level full-name confirmations to one completed recording.
488    ///
489    /// The confirmation must cover every deterministic observation key in the
490    /// packet exactly once. Classifier updates are idempotent. The corrected
491    /// packet is committed before this method returns and is also visible
492    /// through [`AudioIngress::status`].
493    pub fn confirm_speakers(
494        &self,
495        confirmation: RecordingConfirmation,
496    ) -> Result<CorrectionPacket, Error> {
497        let recording_id = confirmation.recording_id;
498        let db = self.inner.db.lock().map_err(Error::internal)?;
499        let stored = db
500            .query_row(
501                "SELECT status,correction_packet_json
502                 FROM audio_recordings WHERE id=?1",
503                [recording_id.to_string()],
504                |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
505            )
506            .optional()
507            .map_err(Error::internal)?;
508        let Some((status, packet_json)) = stored else {
509            return Err(Error::not_found());
510        };
511        if !matches!(
512            status.as_str(),
513            "ready_for_ingress" | "ingressing" | "ingress_failed" | "complete"
514        ) {
515            return Err(Error::conflict(
516                "Speaker confirmation requires a completed recording.",
517            ));
518        }
519        let packet_json = packet_json
520            .ok_or_else(|| Error::conflict("The completed recording has no correction packet."))?;
521        let mut stored = StoredCorrectionPacket::decode(&packet_json).map_err(Error::internal)?;
522        identity::validate_confirmation_coverage(&stored.packet, &confirmation)
523            .map_err(Error::invalid)?;
524        let legacy_observation_keys = stored.legacy_observation_keys().map_err(Error::internal)?;
525
526        let previous = stored.packet.clone();
527        apply_confirmations(
528            &self.inner.classifier,
529            &mut stored.packet,
530            &confirmation,
531            &legacy_observation_keys,
532        )
533        .map_err(Error::internal)?;
534        let updated_json = stored.encode().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(
546                &self.inner.classifier,
547                &previous,
548                &legacy_observation_keys,
549            );
550            if !rollback_errors.is_empty() {
551                tracing::error!(
552                    recording_id=%recording_id,
553                    errors=?rollback_errors,
554                    "Could not fully restore classifier state after packet persistence failed"
555                );
556            }
557            return Err(Error::internal(error));
558        }
559        Ok(stored.packet)
560    }
561
562    fn recording_id_by_sha(&self, sha256: &str) -> Result<Option<Uuid>, Error> {
563        let db = self.inner.db.lock().map_err(Error::internal)?;
564        db.query_row(
565            "SELECT id FROM audio_recordings WHERE sha256=?1",
566            [sha256],
567            |row| row.get::<_, String>(0),
568        )
569        .optional()
570        .map_err(Error::internal)?
571        .map(|id| Uuid::parse_str(&id).map_err(Error::internal))
572        .transpose()
573    }
574}
575
576fn row_recording_status(row: &rusqlite::Row<'_>) -> rusqlite::Result<RecordingStatus> {
577    let id: String = row.get(0)?;
578    let recorded_at: String = row.get(5)?;
579    let received_at: String = row.get(6)?;
580    let durable_status: String = row.get(7)?;
581    let attempts: i64 = row.get(11)?;
582    let last_error: Option<String> = row.get(12)?;
583    let retryable: i64 = row.get(13)?;
584    let progress_json: Option<String> = row.get(14)?;
585    let transcript: Option<String> = row.get(15)?;
586    let correction_packet_json: Option<String> = row.get(16)?;
587    let parse_time = |index, value: &str| {
588        DateTime::parse_from_rfc3339(value)
589            .map(|value| value.with_timezone(&Utc))
590            .map_err(|error| {
591                rusqlite::Error::FromSqlConversionFailure(
592                    index,
593                    rusqlite::types::Type::Text,
594                    Box::new(error),
595                )
596            })
597    };
598    let state = match durable_status.as_str() {
599        "uploaded" => RecordingState::Queued,
600        "chunking" | "transcribing" | "reconciling" => {
601            let progress = progress_json
602                .as_deref()
603                .map(serde_json::from_str)
604                .transpose()
605                .map_err(|error| {
606                    rusqlite::Error::FromSqlConversionFailure(
607                        14,
608                        rusqlite::types::Type::Text,
609                        Box::new(error),
610                    )
611                })?
612                .unwrap_or_else(initial_progress);
613            RecordingState::Processing {
614                attempt: attempts.clamp(0, i64::from(u8::MAX)) as u8,
615                progress: without_results(progress),
616            }
617        }
618        "ready_for_ingress" | "ingressing" | "ingress_failed" | "complete" => {
619            RecordingState::Complete {
620                transcript: transcript.unwrap_or_default(),
621            }
622        }
623        "failed" => RecordingState::Failed {
624            attempts: attempts.clamp(0, i64::from(u8::MAX)) as u8,
625            error: last_error.unwrap_or_else(|| "Audio processing failed.".into()),
626            retryable: retryable != 0,
627        },
628        other => {
629            return Err(rusqlite::Error::FromSqlConversionFailure(
630                7,
631                rusqlite::types::Type::Text,
632                format!("unknown audio status {other:?}").into(),
633            ));
634        }
635    };
636    let correction_packet = correction_packet_json
637        .as_deref()
638        .map(StoredCorrectionPacket::decode)
639        .transpose()
640        .map_err(|error| {
641            rusqlite::Error::FromSqlConversionFailure(
642                16,
643                rusqlite::types::Type::Text,
644                Box::new(error),
645            )
646        })?
647        .map(|stored| stored.packet);
648    Ok(RecordingStatus {
649        id: Uuid::parse_str(&id).map_err(|error| {
650            rusqlite::Error::FromSqlConversionFailure(
651                0,
652                rusqlite::types::Type::Text,
653                Box::new(error),
654            )
655        })?,
656        user_id: row.get(1)?,
657        sha256: row.get(2)?,
658        original_filename: row.get(3)?,
659        size_bytes: u64::try_from(row.get::<_, i64>(4)?).map_err(|error| {
660            rusqlite::Error::FromSqlConversionFailure(
661                4,
662                rusqlite::types::Type::Integer,
663                Box::new(error),
664            )
665        })?,
666        recorded_at: parse_time(5, &recorded_at)?,
667        received_at: parse_time(6, &received_at)?,
668        transcription_model: row.get(8)?,
669        reconciliation_model: row.get(9)?,
670        reconciliation_reasoning: row.get(10)?,
671        state,
672        correction_packet,
673    })
674}
675
676#[derive(Clone, Debug)]
677struct LegacyFeatureRow {
678    chunk_position: usize,
679    speaker_position: usize,
680    value: Value,
681}
682
683#[derive(Clone, Debug)]
684struct StoredCorrectionPacket {
685    packet: CorrectionPacket,
686    legacy_feature_rows: Vec<LegacyFeatureRow>,
687}
688
689impl StoredCorrectionPacket {
690    fn decode(serialized: &str) -> serde_json::Result<Self> {
691        match serde_json::from_str(serialized) {
692            Ok(packet) => Ok(Self {
693                packet,
694                legacy_feature_rows: Vec::new(),
695            }),
696            Err(current_error) => {
697                let mut value: Value = serde_json::from_str(serialized)?;
698                let Some(chunks) = value.get_mut("chunks").and_then(Value::as_array_mut) else {
699                    return Err(current_error);
700                };
701                let mut legacy_feature_rows = Vec::new();
702                for (chunk_position, chunk) in chunks.iter_mut().enumerate() {
703                    let Some(speakers) = chunk
704                        .pointer_mut("/parsed/speakers")
705                        .and_then(Value::as_array_mut)
706                    else {
707                        continue;
708                    };
709                    for (speaker_position, speaker) in speakers.iter_mut().enumerate() {
710                        let Some(feature_row) = speaker.get_mut("feature_row") else {
711                            continue;
712                        };
713                        if is_legacy_feature_row(feature_row) {
714                            legacy_feature_rows.push(LegacyFeatureRow {
715                                chunk_position,
716                                speaker_position,
717                                value: feature_row.take(),
718                            });
719                        }
720                    }
721                }
722                if legacy_feature_rows.is_empty() {
723                    return Err(current_error);
724                }
725                let packet = serde_json::from_value(value)?;
726                Ok(Self {
727                    packet,
728                    legacy_feature_rows,
729                })
730            }
731        }
732    }
733
734    fn encode(&self) -> serde_json::Result<String> {
735        let mut value = serde_json::to_value(&self.packet)?;
736        for legacy in &self.legacy_feature_rows {
737            let feature_row = value
738                .get_mut("chunks")
739                .and_then(Value::as_array_mut)
740                .and_then(|chunks| chunks.get_mut(legacy.chunk_position))
741                .and_then(|chunk| chunk.pointer_mut("/parsed/speakers"))
742                .and_then(Value::as_array_mut)
743                .and_then(|speakers| speakers.get_mut(legacy.speaker_position))
744                .and_then(|speaker| speaker.get_mut("feature_row"))
745                .expect("decoded correction packet retains its speaker positions");
746            *feature_row = legacy.value.clone();
747        }
748        serde_json::to_string(&value)
749    }
750
751    fn legacy_observation_keys(&self) -> anyhow::Result<HashSet<(String, u32)>> {
752        let mut keys = HashSet::new();
753        for legacy in &self.legacy_feature_rows {
754            let chunk = self
755                .packet
756                .chunks
757                .get(legacy.chunk_position)
758                .context("legacy feature row is outside its correction packet")?;
759            let speaker = chunk
760                .parsed
761                .speakers
762                .get(legacy.speaker_position)
763                .context("legacy feature row is outside its parsed speakers")?;
764            let observation = chunk
765                .observations
766                .iter()
767                .find(|observation| {
768                    observation.speaker_ordinal as usize == legacy.speaker_position
769                        && observation.local_label == speaker.local_label
770                })
771                .context("legacy feature row has no matching correction observation")?;
772            ensure!(
773                keys.insert((
774                    observation.observation_key.object_id.clone(),
775                    observation.observation_key.piece_index,
776                )),
777                "legacy correction packet repeats an observation key"
778            );
779        }
780        Ok(keys)
781    }
782}
783
784fn is_legacy_feature_row(value: &Value) -> bool {
785    const FIELDS: [&str; 24] = [
786        "accent_variety",
787        "articulation_rate_syllables_per_second",
788        "breathiness",
789        "cefr",
790        "consonant_cluster_reduction_percent",
791        "creaky_phonation_percent",
792        "f0_pitch_span_semitones",
793        "filled_pauses_per_100_words",
794        "foreign_accentedness",
795        "formant_dispersion_hz",
796        "hypernasality",
797        "lateral_realization",
798        "lexical_stress_accuracy_percent",
799        "median_f0_hz",
800        "monophthongization_percent",
801        "npvi_v",
802        "perceived_age",
803        "rhotic_realization",
804        "roughness",
805        "s_realization",
806        "unstressed_vowel_reduction_percent",
807        "vai",
808        "vocal_gender_presentation",
809        "word_initial_stressed_prevocalic_t_vot_ms",
810    ];
811    value.as_object().is_some_and(|object| {
812        object.len() == FIELDS.len() && FIELDS.iter().all(|field| object.contains_key(*field))
813    })
814}
815
816async fn worker_loop(inner: Weak<Inner>) {
817    loop {
818        let Some(inner) = inner.upgrade() else {
819            return;
820        };
821        let worked = match process_next_recording(&inner).await {
822            Ok(worked) => worked,
823            Err(error) => {
824                tracing::error!(error=%error, "AudioIngress worker iteration failed");
825                false
826            }
827        };
828        drop(inner);
829        tokio::time::sleep(if worked {
830            Duration::from_millis(100)
831        } else {
832            Duration::from_secs(5)
833        })
834        .await;
835    }
836}
837
838#[derive(Debug)]
839struct WorkRecording {
840    id: Uuid,
841    user_id: String,
842    sha256: String,
843    original_filename: String,
844    size_bytes: u64,
845    recorded_at: DateTime<Utc>,
846    original_relative_path: String,
847    attempt_count: i64,
848}
849
850async fn process_next_recording(inner: &Inner) -> anyhow::Result<bool> {
851    let recording = {
852        let db = inner
853            .db
854            .lock()
855            .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
856        fetch_work_recording(&db)?
857    };
858    let Some(recording) = recording else {
859        return Ok(false);
860    };
861    poll_transcription(inner, recording).await?;
862    Ok(true)
863}
864
865fn fetch_work_recording(db: &Connection) -> anyhow::Result<Option<WorkRecording>> {
866    db.query_row(
867        "SELECT id,user_id,sha256,original_filename,size_bytes,source_created_at,
868                original_relative_path,attempt_count
869         FROM audio_recordings
870         WHERE status IN ('uploaded','chunking','transcribing','reconciling')
871           AND (next_attempt_at IS NULL OR datetime(next_attempt_at)<=datetime('now'))
872         ORDER BY datetime(received_at),id
873         LIMIT 1",
874        [],
875        |row| {
876            Ok((
877                row.get::<_, String>(0)?,
878                row.get::<_, String>(1)?,
879                row.get::<_, String>(2)?,
880                row.get::<_, String>(3)?,
881                row.get::<_, i64>(4)?,
882                row.get::<_, String>(5)?,
883                row.get::<_, String>(6)?,
884                row.get::<_, i64>(7)?,
885            ))
886        },
887    )
888    .optional()?
889    .map(
890        |(
891            id,
892            user_id,
893            sha256,
894            original_filename,
895            size_bytes,
896            recorded_at,
897            original_relative_path,
898            attempt_count,
899        )| {
900            Ok(WorkRecording {
901                id: Uuid::parse_str(&id)?,
902                user_id,
903                sha256,
904                original_filename,
905                size_bytes: u64::try_from(size_bytes).context("stored audio size is negative")?,
906                recorded_at: DateTime::parse_from_rfc3339(&recorded_at)
907                    .context("stored recording time is invalid")?
908                    .with_timezone(&Utc),
909                original_relative_path,
910                attempt_count,
911            })
912        },
913    )
914    .transpose()
915}
916
917async fn poll_transcription(inner: &Inner, mut recording: WorkRecording) -> anyhow::Result<()> {
918    let existing = inner
919        .jobs
920        .lock()
921        .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
922        .get(&recording.id)
923        .cloned();
924    let job = if let Some(job) = existing {
925        job
926    } else {
927        if recording.attempt_count >= FAILURE_LIMIT {
928            mark_failed(
929                inner,
930                recording.id,
931                recording.attempt_count,
932                true,
933                "Audio transcription exhausted its five automatic attempts.",
934                None,
935            )?;
936            return Ok(());
937        }
938        let source = inner.root.join(&recording.original_relative_path);
939        let audio = tokio::fs::read(&source)
940            .await
941            .with_context(|| format!("reading retained audio {}", source.display()))?;
942        recording.attempt_count += 1;
943        {
944            let db = inner
945                .db
946                .lock()
947                .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
948            db.execute(
949                "UPDATE audio_recordings
950                 SET status='chunking',attempt_count=?1,next_attempt_at=NULL,last_error=NULL,
951                     failure_retryable=1,updated_at=?2
952                 WHERE id=?3",
953                params![
954                    recording.attempt_count,
955                    Utc::now().to_rfc3339(),
956                    recording.id.to_string()
957                ],
958            )?;
959        }
960
961        let classification = ClassificationContext {
962            recording_id: recording.id,
963            user_id: recording.user_id.clone(),
964            sha256: recording.sha256.clone(),
965            original_filename: recording.original_filename.clone(),
966            size_bytes: recording.size_bytes,
967            recorded_at: recording.recorded_at,
968            classifier: inner.classifier.clone(),
969        };
970
971        let cache_db = inner.db.clone();
972        let cache_recording_id = recording.id;
973        let cache_classification = classification.clone();
974        let piece_cache: PieceCache = Arc::new(move |plan| {
975            load_cached_transcript_piece(&cache_db, cache_recording_id, plan, &cache_classification)
976        });
977
978        let sink_db = inner.db.clone();
979        let sink_recording_id = recording.id;
980        let attempt_id = Uuid::new_v4().to_string();
981        let piece_sink: PieceSink = Arc::new(move |piece| {
982            persist_transcript_piece(&sink_db, sink_recording_id, &attempt_id, piece)
983        });
984
985        let job = inner.transcriber.transcribe_durably(
986            recording.user_id.clone(),
987            audio,
988            piece_cache,
989            piece_sink,
990            classification,
991        );
992        inner
993            .jobs
994            .lock()
995            .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
996            .insert(recording.id, job.clone());
997        job
998    };
999
1000    let snapshot = job.status();
1001    persist_progress(inner, recording.id, &snapshot)?;
1002    match snapshot.state {
1003        JobState::Queued | JobState::Running => Ok(()),
1004        JobState::Completed => {
1005            let transcript = snapshot
1006                .transcript
1007                .clone()
1008                .context("completed transcription omitted its transcript")?;
1009            ensure!(
1010                !transcript.trim().is_empty(),
1011                "completed transcription is empty"
1012            );
1013            let packet = snapshot
1014                .correction_packet
1015                .clone()
1016                .context("completed durable transcription omitted its correction packet")?;
1017            ensure!(
1018                packet.recording_id == recording.id,
1019                "completed correction packet belongs to another recording"
1020            );
1021            let packet_json = serde_json::to_string(&packet)?;
1022            let progress = serde_json::to_string(&without_results(snapshot))?;
1023            {
1024                let db = inner
1025                    .db
1026                    .lock()
1027                    .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1028                db.execute(
1029                    "UPDATE audio_recordings
1030                     SET status='ready_for_ingress',final_transcript=?1,
1031                         correction_packet_json=?2,transcription_status_json=?3,
1032                         next_attempt_at=NULL,last_error=NULL,failure_retryable=1,updated_at=?4
1033                     WHERE id=?5",
1034                    params![
1035                        transcript.trim(),
1036                        packet_json,
1037                        progress,
1038                        Utc::now().to_rfc3339(),
1039                        recording.id.to_string()
1040                    ],
1041                )?;
1042            }
1043            remove_job(inner, recording.id)?;
1044            tracing::info!(
1045                recording_id=%recording.id,
1046                clean=packet.clean,
1047                "Audio transcript and correction packet completed"
1048            );
1049            Ok(())
1050        }
1051        JobState::Failed => {
1052            let error = snapshot
1053                .steps
1054                .iter()
1055                .find(|step| step.state == StepState::Failed)
1056                .and_then(|step| step.error.as_ref());
1057            let message = error
1058                .map(|error| error.message.clone())
1059                .unwrap_or_else(|| "Audio transcription failed without detail.".into());
1060            let retryable = error.is_none_or(|error| error.retryable);
1061            record_attempt_failure(
1062                inner,
1063                recording.id,
1064                recording.attempt_count,
1065                retryable,
1066                &message,
1067                Some(snapshot),
1068            )?;
1069            remove_job(inner, recording.id)
1070        }
1071    }
1072}
1073
1074fn record_attempt_failure(
1075    inner: &Inner,
1076    id: Uuid,
1077    attempts: i64,
1078    retryable: bool,
1079    message: &str,
1080    progress: Option<TranscriptionStatus>,
1081) -> anyhow::Result<()> {
1082    if !retryable || attempts >= FAILURE_LIMIT {
1083        return mark_failed(inner, id, attempts, retryable, message, progress);
1084    }
1085    let db = inner
1086        .db
1087        .lock()
1088        .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1089    db.execute(
1090        "UPDATE audio_recordings
1091         SET status='uploaded',next_attempt_at=?1,last_error=?2,failure_retryable=1,
1092             transcription_status_json=?3,updated_at=?4
1093         WHERE id=?5",
1094        params![
1095            (Utc::now() + ChronoDuration::seconds(RETRY_DELAY_SECONDS)).to_rfc3339(),
1096            concise(message, 2_000),
1097            progress
1098                .map(without_results)
1099                .map(|progress| serde_json::to_string(&progress))
1100                .transpose()?,
1101            Utc::now().to_rfc3339(),
1102            id.to_string()
1103        ],
1104    )?;
1105    tracing::warn!(recording_id=%id, attempt=attempts, "Audio transcription will retry");
1106    Ok(())
1107}
1108
1109fn mark_failed(
1110    inner: &Inner,
1111    id: Uuid,
1112    attempts: i64,
1113    retryable: bool,
1114    message: &str,
1115    progress: Option<TranscriptionStatus>,
1116) -> anyhow::Result<()> {
1117    let db = inner
1118        .db
1119        .lock()
1120        .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1121    db.execute(
1122        "UPDATE audio_recordings
1123         SET status='failed',attempt_count=?1,next_attempt_at=NULL,last_error=?2,
1124             failure_retryable=?3,transcription_status_json=?4,updated_at=?5
1125         WHERE id=?6",
1126        params![
1127            attempts,
1128            concise(message, 2_000),
1129            i64::from(retryable),
1130            progress
1131                .map(without_results)
1132                .map(|progress| serde_json::to_string(&progress))
1133                .transpose()?,
1134            Utc::now().to_rfc3339(),
1135            id.to_string()
1136        ],
1137    )?;
1138    tracing::error!(recording_id=%id, attempts, retryable, "Audio transcription stopped");
1139    Ok(())
1140}
1141
1142fn persist_progress(inner: &Inner, id: Uuid, snapshot: &TranscriptionStatus) -> anyhow::Result<()> {
1143    let durable_status = transcription_stage(snapshot);
1144    let serialized = serde_json::to_string(&without_results(snapshot.clone()))?;
1145    let db = inner
1146        .db
1147        .lock()
1148        .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1149    db.execute(
1150        "UPDATE audio_recordings
1151         SET status=?1,transcription_status_json=?2,updated_at=?3
1152         WHERE id=?4 AND (status<>?1 OR COALESCE(transcription_status_json,'')<>?2)",
1153        params![
1154            durable_status,
1155            serialized,
1156            Utc::now().to_rfc3339(),
1157            id.to_string()
1158        ],
1159    )?;
1160    Ok(())
1161}
1162
1163fn load_cached_transcript_piece(
1164    db: &Mutex<Connection>,
1165    recording_id: Uuid,
1166    plan: ChunkPlan,
1167    classification: &ClassificationContext,
1168) -> anyhow::Result<Option<ChunkTranscript>> {
1169    ensure!(
1170        recording_id == classification.recording_id,
1171        "cache classification context belongs to another recording"
1172    );
1173    let piece_index = i64::try_from(plan.index).context("piece index exceeds SQLite limits")?;
1174    let piece_count = i64::try_from(plan.total).context("piece count exceeds SQLite limits")?;
1175    let audio_start_ms =
1176        i64::try_from(plan.start_ms).context("piece start exceeds SQLite limits")?;
1177    let audio_end_ms = i64::try_from(plan.end_ms).context("piece end exceeds SQLite limits")?;
1178    let stored = {
1179        let db = db
1180            .lock()
1181            .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1182        db.query_row(
1183            "SELECT raw_gemini_response,parsed_json
1184             FROM audio_transcript_pieces
1185             WHERE recording_id=?1
1186               AND cache_revision=?2
1187               AND piece_index=?3
1188               AND piece_count=?4
1189               AND audio_start_ms=?5
1190               AND audio_end_ms=?6
1191             ORDER BY datetime(created_at) DESC,attempt_id DESC
1192             LIMIT 1",
1193            params![
1194                recording_id.to_string(),
1195                PIECE_CACHE_REVISION,
1196                piece_index,
1197                piece_count,
1198                audio_start_ms,
1199                audio_end_ms,
1200            ],
1201            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
1202        )
1203        .optional()?
1204    };
1205    let Some((raw_gemini_response, parsed_json)) = stored else {
1206        return Ok(None);
1207    };
1208    ensure!(
1209        !raw_gemini_response.trim().is_empty(),
1210        "cached piece omitted its raw Gemini response"
1211    );
1212    let duration_seconds = (plan.end_ms - plan.start_ms) as f64 / 1_000.0;
1213    let mut parsed: ParsedChunk = serde_json::from_str(&parsed_json)
1214        .context("cached parsed speaker analysis is not valid JSON")?;
1215    validate_parsed_chunk(&mut parsed, duration_seconds)
1216        .context("cached parsed speaker analysis is invalid")?;
1217    let (observations, clean) = classify_speakers(classification, plan.index, &parsed)
1218        .context("reclassifying cached speaker rows failed")?;
1219    Ok(Some(ChunkTranscript {
1220        plan,
1221        raw_gemini_response,
1222        parsed,
1223        observations,
1224        clean,
1225    }))
1226}
1227
1228fn persist_transcript_piece(
1229    db: &Mutex<Connection>,
1230    recording_id: Uuid,
1231    attempt_id: &str,
1232    piece: &ChunkTranscript,
1233) -> anyhow::Result<()> {
1234    ensure!(!attempt_id.is_empty(), "piece attempt identity is empty");
1235    let piece_index =
1236        i64::try_from(piece.plan.index).context("piece index exceeds SQLite limits")?;
1237    let piece_count =
1238        i64::try_from(piece.plan.total).context("piece count exceeds SQLite limits")?;
1239    let audio_start_ms =
1240        i64::try_from(piece.plan.start_ms).context("piece start exceeds SQLite limits")?;
1241    let audio_end_ms =
1242        i64::try_from(piece.plan.end_ms).context("piece end exceeds SQLite limits")?;
1243    let parsed_json = serde_json::to_string(&piece.parsed)?;
1244    let db = db
1245        .lock()
1246        .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1247    db.execute(
1248        "INSERT INTO audio_transcript_pieces(
1249            recording_id,attempt_id,cache_revision,piece_index,piece_count,
1250            audio_start_ms,audio_end_ms,transcript_json,raw_gemini_response,
1251            parsed_json,created_at
1252         ) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?8,?10)
1253         ON CONFLICT(recording_id,attempt_id,piece_index) DO UPDATE SET
1254            cache_revision=excluded.cache_revision,
1255            piece_count=excluded.piece_count,
1256            audio_start_ms=excluded.audio_start_ms,
1257            audio_end_ms=excluded.audio_end_ms,
1258            transcript_json=excluded.transcript_json,
1259            raw_gemini_response=excluded.raw_gemini_response,
1260            parsed_json=excluded.parsed_json",
1261        params![
1262            recording_id.to_string(),
1263            attempt_id,
1264            PIECE_CACHE_REVISION,
1265            piece_index,
1266            piece_count,
1267            audio_start_ms,
1268            audio_end_ms,
1269            parsed_json,
1270            piece.raw_gemini_response,
1271            Utc::now().to_rfc3339(),
1272        ],
1273    )?;
1274    Ok(())
1275}
1276
1277fn remove_job(inner: &Inner, id: Uuid) -> anyhow::Result<()> {
1278    inner
1279        .jobs
1280        .lock()
1281        .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
1282        .remove(&id);
1283    Ok(())
1284}
1285
1286fn transcription_stage(snapshot: &TranscriptionStatus) -> &'static str {
1287    let plan_complete = snapshot
1288        .steps
1289        .iter()
1290        .any(|entry| entry.step == Step::PlanChunks && entry.state == StepState::Completed);
1291    if !plan_complete {
1292        return "chunking";
1293    }
1294    let chunks_complete = snapshot
1295        .steps
1296        .iter()
1297        .filter(|entry| {
1298            matches!(
1299                entry.step,
1300                Step::TranscribeChunk { .. } | Step::ParseChunk { .. }
1301            )
1302        })
1303        .all(|entry| entry.state == StepState::Completed);
1304    if chunks_complete {
1305        "reconciling"
1306    } else {
1307        "transcribing"
1308    }
1309}
1310
1311fn without_results(mut status: TranscriptionStatus) -> TranscriptionStatus {
1312    status.transcript = None;
1313    status.correction_packet = None;
1314    status
1315}
1316
1317fn initial_progress() -> TranscriptionStatus {
1318    TranscriptionStatus {
1319        state: JobState::Queued,
1320        steps: Vec::new(),
1321        transcript: None,
1322        correction_packet: None,
1323    }
1324}
1325
1326fn recover_interrupted_attempts(connection: &Connection) -> rusqlite::Result<()> {
1327    connection.execute(
1328        "UPDATE audio_recordings
1329         SET status=CASE WHEN attempt_count>=?1 THEN 'failed' ELSE 'uploaded' END,
1330             next_attempt_at=NULL,
1331             last_error=CASE WHEN attempt_count>=?1
1332                 THEN 'Audio transcription stopped after its fifth attempt was interrupted.'
1333                 ELSE 'Audio transcription was interrupted and will restart automatically.'
1334             END,
1335             failure_retryable=1,
1336             updated_at=?2
1337         WHERE status IN ('chunking','transcribing','reconciling')",
1338        params![FAILURE_LIMIT, Utc::now().to_rfc3339()],
1339    )?;
1340    Ok(())
1341}
1342
1343fn apply_migrations(connection: &Connection) -> anyhow::Result<()> {
1344    let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?;
1345    ensure!(
1346        version <= LATEST_SCHEMA_VERSION,
1347        "audio-ingress database schema version {version} is newer than supported version {LATEST_SCHEMA_VERSION}"
1348    );
1349    if version == 0 {
1350        let has_recordings = connection.query_row(
1351            "SELECT EXISTS(
1352                SELECT 1 FROM sqlite_schema
1353                WHERE type='table' AND name='audio_recordings'
1354             )",
1355            [],
1356            |row| row.get::<_, i64>(0),
1357        )? == 1;
1358        if !has_recordings {
1359            connection.execute_batch(FRESH_SCHEMA)?;
1360            return Ok(());
1361        }
1362    }
1363    if version < 1 {
1364        connection.execute_batch(INITIAL_MIGRATION)?;
1365    }
1366    if version < 2 {
1367        connection.execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)?;
1368    }
1369    if version < 3 {
1370        connection.execute_batch(TRANSCRIPTION_STATUS_MIGRATION)?;
1371    }
1372    if version < 4 {
1373        connection.execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)?;
1374    }
1375    if version < 5 {
1376        connection.execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)?;
1377    }
1378    if version < 6 {
1379        connection.execute_batch(STANDALONE_LIBRARY_MIGRATION)?;
1380    }
1381    if version < 7 {
1382        connection.execute_batch(DURABLE_TRANSCRIPT_PIECES_MIGRATION)?;
1383    }
1384    if version < 8 {
1385        connection.execute_batch(UNIQUE_TRANSCRIPTION_ATTEMPTS_MIGRATION)?;
1386    }
1387    if version < 9 {
1388        connection.execute_batch(USAGE_USER_MIGRATION)?;
1389    }
1390    if version < 10 {
1391        connection.execute_batch(SPEAKER_CORRECTION_PACKETS_MIGRATION)?;
1392    }
1393    Ok(())
1394}
1395
1396fn ensure_private_directory(path: &Path) -> anyhow::Result<()> {
1397    fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?;
1398    #[cfg(unix)]
1399    {
1400        use std::os::unix::fs::PermissionsExt;
1401        fs::set_permissions(path, fs::Permissions::from_mode(0o700))
1402            .with_context(|| format!("setting private permissions on {}", path.display()))?;
1403    }
1404    Ok(())
1405}
1406
1407fn set_private_file(path: &Path) -> anyhow::Result<()> {
1408    #[cfg(unix)]
1409    {
1410        use std::os::unix::fs::PermissionsExt;
1411        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
1412            .with_context(|| format!("setting private permissions on {}", path.display()))?;
1413    }
1414    Ok(())
1415}
1416
1417fn sync_file(path: &Path) -> anyhow::Result<()> {
1418    fs::OpenOptions::new()
1419        .read(true)
1420        .write(true)
1421        .open(path)
1422        .with_context(|| format!("opening {} for sync", path.display()))?
1423        .sync_all()
1424        .with_context(|| format!("syncing {}", path.display()))
1425}
1426
1427fn sync_directory(path: &Path) -> anyhow::Result<()> {
1428    #[cfg(unix)]
1429    fs::File::open(path)
1430        .with_context(|| format!("opening directory {} for sync", path.display()))?
1431        .sync_all()
1432        .with_context(|| format!("syncing directory {}", path.display()))?;
1433    Ok(())
1434}
1435
1436fn safe_filename(value: Option<&str>) -> String {
1437    let name = value
1438        .and_then(|value| Path::new(value).file_name())
1439        .and_then(|value| value.to_str())
1440        .unwrap_or("audio.wav");
1441    let clean = name
1442        .chars()
1443        .map(|character| {
1444            if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
1445                character
1446            } else {
1447                '_'
1448            }
1449        })
1450        .take(200)
1451        .collect::<String>();
1452    if clean.is_empty() {
1453        "audio.wav".into()
1454    } else {
1455        clean
1456    }
1457}
1458
1459fn concise(value: &str, limit: usize) -> String {
1460    let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
1461    let bounded = normalized.chars().take(limit).collect::<String>();
1462    if bounded.is_empty() {
1463        "Audio transcription failed without an error message.".into()
1464    } else {
1465        bounded
1466    }
1467}
1468
1469#[cfg(test)]
1470mod tests {
1471    use super::*;
1472    use std::sync::atomic::{AtomicU64, Ordering};
1473
1474    static NEXT_PATH: AtomicU64 = AtomicU64::new(0);
1475
1476    fn database() -> Connection {
1477        let connection = Connection::open_in_memory().unwrap();
1478        apply_migrations(&connection).unwrap();
1479        connection
1480    }
1481
1482    fn classifier_path(label: &str) -> PathBuf {
1483        std::env::temp_dir().join(format!(
1484            "kcode-audio-ingress-lib-{}-{label}-{}.sqlite3",
1485            std::process::id(),
1486            NEXT_PATH.fetch_add(1, Ordering::Relaxed)
1487        ))
1488    }
1489
1490    fn root_path(label: &str) -> PathBuf {
1491        std::env::temp_dir().join(format!(
1492            "kcode-audio-ingress-lib-{}-{label}-{}",
1493            std::process::id(),
1494            NEXT_PATH.fetch_add(1, Ordering::Relaxed)
1495        ))
1496    }
1497
1498    fn remove_database(path: &Path) {
1499        for suffix in ["", "-wal", "-shm"] {
1500            let mut value = path.as_os_str().to_os_string();
1501            value.push(suffix);
1502            let _ = fs::remove_file(PathBuf::from(value));
1503        }
1504    }
1505
1506    fn transcriber() -> AudioTranscriber {
1507        let audio: AudioChunkCall = Arc::new(|_| {
1508            Box::pin(async { Err(IntelligenceError::new("unexpected audio call", false)) })
1509        });
1510        let text: TextGenerationCall = Arc::new(|_| {
1511            Box::pin(async { Err(IntelligenceError::new("unexpected text call", false)) })
1512        });
1513        AudioTranscriber::new(audio, text)
1514    }
1515
1516    fn insert_recording(connection: &Connection, id: Uuid, status: &str) {
1517        let has_user_id = connection
1518            .prepare("SELECT 1 FROM pragma_table_info('audio_recordings') WHERE name='user_id'")
1519            .unwrap()
1520            .exists([])
1521            .unwrap();
1522        let sql = if has_user_id {
1523            "INSERT INTO audio_recordings(
1524                id,user_id,sha256,original_filename,content_type,size_bytes,source_created_at,
1525                received_at,updated_at,original_relative_path,status,gemini_model,
1526                reconciliation_model,reconciliation_reasoning
1527             ) VALUES(?1,'test-user',?2,'note.wav','audio/wav',4,?3,?3,?3,?4,?5,?6,?7,?8)"
1528        } else {
1529            "INSERT INTO audio_recordings(
1530                id,sha256,original_filename,content_type,size_bytes,source_created_at,
1531                received_at,updated_at,original_relative_path,status,gemini_model,
1532                reconciliation_model,reconciliation_reasoning
1533             ) VALUES(?1,?2,'note.wav','audio/wav',4,?3,?3,?3,?4,?5,?6,?7,?8)"
1534        };
1535        connection
1536            .execute(
1537                sql,
1538                params![
1539                    id.to_string(),
1540                    format!("{:064x}", 1),
1541                    "2026-01-01T00:00:00Z",
1542                    format!("originals/{id}.wav"),
1543                    status,
1544                    TRANSCRIPTION_MODEL,
1545                    RECONCILIATION_MODEL,
1546                    RECONCILIATION_REASONING,
1547                ],
1548            )
1549            .unwrap();
1550    }
1551
1552    fn sample_row() -> FeatureRow {
1553        FeatureRow::new(std::array::from_fn(|index| {
1554            u8::try_from(index * 3 + 10).unwrap()
1555        }))
1556        .unwrap()
1557    }
1558
1559    fn legacy_feature_row() -> Value {
1560        serde_json::json!({
1561            "accent_variety":"general_american",
1562            "articulation_rate_syllables_per_second":4.1,
1563            "breathiness":0.1,
1564            "cefr":"c2",
1565            "consonant_cluster_reduction_percent":0.0,
1566            "creaky_phonation_percent":0.0,
1567            "f0_pitch_span_semitones":8.0,
1568            "filled_pauses_per_100_words":1.0,
1569            "foreign_accentedness":0.0,
1570            "formant_dispersion_hz":1100.0,
1571            "hypernasality":0.0,
1572            "lateral_realization":"standard",
1573            "lexical_stress_accuracy_percent":100.0,
1574            "median_f0_hz":150.0,
1575            "monophthongization_percent":0.0,
1576            "npvi_v":50.0,
1577            "perceived_age":40.0,
1578            "rhotic_realization":"rhotic",
1579            "roughness":0.1,
1580            "s_realization":"standard",
1581            "unstressed_vowel_reduction_percent":50.0,
1582            "vai":1.0,
1583            "vocal_gender_presentation":50.0,
1584            "word_initial_stressed_prevocalic_t_vot_ms":60.0
1585        })
1586    }
1587
1588    fn legacy_packet_json(id: Uuid) -> String {
1589        let packet = CorrectionPacket {
1590            recording_id: id,
1591            user_id: "test-user".into(),
1592            sha256: format!("{:064x}", 1),
1593            original_filename: "note.wav".into(),
1594            size_bytes: 4,
1595            recorded_at: DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
1596                .unwrap()
1597                .with_timezone(&Utc),
1598            clean: false,
1599            chunk_count: 1,
1600            chunks: vec![CorrectionChunk {
1601                chunk_index: 0,
1602                chunk_count: 1,
1603                audio_start_ms: 0,
1604                audio_end_ms: 1_000,
1605                raw_gemini_response: "legacy raw response".into(),
1606                parsed: sample_parsed(),
1607                observations: vec![CorrectionObservation {
1608                    local_label: "Speaker A".into(),
1609                    speaker_ordinal: 0,
1610                    observation_key: ObservationKey {
1611                        object_id: format!("kcode-audio-ingress/recording/{id}/chunk/0"),
1612                        piece_index: 0,
1613                    },
1614                    candidate: None,
1615                    identified_full_name: None,
1616                    confirmed_full_name: None,
1617                }],
1618                clean: false,
1619            }],
1620            confirmation_state: ConfirmationState::Unconfirmed,
1621        };
1622        let mut value = serde_json::to_value(packet).unwrap();
1623        value["chunks"][0]["parsed"]["speakers"][0]["feature_row"] = legacy_feature_row();
1624        serde_json::to_string(&value).unwrap()
1625    }
1626
1627    fn sample_parsed() -> ParsedChunk {
1628        ParsedChunk {
1629            utterances: vec![ParsedUtterance {
1630                speaker: "Speaker A".into(),
1631                language: "eng".into(),
1632                original_text: "Hello.".into(),
1633                english_translation: String::new(),
1634                corrected_natural_text: None,
1635                coaching: Vec::new(),
1636                annotations: Vec::new(),
1637            }],
1638            notes: vec!["Clear recording.".into()],
1639            clip_valid: true,
1640            clip_validity_reason: None,
1641            speakers: vec![ParsedSpeaker {
1642                local_label: "Speaker A".into(),
1643                primary_language: Some("eng".into()),
1644                feature_row: Some(sample_row()),
1645            }],
1646        }
1647    }
1648
1649    fn sample_piece() -> ChunkTranscript {
1650        ChunkTranscript {
1651            plan: ChunkPlan {
1652                index: 0,
1653                total: 2,
1654                start_ms: 0,
1655                end_ms: 120_000,
1656            },
1657            raw_gemini_response: "complete raw Gemini response".into(),
1658            parsed: sample_parsed(),
1659            observations: Vec::new(),
1660            clean: false,
1661        }
1662    }
1663
1664    fn classification_context(
1665        recording_id: Uuid,
1666        classifier: Arc<SpeechClassifier>,
1667    ) -> ClassificationContext {
1668        ClassificationContext {
1669            recording_id,
1670            user_id: "test-user".into(),
1671            sha256: format!("{:064x}", 1),
1672            original_filename: "note.wav".into(),
1673            size_bytes: 4,
1674            recorded_at: DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
1675                .unwrap()
1676                .with_timezone(&Utc),
1677            classifier,
1678        }
1679    }
1680
1681    #[test]
1682    fn fresh_schema_contains_recordings_durable_pieces_and_packet_columns() {
1683        let connection = database();
1684        let tables = connection
1685            .prepare(
1686                "SELECT name FROM sqlite_schema
1687                 WHERE type='table' AND name NOT LIKE 'sqlite_%'
1688                 ORDER BY name",
1689            )
1690            .unwrap()
1691            .query_map([], |row| row.get::<_, String>(0))
1692            .unwrap()
1693            .collect::<Result<Vec<_>, _>>()
1694            .unwrap();
1695        let version: i64 = connection
1696            .query_row("PRAGMA user_version", [], |row| row.get(0))
1697            .unwrap();
1698        let piece_columns = connection
1699            .prepare("SELECT name FROM pragma_table_info('audio_transcript_pieces')")
1700            .unwrap()
1701            .query_map([], |row| row.get::<_, String>(0))
1702            .unwrap()
1703            .collect::<Result<Vec<_>, _>>()
1704            .unwrap();
1705        let recording_columns = connection
1706            .prepare("SELECT name FROM pragma_table_info('audio_recordings')")
1707            .unwrap()
1708            .query_map([], |row| row.get::<_, String>(0))
1709            .unwrap()
1710            .collect::<Result<Vec<_>, _>>()
1711            .unwrap();
1712        assert_eq!(tables, vec!["audio_recordings", "audio_transcript_pieces"]);
1713        assert!(piece_columns.contains(&"raw_gemini_response".into()));
1714        assert!(piece_columns.contains(&"parsed_json".into()));
1715        assert!(recording_columns.contains(&"correction_packet_json".into()));
1716        assert_eq!(version, LATEST_SCHEMA_VERSION);
1717    }
1718
1719    #[tokio::test]
1720    async fn open_uses_the_injected_classifier_without_creating_a_private_database() {
1721        let root = root_path("shared-classifier-root");
1722        let classifier_path = classifier_path("shared-classifier");
1723        let classifier = Arc::new(SpeechClassifier::open(&classifier_path).unwrap());
1724
1725        let ingress = AudioIngress::open(&root, transcriber(), classifier.clone())
1726            .await
1727            .unwrap();
1728
1729        assert!(Arc::ptr_eq(&ingress.inner.classifier, &classifier));
1730        assert!(!root.join("speaker-classification.sqlite3").exists());
1731
1732        drop(ingress);
1733        tokio::task::yield_now().await;
1734        let _ = fs::remove_dir_all(&root);
1735        drop(classifier);
1736        remove_database(&classifier_path);
1737    }
1738
1739    #[test]
1740    fn version_five_databases_upgrade_without_losing_legacy_queue_data() {
1741        let connection = Connection::open_in_memory().unwrap();
1742        connection.execute_batch(INITIAL_MIGRATION).unwrap();
1743        connection
1744            .execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)
1745            .unwrap();
1746        connection
1747            .execute_batch(TRANSCRIPTION_STATUS_MIGRATION)
1748            .unwrap();
1749        connection
1750            .execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)
1751            .unwrap();
1752        connection
1753            .execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)
1754            .unwrap();
1755        let id = Uuid::new_v4();
1756        insert_recording(&connection, id, "failed");
1757
1758        apply_migrations(&connection).unwrap();
1759
1760        let version: i64 = connection
1761            .query_row("PRAGMA user_version", [], |row| row.get(0))
1762            .unwrap();
1763        let retryable: i64 = connection
1764            .query_row(
1765                "SELECT failure_retryable FROM audio_recordings WHERE id=?1",
1766                [id.to_string()],
1767                |row| row.get(0),
1768            )
1769            .unwrap();
1770        let legacy_queue_exists: i64 = connection
1771            .query_row(
1772                "SELECT EXISTS(
1773                    SELECT 1 FROM sqlite_schema
1774                    WHERE type='table' AND name='audio_ingress_pieces'
1775                 )",
1776                [],
1777                |row| row.get(0),
1778            )
1779            .unwrap();
1780        assert_eq!(version, LATEST_SCHEMA_VERSION);
1781        assert_eq!(retryable, 1);
1782        assert_eq!(legacy_queue_exists, 1);
1783    }
1784
1785    #[test]
1786    fn version_seven_piece_rows_migrate_but_cannot_hit_the_new_cache() {
1787        let connection = Connection::open_in_memory().unwrap();
1788        connection.execute_batch(INITIAL_MIGRATION).unwrap();
1789        connection
1790            .execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)
1791            .unwrap();
1792        connection
1793            .execute_batch(TRANSCRIPTION_STATUS_MIGRATION)
1794            .unwrap();
1795        connection
1796            .execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)
1797            .unwrap();
1798        connection
1799            .execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)
1800            .unwrap();
1801        connection
1802            .execute_batch(STANDALONE_LIBRARY_MIGRATION)
1803            .unwrap();
1804        connection
1805            .execute_batch(DURABLE_TRANSCRIPT_PIECES_MIGRATION)
1806            .unwrap();
1807        let id = Uuid::new_v4();
1808        insert_recording(&connection, id, "transcribing");
1809        connection
1810            .execute(
1811                "INSERT INTO audio_transcript_pieces(
1812                    recording_id,attempt,piece_index,piece_count,audio_start_ms,
1813                    audio_end_ms,transcript_json,created_at
1814                 ) VALUES(?1,1,0,2,0,120000,?2,?3)",
1815                params![
1816                    id.to_string(),
1817                    r#"{"utterances":[{"original_text":"hello"}]}"#,
1818                    "2026-01-01T00:00:00Z",
1819                ],
1820            )
1821            .unwrap();
1822
1823        apply_migrations(&connection).unwrap();
1824
1825        let migrated: (String, String, String, String) = connection
1826            .query_row(
1827                "SELECT attempt_id,cache_revision,raw_gemini_response,parsed_json
1828                 FROM audio_transcript_pieces
1829                 WHERE recording_id=?1 AND piece_index=0",
1830                [id.to_string()],
1831                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1832            )
1833            .unwrap();
1834        assert_eq!(migrated.0, "version-7-attempt-1");
1835        assert_eq!(migrated.1, "gemini-3.1-pro-transcription-v1");
1836        assert_ne!(migrated.1, PIECE_CACHE_REVISION);
1837        assert_eq!(migrated.2, "");
1838        assert_eq!(migrated.3, "");
1839    }
1840
1841    #[test]
1842    fn status_exposes_a_completed_correction_packet() {
1843        let connection = database();
1844        let id = Uuid::new_v4();
1845        insert_recording(&connection, id, "ready_for_ingress");
1846        let packet = CorrectionPacket {
1847            recording_id: id,
1848            user_id: "test-user".into(),
1849            sha256: format!("{:064x}", 1),
1850            original_filename: "note.wav".into(),
1851            size_bytes: 4,
1852            recorded_at: DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
1853                .unwrap()
1854                .with_timezone(&Utc),
1855            clean: false,
1856            chunk_count: 1,
1857            chunks: Vec::new(),
1858            confirmation_state: ConfirmationState::Unconfirmed,
1859        };
1860        connection
1861            .execute(
1862                "UPDATE audio_recordings
1863                 SET final_transcript='hello',correction_packet_json=?1 WHERE id=?2",
1864                params![serde_json::to_string(&packet).unwrap(), id.to_string()],
1865            )
1866            .unwrap();
1867        let status = connection
1868            .query_row(
1869                "SELECT id,user_id,sha256,original_filename,size_bytes,source_created_at,received_at,
1870                        status,gemini_model,reconciliation_model,reconciliation_reasoning,
1871                        attempt_count,last_error,failure_retryable,transcription_status_json,
1872                        final_transcript,correction_packet_json
1873                 FROM audio_recordings WHERE id=?1",
1874                [id.to_string()],
1875                row_recording_status,
1876            )
1877            .unwrap();
1878        assert!(matches!(
1879            status.state,
1880            RecordingState::Complete { ref transcript } if transcript == "hello"
1881        ));
1882        assert_eq!(status.correction_packet.unwrap().recording_id, id);
1883    }
1884
1885    #[test]
1886    fn legacy_correction_packets_remain_reviewable_without_translating_features() {
1887        let connection = database();
1888        let id = Uuid::new_v4();
1889        insert_recording(&connection, id, "ready_for_ingress");
1890        let serialized = legacy_packet_json(id);
1891        connection
1892            .execute(
1893                "UPDATE audio_recordings
1894                 SET final_transcript='hello',correction_packet_json=?1 WHERE id=?2",
1895                params![serialized, id.to_string()],
1896            )
1897            .unwrap();
1898
1899        let status = connection
1900            .query_row(
1901                "SELECT id,user_id,sha256,original_filename,size_bytes,source_created_at,received_at,
1902                        status,gemini_model,reconciliation_model,reconciliation_reasoning,
1903                        attempt_count,last_error,failure_retryable,transcription_status_json,
1904                        final_transcript,correction_packet_json
1905                 FROM audio_recordings WHERE id=?1",
1906                [id.to_string()],
1907                row_recording_status,
1908            )
1909            .unwrap();
1910        let packet = status.correction_packet.unwrap();
1911        assert_eq!(packet.confirmation_state, ConfirmationState::Unconfirmed);
1912        assert_eq!(packet.chunks[0].observations.len(), 1);
1913        assert!(packet.chunks[0].parsed.speakers[0].feature_row.is_none());
1914    }
1915
1916    #[test]
1917    fn legacy_confirmation_preserves_obsolete_features_without_training_them() {
1918        use kcode_speaker_system::DeleteOutcome;
1919
1920        let id = Uuid::new_v4();
1921        let original = legacy_packet_json(id);
1922        let mut stored = StoredCorrectionPacket::decode(&original).unwrap();
1923        let keys = stored.legacy_observation_keys().unwrap();
1924        let observation_key = stored.packet.chunks[0].observations[0]
1925            .observation_key
1926            .clone();
1927        let confirmation = RecordingConfirmation {
1928            recording_id: id,
1929            observations: vec![ObservationConfirmation {
1930                observation_key: observation_key.clone(),
1931                confirmed_full_name: "David Example".into(),
1932            }],
1933        };
1934        let path = classifier_path("legacy-confirmation");
1935        let classifier = SpeechClassifier::open(&path).unwrap();
1936
1937        apply_confirmations(&classifier, &mut stored.packet, &confirmation, &keys).unwrap();
1938        let encoded = stored.encode().unwrap();
1939        let encoded_value: Value = serde_json::from_str(&encoded).unwrap();
1940        assert_eq!(
1941            encoded_value.pointer("/chunks/0/parsed/speakers/0/feature_row"),
1942            Some(&legacy_feature_row())
1943        );
1944        let decoded = StoredCorrectionPacket::decode(&encoded).unwrap().packet;
1945        assert_eq!(decoded.confirmation_state, ConfirmationState::Confirmed);
1946        assert_eq!(
1947            decoded.chunks[0].observations[0]
1948                .confirmed_full_name
1949                .as_deref(),
1950            Some("David Example")
1951        );
1952        assert_eq!(
1953            classifier.delete(observation_key).unwrap(),
1954            DeleteOutcome::NotFound
1955        );
1956
1957        drop(classifier);
1958        remove_database(&path);
1959    }
1960
1961    #[test]
1962    fn arbitrary_feature_objects_are_not_treated_as_legacy_packets() {
1963        let id = Uuid::new_v4();
1964        let mut value: Value = serde_json::from_str(&legacy_packet_json(id)).unwrap();
1965        value["chunks"][0]["parsed"]["speakers"][0]["feature_row"] =
1966            serde_json::json!({"unknown":1});
1967        assert!(StoredCorrectionPacket::decode(&value.to_string()).is_err());
1968    }
1969
1970    #[test]
1971    fn transcript_pieces_preserve_raw_and_parsed_data_attempt_scoped() {
1972        let connection = database();
1973        let id = Uuid::new_v4();
1974        insert_recording(&connection, id, "transcribing");
1975        let db = Mutex::new(connection);
1976        let piece = sample_piece();
1977        persist_transcript_piece(&db, id, "attempt-a", &piece).unwrap();
1978        persist_transcript_piece(&db, id, "attempt-b", &piece).unwrap();
1979
1980        let db = db.lock().unwrap();
1981        let rows: i64 = db
1982            .query_row(
1983                "SELECT COUNT(*) FROM audio_transcript_pieces WHERE recording_id=?1",
1984                [id.to_string()],
1985                |row| row.get(0),
1986            )
1987            .unwrap();
1988        let stored: (String, String) = db
1989            .query_row(
1990                "SELECT raw_gemini_response,parsed_json
1991                 FROM audio_transcript_pieces
1992                 WHERE recording_id=?1 AND attempt_id='attempt-a' AND piece_index=0",
1993                [id.to_string()],
1994                |row| Ok((row.get(0)?, row.get(1)?)),
1995            )
1996            .unwrap();
1997        assert_eq!(rows, 2);
1998        assert_eq!(stored.0, piece.raw_gemini_response);
1999        assert_eq!(
2000            serde_json::from_str::<ParsedChunk>(&stored.1).unwrap(),
2001            piece.parsed
2002        );
2003    }
2004
2005    #[test]
2006    fn cache_reuses_only_exact_matching_new_revision_piece_plans() {
2007        let connection = database();
2008        let id = Uuid::new_v4();
2009        insert_recording(&connection, id, "transcribing");
2010        let db = Mutex::new(connection);
2011        let piece = sample_piece();
2012        persist_transcript_piece(&db, id, "attempt-a", &piece).unwrap();
2013
2014        let path = classifier_path("cache");
2015        let classifier = Arc::new(SpeechClassifier::open(&path).unwrap());
2016        let context = classification_context(id, classifier);
2017        let exact = load_cached_transcript_piece(&db, id, piece.plan, &context).unwrap();
2018        let changed = load_cached_transcript_piece(
2019            &db,
2020            id,
2021            ChunkPlan {
2022                end_ms: piece.plan.end_ms + 1,
2023                ..piece.plan
2024            },
2025            &context,
2026        )
2027        .unwrap();
2028
2029        assert_eq!(
2030            exact.unwrap().raw_gemini_response,
2031            piece.raw_gemini_response
2032        );
2033        assert!(changed.is_none());
2034        drop(context);
2035        remove_database(&path);
2036    }
2037
2038    #[test]
2039    fn future_schema_versions_are_rejected() {
2040        let connection = Connection::open_in_memory().unwrap();
2041        connection
2042            .execute_batch("PRAGMA user_version = 11;")
2043            .unwrap();
2044        let error = apply_migrations(&connection).unwrap_err().to_string();
2045        assert!(error.contains("newer than supported"));
2046    }
2047
2048    #[test]
2049    fn interrupted_attempts_consume_the_fixed_budget() {
2050        let connection = database();
2051        let id = Uuid::new_v4();
2052        insert_recording(&connection, id, "transcribing");
2053        connection
2054            .execute(
2055                "UPDATE audio_recordings SET attempt_count=5 WHERE id=?1",
2056                [id.to_string()],
2057            )
2058            .unwrap();
2059        recover_interrupted_attempts(&connection).unwrap();
2060        let state: String = connection
2061            .query_row(
2062                "SELECT status FROM audio_recordings WHERE id=?1",
2063                [id.to_string()],
2064                |row| row.get(0),
2065            )
2066            .unwrap();
2067        assert_eq!(state, "failed");
2068    }
2069
2070    #[test]
2071    fn filenames_cannot_escape_the_persistence_root() {
2072        assert_eq!(safe_filename(Some("../../secret.wav")), "secret.wav");
2073        assert_eq!(safe_filename(Some("meeting note.wav")), "meeting_note.wav");
2074        assert_eq!(safe_filename(None), "audio.wav");
2075    }
2076}