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#[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#[derive(Debug, Eq, PartialEq)]
33pub struct TranscriptPlan {
34 pieces: Vec<TranscriptPiece>,
35}
36
37impl TranscriptPlan {
38 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 pub fn pieces(&self) -> &[TranscriptPiece] {
69 &self.pieces
70 }
71}
72
73#[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 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 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
127pub 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(¶graph_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}