use anyhow::Result;
use crate::config::Config;
use super::llm::{char_llm_call, extract_json_object};
use super::store::CharStore;
use super::{
ArcCheckType, ArcDeclaration, ArcVerdict, CharacterArcCheck, CharacterState,
};
pub(crate) fn detect_stall(
name: &str,
states: &[CharacterState],
threshold: u32,
) -> Option<CharacterArcCheck> {
if states.len() < 2 {
return None;
}
let mut best: Option<(u32, u32, usize, usize)> = None; let mut run_start: Option<usize> = None;
for i in 1..states.len() {
if !states[i].changed {
run_start.get_or_insert(i);
let start_idx = run_start.unwrap();
let len = i - start_idx + 1;
if best.is_none_or(|(_, _, bl, _)| len > bl) {
best = Some((states[start_idx].chapter_ord, states[i].chapter_ord, len, start_idx));
}
} else {
run_start = None;
}
}
let (start_ch, end_ch, len, run_start_idx) = best?;
if (len as u32) < threshold {
return None;
}
let scores: Vec<f32> = states[run_start_idx..run_start_idx + len]
.iter()
.filter_map(|s| s.agency_score)
.collect();
let avg = if scores.is_empty() {
String::from("n/a")
} else {
format!("{:.2}", scores.iter().sum::<f32>() / scores.len() as f32)
};
Some(CharacterArcCheck {
character_name: name.to_string(),
check_type: ArcCheckType::StallLocation,
verdict: ArcVerdict::Stalled,
description: format!(
"Character {name} shows no observable state change in chapters {start_ch}-{end_ch} \
({len} chapters). Agency score in this range averaged {avg}."
),
chapter_ord: Some(start_ch),
})
}
const CHECK_SYSTEM: &str = "You are evaluating whether a fiction manuscript delivers a character \
arc the author declared. Judge ONLY observable evidence — behaviour, speech, decisions. Ignore \
motivations not shown in the text and do not use training-data knowledge of the character or story. \
Return ONLY JSON: {\"verdict\":\"aligned|gap|earned\",\"description\":\"2-3 sentences\"}";
pub(super) fn build_alignment_prompt(declared_state: &str, summaries: &[String]) -> String {
let chain = summaries
.iter()
.enumerate()
.map(|(i, s)| format!("[{}] {s}", i + 1))
.collect::<Vec<_>>()
.join("\n");
format!(
"The author declared this state:\n{declared_state}\n\nThe character's state summaries here:\n{chain}\n\n\
Does the prose establish the declared state? Look for observable evidence. \
Verdict \"aligned\" or \"gap\"."
)
}
pub(super) fn build_earned_prompt(
decl: &ArcDeclaration,
states: &[CharacterState],
) -> String {
let chain = states
.iter()
.map(|s| {
let mark = if s.changed { "✦" } else { "·" };
format!("ch.{} {mark} {}", s.chapter_ord, s.state_summary)
})
.collect::<Vec<_>>()
.join("\n");
let enrich = enrichment_context(states);
format!(
"Declared arc type: {}\nDeclared final state:\n{}\n\nState chain across the chapters they appear in:\n{chain}\n{enrich}\n\
For a {} arc to feel earned, {}. Was the movement toward the final state prepared across the arc, \
or does it arrive without prior grounding? Verdict \"earned\" or \"gap\".",
decl.arc_type.as_code(),
decl.desired_state_end,
decl.arc_type.as_code(),
decl.arc_type.earned_framing(),
)
}
fn enrichment_context(states: &[CharacterState]) -> String {
let total_utt: u32 = states.iter().filter_map(|s| s.utterance_count).sum();
let silent: Vec<u32> = states
.iter()
.filter(|s| s.utterance_count == Some(0))
.map(|s| s.chapter_ord)
.collect();
let hedge = states.iter().find_map(|s| s.chapter_hedge_density);
let inter_first = states.first().and_then(|s| s.chapter_interiority_ratio);
let inter_last = states.last().and_then(|s| s.chapter_interiority_ratio);
let mut lines = Vec::new();
if total_utt > 0 {
let mut l = format!("- The character spoke {total_utt} time(s) across these chapters");
if let Some(h) = hedge {
l.push_str(&format!("; book hedge density {h:.3}"));
}
if !silent.is_empty() {
l.push_str(&format!("; silent in chapters {silent:?}"));
}
lines.push(l);
}
if let (Some(a), Some(b)) = (inter_first, inter_last) {
lines.push(format!(
"- Interiority ratio moved from {a:.2} to {b:.2} across the arc"
));
}
if lines.is_empty() {
String::new()
} else {
format!("\nAdditional signals:\n{}", lines.join("\n"))
}
}
pub(super) fn parse_check(raw: &str) -> (ArcVerdict, String) {
let json = extract_json_object(raw);
let v: serde_json::Value = serde_json::from_str(json).unwrap_or(serde_json::Value::Null);
let verdict = v
.get("verdict")
.and_then(|x| x.as_str())
.map(ArcVerdict::from_code)
.unwrap_or(ArcVerdict::Aligned);
let description = v
.get("description")
.and_then(|x| x.as_str())
.unwrap_or("")
.trim()
.to_string();
(verdict, description)
}
pub(crate) fn run_arc_checks(
store: &CharStore,
cfg: &Config,
book_slug: &str,
decl: &ArcDeclaration,
states: &[CharacterState],
stall_threshold: u32,
min_chapters: usize,
) -> Result<Vec<CharacterArcCheck>> {
let name = &decl.character_name;
let now = chrono::Utc::now().to_rfc3339();
store.clear_checks(book_slug, name)?;
let mut out = Vec::new();
if let Some(stall) = detect_stall(name, states, stall_threshold) {
store.upsert_check(book_slug, &stall, &now)?;
out.push(stall);
}
if states.len() < min_chapters {
return Ok(out);
}
let summaries = |slice: &[CharacterState]| slice.iter().map(|s| s.state_summary.clone()).collect::<Vec<_>>();
let first3: Vec<CharacterState> = states.iter().take(3).cloned().collect();
let last3: Vec<CharacterState> = states.iter().rev().take(3).rev().cloned().collect();
let (v, d) = parse_check(&char_llm_call(
cfg,
CHECK_SYSTEM,
&build_alignment_prompt(&decl.desired_state_start, &summaries(&first3)),
)?);
out.push(record(store, book_slug, name, ArcCheckType::StartAlignment, v, d, &now)?);
if let Some(mid) = &decl.desired_midpoint_state {
let m = states.len() / 2;
let around: Vec<CharacterState> =
states[m.saturating_sub(1)..(m + 2).min(states.len())].to_vec();
let (v, d) = parse_check(&char_llm_call(
cfg,
CHECK_SYSTEM,
&build_alignment_prompt(mid, &summaries(&around)),
)?);
out.push(record(store, book_slug, name, ArcCheckType::MidpointAlignment, v, d, &now)?);
}
let (v, d) = parse_check(&char_llm_call(
cfg,
CHECK_SYSTEM,
&build_alignment_prompt(&decl.desired_state_end, &summaries(&last3)),
)?);
out.push(record(store, book_slug, name, ArcCheckType::EndAlignment, v, d, &now)?);
let (v, d) = parse_check(&char_llm_call(cfg, CHECK_SYSTEM, &build_earned_prompt(decl, states))?);
out.push(record(store, book_slug, name, ArcCheckType::ArcEarned, v, d, &now)?);
Ok(out)
}
fn record(
store: &CharStore,
book_slug: &str,
name: &str,
check_type: ArcCheckType,
verdict: ArcVerdict,
description: String,
now: &str,
) -> Result<CharacterArcCheck> {
let c = CharacterArcCheck {
character_name: name.to_string(),
check_type,
verdict,
description,
chapter_ord: None,
};
store.upsert_check(book_slug, &c, now)?;
Ok(c)
}
#[cfg(test)]
mod tests {
use super::*;
fn st(ch: u32, changed: bool, agency: Option<f32>) -> CharacterState {
CharacterState {
character_name: "Mara".into(),
chapter_ord: ch,
state_summary: format!("s{ch}"),
changed,
change_description: None,
agency_score: agency,
active_count: 0,
passive_count: 0,
utterance_count: Some(0),
chapter_hedge_density: None,
chapter_interiority_ratio: None,
}
}
#[test]
fn stall_at_and_under_threshold() {
let states = vec![
st(1, false, Some(0.71)),
st(2, false, Some(0.68)),
st(3, false, Some(0.62)),
st(4, false, Some(0.58)),
st(5, false, Some(0.51)),
];
let s = detect_stall("Mara", &states, 4).unwrap();
assert_eq!(s.verdict, ArcVerdict::Stalled);
assert_eq!(s.chapter_ord, Some(2)); assert!(s.description.contains("chapters 2-5"));
assert!(s.description.contains("averaged 0.6")); assert!(detect_stall("Mara", &states, 5).is_none());
}
#[test]
fn change_breaks_the_run() {
let states = vec![
st(1, false, None),
st(2, false, None),
st(3, true, None), st(4, false, None),
st(5, false, None),
];
assert!(detect_stall("Mara", &states, 4).is_none());
}
#[test]
fn parse_check_tolerant() {
assert_eq!(
parse_check("{\"verdict\":\"gap\",\"description\":\"no build\"}"),
(ArcVerdict::Gap, "no build".to_string())
);
assert_eq!(parse_check("garbage").0, ArcVerdict::Aligned); assert_eq!(parse_check("ok {\"verdict\":\"earned\",\"description\":\"\"}").0, ArcVerdict::Earned);
}
#[test]
fn earned_prompt_injects_arc_framing_and_chain() {
let decl = ArcDeclaration {
character_name: "Mara".into(),
arc_type: super::super::ArcType::Corruption,
desired_state_start: "near truth".into(),
desired_midpoint_state: None,
desired_state_end: "fully compromised".into(),
};
let states = vec![st(1, false, None), st(2, true, None)];
let p = build_earned_prompt(&decl, &states);
assert!(p.contains("corruption"));
assert!(p.contains("compromise")); assert!(p.contains("fully compromised"));
assert!(p.contains("ch.2 ✦"));
}
}