use std::collections::BTreeMap;
use std::path::Path;
use super::grants;
use super::walk::ParaRef;
use super::{Grant, KnowledgeFinding, ScenePos, Severity};
use crate::config::Config;
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::Store;
pub(crate) const IRONY_SYSTEM: &str = "You are a meticulous continuity editor for a work of \
fiction, watching one axis only: what each character KNOWS. You are given a knowledge ledger (which \
character learns which fact, and in which chapter) and a reading-order sequence of scenes (each \
noting whose point of view we are in). Find IMPLIED epistemic breaks — a character ACTING on \
knowledge they could not have yet, or acting conspicuously UNAWARE of something they already know — \
where the fact is NOT named outright (obvious named references are handled by another pass). Be \
conservative: only flag a genuine break you can tie to the ledger. Respond ONLY with a JSON array; \
each item is {\"category\": \"implied_irony\", \"severity\": \"warning\"|\"info\", \"explanation\": \
a one-sentence reason naming the character and the chapter}. Return [] if nothing is off.";
pub(crate) fn run(
project: &Path,
book_name: Option<&str>,
max_cost: usize,
force: bool,
) -> Result<Vec<KnowledgeFinding>, String> {
let layout = ProjectLayout::new(project);
layout.require_initialized().map_err(|e| e.to_string())?;
let cfg = Config::load_layered(&layout.config_path()).map_err(|e| e.to_string())?;
let store = Store::open(layout.clone(), &cfg).map_err(|e| e.to_string())?;
let h = Hierarchy::load(&store).map_err(|e| e.to_string())?;
let book = crate::cli::resolve_user_book(&h, book_name, "knowledge")?;
let (grants, _items, paras) = grants::build_grants(&layout, &h, book);
if grants.is_empty() {
return Ok(Vec::new());
}
let prompt = build_irony_prompt(&build_ledger(&grants), &group_scenes(¶s));
let findings = crate::cli::realworld::slow_llm_call(
project,
"knowledge-irony",
IRONY_SYSTEM,
prompt,
max_cost,
force,
)
.map_err(|e| e.to_string())?;
Ok(findings.into_iter().map(map_finding).collect())
}
fn build_ledger(grants: &[Grant]) -> String {
let mut by_char: BTreeMap<&str, Vec<(&str, u32)>> = BTreeMap::new();
for g in grants {
by_char.entry(g.character.as_str()).or_default().push((g.topic.as_str(), g.at.chapter_ord));
}
let mut out = String::new();
for (character, mut topics) in by_char {
topics.sort();
topics.dedup();
let items: Vec<String> = topics.iter().map(|(t, ch)| format!("{t} (ch. {ch})")).collect();
out.push_str(&format!("- {character} knows: {}\n", items.join("; ")));
}
out
}
fn group_scenes(paras: &[ParaRef]) -> Vec<(ScenePos, Option<String>, String)> {
let mut m: BTreeMap<ScenePos, (Option<String>, String)> = BTreeMap::new();
for p in paras {
let e = m.entry(p.at).or_insert((p.declared_pov.clone(), String::new()));
if e.0.is_none() {
e.0 = p.declared_pov.clone();
}
if !e.1.is_empty() {
e.1.push('\n');
}
e.1.push_str(p.text.trim());
}
m.into_iter().map(|(at, (pov, text))| (at, pov, text)).collect()
}
fn build_irony_prompt(ledger: &str, scenes: &[(ScenePos, Option<String>, String)]) -> String {
let mut body = String::new();
for (at, pov, text) in scenes {
if text.trim().is_empty() {
continue;
}
let pov = pov.as_deref().unwrap_or("—");
body.push_str(&format!(
"[ch. {} · scene {} · POV: {pov}]\n{}\n\n",
at.chapter_ord,
at.scene_index,
text.trim()
));
}
format!(
"KNOWLEDGE LEDGER (who learns what, and when):\n{ledger}\n\n\
SCENES (reading order; find IMPLIED knowledge breaks against the ledger):\n{body}"
)
}
fn map_finding(f: crate::world::fact_check::Finding) -> KnowledgeFinding {
let severity = match f.severity.as_str() {
"warning" | "contradiction" => Severity::Notice,
_ => Severity::Info,
};
KnowledgeFinding {
kind: "implied_irony",
severity,
chapter: 0,
anchor: None,
character: String::new(),
topic: String::new(),
message: f.body,
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::GrantSource;
use uuid::Uuid;
fn grant(c: &str, t: &str, ch: u32) -> Grant {
Grant {
character: c.into(),
topic: t.into(),
at: ScenePos { chapter_ord: ch, scene_index: 1 },
source: GrantSource::Declared,
anchor: Some(Uuid::from_u128(1)),
}
}
#[test]
fn ledger_groups_and_sorts_per_character() {
let grants = vec![grant("Mara", "the map", 3), grant("Mara", "the betrayal", 7), grant("Bob", "the murder", 6)];
let l = build_ledger(&grants);
assert!(l.contains("- Bob knows: the murder (ch. 6)"));
assert!(l.contains("- Mara knows: the betrayal (ch. 7); the map (ch. 3)"));
}
#[test]
fn scenes_group_by_position_in_reading_order() {
let mk = |ch, sc, pov: Option<&str>, text: &str| ParaRef {
id: Uuid::from_u128(ch as u128 * 10 + sc as u128),
at: ScenePos { chapter_ord: ch, scene_index: sc },
tags: vec![],
text: text.into(),
declared_pov: pov.map(str::to_string),
};
let paras = vec![
mk(1, 1, Some("Mara"), "First."),
mk(1, 1, Some("Mara"), "Second."),
mk(2, 1, None, "Later."),
];
let scenes = group_scenes(¶s);
assert_eq!(scenes.len(), 2);
assert_eq!(scenes[0].0, ScenePos { chapter_ord: 1, scene_index: 1 });
assert_eq!(scenes[0].1.as_deref(), Some("Mara"));
assert_eq!(scenes[0].2, "First.\nSecond.");
}
#[test]
fn implied_irony_maps_soft_severity() {
let mk = |sev: &str| crate::world::fact_check::Finding {
category: "implied_irony".into(),
severity: sev.into(),
body: "Mara acts unaware of the betrayal she learned in ch. 7.".into(),
body_en: String::new(),
suppressed_by: None,
};
assert_eq!(map_finding(mk("warning")).severity, Severity::Notice);
assert_eq!(map_finding(mk("info")).severity, Severity::Info);
assert_eq!(map_finding(mk("info")).kind, "implied_irony");
}
}