1#![deny(missing_docs)]
4#![forbid(unsafe_code)]
5
6use std::collections::{HashMap, HashSet};
7
8use anyhow::{Context, ensure};
9use chrono::{DateTime, Utc};
10use kcode_speaker_system::{Cohort, SpeechClassifier};
11pub use kcode_speaker_system::{FeatureRow, ObservationKey};
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15pub const CLASSIFIER_PROVIDER: &str = "google";
17pub const CLASSIFIER_MODEL: &str = "gemini-3.1-pro-preview";
19pub const CLASSIFIER_PROMPT_VERSION: &str = "gemini-transcript-speaker-24-freeform/2";
21pub const CLASSIFIER_SCHEMA_VERSION: &str = "gemini-speaker-24-normalized/1";
23
24#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
26#[serde(deny_unknown_fields)]
27pub struct ParsedSpeaker {
28 pub local_label: String,
30 pub primary_language: Option<String>,
32 pub feature_row: Option<FeatureRow>,
34}
35
36#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
38pub struct ParsedChunk {
39 pub clip_valid: bool,
41 pub clip_validity_reason: Option<String>,
43 pub speakers: Vec<ParsedSpeaker>,
45}
46
47#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
49pub struct CandidateMapping {
50 pub full_name: String,
52 pub score: f64,
54 pub runner_up_score: Option<f64>,
56}
57
58#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
60#[serde(tag = "kind", rename_all = "snake_case")]
61pub enum SpeakerResolution {
62 Known {
64 full_name: String,
66 },
67 Unknown,
69}
70
71#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
73pub struct CorrectionObservation {
74 pub local_label: String,
76 pub speaker_ordinal: u32,
78 pub observation_key: ObservationKey,
80 pub candidate: Option<CandidateMapping>,
82 pub resolution: Option<SpeakerResolution>,
84}
85
86#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
88pub struct CorrectionChunk {
89 pub chunk_index: usize,
91 pub chunk_count: usize,
93 pub audio_start_ms: u64,
95 pub audio_end_ms: u64,
97 pub raw_gemini_response: String,
99 pub parsed: ParsedChunk,
101 pub observations: Vec<CorrectionObservation>,
103 pub signed_off: bool,
105}
106
107#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
109#[serde(rename_all = "snake_case")]
110pub enum ConfirmationState {
111 Unconfirmed,
113 AutomaticallyTrained,
115 Confirmed,
117}
118
119#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
121pub struct CorrectionPacket {
122 pub recording_id: Uuid,
124 pub user_id: String,
126 pub sha256: String,
128 pub original_filename: String,
130 pub size_bytes: u64,
132 pub recorded_at: DateTime<Utc>,
134 pub chunk_count: usize,
136 pub chunks: Vec<CorrectionChunk>,
138 pub confirmation_state: ConfirmationState,
140}
141
142#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
144pub struct ObservationConfirmation {
145 pub observation_key: ObservationKey,
147 pub resolution: SpeakerResolution,
149}
150
151#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
153pub struct ChunkConfirmation {
154 pub recording_id: Uuid,
156 pub chunk_index: usize,
158 pub observations: Vec<ObservationConfirmation>,
160}
161
162#[derive(Clone, Copy, Debug, Eq, PartialEq)]
164pub enum LegacyReviewDisposition {
165 Reprocess,
167 Complete,
169}
170
171pub fn validate_confirmation_coverage(
173 packet: &CorrectionPacket,
174 confirmation: &ChunkConfirmation,
175) -> Result<(), String> {
176 if confirmation.recording_id != packet.recording_id {
177 return Err("Confirmation recording ID does not match the packet.".into());
178 }
179 let chunk = packet
180 .chunks
181 .get(confirmation.chunk_index)
182 .filter(|chunk| chunk.chunk_index == confirmation.chunk_index)
183 .ok_or_else(|| "Confirmation chunk does not exist.".to_owned())?;
184 let known = chunk
185 .observations
186 .iter()
187 .map(|observation| observation.observation_key.clone())
188 .collect::<HashSet<_>>();
189 let mut supplied = HashSet::new();
190 for observation in &confirmation.observations {
191 if let SpeakerResolution::Known { full_name } = &observation.resolution
192 && (full_name.trim().is_empty() || full_name.chars().count() > 512)
193 {
194 return Err("Known speaker names must contain between 1 and 512 characters.".into());
195 }
196 if !supplied.insert(observation.observation_key.clone()) {
197 return Err("Confirmation contains a duplicate observation key.".into());
198 }
199 }
200 if supplied != known {
201 return Err(
202 "Chunk signoff must resolve every speaker exactly once, with no extras.".into(),
203 );
204 }
205 Ok(())
206}
207
208pub fn apply_confirmation(
210 classifier: &SpeechClassifier,
211 packet: &mut CorrectionPacket,
212 confirmation: &ChunkConfirmation,
213 legacy_keys: &HashSet<ObservationKey>,
214) -> anyhow::Result<()> {
215 validate_confirmation_coverage(packet, confirmation).map_err(anyhow::Error::msg)?;
216 let chunk_index = confirmation.chunk_index;
217 if packet.chunks[chunk_index].signed_off {
218 ensure!(
219 confirmation_matches(packet, confirmation),
220 "signed-off chunk cannot be changed"
221 );
222 return Ok(());
223 }
224
225 let assignments = confirmation
226 .observations
227 .iter()
228 .map(|entry| (entry.observation_key.clone(), normalized(&entry.resolution)))
229 .collect::<HashMap<_, _>>();
230 let mut applied: Vec<ObservationKey> = Vec::new();
231 for position in 0..packet.chunks[chunk_index].observations.len() {
232 let observation = &packet.chunks[chunk_index].observations[position];
233 let resolution = assignments
234 .get(&observation.observation_key)
235 .context("validated confirmation assignment disappeared")?
236 .clone();
237 if !legacy_keys.contains(&observation.observation_key) {
238 let speaker = &packet.chunks[chunk_index].parsed.speakers[position];
239 let result = match (
240 &resolution,
241 speaker.primary_language.as_deref(),
242 speaker.feature_row,
243 ) {
244 (SpeakerResolution::Known { full_name }, Some(language), Some(row)) => classifier
245 .train(
246 observation.observation_key.clone(),
247 cohort(language),
248 row,
249 full_name.clone(),
250 )
251 .map(|_| ()),
252 _ => classifier
253 .delete(observation.observation_key.clone())
254 .map(|_| ()),
255 };
256 if let Err(error) = result {
257 let rollback = applied
258 .iter()
259 .rev()
260 .filter_map(|key| classifier.delete(key.clone()).err().map(|e| e.to_string()))
261 .collect::<Vec<_>>();
262 if rollback.is_empty() {
263 anyhow::bail!("applying identity confirmations failed: {error}");
264 }
265 anyhow::bail!(
266 "applying identity confirmations failed: {error}; rollback also failed: {}",
267 rollback.join("; ")
268 );
269 }
270 applied.push(observation.observation_key.clone());
271 }
272 packet.chunks[chunk_index].observations[position].resolution = Some(resolution);
273 }
274 packet.chunks[chunk_index].signed_off = true;
275 if packet.chunks.iter().all(|chunk| chunk.signed_off) {
276 packet.confirmation_state = ConfirmationState::Confirmed;
277 }
278 Ok(())
279}
280
281pub fn restore_training(
283 classifier: &SpeechClassifier,
284 packet: &CorrectionPacket,
285 legacy_keys: &HashSet<ObservationKey>,
286) -> Vec<String> {
287 let mut errors = Vec::new();
288 for chunk in packet.chunks.iter().rev() {
289 for observation in chunk.observations.iter().rev() {
290 if legacy_keys.contains(&observation.observation_key) {
291 continue;
292 }
293 let speaker = chunk
294 .parsed
295 .speakers
296 .get(observation.speaker_ordinal as usize);
297 let result = match (observation.resolution.as_ref(), speaker) {
298 (
299 Some(SpeakerResolution::Known { full_name }),
300 Some(ParsedSpeaker {
301 primary_language: Some(language),
302 feature_row: Some(row),
303 ..
304 }),
305 ) => classifier
306 .train(
307 observation.observation_key.clone(),
308 cohort(language),
309 *row,
310 full_name.clone(),
311 )
312 .map(|_| ()),
313 _ => classifier
314 .delete(observation.observation_key.clone())
315 .map(|_| ()),
316 };
317 if let Err(error) = result {
318 errors.push(error.to_string());
319 }
320 }
321 }
322 errors
323}
324
325pub fn training_object_id(recording_id: Uuid, chunk_index: usize) -> String {
327 format!("kcode-audio-ingress/recording/{recording_id}/chunk/{chunk_index}")
328}
329
330pub fn legacy_review_disposition(
332 recording_complete: bool,
333 confirmation_state: Option<ConfirmationState>,
334 has_ingress: bool,
335) -> Option<LegacyReviewDisposition> {
336 if !recording_complete || confirmation_state == Some(ConfirmationState::Confirmed) {
337 return None;
338 }
339 confirmation_state.map(|_| {
340 if has_ingress {
341 LegacyReviewDisposition::Complete
342 } else {
343 LegacyReviewDisposition::Reprocess
344 }
345 })
346}
347
348pub fn confirmation_matches(packet: &CorrectionPacket, confirmation: &ChunkConfirmation) -> bool {
350 let Some(chunk) = packet
351 .chunks
352 .get(confirmation.chunk_index)
353 .filter(|chunk| chunk.chunk_index == confirmation.chunk_index && chunk.signed_off)
354 else {
355 return false;
356 };
357 chunk.observations.len() == confirmation.observations.len()
358 && confirmation.observations.iter().all(|entry| {
359 chunk
360 .observations
361 .iter()
362 .find(|stored| stored.observation_key == entry.observation_key)
363 .and_then(|stored| stored.resolution.as_ref())
364 == Some(&entry.resolution)
365 })
366}
367
368fn cohort(primary_language: &str) -> Cohort {
369 Cohort {
370 provider: CLASSIFIER_PROVIDER.into(),
371 model: CLASSIFIER_MODEL.into(),
372 prompt_version: CLASSIFIER_PROMPT_VERSION.into(),
373 schema_version: CLASSIFIER_SCHEMA_VERSION.into(),
374 primary_language: primary_language.into(),
375 }
376}
377
378fn normalized(resolution: &SpeakerResolution) -> SpeakerResolution {
379 match resolution {
380 SpeakerResolution::Known { full_name } => SpeakerResolution::Known {
381 full_name: full_name.trim().to_owned(),
382 },
383 SpeakerResolution::Unknown => SpeakerResolution::Unknown,
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 fn packet(id: Uuid) -> CorrectionPacket {
392 let chunks = (0..2)
393 .map(|index| CorrectionChunk {
394 chunk_index: index,
395 chunk_count: 2,
396 audio_start_ms: index as u64 * 1_000,
397 audio_end_ms: (index as u64 + 1) * 1_000,
398 raw_gemini_response: "Speaker 1: hello".into(),
399 parsed: ParsedChunk {
400 clip_valid: true,
401 clip_validity_reason: None,
402 speakers: vec![ParsedSpeaker {
403 local_label: "Speaker 1".into(),
404 primary_language: Some("eng".into()),
405 feature_row: Some(FeatureRow::new([50; 24]).unwrap()),
406 }],
407 },
408 observations: vec![CorrectionObservation {
409 local_label: "Speaker 1".into(),
410 speaker_ordinal: 0,
411 observation_key: ObservationKey {
412 object_id: training_object_id(id, index),
413 piece_index: 0,
414 },
415 candidate: None,
416 resolution: None,
417 }],
418 signed_off: false,
419 })
420 .collect();
421 CorrectionPacket {
422 recording_id: id,
423 user_id: "user".into(),
424 sha256: "a".repeat(64),
425 original_filename: "voice.wav".into(),
426 size_bytes: 1,
427 recorded_at: Utc::now(),
428 chunk_count: 2,
429 chunks,
430 confirmation_state: ConfirmationState::Unconfirmed,
431 }
432 }
433
434 #[test]
435 fn signoff_trains_known_skips_unknown_and_rejects_changes() {
436 let id = Uuid::new_v4();
437 let path = std::env::temp_dir().join(format!("audio-review-{id}.sqlite3"));
438 let classifier = SpeechClassifier::open(&path).unwrap();
439 let mut review_packet = packet(id);
440 let legacy = HashSet::new();
441 for (index, resolution) in [
442 SpeakerResolution::Known {
443 full_name: "David Example".into(),
444 },
445 SpeakerResolution::Unknown,
446 ]
447 .into_iter()
448 .enumerate()
449 {
450 let confirmation = ChunkConfirmation {
451 recording_id: id,
452 chunk_index: index,
453 observations: vec![ObservationConfirmation {
454 observation_key: review_packet.chunks[index].observations[0]
455 .observation_key
456 .clone(),
457 resolution,
458 }],
459 };
460 apply_confirmation(&classifier, &mut review_packet, &confirmation, &legacy).unwrap();
461 apply_confirmation(&classifier, &mut review_packet, &confirmation, &legacy).unwrap();
462 }
463 assert_eq!(
464 review_packet.confirmation_state,
465 ConfirmationState::Confirmed
466 );
467 assert_eq!(classifier.known_speakers().unwrap(), vec!["David Example"]);
468 let changed = packet(id).chunks[0].observations[0].observation_key.clone();
469 let conflict = ChunkConfirmation {
470 recording_id: id,
471 chunk_index: 0,
472 observations: vec![ObservationConfirmation {
473 observation_key: changed,
474 resolution: SpeakerResolution::Unknown,
475 }],
476 };
477 assert!(apply_confirmation(&classifier, &mut review_packet, &conflict, &legacy).is_err());
478 drop(classifier);
479 let _ = std::fs::remove_file(path);
480 }
481
482 #[test]
483 fn legacy_policy_only_selects_unresolved_finalized_packets() {
484 use ConfirmationState::{AutomaticallyTrained, Unconfirmed};
485 use LegacyReviewDisposition::{Complete, Reprocess};
486
487 assert_eq!(
488 legacy_review_disposition(true, Some(Unconfirmed), false),
489 Some(Reprocess)
490 );
491 assert_eq!(
492 legacy_review_disposition(true, Some(AutomaticallyTrained), true),
493 Some(Complete)
494 );
495 assert_eq!(legacy_review_disposition(false, None, false), None);
496 }
497}