use std::collections::HashSet;
use rust_stemmers::Stemmer;
use crate::config::Config;
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
const SHORT_SENTENCE_WORDS: usize = 9;
const STAKES_SCALE: f32 = 20.0;
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub(crate) struct IntensitySignals {
pub dialogue_density: f32,
pub stakes_density: f32,
pub short_sentence_ratio: f32,
pub summary_penalty: f32,
pub ending_turn: f32,
}
pub(crate) fn chapter_intensity(s: &IntensitySignals) -> f32 {
let raw = 0.30 * s.stakes_density
+ 0.26 * s.dialogue_density
+ 0.18 * s.short_sentence_ratio
+ 0.16 * s.ending_turn
- 0.22 * s.summary_penalty;
raw.clamp(0.0, 1.0)
}
pub(crate) struct StakesMatcher {
stemmer: Option<Stemmer>,
conflict: HashSet<String>,
summary: &'static [&'static str],
}
impl StakesMatcher {
pub(crate) fn for_language(language: &str) -> Option<Self> {
let (conflict_words, summary) = word_lists(language)?;
let stemmer = crate::config::parse_stemmer_language(language).map(Stemmer::create);
let conflict = conflict_words
.iter()
.map(|w| crate::text::normalize_stem(w, &stemmer))
.filter(|w| !w.is_empty())
.collect();
Some(Self { stemmer, conflict, summary })
}
fn is_conflict(&self, token: &str) -> bool {
self.conflict.contains(&crate::text::normalize_stem(token, &self.stemmer))
}
}
fn is_dialogue(sentence: &str) -> bool {
let t = sentence.trim_start();
t.starts_with('—') || t.starts_with('–') || t.starts_with('-')
|| sentence.contains('"')
|| sentence.contains('\u{201c}') || sentence.contains('\u{201d}') || sentence.contains('\u{00ab}') || sentence.contains('\u{00bb}') }
fn tokens(sentence: &str) -> impl Iterator<Item = String> + '_ {
sentence
.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty())
.map(|w| w.to_lowercase())
}
fn ending_turn(sentences: &[String], matcher: Option<&StakesMatcher>) -> f32 {
let Some(last) = sentences.last() else { return 0.0 };
let last_t = last.trim_end();
if last_t.ends_with('?') || last_t.ends_with('!') {
return 1.0;
}
let tail_conflict = matcher.is_some_and(|m| {
sentences
.iter()
.rev()
.take(2)
.any(|s| tokens(s).any(|t| m.is_conflict(&t)))
});
if tail_conflict {
return 0.6;
}
if tokens(last).count() < SHORT_SENTENCE_WORDS {
return 0.3;
}
0.0
}
pub(crate) fn signals_from_text(text: &str, matcher: Option<&StakesMatcher>) -> IntensitySignals {
let sentences = crate::continuity::split_sentences(text);
if sentences.is_empty() {
return IntensitySignals::default();
}
let n = sentences.len() as f32;
let mut dialogue = 0usize;
let mut short = 0usize;
let mut words_total = 0usize;
let mut stakes_hits = 0usize;
for s in &sentences {
if is_dialogue(s) {
dialogue += 1;
}
let mut wc = 0usize;
for tok in tokens(s) {
wc += 1;
if matcher.is_some_and(|m| m.is_conflict(&tok)) {
stakes_hits += 1;
}
}
if wc > 0 && wc < SHORT_SENTENCE_WORDS {
short += 1;
}
words_total += wc;
}
let stakes_density = if words_total > 0 {
(stakes_hits as f32 / words_total as f32 * STAKES_SCALE).min(1.0)
} else {
0.0
};
let summary_penalty = match matcher {
Some(m) => {
let text_lc = text.to_lowercase();
let hits: usize = m.summary.iter().map(|p| text_lc.matches(p).count()).sum();
(hits as f32 / n * 3.0).min(1.0)
}
None => 0.0,
};
IntensitySignals {
dialogue_density: dialogue as f32 / n,
stakes_density,
short_sentence_ratio: short as f32 / n,
summary_penalty,
ending_turn: ending_turn(&sentences, matcher),
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ChapterIntensity {
pub chapter: u32,
pub title: String,
pub intensity: Option<f32>,
}
pub(crate) fn measure(layout: &ProjectLayout, h: &Hierarchy, cfg: &Config) -> Vec<ChapterIntensity> {
let matcher = StakesMatcher::for_language(&cfg.language);
h.user_book_chapters()
.into_iter()
.enumerate()
.map(|(idx, (chapter_id, title))| {
let raw = crate::cli::book_walk::chapter_raw_prose(layout, h, chapter_id);
let plain = crate::audiobook::typst_to_plain(&raw);
let intensity = if plain.trim().is_empty() {
None
} else {
Some(chapter_intensity(&signals_from_text(&plain, matcher.as_ref())))
};
ChapterIntensity { chapter: (idx + 1) as u32, title, intensity }
})
.collect()
}
fn word_lists(language: &str) -> Option<(&'static [&'static str], &'static [&'static str])> {
match language.trim().to_ascii_lowercase().as_str() {
"english" | "en" => Some((EN_CONFLICT, EN_SUMMARY)),
"russian" | "ru" => Some((RU_CONFLICT, RU_SUMMARY)),
"german" | "de" => Some((DE_CONFLICT, DE_SUMMARY)),
"french" | "fr" => Some((FR_CONFLICT, FR_SUMMARY)),
"spanish" | "es" => Some((ES_CONFLICT, ES_SUMMARY)),
_ => None,
}
}
const EN_CONFLICT: &[&str] = &[
"kill", "killed", "death", "die", "died", "dead", "blood", "bloody", "fight", "fought",
"danger", "fear", "afraid", "scream", "screamed", "run", "ran", "gun", "knife", "sword",
"blade", "attack", "enemy", "save", "lose", "lost", "never", "must", "hurry", "flee",
"wound", "pain", "betray", "threat", "war", "escape", "trapped", "desperate", "panic",
"terror", "burn", "explode", "shatter", "strike", "rage", "fury",
];
const EN_SUMMARY: &[&str] = &[
"over the next", "in the following", "days passed", "weeks passed", "months passed",
"years passed", "used to", "would often", "every day", "each morning", "as the weeks",
"eventually", "gradually",
];
const RU_CONFLICT: &[&str] = &[
"смерть", "убить", "убил", "кровь", "бой", "драться", "опасность", "страх", "бежать",
"бежал", "крик", "кричать", "оружие", "нож", "меч", "клинок", "враг", "спасти", "потерять",
"никогда", "должен", "война", "боль", "предать", "угроза", "ловушка", "паника", "ужас",
"гореть", "ярость", "удар", "атака",
];
const RU_SUMMARY: &[&str] = &[
"прошли недели", "прошли годы", "прошли месяцы", "с каждым днём", "с каждым днем",
"постепенно", "со временем", "каждый день", "как правило",
];
const DE_CONFLICT: &[&str] = &[
"tod", "töten", "getötet", "blut", "kampf", "kämpfen", "gefahr", "angst", "schrei",
"schreien", "rennen", "waffe", "messer", "schwert", "feind", "retten", "verlieren",
"niemals", "muss", "krieg", "schmerz", "verraten", "falle", "panik", "fliehen", "wut",
];
const DE_SUMMARY: &[&str] = &[
"in den nächsten", "wochen vergingen", "jahre vergingen", "mit der zeit", "allmählich",
"jeden tag", "pflegte",
];
const FR_CONFLICT: &[&str] = &[
"mort", "tuer", "tué", "sang", "combat", "battre", "danger", "peur", "cri", "crier",
"courir", "arme", "couteau", "épée", "ennemi", "sauver", "perdre", "jamais", "guerre",
"douleur", "trahir", "piège", "panique", "fuir", "rage",
];
const FR_SUMMARY: &[&str] = &[
"au cours des", "les semaines passèrent", "les années passèrent", "peu à peu",
"avec le temps", "chaque jour", "autrefois",
];
const ES_CONFLICT: &[&str] = &[
"muerte", "matar", "mató", "sangre", "lucha", "luchar", "peligro", "miedo", "grito",
"gritar", "correr", "arma", "cuchillo", "espada", "enemigo", "salvar", "perder", "nunca",
"guerra", "dolor", "traicionar", "trampa", "pánico", "huir", "furia",
];
const ES_SUMMARY: &[&str] = &[
"en los siguientes", "pasaron las semanas", "pasaron los años", "poco a poco",
"con el tiempo", "cada día", "solía",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn action_scene_outscores_quiet_summary() {
let m = StakesMatcher::for_language("english");
let action = "\"Run!\" she screamed. He drew his knife. Blood. \
The enemy was upon them. Would they escape?";
let summary = "Over the next weeks the town settled into its routines. \
Trade resumed and, gradually, the harvest was gathered as the seasons turned.";
let ai = chapter_intensity(&signals_from_text(action, m.as_ref()));
let qi = chapter_intensity(&signals_from_text(summary, m.as_ref()));
assert!(ai > qi, "action {ai} should outscore quiet summary {qi}");
assert!(ai > 0.4, "action reads high: {ai}");
}
#[test]
fn russian_stakes_words_match() {
let m = StakesMatcher::for_language("russian").expect("ru lexicon");
let s = signals_from_text("Он бежал сквозь кровь и страх. Смерть шла за ним.", Some(&m));
assert!(s.stakes_density > 0.0, "ru conflict words counted: {s:?}");
}
#[test]
fn inflected_forms_match_via_stemmer() {
let m = StakesMatcher::for_language("english").expect("en lexicon");
let s = signals_from_text("The killing would not stop; she screamed and screamed.", Some(&m));
assert!(s.stakes_density > 0.0);
}
#[test]
fn no_lexicon_language_still_scores_on_other_signals() {
let s = signals_from_text("\u{300c}\u{9003}\u{3052}\u{308d}\u{ff01}\u{300d} Short. Fast. Now!", None);
assert!(chapter_intensity(&s) >= 0.0);
assert_eq!(s.stakes_density, 0.0);
assert!(s.ending_turn > 0.0, "ends on '!'");
}
#[test]
fn empty_prose_is_flat() {
assert_eq!(signals_from_text(" ", None), IntensitySignals::default());
assert_eq!(chapter_intensity(&IntensitySignals::default()), 0.0);
}
}