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); + } +}