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
happened and, where the submitter has a move from that stage, the
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,
an automation listening on the bucket's events, written into
aggregates.yaml as portal's own annotation for the reader.
+11
View File
@@ -70,6 +70,7 @@ needs:
door: medlem
handled_by: kasserer
stages: [open, betalt, utmeldt]
stages_text: { open: "mottatt", betalt: "betalt", utmeldt: "meldt ut" }
done_when: [betalt, utmeldt]
success: [betalt]
# Medlemmet melder seg ut selv, gjennom sin egen lenke.
@@ -89,6 +90,7 @@ needs:
door: medlem
handled_by: kasserer
stages: [open, oppdatert]
stages_text: { open: "mottatt", oppdatert: "oppdatert" }
done_when: [oppdatert]
success: [oppdatert]
max_fields: 3
@@ -104,6 +106,7 @@ needs:
door: melde-fra
handled_by: trafikk
stages: [open, tatt_opp, ordnet, avvist]
stages_text: { open: "mottatt", tatt_opp: "tatt opp med kommunen", ordnet: "ordnet", avvist: "avvist" }
done_when: [ordnet, avvist]
success: [ordnet]
# Alle kan se hva som er meldt og hvor det står; e-posten vises ikke.
@@ -121,6 +124,7 @@ needs:
door: melde-fra
handled_by: lekeplasser
stages: [open, tatt_tak_i, utbedret]
stages_text: { open: "mottatt", tatt_tak_i: "tatt tak i", utbedret: "utbedret" }
done_when: [utbedret]
success: [utbedret]
watched_by: [public]
@@ -137,6 +141,7 @@ needs:
handled_by: horingsinstans
watched_by: [styret]
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]
success: [tatt_med]
max_fields: 3
@@ -152,6 +157,7 @@ needs:
door: bidra
handled_by: arrangementer
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]
success: [satt_opp]
max_fields: 3
@@ -167,6 +173,7 @@ needs:
door: bidra
handled_by: styret
stages: [open, plassert, avslatt]
stages_text: { open: "mottatt", plassert: "plassert i en komité", avslatt: "avslått" }
done_when: [plassert, avslatt]
success: [plassert]
max_fields: 3
@@ -185,6 +192,7 @@ needs:
door: naboer
handled_by: nabohjelp
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]
success: [ordnet]
moves_by: { ordnet: [submitter, nabohjelp] }
@@ -204,6 +212,7 @@ needs:
door: naboer
handled_by: nabohjelp
stages: [open, registrert, trukket]
stages_text: { open: "mottatt", registrert: "registrert som hjelper", trukket: "trukket" }
done_when: [registrert, trukket]
success: [registrert]
moves_by: { trukket: [submitter, nabohjelp] }
@@ -222,6 +231,7 @@ needs:
door: naboer
handled_by: styret
stages: [open, lagt_ut, tatt_ned]
stages_text: { open: "mottatt", lagt_ut: "lagt ut", tatt_ned: "tatt ned" }
done_when: [lagt_ut, tatt_ned]
success: [lagt_ut]
# Den som la det ut kan ta det ned selv. Tavla er offentlig: det er
@@ -243,6 +253,7 @@ needs:
lands_in: utlegg
handled_by: kasserer
stages: [open, godkjent, utbetalt, avvist]
stages_text: { open: "mottatt", godkjent: "godkjent", utbetalt: "utbetalt", avvist: "avvist" }
done_when: [utbetalt, avvist]
success: [utbetalt]
max_fields: 3
+35 -5
View File
@@ -190,6 +190,13 @@ pub struct PlanNeed {
/// its desk.
#[serde(default)]
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
/// listening on the bucket's events. Written into aggregates.yaml
/// 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"));
}
}
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 {
if !need.stages.contains(state) {
bail!(at(&format!("events names {state:?}, which is not one of its stages")));
@@ -425,6 +440,7 @@ impl Plan {
|| other.events != first.events
|| other.attended_by != first.attended_by
|| 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);
}
@@ -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,
/// 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 {
let line: Vec<String> = need.stages.iter().filter(|s| !need.done_when.contains(s)).map(|s| humanize(s).to_lowercase()).collect();
let ends: Vec<String> = need.done_when.iter().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| stage_text(need, s)).collect();
let mut out = String::from("It is ");
out.push_str(&line.join(", then "));
if ends.len() == 1 {
@@ -534,6 +555,14 @@ fn stages_sentence(need: &PlanNeed) -> String {
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 {
let mut words = id.replace(['_', '-'], " ");
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.
let moves: Map<String, Value> = moves_of(needs[0], &group)
.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();
(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();
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] {
format!("{} {}.", t.received_what_you_sent_about, need.wants)
} 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,
"stages": n.stages, "done_when": n.done_when, "success": n.success,
"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); }
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(),
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
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,
}
}
+3
View File
@@ -80,6 +80,9 @@ needs:
# who hears when a case enters a stage: `submitter` (the form
# must then collect a required `email`) or one address. Tell the
# 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
# aggregates.yaml already has, so its records keep replaying
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
/// state's own name, never from the label, and the buttons are listed
/// in a seeded shuffle.
fn button_pick(cmd: &str, group: &str, bucket: &str, from: &str, to: &str, labels: &[&str], seed: usize) -> anyhow::Result<String> {
let plain = |id: &str| id.replace(['_', '-'], " ");
fn button_pick(cmd: &str, group: &str, bucket: &str, from: &str, to: &str, labels: &[&str], seed: usize, words: &Value) -> anyhow::Result<String> {
// 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();
// A fixed rotation per move: the right answer is not always first.
let by = seed % order.len().max(1);
@@ -319,7 +321,7 @@ pub fn rate(argv: &[String]) -> anyhow::Result<()> {
continue;
}
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 {
right += 1;
} else {