Files
portal/src/plan.rs
T
Bendik Aagaard LynghaugandClaude Fable 5.1 a2a9f4edcf 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 15:16:29 +02:00

496 lines
27 KiB
Rust

//! From an intake, in plain words, to a `plan.yaml` a person can check.
//!
//! This is the one step where a model decides structure: which needs
//! the intake implies, the stages each goes through, who handles it,
//! what the form collects and how much that person will put up with.
//! Everything after it is deterministic (`build`), so this is where a
//! misreading of the business has to be caught, and it is caught by a
//! person reading the plan, not by a verifier. The model's YAML is
//! parsed and validated exactly as `build` would, and the errors go
//! back to it for another round; what passes is written verbatim, so
//! what the person reads is what the model wrote.
//!
//! Personas are never asked of this model: a persona written by the
//! same model that phrased the needs tests nothing. They come from a
//! person or a sector file, attached with `--personas`.
use anyhow::{anyhow, bail, Context};
use crate::build::Plan;
use crate::simulate::first_object;
const REFERENCE: &str = r#"# plan.yaml
A plan is a needs spec that also says what each form collects. A
builder turns it into a working site with no further decisions, so
every structural fact about the business has to be in it.
```yaml
business: <name>
language: en # the intake's language code
contact: { name: <who answers for the site>, email: <their email> }
about: >
What the business does, in its own words.
mail: { from: "<name> <post@their-domain>", reply_to: <their inbox> }
# who every mail the site sends comes from; required as soon
# as any need has `mails`
does: # the front door's first card: what the business does, one
- id: kebab-case # or two features, one role each, no button. Say it for the
for: <need id> # role the business exists for and the next one it serves.
text: what it does for them, in the intake's own words
shows: # what the site shows as it is: a live figure, the latest
- id: kebab-case # releases, what has been built; `resource` is portal's block
text: one line on what it is
resource: { public: true, source: { kind: url, url: https://... } }
doors: # a first question before the forms, only when the offers
- id: kebab-case # are more than one page holds (measured: four doors beat
who: who comes through it # ten offers on one page; six offers on one page beat doors)
groups: # groups with a label the public may see: the committees
- { id: traffic, label: Traffic, invited_by: [board] } # a person can ask to join (optional; label defaults to the id)
invites_by: [board] # groups whose desk may invite people into every desk
needs:
- id: kebab-case # ASCII only
who: the kind of person, in plain words, in the intake's language
wants: what they want from the business
door: <door id> # the door this public need sits behind; omit to put it on the front door
enters_as: <group> # ONLY for a role inside the business; omit for the public
lands_in: <bucket> # snake_case, ASCII
handled_by: <group> # the role that must see and move the record
stages: [open, <stage>, ..., <end>, <end>]
# EVERY stage the business said a case goes through, first to
# last, read off their own sentences one verb at a time: "they
# come in, we go and look, we send a quote, we win or lose it"
# is [open, surveyed, quoted, won, lost]. A pipeline is not an
# inbox: "answered" is rarely the real outcome.
done_when: [<end>, <end>] # every stage that counts as finished, good or bad
success: [<end>] # the finished stages that are a good outcome for the business
skippable: [<stage>] # stages a case may pass over (papers already complete)
requires: <one sentence> # what the person must ALREADY have or be for this to work:
# a membership, a reference number, the link from a mail, an
# account. Stated on the card above the form. Omit when there
# is none; never invent one
turned_down_from: [open, <stage>]
# the unfinished stages a case can still be turned down from;
# omit for every one. An applicant is declined before they
# start, not after - after, they depart
reached_from: { <stage>: [<stage>, ...] }
# where a stage is entered from, when the line does not say it.
# Two things need it and nothing else does: a case SENT BACK to
# a stage it was already past ("papers missing" again, after the
# hearing turned something up), and a GOOD ENDING partway along,
# where a case finishes well without walking to the end ("we
# changed our own decision", instead of passing it upwards).
# A stage named here is entered from exactly those stages and
# nowhere else, so it also says where a refusal may NOT come
# from. Omit unless the business described one of those two
moves_by: { <stage>: [<group>, ...] }
# only where someone other than handled_by moves a case INTO a
# stage: another group, or `submitter` (the person who sent the
# form; the form must then collect a required `email`)
watched_by: [<group>] # groups that see these cases and move nothing; `public` opens a
# read-only page of the bucket to everyone (contact details hidden)
mails: { open: submitter, <end>: submitter, <stage>: <address> }
# 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.
grants: { <stage>: <group> }
# a stage that makes the person a member of a group when their
# case arrives (an approval that IS membership); the form must
# then collect a required `name` and a required `email`
deadlines: { <stage>: { days: 30, to: <stage> } }
# a stage a clock moves on from when a case sits in it that long
# (a notice that expires, a hearing with no answer); only along
# a move the stages already allow
public_states: [<stage>] # with `public` in watched_by: the stages the public list shows
# (a noticeboard shows what is up, not what was taken down)
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
what moves it, in a sentence
max_fields: 4 # decide this BEFORE writing collects, from what the intake
# says about the person; a budget that equals the form's size
# is counted, not decided, and is reported as such
# how many required fields THIS person will tolerate, from what
# the intake says about them: someone on a phone with gloves
# on gets 3, a first enquiry 4 to 6, a firm's paperwork 5 to 6
collects: # the form; name is snake_case ASCII, type one of
# text (default), email, tel, textarea, date, select, location
# (a pin on a map, stored as lat/lon/zoom; optional: true,
# since a map needs a browser that can draw one)
- { name: name }
- { name: phone, type: tel }
- { name: problem, type: textarea }
- { name: urgency, type: select, options: [This week, This month, No rush] }
- { name: can_do, type: select, multiple: true, options: [Drive, Cook, Carry] } # any number
- { name: committee, type: select, options_from: groups } # the labelled groups above
- { name: address, optional: true, private: true } # never on a public page
```
What a portal site can do - use what serves this business, leave the
rest. Each is one line in the plan, and the builder does the wiring:
- **Show, not only ask.** A bucket with `watched_by: [public]` becomes
a public list: its newest few records sit inline on the front door,
linked to the whole list, contact details pruned. What neighbours
reported and where it stands, which changes were approved, what is
on the noticeboard: a visitor who sees the place working trusts it,
and a person who reported something sees it being dealt with.
`public_states` shows only the stages worth showing.
- **Stay in dialogue.** `mails` tells the person at the stages that
are about them, and a reply to that mail comes back to the case as
a note. `moves_by: { <stage>: [submitter] }` gives them a move of
their own through their link: confirm, withdraw, say it is solved.
- **Let a decision do the work.** `grants` makes an approval into
membership: the person gets an account and a place in the group the
moment the case gets there; `deadlines` lets a stage end on its own
(a notice that expires, a request nobody took up); `invites_by` lets
a desk bring a person in without anyone touching an admin tool.
- **Let nothing wait unseen.** `escalate: { open: { days: 7, to: styret } }`
sends a case nobody has moved in a week up to another group's desk,
where it sits with the same buttons until someone finishes it; the
desk that had it keeps it too, and may send it up itself. A desk job,
not a mail: the group sees it when they sit down.
- **Let one record answer another.** A need `about` another need is
one answer per record of it, from that record's own row on the
public list: a neighbour saying "I can lend you that" to a notice, a
bid to a request. `mails: { open: owner }` sends the answer straight
to whoever posted the record, and the site keeps no queue - the
poster's inbox is the queue. The site never becomes a go-between.
- **Keep some things for members.** A need with `door` and `enters_as`
is offered to everyone on the door and taken on the group's page
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
sign-in provider that says who someone is will do (Vipps, for a
Norwegian association). Invite forms then ask for a phone number.
Without it, Kanidm makes an account per invite.
- **Answer for the pages as a group.** `contact: { name, email, group }`
makes the contact a group's inbox rather than a person: no account
is made for it, and every member of the group may suggest changes
to the pages from the page itself.
- **Make the choices real.** `groups` with labels, and a select with
`options_from: groups`, so a form offers exactly the committees or
teams that exist; `multiple: true` where a person can pick several.
- **Place things.** A `location` field puts a pin on a map (where the
hazard is, where the job is), when the site has maps.
- **Open with the business itself.** `does` says what it does for the
roles it exists for; `shows` puts its live content beside that.
Rules:
- One need per (kind of person, thing they want). Work inside the
business counts: a role recording or posting something is a need
with `enters_as`. The intake's "what slows us down" usually names one.
- Groups are the roles the intake names, as snake_case identifiers.
- The front door opens with `does` (at most two features, one role
each) and `shows`, then holds at most seven offers: doors plus needs
with no door. A door with no need behind it is refused; a door on a
need with `enters_as` is the way to the members' page. A role `does`
speaks for must have a move
of its own after sending (`moves_by` with `submitter`) or a public
view, or it is a spectator.
- Every need whose form collects an email should tell the person on
arrival and on the decisions about them (`mails`); the plan then
needs `mail: { from }`.
- Every feature on a card explains itself: a list says what one row is
and what its buttons do, a form says what to have ready. If an
alternative only works for someone who already has something, say so
in `requires` - a condition the reader meets only after filling the
form in has already cost them the filling in.
- Needs that land in the same bucket must have the same stages,
done_when, skippable, moves_by, watched_by and handled_by.
- Every required field is one the business will use to act on the
record. If the intake gives no reason to need it, make it optional or
leave it out. The required fields must not exceed max_fields.
- Only `select` lists `options`, and it must list them.
- Everything a person reads (who, wants, about, select options) is in
the intake's language. Everything a machine reads (ids, buckets,
groups, stages, field names) is plain ASCII: no æ, ø, å.
- Write no personas.
"#;
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 usage = "usage: portal-trainer plan --intake <file> --out <dir> [--llm 'cmd'] [--personas <yaml with personas:>] [--rounds 4]";
let intake_path = value("--intake").ok_or_else(|| anyhow!(usage))?;
let out = std::path::PathBuf::from(value("--out").ok_or_else(|| anyhow!(usage))?);
let cmd = value("--llm").unwrap_or_else(|| "claude -p".to_string());
let rounds: usize = value("--rounds")
.map(|v| v.parse())
.transpose()?
.unwrap_or(4);
let intake =
std::fs::read_to_string(&intake_path).with_context(|| format!("reading {intake_path}"))?;
let mut personas: Option<serde_yaml::Value> = match value("--personas") {
Some(path) => {
let text = std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?;
let doc: serde_yaml::Value =
serde_yaml::from_str(&text).with_context(|| format!("parsing {path}"))?;
Some(doc.get("personas").cloned().ok_or_else(|| anyhow!("{path} has no `personas:`"))?)
}
None => None,
};
std::fs::create_dir_all(out.join("llm"))?;
let mut feedback = String::new();
// A structural finding the builder would only warn about (a role
// the opener speaks for with nothing to play) goes back to the
// model once; a second time it is the reader's, not another round.
let mut spectators_sent = false;
for round in 1..=rounds {
let prompt = format!(
"{REFERENCE}\n\n# Task\n\nA business filled in this intake. Write its plan.yaml: every need the intake implies, for the public and for each role inside the business, with the stages read off how the business says things progress, the form each need collects, and how much each person will tolerate.\n\n## Intake\n\n{intake}\n{feedback}\nReply with the YAML only, no prose, no code fences."
);
let reply = crate::llm_with(&cmd, &prompt, &[("TEMPERATURE", "0.2")])?;
std::fs::write(out.join(format!("llm/{round:02}-prompt.md")), &prompt)?;
std::fs::write(out.join(format!("llm/{round:02}-reply.md")), &reply)?;
let yaml = extract_yaml(&reply);
match check(&yaml) {
Ok(plan) if !plan.spectators().is_empty() && !spectators_sent && round < rounds => {
spectators_sent = true;
let findings = plan.spectators().join("\n- ");
eprintln!("plan round {round}: the plan parses, and a role the opener speaks for has no move after sending; asking once more");
feedback = format!("\n## Your last attempt parsed, with one thing to fix\n\n- {findings}\n\nGive each such need a move the person makes themselves (`moves_by: {{<stage>: [submitter, <group>]}}`, with a required `email` in its form) or `watched_by: [public]`, and return the whole plan again.\n\n```yaml\n{yaml}\n```\n");
continue;
}
Ok(plan) => {
let mut text = yaml.trim_end().to_string();
text.push('\n');
// The attached personas were written against someone
// else's need ids. The plan is final by now, so a model
// may map them to this plan's ids without its words
// reaching any page: it returns ids and nothing else,
// and an id not in the plan is ignored. What it leaves
// unmapped is listed for the reader.
if let Some(list) = personas.as_mut() {
let mapped = map_personas(&cmd, &plan, list)?;
if mapped > 0 {
eprintln!("plan round {round}: {mapped} persona(s) mapped to this plan's needs");
}
text.push_str("\n# Held out from every model that writes words.\n");
text.push_str(&serde_yaml::to_string(&serde_yaml::Mapping::from_iter([(
serde_yaml::Value::from("personas"),
list.clone(),
)]))?);
}
let whole: Plan =
serde_yaml::from_str(&text).context("the plan with its personas attached")?;
let strangers: Vec<String> = whole
.personas
.iter()
.filter(|p| {
!whole
.needs
.iter()
.any(|n| n.id == p["need"].as_str().unwrap_or_default())
})
.map(|p| {
format!(
"{} (need: {})",
p["id"].as_str().unwrap_or_default(),
p["need"].as_str().unwrap_or_default()
)
})
.collect();
std::fs::write(out.join("plan.yaml"), &text)?;
let groups: std::collections::BTreeSet<&str> = plan
.needs
.iter()
.flat_map(|n| [Some(n.handled_by.as_str()), n.enters_as.as_deref()])
.flatten()
.collect();
let buckets: std::collections::BTreeSet<&str> =
plan.needs.iter().map(|n| n.lands_in.as_str()).collect();
eprintln!("plan round {round}: {} need(s), {} group(s), {} bucket(s), {} persona(s) attached", plan.needs.len(), groups.len(), buckets.len(), whole.personas.len());
for n in &plan.needs {
eprintln!(
" {}: {} -> {} ({}), {} of {} field(s) required",
n.id,
n.stages.join(" -> "),
n.lands_in,
n.handled_by,
n.collects.iter().filter(|f| !f.optional).count(),
n.max_fields
);
}
if !strangers.is_empty() {
eprintln!(" {} persona(s) name a need this plan does not have; set their `need:` to one of the ids above before building:\n {}", strangers.len(), strangers.join("\n "));
}
// A budget that equals the form is the tell of a model
// that counted the form and called it the person's
// tolerance. One such need is a form that uses its
// room; most of them is the number being invented.
let tight: Vec<&str> = plan
.needs
.iter()
.filter(|n| n.collects.iter().filter(|f| !f.optional).count() == n.max_fields)
.map(|n| n.id.as_str())
.collect();
if tight.len() * 2 > plan.needs.len() && tight.len() > 1 {
eprintln!(" {} of {} need(s) have max_fields equal to their required fields ({}): check each against what the intake says about that person, not the form", tight.len(), plan.needs.len(), tight.join(", "));
}
println!(
"plan written to {}; read it before building from it",
out.join("plan.yaml").display()
);
return Ok(());
}
Err(e) => {
eprintln!("plan round {round}: {e}");
feedback = format!("\n## Your last attempt was refused\n\n{e}\n\nFix that and return the whole plan again.\n\n```yaml\n{yaml}\n```\n");
}
}
}
bail!(
"no plan passed in {rounds} round(s); the replies are in {}/llm/",
out.display()
)
}
/// Personas whose `need:` is not one of the plan's ids get one mapped
/// by a model that sees the plan's needs and the persona texts and
/// returns ids only. Returns how many were mapped; an id the plan does
/// not have, or a persona the model leaves out, stays as it was.
fn map_personas(cmd: &str, plan: &Plan, personas: &mut serde_yaml::Value) -> anyhow::Result<usize> {
let ids: Vec<&str> = plan.needs.iter().map(|n| n.id.as_str()).collect();
let Some(list) = personas.as_sequence_mut() else { return Ok(0) };
let strangers: Vec<(String, String)> = list
.iter()
.filter(|p| !ids.contains(&p["need"].as_str().unwrap_or_default()))
.map(|p| (p["id"].as_str().unwrap_or_default().to_string(), p["text"].as_str().unwrap_or_default().to_string()))
.filter(|(id, _)| !id.is_empty())
.collect();
if strangers.is_empty() {
return Ok(0);
}
let needs: Vec<String> = plan.needs.iter().map(|n| format!("- {}: {} - wants {}", n.id, n.who, n.wants)).collect();
let people: Vec<String> = strangers.iter().map(|(id, text)| format!("- {id}: {text}")).collect();
let prompt = format!(
"A site has these needs, one per kind of visitor:\n{}\n\nThese people arrive at it:\n{}\n\nFor each person, which need is theirs? Reply with a JSON object mapping each person's id to one need id from the list, and nothing else. Leave a person out if no need fits.",
needs.join("\n"),
people.join("\n")
);
let reply = crate::llm_with(cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.0")])?;
let Some(mapping) = first_object(&reply) else { return Ok(0) };
let mut mapped = 0;
for persona in list.iter_mut() {
let id = persona["id"].as_str().unwrap_or_default().to_string();
if let Some(need) = mapping.get(&id).and_then(|v| v.as_str()) {
if ids.contains(&need) && !ids.contains(&persona["need"].as_str().unwrap_or_default()) {
persona["need"] = serde_yaml::Value::from(need);
mapped += 1;
}
}
}
Ok(mapped)
}
/// Parsed and validated exactly as `build` will.
fn check(yaml: &str) -> anyhow::Result<Plan> {
let plan: Plan =
serde_yaml::from_str(yaml).map_err(|e| anyhow!("does not parse as a plan: {e}"))?;
if !plan.personas.is_empty() {
bail!("contains personas; write none");
}
if plan.needs.is_empty() {
bail!("has no needs");
}
plan.validate()?;
Ok(plan)
}
/// The YAML in a reply: inside code fences if the model used them,
/// otherwise from the first top-level key on. A stray JSON object
/// (some models answer `{"business": ...}`) is turned into YAML.
fn extract_yaml(reply: &str) -> String {
let text = reply.trim();
if let Some(start) = text.find("```") {
let after = &text[start + 3..];
let after = after.find('\n').map_or(after, |i| &after[i + 1..]);
let body = after.find("```").map_or(after, |i| &after[..i]);
return body.to_string();
}
if text.starts_with('{') {
if let Some(v) = first_object(text) {
return serde_yaml::to_string(&v).unwrap_or_default();
}
}
let start = text
.lines()
.position(|l| {
l.starts_with("business:") || l.starts_with("needs:") || l.starts_with("contact:")
})
.unwrap_or(0);
text.lines().skip(start).collect::<Vec<_>>().join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
const GOOD: &str = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: enquiry\n who: an owner\n wants: a look\n lands_in: enquiries\n handled_by: project_managers\n stages: [open, quoted, won, lost]\n done_when: [won, lost]\n success: [won]\n max_fields: 3\n collects:\n - { name: name }\n - { name: phone, type: tel }\n";
#[test]
fn yaml_is_taken_from_fences_prose_or_json() {
assert_eq!(
extract_yaml(&format!("Here you go:\n```yaml\n{GOOD}```\nDone.")),
GOOD
);
assert_eq!(
extract_yaml(&format!("Sure, the plan:\n\n{GOOD}")),
GOOD.trim_end()
);
let from_json = extract_yaml("{\"business\": \"B\", \"needs\": []}");
assert!(from_json.contains("business: B"));
}
#[test]
fn a_plan_is_held_to_the_same_checks_as_build() {
assert!(check(GOOD).is_ok());
assert!(
check("business: B\ncontact: { name: I, email: i@b.example }\nneeds: []\n")
.unwrap_err()
.to_string()
.contains("no needs")
);
assert!(check(&GOOD.replace("max_fields: 3", "max_fields: 1"))
.unwrap_err()
.to_string()
.contains("tolerate 1"));
assert!(check(&format!(
"{GOOD}personas:\n - {{ id: p, need: enquiry, text: hi }}\n"
))
.unwrap_err()
.to_string()
.contains("personas"));
assert!(check(&GOOD.replace(
"lands_in: enquiries",
"lands_in: henvendelser\n extra: 1"
))
.unwrap_err()
.to_string()
.contains("does not parse"));
}
}