use std::collections::BTreeMap;
use anyhow::Result;
use crate::config::Config;
use crate::dialogue::{DialogueSpan, DialogueStore};
use crate::prose::{
CompiledLexicon, ProseStore, VoiceProfile, VoiceScope, compute_profile_with,
resolve_prose_language,
};
use crate::store::node::Node;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Confidence {
Low,
Medium,
High,
}
impl Confidence {
pub(crate) fn from_counts(utterances: u32, words: u32) -> Self {
if utterances < 5 || words < 40 {
Confidence::Low
} else if utterances < 20 || words < 300 {
Confidence::Medium
} else {
Confidence::High
}
}
pub(crate) fn label(self) -> &'static str {
match self {
Confidence::Low => "low",
Confidence::Medium => "medium",
Confidence::High => "high",
}
}
pub(crate) fn is_comparable(self) -> bool {
matches!(self, Confidence::Medium | Confidence::High)
}
}
const MIN_CHAPTER_WORDS: u32 = 40;
pub(crate) struct CharacterCorpus {
pub all: String,
pub per_chapter: BTreeMap<u32, String>,
pub utterances: u32,
pub words: u32,
}
pub(crate) struct CharacterVoice {
pub name: String,
pub profile: VoiceProfile,
pub confidence: Confidence,
pub utterances: u32,
pub per_chapter: Vec<VoiceProfile>,
}
pub(crate) fn character_corpora(spans: &[(u32, DialogueSpan)]) -> BTreeMap<String, CharacterCorpus> {
let mut out: BTreeMap<String, CharacterCorpus> = BTreeMap::new();
for (ord, span) in spans {
let Some(name) = span.attribution_name.as_deref().map(str::trim).filter(|s| !s.is_empty())
else {
continue;
};
let line = span.speech_text.trim();
if line.is_empty() {
continue;
}
let entry = out.entry(name.to_string()).or_insert_with(|| CharacterCorpus {
all: String::new(),
per_chapter: BTreeMap::new(),
utterances: 0,
words: 0,
});
push_line(&mut entry.all, line);
push_line(entry.per_chapter.entry(*ord).or_default(), line);
entry.utterances += 1;
entry.words += span.word_count;
}
out
}
fn push_line(buf: &mut String, line: &str) {
if !buf.is_empty() {
buf.push(' ');
}
buf.push_str(line);
}
pub(crate) fn character_profiles(
pstore: &ProseStore,
dstore: &DialogueStore,
cfg: &Config,
book: &Node,
explicit_lang: Option<&str>,
now: &str,
) -> Result<Vec<CharacterVoice>> {
let (lang, _note) = resolve_prose_language(explicit_lang, &cfg.language);
let lx = CompiledLexicon::for_language_with(
&lang,
&cfg.prose.extra_modal_tokens,
&cfg.prose.extra_interiority_phrases,
);
let deep = cfg.prose.deep_metrics;
let window = cfg.prose.mattr_window;
let spans = dstore.attributed_spans(&book.slug)?;
let corpora = character_corpora(&spans);
let mut voices = Vec::with_capacity(corpora.len());
for (name, corpus) in corpora {
let profile = compute_profile_with(
&corpus.all,
VoiceScope::Character(name.clone()),
&lang,
&lx,
deep,
window,
);
pstore.upsert(&book.slug, &profile, now)?;
let per_chapter: Vec<VoiceProfile> = corpus
.per_chapter
.iter()
.map(|(ord, text)| {
compute_profile_with(text, VoiceScope::Chapter(*ord), &lang, &lx, deep, window)
})
.filter(|p| p.word_count >= MIN_CHAPTER_WORDS)
.collect();
voices.push(CharacterVoice {
name,
profile,
confidence: Confidence::from_counts(corpus.utterances, corpus.words),
utterances: corpus.utterances,
per_chapter,
});
}
voices.sort_by(|a, b| a.name.cmp(&b.name));
Ok(voices)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dialogue::{AttributionConfidence, SpanForm};
use crate::prose::ProseLanguage;
fn span(ord: u32, name: &str, text: &str) -> (u32, DialogueSpan) {
(
ord,
DialogueSpan {
para_id: "p".into(),
span_index: 0,
form: SpanForm::QuotePair,
char_start: 0,
char_end: 0,
speech_text: text.into(),
word_count: text.split_whitespace().count() as u32,
attribution_name: Some(name.into()),
attribution_conf: AttributionConfidence::Certain,
has_attribution_signal: true,
tag_verb: None,
tag_verb_class: None,
ends_question: false,
ends_exclamation: false,
},
)
}
#[test]
fn corpora_group_by_speaker() {
let spans = vec![
span(1, "Mara", "The tide is turning."),
span(1, "Joren", "Is it?"),
span(2, "Mara", "Look at the water."),
];
let c = character_corpora(&spans);
assert_eq!(c.len(), 2);
let mara = &c["Mara"];
assert_eq!(mara.utterances, 2);
assert!(mara.all.contains("tide") && mara.all.contains("water"));
assert_eq!(mara.words, 8);
assert_eq!(c["Joren"].utterances, 1);
}
#[test]
fn empty_and_unattributed_spans_are_skipped() {
let mut blank = span(1, "Ghost", " ");
let mut noname = span(1, "X", "Hello");
noname.1.attribution_name = None;
blank.1.speech_text = " ".into();
let c = character_corpora(&[blank, noname]);
assert!(c.is_empty());
}
#[test]
fn the_shared_engine_profiles_distinct_voices_distinctly() {
let lx = CompiledLexicon::for_language_with(&ProseLanguage::En, &[], &[]);
let clipped = "Yes. No. Maybe. Fine. Go. Stop. Now. Wait.";
let flowing = "The evening light fell slowly across the wide and silent water, \
and she wondered whether the tide would ever turn again before dawn.";
let a = compute_profile_with(
clipped,
VoiceScope::Character("A".into()),
&ProseLanguage::En,
&lx,
false,
100,
);
let b = compute_profile_with(
flowing,
VoiceScope::Character("B".into()),
&ProseLanguage::En,
&lx,
false,
100,
);
assert!(a.p50 < b.p50, "clipped median {} !< flowing median {}", a.p50, b.p50);
}
#[test]
fn confidence_tracks_corpus_size() {
assert_eq!(Confidence::from_counts(2, 10), Confidence::Low);
assert_eq!(Confidence::from_counts(10, 120), Confidence::Medium);
assert_eq!(Confidence::from_counts(40, 800), Confidence::High);
assert_eq!(Confidence::from_counts(40, 100), Confidence::Medium);
}
}