Skip to main content

kcode_audio_transcript_plan/
lib.rs

1#![forbid(unsafe_code)]
2
3use kcode_audio_ingress::{ConfirmationState, RecordingState, RecordingStatus};
4use serde_json::{Value, json};
5use uuid::Uuid;
6
7const ESTIMATED_CHARACTERS_PER_TOKEN: u64 = 4;
8const INGRESS_CONTEXT_DIVISOR: u64 = 4;
9
10/// A bounded category and library-owned diagnostic for planning failures.
11#[derive(Clone, Debug, Eq, PartialEq)]
12pub enum Error {
13    InvalidInput(&'static str),
14    Conflict(&'static str),
15    Internal(&'static str),
16}
17
18impl std::fmt::Display for Error {
19    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        let message = match self {
21            Self::InvalidInput(message) | Self::Conflict(message) | Self::Internal(message) => {
22                message
23            }
24        };
25        formatter.write_str(message)
26    }
27}
28
29impl std::error::Error for Error {}
30
31/// The complete deterministic piece plan for one recording status.
32#[derive(Debug, Eq, PartialEq)]
33pub struct TranscriptPlan {
34    pieces: Vec<TranscriptPiece>,
35}
36
37impl TranscriptPlan {
38    /// Plans a completed transcript using one quarter of the effective context.
39    ///
40    /// A recording that is not complete produces an empty plan.
41    pub fn new(recording: &RecordingStatus, effective_context_tokens: u64) -> Result<Self, Error> {
42        let maximum_piece_characters = maximum_piece_characters(effective_context_tokens)?;
43        let RecordingState::Complete { transcript } = &recording.state else {
44            return Ok(Self { pieces: Vec::new() });
45        };
46        let texts = split_transcript(transcript, maximum_piece_characters)?;
47        let count = u32::try_from(texts.len())
48            .map_err(|_| Error::Internal("audio transcript contains too many pieces"))?;
49        let pieces = texts
50            .into_iter()
51            .enumerate()
52            .map(|(index, text)| {
53                let index = u32::try_from(index)
54                    .map_err(|_| Error::Internal("audio transcript piece index exceeds u32"))?;
55                Ok(TranscriptPiece {
56                    id: audio_piece_id(recording.id, index),
57                    index,
58                    count,
59                    estimated_tokens: estimate_tokens(&text),
60                    text,
61                })
62            })
63            .collect::<Result<_, Error>>()?;
64        Ok(Self { pieces })
65    }
66
67    /// Returns the ordered, complete piece plan.
68    pub fn pieces(&self) -> &[TranscriptPiece] {
69        &self.pieces
70    }
71}
72
73/// One deterministic transcript-only piece.
74///
75/// Fields are readable by consumers, while construction remains owned by
76/// [`TranscriptPlan`].
77#[derive(Debug, Eq, PartialEq)]
78#[non_exhaustive]
79pub struct TranscriptPiece {
80    pub id: String,
81    pub index: u32,
82    pub count: u32,
83    pub text: String,
84    pub estimated_tokens: u64,
85}
86
87impl TranscriptPiece {
88    /// Reproduces the existing audio ingress metadata for this piece.
89    pub fn metadata(&self, recording: &RecordingStatus) -> Value {
90        json!({
91            "kind":"audio-transcript",
92            "recordingId":recording.id.to_string(),
93            "sha256":recording.sha256,
94            "originalFilename":recording.original_filename,
95            "extension":file_name_extension(&recording.original_filename),
96            "mimeType":"audio/wav",
97            "sizeBytes":recording.size_bytes,
98            "sourceCreatedAt":recording.recorded_at.to_rfc3339(),
99            "pieceIndex":self.index,
100            "pieceCount":self.count,
101            "speakerConfirmationState":recording.correction_packet.as_ref().map(|packet| match packet.confirmation_state {
102                ConfirmationState::Unconfirmed => "unconfirmed",
103                ConfirmationState::AutomaticallyTrained => "automatically_trained",
104                ConfirmationState::Confirmed => "confirmed",
105            }),
106        })
107    }
108
109    /// Reproduces the existing History input for this piece.
110    pub fn formatted_text(&self, recording: &RecordingStatus) -> Result<String, Error> {
111        let speaker_mapping = confirmed_speaker_mapping(recording)?;
112        Ok(format!(
113            "Vnote final transcript piece\n\nRecording began: {}\nRecording SHA-256: {}\nOriginal filename: {}\nExtension: {}\nMIME type: audio/wav\nSize: {} bytes\nTranscript piece: {} of {}{}\n\n{}",
114            recording.recorded_at.to_rfc3339(),
115            recording.sha256,
116            recording.original_filename,
117            file_name_extension(&recording.original_filename),
118            recording.size_bytes,
119            self.index + 1,
120            self.count,
121            speaker_mapping,
122            self.text,
123        ))
124    }
125}
126
127/// Parses only canonical `audio:<uuid>:<u32>` piece identities.
128pub fn parse_audio_piece_id(value: &str) -> Option<(Uuid, u32)> {
129    let mut components = value.split(':');
130    if components.next()? != "audio" {
131        return None;
132    }
133    let recording_id = Uuid::parse_str(components.next()?).ok()?;
134    let piece_index = components.next()?.parse::<u32>().ok()?;
135    if components.next().is_some() || audio_piece_id(recording_id, piece_index) != value {
136        return None;
137    }
138    Some((recording_id, piece_index))
139}
140
141fn audio_piece_id(recording_id: Uuid, piece_index: u32) -> String {
142    format!("audio:{recording_id}:{piece_index}")
143}
144
145fn maximum_piece_characters(effective_context_tokens: u64) -> Result<usize, Error> {
146    let piece_tokens = effective_context_tokens / INGRESS_CONTEXT_DIVISOR;
147    if piece_tokens == 0 {
148        return Err(Error::InvalidInput(
149            "effective ingress context must contain at least four tokens",
150        ));
151    }
152    let characters = piece_tokens
153        .checked_mul(ESTIMATED_CHARACTERS_PER_TOKEN)
154        .ok_or(Error::InvalidInput(
155            "effective ingress context is too large",
156        ))?;
157    usize::try_from(characters)
158        .map_err(|_| Error::InvalidInput("effective ingress context exceeds platform limits"))
159}
160
161fn split_transcript(
162    transcript: &str,
163    maximum_piece_characters: usize,
164) -> Result<Vec<String>, Error> {
165    let mut remaining = transcript.trim();
166    if remaining.is_empty() {
167        return Err(Error::Internal("completed audio transcript is empty"));
168    }
169
170    let mut pieces = Vec::new();
171    while remaining.chars().count() > maximum_piece_characters {
172        let cutoff = remaining
173            .char_indices()
174            .nth(maximum_piece_characters)
175            .map(|(index, _)| index)
176            .unwrap_or(remaining.len());
177        let prefix = &remaining[..cutoff];
178        let minimum = prefix
179            .char_indices()
180            .nth(maximum_piece_characters / 2)
181            .map(|(index, _)| index)
182            .unwrap_or(0);
183        let boundary = prefix
184            .rfind("\n\n")
185            .filter(|index| *index >= minimum)
186            .or_else(|| prefix.rfind('\n').filter(|index| *index >= minimum))
187            .unwrap_or(cutoff);
188        let piece = remaining[..boundary].trim();
189        if piece.is_empty() {
190            return Err(Error::Internal("could not split audio transcript"));
191        }
192        pieces.push(piece.to_owned());
193        remaining = remaining[boundary..].trim();
194    }
195    if !remaining.is_empty() {
196        pieces.push(remaining.to_owned());
197    }
198    Ok(pieces)
199}
200
201fn estimate_tokens(value: &str) -> u64 {
202    (value.chars().count() as u64).div_ceil(ESTIMATED_CHARACTERS_PER_TOKEN)
203}
204
205fn confirmed_speaker_mapping(recording: &RecordingStatus) -> Result<String, Error> {
206    let Some(packet) = recording.correction_packet.as_ref() else {
207        return Ok(String::new());
208    };
209    if packet.confirmation_state != ConfirmationState::Confirmed {
210        return Err(Error::Conflict(
211            "audio speaker labels require exact human confirmation before ingress",
212        ));
213    }
214
215    let mut lines = Vec::new();
216    for chunk in &packet.chunks {
217        for observation in &chunk.observations {
218            let confirmed = observation
219                .confirmed_full_name
220                .as_deref()
221                .ok_or(Error::Internal(
222                    "confirmed audio packet omitted an observation label",
223                ))?;
224            let local_label = serde_json::to_string(&observation.local_label)
225                .map_err(|_| Error::Internal("could not render confirmed audio speaker mapping"))?;
226            let confirmed = serde_json::to_string(confirmed)
227                .map_err(|_| Error::Internal("could not render confirmed audio speaker mapping"))?;
228            let candidate = observation
229                .identified_full_name
230                .as_deref()
231                .map(serde_json::to_string)
232                .transpose()
233                .map_err(|_| Error::Internal("could not render confirmed audio speaker mapping"))?
234                .unwrap_or_else(|| "null".into());
235            lines.push(format!(
236                "- chunk {}/{}, source {:.3}-{:.3}s: localLabel={}, confirmedFullName={}, classifierCandidate={}",
237                chunk.chunk_index + 1,
238                chunk.chunk_count,
239                chunk.audio_start_ms as f64 / 1_000.0,
240                chunk.audio_end_ms as f64 / 1_000.0,
241                local_label,
242                confirmed,
243                candidate,
244            ));
245        }
246    }
247    if lines.is_empty() {
248        return Err(Error::Internal(
249            "confirmed audio packet contains no speaker observations",
250        ));
251    }
252    Ok(format!(
253        "\n\nHuman-confirmed speaker-label data (authoritative; do not infer alternatives):\n{}",
254        lines.join("\n")
255    ))
256}
257
258fn file_name_extension(file_name: &str) -> String {
259    file_name
260        .rsplit_once('.')
261        .and_then(|(stem, extension)| {
262            (!stem.is_empty() && !extension.is_empty()).then_some(extension)
263        })
264        .map(|extension| format!(".{extension}"))
265        .unwrap_or_else(|| "(none)".into())
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use kcode_audio_ingress::{
272        CorrectionChunk, CorrectionObservation, CorrectionPacket, ObservationKey, ParsedChunk,
273    };
274
275    fn completed_recording(transcript: impl Into<String>) -> RecordingStatus {
276        let recorded_at = "2026-08-02T03:04:05Z".parse().unwrap();
277        RecordingStatus {
278            id: Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap(),
279            user_id: "user".into(),
280            sha256: "a".repeat(64),
281            original_filename: "meeting.final.WAV".into(),
282            size_bytes: 42,
283            recorded_at,
284            received_at: recorded_at,
285            transcription_model: "transcription-model".into(),
286            reconciliation_model: "reconciliation-model".into(),
287            reconciliation_reasoning: "xhigh".into(),
288            state: RecordingState::Complete {
289                transcript: transcript.into(),
290            },
291            correction_packet: None,
292        }
293    }
294
295    fn with_speaker_packet(
296        mut recording: RecordingStatus,
297        state: ConfirmationState,
298        confirmed_name: Option<&str>,
299    ) -> RecordingStatus {
300        recording.correction_packet = Some(CorrectionPacket {
301            recording_id: recording.id,
302            user_id: recording.user_id.clone(),
303            sha256: recording.sha256.clone(),
304            original_filename: recording.original_filename.clone(),
305            size_bytes: recording.size_bytes,
306            recorded_at: recording.recorded_at,
307            clean: true,
308            chunk_count: 1,
309            chunks: vec![CorrectionChunk {
310                chunk_index: 0,
311                chunk_count: 1,
312                audio_start_ms: 0,
313                audio_end_ms: 1_500,
314                raw_gemini_response: "raw".into(),
315                parsed: ParsedChunk {
316                    utterances: Vec::new(),
317                    notes: Vec::new(),
318                    clip_valid: true,
319                    clip_validity_reason: None,
320                    speakers: Vec::new(),
321                },
322                observations: vec![CorrectionObservation {
323                    local_label: "Speaker A".into(),
324                    speaker_ordinal: 0,
325                    observation_key: ObservationKey {
326                        object_id: format!(
327                            "kcode-audio-ingress/recording/{}/chunk/0",
328                            recording.id
329                        ),
330                        piece_index: 0,
331                    },
332                    candidate: None,
333                    identified_full_name: Some("Classifier Candidate".into()),
334                    confirmed_full_name: confirmed_name.map(str::to_owned),
335                }],
336                clean: true,
337            }],
338            confirmation_state: state,
339        });
340        recording
341    }
342
343    #[test]
344    fn quarter_context_plans_transcript_only_pieces() {
345        let recording = completed_recording("a".repeat(801));
346        let plan = TranscriptPlan::new(&recording, 400).unwrap();
347
348        assert_eq!(plan.pieces().len(), 3);
349        assert_eq!(
350            plan.pieces()
351                .iter()
352                .map(|piece| piece.text.chars().count())
353                .collect::<Vec<_>>(),
354            vec![400, 400, 1]
355        );
356        assert_eq!(
357            plan.pieces()
358                .iter()
359                .map(|piece| piece.estimated_tokens)
360                .collect::<Vec<_>>(),
361            vec![100, 100, 1]
362        );
363        assert_eq!(plan.pieces()[2].id, format!("audio:{}:2", recording.id));
364    }
365
366    #[test]
367    fn splitting_prefers_late_paragraphs_and_counts_unicode_scalars() {
368        let paragraph_recording =
369            completed_recording(format!("{}\n\n{}", "a".repeat(250), "b".repeat(200)));
370        let paragraph_plan = TranscriptPlan::new(&paragraph_recording, 400).unwrap();
371        assert_eq!(paragraph_plan.pieces()[0].text, "a".repeat(250));
372        assert_eq!(paragraph_plan.pieces()[1].text, "b".repeat(200));
373
374        let unicode_recording = completed_recording("😀".repeat(5));
375        let unicode_plan = TranscriptPlan::new(&unicode_recording, 4).unwrap();
376        assert_eq!(
377            unicode_plan
378                .pieces()
379                .iter()
380                .map(|piece| piece.text.chars().count())
381                .collect::<Vec<_>>(),
382            vec![4, 1]
383        );
384    }
385
386    #[test]
387    fn canonical_audio_piece_ids_are_strict() {
388        let recording_id = Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap();
389        let canonical = format!("audio:{recording_id}:2");
390
391        assert_eq!(parse_audio_piece_id(&canonical), Some((recording_id, 2)));
392        assert!(parse_audio_piece_id(&format!("audio:{recording_id}:02")).is_none());
393        assert!(parse_audio_piece_id("audio:ABCDEF01-2345-6789-ABCD-EF0123456789:2").is_none());
394        assert!(parse_audio_piece_id(&format!("audio:{recording_id}:2:extra")).is_none());
395        assert!(parse_audio_piece_id("audio:not-a-uuid:0").is_none());
396    }
397
398    #[test]
399    fn metadata_reproduces_existing_keys_and_values() {
400        let recording = completed_recording("Transcript");
401        let plan = TranscriptPlan::new(&recording, 400).unwrap();
402
403        assert_eq!(
404            plan.pieces()[0].metadata(&recording),
405            json!({
406                "kind": "audio-transcript",
407                "recordingId": "abcdef01-2345-6789-abcd-ef0123456789",
408                "sha256": "a".repeat(64),
409                "originalFilename": "meeting.final.WAV",
410                "extension": ".WAV",
411                "mimeType": "audio/wav",
412                "sizeBytes": 42,
413                "sourceCreatedAt": "2026-08-02T03:04:05+00:00",
414                "pieceIndex": 0,
415                "pieceCount": 1,
416                "speakerConfirmationState": null,
417            })
418        );
419    }
420
421    #[test]
422    fn legacy_rendering_reproduces_existing_text() {
423        let recording = completed_recording("Transcript");
424        let plan = TranscriptPlan::new(&recording, 400).unwrap();
425
426        assert_eq!(
427            plan.pieces()[0].formatted_text(&recording).unwrap(),
428            format!(
429                "Vnote final transcript piece\n\nRecording began: 2026-08-02T03:04:05+00:00\nRecording SHA-256: {}\nOriginal filename: meeting.final.WAV\nExtension: .WAV\nMIME type: audio/wav\nSize: 42 bytes\nTranscript piece: 1 of 1\n\nTranscript",
430                "a".repeat(64)
431            )
432        );
433    }
434
435    #[test]
436    fn confirmed_rendering_includes_the_complete_mapping() {
437        let recording = with_speaker_packet(
438            completed_recording("Transcript"),
439            ConfirmationState::Confirmed,
440            Some("Human Choice"),
441        );
442        let plan = TranscriptPlan::new(&recording, 400).unwrap();
443
444        assert_eq!(
445            plan.pieces()[0].formatted_text(&recording).unwrap(),
446            format!(
447                "Vnote final transcript piece\n\nRecording began: 2026-08-02T03:04:05+00:00\nRecording SHA-256: {}\nOriginal filename: meeting.final.WAV\nExtension: .WAV\nMIME type: audio/wav\nSize: 42 bytes\nTranscript piece: 1 of 1\n\nHuman-confirmed speaker-label data (authoritative; do not infer alternatives):\n- chunk 1/1, source 0.000-1.500s: localLabel=\"Speaker A\", confirmedFullName=\"Human Choice\", classifierCandidate=\"Classifier Candidate\"\n\nTranscript",
448                "a".repeat(64)
449            )
450        );
451        assert_eq!(
452            plan.pieces()[0]
453                .metadata(&recording)
454                .get("speakerConfirmationState"),
455            Some(&json!("confirmed"))
456        );
457    }
458
459    #[test]
460    fn unconfirmed_speaker_rendering_is_a_conflict() {
461        let recording = with_speaker_packet(
462            completed_recording("Transcript"),
463            ConfirmationState::AutomaticallyTrained,
464            None,
465        );
466        let plan = TranscriptPlan::new(&recording, 400).unwrap();
467
468        assert!(matches!(
469            plan.pieces()[0].formatted_text(&recording),
470            Err(Error::Conflict(_))
471        ));
472        assert_eq!(
473            plan.pieces()[0]
474                .metadata(&recording)
475                .get("speakerConfirmationState"),
476            Some(&json!("automatically_trained"))
477        );
478    }
479}