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