One binary: the lint and the trainer are commands of portal #7

Merged
bl merged 112 commits from one-binary into main 2026-09-30 13:33:14 +00:00
4 changed files with 390 additions and 107 deletions
Showing only changes of commit a2a9f4edcf - Show all commits
+255 -107
View File
@@ -39,6 +39,14 @@ pub struct Plan {
/// phone number first, so invite forms ask for one.
#[serde(default = "kanidm")]
pub people: String,
/// The storylines the site is told in, one tone of voice each:
/// "plain and brief, like a good notice on the door", "warm, as a
/// neighbour would put it". The words model writes every slot once
/// per tone, the content carries the words as lists, and portal
/// gives each visitor one storyline and counts what each brought
/// in (portal docs, Storylines). One tone, or none, is one story.
#[serde(default)]
pub tones: Vec<String>,
/// The sender of every mail the site sends (`mail:` in site.yaml).
/// Required as soon as any need has `mails:`.
#[serde(default)]
@@ -316,7 +324,7 @@ pub struct PlanNeed {
#[serde(deny_unknown_fields)]
pub struct Field {
pub name: String,
#[serde(default = "text", rename = "type")]
#[serde(default = "text_kind", rename = "type")]
pub kind: String,
#[serde(default)]
pub optional: bool,
@@ -367,7 +375,7 @@ pub fn escalated_stage(stage: &str) -> String {
/// of the bucket, no login.
pub const PUBLIC: &str = "public";
fn text() -> String {
fn text_kind() -> String {
"text".to_string()
}
@@ -392,6 +400,9 @@ impl Plan {
if !["kanidm", "memberships"].contains(&self.people.as_str()) {
bail!("people must be `kanidm` or `memberships`, not {:?}", self.people);
}
if self.tones.iter().any(|t| t.trim().is_empty()) {
bail!("tones: every tone says something about the voice; an empty one says nothing");
}
// A contact that is a group has to be one with a desk, or
// nobody could sign in as it.
if let Some(group) = &self.contact.group {
@@ -1507,10 +1518,41 @@ pub fn merge_words(fallback: &Value, given: &Value) -> Value {
(Value::String(_), Value::String(s)) if !s.trim().is_empty() && !s.contains(SLOT) => {
Value::String(s.trim().to_string())
}
// One string per storyline, every one of them said.
(Value::String(_) | Value::Array(_), Value::Array(items)) if !items.is_empty() && items.iter().all(|i| i.as_str().is_some_and(|x| !x.trim().is_empty() && !x.contains(SLOT))) => {
Value::Array(items.iter().map(|i| json!(i.as_str().unwrap_or_default().trim())).collect())
}
(Value::Array(_), Value::String(s)) if !s.trim().is_empty() && !s.contains(SLOT) => {
Value::String(s.trim().to_string())
}
_ => fallback.clone(),
}
}
/// One page's words as told in each tone, folded into one: a slot the
/// tones agree on stays one string, a slot they tell differently
/// becomes the list, in the tones' order.
pub fn fold_tones(told: &[Value]) -> Value {
match told.first() {
Some(Value::Object(first)) => Value::Object(
first
.keys()
.map(|k| (k.clone(), fold_tones(&told.iter().map(|t| t.get(k).cloned().unwrap_or(Value::Null)).collect::<Vec<_>>())))
.collect(),
),
Some(Value::String(_)) => {
let each: Vec<&str> = told.iter().map(|t| t.as_str().unwrap_or_default()).collect();
if each.iter().all(|x| *x == each[0]) {
json!(each[0])
} else {
Value::Array(each.iter().map(|x| json!(x)).collect())
}
}
Some(other) => other.clone(),
None => Value::Null,
}
}
const SLOT: &str = "<WRITE";
/// A model asked for Norwegian sometimes answers in English, and the
@@ -1653,14 +1695,45 @@ pub fn icon_name(given: &str) -> Option<String> {
ICONS.contains(&name).then(|| format!("lucide:{name}"))
}
/// A slot's words for the machinery's purposes: the string, or the
/// first storyline's when the slot holds several (see `text`).
fn s(v: &Value) -> &str {
v.as_str().unwrap_or_default()
match v {
Value::Array(items) => items.first().and_then(|x| x.as_str()).unwrap_or_default(),
other => other.as_str().unwrap_or_default(),
}
}
/// A slot's words as the content carries them: one string, or one
/// per storyline - portal reads a list as its storylines
/// (`docs/storylines.md`). Trimmed either way.
fn text(v: &Value) -> Value {
match v {
Value::Array(items) => Value::Array(items.iter().map(|x| json!(x.as_str().unwrap_or_default().trim())).collect()),
other => json!(other.as_str().unwrap_or_default().trim()),
}
}
/// The same slot, each storyline put through `f`.
fn text_map(v: &Value, f: impl Fn(&str) -> String) -> Value {
match v {
Value::Array(items) => Value::Array(items.iter().map(|x| json!(f(x.as_str().unwrap_or_default()))).collect()),
other => json!(f(other.as_str().unwrap_or_default())),
}
}
/// A one-button `consequence`: `[Send]`, or one list per storyline.
fn button(v: &Value) -> Value {
match v {
Value::Array(items) => Value::Array(items.iter().map(|x| json!([x.as_str().unwrap_or_default().trim()])).collect()),
other => json!([other.as_str().unwrap_or_default().trim()]),
}
}
fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Value {
let parent = plan.parent_of(need);
let mut requirements: Vec<Value> = need.collects.iter().map(|f| {
let mut r = json!({ "name": f.name, "label": s(&w["labels"][&f.name]), "type": f.kind });
let mut r = json!({ "name": f.name, "label": text(&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); }
@@ -1681,19 +1754,19 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
requirements.push(json!({ "name": parent.lands_in, "type": "record", "of": parent.lands_in, "value": "{key}" }));
}
let mut feature = json!({
"name": s(&w["form_title"]),
"name": text(&w["form_title"]),
"requirements": requirements,
});
if !s(&w["form_description"]).trim().is_empty() {
feature["description"] = json!(s(&w["form_description"]).trim());
feature["description"] = text(&w["form_description"]);
}
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() });
let strip = |title: &Value, body: &Value, icon: &str| {
let mut f = json!({ "name": text(title), "description": text(body) });
if let Some(icon) = icon_name(icon) { f["icon"] = json!(icon); }
f
};
@@ -1707,25 +1780,25 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
// never empty, so a word-driven condition would conjure a section
// the live page does not have and shift every feature after it.
if !need.requires.trim().is_empty() {
features.push(strip(s(&w["requires_title"]), s(&w["requires_description"]), s(&w["requires_icon"])));
features.push(strip(&w["requires_title"], &w["requires_description"], s(&w["requires_icon"])));
}
if !s(&w["road_description"]).trim().is_empty() {
features.push(strip(s(&w["road_title"]), s(&w["road_description"]), s(&w["road_icon"])));
features.push(strip(&w["road_title"], &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(strip(&w["who_title"], &w["who_description"], s(&w["who_icon"])));
}
features.push(feature);
let mut alt = json!({
"name": s(&w["heading"]),
"description": s(&w["description"]),
"name": text(&w["heading"]),
"description": text(&w["description"]),
"action": action,
"consequence": [s(&w["button"])],
"consequence": button(&w["button"]),
"record_as": need.lands_in,
"features": features,
});
if !s(&w["encouragement"]).trim().is_empty() {
alt["encouragements"] = json!([s(&w["encouragement"]).trim()]);
alt["encouragements"] = button(&w["encouragement"]);
}
alt
}
@@ -1745,7 +1818,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
site["mail"]["reply_to"] = json!(reply_to);
}
if let Some(invite) = words.get("site.yaml").map(|w| &w["invite"]).filter(|i| i.is_object()) {
site["mail"]["invite"] = json!({ "subject": s(&invite["subject"]), "body": s(&invite["body"]) });
site["mail"]["invite"] = json!({ "subject": text(&invite["subject"]), "body": text(&invite["body"]) });
}
}
files.insert("site.yaml".into(), site);
@@ -1772,7 +1845,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
("owner", Some(parent)) => format!("{{{}.email}}", parent.lands_in),
_ => to.clone(),
};
state["mail"] = json!({ "to": to, "subject": s(&w["subject"]), "body": s(&w["body"]) });
state["mail"] = json!({ "to": to, "subject": text(&w["subject"]), "body": text(&w["body"]) });
}
if let Some(group) = need.grants.get(st) {
state["grants"] = json!(group);
@@ -1798,7 +1871,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
// to trust the place before being asked for anything.
let front = &words["questions/index.yaml"];
let card = |w: &Value| {
let mut f = json!({ "name": s(&w["heading"]), "description": s(&w["description"]) });
let mut f = json!({ "name": text(&w["heading"]), "description": text(&w["description"]) });
if let Some(icon) = icon_name(s(&w["icon"])) {
f["icon"] = json!(icon);
}
@@ -1806,8 +1879,8 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
};
let does: Vec<Value> = front["opener"]["features"].as_object().into_iter().flatten().map(|(_, w)| card(w)).collect();
let mut alternatives: Vec<Value> = vec![json!({
"name": s(&front["opener"]["heading"]),
"description": s(&front["opener"]["description"]),
"name": text(&front["opener"]["heading"]),
"description": text(&front["opener"]["description"]),
"features": does,
})];
let mut shopping: Vec<Value> = plan
@@ -1824,15 +1897,15 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
for (bucket, needs) in public_buckets(plan) {
let w = &front["shopping"]["items"][&format!("status-{bucket}")];
let mut f = card(w);
let link = match s(&w["link"]).trim() { "" => s(&w["heading"]).trim(), l => l };
f["description"] = json!(format!("{} [{link}](/status/{bucket})", s(&w["description"]).trim()).trim().to_string());
let link = match s(&w["link"]).trim() { "" => s(&w["heading"]).trim().to_string(), l => l.to_string() };
f["description"] = text_map(&w["description"], |d| format!("{} [{link}](/status/{bucket})", d.trim()).trim().to_string());
f["resource"] = public_resource(bucket, &needs, s(&w["empty"]), Some(FRONT_DOOR_LATEST), s(&words[&public_view_path(bucket)]["answer"]));
shopping.push(f);
}
if !shopping.is_empty() {
alternatives.push(json!({
"name": s(&front["shopping"]["heading"]),
"description": s(&front["shopping"]["description"]),
"name": text(&front["shopping"]["heading"]),
"description": text(&front["shopping"]["description"]),
"features": shopping,
}));
}
@@ -1842,10 +1915,10 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
.map(|d| {
let dw = &front["doors"][&d.id];
let mut alt = json!({
"name": s(&dw["heading"]),
"description": s(&dw["description"]),
"name": text(&dw["heading"]),
"description": text(&dw["description"]),
"action": format!("/{}", d.id),
"consequence": [s(&dw["button"])],
"consequence": button(&dw["button"]),
});
// A door carries no form; its icon and "for whom" line
// ride an empty section, which portal renders as a
@@ -1873,7 +1946,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
}
files.insert(
"questions/index.yaml".into(),
json!({ "name": s(&front["question"]), "description": s(&front["description"]), "responsible": responsible, "alternatives": alternatives }),
json!({ "name": text(&front["question"]), "description": text(&front["description"]), "responsible": responsible, "alternatives": alternatives }),
);
// A door's page: a nav page (index.yaml), public, holding the
@@ -1884,18 +1957,18 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
files.insert(
path,
json!({
"name": s(&w["question"]),
"description": s(&w["description"]),
"name": text(&w["question"]),
"description": text(&w["description"]),
"responsible": responsible,
"alternatives": plan.needs_behind(&door.id).iter().map(|n| match &n.enters_as {
None => form_alternative(plan, n, &w["needs"][&n.id], &public_followup(n)),
// The way to the group's page: portal shows the
// sign-in gate there to whoever is not in the group.
Some(group) => json!({
"name": s(&w["needs"][&n.id]["heading"]),
"description": s(&w["needs"][&n.id]["description"]),
"name": text(&w["needs"][&n.id]["heading"]),
"description": text(&w["needs"][&n.id]["description"]),
"action": internal_page(n, group),
"consequence": [s(&w["needs"][&n.id]["button"])],
"consequence": button(&w["needs"][&n.id]["button"]),
}),
}).collect::<Vec<_>>(),
}),
@@ -1915,8 +1988,8 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
files.insert(
path,
json!({
"name": s(&w["question"]),
"description": s(&w["description"]),
"name": text(&w["question"]),
"description": text(&w["description"]),
"responsible": responsible,
"alternatives": answers.iter().map(|n| form_alternative(plan, n, &w["forms"][&n.id], &public_followup(n))).collect::<Vec<_>>(),
}),
@@ -1934,16 +2007,16 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
};
let w = &words[&path];
let mut alternatives = vec![
json!({ "name": s(&w["next_heading"]), "description": s(&w["next_description"]) }),
json!({ "name": text(&w["next_heading"]), "description": text(&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"])],
"name": text(&w["see_all"]["heading"]), "description": text(&w["see_all"]["description"]),
"action": format!("/status/{}", need.lands_in), "consequence": button(&w["see_all"]["button"]),
}));
}
// Nested and not an index: portal infers a followup page.
files.insert(path, json!({ "name": s(&w["question"]), "description": s(&w["description"]), "alternatives": alternatives }));
files.insert(path, json!({ "name": text(&w["question"]), "description": text(&w["description"]), "alternatives": alternatives }));
if let Some(group) = &need.enters_as {
let path = format!("questions/{group}/{}.yaml", need.id);
let w = &words[&path];
@@ -1952,8 +2025,8 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
json!({
// A way in, not an outcome, though it sits in a directory.
"followup": false,
"name": s(&w["question"]),
"description": s(&w["page_description"]),
"name": text(&w["question"]),
"description": text(&w["page_description"]),
"responsible": responsible,
"alternatives": [form_alternative(plan, need, w, &format!("/{group}/{}-sent", need.id))],
}),
@@ -1976,7 +2049,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
// other groups' desks with theirs; a watcher's list
// is empty, which portal renders read-only.
"transitions": moves_of(needs[0], &group).into_iter().map(|(from, to)| {
json!({ "from": from, "to": to, "label": s(&bw["moves"][&format!("{from}->{to}")]) })
json!({ "from": from, "to": to, "label": text(&bw["moves"][&format!("{from}->{to}")]) })
}).collect::<Vec<_>>(),
});
// A group that has the bucket only because cases escalate
@@ -1986,20 +2059,20 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
resource["states"] = json!(escalated);
}
alternatives.push(json!({
"name": s(&bw["heading"]),
"description": s(&bw["description"]),
"name": text(&bw["heading"]),
"description": text(&bw["description"]),
"action": format!("/{group}"),
"consequence": [s(&w["confirm"])],
"features": [{ "name": s(&bw["list_title"]), "description": s(&bw["list_note"]), "resource": resource }],
"consequence": button(&w["confirm"]),
"features": [{ "name": text(&bw["list_title"]), "description": text(&bw["list_note"]), "resource": resource }],
}));
}
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"]),
"name": text(&lw["heading"]),
"description": text(&lw["description"]),
"action": internal_page(need, &group),
"consequence": [s(&lw["button"])],
"consequence": button(&lw["button"]),
}));
}
// Invites this desk may extend: `invite: {group}` is portal's
@@ -2012,21 +2085,21 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
for target in targets {
let iw = &inv[target];
alternatives.push(json!({
"name": s(&iw["heading"]),
"description": s(&iw["description"]),
"name": text(&iw["heading"]),
"description": text(&iw["description"]),
"action": format!("/{group}"),
"consequence": [s(&iw["button"])],
"consequence": button(&iw["button"]),
"invite": { "group": target },
"features": [{ "name": s(&iw["form_title"]), "description": s(&iw["form_description"]), "requirements": if plan.memberships() {
"features": [{ "name": text(&iw["form_title"]), "description": text(&iw["form_description"]), "requirements": if plan.memberships() {
json!([
{ "name": "name", "label": s(&iw["labels"]["name"]) },
{ "name": "phone", "label": s(&iw["labels"]["phone"]), "type": "tel" },
{ "name": "email", "label": s(&iw["labels"]["email"]), "type": "email", "optional": true },
{ "name": "name", "label": text(&iw["labels"]["name"]) },
{ "name": "phone", "label": text(&iw["labels"]["phone"]), "type": "tel" },
{ "name": "email", "label": text(&iw["labels"]["email"]), "type": "email", "optional": true },
])
} else {
json!([
{ "name": "name", "label": s(&iw["labels"]["name"]) },
{ "name": "email", "label": s(&iw["labels"]["email"]), "type": "email" },
{ "name": "name", "label": text(&iw["labels"]["name"]) },
{ "name": "email", "label": text(&iw["labels"]["email"]), "type": "email" },
])
}}],
}));
@@ -2036,23 +2109,23 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
// runs out; with memberships the membership is the case.
let (bucket, transitions) = if plan.memberships() {
("portal_memberships", json!([
{ "from": "invited", "to": "removed", "label": s(&lw["withdraw"]) },
{ "from": "active", "to": "removed", "label": s(&lw["done"]) },
{ "from": "removed", "to": "invited", "label": s(&lw["again"]) },
{ "from": "invited", "to": "removed", "label": text(&lw["withdraw"]) },
{ "from": "active", "to": "removed", "label": text(&lw["done"]) },
{ "from": "removed", "to": "invited", "label": text(&lw["again"]) },
]))
} else {
("portal_invites", json!([
{ "from": "open", "to": "done", "label": s(&lw["done"]) },
{ "from": "expired", "to": "open", "label": s(&lw["again"]) },
{ "from": "expired", "to": "done", "label": s(&lw["done"]) },
{ "from": "open", "to": "done", "label": text(&lw["done"]) },
{ "from": "expired", "to": "open", "label": text(&lw["again"]) },
{ "from": "expired", "to": "done", "label": text(&lw["done"]) },
]))
};
alternatives.push(json!({
"name": s(&lw["heading"]),
"description": s(&lw["description"]),
"name": text(&lw["heading"]),
"description": text(&lw["description"]),
"action": format!("/{group}"),
"consequence": [s(&w["confirm"])],
"features": [{ "name": s(&lw["list_title"]), "description": s(&lw["list_note"]), "resource": {
"consequence": button(&w["confirm"]),
"features": [{ "name": text(&lw["list_title"]), "description": text(&lw["list_note"]), "resource": {
"source": { "kind": "kv", "bucket": bucket },
"requires_group": group,
"transitions": transitions,
@@ -2063,19 +2136,19 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
// done or declined.
if let Some(sw) = w["suggestions"].as_object() {
alternatives.push(json!({
"name": s(&sw["heading"]),
"description": s(&sw["description"]),
"name": text(&sw["heading"]),
"description": text(&sw["description"]),
"action": format!("/{group}"),
"consequence": [s(&w["confirm"])],
"features": [{ "name": s(&sw["list_title"]), "description": s(&sw["list_note"]), "resource": {
"consequence": button(&w["confirm"]),
"features": [{ "name": text(&sw["list_title"]), "description": text(&sw["list_note"]), "resource": {
"source": { "kind": "kv", "bucket": "portal_edit_suggestions" },
"requires_group": group,
"transitions": [
{ "from": "open", "to": "discussed", "label": s(&sw["looking"]) },
{ "from": "open", "to": "done", "label": s(&sw["changed"]) },
{ "from": "open", "to": "declined", "label": s(&sw["declined"]) },
{ "from": "discussed", "to": "done", "label": s(&sw["changed"]) },
{ "from": "discussed", "to": "declined", "label": s(&sw["declined"]) },
{ "from": "open", "to": "discussed", "label": text(&sw["looking"]) },
{ "from": "open", "to": "done", "label": text(&sw["changed"]) },
{ "from": "open", "to": "declined", "label": text(&sw["declined"]) },
{ "from": "discussed", "to": "done", "label": text(&sw["changed"]) },
{ "from": "discussed", "to": "declined", "label": text(&sw["declined"]) },
],
}}],
}));
@@ -2083,7 +2156,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
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 }),
json!({ "name": text(&w["question"]), "description": text(&w["description"]), "responsible": responsible, "alternatives": alternatives }),
);
}
@@ -2096,14 +2169,14 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
files.insert(
path,
json!({
"name": s(&w["question"]),
"description": s(&w["description"]),
"name": text(&w["question"]),
"description": text(&w["description"]),
"responsible": responsible,
"alternatives": [
{
"name": s(&w["heading"]),
"description": s(&w["list_description"]),
"features": [{ "name": s(&w["list_title"]), "description": s(&w["list_note"]), "resource": public_resource(bucket, &needs, s(&w["empty"]), None, s(&w["answer"])) }],
"name": text(&w["heading"]),
"description": text(&w["list_description"]),
"features": [{ "name": text(&w["list_title"]), "description": text(&w["list_note"]), "resource": public_resource(bucket, &needs, s(&w["empty"]), None, s(&w["answer"])) }],
},
],
}),
@@ -2133,12 +2206,12 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
files.insert(
path,
json!({
"name": s(&w["question"]),
"description": s(&w["description"]),
"name": text(&w["question"]),
"description": text(&w["description"]),
"alternatives": targets.iter().map(|to| json!({
"name": s(&w["moves"][to.as_str()]["heading"]),
"description": s(&w["moves"][to.as_str()]["description"]),
"self_transition": { "bucket": need.lands_in, "to": to, "from": from_for(to), "label": s(&w["moves"][to.as_str()]["button"]) },
"name": text(&w["moves"][to.as_str()]["heading"]),
"description": text(&w["moves"][to.as_str()]["description"]),
"self_transition": { "bucket": need.lands_in, "to": to, "from": from_for(to), "label": text(&w["moves"][to.as_str()]["button"]) },
})).collect::<Vec<_>>(),
}),
);
@@ -2173,13 +2246,18 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
need
}).collect::<Vec<_>>(),
"personas": plan.personas,
"tones": plan.tones,
}),
);
files.into_iter().map(|(path, value)| Ok((path, serde_yaml::to_string(&value)?))).collect()
}
fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -> String {
let voice = match tone {
Some(tone) => format!("\n\nTone of voice for every word on this page: {tone}. The site is told in more than one tone, each written in a pass of its own, so keep to this one throughout and do not hedge between tones."),
None => String::new(),
};
let shown_only = public_buckets(plan)
.into_iter()
.find(|(bucket, _)| public_view_path(bucket) == path)
@@ -2222,7 +2300,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
.map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::<Vec<_>>().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- 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.",
"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.{voice}\n\n{}\n\nReply with the JSON object only.",
plan.business,
plan.about.trim(),
plan.contact.name,
@@ -2282,21 +2360,35 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
if only.as_ref().is_some_and(|p| !path.starts_with(p.as_str())) {
continue;
}
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) => {
let merged = merge_words(fallback, &given);
match wrong_language(&plan.language, &given) {
Some(found) => eprintln!("words: {path} - the reply reads as {found} for a {} plan, keeping the words it had", plan.language),
None => {
words.insert(path.clone(), merged);
eprintln!("words: {path}");
// Once per tone, or once: each tone's page is written whole,
// so a storyline is consistent within itself, and the tones
// are folded into lists where they differ.
let tones: Vec<Option<&str>> = if plan.tones.is_empty() { vec![None] } else { plan.tones.iter().map(|t| Some(t.as_str())).collect() };
let mut told: Vec<Value> = Vec::new();
for (t, tone) in tones.iter().enumerate() {
let prompt = words_prompt(&plan, path, fallback, *tone);
let reply = crate::llm_with(&cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.4")]).unwrap_or_default();
let suffix = if tones.len() > 1 { format!("-tone{}", t + 1) } else { String::new() };
std::fs::write(out.join(format!("llm/{:02}-{}{suffix}.md", n + 1, path.replace('/', "_"))), format!("{prompt}\n\n=== REPLY ===\n{reply}"))?;
match first_object(&reply) {
Some(given) => match wrong_language(&plan.language, &given) {
Some(found) => {
eprintln!("words: {path} - the reply reads as {found} for a {} plan, keeping the words it had", plan.language);
told.clear();
break;
}
None => told.push(merge_words(fallback, &given)),
},
None => {
eprintln!("words: {path} - no usable reply, keeping the fallback");
told.clear();
break;
}
}
None => eprintln!("words: {path} - no usable reply, keeping the fallback"),
}
if !told.is_empty() {
words.insert(path.clone(), fold_tones(&told));
eprintln!("words: {path}");
}
}
}
@@ -2860,7 +2952,7 @@ pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result<BTreeMap<String, V
let tokened: BTreeMap<String, Value> =
fallback.iter().map(|(path, v)| (path.clone(), tokenize(v, path, ""))).collect();
let rendered = render(plan, &tokened)?;
let mut found: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut found: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
for (path, text) in &rendered {
// The spec is derived from the words (a need's `via` is its
// heading), never a source of them: behind a door it named the
@@ -3008,18 +3100,18 @@ fn tokenize(v: &Value, path: &str, pointer: &str) -> Value {
}
}
fn untokenize(v: &Value, path: &str, pointer: &str, found: &std::collections::HashMap<String, String>) -> Value {
fn untokenize(v: &Value, path: &str, pointer: &str, found: &std::collections::HashMap<String, Value>) -> Value {
match v {
Value::Object(m) => Value::Object(m.iter().map(|(k, x)| (k.clone(), untokenize(x, path, &format!("{pointer}/{k}"), found))).collect()),
Value::String(_) => match found.get(&token(path, pointer)) {
Some(word) => Value::String(word.clone()),
Some(word) => word.clone(),
None => v.clone(),
},
other => other.clone(),
}
}
fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std::collections::HashMap<String, String>) {
fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std::collections::HashMap<String, Value>) {
match (a, b) {
(serde_yaml::Value::Mapping(x), serde_yaml::Value::Mapping(y)) => {
for (k, va) in x {
@@ -3036,6 +3128,19 @@ fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std::
// door's three headings on three unrelated new cards. Items
// with no identity (an information feature) fall back to
// position.
// A live list of lists under a rendered list of tokens is the
// storylines of a button list (`consequence: [[Send], [Meld meg]]`):
// each token gets one word per storyline.
(serde_yaml::Value::Sequence(x), serde_yaml::Value::Sequence(y))
if x.iter().all(|t| t.as_str().is_some_and(|t| t.starts_with('\u{1}'))) && !y.is_empty() && y.iter().all(|v| v.as_sequence().is_some_and(|l| l.iter().all(|w| w.as_str().is_some()))) =>
{
for (k, t) in x.iter().enumerate() {
let words: Vec<&str> = y.iter().filter_map(|l| l.as_sequence()?.get(k)?.as_str()).collect();
if words.len() == y.len() && words.iter().all(|w| !w.trim().is_empty()) {
found.insert(t.as_str().unwrap_or_default().to_string(), Value::Array(words.iter().map(|w| json!(w)).collect()));
}
}
}
(serde_yaml::Value::Sequence(x), serde_yaml::Value::Sequence(y)) => {
let mut used = vec![false; y.len()];
for (i, va) in x.iter().enumerate() {
@@ -3071,9 +3176,13 @@ fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std::
// that has since been given something to say would be
// emptied again by every rebuild.
if !word.trim().is_empty() {
found.insert(t.clone(), word.to_string());
found.insert(t.clone(), json!(word));
}
}
// A live list of strings under one token: the slot's storylines.
(serde_yaml::Value::String(t), serde_yaml::Value::Sequence(live)) if t.starts_with('\u{1}') && !live.is_empty() && live.iter().all(|w| w.as_str().is_some_and(|w| !w.trim().is_empty())) => {
found.insert(t.clone(), Value::Array(live.iter().map(|w| json!(w.as_str().unwrap_or_default())).collect()));
}
_ => {}
}
}
@@ -3119,6 +3228,45 @@ mod invite_tests {
assert!(nobody.validate().unwrap_err().to_string().contains("no desk"));
}
#[test]
fn two_tones_become_two_storylines_that_render_lift_and_render_again() {
let plan: Plan = serde_yaml::from_str(&PLAN.replace("business: Vel", "business: Vel\ntones:\n - plain, like a notice on the door\n - warm, as a neighbour would put it")).unwrap();
plan.validate().unwrap();
// The words as two tones told them, folded: agreed slots stay
// one string, others become the list.
let mut words = fallback_words(&plan);
let page = words["questions/index.yaml"].clone();
let mut warm = page.clone();
warm["needs"]["report"]["heading"] = json!("Say where it is dangerous");
warm["needs"]["report"]["button"] = json!("Tell us");
let folded = fold_tones(&[page.clone(), warm]);
assert_eq!(folded["needs"]["report"]["heading"], json!([page["needs"]["report"]["heading"], "Say where it is dangerous"]));
assert_eq!(folded["question"], page["question"], "a slot the tones agree on stays one string");
words.insert("questions/index.yaml".into(), folded);
// Rendered, a list is a list, and a button list a list of lists.
let files = render(&plan, &words).unwrap();
let front: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap();
let card = front["alternatives"].as_array().unwrap().iter().find(|a| a["record_as"] == "hazards").unwrap();
assert_eq!(card["name"], json!([page["needs"]["report"]["heading"], "Say where it is dangerous"]));
assert_eq!(card["consequence"], json!([[page["needs"]["report"]["button"]], ["Tell us"]]));
assert!(files["needs.yaml"].contains("tones:"));
// Lifted back out of the rendered site, the storylines survive.
let repo = std::env::temp_dir().join(format!("trainer-tones-{}", std::process::id()));
for (path, text) in &files {
let full = repo.join(path);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(full, text).unwrap();
}
let lifted = lift_words(&plan, &repo).unwrap();
assert_eq!(lifted["questions/index.yaml"]["needs"]["report"]["heading"], json!([page["needs"]["report"]["heading"], "Say where it is dangerous"]));
assert_eq!(lifted["questions/index.yaml"]["needs"]["report"]["button"], json!([page["needs"]["report"]["button"], "Tell us"]));
assert_eq!(render(&plan, &lifted).unwrap()["questions/index.yaml"], files["questions/index.yaml"]);
let _ = std::fs::remove_dir_all(&repo);
// An empty tone says nothing.
let blank: Plan = serde_yaml::from_str(&PLAN.replace("business: Vel", "business: Vel\ntones: [\"\"]")).unwrap();
assert!(blank.validate().unwrap_err().to_string().contains("tones"));
}
#[test]
fn with_memberships_a_desk_invites_by_phone_and_lists_who_is_in() {
let plan: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]\n", "invites_by: [board]\npeople: memberships\n")).unwrap();
+2
View File
@@ -26,6 +26,7 @@ mod build;
mod card;
mod plan;
mod rate;
mod report;
mod score;
mod simulate;
@@ -54,6 +55,7 @@ fn main() -> anyhow::Result<()> {
Some("plan") => return plan::run(&argv),
Some("build") => return build::run(&argv),
Some("rate") => return rate::rate(&argv),
Some("report") => return report::run(&argv),
Some("prefer") => return rate::prefer(&argv),
Some("score") => return score::run(&argv),
_ => {}
+6
View File
@@ -169,6 +169,12 @@ rest. Each is one line in the plan, and the builder does the wiring:
behind sign-in, with name and address filled from the account: a
seat on a committee for members, a discount for customers. The
group is one a decision `grants`, so nobody has to be waved in.
- **Tell it two ways and find out.** `tones` lists tones of voice, one
storyline each: every word on the site is written once per tone, a
visitor reads one storyline throughout, and the site counts what
each brought in. Two is plenty; name them as a person would ("plain,
like a good notice on the door"; "warm, as a neighbour would put
it"). Leave it out to tell one story.
- **Keep the people in portal.** `people: memberships` keeps every
group in portal itself: a desk invite is a place held until the
person signs in, a person is their phone number first, and any
+127
View File
@@ -0,0 +1,127 @@
//! What a live site's storylines did, read back as counts.
//!
//! `portal-trainer report --site https://vel.example [--needs needs.yaml]`
//! fetches the site's `/report` (portal docs, Storylines): per bucket,
//! per storyline, per state, a count and nothing else. With the site's
//! `needs.yaml` the states that end well are known, and each storyline
//! is scored on how many cases it brought in and how many of those
//! ended well. Storylines are assigned evenly, so the counts compare
//! directly. This is the live half of what the scorer measures offline
//! with personas: the same tones, judged by what people did.
use std::collections::BTreeMap;
use anyhow::{anyhow, bail, Context};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Report {
storylines: usize,
/// bucket -> storyline -> state -> count
buckets: BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>>,
}
#[derive(Debug, Default, Deserialize)]
struct Needs {
#[serde(default)]
needs: Vec<Need>,
#[serde(default)]
tones: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct Need {
lands_in: String,
#[serde(default)]
done_when: Vec<String>,
#[serde(default)]
success: Vec<String>,
}
/// One storyline's showing in one bucket.
#[derive(Debug, Default, PartialEq)]
struct Showing {
arrived: u64,
ended_well: u64,
}
/// Per bucket, per storyline index, how many arrived and how many
/// ended in a state the need calls success (or, without `success`,
/// any finished state).
fn showings(counts: &BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>>, needs: &[Need]) -> BTreeMap<String, Vec<Showing>> {
let mut out = BTreeMap::new();
for (bucket, per_story) in counts {
let well: Vec<&str> = needs
.iter()
.filter(|n| &n.lands_in == bucket)
.flat_map(|n| if n.success.is_empty() { n.done_when.iter() } else { n.success.iter() })
.map(String::as_str)
.collect();
let arity = per_story.keys().filter_map(|k| k.parse::<usize>().ok()).max().map(|m| m + 1).unwrap_or(1);
let mut rows: Vec<Showing> = (0..arity).map(|_| Showing::default()).collect();
for (story, states) in per_story {
let Ok(n) = story.parse::<usize>() else { continue };
for (state, count) in states {
rows[n].arrived += count;
if well.contains(&state.as_str()) {
rows[n].ended_well += count;
}
}
}
out.insert(bucket.clone(), rows);
}
out
}
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 site = value("--site").ok_or_else(|| anyhow!("usage: portal-trainer report --site <https://the.site> [--needs <needs.yaml>]"))?;
let url = format!("{}/report", site.trim_end_matches('/'));
let fetched = std::process::Command::new("curl").args(["-sSf", "--max-time", "30", &url]).output().context("running curl")?;
if !fetched.status.success() {
bail!("could not read {url}: {}", String::from_utf8_lossy(&fetched.stderr).trim());
}
let report: Report = serde_json::from_slice(&fetched.stdout).with_context(|| format!("reading {url}"))?;
let needs: Needs = match value("--needs") {
Some(path) => serde_yaml::from_str(&std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?)?,
None => Needs::default(),
};
println!("{site}: {} storyline(s)", report.storylines);
for (i, tone) in needs.tones.iter().enumerate() {
println!(" [{i}] {tone}");
}
if report.buckets.is_empty() {
println!("nothing sent in yet");
return Ok(());
}
let rows = showings(&report.buckets, &needs.needs);
for (bucket, per_story) in &rows {
println!("\n{bucket}");
for (n, showing) in per_story.iter().enumerate() {
let rate = if showing.arrived > 0 { format!("{:.0}%", 100.0 * showing.ended_well as f64 / showing.arrived as f64) } else { "-".to_string() };
println!(" storyline {n}: {} arrived, {} ended well ({rate})", showing.arrived, showing.ended_well);
}
if per_story.len() > 1 {
let best = per_story.iter().enumerate().max_by_key(|(_, s)| (s.ended_well, s.arrived)).map(|(n, _)| n).unwrap_or(0);
println!(" most so far: storyline {best}");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_storyline_is_scored_on_what_arrived_and_what_ended_well() {
let counts: BTreeMap<String, BTreeMap<String, BTreeMap<String, u64>>> =
serde_json::from_str(r#"{"hazards": {"0": {"open": 3, "fixed": 4}, "1": {"open": 1, "fixed": 9, "dropped": 2}}}"#).unwrap();
let needs = vec![Need { lands_in: "hazards".into(), done_when: vec!["fixed".into(), "dropped".into()], success: vec!["fixed".into()] }];
let rows = showings(&counts, &needs);
assert_eq!(rows["hazards"][0], Showing { arrived: 7, ended_well: 4 });
assert_eq!(rows["hazards"][1], Showing { arrived: 12, ended_well: 9 });
// Without a success list, any finished state counts.
let any = vec![Need { lands_in: "hazards".into(), done_when: vec!["fixed".into(), "dropped".into()], success: vec![] }];
assert_eq!(showings(&counts, &any)["hazards"][1].ended_well, 11);
}
}