use std::sync::Arc;
use anyhow::ensure;
use chrono::{DateTime, Utc};
use kcode_audio_speaker_review::{ConfirmationState, ParsedSpeaker};
pub(crate) use kcode_audio_speaker_review::{
CorrectionChunk, CorrectionObservation, CorrectionPacket, ParsedChunk,
};
use kcode_speaker_extract::ExtractionOutcome;
use kcode_speaker_system::SpeechClassifier;
use uuid::Uuid;
#[derive(Clone)]
pub(crate) struct ClassificationContext {
pub(crate) recording_id: Uuid,
pub(crate) user_id: String,
pub(crate) sha256: String,
pub(crate) original_filename: String,
pub(crate) size_bytes: u64,
pub(crate) recorded_at: DateTime<Utc>,
pub(crate) classifier: Arc<SpeechClassifier>,
}
pub(crate) fn parsed_chunk_from_extraction(
extraction: &ExtractionOutcome,
) -> anyhow::Result<ParsedChunk> {
let (count, clip_valid, clip_validity_reason) = match extraction {
ExtractionOutcome::Scored(scored) if scored.additional_speakers.is_empty() => {
(scored.speakers.len(), true, None)
}
ExtractionOutcome::Scored(scored) => (
scored.speakers.len() + scored.additional_speakers.len(),
false,
Some("One or more speakers lacked a complete feature profile.".to_owned()),
),
ExtractionOutcome::Unscorable {
reason,
additional_speakers,
} => (additional_speakers.len(), false, Some(reason.clone())),
};
let speakers = (0..count)
.map(|ordinal| {
let profile = match extraction {
ExtractionOutcome::Scored(scored) => scored
.speakers
.iter()
.find(|profile| usize::from(profile.speaker_ordinal) == ordinal),
ExtractionOutcome::Unscorable { .. } => None,
};
ParsedSpeaker {
local_label: format!("Speaker {}", ordinal + 1),
primary_language: profile.map(|value| value.primary_language.as_ref().to_owned()),
feature_row: profile.map(|value| value.features),
}
})
.collect();
Ok(ParsedChunk {
clip_valid,
clip_validity_reason,
speakers,
})
}
pub(crate) fn classify_speakers(
context: &ClassificationContext,
chunk_index: usize,
parsed: &ParsedChunk,
) -> anyhow::Result<Vec<CorrectionObservation>> {
kcode_audio_speaker_candidates::classify_chunk(
&context.classifier,
context.recording_id,
chunk_index,
parsed,
)
}
pub(crate) fn unclassified_observations(
recording_id: Uuid,
chunk_index: usize,
parsed: &ParsedChunk,
) -> anyhow::Result<Vec<CorrectionObservation>> {
kcode_audio_speaker_candidates::observations_without_candidates(
recording_id,
chunk_index,
parsed,
)
}
pub(crate) fn build_packet(
context: &ClassificationContext,
chunks: Vec<CorrectionChunk>,
) -> anyhow::Result<CorrectionPacket> {
ensure!(!chunks.is_empty(), "correction packet has no chunks");
let chunk_count = chunks.len();
ensure!(
chunks.iter().enumerate().all(|(index, chunk)| {
chunk.chunk_index == index
&& chunk.chunk_count == chunk_count
&& chunk.audio_end_ms > chunk.audio_start_ms
&& chunk.observations.len() == chunk.parsed.speakers.len()
&& !chunk.signed_off
}),
"correction packet chunks are not one complete chronological plan"
);
Ok(CorrectionPacket {
recording_id: context.recording_id,
user_id: context.user_id.clone(),
sha256: context.sha256.clone(),
original_filename: context.original_filename.clone(),
size_bytes: context.size_bytes,
recorded_at: context.recorded_at,
chunk_count,
chunks,
confirmation_state: ConfirmationState::Unconfirmed,
})
}