build: stages in the plan's own words

`stages_text` on a need gives each stage plain words in the plan's
language. The fallback mails, the road line and the desk buttons use
them, and needs.yaml carries them as `stage_words` so the desk score
describes an outcome the way the site would. The vel's plan has them;
its desks score 26 of 26 where the id-based outcome scored 24.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-24 05:17:18 +02:00
co-authored by Claude Fable 5.1
parent 8e6a93ebb7
commit be14e33603
5 changed files with 60 additions and 8 deletions
+6
View File
@@ -167,6 +167,12 @@ the business's own words and held-out personas.
body (`aggregates.yaml` in the words map); the fallback says what body (`aggregates.yaml` in the words map); the fallback says what
happened and, where the submitter has a move from that stage, the happened and, where the submitter has a move from that stage, the
link to make it. link to make it.
- `stages_text: { <stage>: <words> }` on a need: each stage in the
plan's language, where the ASCII id will not do (`ikke_lost` reads
as English). Used by the fallback mails, the "what happens next"
line, the desk buttons' fallback labels, and written into needs.yaml
as `stage_words` so the desk score describes an outcome in the
site's own words. With them, the vel's desks score 26 of 26.
- `attended_by:` on a need: who moves cases that no desk button moves, - `attended_by:` on a need: who moves cases that no desk button moves,
an automation listening on the bucket's events, written into an automation listening on the bucket's events, written into
aggregates.yaml as portal's own annotation for the reader. aggregates.yaml as portal's own annotation for the reader.
+11
View File
@@ -70,6 +70,7 @@ needs:
door: medlem door: medlem
handled_by: kasserer handled_by: kasserer
stages: [open, betalt, utmeldt] stages: [open, betalt, utmeldt]
stages_text: { open: "mottatt", betalt: "betalt", utmeldt: "meldt ut" }
done_when: [betalt, utmeldt] done_when: [betalt, utmeldt]
success: [betalt] success: [betalt]
# Medlemmet melder seg ut selv, gjennom sin egen lenke. # Medlemmet melder seg ut selv, gjennom sin egen lenke.
@@ -89,6 +90,7 @@ needs:
door: medlem door: medlem
handled_by: kasserer handled_by: kasserer
stages: [open, oppdatert] stages: [open, oppdatert]
stages_text: { open: "mottatt", oppdatert: "oppdatert" }
done_when: [oppdatert] done_when: [oppdatert]
success: [oppdatert] success: [oppdatert]
max_fields: 3 max_fields: 3
@@ -104,6 +106,7 @@ needs:
door: melde-fra door: melde-fra
handled_by: trafikk handled_by: trafikk
stages: [open, tatt_opp, ordnet, avvist] stages: [open, tatt_opp, ordnet, avvist]
stages_text: { open: "mottatt", tatt_opp: "tatt opp med kommunen", ordnet: "ordnet", avvist: "avvist" }
done_when: [ordnet, avvist] done_when: [ordnet, avvist]
success: [ordnet] success: [ordnet]
# Alle kan se hva som er meldt og hvor det står; e-posten vises ikke. # Alle kan se hva som er meldt og hvor det står; e-posten vises ikke.
@@ -121,6 +124,7 @@ needs:
door: melde-fra door: melde-fra
handled_by: lekeplasser handled_by: lekeplasser
stages: [open, tatt_tak_i, utbedret] stages: [open, tatt_tak_i, utbedret]
stages_text: { open: "mottatt", tatt_tak_i: "tatt tak i", utbedret: "utbedret" }
done_when: [utbedret] done_when: [utbedret]
success: [utbedret] success: [utbedret]
watched_by: [public] watched_by: [public]
@@ -137,6 +141,7 @@ needs:
handled_by: horingsinstans handled_by: horingsinstans
watched_by: [styret] watched_by: [styret]
stages: [open, tatt_med, ikke_tatt_med] stages: [open, tatt_med, ikke_tatt_med]
stages_text: { open: "mottatt", tatt_med: "tatt med i høringssvaret", ikke_tatt_med: "ikke tatt med" }
done_when: [tatt_med, ikke_tatt_med] done_when: [tatt_med, ikke_tatt_med]
success: [tatt_med] success: [tatt_med]
max_fields: 3 max_fields: 3
@@ -152,6 +157,7 @@ needs:
door: bidra door: bidra
handled_by: arrangementer handled_by: arrangementer
stages: [open, kontaktet, satt_opp, takket_nei] stages: [open, kontaktet, satt_opp, takket_nei]
stages_text: { open: "mottatt", kontaktet: "kontaktet", satt_opp: "satt opp", takket_nei: "takket nei" }
done_when: [satt_opp, takket_nei] done_when: [satt_opp, takket_nei]
success: [satt_opp] success: [satt_opp]
max_fields: 3 max_fields: 3
@@ -167,6 +173,7 @@ needs:
door: bidra door: bidra
handled_by: styret handled_by: styret
stages: [open, plassert, avslatt] stages: [open, plassert, avslatt]
stages_text: { open: "mottatt", plassert: "plassert i en komité", avslatt: "avslått" }
done_when: [plassert, avslatt] done_when: [plassert, avslatt]
success: [plassert] success: [plassert]
max_fields: 3 max_fields: 3
@@ -185,6 +192,7 @@ needs:
door: naboer door: naboer
handled_by: nabohjelp handled_by: nabohjelp
stages: [open, formidlet, ordnet, ikke_lost] stages: [open, formidlet, ordnet, ikke_lost]
stages_text: { open: "mottatt", formidlet: "formidlet til en nabo", ordnet: "ordnet", ikke_lost: "ikke løst" }
done_when: [ordnet, ikke_lost] done_when: [ordnet, ikke_lost]
success: [ordnet] success: [ordnet]
moves_by: { ordnet: [submitter, nabohjelp] } moves_by: { ordnet: [submitter, nabohjelp] }
@@ -204,6 +212,7 @@ needs:
door: naboer door: naboer
handled_by: nabohjelp handled_by: nabohjelp
stages: [open, registrert, trukket] stages: [open, registrert, trukket]
stages_text: { open: "mottatt", registrert: "registrert som hjelper", trukket: "trukket" }
done_when: [registrert, trukket] done_when: [registrert, trukket]
success: [registrert] success: [registrert]
moves_by: { trukket: [submitter, nabohjelp] } moves_by: { trukket: [submitter, nabohjelp] }
@@ -222,6 +231,7 @@ needs:
door: naboer door: naboer
handled_by: styret handled_by: styret
stages: [open, lagt_ut, tatt_ned] stages: [open, lagt_ut, tatt_ned]
stages_text: { open: "mottatt", lagt_ut: "lagt ut", tatt_ned: "tatt ned" }
done_when: [lagt_ut, tatt_ned] done_when: [lagt_ut, tatt_ned]
success: [lagt_ut] success: [lagt_ut]
# Den som la det ut kan ta det ned selv. Tavla er offentlig: det er # Den som la det ut kan ta det ned selv. Tavla er offentlig: det er
@@ -243,6 +253,7 @@ needs:
lands_in: utlegg lands_in: utlegg
handled_by: kasserer handled_by: kasserer
stages: [open, godkjent, utbetalt, avvist] stages: [open, godkjent, utbetalt, avvist]
stages_text: { open: "mottatt", godkjent: "godkjent", utbetalt: "utbetalt", avvist: "avvist" }
done_when: [utbetalt, avvist] done_when: [utbetalt, avvist]
success: [utbetalt] success: [utbetalt]
max_fields: 3 max_fields: 3
+35 -5
View File
@@ -190,6 +190,13 @@ pub struct PlanNeed {
/// its desk. /// its desk.
#[serde(default)] #[serde(default)]
pub events: BTreeMap<String, String>, pub events: BTreeMap<String, String>,
/// Each stage in plain words, in the plan's language, where the
/// id will not do: ids are ASCII, so a Norwegian `ikke_lost` reads
/// as English in a mail, a road line or a desk score. Used by the
/// fallback mails, the "what happens next" line, the desk
/// buttons, and written into needs.yaml for the scorer.
#[serde(default)]
pub stages_text: BTreeMap<String, String>,
/// Who moves cases that no desk button moves - an automation /// Who moves cases that no desk button moves - an automation
/// listening on the bucket's events. Written into aggregates.yaml /// listening on the bucket's events. Written into aggregates.yaml
/// for the reader, as portal's `attended_by` annotation. /// for the reader, as portal's `attended_by` annotation.
@@ -364,6 +371,14 @@ impl Plan {
bail!(at("has mails, so the plan needs `mail: { from: ... }` saying who they come from")); bail!(at("has mails, so the plan needs `mail: { from: ... }` saying who they come from"));
} }
} }
for (state, text) in &need.stages_text {
if !need.stages.contains(state) {
bail!(at(&format!("stages_text names {state:?}, which is not one of its stages")));
}
if text.trim().is_empty() {
bail!(at(&format!("stages_text for {state:?} is empty")));
}
}
for (state, event) in &need.events { for (state, event) in &need.events {
if !need.stages.contains(state) { if !need.stages.contains(state) {
bail!(at(&format!("events names {state:?}, which is not one of its stages"))); bail!(at(&format!("events names {state:?}, which is not one of its stages")));
@@ -425,6 +440,7 @@ impl Plan {
|| other.events != first.events || other.events != first.events
|| other.attended_by != first.attended_by || other.attended_by != first.attended_by
|| other.mails != first.mails || other.mails != first.mails
|| other.stages_text != first.stages_text
{ {
bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable, events, attended_by, mails and who moves and watches it", first.id, other.id); bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable, events, attended_by, mails and who moves and watches it", first.id, other.id);
} }
@@ -521,9 +537,14 @@ pub fn event_for(need: &PlanNeed, stage: &str) -> String {
/// The stages a case goes through, as one sentence: "It is received, /// The stages a case goes through, as one sentence: "It is received,
/// then surveyed, then quoted, and ends won or lost." /// then surveyed, then quoted, and ends won or lost."
/// A stage in words: what the plan says, else its id made readable.
pub fn stage_text(need: &PlanNeed, stage: &str) -> String {
need.stages_text.get(stage).cloned().unwrap_or_else(|| humanize(stage).to_lowercase())
}
fn stages_sentence(need: &PlanNeed) -> String { fn stages_sentence(need: &PlanNeed) -> String {
let line: Vec<String> = need.stages.iter().filter(|s| !need.done_when.contains(s)).map(|s| humanize(s).to_lowercase()).collect(); let line: Vec<String> = need.stages.iter().filter(|s| !need.done_when.contains(s)).map(|s| stage_text(need, s)).collect();
let ends: Vec<String> = need.done_when.iter().map(|s| humanize(s).to_lowercase()).collect(); let ends: Vec<String> = need.done_when.iter().map(|s| stage_text(need, s)).collect();
let mut out = String::from("It is "); let mut out = String::from("It is ");
out.push_str(&line.join(", then ")); out.push_str(&line.join(", then "));
if ends.len() == 1 { if ends.len() == 1 {
@@ -534,6 +555,14 @@ fn stages_sentence(need: &PlanNeed) -> String {
out out
} }
fn capitalize(text: &str) -> String {
let mut chars = text.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn humanize(id: &str) -> String { fn humanize(id: &str) -> String {
let mut words = id.replace(['_', '-'], " "); let mut words = id.replace(['_', '-'], " ");
if let Some(first) = words.get_mut(0..1) { if let Some(first) = words.get_mut(0..1) {
@@ -679,7 +708,7 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
// Only the buttons this group presses; a watcher gets none. // Only the buttons this group presses; a watcher gets none.
let moves: Map<String, Value> = moves_of(needs[0], &group) let moves: Map<String, Value> = moves_of(needs[0], &group)
.into_iter() .into_iter()
.map(|(from, to)| (format!("{from}->{to}"), Value::String(humanize(&to)))) .map(|(from, to)| (format!("{from}->{to}"), Value::String(capitalize(&stage_text(needs[0], &to)))))
.collect(); .collect();
(bucket.to_string(), json!({ "heading": humanize(bucket), "description": "", "moves": moves })) (bucket.to_string(), json!({ "heading": humanize(bucket), "description": "", "moves": moves }))
}) })
@@ -749,7 +778,7 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
}; };
let mut per_stage: Map<String, Value> = Map::new(); let mut per_stage: Map<String, Value> = Map::new();
for (stage, to) in &need.mails { for (stage, to) in &need.mails {
let stage_word = humanize(stage).to_lowercase(); let stage_word = stage_text(need, stage);
let what = if stage == &need.stages[0] { let what = if stage == &need.stages[0] {
format!("{} {}.", t.received_what_you_sent_about, need.wants) format!("{} {}.", t.received_what_you_sent_about, need.wants)
} else { } else {
@@ -1440,6 +1469,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
"lands_in": n.lands_in, "handled_by": n.handled_by, "lands_in": n.lands_in, "handled_by": n.handled_by,
"stages": n.stages, "done_when": n.done_when, "success": n.success, "stages": n.stages, "done_when": n.done_when, "success": n.success,
"max_steps": steps, "max_fields": n.max_fields, "max_steps": steps, "max_fields": n.max_fields,
"stage_words": n.stages.iter().map(|s| (s.clone(), json!(stage_text(n, s)))).collect::<Map<_, _>>(),
}); });
if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); } if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); }
let others: BTreeSet<&String> = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect(); let others: BTreeSet<&String> = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect();
@@ -1612,7 +1642,7 @@ mod tests {
lands_in: "b".into(), handled_by: "g".into(), lands_in: "b".into(), handled_by: "g".into(),
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable), stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
moves_by: BTreeMap::new(), watched_by: vec![], moves_by: BTreeMap::new(), watched_by: vec![],
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(),
collects: vec![], max_fields: 3, collects: vec![], max_fields: 3,
} }
} }
+3
View File
@@ -80,6 +80,9 @@ needs:
# who hears when a case enters a stage: `submitter` (the form # who hears when a case enters a stage: `submitter` (the form
# must then collect a required `email`) or one address. Tell the # must then collect a required `email`) or one address. Tell the
# person on arrival and on every decision that is about them. # person on arrival and on every decision that is about them.
stages_text: { open: <words>, <stage>: <words> }
# each stage in the intake's language, where the ASCII id
# would read wrong to a person: "ikke_lost" is "ikke løst"
events: { open: applied } # ONLY when rebuilding a live site: the event names its events: { open: applied } # ONLY when rebuilding a live site: the event names its
# aggregates.yaml already has, so its records keep replaying # aggregates.yaml already has, so its records keep replaying
attended_by: > # ONLY for a stage no desk button moves: the automation that does attended_by: > # ONLY for a stage no desk button moves: the automation that does
+5 -3
View File
@@ -187,8 +187,10 @@ fn s(v: &Value) -> String {
/// the button for it. The outcome is put in plain words from the /// the button for it. The outcome is put in plain words from the
/// state's own name, never from the label, and the buttons are listed /// state's own name, never from the label, and the buttons are listed
/// in a seeded shuffle. /// in a seeded shuffle.
fn button_pick(cmd: &str, group: &str, bucket: &str, from: &str, to: &str, labels: &[&str], seed: usize) -> anyhow::Result<String> { fn button_pick(cmd: &str, group: &str, bucket: &str, from: &str, to: &str, labels: &[&str], seed: usize, words: &Value) -> anyhow::Result<String> {
let plain = |id: &str| id.replace(['_', '-'], " "); // The state in the plan's own words when the spec carries them,
// else the id made readable.
let plain = |id: &str| words[id].as_str().map(str::to_string).unwrap_or_else(|| id.replace(['_', '-'], " "));
let mut order: Vec<&str> = labels.to_vec(); let mut order: Vec<&str> = labels.to_vec();
// A fixed rotation per move: the right answer is not always first. // A fixed rotation per move: the right answer is not always first.
let by = seed % order.len().max(1); let by = seed % order.len().max(1);
@@ -319,7 +321,7 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
continue; continue;
} }
asked += 1; asked += 1;
let picked = button_pick(&cmd, group, bucket, from, to, &labels, i)?; let picked = button_pick(&cmd, group, bucket, from, to, &labels, i, &model["buckets"][bucket]["words"])?;
if picked == *label { if picked == *label {
right += 1; right += 1;
} else { } else {