plan: intake to plan by a model; doors, labelled groups, the whole card

Work from another session, committed as found (tests green):

- `plan` reads an intake into a plan.yaml (a model decides needs,
  stages, forms and budgets; a person reads the result before build).
- Plans gain `doors` (a first question before the forms, when a front
  door would carry more than five needs), `groups` with labels that a
  select may draw its options from (`options_from: groups`), `private`
  fields kept off public pages, `multiple` selects, and `max_fields`
  as what the intake said the person will tolerate.
- `card.rs`: an alternative as the visitor meets it, in text, so the
  answering visitor and `rate` read the served card, not the heading.
- `rate` reads followup pages from the repo and reports "what happens
  next" as not measured rather than zero when it cannot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-23 12:31:51 +02:00
co-authored by Claude Fable 5.1
parent baf36319b7
commit 255aeb4f4b
570 changed files with 40795 additions and 181 deletions
+577 -76
View File
@@ -36,6 +36,19 @@ pub struct Plan {
/// model's wording, never structure.
#[serde(default)]
pub about: String,
/// A first question before the forms. Each door is one alternative
/// on the front door leading to a page of its own, where the needs
/// that name it sit. Five or six needs on one page is more than a
/// visitor sorts through; two or three doors of two or three each
/// is what the page-load limit asks for.
#[serde(default)]
pub doors: Vec<Door>,
/// Groups with a label the public may see: the committees a
/// person can ask to join. A select field with `options_from:
/// groups` offers exactly these, so the form and the desks can
/// never disagree about what the committees are.
#[serde(default)]
pub groups: Vec<GroupDef>,
pub needs: Vec<PlanNeed>,
#[serde(default)]
pub personas: Vec<Value>,
@@ -45,6 +58,22 @@ fn english() -> String {
"en".to_string()
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GroupDef {
pub id: String,
pub label: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Door {
pub id: String,
/// Who comes through it, in plain words: context for the model's
/// heading, and the fallback description.
pub who: String,
}
#[derive(Debug, Deserialize)]
pub struct Contact {
pub name: String,
@@ -59,6 +88,10 @@ pub struct PlanNeed {
pub wants: String,
#[serde(default)]
pub enters_as: Option<String>,
/// The door this public need sits behind; none means straight on
/// the front door.
#[serde(default)]
pub door: Option<String>,
pub lands_in: String,
pub handled_by: String,
/// Every stage, first to last, finished states included.
@@ -82,8 +115,11 @@ pub struct PlanNeed {
#[serde(default)]
pub watched_by: Vec<String>,
pub collects: Vec<Field>,
#[serde(default)]
pub max_fields: Option<usize>,
/// How many required fields this person will tolerate: what the
/// business said about them, not what the form happens to ask.
/// The form is refused when it asks for more, and the verifier is
/// told this number, so the check means something after handover.
pub max_fields: usize,
}
#[derive(Debug, Deserialize, Clone)]
@@ -96,8 +132,31 @@ pub struct Field {
pub optional: bool,
#[serde(default)]
pub options: Vec<String>,
/// `select` only: pick any number instead of exactly one.
#[serde(default)]
pub multiple: bool,
/// `select` only: `groups` takes the options from the plan's
/// labelled groups instead of a list here.
#[serde(default)]
pub options_from: Option<String>,
/// Never shown on a public view of the bucket. `email` and `tel`
/// fields are private whether or not this is set.
#[serde(default)]
pub private: bool,
}
impl Field {
/// What a public listing may show: everything but contact details
/// and what the plan marks private.
pub fn is_public(&self) -> bool {
!self.private && self.kind != "email" && self.kind != "tel"
}
}
/// The name a plan uses in `watched_by` for everyone: a read-only page
/// of the bucket, no login.
pub const PUBLIC: &str = "public";
fn text() -> String {
"text".to_string()
}
@@ -110,6 +169,27 @@ fn is_id(s: &str) -> bool {
impl Plan {
pub fn validate(&self) -> anyhow::Result<()> {
let mut doors = BTreeSet::new();
for door in &self.doors {
if !is_id(&door.id) || !doors.insert(&door.id) {
bail!("door {:?}: id must be unique ASCII lowercase with - or _", door.id);
}
if !self.needs.iter().any(|n| n.door.as_ref() == Some(&door.id)) {
bail!("door {:?} leads nowhere: no need names it", door.id);
}
if groups(self).contains(&door.id) {
bail!("door {:?} is also a group; a door's page is public and a group's is gated", door.id);
}
if door.id == "status" || door.id == "received" || door.id == "decide" {
bail!("door {:?}: that path is taken by the builder's own pages", door.id);
}
}
let mut labelled = BTreeSet::new();
for g in &self.groups {
if !is_id(&g.id) || !labelled.insert(&g.id) || g.label.trim().is_empty() {
bail!("group {:?}: id must be unique ASCII lowercase with - or _, and it needs a label", g.id);
}
}
let mut seen = BTreeSet::new();
for need in &self.needs {
let at = |what: &str| format!("need {:?}: {what}", need.id);
@@ -124,6 +204,14 @@ impl Plan {
if need.stages.len() < 2 {
bail!(at("stages needs a first stage and at least one finished one"));
}
if let Some(door) = &need.door {
if !doors.contains(door) {
bail!(at(&format!("door {door:?} is not one of the plan's doors")));
}
if need.enters_as.is_some() {
bail!(at("a door is a public way in; a need with enters_as has its group's page instead"));
}
}
for (state, movers) in &need.moves_by {
if !need.stages[1..].contains(state) {
bail!(at(&format!("moves_by names {state:?}, which is not a stage a case can move into")));
@@ -138,7 +226,10 @@ impl Plan {
}
}
if need.watched_by.iter().any(|g| !is_id(g)) {
bail!(at("watched_by must list group identifiers"));
bail!(at("watched_by must list group identifiers, or `public`"));
}
if need.watched_by.iter().any(|g| g == PUBLIC) && need.enters_as.is_some() {
bail!(at("a public view is for what the public sent; an internal need has its group's desk"));
}
for state in need.done_when.iter().chain(&need.skippable) {
if !need.stages.contains(state) {
@@ -153,12 +244,35 @@ impl Plan {
if need.done_when.is_empty() || need.done_when.contains(&need.stages[0]) {
bail!(at("done_when must name finished states, and the first stage cannot be one"));
}
let required = need.collects.iter().filter(|f| !f.optional).count();
if need.max_fields == 0 {
bail!(at("max_fields is how many required fields this person will tolerate, and must be at least 1"));
}
if required > need.max_fields {
bail!(at(&format!("the form asks for {required} required field(s) and this person will tolerate {}; mark some optional or drop them", need.max_fields)));
}
for field in &need.collects {
if !is_id(&field.name) || !FIELD_TYPES.contains(&field.kind.as_str()) {
bail!(at(&format!("field {:?}: name must be an identifier and type one of {FIELD_TYPES:?}", field.name)));
}
if field.kind == "select" && field.options.is_empty() {
bail!(at(&format!("field {:?} is a select and lists no options", field.name)));
match (field.kind.as_str(), field.options_from.as_deref()) {
("select", Some("groups")) => {
if self.groups.is_empty() {
bail!(at(&format!("field {:?} takes its options from groups, and the plan has no labelled `groups`", field.name)));
}
if !field.options.is_empty() {
bail!(at(&format!("field {:?} has both options and options_from", field.name)));
}
}
("select", None) if field.options.is_empty() => {
bail!(at(&format!("field {:?} is a select and lists no options", field.name)));
}
("select", None) => {}
(_, Some(_)) => bail!(at(&format!("field {:?}: options_from is for select fields, and only `groups`", field.name))),
(kind, None) if field.multiple && kind != "select" => {
bail!(at(&format!("field {:?}: multiple is for select fields", field.name)))
}
_ => {}
}
}
}
@@ -180,6 +294,16 @@ impl Plan {
Ok(())
}
/// What sits on the front door: each door, and each public need
/// with no door.
fn front_door_count(&self) -> usize {
self.doors.len() + self.needs.iter().filter(|n| n.enters_as.is_none() && n.door.is_none()).count()
}
fn needs_behind(&self, door: &str) -> Vec<&PlanNeed> {
self.needs.iter().filter(|n| n.door.as_deref() == Some(door)).collect()
}
fn by_bucket(&self) -> BTreeMap<&str, Vec<&PlanNeed>> {
let mut map: BTreeMap<&str, Vec<&PlanNeed>> = BTreeMap::new();
for need in &self.needs {
@@ -223,6 +347,21 @@ pub fn machine(need: &PlanNeed) -> BTreeMap<String, Vec<String>> {
moves
}
/// The stages a case goes through, as one sentence: "It is received,
/// then surveyed, then quoted, and ends won or lost."
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 mut out = String::from("It is ");
out.push_str(&line.join(", then "));
if ends.len() == 1 {
out.push_str(&format!(", and ends {}.", ends[0]));
} else {
out.push_str(&format!(", and ends {} or {}.", ends[..ends.len() - 1].join(", "), ends[ends.len() - 1]));
}
out
}
fn humanize(id: &str) -> String {
let mut words = id.replace(['_', '-'], " ");
if let Some(first) = words.get_mut(0..1) {
@@ -243,41 +382,74 @@ fn internal_page(need: &PlanNeed, group: &str) -> String {
/// made from the plan's own text. Keyed by file path, then the same
/// JSON shape the model is asked for.
pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
// Every part of the card portal renders has a slot: the model
// gets to write the button, an encouragement, the section's icon
// and guidance line, and a placeholder per field, not only the
// heading and description. Empty fallbacks render as absent.
let form = |need: &PlanNeed| {
json!({
"heading": need.wants,
"description": format!("For {}.", need.who),
"button": "Send",
"encouragement": "",
"icon": "",
// Two sections of context the plan already knows: the road
// a case travels, and who is at the other end. The
// fallbacks are built from the plan, so they are true
// before a model has said a word.
"road_title": "What happens next",
"road_description": stages_sentence(need),
"road_icon": "route",
"who_title": "Who reads it",
"who_description": format!("{}. Questions go to {} at {}.", humanize(&need.handled_by), plan.contact.name, plan.contact.email),
"who_icon": "users",
"form_title": "Details",
"form_description": "",
"labels": need.collects.iter().map(|f| (f.name.clone(), Value::String(humanize(&f.name)))).collect::<Map<_, _>>(),
"hints": need.collects.iter().map(|f| (f.name.clone(), Value::String(String::new()))).collect::<Map<_, _>>(),
})
};
let mut words = BTreeMap::new();
let public: Vec<&PlanNeed> = plan.needs.iter().filter(|n| n.enters_as.is_none()).collect();
let direct: Vec<&PlanNeed> = plan.needs.iter().filter(|n| n.enters_as.is_none() && n.door.is_none()).collect();
words.insert(
"questions/index.yaml".to_string(),
json!({
"question": "What brings you here?",
"description": plan.about,
"needs": public.iter().map(|n| (n.id.clone(), form(n))).collect::<Map<_, _>>(),
"doors": plan.doors.iter().map(|d| (d.id.clone(), json!({
"heading": humanize(&d.id), "description": format!("For {}.", d.who), "button": "Continue",
"icon": "", "for_whom": "",
}))).collect::<Map<_, _>>(),
"needs": direct.iter().map(|n| (n.id.clone(), form(n))).collect::<Map<_, _>>(),
}),
);
for door in &plan.doors {
words.insert(
door_path(door),
json!({
"question": "What do you need?",
"description": format!("For {}.", door.who),
"needs": plan.needs_behind(&door.id).iter().map(|n| (n.id.clone(), form(n))).collect::<Map<_, _>>(),
}),
);
}
for need in &plan.needs {
let (path, back) = match &need.enters_as {
None => (format!("questions/received/{}.yaml", need.id), "/".to_string()),
Some(group) => (format!("questions/{group}/{}-sent.yaml", need.id), format!("/{group}")),
};
let _ = back;
words.insert(
path,
json!({
"question": "What happens now?",
"description": format!("It has reached our {}.", humanize(&need.handled_by).to_lowercase()),
"next_heading": "We read it and come back to you",
"next_description": format!("Questions in the meantime go to {} at {}.", plan.contact.name, plan.contact.email),
"back": "Back",
}),
);
let mut page = json!({
"question": "What happens now?",
"description": format!("It has reached our {}.", humanize(&need.handled_by).to_lowercase()),
"next_heading": "We read it and come back to you",
"next_description": format!("Questions in the meantime go to {} at {}.", plan.contact.name, plan.contact.email),
"back": "Back",
});
if need.watched_by.iter().any(|g| g == PUBLIC) {
page["see_all"] = json!({ "heading": format!("See all {}", humanize(&need.lands_in).to_lowercase()), "description": "", "button": "Open" });
}
words.insert(path, page);
if let Some(group) = &need.enters_as {
let mut page = form(need);
page["question"] = json!(format!("{}?", humanize(&need.id)));
@@ -310,6 +482,20 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
json!({ "question": "What needs doing?", "description": "", "confirm": "Confirm", "buckets": buckets, "links": links }),
);
}
// A public view per bucket the plan opens: a read-only list.
for (bucket, needs) in public_buckets(plan) {
words.insert(
public_view_path(bucket),
json!({
"question": format!("What has been sent in about {}?", humanize(bucket).to_lowercase()),
"description": format!("Everything sent in by {}, and where each one stands.", needs.iter().map(|n| n.who.as_str()).collect::<Vec<_>>().join(" or ")),
"heading": humanize(bucket),
"list_description": "",
"back": "Back",
}),
);
}
// The submitter's own page: reached by their private link, it
// offers the moves that are theirs to make.
for need in &plan.needs {
@@ -332,12 +518,19 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
words
}
fn door_path(door: &Door) -> String {
format!("questions/{}/index.yaml", door.id)
}
fn decide_path(need: &PlanNeed) -> String {
format!("questions/decide/{}.yaml", need.id)
}
const SUBMITTER: &str = "submitter";
/// Alternatives on one page before a visitor stops reading them.
const MAX_FRONT_DOOR: usize = 5;
/// Who presses the button into `to`.
fn movers<'a>(need: &'a PlanNeed, to: &str) -> Vec<&'a str> {
match need.moves_by.get(to) {
@@ -358,7 +551,7 @@ fn moves_of(need: &PlanNeed, who: &str) -> Vec<(String, String)> {
/// A group's desk shows a bucket when it moves something in it, is
/// answerable for it, or watches it.
fn on_desk(need: &PlanNeed, group: &str) -> bool {
need.handled_by == group || need.watched_by.iter().any(|g| g == group) || !moves_of(need, group).is_empty()
group != PUBLIC && (need.handled_by == group || need.watched_by.iter().any(|g| g == group) || !moves_of(need, group).is_empty())
}
fn groups(plan: &Plan) -> BTreeSet<String> {
@@ -368,12 +561,29 @@ fn groups(plan: &Plan) -> BTreeSet<String> {
[Some(n.handled_by.clone()), n.enters_as.clone()]
.into_iter()
.flatten()
.chain(n.watched_by.iter().cloned())
.chain(n.watched_by.iter().filter(|g| *g != PUBLIC).cloned())
.chain(n.moves_by.values().flatten().filter(|m| *m != SUBMITTER).cloned())
})
.collect()
}
/// Buckets the public may read, with the needs that fill them.
fn public_buckets(plan: &Plan) -> Vec<(&str, Vec<&PlanNeed>)> {
plan.by_bucket().into_iter().filter(|(_, needs)| needs.iter().any(|n| n.watched_by.iter().any(|g| g == PUBLIC))).collect()
}
fn public_view_path(bucket: &str) -> String {
format!("questions/status/{bucket}/index.yaml")
}
/// The options of a select: its own list, or the plan's labelled groups.
fn select_options<'a>(plan: &'a Plan, field: &'a Field) -> Vec<&'a str> {
match field.options_from.as_deref() {
Some("groups") => plan.groups.iter().map(|g| g.label.as_str()).collect(),
_ => field.options.iter().map(String::as_str).collect(),
}
}
/// Takes the model's strings where it gave them, key by key, and keeps
/// the fallback everywhere else. The shape always comes from the
/// fallback, so a reply can never add, drop or restructure anything.
@@ -398,45 +608,107 @@ const SLOT: &str = "<WRITE";
/// copy straight back (measured: nemotron returned the front door
/// unchanged). A slot cannot be mistaken for an answer.
fn slots(value: &Value, key: &str) -> Value {
slots_in(value, key, "")
}
fn slots_in(value: &Value, key: &str, parent: &str) -> Value {
match value {
Value::Object(map) => Value::Object(map.iter().map(|(k, v)| (k.clone(), slots(v, k))).collect()),
Value::Object(map) => Value::Object(map.iter().map(|(k, v)| (k.clone(), slots_in(v, k, key))).collect()),
Value::String(_) => Value::String(format!(
"{SLOT}: {}>",
match key {
"question" => "the one question this page asks its reader".to_string(),
"heading" | "next_heading" => "a heading, at most eight words, saying what the reader gets".to_string(),
"description" | "page_description" | "next_description" => "one or two short sentences".to_string(),
"button" | "back" | "confirm" => "a button, one to three words".to_string(),
"form_title" => "a title for the form, two to four words".to_string(),
other if other.contains("->") => format!("a button, two to four words, for a case going {other}"),
other => format!("a form label for the field `{other}`"),
match (parent, key) {
(_, "question") => "the one question this page asks its reader".to_string(),
(_, "heading" | "next_heading") => "a heading, at most eight words, saying what the reader gets".to_string(),
(_, "description" | "page_description" | "next_description") => "one or two short sentences".to_string(),
(_, "button" | "back" | "confirm") => "a button, one to three words, saying what pressing it does".to_string(),
(_, "encouragement") => "one reassuring sentence: what happens with what they send, or how little it takes; or empty".to_string(),
(_, "icon") => "an icon name from the list at the end, the one that fits this best; or empty".to_string(),
(_, "for_whom") => "a few words naming who this is for, addressed to them; or empty".to_string(),
(_, "form_title") => "a title for the form, two to four words".to_string(),
(_, "form_description") => "one short line of guidance above the form, or empty".to_string(),
(_, "road_title") => "a short title, two to four words, for the line that says what happens after sending".to_string(),
(_, "road_description") => "one or two sentences walking the reader through the stages this goes through, in order (the stages are listed above), in their words".to_string(),
(_, "road_icon" | "who_icon") => "an icon name from the list at the end that fits this section; or empty".to_string(),
(_, "who_title") => "a short title, two to four words, for the line that says who reads it".to_string(),
(_, "list_description") => "one line over a public list of what people have sent in, or empty".to_string(),
(_, "who_description") => "one sentence: who reads what they send, and how to reach them meanwhile".to_string(),
("hints", field) => format!("an example answer for the field `{field}`, as a placeholder; or empty"),
(_, other) if other.contains("->") => format!("a button, two to four words, for a case going {other}"),
(_, other) => format!("a form label for the field `{other}`"),
}
)),
other => other.clone(),
}
}
/// Icons the words may name: a fixed set from Iconify's lucide pack,
/// so a reply can only pick one that exists. Anything else is dropped.
pub const ICONS: &[&str] = &[
"house", "building-2", "store", "school", "map-pin", "route", "footprints", "trees", "leaf", "snowflake", "sun",
"users", "user", "user-plus", "user-check", "baby", "heart-handshake", "hand-helping", "handshake", "smile",
"mail", "phone", "message-circle", "megaphone", "bell", "send", "inbox",
"alert-triangle", "siren", "shield", "construction", "hammer", "wrench", "ruler", "package", "truck", "car", "bus", "train-front", "bike",
"calendar", "clock", "file-text", "clipboard-list", "receipt", "wallet", "coins", "banknote", "credit-card",
"key", "lock", "search", "star", "gift", "shopping-cart", "lightbulb", "flag", "thumbs-up", "check-circle",
"briefcase", "stethoscope", "activity", "camera", "pencil", "refresh-cw", "list", "layers", "vote", "scale",
];
/// `lucide:<name>` for a name on the list, otherwise nothing.
pub fn icon_name(given: &str) -> Option<String> {
let name = given.trim().trim_start_matches("lucide:");
ICONS.contains(&name).then(|| format!("lucide:{name}"))
}
fn s(v: &Value) -> &str {
v.as_str().unwrap_or_default()
}
fn form_alternative(need: &PlanNeed, w: &Value, action: &str) -> Value {
json!({
fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Value {
let mut feature = json!({
"name": s(&w["form_title"]),
"requirements": need.collects.iter().map(|f| {
let mut r = json!({ "name": f.name, "label": s(&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); }
let hint = s(&w["hints"][&f.name]).trim();
if !hint.is_empty() && f.kind != "select" { r["placeholder"] = json!(hint); }
r
}).collect::<Vec<_>>(),
});
if !s(&w["form_description"]).trim().is_empty() {
feature["description"] = json!(s(&w["form_description"]).trim());
}
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() });
if let Some(icon) = icon_name(icon) { f["icon"] = json!(icon); }
f
};
let mut features = Vec::new();
if !s(&w["road_description"]).trim().is_empty() {
features.push(strip(s(&w["road_title"]), s(&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(feature);
let mut alt = json!({
"name": s(&w["heading"]),
"description": s(&w["description"]),
"action": action,
"consequence": [s(&w["button"])],
"record_as": need.lands_in,
"features": [{
"name": s(&w["form_title"]),
"requirements": need.collects.iter().map(|f| {
let mut r = json!({ "name": f.name, "label": s(&w["labels"][&f.name]), "type": f.kind });
if f.optional { r["optional"] = json!(true); }
if !f.options.is_empty() { r["options"] = json!(f.options); }
r
}).collect::<Vec<_>>(),
}],
})
"features": features,
});
if !s(&w["encouragement"]).trim().is_empty() {
alt["encouragements"] = json!([s(&w["encouragement"]).trim()]);
}
alt
}
/// Every file of the content repo, from the plan and a set of words.
@@ -461,37 +733,83 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
}).collect::<Vec<_>>() }),
);
// The front door: one alternative per need the public may bring.
// The front door: one alternative per door, then one per need the
// public brings straight to it.
let front = &words["questions/index.yaml"];
let public: Vec<&PlanNeed> = plan.needs.iter().filter(|n| n.enters_as.is_none()).collect();
let mut alternatives: Vec<Value> = plan
.doors
.iter()
.map(|d| {
let dw = &front["doors"][&d.id];
let mut alt = json!({
"name": s(&dw["heading"]),
"description": s(&dw["description"]),
"action": format!("/{}", d.id),
"consequence": [s(&dw["button"])],
});
// A door carries no form; its icon and "for whom" line
// ride an empty section, which portal renders as a
// labelled strip on the card.
let (icon, for_whom) = (icon_name(s(&dw["icon"])), s(&dw["for_whom"]).trim());
if icon.is_some() || !for_whom.is_empty() {
let mut f = json!({ "name": for_whom, "description": "" });
if let Some(icon) = icon { f["icon"] = json!(icon); }
alt["features"] = json!([f]);
}
alt
})
.collect();
alternatives.extend(
plan.needs
.iter()
.filter(|n| n.enters_as.is_none() && n.door.is_none())
.map(|n| form_alternative(plan, n, &front["needs"][&n.id], &public_followup(n))),
);
if plan.front_door_count() > MAX_FRONT_DOOR {
eprintln!(
"build: the front door offers {} alternatives; past {MAX_FRONT_DOOR} a visitor stops reading. Put needs behind doors.",
plan.front_door_count()
);
}
files.insert(
"questions/index.yaml".into(),
json!({
"name": s(&front["question"]),
"description": s(&front["description"]),
"responsible": responsible,
"alternatives": public.iter().map(|n| form_alternative(n, &front["needs"][&n.id], &public_followup(n))).collect::<Vec<_>>(),
}),
json!({ "name": s(&front["question"]), "description": s(&front["description"]), "responsible": responsible, "alternatives": alternatives }),
);
// A door's page: a nav page (index.yaml), public, holding the
// forms of the needs behind it.
for door in &plan.doors {
let path = door_path(door);
let w = &words[&path];
files.insert(
path,
json!({
"name": s(&w["question"]),
"description": s(&w["description"]),
"responsible": responsible,
"alternatives": plan.needs_behind(&door.id).iter().map(|n| form_alternative(plan, n, &w["needs"][&n.id], &public_followup(n))).collect::<Vec<_>>(),
}),
);
}
for need in &plan.needs {
let (path, back) = match &need.enters_as {
None => (format!("questions/received/{}.yaml", need.id), "/".to_string()),
Some(group) => (format!("questions/{group}/{}-sent.yaml", need.id), format!("/{group}")),
};
let w = &words[&path];
let mut alternatives = vec![
json!({ "name": s(&w["next_heading"]), "description": s(&w["next_description"]) }),
];
if need.watched_by.iter().any(|g| g == PUBLIC) {
alternatives.push(json!({
"name": s(&w["see_all"]["heading"]), "description": s(&w["see_all"]["description"]),
"action": format!("/status/{}", need.lands_in), "consequence": [s(&w["see_all"]["button"])],
}));
}
alternatives.push(json!({ "name": s(&w["back"]), "description": "", "action": back, "consequence": [s(&w["back"])] }));
// Nested and not an index: portal infers a followup page.
files.insert(
path,
json!({
"name": s(&w["question"]),
"description": s(&w["description"]),
"alternatives": [
{ "name": s(&w["next_heading"]), "description": s(&w["next_description"]) },
{ "name": s(&w["back"]), "description": "", "action": back, "consequence": [s(&w["back"])] },
],
}),
);
files.insert(path, json!({ "name": s(&w["question"]), "description": s(&w["description"]), "alternatives": alternatives }));
if let Some(group) = &need.enters_as {
let path = format!("questions/{group}/{}.yaml", need.id);
let w = &words[&path];
@@ -503,7 +821,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
"name": s(&w["question"]),
"description": s(&w["page_description"]),
"responsible": responsible,
"alternatives": [form_alternative(need, w, &format!("/{group}/{}-sent", need.id))],
"alternatives": [form_alternative(plan, need, w, &format!("/{group}/{}-sent", need.id))],
}),
);
}
@@ -550,6 +868,47 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
);
}
// Public views: an ungated nav page per opened bucket, listing it
// read-only, with contact details and private fields projected
// away. Anyone can see what was sent in and where it stands.
for (bucket, needs) in public_buckets(plan) {
let path = public_view_path(bucket);
let w = &words[&path];
let mut shown: BTreeSet<&str> = BTreeSet::new();
for n in &needs {
shown.extend(n.collects.iter().filter(|f| f.is_public()).map(|f| f.name.as_str()));
}
// Records keep their shape (state, timestamps) so portal
// renders them as records; only the answers are pruned. One
// record per yielded value: portal collects what the filter
// yields into the list, so `map` would nest it once more
// (measured: an empty bucket rendered as `[[]]`).
let projection = format!(
".[] | .responses |= {{{}}}",
shown.iter().map(|f| format!("{f}: .{f}")).collect::<Vec<_>>().join(", ")
);
files.insert(
path,
json!({
"name": s(&w["question"]),
"description": s(&w["description"]),
"responsible": responsible,
"alternatives": [
{
"name": s(&w["heading"]),
"description": s(&w["list_description"]),
"features": [{ "name": "", "description": "", "resource": {
"source": { "kind": "kv", "bucket": bucket },
"public": true,
"jq": projection,
}}],
},
{ "name": s(&w["back"]), "description": "", "action": "/", "consequence": [s(&w["back"])] },
],
}),
);
}
// A submitter's own moves: a followup page (nested, not an index)
// whose alternatives are self_transitions, authorized at runtime by
// the record's link plus the email they gave.
@@ -581,16 +940,18 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
"needs.yaml".into(),
json!({
"needs": plan.needs.iter().map(|n| {
let (enters, heading) = match &n.enters_as {
None => ("/".to_string(), s(&front["needs"][&n.id]["heading"]).to_string()),
Some(group) => (internal_page(n, group), s(&words[&format!("questions/{group}/{}.yaml", n.id)]["heading"]).to_string()),
// `via` pins the alternative on the entry page: the
// door's heading for a need behind one, else the form's.
let (enters, heading, steps) = match (&n.enters_as, &n.door) {
(Some(group), _) => (internal_page(n, group), s(&words[&format!("questions/{group}/{}.yaml", n.id)]["heading"]).to_string(), 1),
(None, Some(door)) => ("/".to_string(), s(&front["doors"][door]["heading"]).to_string(), 2),
(None, None) => ("/".to_string(), s(&front["needs"][&n.id]["heading"]).to_string(), 1),
};
let required = n.collects.iter().filter(|f| !f.optional).count();
let mut need = json!({
"id": n.id, "who": n.who, "wants": n.wants, "enters": enters, "via": heading,
"lands_in": n.lands_in, "handled_by": n.handled_by,
"stages": n.stages, "done_when": n.done_when, "success": n.success,
"max_steps": 1, "max_fields": n.max_fields.unwrap_or(required),
"max_steps": steps, "max_fields": n.max_fields,
});
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();
@@ -608,7 +969,11 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
let purpose = if path.contains("/decide/") {
"a page the person who sent a form reaches later, through a private link, to make a decision that is theirs: under `moves`, each entry is one thing they can decide."
} else if path == "questions/index.yaml" {
"the front door: the first page any visitor sees. `question` is the page's one question; under `needs`, each entry is one alternative the visitor can pick, with the form behind it."
"the front door: the first page any visitor sees. `question` is the page's one question. Under `doors`, each entry is one alternative that leads to a page of its own for one kind of visitor: its heading must let that visitor recognise themselves from the heading alone. Under `needs`, each entry is one alternative with a form behind it."
} else if path.starts_with("questions/status/") {
"a public page anyone can open, listing everything sent in to one bucket and where each one stands, read-only. `question` is what this page asks; `heading` names the list."
} else if plan.doors.iter().any(|d| door_path(d) == path) {
"a page one kind of visitor reaches from the front door. `question` is what this page asks them; under `needs`, each entry is one alternative they can pick, with the form behind it."
} else if path.ends_with("/index.yaml") {
"a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open."
} else if path.contains("/received/") || path.ends_with("-sent.yaml") {
@@ -616,19 +981,25 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
} else {
"a page only one role inside the business can open, holding one form."
};
let context: Vec<String> = plan
.needs
let mut context: Vec<String> = plan
.doors
.iter()
.map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> ")))
.map(|d| format!("- door {}: for {}; behind it: {}", d.id, d.who, plan.needs_behind(&d.id).iter().map(|n| n.id.as_str()).collect::<Vec<_>>().join(", ")))
.collect();
context.extend(
plan.needs
.iter()
.map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.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- 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.\n\n{}\n\nReply with the JSON object only.",
plan.business,
plan.about.trim(),
plan.contact.name,
plan.contact.email,
plan.language,
context.join("\n"),
ICONS.join(", "),
serde_json::to_string_pretty(&slots(fallback, "")).unwrap_or_default(),
)
}
@@ -692,11 +1063,11 @@ mod tests {
fn need(stages: &[&str], done: &[&str], success: &[&str], skippable: &[&str]) -> PlanNeed {
let v = |xs: &[&str]| xs.iter().map(|x| x.to_string()).collect::<Vec<_>>();
PlanNeed {
id: "n".into(), who: "w".into(), wants: "x".into(), enters_as: None,
id: "n".into(), who: "w".into(), wants: "x".into(), enters_as: None, door: None,
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![],
collects: vec![], max_fields: None,
collects: vec![], max_fields: 3,
}
}
@@ -723,6 +1094,42 @@ mod tests {
assert_eq!(m["in_hand"], ["closed"]);
}
#[test]
fn the_card_carries_button_encouragement_icon_guidance_and_placeholders() {
let plan: Plan = serde_yaml::from_str(PLAN).unwrap();
let mut words = fallback_words(&plan);
let front = words.get_mut("questions/index.yaml").unwrap();
front["needs"]["survey"]["button"] = json!("Book a look");
front["needs"]["survey"]["encouragement"] = json!("We call within two days.");
front["needs"]["survey"]["icon"] = json!("house");
front["needs"]["survey"]["form_description"] = json!("The address is enough to start.");
front["needs"]["survey"]["hints"]["email"] = json!("you@example.no");
let files = render(&plan, &words).unwrap();
let page: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
let alt = &page["alternatives"][0];
assert_eq!(alt["consequence"][0], "Book a look");
assert_eq!(alt["encouragements"][0], "We call within two days.");
// Two context sections from the plan come before the form.
let f = alt["features"].as_array().unwrap();
assert_eq!(f.len(), 3);
assert_eq!(f[0]["name"], "What happens next");
assert_eq!(f[0]["description"], "It is open, then surveyed, then quoted, and ends won or lost.");
assert_eq!(f[0]["icon"], "lucide:route");
assert_eq!(f[1]["name"], "Who reads it");
assert_eq!(f[1]["description"], "Project managers. Questions go to Ingrid at i@n.example.");
assert_eq!(f[2]["icon"], "lucide:house");
assert_eq!(f[2]["description"], "The address is enough to start.");
assert_eq!(f[2]["requirements"][0]["placeholder"], "you@example.no");
// An icon off the list is dropped, not passed through.
assert_eq!(icon_name("skull"), None);
assert_eq!(icon_name("lucide:house").as_deref(), Some("lucide:house"));
// Empty slots leave nothing behind: no encouragement key, no form icon.
let plain = render(&plan, &fallback_words(&plan)).unwrap();
let page: Value = serde_yaml::from_str(&plain["questions/index.yaml"]).unwrap();
assert!(page["alternatives"][0].get("encouragements").is_none());
assert!(page["alternatives"][0]["features"][2].get("icon").is_none());
}
#[test]
fn words_take_the_models_strings_and_never_its_structure() {
let fallback = json!({"question": "What?", "needs": {"a": {"heading": "A", "labels": {"email": "Email"}}}});
@@ -731,7 +1138,7 @@ mod tests {
assert_eq!(merged, json!({"question": "Hva trenger du?", "needs": {"a": {"heading": "A", "labels": {"email": "Email"}}}}));
}
const PLAN: &str = "business: Nordvik Bygg\ncontact: { name: Ingrid, email: i@n.example }\nabout: We fix old buildings.\nneeds:\n - id: survey\n who: owners\n wants: Someone to look at the building\n lands_in: enquiries\n handled_by: project_managers\n stages: [open, surveyed, quoted, won, lost]\n done_when: [won, lost]\n success: [won]\n collects:\n - { name: email, type: email }\n - { name: problem, type: textarea }\n - { name: address, optional: true }\n - id: report\n who: crew\n wants: Report a hazard\n enters_as: crew\n lands_in: site_issues\n handled_by: hse\n stages: [open, in_hand, closed]\n done_when: [closed]\n success: [closed]\n collects:\n - { name: kind, type: select, options: [Hazard, Near miss] }\n";
const PLAN: &str = "business: Nordvik Bygg\ncontact: { name: Ingrid, email: i@n.example }\nabout: We fix old buildings.\nneeds:\n - id: survey\n who: owners\n wants: Someone to look at the building\n lands_in: enquiries\n handled_by: project_managers\n stages: [open, surveyed, quoted, won, lost]\n done_when: [won, lost]\n success: [won]\n max_fields: 4\n collects:\n - { name: email, type: email }\n - { name: problem, type: textarea }\n - { name: address, optional: true }\n - id: report\n who: crew\n wants: Report a hazard\n enters_as: crew\n lands_in: site_issues\n handled_by: hse\n stages: [open, in_hand, closed]\n done_when: [closed]\n success: [closed]\n max_fields: 3\n collects:\n - { name: kind, type: select, options: [Hazard, Near miss] }\n";
#[test]
fn a_plan_renders_every_file_the_site_needs() {
@@ -754,13 +1161,13 @@ mod tests {
assert!(files["questions/hse/index.yaml"].contains("to: closed"));
// The spec names the heading the words gave, and the budget the form implies.
assert!(files["needs.yaml"].contains("via: Someone to look at the building"));
assert!(files["needs.yaml"].contains("max_fields: 2"));
assert!(files["needs.yaml"].contains("max_fields: 4"));
assert!(files["needs.yaml"].contains("enters: /crew/report"));
}
#[test]
fn roles_overlap_on_one_aggregate_each_with_their_own_buttons() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: change\n who: a client\n wants: a change priced and done\n lands_in: change_orders\n handled_by: project_managers\n stages: [open, priced, approved, completed, declined]\n done_when: [completed, declined]\n success: [completed]\n moves_by:\n approved: [submitter]\n declined: [submitter, project_managers]\n completed: [site_managers]\n watched_by: [accounts]\n collects:\n - { name: email, type: email }\n - { name: change, type: textarea }\n";
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: change\n who: a client\n wants: a change priced and done\n lands_in: change_orders\n handled_by: project_managers\n stages: [open, priced, approved, completed, declined]\n done_when: [completed, declined]\n success: [completed]\n moves_by:\n approved: [submitter]\n declined: [submitter, project_managers]\n completed: [site_managers]\n watched_by: [accounts]\n max_fields: 3\n collects:\n - { name: email, type: email }\n - { name: change, type: textarea }\n";
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
@@ -787,11 +1194,105 @@ mod tests {
assert!(plan.validate().unwrap_err().to_string().contains("required `email`"));
}
#[test]
fn a_door_is_a_first_question_with_its_needs_on_a_page_of_their_own() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\ndoors:\n - { id: your-building, who: an owner with a building }\nneeds:\n - id: survey\n who: owners\n wants: a look\n door: your-building\n lands_in: enquiries\n handled_by: pms\n stages: [open, won, lost]\n done_when: [won, lost]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: build\n who: owners\n wants: a price\n door: your-building\n lands_in: enquiries\n handled_by: pms\n stages: [open, won, lost]\n done_when: [won, lost]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: apply\n who: workers\n wants: a job\n lands_in: applicants\n handled_by: pms\n stages: [open, hired]\n done_when: [hired]\n max_fields: 1\n collects:\n - { name: name }\n";
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
// The front door: the door, then the direct need.
let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
let names: Vec<&str> = front["alternatives"].as_array().unwrap().iter().map(|a| a["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Your building", "a job"]);
assert_eq!(front["alternatives"][0]["action"], "/your-building");
assert!(front["alternatives"][0].get("record_as").is_none());
// The door's page holds both forms and is not gated.
let door: Value = serde_yaml::from_str(&files["questions/your-building/index.yaml"]).unwrap();
assert_eq!(door["alternatives"].as_array().unwrap().len(), 2);
assert_eq!(door["alternatives"][0]["record_as"], "enquiries");
assert!(!files.contains_key("questions/your-building/_section.yaml"));
// The spec pins the door as the way in, two submissions deep.
let needs: Value = serde_yaml::from_str(&files["needs.yaml"]).unwrap();
assert_eq!(needs["needs"][0]["via"], "Your building");
assert_eq!(needs["needs"][0]["max_steps"], 2);
assert_eq!(needs["needs"][2]["max_steps"], 1);
assert_eq!(plan.front_door_count(), 2);
// A door nobody is behind, a door on an internal need, and a
// door that is also a group are refused.
let lonely: Plan = serde_yaml::from_str(&yaml.replace(" door: your-building\n", "")).unwrap();
assert!(lonely.validate().unwrap_err().to_string().contains("leads nowhere"));
let internal: Plan = serde_yaml::from_str(&yaml.replacen(" door: your-building\n", " door: your-building\n enters_as: pms\n", 1)).unwrap();
assert!(internal.validate().unwrap_err().to_string().contains("public way in"));
let clash: Plan = serde_yaml::from_str(&yaml.replace("your-building", "pms")).unwrap();
assert!(clash.validate().unwrap_err().to_string().contains("also a group"));
}
#[test]
fn selects_can_take_many_and_draw_their_options_from_the_labelled_groups() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\ngroups:\n - { id: trafikk, label: Trafikkforhold }\n - { id: elvesti, label: Elvesti }\nneeds:\n - id: komite\n who: en som vil sitte i en komité\n wants: en plass\n lands_in: soknader\n handled_by: styret\n stages: [open, plassert, avslatt]\n done_when: [plassert, avslatt]\n max_fields: 3\n collects:\n - { name: navn }\n - { name: komite, type: select, options_from: groups, multiple: true }\n";
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let page: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
let field = &page["alternatives"][0]["features"][2]["requirements"][1];
assert_eq!(field["options"], json!(["Trafikkforhold", "Elvesti"]));
assert_eq!(field["multiple"], true);
// Without labelled groups there is nothing to draw from.
let no_groups: Plan = serde_yaml::from_str(&yaml.replace("groups:\n - { id: trafikk, label: Trafikkforhold }\n - { id: elvesti, label: Elvesti }\n", "")).unwrap();
assert!(no_groups.validate().unwrap_err().to_string().contains("no labelled"));
// `multiple` on a text field is a mistake, not a no-op.
let bad: Plan = serde_yaml::from_str(&yaml.replace("{ name: navn }", "{ name: navn, multiple: true }")).unwrap();
assert!(bad.validate().unwrap_err().to_string().contains("multiple is for select"));
}
#[test]
fn a_public_watcher_gets_a_read_only_page_without_contact_details() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: trafikk\n who: en som har sett noe farlig\n wants: at det tas opp\n lands_in: meldinger\n handled_by: trafikk\n stages: [open, ordnet]\n done_when: [ordnet]\n watched_by: [styret, public]\n max_fields: 2\n collects:\n - { name: hvor }\n - { name: hva, type: textarea }\n - { name: epost, type: email, optional: true }\n - { name: navn, optional: true, private: true }\n";
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
// `public` is not a group: no desk directory for it.
assert!(!files.contains_key("questions/public/index.yaml"));
assert!(files.contains_key("questions/styret/index.yaml"));
let view: Value = serde_yaml::from_str(&files["questions/status/meldinger/index.yaml"]).unwrap();
let resource = &view["alternatives"][0]["features"][0]["resource"];
assert_eq!(resource["public"], true);
assert!(resource.get("requires_group").is_none());
assert_eq!(resource["jq"], ".[] | .responses |= {hva: .hva, hvor: .hvor}");
assert!(view.get("qualifies").is_none());
// The followup page points people at the list.
let followup: Value = serde_yaml::from_str(&files["questions/received/trafikk.yaml"]).unwrap();
assert_eq!(followup["alternatives"][1]["action"], "/status/meldinger");
// An internal need cannot be public.
let internal: Plan = serde_yaml::from_str(&yaml.replace(" lands_in:", " enters_as: styret\n lands_in:")).unwrap();
assert!(internal.validate().unwrap_err().to_string().contains("public view"));
}
#[test]
fn a_form_that_asks_more_than_the_person_will_tolerate_is_refused() {
// Two required fields under a budget of one: the plan is refused
// before any page exists, and the message says what to do.
let plan: Plan = serde_yaml::from_str(&PLAN.replace("max_fields: 4", "max_fields: 1")).unwrap();
let err = plan.validate().unwrap_err().to_string();
assert!(err.contains("2 required field(s)") && err.contains("tolerate 1"), "{err}");
// A budget of zero is not a budget.
let plan: Plan = serde_yaml::from_str(&PLAN.replace("max_fields: 3", "max_fields: 0")).unwrap();
assert!(plan.validate().unwrap_err().to_string().contains("at least 1"));
// The budget is what the spec hands the verifier, not the form's size.
let plan: Plan = serde_yaml::from_str(PLAN).unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
assert!(files["needs.yaml"].contains("max_fields: 4"));
assert!(files["needs.yaml"].contains("max_fields: 3"));
// A plan that says nothing about tolerance is not a plan.
assert!(serde_yaml::from_str::<Plan>(&PLAN.replace(" max_fields: 4\n", "")).is_err());
}
#[test]
fn a_plan_with_a_bad_identifier_or_a_disagreeing_bucket_is_refused() {
let bad: Plan = serde_yaml::from_str(&PLAN.replace("lost]", "avslått]")).unwrap();
assert!(bad.validate().unwrap_err().to_string().contains("ASCII"));
let clash = format!("{PLAN} - id: other\n who: w\n wants: x\n lands_in: enquiries\n handled_by: project_managers\n stages: [open, won]\n done_when: [won]\n collects: []\n");
let clash = format!("{PLAN} - id: other\n who: w\n wants: x\n lands_in: enquiries\n handled_by: project_managers\n stages: [open, won]\n done_when: [won]\n max_fields: 1\n collects: []\n");
let plan: Plan = serde_yaml::from_str(&clash).unwrap();
assert!(plan.validate().unwrap_err().to_string().contains("must agree"));
}
+101
View File
@@ -0,0 +1,101 @@
//! An alternative as the visitor meets it, in text.
//!
//! Portal renders a card: the heading, the description, a button, one
//! or two encouragements, and for each section an icon, a title, a
//! line of guidance and the fields with their labels and placeholders.
//! A visitor is oriented by all of it, not the heading alone. The
//! answering visitor and `rate` both read this rendering, so the
//! scored page is the served page; the gutted twin keeps the card's
//! shape and none of its words.
use serde_json::Value;
pub fn s(v: &Value) -> &str {
v.as_str().unwrap_or_default()
}
/// One option on a page. `full` is the card as rendered; otherwise the
/// heading alone, which is what a visitor who reads nothing else sees.
pub fn alternative(alt: &Value, full: bool) -> String {
let mut out = format!("- {}", s(&alt["name"]));
if !full {
out.push('\n');
return out;
}
let d = s(&alt["description"]).trim();
if !d.is_empty() {
out.push_str(&format!(": {d}"));
}
out.push('\n');
if let Some(button) = alt["consequence"].as_array().and_then(|c| c.first()).and_then(Value::as_str) {
out.push_str(&format!(" Button: {button}\n"));
}
for e in alt["encouragements"].as_array().into_iter().flatten().filter_map(Value::as_str) {
out.push_str(&format!(" {e}\n"));
}
for f in alt["features"].as_array().into_iter().flatten() {
let (name, desc, icon) = (s(&f["name"]).trim(), s(&f["description"]).trim(), s(&f["icon"]).trim());
let mut line = String::from(" ");
if !icon.is_empty() {
line.push_str(&format!("[icon {}] ", icon.rsplit(':').next().unwrap_or(icon)));
}
if !name.is_empty() {
line.push_str(name);
}
if !desc.is_empty() {
line.push_str(&format!("{}{desc}", if name.is_empty() { "" } else { ": " }));
}
if !line.trim().is_empty() {
out.push_str(line.trim_end());
out.push('\n');
}
let fields: Vec<String> = f["requirements"]
.as_array()
.into_iter()
.flatten()
.map(|r| {
let label = r["label"].as_str().unwrap_or_else(|| s(&r["name"]));
let hint = s(&r["placeholder"]).trim();
let opt = if r["optional"] == true { " (optional)" } else { "" };
if hint.is_empty() { format!("{label}{opt}") } else { format!("{label}{opt} e.g. {hint}") }
})
.collect();
if !fields.is_empty() {
out.push_str(&format!(" Asks for: {}\n", fields.join("; ")));
}
}
out
}
/// The page around its options: what it asks and says of itself.
pub fn page_head(page: &Value) -> String {
let mut out = format!("{}\n", s(&page["name"]));
let d = s(&page["description"]).trim();
if !d.is_empty() {
out.push_str(&format!("{d}\n"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_card_reads_as_the_visitor_sees_it_and_a_heading_alone_is_a_heading_alone() {
let alt = json!({
"name": "Get a price", "description": "For owners.",
"consequence": ["Send it"], "encouragements": ["We answer within two days."],
"features": [{"name": "Your brief", "icon": "lucide:file-text", "description": "A few lines are enough.",
"requirements": [{"name": "email", "label": "Email", "placeholder": "you@example.no"},
{"name": "address", "label": "Address", "optional": true}]}]
});
let full = alternative(&alt, true);
assert!(full.starts_with("- Get a price: For owners.\n"));
assert!(full.contains("Button: Send it") && full.contains("We answer within two days."));
assert!(full.contains("[icon file-text] Your brief: A few lines are enough."));
assert!(full.contains("Asks for: Email e.g. you@example.no; Address (optional)"));
assert_eq!(alternative(&alt, false), "- Get a price\n");
}
}
+115 -26
View File
@@ -23,6 +23,8 @@ use std::process::{Command, Stdio};
use anyhow::{anyhow, bail, Context};
mod build;
mod card;
mod plan;
mod rate;
mod simulate;
@@ -48,6 +50,7 @@ fn main() -> anyhow::Result<()> {
match argv.first().map(String::as_str) {
Some("simulate") => return simulate::run(&argv),
Some("compare") => return simulate::compare(&argv),
Some("plan") => return plan::run(&argv),
Some("build") => return build::run(&argv),
Some("rate") => return rate::rate(&argv),
Some("prefer") => return rate::prefer(&argv),
@@ -60,9 +63,10 @@ fn main() -> anyhow::Result<()> {
.and_then(|i| argv.get(i + 1))
.cloned()
};
let tasks = value("--tasks").ok_or_else(|| anyhow!("usage: portal-trainer answer --tasks <tasks.jsonl> [--llm 'cmd']"))?;
let tasks = value("--tasks").ok_or_else(|| anyhow!("usage: portal-trainer answer --tasks <tasks.jsonl> [--repo <content dir>] [--llm 'cmd']"))?;
let cmd = value("--llm").unwrap_or_else(|| "claude -p".to_string());
print!("{}", answer_blind(&cmd, &std::fs::read_to_string(&tasks).with_context(|| format!("reading {tasks}"))?)?);
let repo = value("--repo").map(PathBuf::from);
print!("{}", answer_blind(&cmd, &std::fs::read_to_string(&tasks).with_context(|| format!("reading {tasks}"))?, repo.as_deref())?);
return Ok(());
}
let opts = parse_args()?;
@@ -161,7 +165,7 @@ fn main() -> anyhow::Result<()> {
args.push("--skim".to_string());
}
lint(&opts, &args)?;
let answers = answer_blind(opts.visitor.as_deref().unwrap_or(&opts.llm), &std::fs::read_to_string(&tasks_path)?)?;
let answers = answer_blind(opts.visitor.as_deref().unwrap_or(&opts.llm), &std::fs::read_to_string(&tasks_path)?, Some(&opts.out))?;
let answers_path = opts.out.join(if skim { "answers-skim.jsonl" } else { "answers.jsonl" });
std::fs::write(&answers_path, answers)?;
let mut args = vec!["--needs-score".to_string(), answers_path.display().to_string()];
@@ -244,7 +248,12 @@ static LLM_LOG: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
static LLM_CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
fn llm(cmd: &str, prompt: &str) -> anyhow::Result<String> {
let reply = llm_with(cmd, prompt, &[]);
llm_env(cmd, prompt, &[])
}
/// As `llm`, with hints for the backend in its environment.
fn llm_env(cmd: &str, prompt: &str, env: &[(&str, &str)]) -> anyhow::Result<String> {
let reply = llm_with(cmd, prompt, env);
if let Some(dir) = LLM_LOG.get() {
let n = LLM_CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
std::fs::write(dir.join(format!("{n:02}-prompt.md")), prompt).ok();
@@ -424,31 +433,96 @@ fn dump(out: &Path) -> anyhow::Result<String> {
}
/// The answering side never sees `expect` or which need a persona has.
fn answer_blind(cmd: &str, tasks: &str) -> anyhow::Result<String> {
let mut blind = String::new();
///
/// One call per task, so no visitor sees another visitor's situation
/// (a batch of eight personas over four headings is nearly a matching
/// puzzle, and a model solves it as one). The options are shuffled per
/// task, so a heading's place on the page cannot carry the answer, and
/// the call is seeded from the task id, so a rerun on the same site is
/// the same experiment.
///
/// With a content repo to read, the visitor sees each option as the
/// card portal renders: button, encouragement, sections with icon and
/// guidance, fields with placeholders. Headings-only tasks (`--skim`,
/// where the task's text is the heading itself) stay headings only.
fn answer_blind(cmd: &str, tasks: &str, repo: Option<&Path>) -> anyhow::Result<String> {
let mut answers = String::new();
for line in tasks.lines().filter(|l| !l.trim().is_empty()) {
let mut task: serde_json::Value = serde_json::from_str(line)?;
if let Some(map) = task.as_object_mut() {
map.remove("expect");
map.remove("need");
}
blind.push_str(&serde_json::to_string(&task)?);
blind.push('\n');
let task: serde_json::Value = serde_json::from_str(line)?;
let id = task["id"].as_str().unwrap_or_default();
let body = task["state"]["body"].as_str().unwrap_or_default();
let pick = &task["questions"]["pick"];
let instructions = pick["instructions"].as_str().unwrap_or_default();
let mut options: Vec<(&str, &str)> = pick["criteria"]
.as_object()
.into_iter()
.flatten()
.map(|(k, v)| (k.as_str(), v.as_str().unwrap_or_default()))
.collect();
let skim = options.iter().all(|(name, text)| text.is_empty() || text == name);
let page = if skim { None } else { repo.and_then(|r| rate::load_page(r, task["page"].as_str().unwrap_or("/"))) };
let seed = fnv(id);
shuffle(&mut options, seed);
// Options are numbered and the answer is the number: with a
// whole card in view, a small model asked to copy a heading
// copied the page's description instead (measured: six of
// fourteen misroutes on one run were that), and no heading
// matched. A number cannot be a description.
let listed: Vec<String> = options
.iter()
.enumerate()
.map(|(i, (name, text))| {
let card = page
.as_ref()
.and_then(|p| p["alternatives"].as_array())
.and_then(|alts| alts.iter().find(|a| a["name"] == *name))
.map(|a| card::alternative(a, true));
let body = match card {
Some(card) => card.trim_end().trim_start_matches("- ").to_string(),
None if skim => name.to_string(),
None => format!("{name}: {text}"),
};
format!("{}. {body}", i + 1)
})
.collect();
let head = page.as_ref().map(card::page_head).unwrap_or_default();
let prompt = format!(
"You are this person: {body}\n\n{instructions}\n{head}The page offers these options:\n\n{}\n\nWhich one do you pick? Judge only from your own situation and the wording in front of you. Give your honest confidence between 0 and 1; if two options fit you about equally, confidence is low.\nReply as JSON: {{\"choice\": <the option's number>, \"confidence\": 0.00}}",
listed.join("\n\n")
);
let reply = llm_env(cmd, &prompt, &[("FORMAT", "json"), ("SEED", &seed.to_string()), ("TEMPERATURE", "0.2")])?;
let parsed = simulate::first_object(&reply).unwrap_or(serde_json::Value::Null);
let names: Vec<&str> = options.iter().map(|(n, _)| *n).collect();
let raw = match &parsed["choice"] {
serde_json::Value::Number(n) => n.to_string(),
other => other.as_str().unwrap_or_default().to_string(),
};
// A number, or a heading for a model that wrote one anyway.
let choice = match raw.trim().trim_end_matches('.').parse::<usize>() {
Ok(n) if (1..=names.len()).contains(&n) => names[n - 1].to_string(),
_ => simulate::resolve_choice(&names, &raw),
};
let confidence = parsed["confidence"].as_f64().unwrap_or(0.0);
answers.push_str(&serde_json::to_string(&serde_json::json!({ "id": id, "choice": choice, "confidence": confidence }))?);
answers.push('\n');
}
if blind.is_empty() {
return Ok(String::new());
Ok(answers)
}
/// FNV-1a over the task id: a seed that is the same on every run.
fn fnv(text: &str) -> u32 {
text.bytes().fold(0x811c_9dc5u32, |h, b| (h ^ u32::from(b)).wrapping_mul(0x0100_0193))
}
/// Fisher-Yates on a small xorshift, so the order is fixed by the seed.
fn shuffle<T>(items: &mut [T], seed: u32) {
let mut x = seed.max(1);
for i in (1..items.len()).rev() {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
items.swap(i, (x as usize) % (i + 1));
}
let prompt = format!(
"You are a typed-decision answering engine. Each line below is a JSON task: `state.body` is a website visitor describing their situation in their own words; `questions.pick` holds `instructions` and `criteria`, a map from option label to what that option says.\n\nFor each task decide which single option that visitor would most plausibly choose, judging only from their text and the option wording, and give your honest confidence between 0 and 1. If two options are similarly plausible, confidence is low.\n\nReply with ONLY JSONL, one line per task, same order:\n{{\"id\":\"<task id>\",\"choice\":\"<option label copied exactly>\",\"confidence\":0.00}}\n\n{blind}"
);
let reply = llm(cmd, &prompt)?;
Ok(reply
.lines()
.map(str::trim)
.filter(|l| l.starts_with('{') && serde_json::from_str::<serde_json::Value>(l).is_ok())
.collect::<Vec<_>>()
.join("\n")
+ "\n")
}
#[cfg(test)]
@@ -476,6 +550,21 @@ mod tests {
assert_eq!(files["site.yaml"], "title: A\n");
}
#[test]
fn a_task_is_shuffled_the_same_way_every_time_and_differently_from_the_next() {
let mut a = ["w", "x", "y", "z"];
let mut b = a;
shuffle(&mut a, fnv("scaffolder@/"));
shuffle(&mut b, fnv("scaffolder@/"));
assert_eq!(a, b);
let mut c = ["w", "x", "y", "z"];
shuffle(&mut c, fnv("apprentice@/"));
assert_ne!(a, c);
let mut sorted = a;
sorted.sort();
assert_eq!(sorted, ["w", "x", "y", "z"]);
}
#[test]
fn only_content_paths_may_be_written() {
assert!(allowed("site.yaml"));
+301
View File
@@ -0,0 +1,301 @@
//! 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.
doors: # a first question before the forms, when more than four or
- id: kebab-case # five needs would otherwise sit on the front door: one door
who: who comes through it # per kind of visitor, two or three needs behind each
groups: # groups with a label the public may see: the committees
- { id: trafikk, label: Trafikkforhold } # a person can ask to join (optional)
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)
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)
max_fields: 4 # 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
- { 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
```
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 holds at most five alternatives: doors plus needs
with no door. A door with no need behind it, or a door on a need with
`enters_as`, is refused.
- 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 personas = 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}"))?;
let list = doc
.get("personas")
.cloned()
.ok_or_else(|| anyhow!("{path} has no `personas:`"))?;
Some(serde_yaml::to_string(&serde_yaml::Mapping::from_iter([(
serde_yaml::Value::from("personas"),
list,
)]))?)
}
None => None,
};
std::fs::create_dir_all(out.join("llm"))?;
let mut feedback = String::new();
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) => {
let mut text = yaml.trim_end().to_string();
text.push('\n');
if let Some(p) = &personas {
text.push_str("\n# Held out from every model that writes words.\n");
text.push_str(p);
}
// The attached personas were written against someone
// else's need ids. The person reading the plan maps
// them; the mismatches are listed so they know which.
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 "));
}
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()
)
}
/// 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"));
}
}
+202 -62
View File
@@ -3,22 +3,34 @@
//! at its maximum.
//!
//! - **Readability** is counted, not judged: LIX, sentence length plus
//! the share of words over six letters. Below 30 reads very easily,
//! 30-40 easily, 40-50 takes effort, above 50 is official prose.
//! the share of words over six letters, over the page's prose only -
//! the question, the descriptions and the followup - never headings
//! or field labels, which are not sentences and would drag the score
//! down to "very easy" whatever the prose says. Below 30 reads very
//! easily, 30-40 easily, 40-50 takes effort, above 50 is official
//! prose.
//! - **Understanding** is three yes-or-no questions put to each persona
//! about the page in front of them. `rate` also puts them to a
//! deliberately gutted twin of the page (headings replaced by
//! "Option 1", descriptions removed). An instrument that still says
//! yes to the twin is not measuring the page, and `rate` says so.
//! about the page in front of them: which option is theirs, what the
//! form will ask, and what happens after sending. The last needs the
//! followup page, which `rate` reads from the content repo (`--repo`)
//! because the sim export stops at the form. `rate` also puts all
//! three to a deliberately gutted twin of the page (headings replaced
//! by "Option 1", descriptions, the form line and the followup
//! removed). An instrument that still says yes to the twin is not
//! measuring the page, and `rate` says so, per question. Every page
//! on a need's path is rated: behind a door, "which" is decided on
//! the door page and the form's page carries "asked" and "next".
//! - **Friendliness** is a preference between two versions, never an
//! absolute mark: `prefer` shows each persona both versions in both
//! orders; a pick that flips with the order counts as no preference.
use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{anyhow, Context};
use serde_json::{json, Value};
use crate::card;
use crate::simulate::ask;
fn flag(argv: &[String], name: &str) -> Option<String> {
@@ -53,20 +65,41 @@ pub fn lix_band(score: f64) -> &'static str {
}
/// The page as the visitor meets it: the question, every alternative,
/// and the form behind the one that serves them.
fn page_text(step: &Value, gutted: bool) -> String {
let mut text = format!("{}\n", step["question"].as_str().unwrap_or_default());
/// the form behind the one that serves them, and the page they land on
/// after sending, when the repo is there to read it from.
fn page_text(step: &Value, page: Option<&Value>, followup: Option<&Value>, gutted: bool) -> String {
let mut text = match (gutted, page) {
(false, Some(p)) => card::page_head(p),
_ => format!("{}\n", step["question"].as_str().unwrap_or_default()),
};
for (i, option) in step["options"].as_array().into_iter().flatten().enumerate() {
if gutted {
text.push_str(&format!("\n- Option {}\n", i + 1));
} else {
text.push_str(&format!(
continue;
}
// The whole card when the repo is there to read it from;
// heading and description otherwise.
let card = page
.and_then(|p| p["alternatives"].as_array())
.and_then(|alts| alts.iter().find(|a| a["name"] == option["name"]))
.map(|a| card::alternative(a, true));
match card {
Some(c) => text.push_str(&format!("\n{c}")),
None => text.push_str(&format!(
"\n- {}\n {}\n",
option["name"].as_str().unwrap_or_default(),
option["description"].as_str().unwrap_or_default()
));
)),
}
}
if page.is_some() && !gutted {
// The cards carry their own "Asks for" lines.
return finish_page_text(text, followup);
}
// The twin has no form line at all: "Field 1, Field 2" still
// tells a reader how many things they will be asked, and a model
// answers "can you tell what you will be asked" with yes to that
// (measured: 2 of 2 on every need).
let fields: Vec<&str> = step["fields"].as_array().into_iter().flatten().filter_map(|f| f["label"].as_str()).collect();
if !gutted && !fields.is_empty() {
text.push_str(&format!(
@@ -75,9 +108,62 @@ fn page_text(step: &Value, gutted: bool) -> String {
fields.join(", ")
));
}
if gutted {
return text;
}
finish_page_text(text, followup)
}
fn finish_page_text(mut text: String, followup: Option<&Value>) -> String {
if let Some(next) = followup {
text.push_str(&format!("\nAfter you send it, the next page says:\n{}\n", next["name"].as_str().unwrap_or_default()));
if let Some(d) = next["description"].as_str().filter(|d| !d.trim().is_empty()) {
text.push_str(&format!("{}\n", d.trim()));
}
for alt in next["alternatives"].as_array().into_iter().flatten() {
let d = alt["description"].as_str().unwrap_or_default().trim();
text.push_str(&format!("- {}{}{}\n", alt["name"].as_str().unwrap_or_default(), if d.is_empty() { "" } else { ": " }, d));
}
}
text
}
/// The running text a reader has to parse: the question, each
/// description, and the followup. Headings and labels are left out.
fn prose_text(step: &Value, followup: Option<&Value>) -> String {
let mut prose = vec![step["question"].as_str().unwrap_or_default().to_string()];
prose.extend(step["options"].as_array().into_iter().flatten().filter_map(|o| o["description"].as_str()).map(str::to_string));
if let Some(next) = followup {
prose.extend(next["description"].as_str().map(str::to_string));
prose.extend(next["alternatives"].as_array().into_iter().flatten().filter_map(|a| a["description"].as_str()).map(str::to_string));
}
prose.into_iter().filter(|p| !p.trim().is_empty()).collect::<Vec<_>>().join("\n")
}
/// The page a visitor lands on after the step's alternative: read from
/// the content repo, since the sim export stops at the form. `None`
/// when there is no repo, no `action`, or the page is not there.
fn followup_page(repo: Option<&Path>, step: &Value) -> Option<Value> {
let repo = repo?;
let page = load_page(repo, step["page"].as_str()?)?;
let alternative = step["alternative"].as_str()?;
let action = page["alternatives"].as_array()?.iter().find(|a| a["name"] == alternative)?["action"].as_str()?;
load_page(repo, action)
}
/// `/` is `questions/index.yaml`; `/a/b` is `questions/a/b.yaml` or
/// `questions/a/b/index.yaml`.
pub(crate) fn load_page(repo: &Path, route: &str) -> Option<Value> {
let rel = route.trim_matches('/');
let candidates = if rel.is_empty() {
vec![repo.join("questions/index.yaml")]
} else {
vec![repo.join(format!("questions/{rel}.yaml")), repo.join(format!("questions/{rel}/index.yaml"))]
};
let text = candidates.iter().find_map(|p| std::fs::read_to_string(p).ok())?;
serde_yaml::from_str(&text).ok()
}
/// What one page may ask of one person before it should be split into
/// a further question, or the fields moved to a later stage of the
/// case where they are actually needed. Complexity belongs in the
@@ -93,80 +179,105 @@ fn page_load(step: &Value) -> (usize, usize) {
)
}
const QUESTIONS: [(&str, &str); 3] = [
const QUESTIONS: [(&str, &str); 4] = [
("which", "Is it clear from the page which option is meant for someone in your situation?"),
("where", "Do you know where you are on this site, who this option is for, and what pressing its button will do?"),
("asked", "Can you tell from the page what you will be asked to provide?"),
("next", "Does the page tell you what happens after you send it, or who will read it?"),
];
const N: usize = QUESTIONS.len();
fn understanding(cmd: &str, who: &str, page: &str, seed: usize) -> anyhow::Result<[bool; 3]> {
fn understanding(cmd: &str, who: &str, page: &str, seed: usize) -> anyhow::Result<[bool; N]> {
let prompt = format!(
"You are this person: {who}\n\nThis is the whole of a web page you have just opened:\n---\n{page}---\n\nAnswer three questions about it as yourself. Say yes only when the page text itself gives you that; if you would be guessing, say no.\n{}\nReply as JSON: {{\"which\": true or false, \"asked\": true or false, \"next\": true or false}}",
"You are this person: {who}\n\nThis is the whole of a web page you have just opened:\n---\n{page}---\n\nAnswer four questions about it as yourself. Say yes only when the page text itself gives you that; if you would be guessing, say no.\n{}\nReply as JSON: {{\"which\": true or false, \"where\": true or false, \"asked\": true or false, \"next\": true or false}}",
QUESTIONS.iter().map(|(k, q)| format!("- {k}: {q}")).collect::<Vec<_>>().join("\n")
);
let reply = ask(cmd, &prompt, &seed.to_string())?;
Ok([reply["which"] == true, reply["asked"] == true, reply["next"] == true])
Ok([reply["which"] == true, reply["where"] == true, reply["asked"] == true, reply["next"] == true])
}
pub fn rate(argv: &[String]) -> anyhow::Result<()> {
let usage = "usage: portal-trainer rate --model <model.json> --out <dir> [--llm 'cmd']";
let usage = "usage: portal-trainer rate --model <model.json> --out <dir> [--repo <content dir>] [--llm 'cmd']";
let model = load(&flag(argv, "--model").ok_or_else(|| anyhow!(usage))?)?;
let out = std::path::PathBuf::from(flag(argv, "--out").ok_or_else(|| anyhow!(usage))?);
let repo = flag(argv, "--repo").map(std::path::PathBuf::from);
let cmd = flag(argv, "--llm").unwrap_or_else(|| "claude -p".to_string());
std::fs::create_dir_all(&out)?;
if repo.is_none() {
eprintln!("rate: no --repo, so no followup page is shown and \"next\" is not measured");
}
let mut report = BTreeMap::new();
println!("{:<28} {:>6} {:<14} {:>8} {:>8} {:>8} {:>16} {:<22}", "need", "LIX", "", "which", "asked", "next", "gutted twin says", "page load");
println!("{:<28} {:>6} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}", "need", "LIX", "", "which", "where", "asked", "next", "gutted twin says", "page load");
for need in model["needs"].as_array().into_iter().flatten() {
let id = need["id"].as_str().unwrap_or_default();
let Some(step) = need["steps"].as_array().and_then(|s| s.first()) else { continue };
let steps = need["steps"].as_array().cloned().unwrap_or_default();
let personas: Vec<&Value> = model["personas"].as_array().into_iter().flatten().filter(|p| p["need"] == id).collect();
if personas.is_empty() {
continue;
}
let page = page_text(step, false);
let readability = lix(&page);
let (mut yes, mut control) = ([0usize; 3], [0usize; 3]);
for (i, persona) in personas.iter().enumerate() {
let who = persona["text"].as_str().unwrap_or_default();
for (tally, gutted) in [(&mut yes, false), (&mut control, true)] {
let answers = understanding(&cmd, who, &page_text(step, gutted), i)?;
for (slot, answer) in tally.iter_mut().zip(answers) {
*slot += answer as usize;
// Every page on the path is rated. The followup follows only
// the last page's alternative: on a door page, what the
// alternative leads to is the next page, not what happens
// after sending.
let mut pages = Vec::new();
for (i, step) in steps.iter().enumerate() {
let followup = if i + 1 == steps.len() { followup_page(repo.as_deref(), step) } else { None };
let page = repo.as_deref().and_then(|r| load_page(r, step["page"].as_str().unwrap_or("/")));
let row = if steps.len() > 1 { format!("{id} @{}", step["page"].as_str().unwrap_or_default()) } else { id.to_string() };
let readability = lix(&prose_text(step, followup.as_ref()));
let (mut yes, mut control) = ([0usize; N], [0usize; N]);
for (i, persona) in personas.iter().enumerate() {
let who = persona["text"].as_str().unwrap_or_default();
for (tally, gutted) in [(&mut yes, false), (&mut control, true)] {
let answers = understanding(&cmd, who, &page_text(step, page.as_ref(), followup.as_ref(), gutted), i)?;
for (slot, answer) in tally.iter_mut().zip(answers) {
*slot += answer as usize;
}
}
}
}
let n = personas.len();
let share = |k: usize| format!("{}/{n}", yes[k]);
// The twin has no headings and no descriptions: "which" must
// collapse on it, or the instrument is agreeing with anything.
let control_ok = control[0] * 2 < n.max(1);
let (options, required) = page_load(step);
let steps = need["steps"].as_array().map_or(1, Vec::len);
println!(
"{:<28} {:>6.0} {:<14} {:>8} {:>8} {:>8} {:>16} {:<22}",
id,
readability,
lix_band(readability),
share(0),
share(1),
share(2),
format!("{}/{n} {}", control[0], if control_ok { "ok" } else { "CONTROL FAILED" }),
format!(
"{options} options, {required} required{}",
if options > MAX_OPTIONS || required > MAX_REQUIRED { " - SPLIT" } else { "" }
),
);
report.insert(
id.to_string(),
json!({
"lix": readability, "personas": n, "steps": steps,
let n = personas.len();
let measured = |k: usize| k != 3 || followup.is_some();
let share = |k: usize| if measured(k) { format!("{}/{n}", yes[k]) } else { "-".to_string() };
// The twin has no headings, no descriptions, no cards and no
// followup: every question must collapse on it, or that
// question is agreeing with anything.
let control_ok: Vec<bool> = (0..N).map(|k| !measured(k) || control[k] * 2 < n.max(1)).collect();
let failed: Vec<&str> = QUESTIONS.iter().zip(&control_ok).filter(|(_, ok)| !**ok).map(|((k, _), _)| *k).collect();
let (options, required) = page_load(step);
println!(
"{:<28} {:>6.0} {:<14} {:>8} {:>8} {:>8} {:>8} {:<22} {:<22}",
row,
readability,
lix_band(readability),
share(0),
share(1),
share(2),
share(3),
format!(
"{}/{}/{}/{} {}",
control[0],
control[1],
control[2],
if measured(3) { control[3].to_string() } else { "-".to_string() },
if failed.is_empty() { "ok".to_string() } else { format!("FAILED: {}", failed.join(", ")) }
),
format!(
"{options} options, {required} required{}",
if options > MAX_OPTIONS || required > MAX_REQUIRED { " - SPLIT" } else { "" }
),
);
pages.push(json!({
"page": step["page"], "lix": readability, "personas": n,
"options": options, "required_fields": required,
"overloaded": options > MAX_OPTIONS || required > MAX_REQUIRED,
"understanding": {"which": yes[0], "asked": yes[1], "next": yes[2]},
"control_which": control[0], "control_ok": control_ok,
}),
);
"followup_shown": followup.is_some(),
"understanding": {"which": yes[0], "where": yes[1], "asked": yes[2], "next": if measured(3) { json!(yes[3]) } else { Value::Null }},
"control": {"which": control[0], "where": control[1], "asked": control[2], "next": if measured(3) { json!(control[3]) } else { Value::Null }},
"control_ok": failed.is_empty(),
}));
}
report.insert(id.to_string(), json!({ "steps": pages.len(), "pages": pages }));
}
std::fs::write(out.join("rate.json"), serde_json::to_string_pretty(&report)?)?;
Ok(())
@@ -184,7 +295,7 @@ pub fn prefer(argv: &[String]) -> anyhow::Result<()> {
let id = need_a["id"].as_str().unwrap_or_default();
let Some(need_b) = b["needs"].as_array().into_iter().flatten().find(|n| n["id"] == id) else { continue };
let (Some(step_a), Some(step_b)) = (need_a["steps"].as_array().and_then(|s| s.first()), need_b["steps"].as_array().and_then(|s| s.first())) else { continue };
let (text_a, text_b) = (page_text(step_a, false), page_text(step_b, false));
let (text_a, text_b) = (page_text(step_a, None, None, false), page_text(step_b, None, None, false));
if text_a == text_b {
continue;
}
@@ -244,10 +355,39 @@ mod tests {
"alternative": "Get a price",
"fields": [{"label": "Email"}, {"label": "The build"}]
});
let page = page_text(&step, false);
let next = json!({"name": "What happens now?", "description": "A project manager reads it.", "alternatives": [{"name": "We call you within two days", "description": ""}]});
let page = page_text(&step, None, Some(&next), false);
assert!(page.contains("Get a price") && page.contains("asks for: Email, The build"));
let twin = page_text(&step, true);
assert!(page.contains("After you send it") && page.contains("A project manager reads it."));
// With the repo's page in hand, each option is its whole card.
let repo_page = json!({"name": "What brings you here?", "description": "", "alternatives": [
{"name": "Get a price", "description": "For owners.", "consequence": ["Send it"], "encouragements": ["We answer within two days."], "features": [{"name": "Your brief", "requirements": [{"name": "email", "label": "Email"}]}]},
{"name": "Work with us", "description": "For crews."}]});
let rich = page_text(&step, Some(&repo_page), Some(&next), false);
assert!(rich.contains("Button: Send it") && rich.contains("We answer within two days.") && rich.contains("Asks for: Email"));
assert!(!rich.contains("asks for: Email, The build"));
let twin = page_text(&step, Some(&repo_page), Some(&next), true);
assert!(!twin.contains("Send it") && !twin.contains("Your brief"));
let twin = page_text(&step, None, Some(&next), true);
assert!(twin.contains("What brings you here?") && twin.contains("Option 2"));
assert!(!twin.contains("Get a price") && !twin.contains("Email"));
assert!(!twin.contains("asks for"));
assert!(!twin.contains("Get a price") && !twin.contains("Email") && !twin.contains("project manager"));
// Readability is measured on the sentences, not the headings.
let prose = prose_text(&step, Some(&next));
assert_eq!(prose, "What brings you here?\nFor owners.\nFor crews.\nA project manager reads it.");
}
#[test]
fn the_followup_page_is_read_from_the_repo_by_following_the_action() {
let dir = std::env::temp_dir().join(format!("portal-trainer-rate-{}", std::process::id()));
std::fs::create_dir_all(dir.join("questions/received")).unwrap();
std::fs::write(dir.join("questions/index.yaml"), "name: What?\nalternatives:\n - name: Get a price\n action: /received/price\n").unwrap();
std::fs::write(dir.join("questions/received/price.yaml"), "name: What happens now?\ndescription: We read it.\nalternatives:\n - name: Back\n").unwrap();
let step = json!({"page": "/", "alternative": "Get a price"});
let next = followup_page(Some(&dir), &step).unwrap();
assert_eq!(next["name"], "What happens now?");
assert!(followup_page(None, &step).is_none());
assert!(followup_page(Some(&dir), &json!({"page": "/", "alternative": "Nope"})).is_none());
std::fs::remove_dir_all(dir).ok();
}
}
+1 -1
View File
@@ -410,7 +410,7 @@ pub(crate) fn all_routes(bucket: &Value, done: &[&str]) -> Vec<Vec<Move>> {
/// Models often answer "Heading: its description" or change the case.
/// The pick is the longest option heading the answer starts with;
/// anything else is kept verbatim and counts as a misroute.
fn resolve_choice(names: &[&str], raw: &str) -> String {
pub(crate) fn resolve_choice(names: &[&str], raw: &str) -> String {
let answer = raw.trim().trim_matches('"').to_lowercase();
names
.iter()