From 8c286a84832a0f92eaa33cd56700ff5a9e19b9f4 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Mon, 21 Sep 2026 21:01:35 +0200 Subject: [PATCH] build: structure from the plan, words from the model A plan.yaml is a needs spec that also says what each form collects (and which stages a case may pass over). `portal-trainer build` derives everything structural from it with no model involved: needs.yaml, aggregates.yaml with the state machine read off `stages` (walk the line, be turned down anywhere, succeed only from the end), the front door, a followup page per need, a gated directory per group holding its desk and the pages only that group may open, and every desk button the machine allows. The model is asked for words only, one page at a time, as a JSON object whose shape is fixed by built-in fallbacks. Its strings are merged over the fallbacks key by key, so a missing, malformed or partial reply costs wording, never a working site, and it can neither add nor drop structure. `via` in needs.yaml is filled mechanically from the heading the words gave. plans/contractor.yaml builds with no model into a site portal's question_lint accepts: 6 needs met, 12 pages, 5 aggregates. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH --- plans/contractor.yaml | 116 ++++++++ src/build.rs | 616 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 2 + 3 files changed, 734 insertions(+) create mode 100644 plans/contractor.yaml create mode 100644 src/build.rs diff --git a/plans/contractor.yaml b/plans/contractor.yaml new file mode 100644 index 0000000..e89f76c --- /dev/null +++ b/plans/contractor.yaml @@ -0,0 +1,116 @@ +# The contractor from intakes/contractor.yaml as a plan: the needs spec +# plus what each form collects. Everything structural in the generated +# site comes from this file; a model only writes the words. +business: Nordvik Bygg +language: en +contact: { name: Ingrid Nordvik, email: ingrid@nordvikbygg.example } +about: > + Twelve people who survey old buildings, draw up rehabilitation plans, + handle the permit application, and do the building work with their + own carpenters plus subcontractors for electrical, plumbing and + scaffolding. + +needs: + - id: survey-enquiry + who: a housing board or private owner with a problem in a building + wants: someone qualified to come and look at it and say what it takes + lands_in: enquiries + handled_by: project_managers + stages: [open, surveyed, quoted, won, lost] + done_when: [won, lost] + success: [won] + collects: + - { name: name } + - { name: phone, type: tel } + - { name: address } + - { name: problem, type: textarea } + - { name: access, optional: true } + + - id: build-enquiry + who: an owner who already knows what they want built or changed + wants: drawings, a permit application and a price + lands_in: enquiries + handled_by: project_managers + stages: [open, surveyed, quoted, won, lost] + done_when: [won, lost] + success: [won] + collects: + - { name: name } + - { name: phone, type: tel } + - { name: address } + - { name: project, type: textarea } + - { name: timeframe, type: select, options: [This year, Next year, No fixed date] } + + - id: log-phone-enquiry + who: a project manager who just took a call or a text about a building + wants: the enquiry written down before it gets lost + enters_as: project_managers + lands_in: enquiries + handled_by: project_managers + stages: [open, surveyed, quoted, won, lost] + done_when: [won, lost] + success: [won] + collects: + - { name: caller } + - { name: phone, type: tel } + - { name: address } + - { name: problem, type: textarea } + + - id: subcontractor-application + who: a trade firm doing electrical, plumbing or scaffolding work + wants: to be approved once, and then be called when a job needs them + lands_in: subcontractors + handled_by: project_managers + stages: [open, awaiting_papers, approved, declined] + done_when: [approved, declined] + success: [approved] + skippable: [awaiting_papers] + collects: + - { name: company } + - { name: registration_number } + - { name: trade, type: select, options: [Electrical, Plumbing, Scaffolding, Sheet metal, Other] } + - { name: contact_email, type: email } + - { name: insurance_confirmed, type: select, options: ["Yes", Not yet] } + - { name: trade_approval, optional: true } + + - id: job-application + who: a carpenter, bricklayer or apprentice looking for work + wants: to be considered, without writing a letter + lands_in: applicants + handled_by: project_managers + stages: [open, interviewing, hired, declined] + done_when: [hired, declined] + success: [hired] + collects: + - { name: name } + - { name: phone, type: tel } + - { name: trade, type: select, options: [Carpenter, Bricklayer, Apprentice, Other] } + - { name: experience, type: textarea, optional: true } + + - id: report-site-issue + who: anyone on site, ours or a subcontractor's, on a phone with gloves on + wants: to report a hazard, a near miss or wrong materials before they forget + enters_as: crew + lands_in: site_issues + handled_by: safety_officer + stages: [open, in_hand, closed] + done_when: [closed] + success: [closed] + collects: + - { name: kind, type: select, options: [Hazard, Near miss, Wrong materials, Other] } + - { name: where } + - { name: what_happened, type: textarea } + +# Held out from every model that writes words: these are the personas +# from sectors/construction, which no generator has seen. +personas: + - { id: housing-board-damp, need: survey-enquiry, text: "I chair the board of a housing cooperative from the seventies. Two ground-floor flats have damp in the walls and we don't know if it's drainage or the facade. We need someone to tell us." } + - { id: buyer-before-purchase, need: survey-enquiry, text: "We are about to buy an old timber house. Before we sign we want a professional to go through it and tell us what is wrong with it." } + - { id: attic-conversion, need: build-enquiry, text: "We want to turn our attic into two bedrooms and a bathroom. We need drawings and someone who handles the application to the municipality." } + - { id: shop-to-flat, need: build-enquiry, text: "I own a former shop on the ground floor and want to make it a flat. I have no idea what is allowed. Can you sort that out and give me a price?" } + - { id: electrician-firm, need: subcontractor-application, text: "We are a four-person electrical firm, fully certified. We would like to get on your list so you call us when you need electricians." } + - { id: scaffolder, need: subcontractor-application, text: "I run a scaffolding company. How do I get to do jobs for you?" } + - { id: carpenter-jobseeker, need: job-application, text: "I'm a certified carpenter with eight years on rehabilitation jobs. Are you hiring?" } + - { id: apprentice, need: job-application, text: "I finish vocational school this spring and need an apprenticeship as a bricklayer." } + - { id: loose-railing, need: report-site-issue, text: "The railing on the third level of the scaffold on the north side is loose. Nobody got hurt but someone will." } + - { id: wrong-membrane, need: report-site-issue, text: "The bathroom membrane delivered today is not the one in the specification. We stopped work on it." } diff --git a/src/build.rs b/src/build.rs new file mode 100644 index 0000000..77ee04c --- /dev/null +++ b/src/build.rs @@ -0,0 +1,616 @@ +//! Structure from the plan, words from the model. +//! +//! A `plan.yaml` is a needs spec that also says what each form collects. +//! From it, with no model involved, come `needs.yaml`, +//! `aggregates.yaml`, `site.yaml`, the front door, a followup page per +//! need, a directory per group (gated by `_section.yaml`) holding its +//! desk and the pages only that group may open, and every desk button +//! the state machine allows. What the business said determines all of +//! that; a model has nothing to decide there and much to get wrong +//! (measured: a dropped stage from a strong model, no loadable site at +//! all from a small one). +//! +//! The model writes the words: one small JSON object per page - the +//! question, headings, descriptions, field and button labels - in the +//! plan's language. Words are merged over built-in fallbacks key by +//! key, so a reply that is missing, malformed or partial costs wording, +//! never a working site. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use anyhow::{anyhow, bail, Context}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; + +use crate::simulate::first_object; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Plan { + pub business: String, + #[serde(default = "english")] + pub language: String, + pub contact: Contact, + /// What the business does, in its own words - context for the + /// model's wording, never structure. + #[serde(default)] + pub about: String, + pub needs: Vec, + #[serde(default)] + pub personas: Vec, +} + +fn english() -> String { + "en".to_string() +} + +#[derive(Debug, Deserialize)] +pub struct Contact { + pub name: String, + pub email: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlanNeed { + pub id: String, + pub who: String, + pub wants: String, + #[serde(default)] + pub enters_as: Option, + pub lands_in: String, + pub handled_by: String, + /// Every stage, first to last, finished states included. + pub stages: Vec, + pub done_when: Vec, + #[serde(default)] + pub success: Vec, + /// Stages a case may pass over ("awaiting papers" when the papers + /// were complete). + #[serde(default)] + pub skippable: Vec, + pub collects: Vec, + #[serde(default)] + pub max_fields: Option, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct Field { + pub name: String, + #[serde(default = "text", rename = "type")] + pub kind: String, + #[serde(default)] + pub optional: bool, + #[serde(default)] + pub options: Vec, +} + +fn text() -> String { + "text".to_string() +} + +const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select"]; + +fn is_id(s: &str) -> bool { + !s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') +} + +impl Plan { + pub fn validate(&self) -> anyhow::Result<()> { + let mut seen = BTreeSet::new(); + for need in &self.needs { + let at = |what: &str| format!("need {:?}: {what}", need.id); + if !is_id(&need.id) || !seen.insert(&need.id) { + bail!(at("id must be unique ASCII lowercase with - or _")); + } + for id in [&need.lands_in, &need.handled_by].into_iter().chain(&need.enters_as).chain(&need.stages) { + if !is_id(id) { + bail!(at(&format!("{id:?} is an identifier and must be ASCII lowercase with - or _"))); + } + } + if need.stages.len() < 2 { + bail!(at("stages needs a first stage and at least one finished one")); + } + for state in need.done_when.iter().chain(&need.skippable) { + if !need.stages.contains(state) { + bail!(at(&format!("{state:?} is not one of its stages"))); + } + } + for state in &need.success { + if !need.done_when.contains(state) { + bail!(at(&format!("success {state:?} is not one of done_when"))); + } + } + 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")); + } + 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))); + } + } + } + // Needs sharing a bucket share its machine. + for (bucket, needs) in self.by_bucket() { + let first = needs[0]; + for other in &needs[1..] { + if other.stages != first.stages || other.done_when != first.done_when || other.skippable != first.skippable { + bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when and skippable", first.id, other.id); + } + } + } + Ok(()) + } + + fn by_bucket(&self) -> BTreeMap<&str, Vec<&PlanNeed>> { + let mut map: BTreeMap<&str, Vec<&PlanNeed>> = BTreeMap::new(); + for need in &self.needs { + map.entry(&need.lands_in).or_default().push(need); + } + map + } +} + +/// The moves out of every unfinished stage. The unfinished stages form +/// the line a case walks; from each, the next stage, plus whatever lies +/// beyond a run of skippable ones, plus every finished state that is +/// not a success (a case can be turned down at any point). Success is +/// only reachable from the end of the line. +pub fn machine(need: &PlanNeed) -> BTreeMap> { + let line: Vec<&String> = need.stages.iter().filter(|s| !need.done_when.contains(s)).collect(); + let ends_well: Vec<&String> = if need.success.is_empty() { need.done_when.iter().collect() } else { need.success.iter().collect() }; + let ends_badly: Vec<&String> = need.done_when.iter().filter(|s| !ends_well.contains(s)).collect(); + let mut moves = BTreeMap::new(); + for (i, stage) in line.iter().enumerate() { + let mut to: Vec = Vec::new(); + let mut j = i + 1; + loop { + match line.get(j) { + Some(next) => { + to.push((*next).clone()); + if !need.skippable.contains(next) { + break; + } + j += 1; + } + None => { + to.extend(ends_well.iter().map(|s| (*s).clone())); + break; + } + } + } + to.extend(ends_badly.iter().map(|s| (*s).clone())); + moves.insert((*stage).clone(), to); + } + moves +} + +fn humanize(id: &str) -> String { + let mut words = id.replace(['_', '-'], " "); + if let Some(first) = words.get_mut(0..1) { + first.make_ascii_uppercase(); + } + words +} + +fn public_followup(need: &PlanNeed) -> String { + format!("/received/{}", need.id) +} + +fn internal_page(need: &PlanNeed, group: &str) -> String { + format!("/{group}/{}", need.id) +} + +/// The words every page needs, as built-in fallbacks: plain, English, +/// 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 { + let form = |need: &PlanNeed| { + json!({ + "heading": need.wants, + "description": format!("For {}.", need.who), + "button": "Send", + "form_title": "Details", + "labels": need.collects.iter().map(|f| (f.name.clone(), Value::String(humanize(&f.name)))).collect::>(), + }) + }; + let mut words = BTreeMap::new(); + let public: Vec<&PlanNeed> = plan.needs.iter().filter(|n| n.enters_as.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::>(), + }), + ); + 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", + }), + ); + if let Some(group) = &need.enters_as { + let mut page = form(need); + page["question"] = json!(format!("{}?", humanize(&need.id))); + page["page_description"] = json!(""); + words.insert(format!("questions/{group}/{}.yaml", need.id), page); + } + } + for group in groups(plan) { + let buckets: Map = plan + .by_bucket() + .into_iter() + .filter(|(_, needs)| needs.iter().any(|n| n.handled_by == group)) + .map(|(bucket, needs)| { + let moves: Map = machine(needs[0]) + .into_iter() + .flat_map(|(from, tos)| tos.into_iter().map(move |to| (format!("{from}->{to}"), Value::String(humanize(&to))))) + .collect(); + (bucket.to_string(), json!({ "heading": humanize(bucket), "description": "", "moves": moves })) + }) + .collect(); + let links: Map = plan + .needs + .iter() + .filter(|n| n.enters_as.as_deref() == Some(group.as_str())) + .map(|n| (n.id.clone(), json!({ "heading": n.wants, "description": "", "button": "Open" }))) + .collect(); + words.insert( + format!("questions/{group}/index.yaml"), + json!({ "question": "What needs doing?", "description": "", "confirm": "Confirm", "buckets": buckets, "links": links }), + ); + } + words +} + +fn groups(plan: &Plan) -> BTreeSet { + plan.needs.iter().flat_map(|n| [Some(n.handled_by.clone()), n.enters_as.clone()]).flatten().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. +pub fn merge_words(fallback: &Value, given: &Value) -> Value { + match (fallback, given) { + (Value::Object(f), Value::Object(g)) => { + Value::Object(f.iter().map(|(k, v)| (k.clone(), g.get(k).map_or_else(|| v.clone(), |gv| merge_words(v, gv)))).collect()) + } + (Value::String(_), Value::String(s)) if !s.trim().is_empty() => Value::String(s.trim().to_string()), + _ => fallback.clone(), + } +} + +fn s(v: &Value) -> &str { + v.as_str().unwrap_or_default() +} + +fn form_alternative(need: &PlanNeed, w: &Value, action: &str) -> Value { + 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::>(), + }], + }) +} + +/// Every file of the content repo, from the plan and a set of words. +pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result> { + let mut files: BTreeMap = BTreeMap::new(); + let responsible = json!({ "name": plan.contact.name, "contact": plan.contact.email }); + + files.insert("site.yaml".into(), json!({ "title": plan.business, "lang": plan.language })); + + files.insert( + "aggregates.yaml".into(), + json!({ "aggregates": plan.by_bucket().into_iter().map(|(bucket, needs)| { + let need = needs[0]; + let initial = &need.stages[0]; + let first_event = if need.stages.iter().any(|st| st == "received") { "submitted" } else { "received" }; + json!({ + "bucket": bucket, + "initial": initial, + "states": need.stages.iter().map(|st| (st.clone(), json!({ "event": if st == initial { first_event } else { st.as_str() } }))).collect::>(), + "transitions": machine(need), + }) + }).collect::>() }), + ); + + // The front door: one alternative per need the public may bring. + let front = &words["questions/index.yaml"]; + let public: Vec<&PlanNeed> = plan.needs.iter().filter(|n| n.enters_as.is_none()).collect(); + 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::>(), + }), + ); + + 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]; + // 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"])] }, + ], + }), + ); + if let Some(group) = &need.enters_as { + let path = format!("questions/{group}/{}.yaml", need.id); + let w = &words[&path]; + files.insert( + path, + json!({ + // A way in, not an outcome, though it sits in a directory. + "followup": false, + "name": s(&w["question"]), + "description": s(&w["page_description"]), + "responsible": responsible, + "alternatives": [form_alternative(need, w, &format!("/{group}/{}-sent", need.id))], + }), + ); + } + } + + for group in groups(plan) { + let w = &words[&format!("questions/{group}/index.yaml")]; + let mut alternatives = Vec::new(); + for (bucket, needs) in plan.by_bucket() { + if !needs.iter().any(|n| n.handled_by == group) { + continue; + } + let bw = &w["buckets"][bucket]; + alternatives.push(json!({ + "name": s(&bw["heading"]), + "description": s(&bw["description"]), + "action": format!("/{group}"), + "consequence": [s(&w["confirm"])], + "features": [{ "name": "", "description": "", "resource": { + "source": { "kind": "kv", "bucket": bucket }, + "requires_group": group, + "transitions": machine(needs[0]).into_iter().flat_map(|(from, tos)| { + tos.into_iter().map(|to| json!({ "from": from, "to": to, "label": s(&bw["moves"][&format!("{from}->{to}")]) })).collect::>() + }).collect::>(), + }}], + })); + } + for need in plan.needs.iter().filter(|n| n.enters_as.as_deref() == Some(group.as_str())) { + let lw = &w["links"][&need.id]; + alternatives.push(json!({ + "name": s(&lw["heading"]), + "description": s(&lw["description"]), + "action": internal_page(need, &group), + "consequence": [s(&lw["button"])], + })); + } + files.insert(format!("questions/{group}/_section.yaml"), json!({ "qualifies": group })); + files.insert( + format!("questions/{group}/index.yaml"), + json!({ "name": s(&w["question"]), "description": s(&w["description"]), "responsible": responsible, "alternatives": alternatives }), + ); + } + + // The spec the verifier holds the site to. `via` is the heading the + // words gave that need, filled in mechanically. + files.insert( + "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()), + }; + 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), + }); + if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); } + need + }).collect::>(), + "personas": plan.personas, + }), + ); + + files.into_iter().map(|(path, value)| Ok((path, serde_yaml::to_string(&value)?))).collect() +} + +fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String { + let purpose = 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." + } 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") { + "a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next." + } else { + "a page only one role inside the business can open, holding one form." + }; + let context: Vec = plan + .needs + .iter() + .map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "))) + .collect(); + 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's wording as JSON, filled with placeholder text. Return the same JSON with every string rewritten. Keep every key exactly as it is; change only the string values.\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.", + plan.business, + plan.about.trim(), + plan.contact.name, + plan.contact.email, + plan.language, + context.join("\n"), + serde_json::to_string_pretty(fallback).unwrap_or_default(), + ) +} + +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 build --plan --out [--llm 'cmd'] [--lint question_lint]"; + let plan_path = value("--plan").ok_or_else(|| anyhow!(usage))?; + let out = std::path::PathBuf::from(value("--out").ok_or_else(|| anyhow!(usage))?); + let plan: Plan = serde_yaml::from_str(&std::fs::read_to_string(&plan_path).with_context(|| format!("reading {plan_path}"))?) + .with_context(|| format!("parsing {plan_path}"))?; + plan.validate()?; + + let mut words = fallback_words(&plan); + if let Some(cmd) = value("--llm") { + std::fs::create_dir_all(out.join("llm"))?; + for (n, (path, fallback)) in words.clone().iter().enumerate() { + let prompt = words_prompt(&plan, path, fallback); + let reply = crate::llm_with(&cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.4")]).unwrap_or_default(); + std::fs::write(out.join(format!("llm/{:02}-{}.md", n + 1, path.replace('/', "_"))), format!("{prompt}\n\n=== REPLY ===\n{reply}"))?; + match first_object(&reply) { + Some(given) => { + words.insert(path.clone(), merge_words(fallback, &given)); + eprintln!("words: {path}"); + } + None => eprintln!("words: {path} - no usable reply, keeping the fallback"), + } + } + } + write_repo(&out, render(&plan, &words)?)?; + println!("content repo written to {}", out.display()); + + if let Some(lint) = value("--lint") { + let output = std::process::Command::new(&lint).arg("--path").arg(out.join("questions")).output().with_context(|| format!("running {lint}"))?; + print!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + if !output.status.success() { + bail!("the verifier rejected a deterministically built site - that is a bug in the builder, not in the words"); + } + } + Ok(()) +} + +fn write_repo(out: &Path, files: BTreeMap) -> anyhow::Result<()> { + // The questions tree is rebuilt whole, so a page from an earlier + // plan never lingers. + let _ = std::fs::remove_dir_all(out.join("questions")); + for (path, body) in files { + let target = out.join(&path); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(target, body)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn need(stages: &[&str], done: &[&str], success: &[&str], skippable: &[&str]) -> PlanNeed { + let v = |xs: &[&str]| xs.iter().map(|x| x.to_string()).collect::>(); + PlanNeed { + id: "n".into(), who: "w".into(), wants: "x".into(), enters_as: None, + lands_in: "b".into(), handled_by: "g".into(), + stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable), + collects: vec![], max_fields: None, + } + } + + #[test] + fn the_machine_walks_the_line_and_can_be_turned_down_anywhere() { + let m = machine(&need(&["open", "surveyed", "quoted", "won", "lost"], &["won", "lost"], &["won"], &[])); + assert_eq!(m["open"], ["surveyed", "lost"]); + assert_eq!(m["surveyed"], ["quoted", "lost"]); + assert_eq!(m["quoted"], ["won", "lost"]); + assert!(!m.contains_key("won")); + } + + #[test] + fn a_skippable_stage_can_be_passed_over() { + let m = machine(&need(&["open", "awaiting_papers", "approved", "declined"], &["approved", "declined"], &["approved"], &["awaiting_papers"])); + assert_eq!(m["open"], ["awaiting_papers", "approved", "declined"]); + assert_eq!(m["awaiting_papers"], ["approved", "declined"]); + } + + #[test] + fn a_line_with_one_ending_has_no_way_to_be_turned_down() { + let m = machine(&need(&["open", "in_hand", "closed"], &["closed"], &["closed"], &[])); + assert_eq!(m["open"], ["in_hand"]); + assert_eq!(m["in_hand"], ["closed"]); + } + + #[test] + fn words_take_the_models_strings_and_never_its_structure() { + let fallback = json!({"question": "What?", "needs": {"a": {"heading": "A", "labels": {"email": "Email"}}}}); + let given = json!({"question": " Hva trenger du? ", "needs": {"a": {"heading": "", "labels": {"email": 7, "extra": "x"}}, "b": {"heading": "B"}}, "injected": "y"}); + let merged = merge_words(&fallback, &given); + 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"; + + #[test] + fn a_plan_renders_every_file_the_site_needs() { + let plan: Plan = serde_yaml::from_str(PLAN).unwrap(); + plan.validate().unwrap(); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + let paths: Vec<&str> = files.keys().map(String::as_str).collect(); + assert_eq!(paths, [ + "aggregates.yaml", "needs.yaml", + "questions/crew/_section.yaml", "questions/crew/index.yaml", "questions/crew/report-sent.yaml", "questions/crew/report.yaml", + "questions/hse/_section.yaml", "questions/hse/index.yaml", + "questions/index.yaml", + "questions/project_managers/_section.yaml", "questions/project_managers/index.yaml", + "questions/received/survey.yaml", "site.yaml", + ]); + // The crew's form is gated, a way in, and recorded for the safety officer's desk. + assert!(files["questions/crew/_section.yaml"].contains("qualifies: crew")); + assert!(files["questions/crew/report.yaml"].contains("followup: false")); + assert!(files["questions/hse/index.yaml"].contains("bucket: site_issues")); + 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("enters: /crew/report")); + } + + #[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 plan: Plan = serde_yaml::from_str(&clash).unwrap(); + assert!(plan.validate().unwrap_err().to_string().contains("must agree")); + } +} diff --git a/src/main.rs b/src/main.rs index 53339c1..814d0da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ use std::process::{Command, Stdio}; use anyhow::{anyhow, bail, Context}; +mod build; mod rate; mod simulate; @@ -47,6 +48,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("build") => return build::run(&argv), Some("rate") => return rate::rate(&argv), Some("prefer") => return rate::prefer(&argv), _ => {}