build --llm-for <prefix>, 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 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-23 23:00:37 +02:00
co-authored by Claude Fable 5.1
parent 1191551f28
commit b5f5c46ff2
2 changed files with 58 additions and 0 deletions
+8
View File
@@ -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 <prefix>: 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}"))?;
+50
View File
@@ -158,6 +158,41 @@ fn followup_page(repo: Option<&Path>, step: &Value) -> Option<Value> {
/// `/` 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<Vec<(String, String, String)>> {
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::<Vec<_>>().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<Value> {
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();