Skip to main content

native_whisperx/
speaker_directory.rs

1//! Speaker Directory identity, trace, validation, and rebuild operations.
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::io::ErrorKind;
6use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use audio_analysis_speakers::{
10    SpeakerEmbedding, SpeakerId, SpeakerLabel, SpeakerLibrary, SpeakerProfile,
11};
12use serde::{Deserialize, Serialize};
13use serde_json::{Map, Value};
14use text_transcripts::TranscriptionContract;
15
16use crate::{import_whisperx_json, NativeWhisperxError};
17
18pub const LOCAL_SPEAKER_DIRECTORY: &str = ".native-whisperx/speakers";
19pub const GLOBAL_SPEAKER_DIRECTORY_APP: &str = "native-whisperx";
20pub const GLOBAL_SPEAKER_DIRECTORY_NAME: &str = "speakers";
21pub const SPEAKER_LIBRARY_FILE: &str = "library.json";
22pub const SPEAKER_TRACE_FILE: &str = "speaker-trace.json";
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct SpeakerDirectorySelection {
27    #[serde(default)]
28    pub scope: SpeakerDirectoryScope,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub explicit_path: Option<PathBuf>,
31}
32
33impl Default for SpeakerDirectorySelection {
34    fn default() -> Self {
35        Self {
36            scope: SpeakerDirectoryScope::Auto,
37            explicit_path: None,
38        }
39    }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub enum SpeakerDirectoryScope {
45    #[default]
46    Auto,
47    Local,
48    Global,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ResolvedSpeakerDirectoryScope {
53    Local,
54    Global,
55    Explicit,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct ResolvedSpeakerDirectory {
60    pub path: PathBuf,
61    pub scope: ResolvedSpeakerDirectoryScope,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct SpeakerLibraryValidation {
66    pub path: PathBuf,
67    pub profile_count: usize,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "camelCase")]
72pub struct SpeakerProfileSummary {
73    pub speaker_id: String,
74    pub label: String,
75    pub status: String,
76    pub embedding_count: usize,
77    pub metadata: BTreeMap<String, String>,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct SpeakerCorrectionRange {
83    pub start_seconds: f64,
84    pub end_seconds: f64,
85}
86
87impl SpeakerCorrectionRange {
88    pub fn overlaps(self, start_seconds: f64, end_seconds: f64) -> bool {
89        start_seconds < self.end_seconds && end_seconds > self.start_seconds
90    }
91}
92
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct SpeakerTrace {
96    pub version: u32,
97    pub scan_root: PathBuf,
98    pub speakers: Vec<SpeakerTraceSpeaker>,
99    #[serde(default)]
100    pub errors: Vec<SpeakerTraceError>,
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct SpeakerTraceSpeaker {
106    pub kind: SpeakerTraceSpeakerKind,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub profile_id: Option<String>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub label: Option<String>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub anonymous_label: Option<String>,
113    pub files: Vec<SpeakerTraceFile>,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub enum SpeakerTraceSpeakerKind {
119    Enrolled,
120    Anonymous,
121}
122
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub struct SpeakerTraceFile {
126    pub source_file: PathBuf,
127    pub segment_count: usize,
128    pub total_duration: f64,
129    pub spans: Vec<SpeakerTraceSpan>,
130}
131
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct SpeakerTraceSpan {
135    pub start_seconds: Option<f64>,
136    pub end_seconds: Option<f64>,
137    pub snippet: String,
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct SpeakerTraceError {
143    pub source_file: PathBuf,
144    pub message: String,
145}
146
147#[derive(Debug, Clone, PartialEq, Serialize)]
148#[serde(rename_all = "camelCase")]
149pub struct SpeakerTraceRebuildReport {
150    pub trace_path: PathBuf,
151    pub trace: SpeakerTrace,
152    pub stats: SpeakerTraceRebuildStats,
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
156#[serde(rename_all = "camelCase")]
157pub struct SpeakerTraceRebuildStats {
158    pub scanned_files: usize,
159    pub accepted_entries: usize,
160    pub ignored_non_json_files: usize,
161    pub malformed_json_errors: usize,
162}
163
164#[derive(Debug, Clone, PartialEq, Serialize)]
165#[serde(rename_all = "camelCase")]
166pub struct SpeakerDirectoryState {
167    pub scope: SpeakerDirectoryStateScope,
168    pub path: PathBuf,
169    pub library: SpeakerLibraryState,
170    pub profiles: Vec<SpeakerProfileState>,
171    pub trace: SpeakerTraceState,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
175#[serde(rename_all = "camelCase")]
176pub enum SpeakerDirectoryStateScope {
177    Local,
178    Global,
179    Explicit,
180}
181
182#[derive(Debug, Clone, PartialEq, Serialize)]
183#[serde(rename_all = "camelCase")]
184pub struct SpeakerLibraryState {
185    pub path: PathBuf,
186    pub status: SpeakerLibraryValidationStatus,
187    pub profile_count: usize,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub message: Option<String>,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
193#[serde(rename_all = "camelCase")]
194pub enum SpeakerLibraryValidationStatus {
195    Valid,
196    Missing,
197    Invalid,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
201#[serde(rename_all = "camelCase")]
202pub struct SpeakerProfileState {
203    pub id: String,
204    pub label: String,
205    pub metadata: BTreeMap<String, String>,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
209#[serde(rename_all = "camelCase")]
210#[serde(deny_unknown_fields)]
211pub struct SpeakerProfileEdit {
212    #[serde(default)]
213    pub id: Option<String>,
214    #[serde(default)]
215    pub label: Option<String>,
216    #[serde(default)]
217    pub metadata: Option<BTreeMap<String, String>>,
218}
219
220#[derive(Debug, Clone, PartialEq, Serialize)]
221#[serde(rename_all = "camelCase")]
222pub struct SpeakerTraceState {
223    pub path: PathBuf,
224    pub status: SpeakerTraceStateStatus,
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub scan_root: Option<PathBuf>,
227    pub speakers: Vec<SpeakerTraceSpeaker>,
228    pub errors: Vec<SpeakerTraceError>,
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub message: Option<String>,
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
234#[serde(rename_all = "camelCase")]
235pub enum SpeakerTraceStateStatus {
236    Valid,
237    Missing,
238    Invalid,
239}
240
241pub fn resolve_speaker_directory(
242    selection: &SpeakerDirectorySelection,
243    current_dir: &Path,
244) -> Result<ResolvedSpeakerDirectory, NativeWhisperxError> {
245    if let Some(explicit_path) = &selection.explicit_path {
246        return Ok(ResolvedSpeakerDirectory {
247            path: absolute_from_base(current_dir, explicit_path),
248            scope: ResolvedSpeakerDirectoryScope::Explicit,
249        });
250    }
251
252    match selection.scope {
253        SpeakerDirectoryScope::Auto => {
254            let local = local_speaker_directory(current_dir);
255            if local.is_dir() {
256                Ok(ResolvedSpeakerDirectory {
257                    path: local,
258                    scope: ResolvedSpeakerDirectoryScope::Local,
259                })
260            } else {
261                Ok(ResolvedSpeakerDirectory {
262                    path: global_speaker_directory()?,
263                    scope: ResolvedSpeakerDirectoryScope::Global,
264                })
265            }
266        }
267        SpeakerDirectoryScope::Local => Ok(ResolvedSpeakerDirectory {
268            path: local_speaker_directory(current_dir),
269            scope: ResolvedSpeakerDirectoryScope::Local,
270        }),
271        SpeakerDirectoryScope::Global => Ok(ResolvedSpeakerDirectory {
272            path: global_speaker_directory()?,
273            scope: ResolvedSpeakerDirectoryScope::Global,
274        }),
275    }
276}
277
278pub fn local_speaker_directory(current_dir: &Path) -> PathBuf {
279    current_dir.join(LOCAL_SPEAKER_DIRECTORY)
280}
281
282pub fn global_speaker_directory() -> Result<PathBuf, NativeWhisperxError> {
283    let data_dir = dirs::data_dir().ok_or_else(|| {
284        NativeWhisperxError::InvalidConfig(
285            "could not resolve a platform data directory for the global Speaker Directory"
286                .to_string(),
287        )
288    })?;
289    Ok(data_dir
290        .join(GLOBAL_SPEAKER_DIRECTORY_APP)
291        .join(GLOBAL_SPEAKER_DIRECTORY_NAME))
292}
293
294pub fn speaker_library_path(speaker_directory: &Path) -> PathBuf {
295    speaker_directory.join(SPEAKER_LIBRARY_FILE)
296}
297
298pub fn speaker_trace_path(speaker_directory: &Path) -> PathBuf {
299    speaker_directory.join(SPEAKER_TRACE_FILE)
300}
301
302pub fn validate_speaker_library(
303    speaker_directory: &Path,
304) -> Result<SpeakerLibraryValidation, NativeWhisperxError> {
305    validate_speaker_library_file(&speaker_library_path(speaker_directory))
306}
307
308pub fn validate_speaker_library_file(
309    library_path: &Path,
310) -> Result<SpeakerLibraryValidation, NativeWhisperxError> {
311    let bytes = fs::read(library_path)?;
312    let (validation, _) = parse_canonical_speaker_library_json(library_path, &bytes)?;
313    Ok(validation)
314}
315
316pub fn list_speaker_profiles(
317    selection: SpeakerDirectorySelection,
318    include_drafts: bool,
319) -> Result<Vec<SpeakerProfileSummary>, NativeWhisperxError> {
320    let current_dir = std::env::current_dir()?;
321    let resolved = resolve_speaker_directory(&selection, &current_dir)?;
322    let Some(library) = load_speaker_library_if_present(&resolved.path)? else {
323        return Ok(Vec::new());
324    };
325    Ok(library
326        .profiles()
327        .filter(|profile| include_drafts || speaker_profile_status(profile) != "draft")
328        .map(|profile| SpeakerProfileSummary {
329            speaker_id: profile.id().as_str().to_string(),
330            label: profile.label().as_str().to_string(),
331            status: speaker_profile_status(profile),
332            embedding_count: profile.embeddings().len(),
333            metadata: profile.metadata_map().clone(),
334        })
335        .collect())
336}
337
338pub fn update_speaker_profile(
339    speaker_directory: &Path,
340    profile_id: &str,
341    edit: SpeakerProfileEdit,
342) -> Result<SpeakerLibraryValidation, NativeWhisperxError> {
343    if profile_id.trim().is_empty() {
344        return Err(NativeWhisperxError::InvalidConfig(
345            "Speaker Library profile id must not be empty".to_string(),
346        ));
347    }
348    if let Some(request_id) = &edit.id {
349        if request_id != profile_id {
350            return Err(NativeWhisperxError::InvalidConfig(format!(
351                "Speaker Library profile ids are immutable: `{profile_id}` cannot be changed to `{request_id}`"
352            )));
353        }
354    }
355    if edit.label.is_none() && edit.metadata.is_none() {
356        return Err(NativeWhisperxError::InvalidConfig(
357            "Speaker Library profile edit must include a label or metadata".to_string(),
358        ));
359    }
360
361    let library_path = speaker_library_path(speaker_directory);
362    let mut value = read_canonical_speaker_library_value(&library_path)?;
363    let profile = speaker_profile_value_mut(&library_path, &mut value, profile_id)?;
364
365    if let Some(label) = edit.label {
366        if label.trim().is_empty() {
367            return Err(NativeWhisperxError::InvalidConfig(
368                "Speaker Library profile label must not be empty".to_string(),
369            ));
370        }
371        profile.insert("label".to_string(), Value::String(label));
372    }
373    if let Some(metadata) = edit.metadata {
374        let metadata = metadata
375            .into_iter()
376            .map(|(key, value)| (key, Value::String(value)))
377            .collect::<Map<String, Value>>();
378        profile.insert("metadata".to_string(), Value::Object(metadata));
379    }
380
381    write_validated_speaker_library_value(&library_path, &value)
382}
383
384pub fn delete_speaker_profile(
385    speaker_directory: &Path,
386    profile_id: &str,
387) -> Result<SpeakerLibraryValidation, NativeWhisperxError> {
388    if profile_id.trim().is_empty() {
389        return Err(NativeWhisperxError::InvalidConfig(
390            "Speaker Library profile id must not be empty".to_string(),
391        ));
392    }
393
394    let library_path = speaker_library_path(speaker_directory);
395    let mut value = read_canonical_speaker_library_value(&library_path)?;
396    let profiles = speaker_profiles_array_mut(&library_path, &mut value)?;
397    let Some(index) = profiles.iter().position(|profile| {
398        profile
399            .as_object()
400            .and_then(|object| object.get("id"))
401            .and_then(Value::as_str)
402            == Some(profile_id)
403    }) else {
404        return Err(NativeWhisperxError::InvalidConfig(format!(
405            "Speaker Library profile `{profile_id}` was not found"
406        )));
407    };
408    profiles.remove(index);
409    if profiles.is_empty() {
410        fs::remove_file(&library_path)?;
411        return Ok(SpeakerLibraryValidation {
412            path: library_path,
413            profile_count: 0,
414        });
415    }
416
417    write_validated_speaker_library_value(&library_path, &value)
418}
419
420pub fn reject_draft_speaker_profile_creation() -> Result<(), NativeWhisperxError> {
421    Err(NativeWhisperxError::InvalidConfig(
422        "creating draft Speaker Library profiles without embeddings is not supported".to_string(),
423    ))
424}
425
426pub(crate) fn load_speaker_library_if_present(
427    speaker_directory: &Path,
428) -> Result<Option<SpeakerLibrary>, NativeWhisperxError> {
429    let path = speaker_library_path(speaker_directory);
430    match fs::read_to_string(&path) {
431        Ok(json) => SpeakerLibrary::from_json_str(&json)
432            .map(Some)
433            .map_err(|error| {
434                NativeWhisperxError::InvalidConfig(format!(
435                    "Speaker Library `{}` is malformed or incompatible: {error}",
436                    path.display()
437                ))
438            }),
439        Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
440        Err(error) => Err(error.into()),
441    }
442}
443
444pub(crate) fn save_speaker_library(
445    speaker_directory: &Path,
446    library: &SpeakerLibrary,
447) -> Result<SpeakerLibraryValidation, NativeWhisperxError> {
448    let library_path = speaker_library_path(speaker_directory);
449    let json = library.to_json_string().map_err(|error| {
450        NativeWhisperxError::InvalidConfig(format!(
451            "Speaker Library `{}` could not be serialized: {error}",
452            library_path.display()
453        ))
454    })?;
455    let value = serde_json::from_str::<Value>(&json)?;
456    write_validated_speaker_library_value(&library_path, &value)
457}
458
459#[cfg(feature = "diarization")]
460pub(crate) fn filter_speaker_library_drafts(
461    library: &SpeakerLibrary,
462    include_drafts: bool,
463) -> Result<(SpeakerLibrary, usize), NativeWhisperxError> {
464    if include_drafts {
465        return Ok((library.clone(), 0));
466    }
467
468    let mut filtered = SpeakerLibrary::new();
469    let mut filtered_count = 0usize;
470    for profile in library.profiles() {
471        if speaker_profile_status(profile) == "draft" {
472            filtered_count += 1;
473            continue;
474        }
475        filtered
476            .add_profile(profile.clone())
477            .map_err(speaker_library_error)?;
478    }
479    Ok((filtered, filtered_count))
480}
481
482pub(crate) fn upsert_speaker_profile_embedding(
483    library: &SpeakerLibrary,
484    profile_id: &str,
485    label: &str,
486    embedding: SpeakerEmbedding,
487    metadata: BTreeMap<String, String>,
488) -> Result<(SpeakerLibrary, bool), NativeWhisperxError> {
489    let speaker_id = SpeakerId::new(profile_id.to_string()).map_err(speaker_library_error)?;
490    let speaker_label = SpeakerLabel::new(label.to_string()).map_err(speaker_library_error)?;
491    let mut updated = SpeakerLibrary::new();
492    let mut matched = false;
493
494    for profile in library.profiles() {
495        if profile.id().as_str() == profile_id {
496            if profile.label().as_str() != label {
497                return Err(NativeWhisperxError::InvalidConfig(format!(
498                    "Speaker Library profile `{profile_id}` already has label `{}`; refusing to relabel it to `{label}`",
499                    profile.label().as_str()
500                )));
501            }
502            let mut replacement = SpeakerProfile::new(speaker_id.clone(), speaker_label.clone());
503            for existing_embedding in profile.embeddings() {
504                replacement
505                    .add_embedding(existing_embedding.clone())
506                    .map_err(speaker_library_error)?;
507            }
508            replacement
509                .add_embedding(embedding.clone())
510                .map_err(speaker_library_error)?;
511            for (key, value) in profile.metadata_map().iter().chain(metadata.iter()) {
512                replacement = replacement.metadata(key.clone(), value.clone());
513            }
514            updated
515                .add_profile(replacement)
516                .map_err(speaker_library_error)?;
517            matched = true;
518        } else {
519            updated
520                .add_profile(profile.clone())
521                .map_err(speaker_library_error)?;
522        }
523    }
524
525    if !matched {
526        let mut profile = SpeakerProfile::new(speaker_id, speaker_label)
527            .with_embedding(embedding)
528            .map_err(speaker_library_error)?;
529        for (key, value) in metadata {
530            profile = profile.metadata(key, value);
531        }
532        updated
533            .add_profile(profile)
534            .map_err(speaker_library_error)?;
535    }
536
537    Ok((updated, matched))
538}
539
540pub(crate) fn speaker_profile_status(profile: &SpeakerProfile) -> String {
541    profile
542        .metadata_map()
543        .get("status")
544        .cloned()
545        .filter(|status| !status.trim().is_empty())
546        .unwrap_or_else(|| "confirmed".to_string())
547}
548
549fn speaker_library_error(error: impl std::fmt::Display) -> NativeWhisperxError {
550    NativeWhisperxError::InvalidConfig(error.to_string())
551}
552
553fn read_canonical_speaker_library_value(library_path: &Path) -> Result<Value, NativeWhisperxError> {
554    let bytes = fs::read(library_path)?;
555    let value = serde_json::from_slice::<Value>(&bytes)?;
556    validate_canonical_snapshot_shape(library_path, &value)?;
557    parse_canonical_speaker_library_json(library_path, &bytes)?;
558    Ok(value)
559}
560
561fn speaker_profiles_array_mut<'a>(
562    library_path: &Path,
563    value: &'a mut Value,
564) -> Result<&'a mut Vec<Value>, NativeWhisperxError> {
565    value
566        .as_object_mut()
567        .and_then(|object| object.get_mut("profiles"))
568        .and_then(Value::as_array_mut)
569        .ok_or_else(|| {
570            NativeWhisperxError::InvalidConfig(format!(
571                "Speaker Library `{}` must contain a profiles array",
572                library_path.display()
573            ))
574        })
575}
576
577fn speaker_profile_value_mut<'a>(
578    library_path: &Path,
579    value: &'a mut Value,
580    profile_id: &str,
581) -> Result<&'a mut Map<String, Value>, NativeWhisperxError> {
582    let profiles = speaker_profiles_array_mut(library_path, value)?;
583    profiles
584        .iter_mut()
585        .filter_map(Value::as_object_mut)
586        .find(|profile| profile.get("id").and_then(Value::as_str) == Some(profile_id))
587        .ok_or_else(|| {
588            NativeWhisperxError::InvalidConfig(format!(
589                "Speaker Library profile `{profile_id}` was not found"
590            ))
591        })
592}
593
594fn write_validated_speaker_library_value(
595    library_path: &Path,
596    value: &Value,
597) -> Result<SpeakerLibraryValidation, NativeWhisperxError> {
598    let bytes = serde_json::to_vec_pretty(value)?;
599    let (validation, _) = parse_canonical_speaker_library_json(library_path, &bytes)?;
600    if let Some(parent) = library_path.parent() {
601        fs::create_dir_all(parent)?;
602    }
603
604    let temp_path = library_path.with_file_name(format!(
605        ".{}.tmp-{}-{}",
606        library_path
607            .file_name()
608            .and_then(|name| name.to_str())
609            .unwrap_or(SPEAKER_LIBRARY_FILE),
610        std::process::id(),
611        SystemTime::now()
612            .duration_since(UNIX_EPOCH)
613            .map(|duration| duration.as_nanos())
614            .unwrap_or_default()
615    ));
616    fs::write(&temp_path, &bytes)?;
617    if let Err(error) = fs::rename(&temp_path, library_path) {
618        let _ = fs::remove_file(&temp_path);
619        return Err(error.into());
620    }
621    Ok(validation)
622}
623
624fn parse_canonical_speaker_library_json(
625    library_path: &Path,
626    bytes: &[u8],
627) -> Result<(SpeakerLibraryValidation, SpeakerLibrary), NativeWhisperxError> {
628    let value = serde_json::from_slice::<Value>(bytes)?;
629    validate_canonical_snapshot_shape(library_path, &value)?;
630
631    let json = std::str::from_utf8(bytes).map_err(|error| {
632        NativeWhisperxError::InvalidConfig(format!(
633            "Speaker Library `{}` is not valid UTF-8: {error}",
634            library_path.display()
635        ))
636    })?;
637    let library = SpeakerLibrary::from_json_str(json).map_err(|error| {
638        NativeWhisperxError::InvalidConfig(format!(
639            "Speaker Library `{}` is malformed or incompatible: {error}",
640            library_path.display()
641        ))
642    })?;
643
644    let validation = SpeakerLibraryValidation {
645        path: library_path.to_path_buf(),
646        profile_count: library.len(),
647    };
648    Ok((validation, library))
649}
650
651fn validate_canonical_snapshot_shape(
652    library_path: &Path,
653    value: &Value,
654) -> Result<(), NativeWhisperxError> {
655    let object = value.as_object().ok_or_else(|| {
656        NativeWhisperxError::InvalidConfig(format!(
657            "Speaker Library `{}` must be a JSON object",
658            library_path.display()
659        ))
660    })?;
661    for key in object.keys() {
662        if !matches!(key.as_str(), "version" | "embedding_model" | "profiles") {
663            return Err(NativeWhisperxError::InvalidConfig(format!(
664                "Speaker Library `{}` is not canonical: unexpected top-level field `{key}`",
665                library_path.display()
666            )));
667        }
668    }
669    Ok(())
670}
671
672pub fn read_speaker_directory_state(
673    resolved: &ResolvedSpeakerDirectory,
674) -> Result<SpeakerDirectoryState, NativeWhisperxError> {
675    let library_path = speaker_library_path(&resolved.path);
676    let (library_state, profiles) = match fs::read(&library_path) {
677        Ok(bytes) => match parse_canonical_speaker_library_json(&library_path, &bytes) {
678            Ok((validation, library)) => {
679                let profiles = library
680                    .profiles()
681                    .map(|profile| SpeakerProfileState {
682                        id: profile.id().as_str().to_string(),
683                        label: profile.label().as_str().to_string(),
684                        metadata: profile.metadata_map().clone(),
685                    })
686                    .collect();
687                (
688                    SpeakerLibraryState {
689                        path: validation.path,
690                        status: SpeakerLibraryValidationStatus::Valid,
691                        profile_count: validation.profile_count,
692                        message: None,
693                    },
694                    profiles,
695                )
696            }
697            Err(error) => (
698                SpeakerLibraryState {
699                    path: library_path.clone(),
700                    status: SpeakerLibraryValidationStatus::Invalid,
701                    profile_count: 0,
702                    message: Some(error.to_string()),
703                },
704                Vec::new(),
705            ),
706        },
707        Err(error) if error.kind() == ErrorKind::NotFound => (
708            SpeakerLibraryState {
709                path: library_path,
710                status: SpeakerLibraryValidationStatus::Missing,
711                profile_count: 0,
712                message: Some("Speaker Library file is missing".to_string()),
713            },
714            Vec::new(),
715        ),
716        Err(error) => return Err(error.into()),
717    };
718
719    let trace = read_speaker_trace_state(&speaker_trace_path(&resolved.path))?;
720
721    Ok(SpeakerDirectoryState {
722        scope: resolved.scope.into(),
723        path: resolved.path.clone(),
724        library: library_state,
725        profiles,
726        trace,
727    })
728}
729
730fn read_speaker_trace_state(trace_path: &Path) -> Result<SpeakerTraceState, NativeWhisperxError> {
731    match fs::read(trace_path) {
732        Ok(bytes) => match serde_json::from_slice::<SpeakerTrace>(&bytes) {
733            Ok(trace) => Ok(SpeakerTraceState {
734                path: trace_path.to_path_buf(),
735                status: SpeakerTraceStateStatus::Valid,
736                scan_root: Some(trace.scan_root),
737                speakers: trace.speakers,
738                errors: trace.errors,
739                message: None,
740            }),
741            Err(error) => Ok(SpeakerTraceState {
742                path: trace_path.to_path_buf(),
743                status: SpeakerTraceStateStatus::Invalid,
744                scan_root: None,
745                speakers: Vec::new(),
746                errors: Vec::new(),
747                message: Some(format!(
748                    "Speaker Trace is malformed or incompatible: {error}"
749                )),
750            }),
751        },
752        Err(error) if error.kind() == ErrorKind::NotFound => Ok(SpeakerTraceState {
753            path: trace_path.to_path_buf(),
754            status: SpeakerTraceStateStatus::Missing,
755            scan_root: None,
756            speakers: Vec::new(),
757            errors: Vec::new(),
758            message: Some("Speaker Trace file is missing".to_string()),
759        }),
760        Err(error) => Err(error.into()),
761    }
762}
763
764impl From<ResolvedSpeakerDirectoryScope> for SpeakerDirectoryStateScope {
765    fn from(value: ResolvedSpeakerDirectoryScope) -> Self {
766        match value {
767            ResolvedSpeakerDirectoryScope::Local => Self::Local,
768            ResolvedSpeakerDirectoryScope::Global => Self::Global,
769            ResolvedSpeakerDirectoryScope::Explicit => Self::Explicit,
770        }
771    }
772}
773
774pub fn rebuild_speaker_trace(
775    speaker_directory: &Path,
776    scan_root: &Path,
777) -> Result<SpeakerTraceRebuildReport, NativeWhisperxError> {
778    let library = load_speaker_library_for_trace(speaker_directory)?;
779    let scan_root = absolute_from_base(&std::env::current_dir()?, scan_root);
780    let scan = scan_transcript_files(&scan_root)?;
781    let mut builder = SpeakerTraceBuilder::new(scan_root.clone(), &library);
782    let mut accepted_entries = 0usize;
783    for json_path in scan.json_candidates {
784        match read_trace_transcript(&json_path) {
785            Ok(Some(transcript)) => {
786                accepted_entries += builder.add_transcript(json_path, transcript);
787            }
788            Ok(None) => {}
789            Err(message) => builder.add_error(json_path, message),
790        }
791    }
792
793    let trace = builder.finish();
794    let stats = SpeakerTraceRebuildStats {
795        scanned_files: scan.scanned_files,
796        accepted_entries,
797        ignored_non_json_files: scan.ignored_non_json_files,
798        malformed_json_errors: trace.errors.len(),
799    };
800    let trace_path = speaker_trace_path(speaker_directory);
801    if let Some(parent) = trace_path.parent() {
802        fs::create_dir_all(parent)?;
803    }
804    fs::write(&trace_path, serde_json::to_string_pretty(&trace)?)?;
805
806    Ok(SpeakerTraceRebuildReport {
807        trace_path,
808        trace,
809        stats,
810    })
811}
812
813fn absolute_from_base(base: &Path, path: &Path) -> PathBuf {
814    if path.is_absolute() {
815        path.to_path_buf()
816    } else {
817        base.join(path)
818    }
819}
820
821fn load_speaker_library_for_trace(
822    speaker_directory: &Path,
823) -> Result<SpeakerLibrary, NativeWhisperxError> {
824    let path = speaker_library_path(speaker_directory);
825    match fs::read_to_string(&path) {
826        Ok(json) => SpeakerLibrary::from_json_str(&json).map_err(|error| {
827            NativeWhisperxError::InvalidConfig(format!(
828                "Speaker Library `{}` is malformed or incompatible: {error}",
829                path.display()
830            ))
831        }),
832        Err(error) if error.kind() == ErrorKind::NotFound => Ok(SpeakerLibrary::new()),
833        Err(error) => Err(error.into()),
834    }
835}
836
837#[derive(Debug, Default)]
838struct SpeakerTraceScan {
839    json_candidates: Vec<PathBuf>,
840    scanned_files: usize,
841    ignored_non_json_files: usize,
842}
843
844fn scan_transcript_files(scan_root: &Path) -> Result<SpeakerTraceScan, NativeWhisperxError> {
845    let mut scan = SpeakerTraceScan::default();
846    collect_transcript_files(scan_root, &mut scan)?;
847    scan.json_candidates.sort();
848    Ok(scan)
849}
850
851fn collect_transcript_files(
852    path: &Path,
853    scan: &mut SpeakerTraceScan,
854) -> Result<(), NativeWhisperxError> {
855    if path.is_file() {
856        scan.scanned_files += 1;
857        if path
858            .extension()
859            .and_then(|extension| extension.to_str())
860            .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
861        {
862            scan.json_candidates.push(path.to_path_buf());
863        } else {
864            scan.ignored_non_json_files += 1;
865        }
866        return Ok(());
867    }
868
869    for entry in fs::read_dir(path)? {
870        let entry = entry?;
871        let path = entry.path();
872        if path.is_dir() {
873            collect_transcript_files(&path, scan)?;
874        } else if path.is_file() {
875            scan.scanned_files += 1;
876            if path
877                .extension()
878                .and_then(|extension| extension.to_str())
879                .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
880            {
881                scan.json_candidates.push(path);
882            } else {
883                scan.ignored_non_json_files += 1;
884            }
885        }
886    }
887    Ok(())
888}
889
890fn read_trace_transcript(path: &Path) -> Result<Option<TranscriptionContract>, String> {
891    let bytes =
892        fs::read(path).map_err(|error| format!("failed to read transcript JSON: {error}"))?;
893    let value = serde_json::from_slice::<Value>(&bytes)
894        .map_err(|error| format!("malformed transcript JSON: {error}"))?;
895    if !value
896        .as_object()
897        .and_then(|object| object.get("segments"))
898        .is_some_and(Value::is_array)
899    {
900        return Ok(None);
901    }
902
903    if let Ok(transcript) = serde_json::from_value::<TranscriptionContract>(value.clone()) {
904        if let Err(error) = transcript.validate_strict() {
905            return Err(format!("malformed Native JSON transcript: {error}"));
906        }
907        return Ok(Some(transcript));
908    }
909
910    import_whisperx_json(&bytes)
911        .map(Some)
912        .map_err(|error| error.to_string())
913}
914
915#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
916enum SpeakerTraceKey {
917    Enrolled { profile_id: String, label: String },
918    Anonymous { label: String },
919}
920
921#[derive(Debug, Default)]
922struct SpeakerTraceFileBuilder {
923    segment_count: usize,
924    total_duration: f64,
925    spans: Vec<SpeakerTraceSpan>,
926}
927
928#[derive(Debug)]
929struct SpeakerTraceBuilder {
930    scan_root: PathBuf,
931    profile_ids: BTreeMap<String, String>,
932    profile_labels: BTreeMap<String, (String, String)>,
933    speakers: BTreeMap<SpeakerTraceKey, BTreeMap<PathBuf, SpeakerTraceFileBuilder>>,
934    errors: Vec<SpeakerTraceError>,
935}
936
937impl SpeakerTraceBuilder {
938    fn new(scan_root: PathBuf, library: &SpeakerLibrary) -> Self {
939        let mut profile_ids = BTreeMap::new();
940        let mut profile_labels = BTreeMap::new();
941        for profile in library.profiles() {
942            let profile_id = profile.id().as_str().to_string();
943            let label = profile.label().as_str().to_string();
944            profile_ids.insert(profile_id.clone(), label.clone());
945            profile_labels
946                .entry(label.clone())
947                .or_insert((profile_id, label));
948        }
949
950        Self {
951            scan_root,
952            profile_ids,
953            profile_labels,
954            speakers: BTreeMap::new(),
955            errors: Vec::new(),
956        }
957    }
958
959    fn add_transcript(&mut self, source_file: PathBuf, transcript: TranscriptionContract) -> usize {
960        let mut accepted_entries = 0usize;
961        for segment in transcript.segments {
962            let Some(speaker) = segment
963                .speaker
964                .as_deref()
965                .map(str::trim)
966                .filter(|speaker| !speaker.is_empty())
967            else {
968                continue;
969            };
970            accepted_entries += 1;
971            let key = self.trace_key(speaker);
972            let file = self
973                .speakers
974                .entry(key)
975                .or_default()
976                .entry(source_file.clone())
977                .or_default();
978            file.segment_count += 1;
979            if let Some(duration) = segment.duration_seconds() {
980                file.total_duration += duration;
981            }
982            file.spans.push(SpeakerTraceSpan {
983                start_seconds: segment.start_seconds,
984                end_seconds: segment.end_seconds,
985                snippet: segment.text.trim().to_string(),
986            });
987        }
988        accepted_entries
989    }
990
991    fn add_error(&mut self, source_file: PathBuf, message: String) {
992        self.errors.push(SpeakerTraceError {
993            source_file,
994            message,
995        });
996    }
997
998    fn finish(self) -> SpeakerTrace {
999        let speakers = self
1000            .speakers
1001            .into_iter()
1002            .map(|(key, files)| {
1003                let files = files
1004                    .into_iter()
1005                    .map(|(source_file, file)| SpeakerTraceFile {
1006                        source_file,
1007                        segment_count: file.segment_count,
1008                        total_duration: file.total_duration,
1009                        spans: file.spans,
1010                    })
1011                    .collect();
1012                match key {
1013                    SpeakerTraceKey::Enrolled { profile_id, label } => SpeakerTraceSpeaker {
1014                        kind: SpeakerTraceSpeakerKind::Enrolled,
1015                        profile_id: Some(profile_id),
1016                        label: Some(label),
1017                        anonymous_label: None,
1018                        files,
1019                    },
1020                    SpeakerTraceKey::Anonymous { label } => SpeakerTraceSpeaker {
1021                        kind: SpeakerTraceSpeakerKind::Anonymous,
1022                        profile_id: None,
1023                        label: None,
1024                        anonymous_label: Some(label),
1025                        files,
1026                    },
1027                }
1028            })
1029            .collect();
1030        SpeakerTrace {
1031            version: 1,
1032            scan_root: self.scan_root,
1033            speakers,
1034            errors: self.errors,
1035        }
1036    }
1037
1038    fn trace_key(&self, speaker: &str) -> SpeakerTraceKey {
1039        if let Some(label) = self.profile_ids.get(speaker) {
1040            return SpeakerTraceKey::Enrolled {
1041                profile_id: speaker.to_string(),
1042                label: label.clone(),
1043            };
1044        }
1045        if let Some((profile_id, label)) = self.profile_labels.get(speaker) {
1046            return SpeakerTraceKey::Enrolled {
1047                profile_id: profile_id.clone(),
1048                label: label.clone(),
1049            };
1050        }
1051        SpeakerTraceKey::Anonymous {
1052            label: speaker.to_string(),
1053        }
1054    }
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use super::*;
1060
1061    #[test]
1062    fn auto_resolution_prefers_existing_local_speaker_directory() {
1063        let temp = tempfile::tempdir().expect("tempdir");
1064        let local = temp.path().join(LOCAL_SPEAKER_DIRECTORY);
1065        fs::create_dir_all(&local).expect("local speaker directory");
1066
1067        let resolved =
1068            resolve_speaker_directory(&SpeakerDirectorySelection::default(), temp.path()).unwrap();
1069
1070        assert_eq!(resolved.path, local);
1071        assert_eq!(resolved.scope, ResolvedSpeakerDirectoryScope::Local);
1072    }
1073
1074    #[test]
1075    fn explicit_resolution_is_relative_to_current_directory() {
1076        let temp = tempfile::tempdir().expect("tempdir");
1077
1078        let resolved = resolve_speaker_directory(
1079            &SpeakerDirectorySelection {
1080                scope: SpeakerDirectoryScope::Auto,
1081                explicit_path: Some(PathBuf::from("controlled-speakers")),
1082            },
1083            temp.path(),
1084        )
1085        .unwrap();
1086
1087        assert_eq!(resolved.path, temp.path().join("controlled-speakers"));
1088        assert_eq!(resolved.scope, ResolvedSpeakerDirectoryScope::Explicit);
1089    }
1090
1091    #[test]
1092    fn validates_upstream_speaker_library_snapshot() {
1093        let temp = tempfile::tempdir().expect("tempdir");
1094        let directory = temp.path().join("speakers");
1095        fs::create_dir_all(&directory).expect("speaker directory");
1096        fs::write(speaker_library_path(&directory), valid_library_json()).expect("library");
1097
1098        let validation = validate_speaker_library(&directory).unwrap();
1099
1100        assert_eq!(validation.profile_count, 1);
1101        assert_eq!(validation.path, speaker_library_path(&directory));
1102    }
1103
1104    #[test]
1105    fn validation_rejects_incompatible_speaker_library_snapshot() {
1106        let temp = tempfile::tempdir().expect("tempdir");
1107        let directory = temp.path().join("speakers");
1108        fs::create_dir_all(&directory).expect("speaker directory");
1109        fs::write(
1110            speaker_library_path(&directory),
1111            valid_library_json().replace("\"values\": [1.0, 0.0]", "\"values\": [2.0, 0.0]"),
1112        )
1113        .expect("library");
1114
1115        let error = validate_speaker_library(&directory)
1116            .expect_err("incompatible library should fail")
1117            .to_string();
1118
1119        assert!(error.contains("malformed or incompatible"));
1120        assert!(error.contains("normalized"));
1121    }
1122
1123    #[test]
1124    fn validation_rejects_trace_provenance_in_library_snapshot() {
1125        let temp = tempfile::tempdir().expect("tempdir");
1126        let directory = temp.path().join("speakers");
1127        fs::create_dir_all(&directory).expect("speaker directory");
1128        fs::write(
1129            speaker_library_path(&directory),
1130            valid_library_json().replace(
1131                "\"profiles\": [",
1132                "\"speaker_trace\": {\"files\": []}, \"profiles\": [",
1133            ),
1134        )
1135        .expect("library");
1136
1137        let error = validate_speaker_library(&directory)
1138            .expect_err("trace provenance should fail")
1139            .to_string();
1140
1141        assert!(error.contains("unexpected top-level field `speaker_trace`"));
1142    }
1143
1144    #[test]
1145    fn updates_profile_label_and_metadata_without_changing_id() {
1146        let temp = tempfile::tempdir().expect("tempdir");
1147        let directory = temp.path().join("speakers");
1148        fs::create_dir_all(&directory).expect("speaker directory");
1149        fs::write(speaker_library_path(&directory), valid_library_json()).expect("library");
1150
1151        let validation = update_speaker_profile(
1152            &directory,
1153            "speaker-a",
1154            SpeakerProfileEdit {
1155                id: Some("speaker-a".to_string()),
1156                label: Some("Updated Speaker".to_string()),
1157                metadata: Some(BTreeMap::from([
1158                    ("note".to_string(), "changed".to_string()),
1159                    ("external_id".to_string(), "crm-123".to_string()),
1160                ])),
1161            },
1162        )
1163        .expect("profile edit");
1164
1165        assert_eq!(validation.profile_count, 1);
1166        let state = read_speaker_directory_state(&ResolvedSpeakerDirectory {
1167            path: directory.clone(),
1168            scope: ResolvedSpeakerDirectoryScope::Explicit,
1169        })
1170        .expect("state");
1171        assert_eq!(state.profiles[0].id, "speaker-a");
1172        assert_eq!(state.profiles[0].label, "Updated Speaker");
1173        assert_eq!(
1174            state.profiles[0]
1175                .metadata
1176                .get("external_id")
1177                .map(String::as_str),
1178            Some("crm-123")
1179        );
1180    }
1181
1182    #[test]
1183    fn update_rejects_profile_id_mutation() {
1184        let temp = tempfile::tempdir().expect("tempdir");
1185        let directory = temp.path().join("speakers");
1186        fs::create_dir_all(&directory).expect("speaker directory");
1187        fs::write(speaker_library_path(&directory), valid_library_json()).expect("library");
1188
1189        let error = update_speaker_profile(
1190            &directory,
1191            "speaker-a",
1192            SpeakerProfileEdit {
1193                id: Some("speaker-renamed".to_string()),
1194                label: Some("Updated Speaker".to_string()),
1195                metadata: None,
1196            },
1197        )
1198        .expect_err("id mutation should fail")
1199        .to_string();
1200
1201        assert!(error.contains("profile ids are immutable"));
1202        let state = read_speaker_directory_state(&ResolvedSpeakerDirectory {
1203            path: directory.clone(),
1204            scope: ResolvedSpeakerDirectoryScope::Explicit,
1205        })
1206        .expect("state");
1207        assert_eq!(state.profiles[0].id, "speaker-a");
1208        assert_eq!(state.profiles[0].label, "Speaker A");
1209    }
1210
1211    #[test]
1212    fn update_rejects_invalid_label_before_write() {
1213        let temp = tempfile::tempdir().expect("tempdir");
1214        let directory = temp.path().join("speakers");
1215        fs::create_dir_all(&directory).expect("speaker directory");
1216        fs::write(speaker_library_path(&directory), valid_library_json()).expect("library");
1217
1218        let error = update_speaker_profile(
1219            &directory,
1220            "speaker-a",
1221            SpeakerProfileEdit {
1222                id: None,
1223                label: Some("   ".to_string()),
1224                metadata: None,
1225            },
1226        )
1227        .expect_err("empty label should fail")
1228        .to_string();
1229
1230        assert!(error.contains("profile label must not be empty"));
1231        let saved = fs::read_to_string(speaker_library_path(&directory)).expect("library");
1232        assert!(saved.contains("\"label\": \"Speaker A\""));
1233    }
1234
1235    #[test]
1236    fn deletes_profile_and_validates_saved_library() {
1237        let temp = tempfile::tempdir().expect("tempdir");
1238        let directory = temp.path().join("speakers");
1239        fs::create_dir_all(&directory).expect("speaker directory");
1240        fs::write(speaker_library_path(&directory), two_profile_library_json()).expect("library");
1241
1242        let validation = delete_speaker_profile(&directory, "speaker-b").expect("delete profile");
1243
1244        assert_eq!(validation.profile_count, 1);
1245        let state = read_speaker_directory_state(&ResolvedSpeakerDirectory {
1246            path: directory.clone(),
1247            scope: ResolvedSpeakerDirectoryScope::Explicit,
1248        })
1249        .expect("state");
1250        assert_eq!(state.profiles.len(), 1);
1251        assert_eq!(state.profiles[0].id, "speaker-a");
1252    }
1253
1254    #[test]
1255    fn deleting_final_profile_removes_library_file() {
1256        let temp = tempfile::tempdir().expect("tempdir");
1257        let directory = temp.path().join("speakers");
1258        fs::create_dir_all(&directory).expect("speaker directory");
1259        let library_path = speaker_library_path(&directory);
1260        fs::write(&library_path, valid_library_json()).expect("library");
1261
1262        let validation = delete_speaker_profile(&directory, "speaker-a").expect("delete profile");
1263
1264        assert_eq!(validation.profile_count, 0);
1265        assert!(!library_path.exists());
1266        let state = read_speaker_directory_state(&ResolvedSpeakerDirectory {
1267            path: directory.clone(),
1268            scope: ResolvedSpeakerDirectoryScope::Explicit,
1269        })
1270        .expect("state");
1271        assert_eq!(
1272            state.library.status,
1273            SpeakerLibraryValidationStatus::Missing
1274        );
1275        assert!(state.profiles.is_empty());
1276    }
1277
1278    #[test]
1279    fn rejects_draft_profile_creation_without_embeddings() {
1280        let error = reject_draft_speaker_profile_creation()
1281            .expect_err("draft profile creation should fail")
1282            .to_string();
1283
1284        assert!(error.contains("without embeddings is not supported"));
1285    }
1286
1287    #[test]
1288    fn rebuild_trace_indexes_whisperx_and_native_json_transcripts() {
1289        let temp = tempfile::tempdir().expect("tempdir");
1290        let directory = temp.path().join("speakers");
1291        let scan_root = temp.path().join("outputs");
1292        fs::create_dir_all(&directory).expect("speaker directory");
1293        fs::create_dir_all(&scan_root).expect("scan root");
1294        fs::write(speaker_library_path(&directory), two_profile_library_json()).expect("library");
1295        fs::write(
1296            scan_root.join("sample.json"),
1297            r#"{
1298              "language": "en",
1299              "segments": [
1300                {"id": 0, "start": 0.0, "end": 1.2, "text": "Known by id", "speaker": "speaker-a"},
1301                {"id": 1, "start": 1.2, "end": 2.0, "text": "Still known", "speaker": "speaker-a"},
1302                {"id": 2, "start": 2.5, "end": 3.0, "text": "Unknown turn", "speaker": "SPEAKER_99"}
1303              ]
1304            }"#,
1305        )
1306        .expect("whisperx json");
1307        fs::write(
1308            scan_root.join("sample.native.json"),
1309            r#"{
1310              "segments": [
1311                {
1312                  "index": 0,
1313                  "startSeconds": 3.0,
1314                  "endSeconds": 4.5,
1315                  "text": "Known by label",
1316                  "speaker": "Speaker B",
1317                  "isFinal": true
1318                }
1319              ]
1320            }"#,
1321        )
1322        .expect("native json");
1323        fs::write(scan_root.join("sample.srt"), "not parsed").expect("srt");
1324        fs::write(
1325            scan_root.join("report.json"),
1326            r#"{"kind": "not a transcript"}"#,
1327        )
1328        .expect("unrelated json");
1329
1330        let report = rebuild_speaker_trace(&directory, &scan_root).expect("trace rebuild");
1331
1332        assert_eq!(report.trace.errors, Vec::new());
1333        assert_eq!(report.stats.scanned_files, 4);
1334        assert_eq!(report.stats.accepted_entries, 4);
1335        assert_eq!(report.stats.ignored_non_json_files, 1);
1336        assert_eq!(report.stats.malformed_json_errors, 0);
1337        assert_eq!(report.trace.speakers.len(), 3);
1338        let speaker_a = report
1339            .trace
1340            .speakers
1341            .iter()
1342            .find(|speaker| speaker.profile_id.as_deref() == Some("speaker-a"))
1343            .expect("speaker-a trace");
1344        assert_eq!(speaker_a.kind, SpeakerTraceSpeakerKind::Enrolled);
1345        assert_eq!(speaker_a.label.as_deref(), Some("Speaker A"));
1346        assert_eq!(speaker_a.files.len(), 1);
1347        assert_eq!(speaker_a.files[0].segment_count, 2);
1348        assert_eq!(speaker_a.files[0].total_duration, 2.0);
1349        assert_eq!(speaker_a.files[0].spans[0].start_seconds, Some(0.0));
1350        assert_eq!(speaker_a.files[0].spans[0].end_seconds, Some(1.2));
1351        assert_eq!(speaker_a.files[0].spans[0].snippet, "Known by id");
1352
1353        let speaker_b = report
1354            .trace
1355            .speakers
1356            .iter()
1357            .find(|speaker| speaker.profile_id.as_deref() == Some("speaker-b"))
1358            .expect("speaker-b trace");
1359        assert_eq!(speaker_b.kind, SpeakerTraceSpeakerKind::Enrolled);
1360        assert_eq!(speaker_b.label.as_deref(), Some("Speaker B"));
1361        assert_eq!(speaker_b.files[0].spans[0].snippet, "Known by label");
1362
1363        let anonymous = report
1364            .trace
1365            .speakers
1366            .iter()
1367            .find(|speaker| speaker.anonymous_label.as_deref() == Some("SPEAKER_99"))
1368            .expect("anonymous trace");
1369        assert_eq!(anonymous.kind, SpeakerTraceSpeakerKind::Anonymous);
1370        assert_eq!(anonymous.files[0].spans[0].snippet, "Unknown turn");
1371
1372        let saved = fs::read_to_string(speaker_trace_path(&directory)).expect("saved trace");
1373        assert!(saved.contains("\"sourceFile\""));
1374        assert!(saved.contains("\"segmentCount\""));
1375        assert!(saved.contains("\"totalDuration\""));
1376        assert!(saved.contains("\"spans\""));
1377    }
1378
1379    #[test]
1380    fn rebuild_trace_reports_malformed_json_without_aborting_valid_files() {
1381        let temp = tempfile::tempdir().expect("tempdir");
1382        let directory = temp.path().join("speakers");
1383        let scan_root = temp.path().join("outputs");
1384        fs::create_dir_all(&directory).expect("speaker directory");
1385        fs::create_dir_all(&scan_root).expect("scan root");
1386        fs::write(speaker_library_path(&directory), valid_library_json()).expect("library");
1387        fs::write(
1388            scan_root.join("valid.json"),
1389            r#"{"segments": [{"id": 0, "start": 0.0, "end": 1.0, "text": "ok", "speaker": "speaker-a"}]}"#,
1390        )
1391        .expect("valid json");
1392        fs::write(scan_root.join("broken.json"), "{").expect("broken json");
1393
1394        let report = rebuild_speaker_trace(&directory, &scan_root).expect("trace rebuild");
1395
1396        assert_eq!(report.trace.speakers.len(), 1);
1397        assert_eq!(report.stats.scanned_files, 2);
1398        assert_eq!(report.stats.accepted_entries, 1);
1399        assert_eq!(report.stats.ignored_non_json_files, 0);
1400        assert_eq!(report.stats.malformed_json_errors, 1);
1401        assert_eq!(report.trace.errors.len(), 1);
1402        assert!(report.trace.errors[0].source_file.ends_with("broken.json"));
1403        assert!(report.trace.errors[0]
1404            .message
1405            .contains("malformed transcript JSON"));
1406        let saved = fs::read_to_string(speaker_trace_path(&directory)).expect("saved trace");
1407        assert!(saved.contains("broken.json"));
1408        assert!(saved.contains("speaker-a"));
1409    }
1410
1411    fn valid_library_json() -> String {
1412        r#"{
1413          "version": 1,
1414          "embedding_model": {
1415            "family": "SpeechBrain",
1416            "name": "spkrec",
1417            "version": "1",
1418            "dimensions": 2
1419          },
1420          "profiles": [{
1421            "id": "speaker-a",
1422            "label": "Speaker A",
1423            "embeddings": [{
1424              "values": [1.0, 0.0],
1425              "model": {
1426                "family": "SpeechBrain",
1427                "name": "spkrec",
1428                "version": "1",
1429                "dimensions": 2
1430              },
1431              "sample_rate": 16000
1432            }],
1433            "metadata": {
1434              "note": "fixture"
1435            }
1436          }]
1437        }"#
1438        .to_string()
1439    }
1440
1441    fn two_profile_library_json() -> String {
1442        valid_library_json().replace(
1443            r#"{
1444            "id": "speaker-a",
1445            "label": "Speaker A",
1446            "embeddings": [{
1447              "values": [1.0, 0.0],
1448              "model": {
1449                "family": "SpeechBrain",
1450                "name": "spkrec",
1451                "version": "1",
1452                "dimensions": 2
1453              },
1454              "sample_rate": 16000
1455            }],
1456            "metadata": {
1457              "note": "fixture"
1458            }
1459          }"#,
1460            r#"{
1461            "id": "speaker-a",
1462            "label": "Speaker A",
1463            "embeddings": [{
1464              "values": [1.0, 0.0],
1465              "model": {
1466                "family": "SpeechBrain",
1467                "name": "spkrec",
1468                "version": "1",
1469                "dimensions": 2
1470              },
1471              "sample_rate": 16000
1472            }],
1473            "metadata": {
1474              "note": "fixture"
1475            }
1476          },
1477          {
1478            "id": "speaker-b",
1479            "label": "Speaker B",
1480            "embeddings": [{
1481              "values": [0.0, 1.0],
1482              "model": {
1483                "family": "SpeechBrain",
1484                "name": "spkrec",
1485                "version": "1",
1486                "dimensions": 2
1487              },
1488              "sample_rate": 16000
1489            }],
1490            "metadata": {
1491              "note": "second fixture"
1492            }
1493          }"#,
1494        )
1495    }
1496}