617 lines
28 KiB
Rust
617 lines
28 KiB
Rust
//! 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<PlanNeed>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub personas: Vec<Value>,
|
||
|
|
}
|
||
|
|
|
||
|
|
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<String>,
|
||
|
|
pub lands_in: String,
|
||
|
|
pub handled_by: String,
|
||
|
|
/// Every stage, first to last, finished states included.
|
||
|
|
pub stages: Vec<String>,
|
||
|
|
pub done_when: Vec<String>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub success: Vec<String>,
|
||
|
|
/// Stages a case may pass over ("awaiting papers" when the papers
|
||
|
|
/// were complete).
|
||
|
|
#[serde(default)]
|
||
|
|
pub skippable: Vec<String>,
|
||
|
|
pub collects: Vec<Field>,
|
||
|
|
#[serde(default)]
|
||
|
|
pub max_fields: Option<usize>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[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<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
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<String, Vec<String>> {
|
||
|
|
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<String> = 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<String, Value> {
|
||
|
|
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::<Map<_, _>>(),
|
||
|
|
})
|
||
|
|
};
|
||
|
|
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::<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",
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
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<String, Value> = plan
|
||
|
|
.by_bucket()
|
||
|
|
.into_iter()
|
||
|
|
.filter(|(_, needs)| needs.iter().any(|n| n.handled_by == group))
|
||
|
|
.map(|(bucket, needs)| {
|
||
|
|
let moves: Map<String, Value> = 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<String, Value> = 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<String> {
|
||
|
|
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::<Vec<_>>(),
|
||
|
|
}],
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Every file of the content repo, from the plan and a set of words.
|
||
|
|
pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BTreeMap<String, String>> {
|
||
|
|
let mut files: BTreeMap<String, Value> = 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::<Map<_, _>>(),
|
||
|
|
"transitions": machine(need),
|
||
|
|
})
|
||
|
|
}).collect::<Vec<_>>() }),
|
||
|
|
);
|
||
|
|
|
||
|
|
// 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::<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];
|
||
|
|
// 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::<Vec<_>>()
|
||
|
|
}).collect::<Vec<_>>(),
|
||
|
|
}}],
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
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::<Vec<_>>(),
|
||
|
|
"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<String> = 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 <plan.yaml> --out <dir> [--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<String, String>) -> 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::<Vec<_>>();
|
||
|
|
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"));
|
||
|
|
}
|
||
|
|
}
|