use std::collections::HashSet;
use std::io::{BufRead, Write};
use std::path::Path;
use rust_stemmers::Stemmer;
use uuid::Uuid;
use crate::ai::AiClient;
use crate::config::{Config, parse_stemmer_language};
use crate::error::{Error, Result};
use crate::facts_scan::{
FactScanReport, near_duplicate, normalise_tokens, parse_candidates, parse_findings,
};
use crate::project::ProjectLayout;
use crate::store::InsertPosition;
use crate::store::Store;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::NodeKind;
use super::FactsCommand;
const SYSTEM_PROMPT: &str = "You are a fact-checker for a work of fiction. You receive a set \
of ESTABLISHED facts about the story's world (climate, geography, seasons, distances, \
chronology) and a chapter's prose. Flag any claim in the prose that CONTRADICTS an \
established fact — snow in a region established as tropical, a three-day ride done overnight, \
an event dated before something it must follow. Treat the established facts as ground truth; \
do not flag things merely unmentioned by them. Output ONE contradiction per line in the exact \
form:\n\
claim | fact | detail\n\
where `claim` is the exact contradicting phrase from the prose, `fact` is the established \
fact it violates, and `detail` is a one-line explanation. Output nothing else — no preamble, \
no commentary, no markdown. If the chapter contradicts no facts, output nothing.";
const EXTRACT_SYSTEM_PROMPT: &str = "You extract ESTABLISHED world facts — the invariants a \
story relies on: climate, geography, seasons, distances / travel-times, chronology / dates, \
and recurring rules (magic, technology, law, custom). You do NOT extract plot events, \
character actions, emotions, dialogue, or one-off details. Output ONE fact per line in the \
exact form:\n\
category | statement\n\
where category is one of: climate, geography, seasons, distances, chronology, culture, rules, \
other; and statement is a short, self-contained factual sentence. Output nothing else — no \
preamble, no commentary, no markdown. If a chapter establishes no durable world facts, output \
nothing.";
pub fn run(project: &Path, cmd: FactsCommand) -> Result<()> {
match cmd {
FactsCommand::Scan { provider, json } => scan(project, provider.as_deref(), json),
FactsCommand::Check { provider, json } => check(project, provider.as_deref(), json),
FactsCommand::Import { from, yes } => import(project, from.as_deref(), yes),
FactsCommand::List { json } => list(project, json),
FactsCommand::Extract {
provider,
yes,
dry_run,
} => extract(project, provider.as_deref(), yes, dry_run),
FactsCommand::Init { force, genre } => {
init(project, force, genre.as_deref())
}
}
}
const GENERAL_SKELETON: &[(&str, &str)] = &[
(
"Climate",
"Temperature bands, rainfall / monsoon, extremes — and what's impossible here (snow? drought?).",
),
(
"Geography",
"Key places and the distances / travel-times between them; terrain, borders, the capital.",
),
(
"Seasons",
"The seasonal cycle: names, lengths, and what each season brings.",
),
(
"Chronology",
"The calendar and fixed dates: when the story sits, and what happened how long ago.",
),
(
"Culture",
"Customs, social structure, religion, and language facts the prose relies on.",
),
(
"Rules",
"The hard limits of the world — magic / technology / law the plot can't break.",
),
];
fn genre_extras(genre: &str) -> Option<&'static [(&'static str, &'static str)]> {
match genre.trim().to_lowercase().as_str() {
"" | "general" => Some(&[]),
"fantasy" => Some(&[
(
"Magic system",
"What magic can and can't do, who can use it, and its costs / limits — the rules a clever reader will hold you to.",
),
(
"Cosmology & religion",
"Gods, planes, afterlife, prophecy — the metaphysics the plot leans on.",
),
(
"Peoples & lineages",
"The races / peoples / bloodlines, their traits, lifespans, and relations.",
),
]),
"scifi" | "sci-fi" | "science-fiction" => Some(&[
(
"Technology",
"The tech baseline: what exists, what doesn't, and the hard limits (energy, comms, weapons).",
),
(
"Physics & travel",
"FTL / sublight rules, travel-times between worlds, gravity, and any bent physics — kept consistent.",
),
(
"Polity & factions",
"Governments, corporations, alliances, and who controls what.",
),
]),
"mystery" => Some(&[
(
"Timeline of events",
"The true sequence of what happened, with times — the spine the clues must fit.",
),
(
"Suspects & alibis",
"Each suspect's whereabouts, motive, and means — what's verifiably true.",
),
(
"Physical evidence",
"The clues, where they were, and what they actually prove (fair-play: no cheating).",
),
]),
"historical" => Some(&[
(
"Period & setting",
"The era's material facts: dress, technology, money, travel, daily life.",
),
(
"Real events & figures",
"The historical events / people the story touches, and their real dates.",
),
(
"Anachronism watch",
"Words, objects, and ideas that would be out of period — the things to never let slip in.",
),
]),
_ => None,
}
}
const GENRES: &[&str] = &["general", "fantasy", "scifi", "mystery", "historical"];
fn init(project: &Path, force: bool, genre: Option<&str>) -> Result<()> {
let genre = genre.unwrap_or("general");
let Some(extras) = genre_extras(genre) else {
return Err(Error::Store(format!(
"facts init: unknown genre `{genre}` — try one of: {}",
GENRES.join(", "),
)));
};
let skeleton: Vec<(&str, &str)> =
GENERAL_SKELETON.iter().chain(extras.iter()).copied().collect();
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load_layered(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg).map_err(|e| Error::Store(e.to_string()))?;
let hierarchy = Hierarchy::load(&store).map_err(|e| Error::Store(e.to_string()))?;
let Some(facts_id) = hierarchy
.iter()
.find(|n| {
n.kind == NodeKind::Book
&& n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_FACTS)
})
.map(|n| n.id)
else {
return Err(Error::Store(
"facts init: this project has no Facts book".into(),
));
};
let existing: HashSet<String> = hierarchy
.collect_subtree(facts_id)
.into_iter()
.filter_map(|id| hierarchy.get(id))
.filter(|n| n.kind == NodeKind::Paragraph)
.map(|n| n.title.trim().to_lowercase())
.collect();
let mut added = 0usize;
let mut skipped = 0usize;
for (title, hint) in &skeleton {
if !force && existing.contains(&title.to_lowercase()) {
skipped += 1;
continue;
}
let h = Hierarchy::load(&store).map_err(|e| Error::Store(e.to_string()))?;
let facts_node = h
.iter()
.find(|n| n.id == facts_id)
.cloned()
.ok_or_else(|| Error::Store("facts init: Facts book vanished".into()))?;
let mut node = store.create_node(
&cfg,
&h,
NodeKind::Paragraph,
title,
Some(&facts_node),
None,
InsertPosition::End,
)?;
let body = format!("= {title}\n\n{hint}\n");
if let Some(rel) = &node.file {
let abs = store.project_root().join(rel);
std::fs::write(&abs, body.as_bytes()).map_err(Error::Io)?;
store.update_paragraph_content(&mut node, body.as_bytes())?;
}
added += 1;
}
println!(
"facts init: added {added} categor{}{} to the Facts book",
if added == 1 { "y" } else { "ies" },
if skipped > 0 {
format!(" ({skipped} already present, kept)")
} else {
String::new()
},
);
Ok(())
}
fn extract(project: &Path, provider: Option<&str>, yes: bool, dry_run: bool) -> Result<()> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load_layered(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg).map_err(|e| Error::Store(e.to_string()))?;
let hierarchy = Hierarchy::load(&store).map_err(|e| Error::Store(e.to_string()))?;
let language = if cfg.language.trim().is_empty() {
"English".to_string()
} else {
cfg.language.clone()
};
let Some(facts_id) = hierarchy
.iter()
.find(|n| {
n.kind == NodeKind::Book
&& n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_FACTS)
})
.map(|n| n.id)
else {
return Err(Error::Store(
"facts extract: this project has no Facts book".into(),
));
};
let facts_ids: HashSet<Uuid> = hierarchy.collect_subtree(facts_id).into_iter().collect();
let chapters = hierarchy.user_book_chapters();
if chapters.is_empty() {
return Err(Error::Store(
"facts extract: no user-book chapters found".into(),
));
}
let stemmer: Option<Stemmer> = parse_stemmer_language(&cfg.language).map(Stemmer::create);
let mut existing_sets: Vec<std::collections::BTreeSet<String>> = Vec::new();
for &id in &facts_ids {
let Some(node) = hierarchy.get(id) else {
continue;
};
if node.kind != NodeKind::Paragraph {
continue;
}
if let Ok(Some(bytes)) = store.get_content(id) {
let body = crate::audiobook::typst_to_plain(&String::from_utf8_lossy(&bytes));
existing_sets.push(normalise_tokens(&body, &stemmer));
}
}
let ai = AiClient::from_config(&cfg.llm)?;
let (model, _env) = ai.resolve_provider(&cfg.llm, provider)?;
eprintln!(
"inkhaven facts extract · language: {language} · model: {model} · {} chapter(s)",
chapters.len(),
);
let mut kept: Vec<crate::facts_scan::FactCandidate> = Vec::new();
let mut kept_sets: Vec<std::collections::BTreeSet<String>> = Vec::new();
for (idx, (chapter_id, chapter_title)) in chapters.iter().enumerate() {
let prose = crate::cli::book_walk::chapter_raw_prose(&layout, &hierarchy, *chapter_id);
let plain = crate::audiobook::typst_to_plain(&prose);
if plain.trim().is_empty() {
continue;
}
eprint!(" [{}/{}] {chapter_title} ", idx + 1, chapters.len());
let prompt = build_extract_prompt(&language, chapter_title, &plain);
let raw = run_blocking(&ai, model, EXTRACT_SYSTEM_PROMPT, &prompt)?;
let candidates = parse_candidates(&raw, chapter_title);
let mut new_here = 0;
for cand in candidates {
let toks = normalise_tokens(&cand.statement, &stemmer);
if toks.is_empty() {
continue;
}
if existing_sets.iter().any(|e| near_duplicate(&toks, e, 0.7)) {
continue;
}
if kept_sets.iter().any(|k| near_duplicate(&toks, k, 0.7)) {
continue;
}
kept_sets.push(toks);
kept.push(cand);
new_here += 1;
}
eprintln!("→ {new_here} new candidate(s)");
}
if kept.is_empty() {
println!("facts extract: no new world-facts proposed (the Facts book already covers what the prose establishes)");
return Ok(());
}
if dry_run {
println!(
"facts extract: {} candidate(s) (dry run — nothing added):\n",
kept.len()
);
for (i, c) in kept.iter().enumerate() {
println!(" {:>3}. [{}] {}", i + 1, c.category, c.statement);
println!(" (from: {})", c.chapter);
}
return Ok(());
}
let stdin = std::io::stdin();
let mut accept_all = yes;
let mut added = 0usize;
'review: for (i, cand) in kept.iter().enumerate() {
println!("\n[{}/{}] {} — {}", i + 1, kept.len(), cand.category, cand.statement);
println!(" (established in: {})", cand.chapter);
let accept = if accept_all {
true
} else {
loop {
print!(" add to Facts? [y/N/a=all/q=quit]: ");
std::io::stdout().flush().ok();
let mut line = String::new();
stdin.lock().read_line(&mut line).map_err(Error::Io)?;
match line.trim() {
"y" | "Y" => break true,
"" | "n" | "N" => break false,
"a" | "A" => {
accept_all = true;
break true;
}
"q" | "Q" => break 'review,
other => {
println!(" (didn't understand `{other}` — y, N, a, or q)");
}
}
}
};
if accept {
add_fact(&store, &cfg, &layout, facts_id, cand)?;
added += 1;
}
}
println!(
"\nfacts extract: added {added} fact(s) to the Facts book{}",
if added < kept.len() {
format!(" ({} left unaccepted)", kept.len() - added)
} else {
String::new()
},
);
Ok(())
}
fn add_fact(
store: &Store,
cfg: &Config,
layout: &ProjectLayout,
facts_id: Uuid,
cand: &crate::facts_scan::FactCandidate,
) -> Result<()> {
let h = Hierarchy::load(store).map_err(|e| Error::Store(e.to_string()))?;
let facts_node = h
.iter()
.find(|n| n.id == facts_id)
.cloned()
.ok_or_else(|| Error::Store("facts extract: Facts book vanished".into()))?;
let title = title_case(&cand.category);
let mut node = store.create_node(
cfg,
&h,
NodeKind::Paragraph,
&title,
Some(&facts_node),
None,
InsertPosition::End,
)?;
let body = format!("= {title}\n\n{}\n", cand.statement.trim());
if let Some(rel) = &node.file {
let abs = layout.root.join(rel);
std::fs::write(&abs, body.as_bytes()).map_err(Error::Io)?;
store.update_paragraph_content(&mut node, body.as_bytes())?;
}
Ok(())
}
fn title_case(s: &str) -> String {
let s = s.trim();
let mut chars = s.chars();
match chars.next() {
Some(first) => first.to_uppercase().chain(chars).collect(),
None => "Fact".to_string(),
}
}
fn build_extract_prompt(language: &str, chapter: &str, prose: &str) -> String {
format!(
"Language of the manuscript: {language}.\n\
Extract the established world facts from this chapter (\"{chapter}\"). \
One fact per line, `category | statement`, no other output.\n\n\
--- CHAPTER PROSE ---\n{prose}\n--- END ---",
)
}
fn scan(project: &Path, provider: Option<&str>, json: bool) -> Result<()> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load_layered(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg).map_err(|e| Error::Store(e.to_string()))?;
let hierarchy = Hierarchy::load(&store).map_err(|e| Error::Store(e.to_string()))?;
let report = scan_with(&store, &hierarchy, &cfg, &layout, provider, &|s| eprintln!("{s}"))?;
if json {
let rendered = serde_json::to_string_pretty(&report)
.map_err(|e| Error::Store(format!("facts_scan JSON: {e}")))?;
println!("{rendered}");
} else {
println!(
"facts scan: {} contradiction(s) across {} chapter(s) → {}",
report.findings.len(),
hierarchy.user_book_chapters().len(),
FactScanReport::sidecar_path(&layout.root).display(),
);
}
Ok(())
}
pub fn scan_with(
store: &Store,
hierarchy: &Hierarchy,
cfg: &Config,
layout: &ProjectLayout,
provider: Option<&str>,
progress: &dyn Fn(&str),
) -> Result<FactScanReport> {
let language = if cfg.language.trim().is_empty() {
"English".to_string()
} else {
cfg.language.clone()
};
let Some(facts_id) = hierarchy
.iter()
.find(|n| {
n.kind == NodeKind::Book
&& n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_FACTS)
})
.map(|n| n.id)
else {
return Err(Error::Store(
"facts scan: this project has no Facts book".into(),
));
};
let facts_ids: HashSet<Uuid> = hierarchy.collect_subtree(facts_id).into_iter().collect();
let total_facts = facts_ids
.iter()
.filter(|id| {
hierarchy
.get(**id)
.map(|n| n.kind == NodeKind::Paragraph)
.unwrap_or(false)
})
.count();
if total_facts == 0 {
return Err(Error::Store(
"facts scan: the Facts book is empty — collect some facts first".into(),
));
}
let chapters = hierarchy.user_book_chapters();
if chapters.is_empty() {
return Err(Error::Store("facts scan: no user-book chapters found".into()));
}
let ai = AiClient::from_config(&cfg.llm)?;
let (model, _env) = ai.resolve_provider(&cfg.llm, provider)?;
progress(&format!(
"facts scan · language: {language} · model: {model} · {} chapter(s) · {total_facts} fact(s)",
chapters.len(),
));
let mut report = FactScanReport {
version: env!("CARGO_PKG_VERSION").to_string(),
language: language.clone(),
findings: Vec::new(),
manuscript_fingerprint: crate::world_report::manuscript_fingerprint(hierarchy),
};
for (idx, (chapter_id, chapter_title)) in chapters.iter().enumerate() {
let prose = crate::cli::book_walk::chapter_raw_prose(layout, hierarchy, *chapter_id);
let plain = crate::audiobook::typst_to_plain(&prose);
if plain.trim().is_empty() {
continue;
}
let facts_ctx = relevant_facts(store, hierarchy, &facts_ids, &plain, 12);
if facts_ctx.is_empty() {
progress(&format!("facts [{}/{}] {chapter_title} → no relevant facts", idx + 1, chapters.len()));
continue;
}
let prompt = build_check_prompt(&language, chapter_title, &plain, &facts_ctx);
let raw = run_blocking(&ai, model, SYSTEM_PROMPT, &prompt)?;
let findings = parse_findings(&raw, chapter_title, idx);
progress(&format!(
"facts [{}/{}] {chapter_title} → {} contradiction(s)",
idx + 1,
chapters.len(),
findings.len()
));
report.findings.extend(findings);
}
report
.save(&layout.root)
.map_err(|e| Error::Store(format!("facts_scan save: {e}")))?;
Ok(report)
}
const CHECK_SYSTEM_PROMPT: &str = "You are a continuity editor. Below is a story world's \
established facts. Find pairs of facts that CONTRADICT each other — two statements that cannot \
both be true (a climate fact vs a geography fact, an age vs a timeline, a distance vs a travel \
time, …). Output ONE contradiction per line as `fact A | fact B | why`, quoting each fact \
briefly. If nothing contradicts, output nothing. No preamble, no header row.";
fn check(project: &Path, provider: Option<&str>, json: bool) -> Result<()> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load_layered(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg).map_err(|e| Error::Store(e.to_string()))?;
let hierarchy = Hierarchy::load(&store).map_err(|e| Error::Store(e.to_string()))?;
let report = check_with(&store, &hierarchy, &cfg, &layout, provider, &|s| eprintln!("{s}"))?;
if json {
let rendered = serde_json::to_string_pretty(&report)
.map_err(|e| Error::Store(format!("facts_check JSON: {e}")))?;
println!("{rendered}");
} else if report.conflicts.is_empty() {
println!("facts check: ✓ no internal contradictions");
} else {
println!(
"facts check: {} internal contradiction(s):",
report.conflicts.len()
);
for c in &report.conflicts {
println!(" ⚠ {} ⟷ {}\n ↳ {}", c.a, c.b, c.detail);
}
eprintln!(" (also surfaced in `inkhaven edit`)");
}
Ok(())
}
pub fn check_with(
store: &Store,
hierarchy: &Hierarchy,
cfg: &Config,
layout: &ProjectLayout,
provider: Option<&str>,
progress: &dyn Fn(&str),
) -> Result<crate::facts_scan::FactCheckReport> {
let language = if cfg.language.trim().is_empty() {
"English".to_string()
} else {
cfg.language.clone()
};
let Some(facts_id) = hierarchy
.iter()
.find(|n| {
n.kind == NodeKind::Book
&& n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_FACTS)
})
.map(|n| n.id)
else {
return Err(Error::Store("facts check: this project has no Facts book".into()));
};
let facts_ids: HashSet<Uuid> = hierarchy.collect_subtree(facts_id).into_iter().collect();
let local_titles: HashSet<String> = facts_ids
.iter()
.filter_map(|id| hierarchy.get(*id))
.filter(|n| n.kind == NodeKind::Paragraph)
.map(|n| n.title.trim().to_lowercase())
.collect();
let mut facts = all_facts(store, hierarchy, &facts_ids);
if let Some(dir) = cfg.facts.shared_path.as_deref() {
let added: Vec<String> = crate::facts_scan::shared_facts(std::path::Path::new(dir))
.into_iter()
.filter(|(t, _)| !local_titles.contains(&t.to_lowercase()))
.map(|(t, b)| format!("- {t} (shared): {b}"))
.collect();
if !added.is_empty() {
progress(&format!("+ {} shared fact(s) from {dir}", added.len()));
facts.extend(added);
facts.sort();
}
}
if facts.len() < 2 {
return Err(Error::Store(
"facts check: need at least two facts to compare — `inkhaven facts extract` first".into(),
));
}
let ai = AiClient::from_config(&cfg.llm)?;
let (model, _env) = ai.resolve_provider(&cfg.llm, provider)?;
progress(&format!(
"facts check · language: {language} · model: {model} · {} fact(s)",
facts.len()
));
let prompt = format!(
"Language: {language}.\n--- ESTABLISHED FACTS ---\n{}\n--- END ---",
facts.join("\n")
);
let raw = run_blocking(&ai, model, CHECK_SYSTEM_PROMPT, &prompt)?;
let report = crate::facts_scan::FactCheckReport {
version: env!("CARGO_PKG_VERSION").to_string(),
content_hash: crate::facts_scan::FactCheckReport::compute_hash(&facts),
conflicts: crate::facts_scan::parse_conflicts(&raw),
manuscript_fingerprint: crate::world_report::manuscript_fingerprint(hierarchy),
};
report
.save(&layout.root)
.map_err(|e| Error::Store(format!("facts_check save: {e}")))?;
Ok(report)
}
fn all_facts(store: &Store, hierarchy: &Hierarchy, facts_ids: &HashSet<Uuid>) -> Vec<String> {
let mut out = Vec::new();
for id in facts_ids {
let Some(node) = hierarchy.get(*id) else {
continue;
};
if node.kind != NodeKind::Paragraph {
continue;
}
if let Ok(Some(bytes)) = store.get_content(*id) {
let body = crate::audiobook::typst_to_plain(&String::from_utf8_lossy(&bytes));
let body = body.trim();
if !body.is_empty() {
out.push(format!("- {}: {}", node.title.trim(), body));
}
}
}
out.sort();
out
}
fn import(project: &Path, from: Option<&str>, yes: bool) -> Result<()> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let cfg = Config::load_layered(&layout.config_path())?;
let store = Store::open(layout.clone(), &cfg).map_err(|e| Error::Store(e.to_string()))?;
let hierarchy = Hierarchy::load(&store).map_err(|e| Error::Store(e.to_string()))?;
let dir = from
.map(String::from)
.or_else(|| cfg.facts.shared_path.clone())
.ok_or_else(|| {
Error::Store("facts import: pass --from <dir> or set `facts.shared_path`".into())
})?;
let shared = crate::facts_scan::shared_facts(std::path::Path::new(&dir));
if shared.is_empty() {
return Err(Error::Store(format!(
"facts import: no fact files (.txt / .md / .typ) in {dir}"
)));
}
let Some(facts_id) = hierarchy
.iter()
.find(|n| {
n.kind == NodeKind::Book
&& n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_FACTS)
})
.map(|n| n.id)
else {
return Err(Error::Store("facts import: this project has no Facts book".into()));
};
let existing: HashSet<String> = hierarchy
.collect_subtree(facts_id)
.into_iter()
.filter_map(|id| hierarchy.get(id))
.filter(|n| n.kind == NodeKind::Paragraph)
.map(|n| n.title.trim().to_lowercase())
.collect();
let to_add: Vec<(String, String)> = shared
.into_iter()
.filter(|(t, _)| !existing.contains(&t.to_lowercase()))
.collect();
if to_add.is_empty() {
println!("facts import: every shared fact is already in the Facts book");
return Ok(());
}
if !yes {
println!("facts import: would add {} fact(s) from {dir}:", to_add.len());
for (t, _) in &to_add {
println!(" + {t}");
}
eprintln!(" pass --yes to write them");
return Ok(());
}
for (title, body) in &to_add {
let h = Hierarchy::load(&store).map_err(|e| Error::Store(e.to_string()))?;
let facts_node = h
.iter()
.find(|n| n.id == facts_id)
.cloned()
.ok_or_else(|| Error::Store("facts import: Facts book vanished".into()))?;
let mut node = store.create_node(
&cfg,
&h,
NodeKind::Paragraph,
title,
Some(&facts_node),
None,
InsertPosition::End,
)?;
let para_body = format!("= {title}\n\n{}\n", body.trim());
if let Some(rel) = &node.file {
let abs = store.project_root().join(rel);
std::fs::write(&abs, para_body.as_bytes()).map_err(Error::Io)?;
store.update_paragraph_content(&mut node, para_body.as_bytes())?;
}
}
println!(
"facts import: added {} fact(s) from {dir} → the Facts book",
to_add.len()
);
Ok(())
}
fn relevant_facts(
store: &Store,
hierarchy: &Hierarchy,
facts_ids: &HashSet<Uuid>,
prose: &str,
limit: usize,
) -> Vec<String> {
let query: String = prose.chars().take(2000).collect();
let raw = match store.search_text(&query, (limit + 12).max(24)) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let mut out = Vec::new();
let mut seen = HashSet::new();
for v in raw.iter() {
let Some(id) = v
.get("id")
.and_then(|x| x.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
else {
continue;
};
if !facts_ids.contains(&id) || !seen.insert(id) {
continue;
}
let Some(node) = hierarchy.get(id) else {
continue;
};
if node.kind != NodeKind::Paragraph {
continue;
}
if let Ok(Some(bytes)) = store.get_content(id) {
let body = crate::audiobook::typst_to_plain(&String::from_utf8_lossy(&bytes));
let body = body.trim();
if body.is_empty() {
continue;
}
out.push(format!("- {}: {}", node.title.trim(), body));
}
if out.len() >= limit {
break;
}
}
out
}
fn build_check_prompt(language: &str, chapter: &str, prose: &str, facts: &[String]) -> String {
format!(
"Language of the manuscript: {language}.\n\
Fact-check this chapter (\"{chapter}\") against the established facts below. \
One contradiction per line, `claim | fact | detail`, no other output.\n\n\
--- ESTABLISHED FACTS ---\n{}\n--- END ---\n\n\
--- CHAPTER PROSE ---\n{prose}\n--- END ---",
facts.join("\n"),
)
}
fn run_blocking(ai: &AiClient, model: &str, system: &str, prompt: &str) -> Result<String> {
crate::ai::stream::collect_blocking(
ai.client.clone(),
model.to_string(),
Some(system.to_string()),
prompt.to_string(),
)
.map_err(|e| Error::Store(format!("inference error: {e}")))
}
fn list(project: &Path, json: bool) -> Result<()> {
let layout = ProjectLayout::new(project);
layout.require_initialized()?;
let report = FactScanReport::load(&layout.root)
.map_err(|e| Error::Store(format!("facts_scan load: {e}")))?;
if json {
let rendered = serde_json::to_string_pretty(&report)
.map_err(|e| Error::Store(format!("facts_scan JSON: {e}")))?;
println!("{rendered}");
return Ok(());
}
if report.findings.is_empty() {
println!("facts scan: no contradictions recorded — run `inkhaven facts scan` first");
return Ok(());
}
println!(
"Fact-check findings — {} contradiction(s), language {}\n",
report.findings.len(),
report.language,
);
let mut last = String::new();
for f in &report.findings {
if f.chapter != last {
println!("{}", f.chapter);
last = f.chapter.clone();
}
println!(" claim: {}", f.claim);
println!(" fact: {}", f.fact);
println!(" → {}\n", f.detail);
}
Ok(())
}