use std::path::Path;
use super::{ReaderFinding, Severity};
use crate::config::Config;
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::Store;
const FIRST_READER_SYSTEM: &str = "You are a thoughtful first-time reader of a novel, reading it one \
chapter at a time IN ORDER. You do NOT know what happens in later chapters — react only to what you \
have read so far. You are given a short recap of what you've read up to now, then the current \
chapter. Report ONLY genuine first-reader problems with THIS chapter: confusion (you can't tell who \
someone is or what is happening), unclear stakes (you don't see why it matters), flagging engagement \
(your attention drifts), or a point where you would put the book down. Do NOT critique prose style, \
do NOT guess the plot, do NOT praise, do NOT invent problems. Be sparing — most chapters are fine. \
Respond ONLY with a JSON array; each item is {\"category\": one of \
confusion|stakes|engagement|pacing|put_down|other, \"severity\": notice|concern, \"explanation\": a \
one-sentence reaction in the reader's voice (\"I lost track of who Joren is\")}. Return [] if the \
chapter reads clean.";
const RECAP_CAP: usize = 24;
fn build_recap(met: &[String], open: &[String]) -> String {
if met.is_empty() && open.is_empty() {
return "This is the opening — you know nothing yet.".to_string();
}
let tail = |v: &[String]| -> String {
let start = v.len().saturating_sub(RECAP_CAP);
v[start..].join(", ")
};
let mut s = String::new();
if !met.is_empty() {
s.push_str(&format!("People/places you've met: {}.", tail(met)));
}
if !open.is_empty() {
if !s.is_empty() {
s.push(' ');
}
s.push_str(&format!("Open questions still unanswered: {}.", tail(open)));
}
s
}
fn build_prompt(recap: &str, title: &str, prose: &str) -> String {
format!(
"WHAT YOU'VE READ SO FAR:\n{recap}\n\nCURRENT CHAPTER (\u{201c}{title}\u{201d}):\n{prose}\n\n\
React as a first reader to THIS chapter only — you do not know what happens next. \
Return the JSON array of first-reader problems (or [])."
)
}
fn map_category(category: &str) -> (&'static str, Severity) {
match category.trim().to_ascii_lowercase().as_str() {
"put_down" => ("put_down_risk", Severity::Concern),
"confusion" => ("confusion", Severity::Notice),
"stakes" => ("stakes_gap", Severity::Notice),
"pacing" => ("attention_dip", Severity::Notice),
"engagement" => ("engagement", Severity::Info),
_ => ("reader", Severity::Info),
}
}
pub(crate) fn run(project: &Path, max_cost: usize, force: bool) -> Result<Vec<ReaderFinding>, 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 read = super::walk::read_forward(&store, &cfg, &layout, &h);
let chapters = h.user_book_chapters();
let mut out: Vec<ReaderFinding> = Vec::new();
let mut met: Vec<String> = Vec::new();
let mut open: Vec<String> = Vec::new();
let mut counter = 0usize;
for (idx, cr) in read.chapters.iter().enumerate() {
let Some((chapter_id, _)) = chapters.get(idx) else { break };
let raw = crate::cli::book_walk::chapter_raw_prose(&layout, &h, *chapter_id);
let prose = crate::audiobook::typst_to_plain(&raw);
if prose.trim().is_empty() {
continue;
}
let recap = build_recap(&met, &open);
let prompt = build_prompt(&recap, &cr.title, &prose);
let findings = crate::cli::realworld::slow_llm_call(
project,
"lector-read",
FIRST_READER_SYSTEM,
prompt,
max_cost,
force,
)
.map_err(|e| e.to_string())?;
for f in findings {
let (kind, severity) = map_category(&f.category);
counter += 1;
out.push(ReaderFinding {
kind,
severity,
chapter: cr.chapter,
anchor: None,
entities: Vec::new(),
message: f.body,
source: "reader",
dedup_key: format!("reader|{kind}|{}|{counter}", cr.chapter),
});
}
met.extend(cr.new_entities.iter().cloned());
for t in &cr.opened_threads {
open.push(t.clone());
}
open.retain(|t| !cr.resolved_threads.contains(t));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recap_opens_blank_then_accumulates() {
assert!(build_recap(&[], &[]).contains("opening"));
let r = build_recap(&["Mara".into(), "Velmaril".into()], &["the sealed letter".into()]);
assert!(r.contains("Mara") && r.contains("Velmaril"));
assert!(r.contains("the sealed letter"));
}
#[test]
fn prompt_carries_recap_prose_and_forward_only_framing() {
let p = build_prompt("You've met: Mara.", "The Crossing", "Aldous rowed on.");
assert!(p.contains("You've met: Mara."));
assert!(p.contains("The Crossing"));
assert!(p.contains("Aldous rowed on."));
assert!(p.contains("you do not know what happens next"));
}
#[test]
fn categories_map_to_kind_and_severity() {
assert_eq!(map_category("put_down"), ("put_down_risk", Severity::Concern));
assert_eq!(map_category("Confusion").0, "confusion");
assert_eq!(map_category("stakes"), ("stakes_gap", Severity::Notice));
assert_eq!(map_category("engagement").1, Severity::Info);
assert_eq!(map_category("whatever"), ("reader", Severity::Info));
}
#[test]
fn system_prompt_is_forward_only_and_structured() {
assert!(FIRST_READER_SYSTEM.contains("do NOT know what happens in later chapters"));
assert!(FIRST_READER_SYSTEM.contains("JSON array"));
assert!(FIRST_READER_SYSTEM.contains("Return [] if"));
}
}