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
+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 {