From b5f5c46ff201654bcd8661f030301b41ac0051c8 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 23 Sep 2026 23:00:37 +0200 Subject: [PATCH] build --llm-for , and rate reads the mails The words model can be pointed at a subset of pages (`aggregates.yaml` for the mails alone) while everything else keeps the words it has. `rate --repo` rates each mail's prose on its own, LIX and length, since a person reads a mail with no card around it. Co-Authored-By: Claude Fable 5.1 --- src/build.rs | 8 ++++++++ src/rate.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/build.rs b/src/build.rs index 7a27983..6732a87 100644 --- a/src/build.rs +++ b/src/build.rs @@ -1473,7 +1473,15 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { } if let Some(cmd) = value("--llm") { std::fs::create_dir_all(out.join("llm"))?; + // --llm-for : the model writes only the pages whose + // path starts with it (`aggregates.yaml` for the mails alone, + // `questions/received/` for the followups), and everything + // else keeps the words it has. + let only = value("--llm-for"); for (n, (path, fallback)) in words.clone().iter().enumerate() { + if only.as_ref().is_some_and(|p| !path.starts_with(p.as_str())) { + continue; + } let prompt = words_prompt(&plan, path, fallback); let reply = crate::llm_with(&cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.4")]).unwrap_or_default(); std::fs::write(out.join(format!("llm/{:02}-{}.md", n + 1, path.replace('/', "_"))), format!("{prompt}\n\n=== REPLY ===\n{reply}"))?; diff --git a/src/rate.rs b/src/rate.rs index 0dc77c4..17beda5 100644 --- a/src/rate.rs +++ b/src/rate.rs @@ -158,6 +158,41 @@ fn followup_page(repo: Option<&Path>, step: &Value) -> Option { /// `/` is `questions/index.yaml`; `/a/b` is `questions/a/b.yaml` or /// `questions/a/b/index.yaml`. +/// Every `mail:` on a state in the repo's aggregates.yaml, as +/// (`bucket:state`, subject, body) with the `{placeholders}` taken +/// out, so they read as the person will read them. +pub(crate) fn mail_texts(repo: &Path) -> Option> { + let raw = std::fs::read_to_string(repo.join("aggregates.yaml")).ok()?; + let doc: Value = serde_yaml::from_str(&raw).ok()?; + let strip = |s: &str| { + let mut out = String::new(); + let mut skip = false; + for c in s.chars() { + match c { + '{' => skip = true, + '}' => skip = false, + _ if !skip => out.push(c), + _ => {} + } + } + out.split_whitespace().collect::>().join(" ") + }; + let mut out = Vec::new(); + for aggregate in doc["aggregates"].as_array().into_iter().flatten() { + let bucket = aggregate["bucket"].as_str().unwrap_or_default(); + for (state, spec) in aggregate["states"].as_object().into_iter().flatten() { + if let Some(mail) = spec.get("mail") { + out.push(( + format!("{bucket}:{state}"), + strip(mail["subject"].as_str().unwrap_or_default()), + strip(mail["body"].as_str().unwrap_or_default()), + )); + } + } + } + Some(out) +} + pub(crate) fn load_page(repo: &Path, route: &str) -> Option { let rel = route.trim_matches('/'); let candidates = if rel.is_empty() { @@ -213,6 +248,21 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> { } let mut report = BTreeMap::new(); + // The mails first, when the repo carries any: a person reads a + // mail alone, with no card around it, so its prose is rated on + // its own - LIX and length. + if let Some(mails) = repo.as_deref().and_then(mail_texts) { + if !mails.is_empty() { + println!("{:<40} {:>6} {:<14} {:>6}", "mail", "LIX", "", "words"); + for (name, subject, body) in &mails { + let text = format!("{subject}. {body}"); + let score = lix(&text); + println!("{:<40} {:>6.0} {:<14} {:>6}", name, score, lix_band(score), text.split_whitespace().count()); + report.insert(format!("mail {name}"), json!({ "lix": score, "words": text.split_whitespace().count() })); + } + println!(); + } + } println!("{:<28} {:>6} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}", "need", "LIX", "", "which", "where", "asked", "next", "gutted twin says", "page load"); for need in model["needs"].as_array().into_iter().flatten() { let id = need["id"].as_str().unwrap_or_default();