Tones and storylines: every word once per tone, kept through a lift, and a report of what each brought in
tones in the plan lists tones of voice, one storyline each. The words pass writes each page once per tone and folds the tones into lists where they differ; the render emits the lists portal reads as storylines; the lift carries them back. needs.yaml names the tones. portal-trainer report --site reads the site's counts per storyline and scores each on what arrived and what ended well. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
ca73ef7e9f
commit
a2a9f4edcf
+254
-106
@@ -39,6 +39,14 @@ pub struct Plan {
|
|||||||
/// phone number first, so invite forms ask for one.
|
/// phone number first, so invite forms ask for one.
|
||||||
#[serde(default = "kanidm")]
|
#[serde(default = "kanidm")]
|
||||||
pub people: String,
|
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).
|
/// The sender of every mail the site sends (`mail:` in site.yaml).
|
||||||
/// Required as soon as any need has `mails:`.
|
/// Required as soon as any need has `mails:`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -316,7 +324,7 @@ pub struct PlanNeed {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct Field {
|
pub struct Field {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(default = "text", rename = "type")]
|
#[serde(default = "text_kind", rename = "type")]
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub optional: bool,
|
pub optional: bool,
|
||||||
@@ -367,7 +375,7 @@ pub fn escalated_stage(stage: &str) -> String {
|
|||||||
/// of the bucket, no login.
|
/// of the bucket, no login.
|
||||||
pub const PUBLIC: &str = "public";
|
pub const PUBLIC: &str = "public";
|
||||||
|
|
||||||
fn text() -> String {
|
fn text_kind() -> String {
|
||||||
"text".to_string()
|
"text".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,6 +400,9 @@ impl Plan {
|
|||||||
if !["kanidm", "memberships"].contains(&self.people.as_str()) {
|
if !["kanidm", "memberships"].contains(&self.people.as_str()) {
|
||||||
bail!("people must be `kanidm` or `memberships`, not {:?}", self.people);
|
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
|
// A contact that is a group has to be one with a desk, or
|
||||||
// nobody could sign in as it.
|
// nobody could sign in as it.
|
||||||
if let Some(group) = &self.contact.group {
|
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(_), Value::String(s)) if !s.trim().is_empty() && !s.contains(SLOT) => {
|
||||||
Value::String(s.trim().to_string())
|
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(),
|
_ => 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";
|
const SLOT: &str = "<WRITE";
|
||||||
|
|
||||||
/// A model asked for Norwegian sometimes answers in English, and the
|
/// 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}"))
|
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 {
|
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 {
|
fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Value {
|
||||||
let parent = plan.parent_of(need);
|
let parent = plan.parent_of(need);
|
||||||
let mut requirements: Vec<Value> = need.collects.iter().map(|f| {
|
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.optional { r["optional"] = json!(true); }
|
||||||
if f.kind == "select" { r["options"] = json!(select_options(plan, f)); }
|
if f.kind == "select" { r["options"] = json!(select_options(plan, f)); }
|
||||||
if f.multiple { r["multiple"] = json!(true); }
|
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}" }));
|
requirements.push(json!({ "name": parent.lands_in, "type": "record", "of": parent.lands_in, "value": "{key}" }));
|
||||||
}
|
}
|
||||||
let mut feature = json!({
|
let mut feature = json!({
|
||||||
"name": s(&w["form_title"]),
|
"name": text(&w["form_title"]),
|
||||||
"requirements": requirements,
|
"requirements": requirements,
|
||||||
});
|
});
|
||||||
if !s(&w["form_description"]).trim().is_empty() {
|
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"])) {
|
if let Some(icon) = icon_name(s(&w["icon"])) {
|
||||||
feature["icon"] = json!(icon);
|
feature["icon"] = json!(icon);
|
||||||
}
|
}
|
||||||
// Context first, then the form: the reader learns where this goes
|
// Context first, then the form: the reader learns where this goes
|
||||||
// and who is at the other end before being asked for anything.
|
// and who is at the other end before being asked for anything.
|
||||||
let strip = |title: &str, text: &str, icon: &str| {
|
let strip = |title: &Value, body: &Value, icon: &str| {
|
||||||
let mut f = json!({ "name": title.trim(), "description": text.trim() });
|
let mut f = json!({ "name": text(title), "description": text(body) });
|
||||||
if let Some(icon) = icon_name(icon) { f["icon"] = json!(icon); }
|
if let Some(icon) = icon_name(icon) { f["icon"] = json!(icon); }
|
||||||
f
|
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
|
// never empty, so a word-driven condition would conjure a section
|
||||||
// the live page does not have and shift every feature after it.
|
// the live page does not have and shift every feature after it.
|
||||||
if !need.requires.trim().is_empty() {
|
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() {
|
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() {
|
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);
|
features.push(feature);
|
||||||
let mut alt = json!({
|
let mut alt = json!({
|
||||||
"name": s(&w["heading"]),
|
"name": text(&w["heading"]),
|
||||||
"description": s(&w["description"]),
|
"description": text(&w["description"]),
|
||||||
"action": action,
|
"action": action,
|
||||||
"consequence": [s(&w["button"])],
|
"consequence": button(&w["button"]),
|
||||||
"record_as": need.lands_in,
|
"record_as": need.lands_in,
|
||||||
"features": features,
|
"features": features,
|
||||||
});
|
});
|
||||||
if !s(&w["encouragement"]).trim().is_empty() {
|
if !s(&w["encouragement"]).trim().is_empty() {
|
||||||
alt["encouragements"] = json!([s(&w["encouragement"]).trim()]);
|
alt["encouragements"] = button(&w["encouragement"]);
|
||||||
}
|
}
|
||||||
alt
|
alt
|
||||||
}
|
}
|
||||||
@@ -1745,7 +1818,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
|||||||
site["mail"]["reply_to"] = json!(reply_to);
|
site["mail"]["reply_to"] = json!(reply_to);
|
||||||
}
|
}
|
||||||
if let Some(invite) = words.get("site.yaml").map(|w| &w["invite"]).filter(|i| i.is_object()) {
|
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);
|
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),
|
("owner", Some(parent)) => format!("{{{}.email}}", parent.lands_in),
|
||||||
_ => to.clone(),
|
_ => 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) {
|
if let Some(group) = need.grants.get(st) {
|
||||||
state["grants"] = json!(group);
|
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.
|
// to trust the place before being asked for anything.
|
||||||
let front = &words["questions/index.yaml"];
|
let front = &words["questions/index.yaml"];
|
||||||
let card = |w: &Value| {
|
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"])) {
|
if let Some(icon) = icon_name(s(&w["icon"])) {
|
||||||
f["icon"] = json!(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 does: Vec<Value> = front["opener"]["features"].as_object().into_iter().flatten().map(|(_, w)| card(w)).collect();
|
||||||
let mut alternatives: Vec<Value> = vec![json!({
|
let mut alternatives: Vec<Value> = vec![json!({
|
||||||
"name": s(&front["opener"]["heading"]),
|
"name": text(&front["opener"]["heading"]),
|
||||||
"description": s(&front["opener"]["description"]),
|
"description": text(&front["opener"]["description"]),
|
||||||
"features": does,
|
"features": does,
|
||||||
})];
|
})];
|
||||||
let mut shopping: Vec<Value> = plan
|
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) {
|
for (bucket, needs) in public_buckets(plan) {
|
||||||
let w = &front["shopping"]["items"][&format!("status-{bucket}")];
|
let w = &front["shopping"]["items"][&format!("status-{bucket}")];
|
||||||
let mut f = card(w);
|
let mut f = card(w);
|
||||||
let link = match s(&w["link"]).trim() { "" => s(&w["heading"]).trim(), l => l };
|
let link = match s(&w["link"]).trim() { "" => s(&w["heading"]).trim().to_string(), l => l.to_string() };
|
||||||
f["description"] = json!(format!("{} [{link}](/status/{bucket})", s(&w["description"]).trim()).trim().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"]));
|
f["resource"] = public_resource(bucket, &needs, s(&w["empty"]), Some(FRONT_DOOR_LATEST), s(&words[&public_view_path(bucket)]["answer"]));
|
||||||
shopping.push(f);
|
shopping.push(f);
|
||||||
}
|
}
|
||||||
if !shopping.is_empty() {
|
if !shopping.is_empty() {
|
||||||
alternatives.push(json!({
|
alternatives.push(json!({
|
||||||
"name": s(&front["shopping"]["heading"]),
|
"name": text(&front["shopping"]["heading"]),
|
||||||
"description": s(&front["shopping"]["description"]),
|
"description": text(&front["shopping"]["description"]),
|
||||||
"features": shopping,
|
"features": shopping,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -1842,10 +1915,10 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
|||||||
.map(|d| {
|
.map(|d| {
|
||||||
let dw = &front["doors"][&d.id];
|
let dw = &front["doors"][&d.id];
|
||||||
let mut alt = json!({
|
let mut alt = json!({
|
||||||
"name": s(&dw["heading"]),
|
"name": text(&dw["heading"]),
|
||||||
"description": s(&dw["description"]),
|
"description": text(&dw["description"]),
|
||||||
"action": format!("/{}", d.id),
|
"action": format!("/{}", d.id),
|
||||||
"consequence": [s(&dw["button"])],
|
"consequence": button(&dw["button"]),
|
||||||
});
|
});
|
||||||
// A door carries no form; its icon and "for whom" line
|
// A door carries no form; its icon and "for whom" line
|
||||||
// ride an empty section, which portal renders as a
|
// 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(
|
files.insert(
|
||||||
"questions/index.yaml".into(),
|
"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
|
// 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(
|
files.insert(
|
||||||
path,
|
path,
|
||||||
json!({
|
json!({
|
||||||
"name": s(&w["question"]),
|
"name": text(&w["question"]),
|
||||||
"description": s(&w["description"]),
|
"description": text(&w["description"]),
|
||||||
"responsible": responsible,
|
"responsible": responsible,
|
||||||
"alternatives": plan.needs_behind(&door.id).iter().map(|n| match &n.enters_as {
|
"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)),
|
None => form_alternative(plan, n, &w["needs"][&n.id], &public_followup(n)),
|
||||||
// The way to the group's page: portal shows the
|
// The way to the group's page: portal shows the
|
||||||
// sign-in gate there to whoever is not in the group.
|
// sign-in gate there to whoever is not in the group.
|
||||||
Some(group) => json!({
|
Some(group) => json!({
|
||||||
"name": s(&w["needs"][&n.id]["heading"]),
|
"name": text(&w["needs"][&n.id]["heading"]),
|
||||||
"description": s(&w["needs"][&n.id]["description"]),
|
"description": text(&w["needs"][&n.id]["description"]),
|
||||||
"action": internal_page(n, group),
|
"action": internal_page(n, group),
|
||||||
"consequence": [s(&w["needs"][&n.id]["button"])],
|
"consequence": button(&w["needs"][&n.id]["button"]),
|
||||||
}),
|
}),
|
||||||
}).collect::<Vec<_>>(),
|
}).collect::<Vec<_>>(),
|
||||||
}),
|
}),
|
||||||
@@ -1915,8 +1988,8 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
|||||||
files.insert(
|
files.insert(
|
||||||
path,
|
path,
|
||||||
json!({
|
json!({
|
||||||
"name": s(&w["question"]),
|
"name": text(&w["question"]),
|
||||||
"description": s(&w["description"]),
|
"description": text(&w["description"]),
|
||||||
"responsible": responsible,
|
"responsible": responsible,
|
||||||
"alternatives": answers.iter().map(|n| form_alternative(plan, n, &w["forms"][&n.id], &public_followup(n))).collect::<Vec<_>>(),
|
"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 w = &words[&path];
|
||||||
let mut alternatives = vec![
|
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) {
|
if need.watched_by.iter().any(|g| g == PUBLIC) {
|
||||||
alternatives.push(json!({
|
alternatives.push(json!({
|
||||||
"name": s(&w["see_all"]["heading"]), "description": s(&w["see_all"]["description"]),
|
"name": text(&w["see_all"]["heading"]), "description": text(&w["see_all"]["description"]),
|
||||||
"action": format!("/status/{}", need.lands_in), "consequence": [s(&w["see_all"]["button"])],
|
"action": format!("/status/{}", need.lands_in), "consequence": button(&w["see_all"]["button"]),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
// Nested and not an index: portal infers a followup page.
|
// 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 {
|
if let Some(group) = &need.enters_as {
|
||||||
let path = format!("questions/{group}/{}.yaml", need.id);
|
let path = format!("questions/{group}/{}.yaml", need.id);
|
||||||
let w = &words[&path];
|
let w = &words[&path];
|
||||||
@@ -1952,8 +2025,8 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
|||||||
json!({
|
json!({
|
||||||
// A way in, not an outcome, though it sits in a directory.
|
// A way in, not an outcome, though it sits in a directory.
|
||||||
"followup": false,
|
"followup": false,
|
||||||
"name": s(&w["question"]),
|
"name": text(&w["question"]),
|
||||||
"description": s(&w["page_description"]),
|
"description": text(&w["page_description"]),
|
||||||
"responsible": responsible,
|
"responsible": responsible,
|
||||||
"alternatives": [form_alternative(plan, need, w, &format!("/{group}/{}-sent", need.id))],
|
"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
|
// other groups' desks with theirs; a watcher's list
|
||||||
// is empty, which portal renders read-only.
|
// is empty, which portal renders read-only.
|
||||||
"transitions": moves_of(needs[0], &group).into_iter().map(|(from, to)| {
|
"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<_>>(),
|
}).collect::<Vec<_>>(),
|
||||||
});
|
});
|
||||||
// A group that has the bucket only because cases escalate
|
// 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);
|
resource["states"] = json!(escalated);
|
||||||
}
|
}
|
||||||
alternatives.push(json!({
|
alternatives.push(json!({
|
||||||
"name": s(&bw["heading"]),
|
"name": text(&bw["heading"]),
|
||||||
"description": s(&bw["description"]),
|
"description": text(&bw["description"]),
|
||||||
"action": format!("/{group}"),
|
"action": format!("/{group}"),
|
||||||
"consequence": [s(&w["confirm"])],
|
"consequence": button(&w["confirm"]),
|
||||||
"features": [{ "name": s(&bw["list_title"]), "description": s(&bw["list_note"]), "resource": resource }],
|
"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())) {
|
for need in plan.needs.iter().filter(|n| n.enters_as.as_deref() == Some(group.as_str())) {
|
||||||
let lw = &w["links"][&need.id];
|
let lw = &w["links"][&need.id];
|
||||||
alternatives.push(json!({
|
alternatives.push(json!({
|
||||||
"name": s(&lw["heading"]),
|
"name": text(&lw["heading"]),
|
||||||
"description": s(&lw["description"]),
|
"description": text(&lw["description"]),
|
||||||
"action": internal_page(need, &group),
|
"action": internal_page(need, &group),
|
||||||
"consequence": [s(&lw["button"])],
|
"consequence": button(&lw["button"]),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
// Invites this desk may extend: `invite: {group}` is portal's
|
// 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 {
|
for target in targets {
|
||||||
let iw = &inv[target];
|
let iw = &inv[target];
|
||||||
alternatives.push(json!({
|
alternatives.push(json!({
|
||||||
"name": s(&iw["heading"]),
|
"name": text(&iw["heading"]),
|
||||||
"description": s(&iw["description"]),
|
"description": text(&iw["description"]),
|
||||||
"action": format!("/{group}"),
|
"action": format!("/{group}"),
|
||||||
"consequence": [s(&iw["button"])],
|
"consequence": button(&iw["button"]),
|
||||||
"invite": { "group": target },
|
"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!([
|
json!([
|
||||||
{ "name": "name", "label": s(&iw["labels"]["name"]) },
|
{ "name": "name", "label": text(&iw["labels"]["name"]) },
|
||||||
{ "name": "phone", "label": s(&iw["labels"]["phone"]), "type": "tel" },
|
{ "name": "phone", "label": text(&iw["labels"]["phone"]), "type": "tel" },
|
||||||
{ "name": "email", "label": s(&iw["labels"]["email"]), "type": "email", "optional": true },
|
{ "name": "email", "label": text(&iw["labels"]["email"]), "type": "email", "optional": true },
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
json!([
|
json!([
|
||||||
{ "name": "name", "label": s(&iw["labels"]["name"]) },
|
{ "name": "name", "label": text(&iw["labels"]["name"]) },
|
||||||
{ "name": "email", "label": s(&iw["labels"]["email"]), "type": "email" },
|
{ "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.
|
// runs out; with memberships the membership is the case.
|
||||||
let (bucket, transitions) = if plan.memberships() {
|
let (bucket, transitions) = if plan.memberships() {
|
||||||
("portal_memberships", json!([
|
("portal_memberships", json!([
|
||||||
{ "from": "invited", "to": "removed", "label": s(&lw["withdraw"]) },
|
{ "from": "invited", "to": "removed", "label": text(&lw["withdraw"]) },
|
||||||
{ "from": "active", "to": "removed", "label": s(&lw["done"]) },
|
{ "from": "active", "to": "removed", "label": text(&lw["done"]) },
|
||||||
{ "from": "removed", "to": "invited", "label": s(&lw["again"]) },
|
{ "from": "removed", "to": "invited", "label": text(&lw["again"]) },
|
||||||
]))
|
]))
|
||||||
} else {
|
} else {
|
||||||
("portal_invites", json!([
|
("portal_invites", json!([
|
||||||
{ "from": "open", "to": "done", "label": s(&lw["done"]) },
|
{ "from": "open", "to": "done", "label": text(&lw["done"]) },
|
||||||
{ "from": "expired", "to": "open", "label": s(&lw["again"]) },
|
{ "from": "expired", "to": "open", "label": text(&lw["again"]) },
|
||||||
{ "from": "expired", "to": "done", "label": s(&lw["done"]) },
|
{ "from": "expired", "to": "done", "label": text(&lw["done"]) },
|
||||||
]))
|
]))
|
||||||
};
|
};
|
||||||
alternatives.push(json!({
|
alternatives.push(json!({
|
||||||
"name": s(&lw["heading"]),
|
"name": text(&lw["heading"]),
|
||||||
"description": s(&lw["description"]),
|
"description": text(&lw["description"]),
|
||||||
"action": format!("/{group}"),
|
"action": format!("/{group}"),
|
||||||
"consequence": [s(&w["confirm"])],
|
"consequence": button(&w["confirm"]),
|
||||||
"features": [{ "name": s(&lw["list_title"]), "description": s(&lw["list_note"]), "resource": {
|
"features": [{ "name": text(&lw["list_title"]), "description": text(&lw["list_note"]), "resource": {
|
||||||
"source": { "kind": "kv", "bucket": bucket },
|
"source": { "kind": "kv", "bucket": bucket },
|
||||||
"requires_group": group,
|
"requires_group": group,
|
||||||
"transitions": transitions,
|
"transitions": transitions,
|
||||||
@@ -2063,19 +2136,19 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
|||||||
// done or declined.
|
// done or declined.
|
||||||
if let Some(sw) = w["suggestions"].as_object() {
|
if let Some(sw) = w["suggestions"].as_object() {
|
||||||
alternatives.push(json!({
|
alternatives.push(json!({
|
||||||
"name": s(&sw["heading"]),
|
"name": text(&sw["heading"]),
|
||||||
"description": s(&sw["description"]),
|
"description": text(&sw["description"]),
|
||||||
"action": format!("/{group}"),
|
"action": format!("/{group}"),
|
||||||
"consequence": [s(&w["confirm"])],
|
"consequence": button(&w["confirm"]),
|
||||||
"features": [{ "name": s(&sw["list_title"]), "description": s(&sw["list_note"]), "resource": {
|
"features": [{ "name": text(&sw["list_title"]), "description": text(&sw["list_note"]), "resource": {
|
||||||
"source": { "kind": "kv", "bucket": "portal_edit_suggestions" },
|
"source": { "kind": "kv", "bucket": "portal_edit_suggestions" },
|
||||||
"requires_group": group,
|
"requires_group": group,
|
||||||
"transitions": [
|
"transitions": [
|
||||||
{ "from": "open", "to": "discussed", "label": s(&sw["looking"]) },
|
{ "from": "open", "to": "discussed", "label": text(&sw["looking"]) },
|
||||||
{ "from": "open", "to": "done", "label": s(&sw["changed"]) },
|
{ "from": "open", "to": "done", "label": text(&sw["changed"]) },
|
||||||
{ "from": "open", "to": "declined", "label": s(&sw["declined"]) },
|
{ "from": "open", "to": "declined", "label": text(&sw["declined"]) },
|
||||||
{ "from": "discussed", "to": "done", "label": s(&sw["changed"]) },
|
{ "from": "discussed", "to": "done", "label": text(&sw["changed"]) },
|
||||||
{ "from": "discussed", "to": "declined", "label": s(&sw["declined"]) },
|
{ "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}/_section.yaml"), json!({ "qualifies": group }));
|
||||||
files.insert(
|
files.insert(
|
||||||
format!("questions/{group}/index.yaml"),
|
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(
|
files.insert(
|
||||||
path,
|
path,
|
||||||
json!({
|
json!({
|
||||||
"name": s(&w["question"]),
|
"name": text(&w["question"]),
|
||||||
"description": s(&w["description"]),
|
"description": text(&w["description"]),
|
||||||
"responsible": responsible,
|
"responsible": responsible,
|
||||||
"alternatives": [
|
"alternatives": [
|
||||||
{
|
{
|
||||||
"name": s(&w["heading"]),
|
"name": text(&w["heading"]),
|
||||||
"description": s(&w["list_description"]),
|
"description": text(&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"])) }],
|
"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(
|
files.insert(
|
||||||
path,
|
path,
|
||||||
json!({
|
json!({
|
||||||
"name": s(&w["question"]),
|
"name": text(&w["question"]),
|
||||||
"description": s(&w["description"]),
|
"description": text(&w["description"]),
|
||||||
"alternatives": targets.iter().map(|to| json!({
|
"alternatives": targets.iter().map(|to| json!({
|
||||||
"name": s(&w["moves"][to.as_str()]["heading"]),
|
"name": text(&w["moves"][to.as_str()]["heading"]),
|
||||||
"description": s(&w["moves"][to.as_str()]["description"]),
|
"description": text(&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"]) },
|
"self_transition": { "bucket": need.lands_in, "to": to, "from": from_for(to), "label": text(&w["moves"][to.as_str()]["button"]) },
|
||||||
})).collect::<Vec<_>>(),
|
})).collect::<Vec<_>>(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -2173,13 +2246,18 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
|||||||
need
|
need
|
||||||
}).collect::<Vec<_>>(),
|
}).collect::<Vec<_>>(),
|
||||||
"personas": plan.personas,
|
"personas": plan.personas,
|
||||||
|
"tones": plan.tones,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
files.into_iter().map(|(path, value)| Ok((path, serde_yaml::to_string(&value)?))).collect()
|
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)
|
let shown_only = public_buckets(plan)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|(bucket, _)| public_view_path(bucket) == path)
|
.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(", "))),
|
.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!(
|
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.business,
|
||||||
plan.about.trim(),
|
plan.about.trim(),
|
||||||
plan.contact.name,
|
plan.contact.name,
|
||||||
@@ -2282,24 +2360,38 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
|||||||
if only.as_ref().is_some_and(|p| !path.starts_with(p.as_str())) {
|
if only.as_ref().is_some_and(|p| !path.starts_with(p.as_str())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let prompt = words_prompt(&plan, path, fallback);
|
// 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 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}"))?;
|
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) {
|
match first_object(&reply) {
|
||||||
Some(given) => {
|
Some(given) => match wrong_language(&plan.language, &given) {
|
||||||
let merged = merge_words(fallback, &given);
|
Some(found) => {
|
||||||
match wrong_language(&plan.language, &given) {
|
eprintln!("words: {path} - the reply reads as {found} for a {} plan, keeping the words it had", plan.language);
|
||||||
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 => {
|
None => {
|
||||||
words.insert(path.clone(), merged);
|
eprintln!("words: {path} - no usable reply, keeping the fallback");
|
||||||
|
told.clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !told.is_empty() {
|
||||||
|
words.insert(path.clone(), fold_tones(&told));
|
||||||
eprintln!("words: {path}");
|
eprintln!("words: {path}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => eprintln!("words: {path} - no usable reply, keeping the fallback"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Last, whatever the words came from - a model, an overrides file,
|
// Last, whatever the words came from - a model, an overrides file,
|
||||||
// a lift from a live repo: a mail keeps the link to the sender's
|
// a lift from a live repo: a mail keeps the link to the sender's
|
||||||
// own move, and a placeholder left loose in its prose goes.
|
// own move, and a placeholder left loose in its prose goes.
|
||||||
@@ -2860,7 +2952,7 @@ pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result<BTreeMap<String, V
|
|||||||
let tokened: BTreeMap<String, Value> =
|
let tokened: BTreeMap<String, Value> =
|
||||||
fallback.iter().map(|(path, v)| (path.clone(), tokenize(v, path, ""))).collect();
|
fallback.iter().map(|(path, v)| (path.clone(), tokenize(v, path, ""))).collect();
|
||||||
let rendered = render(plan, &tokened)?;
|
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 {
|
for (path, text) in &rendered {
|
||||||
// The spec is derived from the words (a need's `via` is its
|
// 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
|
// 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 {
|
match v {
|
||||||
Value::Object(m) => Value::Object(m.iter().map(|(k, x)| (k.clone(), untokenize(x, path, &format!("{pointer}/{k}"), found))).collect()),
|
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)) {
|
Value::String(_) => match found.get(&token(path, pointer)) {
|
||||||
Some(word) => Value::String(word.clone()),
|
Some(word) => word.clone(),
|
||||||
None => v.clone(),
|
None => v.clone(),
|
||||||
},
|
},
|
||||||
other => other.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) {
|
match (a, b) {
|
||||||
(serde_yaml::Value::Mapping(x), serde_yaml::Value::Mapping(y)) => {
|
(serde_yaml::Value::Mapping(x), serde_yaml::Value::Mapping(y)) => {
|
||||||
for (k, va) in x {
|
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
|
// door's three headings on three unrelated new cards. Items
|
||||||
// with no identity (an information feature) fall back to
|
// with no identity (an information feature) fall back to
|
||||||
// position.
|
// 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)) => {
|
(serde_yaml::Value::Sequence(x), serde_yaml::Value::Sequence(y)) => {
|
||||||
let mut used = vec![false; y.len()];
|
let mut used = vec![false; y.len()];
|
||||||
for (i, va) in x.iter().enumerate() {
|
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
|
// that has since been given something to say would be
|
||||||
// emptied again by every rebuild.
|
// emptied again by every rebuild.
|
||||||
if !word.trim().is_empty() {
|
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"));
|
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]
|
#[test]
|
||||||
fn with_memberships_a_desk_invites_by_phone_and_lists_who_is_in() {
|
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();
|
let plan: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]\n", "invites_by: [board]\npeople: memberships\n")).unwrap();
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ mod build;
|
|||||||
mod card;
|
mod card;
|
||||||
mod plan;
|
mod plan;
|
||||||
mod rate;
|
mod rate;
|
||||||
|
mod report;
|
||||||
mod score;
|
mod score;
|
||||||
mod simulate;
|
mod simulate;
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ fn main() -> anyhow::Result<()> {
|
|||||||
Some("plan") => return plan::run(&argv),
|
Some("plan") => return plan::run(&argv),
|
||||||
Some("build") => return build::run(&argv),
|
Some("build") => return build::run(&argv),
|
||||||
Some("rate") => return rate::rate(&argv),
|
Some("rate") => return rate::rate(&argv),
|
||||||
|
Some("report") => return report::run(&argv),
|
||||||
Some("prefer") => return rate::prefer(&argv),
|
Some("prefer") => return rate::prefer(&argv),
|
||||||
Some("score") => return score::run(&argv),
|
Some("score") => return score::run(&argv),
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@@ -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
|
behind sign-in, with name and address filled from the account: a
|
||||||
seat on a committee for members, a discount for customers. The
|
seat on a committee for members, a discount for customers. The
|
||||||
group is one a decision `grants`, so nobody has to be waved in.
|
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
|
- **Keep the people in portal.** `people: memberships` keeps every
|
||||||
group in portal itself: a desk invite is a place held until the
|
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
|
person signs in, a person is their phone number first, and any
|
||||||
|
|||||||
+127
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user