From a2a9f4edcfdb6feca5d9047741f162bfa05ef4bf Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 30 Sep 2026 15:16:29 +0200 Subject: [PATCH] Tones and storylines: every word once per tone, kept through a lift, and a report of what each brought in tones in the plan lists tones of voice, one storyline each. The words pass writes each page once per tone and folds the tones into lists where they differ; the render emits the lists portal reads as storylines; the lift carries them back. needs.yaml names the tones. portal-trainer report --site reads the site's counts per storyline and scores each on what arrived and what ended well. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt --- src/build.rs | 362 +++++++++++++++++++++++++++++++++++--------------- src/main.rs | 2 + src/plan.rs | 6 + src/report.rs | 127 ++++++++++++++++++ 4 files changed, 390 insertions(+), 107 deletions(-) create mode 100644 src/report.rs diff --git a/src/build.rs b/src/build.rs index 99b6b82..78d3221 100644 --- a/src/build.rs +++ b/src/build.rs @@ -39,6 +39,14 @@ pub struct Plan { /// phone number first, so invite forms ask for one. #[serde(default = "kanidm")] pub people: String, + /// The storylines the site is told in, one tone of voice each: + /// "plain and brief, like a good notice on the door", "warm, as a + /// neighbour would put it". The words model writes every slot once + /// per tone, the content carries the words as lists, and portal + /// gives each visitor one storyline and counts what each brought + /// in (portal docs, Storylines). One tone, or none, is one story. + #[serde(default)] + pub tones: Vec, /// The sender of every mail the site sends (`mail:` in site.yaml). /// Required as soon as any need has `mails:`. #[serde(default)] @@ -316,7 +324,7 @@ pub struct PlanNeed { #[serde(deny_unknown_fields)] pub struct Field { pub name: String, - #[serde(default = "text", rename = "type")] + #[serde(default = "text_kind", rename = "type")] pub kind: String, #[serde(default)] pub optional: bool, @@ -367,7 +375,7 @@ pub fn escalated_stage(stage: &str) -> String { /// of the bucket, no login. pub const PUBLIC: &str = "public"; -fn text() -> String { +fn text_kind() -> String { "text".to_string() } @@ -392,6 +400,9 @@ impl Plan { if !["kanidm", "memberships"].contains(&self.people.as_str()) { bail!("people must be `kanidm` or `memberships`, not {:?}", self.people); } + if self.tones.iter().any(|t| t.trim().is_empty()) { + bail!("tones: every tone says something about the voice; an empty one says nothing"); + } // A contact that is a group has to be one with a desk, or // nobody could sign in as it. if let Some(group) = &self.contact.group { @@ -1507,10 +1518,41 @@ pub fn merge_words(fallback: &Value, given: &Value) -> Value { (Value::String(_), Value::String(s)) if !s.trim().is_empty() && !s.contains(SLOT) => { Value::String(s.trim().to_string()) } + // One string per storyline, every one of them said. + (Value::String(_) | Value::Array(_), Value::Array(items)) if !items.is_empty() && items.iter().all(|i| i.as_str().is_some_and(|x| !x.trim().is_empty() && !x.contains(SLOT))) => { + Value::Array(items.iter().map(|i| json!(i.as_str().unwrap_or_default().trim())).collect()) + } + (Value::Array(_), Value::String(s)) if !s.trim().is_empty() && !s.contains(SLOT) => { + Value::String(s.trim().to_string()) + } _ => fallback.clone(), } } +/// One page's words as told in each tone, folded into one: a slot the +/// tones agree on stays one string, a slot they tell differently +/// becomes the list, in the tones' order. +pub fn fold_tones(told: &[Value]) -> Value { + match told.first() { + Some(Value::Object(first)) => Value::Object( + first + .keys() + .map(|k| (k.clone(), fold_tones(&told.iter().map(|t| t.get(k).cloned().unwrap_or(Value::Null)).collect::>()))) + .collect(), + ), + Some(Value::String(_)) => { + let each: Vec<&str> = told.iter().map(|t| t.as_str().unwrap_or_default()).collect(); + if each.iter().all(|x| *x == each[0]) { + json!(each[0]) + } else { + Value::Array(each.iter().map(|x| json!(x)).collect()) + } + } + Some(other) => other.clone(), + None => Value::Null, + } +} + const SLOT: &str = " Option { ICONS.contains(&name).then(|| format!("lucide:{name}")) } +/// A slot's words for the machinery's purposes: the string, or the +/// first storyline's when the slot holds several (see `text`). fn s(v: &Value) -> &str { - v.as_str().unwrap_or_default() + match v { + Value::Array(items) => items.first().and_then(|x| x.as_str()).unwrap_or_default(), + other => other.as_str().unwrap_or_default(), + } +} + +/// A slot's words as the content carries them: one string, or one +/// per storyline - portal reads a list as its storylines +/// (`docs/storylines.md`). Trimmed either way. +fn text(v: &Value) -> Value { + match v { + Value::Array(items) => Value::Array(items.iter().map(|x| json!(x.as_str().unwrap_or_default().trim())).collect()), + other => json!(other.as_str().unwrap_or_default().trim()), + } +} + +/// The same slot, each storyline put through `f`. +fn text_map(v: &Value, f: impl Fn(&str) -> String) -> Value { + match v { + Value::Array(items) => Value::Array(items.iter().map(|x| json!(f(x.as_str().unwrap_or_default()))).collect()), + other => json!(f(other.as_str().unwrap_or_default())), + } +} + +/// A one-button `consequence`: `[Send]`, or one list per storyline. +fn button(v: &Value) -> Value { + match v { + Value::Array(items) => Value::Array(items.iter().map(|x| json!([x.as_str().unwrap_or_default().trim()])).collect()), + other => json!([other.as_str().unwrap_or_default().trim()]), + } } fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Value { let parent = plan.parent_of(need); let mut requirements: Vec = need.collects.iter().map(|f| { - let mut r = json!({ "name": f.name, "label": s(&w["labels"][&f.name]), "type": f.kind }); + let mut r = json!({ "name": f.name, "label": text(&w["labels"][&f.name]), "type": f.kind }); if f.optional { r["optional"] = json!(true); } if f.kind == "select" { r["options"] = json!(select_options(plan, f)); } if f.multiple { r["multiple"] = json!(true); } @@ -1681,19 +1754,19 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va requirements.push(json!({ "name": parent.lands_in, "type": "record", "of": parent.lands_in, "value": "{key}" })); } let mut feature = json!({ - "name": s(&w["form_title"]), + "name": text(&w["form_title"]), "requirements": requirements, }); if !s(&w["form_description"]).trim().is_empty() { - feature["description"] = json!(s(&w["form_description"]).trim()); + feature["description"] = text(&w["form_description"]); } if let Some(icon) = icon_name(s(&w["icon"])) { feature["icon"] = json!(icon); } // Context first, then the form: the reader learns where this goes // and who is at the other end before being asked for anything. - let strip = |title: &str, text: &str, icon: &str| { - let mut f = json!({ "name": title.trim(), "description": text.trim() }); + let strip = |title: &Value, body: &Value, icon: &str| { + let mut f = json!({ "name": text(title), "description": text(body) }); if let Some(icon) = icon_name(icon) { f["icon"] = json!(icon); } f }; @@ -1707,25 +1780,25 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va // never empty, so a word-driven condition would conjure a section // the live page does not have and shift every feature after it. if !need.requires.trim().is_empty() { - features.push(strip(s(&w["requires_title"]), s(&w["requires_description"]), s(&w["requires_icon"]))); + features.push(strip(&w["requires_title"], &w["requires_description"], s(&w["requires_icon"]))); } if !s(&w["road_description"]).trim().is_empty() { - features.push(strip(s(&w["road_title"]), s(&w["road_description"]), s(&w["road_icon"]))); + features.push(strip(&w["road_title"], &w["road_description"], s(&w["road_icon"]))); } if !s(&w["who_description"]).trim().is_empty() { - features.push(strip(s(&w["who_title"]), s(&w["who_description"]), s(&w["who_icon"]))); + features.push(strip(&w["who_title"], &w["who_description"], s(&w["who_icon"]))); } features.push(feature); let mut alt = json!({ - "name": s(&w["heading"]), - "description": s(&w["description"]), + "name": text(&w["heading"]), + "description": text(&w["description"]), "action": action, - "consequence": [s(&w["button"])], + "consequence": button(&w["button"]), "record_as": need.lands_in, "features": features, }); if !s(&w["encouragement"]).trim().is_empty() { - alt["encouragements"] = json!([s(&w["encouragement"]).trim()]); + alt["encouragements"] = button(&w["encouragement"]); } alt } @@ -1745,7 +1818,7 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result) -> anyhow::Result format!("{{{}.email}}", parent.lands_in), _ => to.clone(), }; - state["mail"] = json!({ "to": to, "subject": s(&w["subject"]), "body": s(&w["body"]) }); + state["mail"] = json!({ "to": to, "subject": text(&w["subject"]), "body": text(&w["body"]) }); } if let Some(group) = need.grants.get(st) { state["grants"] = json!(group); @@ -1798,7 +1871,7 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result) -> anyhow::Result = front["opener"]["features"].as_object().into_iter().flatten().map(|(_, w)| card(w)).collect(); let mut alternatives: Vec = vec![json!({ - "name": s(&front["opener"]["heading"]), - "description": s(&front["opener"]["description"]), + "name": text(&front["opener"]["heading"]), + "description": text(&front["opener"]["description"]), "features": does, })]; let mut shopping: Vec = plan @@ -1824,15 +1897,15 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result s(&w["heading"]).trim(), l => l }; - f["description"] = json!(format!("{} [{link}](/status/{bucket})", s(&w["description"]).trim()).trim().to_string()); + let link = match s(&w["link"]).trim() { "" => s(&w["heading"]).trim().to_string(), l => l.to_string() }; + f["description"] = text_map(&w["description"], |d| format!("{} [{link}](/status/{bucket})", d.trim()).trim().to_string()); f["resource"] = public_resource(bucket, &needs, s(&w["empty"]), Some(FRONT_DOOR_LATEST), s(&words[&public_view_path(bucket)]["answer"])); shopping.push(f); } if !shopping.is_empty() { alternatives.push(json!({ - "name": s(&front["shopping"]["heading"]), - "description": s(&front["shopping"]["description"]), + "name": text(&front["shopping"]["heading"]), + "description": text(&front["shopping"]["description"]), "features": shopping, })); } @@ -1842,10 +1915,10 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result form_alternative(plan, n, &w["needs"][&n.id], &public_followup(n)), // The way to the group's page: portal shows the // sign-in gate there to whoever is not in the group. Some(group) => json!({ - "name": s(&w["needs"][&n.id]["heading"]), - "description": s(&w["needs"][&n.id]["description"]), + "name": text(&w["needs"][&n.id]["heading"]), + "description": text(&w["needs"][&n.id]["description"]), "action": internal_page(n, group), - "consequence": [s(&w["needs"][&n.id]["button"])], + "consequence": button(&w["needs"][&n.id]["button"]), }), }).collect::>(), }), @@ -1915,8 +1988,8 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result>(), }), @@ -1934,16 +2007,16 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result{to}")]) }) + json!({ "from": from, "to": to, "label": text(&bw["moves"][&format!("{from}->{to}")]) }) }).collect::>(), }); // A group that has the bucket only because cases escalate @@ -1986,20 +2059,20 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result) -> anyhow::Result>(), }), ); @@ -2173,13 +2246,18 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result>(), "personas": plan.personas, + "tones": plan.tones, }), ); files.into_iter().map(|(path, value)| Ok((path, serde_yaml::to_string(&value)?))).collect() } -fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String { +fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -> String { + let voice = match tone { + Some(tone) => format!("\n\nTone of voice for every word on this page: {tone}. The site is told in more than one tone, each written in a pass of its own, so keep to this one throughout and do not hedge between tones."), + None => String::new(), + }; let shown_only = public_buckets(plan) .into_iter() .find(|(bucket, _)| public_view_path(bucket) == path) @@ -2222,7 +2300,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String { .map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::>().join(", "))), ); format!( - "You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- The button says what pressing it does; the encouragement says what happens next or how little it takes; the form's guidance line says what to have ready. Placeholders are example answers in the reader's words.\n- An icon is one name from this list, chosen for what the reader is doing, or empty: {}\n- Write the way people in this trade and country actually speak.\n\n{}\n\nReply with the JSON object only.", + "You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- The button says what pressing it does; the encouragement says what happens next or how little it takes; the form's guidance line says what to have ready. Placeholders are example answers in the reader's words.\n- An icon is one name from this list, chosen for what the reader is doing, or empty: {}\n- Write the way people in this trade and country actually speak.{voice}\n\n{}\n\nReply with the JSON object only.", plan.business, plan.about.trim(), plan.contact.name, @@ -2282,21 +2360,35 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { 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}"))?; - match first_object(&reply) { - Some(given) => { - let merged = merge_words(fallback, &given); - match wrong_language(&plan.language, &given) { - Some(found) => eprintln!("words: {path} - the reply reads as {found} for a {} plan, keeping the words it had", plan.language), - None => { - words.insert(path.clone(), merged); - eprintln!("words: {path}"); + // Once per tone, or once: each tone's page is written whole, + // so a storyline is consistent within itself, and the tones + // are folded into lists where they differ. + let tones: Vec> = if plan.tones.is_empty() { vec![None] } else { plan.tones.iter().map(|t| Some(t.as_str())).collect() }; + let mut told: Vec = Vec::new(); + for (t, tone) in tones.iter().enumerate() { + let prompt = words_prompt(&plan, path, fallback, *tone); + let reply = crate::llm_with(&cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.4")]).unwrap_or_default(); + let suffix = if tones.len() > 1 { format!("-tone{}", t + 1) } else { String::new() }; + std::fs::write(out.join(format!("llm/{:02}-{}{suffix}.md", n + 1, path.replace('/', "_"))), format!("{prompt}\n\n=== REPLY ===\n{reply}"))?; + match first_object(&reply) { + Some(given) => match wrong_language(&plan.language, &given) { + Some(found) => { + eprintln!("words: {path} - the reply reads as {found} for a {} plan, keeping the words it had", plan.language); + told.clear(); + break; } + None => told.push(merge_words(fallback, &given)), + }, + None => { + eprintln!("words: {path} - no usable reply, keeping the fallback"); + told.clear(); + break; } } - None => eprintln!("words: {path} - no usable reply, keeping the fallback"), + } + if !told.is_empty() { + words.insert(path.clone(), fold_tones(&told)); + eprintln!("words: {path}"); } } } @@ -2860,7 +2952,7 @@ pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result = fallback.iter().map(|(path, v)| (path.clone(), tokenize(v, path, ""))).collect(); let rendered = render(plan, &tokened)?; - let mut found: std::collections::HashMap = std::collections::HashMap::new(); + let mut found: std::collections::HashMap = std::collections::HashMap::new(); for (path, text) in &rendered { // The spec is derived from the words (a need's `via` is its // heading), never a source of them: behind a door it named the @@ -3008,18 +3100,18 @@ fn tokenize(v: &Value, path: &str, pointer: &str) -> Value { } } -fn untokenize(v: &Value, path: &str, pointer: &str, found: &std::collections::HashMap) -> Value { +fn untokenize(v: &Value, path: &str, pointer: &str, found: &std::collections::HashMap) -> Value { match v { Value::Object(m) => Value::Object(m.iter().map(|(k, x)| (k.clone(), untokenize(x, path, &format!("{pointer}/{k}"), found))).collect()), Value::String(_) => match found.get(&token(path, pointer)) { - Some(word) => Value::String(word.clone()), + Some(word) => word.clone(), None => v.clone(), }, other => other.clone(), } } -fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std::collections::HashMap) { +fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std::collections::HashMap) { match (a, b) { (serde_yaml::Value::Mapping(x), serde_yaml::Value::Mapping(y)) => { for (k, va) in x { @@ -3036,6 +3128,19 @@ fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std:: // door's three headings on three unrelated new cards. Items // with no identity (an information feature) fall back to // position. + // A live list of lists under a rendered list of tokens is the + // storylines of a button list (`consequence: [[Send], [Meld meg]]`): + // each token gets one word per storyline. + (serde_yaml::Value::Sequence(x), serde_yaml::Value::Sequence(y)) + if x.iter().all(|t| t.as_str().is_some_and(|t| t.starts_with('\u{1}'))) && !y.is_empty() && y.iter().all(|v| v.as_sequence().is_some_and(|l| l.iter().all(|w| w.as_str().is_some()))) => + { + for (k, t) in x.iter().enumerate() { + let words: Vec<&str> = y.iter().filter_map(|l| l.as_sequence()?.get(k)?.as_str()).collect(); + if words.len() == y.len() && words.iter().all(|w| !w.trim().is_empty()) { + found.insert(t.as_str().unwrap_or_default().to_string(), Value::Array(words.iter().map(|w| json!(w)).collect())); + } + } + } (serde_yaml::Value::Sequence(x), serde_yaml::Value::Sequence(y)) => { let mut used = vec![false; y.len()]; for (i, va) in x.iter().enumerate() { @@ -3071,9 +3176,13 @@ fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std:: // that has since been given something to say would be // emptied again by every rebuild. if !word.trim().is_empty() { - found.insert(t.clone(), word.to_string()); + found.insert(t.clone(), json!(word)); } } + // A live list of strings under one token: the slot's storylines. + (serde_yaml::Value::String(t), serde_yaml::Value::Sequence(live)) if t.starts_with('\u{1}') && !live.is_empty() && live.iter().all(|w| w.as_str().is_some_and(|w| !w.trim().is_empty())) => { + found.insert(t.clone(), Value::Array(live.iter().map(|w| json!(w.as_str().unwrap_or_default())).collect())); + } _ => {} } } @@ -3119,6 +3228,45 @@ mod invite_tests { assert!(nobody.validate().unwrap_err().to_string().contains("no desk")); } + #[test] + fn two_tones_become_two_storylines_that_render_lift_and_render_again() { + let plan: Plan = serde_yaml::from_str(&PLAN.replace("business: Vel", "business: Vel\ntones:\n - plain, like a notice on the door\n - warm, as a neighbour would put it")).unwrap(); + plan.validate().unwrap(); + // The words as two tones told them, folded: agreed slots stay + // one string, others become the list. + let mut words = fallback_words(&plan); + let page = words["questions/index.yaml"].clone(); + let mut warm = page.clone(); + warm["needs"]["report"]["heading"] = json!("Say where it is dangerous"); + warm["needs"]["report"]["button"] = json!("Tell us"); + let folded = fold_tones(&[page.clone(), warm]); + assert_eq!(folded["needs"]["report"]["heading"], json!([page["needs"]["report"]["heading"], "Say where it is dangerous"])); + assert_eq!(folded["question"], page["question"], "a slot the tones agree on stays one string"); + words.insert("questions/index.yaml".into(), folded); + // Rendered, a list is a list, and a button list a list of lists. + let files = render(&plan, &words).unwrap(); + let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap(); + let card = front["alternatives"].as_array().unwrap().iter().find(|a| a["record_as"] == "hazards").unwrap(); + assert_eq!(card["name"], json!([page["needs"]["report"]["heading"], "Say where it is dangerous"])); + assert_eq!(card["consequence"], json!([[page["needs"]["report"]["button"]], ["Tell us"]])); + assert!(files["needs.yaml"].contains("tones:")); + // Lifted back out of the rendered site, the storylines survive. + let repo = std::env::temp_dir().join(format!("trainer-tones-{}", std::process::id())); + for (path, text) in &files { + let full = repo.join(path); + std::fs::create_dir_all(full.parent().unwrap()).unwrap(); + std::fs::write(full, text).unwrap(); + } + let lifted = lift_words(&plan, &repo).unwrap(); + assert_eq!(lifted["questions/index.yaml"]["needs"]["report"]["heading"], json!([page["needs"]["report"]["heading"], "Say where it is dangerous"])); + assert_eq!(lifted["questions/index.yaml"]["needs"]["report"]["button"], json!([page["needs"]["report"]["button"], "Tell us"])); + assert_eq!(render(&plan, &lifted).unwrap()["questions/index.yaml"], files["questions/index.yaml"]); + let _ = std::fs::remove_dir_all(&repo); + // An empty tone says nothing. + let blank: Plan = serde_yaml::from_str(&PLAN.replace("business: Vel", "business: Vel\ntones: [\"\"]")).unwrap(); + assert!(blank.validate().unwrap_err().to_string().contains("tones")); + } + #[test] fn with_memberships_a_desk_invites_by_phone_and_lists_who_is_in() { let plan: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]\n", "invites_by: [board]\npeople: memberships\n")).unwrap(); diff --git a/src/main.rs b/src/main.rs index 6a6f56a..13655e1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ mod build; mod card; mod plan; mod rate; +mod report; mod score; mod simulate; @@ -54,6 +55,7 @@ fn main() -> anyhow::Result<()> { Some("plan") => return plan::run(&argv), Some("build") => return build::run(&argv), Some("rate") => return rate::rate(&argv), + Some("report") => return report::run(&argv), Some("prefer") => return rate::prefer(&argv), Some("score") => return score::run(&argv), _ => {} diff --git a/src/plan.rs b/src/plan.rs index b63d86a..28728ef 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -169,6 +169,12 @@ rest. Each is one line in the plan, and the builder does the wiring: behind sign-in, with name and address filled from the account: a seat on a committee for members, a discount for customers. The group is one a decision `grants`, so nobody has to be waved in. +- **Tell it two ways and find out.** `tones` lists tones of voice, one + storyline each: every word on the site is written once per tone, a + visitor reads one storyline throughout, and the site counts what + each brought in. Two is plenty; name them as a person would ("plain, + like a good notice on the door"; "warm, as a neighbour would put + it"). Leave it out to tell one story. - **Keep the people in portal.** `people: memberships` keeps every group in portal itself: a desk invite is a place held until the person signs in, a person is their phone number first, and any diff --git a/src/report.rs b/src/report.rs new file mode 100644 index 0000000..e096589 --- /dev/null +++ b/src/report.rs @@ -0,0 +1,127 @@ +//! What a live site's storylines did, read back as counts. +//! +//! `portal-trainer report --site https://vel.example [--needs needs.yaml]` +//! fetches the site's `/report` (portal docs, Storylines): per bucket, +//! per storyline, per state, a count and nothing else. With the site's +//! `needs.yaml` the states that end well are known, and each storyline +//! is scored on how many cases it brought in and how many of those +//! ended well. Storylines are assigned evenly, so the counts compare +//! directly. This is the live half of what the scorer measures offline +//! with personas: the same tones, judged by what people did. +use std::collections::BTreeMap; + +use anyhow::{anyhow, bail, Context}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +struct Report { + storylines: usize, + /// bucket -> storyline -> state -> count + buckets: BTreeMap>>, +} + +#[derive(Debug, Default, Deserialize)] +struct Needs { + #[serde(default)] + needs: Vec, + #[serde(default)] + tones: Vec, +} + +#[derive(Debug, Deserialize)] +struct Need { + lands_in: String, + #[serde(default)] + done_when: Vec, + #[serde(default)] + success: Vec, +} + +/// One storyline's showing in one bucket. +#[derive(Debug, Default, PartialEq)] +struct Showing { + arrived: u64, + ended_well: u64, +} + +/// Per bucket, per storyline index, how many arrived and how many +/// ended in a state the need calls success (or, without `success`, +/// any finished state). +fn showings(counts: &BTreeMap>>, needs: &[Need]) -> BTreeMap> { + let mut out = BTreeMap::new(); + for (bucket, per_story) in counts { + let well: Vec<&str> = needs + .iter() + .filter(|n| &n.lands_in == bucket) + .flat_map(|n| if n.success.is_empty() { n.done_when.iter() } else { n.success.iter() }) + .map(String::as_str) + .collect(); + let arity = per_story.keys().filter_map(|k| k.parse::().ok()).max().map(|m| m + 1).unwrap_or(1); + let mut rows: Vec = (0..arity).map(|_| Showing::default()).collect(); + for (story, states) in per_story { + let Ok(n) = story.parse::() else { continue }; + for (state, count) in states { + rows[n].arrived += count; + if well.contains(&state.as_str()) { + rows[n].ended_well += count; + } + } + } + out.insert(bucket.clone(), rows); + } + out +} + +pub fn run(argv: &[String]) -> anyhow::Result<()> { + let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned(); + let site = value("--site").ok_or_else(|| anyhow!("usage: portal-trainer report --site [--needs ]"))?; + let url = format!("{}/report", site.trim_end_matches('/')); + let fetched = std::process::Command::new("curl").args(["-sSf", "--max-time", "30", &url]).output().context("running curl")?; + if !fetched.status.success() { + bail!("could not read {url}: {}", String::from_utf8_lossy(&fetched.stderr).trim()); + } + let report: Report = serde_json::from_slice(&fetched.stdout).with_context(|| format!("reading {url}"))?; + let needs: Needs = match value("--needs") { + Some(path) => serde_yaml::from_str(&std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?)?, + None => Needs::default(), + }; + println!("{site}: {} storyline(s)", report.storylines); + for (i, tone) in needs.tones.iter().enumerate() { + println!(" [{i}] {tone}"); + } + if report.buckets.is_empty() { + println!("nothing sent in yet"); + return Ok(()); + } + let rows = showings(&report.buckets, &needs.needs); + for (bucket, per_story) in &rows { + println!("\n{bucket}"); + for (n, showing) in per_story.iter().enumerate() { + let rate = if showing.arrived > 0 { format!("{:.0}%", 100.0 * showing.ended_well as f64 / showing.arrived as f64) } else { "-".to_string() }; + println!(" storyline {n}: {} arrived, {} ended well ({rate})", showing.arrived, showing.ended_well); + } + if per_story.len() > 1 { + let best = per_story.iter().enumerate().max_by_key(|(_, s)| (s.ended_well, s.arrived)).map(|(n, _)| n).unwrap_or(0); + println!(" most so far: storyline {best}"); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_storyline_is_scored_on_what_arrived_and_what_ended_well() { + let counts: BTreeMap>> = + serde_json::from_str(r#"{"hazards": {"0": {"open": 3, "fixed": 4}, "1": {"open": 1, "fixed": 9, "dropped": 2}}}"#).unwrap(); + let needs = vec![Need { lands_in: "hazards".into(), done_when: vec!["fixed".into(), "dropped".into()], success: vec!["fixed".into()] }]; + let rows = showings(&counts, &needs); + assert_eq!(rows["hazards"][0], Showing { arrived: 7, ended_well: 4 }); + assert_eq!(rows["hazards"][1], Showing { arrived: 12, ended_well: 9 }); + // Without a success list, any finished state counts. + let any = vec![Need { lands_in: "hazards".into(), done_when: vec!["fixed".into(), "dropped".into()], success: vec![] }]; + assert_eq!(showings(&counts, &any)["hazards"][1].ended_well, 11); + } +}