Skip to main content

kcode_audio_transcript_plan/
lib.rs

1#![forbid(unsafe_code)]
2
3use kcode_audio_ingress::{ConfirmationState, RecordingState, RecordingStatus, SpeakerResolution};
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 resolution = observation.resolution.as_ref().ok_or(Error::Internal(
219                "confirmed audio packet omitted an observation resolution",
220            ))?;
221            let local_label = serde_json::to_string(&observation.local_label)
222                .map_err(|_| Error::Internal("could not render confirmed audio speaker mapping"))?;
223            let confirmed = serde_json::to_string(match resolution {
224                SpeakerResolution::Known { full_name } => full_name.as_str(),
225                SpeakerResolution::Unknown => "Unknown Speaker",
226            })
227            .map_err(|_| Error::Internal("could not render confirmed audio speaker mapping"))?;
228            let candidate = observation
229                .candidate
230                .as_ref()
231                .map(|candidate| candidate.full_name.as_str())
232                .map(serde_json::to_string)
233                .transpose()
234                .map_err(|_| Error::Internal("could not render confirmed audio speaker mapping"))?
235                .unwrap_or_else(|| "null".into());
236            lines.push(format!(
237                "- chunk {}/{}, source {:.3}-{:.3}s: localLabel={}, approvedSpeaker={}, classifierCandidate={}",
238                chunk.chunk_index + 1,
239                chunk.chunk_count,
240                chunk.audio_start_ms as f64 / 1_000.0,
241                chunk.audio_end_ms as f64 / 1_000.0,
242                local_label,
243                confirmed,
244                candidate,
245            ));
246        }
247    }
248    if lines.is_empty() {
249        return Ok(String::new());
250    }
251    Ok(format!(
252        "\n\nHuman-confirmed speaker-label data (authoritative; do not infer alternatives):\n{}",
253        lines.join("\n")
254    ))
255}
256
257fn file_name_extension(file_name: &str) -> String {
258    file_name
259        .rsplit_once('.')
260        .and_then(|(stem, extension)| {
261            (!stem.is_empty() && !extension.is_empty()).then_some(extension)
262        })
263        .map(|extension| format!(".{extension}"))
264        .unwrap_or_else(|| "(none)".into())
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use kcode_audio_ingress::{
271        CandidateMapping, CorrectionChunk, CorrectionObservation, CorrectionPacket, ObservationKey,
272        ParsedChunk, SpeakerResolution,
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            chunk_count: 1,
308            chunks: vec![CorrectionChunk {
309                chunk_index: 0,
310                chunk_count: 1,
311                audio_start_ms: 0,
312                audio_end_ms: 1_500,
313                raw_gemini_response: "raw".into(),
314                parsed: ParsedChunk {
315                    clip_valid: true,
316                    clip_validity_reason: None,
317                    speakers: Vec::new(),
318                },
319                observations: vec![CorrectionObservation {
320                    local_label: "Speaker A".into(),
321                    speaker_ordinal: 0,
322                    observation_key: ObservationKey {
323                        object_id: format!(
324                            "kcode-audio-ingress/recording/{}/chunk/0",
325                            recording.id
326                        ),
327                        piece_index: 0,
328                    },
329                    candidate: Some(CandidateMapping {
330                        full_name: "Classifier Candidate".into(),
331                        score: 2.0,
332                        runner_up_score: Some(-1.0),
333                    }),
334                    resolution: confirmed_name.map(|name| SpeakerResolution::Known {
335                        full_name: name.into(),
336                    }),
337                }],
338                signed_off: state == ConfirmationState::Confirmed,
339            }],
340            confirmation_state: state,
341        });
342        recording
343    }
344
345    #[test]
346    fn quarter_context_plans_transcript_only_pieces() {
347        let recording = completed_recording("a".repeat(801));
348        let plan = TranscriptPlan::new(&recording, 400).unwrap();
349
350        assert_eq!(plan.pieces().len(), 3);
351        assert_eq!(
352            plan.pieces()
353                .iter()
354                .map(|piece| piece.text.chars().count())
355                .collect::<Vec<_>>(),
356            vec![400, 400, 1]
357        );
358        assert_eq!(
359            plan.pieces()
360                .iter()
361                .map(|piece| piece.estimated_tokens)
362                .collect::<Vec<_>>(),
363            vec![100, 100, 1]
364        );
365        assert_eq!(plan.pieces()[2].id, format!("audio:{}:2", recording.id));
366    }
367
368    #[test]
369    fn splitting_prefers_late_paragraphs_and_counts_unicode_scalars() {
370        let paragraph_recording =
371            completed_recording(format!("{}\n\n{}", "a".repeat(250), "b".repeat(200)));
372        let paragraph_plan = TranscriptPlan::new(&paragraph_recording, 400).unwrap();
373        assert_eq!(paragraph_plan.pieces()[0].text, "a".repeat(250));
374        assert_eq!(paragraph_plan.pieces()[1].text, "b".repeat(200));
375
376        let unicode_recording = completed_recording("😀".repeat(5));
377        let unicode_plan = TranscriptPlan::new(&unicode_recording, 4).unwrap();
378        assert_eq!(
379            unicode_plan
380                .pieces()
381                .iter()
382                .map(|piece| piece.text.chars().count())
383                .collect::<Vec<_>>(),
384            vec![4, 1]
385        );
386    }
387
388    #[test]
389    fn canonical_audio_piece_ids_are_strict() {
390        let recording_id = Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap();
391        let canonical = format!("audio:{recording_id}:2");
392
393        assert_eq!(parse_audio_piece_id(&canonical), Some((recording_id, 2)));
394        assert!(parse_audio_piece_id(&format!("audio:{recording_id}:02")).is_none());
395        assert!(parse_audio_piece_id("audio:ABCDEF01-2345-6789-ABCD-EF0123456789:2").is_none());
396        assert!(parse_audio_piece_id(&format!("audio:{recording_id}:2:extra")).is_none());
397        assert!(parse_audio_piece_id("audio:not-a-uuid:0").is_none());
398    }
399
400    #[test]
401    fn metadata_reproduces_existing_keys_and_values() {
402        let recording = completed_recording("Transcript");
403        let plan = TranscriptPlan::new(&recording, 400).unwrap();
404
405        assert_eq!(
406            plan.pieces()[0].metadata(&recording),
407            json!({
408                "kind": "audio-transcript",
409                "recordingId": "abcdef01-2345-6789-abcd-ef0123456789",
410                "sha256": "a".repeat(64),
411                "originalFilename": "meeting.final.WAV",
412                "extension": ".WAV",
413                "mimeType": "audio/wav",
414                "sizeBytes": 42,
415                "sourceCreatedAt": "2026-08-02T03:04:05+00:00",
416                "pieceIndex": 0,
417                "pieceCount": 1,
418                "speakerConfirmationState": null,
419            })
420        );
421    }
422
423    #[test]
424    fn legacy_rendering_reproduces_existing_text() {
425        let recording = completed_recording("Transcript");
426        let plan = TranscriptPlan::new(&recording, 400).unwrap();
427
428        assert_eq!(
429            plan.pieces()[0].formatted_text(&recording).unwrap(),
430            format!(
431                "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",
432                "a".repeat(64)
433            )
434        );
435    }
436
437    #[test]
438    fn confirmed_rendering_includes_the_complete_mapping() {
439        let recording = with_speaker_packet(
440            completed_recording("Transcript"),
441            ConfirmationState::Confirmed,
442            Some("Human Choice"),
443        );
444        let plan = TranscriptPlan::new(&recording, 400).unwrap();
445
446        assert_eq!(
447            plan.pieces()[0].formatted_text(&recording).unwrap(),
448            format!(
449                "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\", approvedSpeaker=\"Human Choice\", classifierCandidate=\"Classifier Candidate\"\n\nTranscript",
450                "a".repeat(64)
451            )
452        );
453        assert_eq!(
454            plan.pieces()[0]
455                .metadata(&recording)
456                .get("speakerConfirmationState"),
457            Some(&json!("confirmed"))
458        );
459    }
460
461    #[test]
462    fn unconfirmed_speaker_rendering_is_a_conflict() {
463        let recording = with_speaker_packet(
464            completed_recording("Transcript"),
465            ConfirmationState::AutomaticallyTrained,
466            None,
467        );
468        let plan = TranscriptPlan::new(&recording, 400).unwrap();
469
470        assert!(matches!(
471            plan.pieces()[0].formatted_text(&recording),
472            Err(Error::Conflict(_))
473        ));
474        assert_eq!(
475            plan.pieces()[0]
476                .metadata(&recording)
477                .get("speakerConfirmationState"),
478            Some(&json!("automatically_trained"))
479        );
480    }
481}