One binary: the lint and the trainer are commands of portal #7
+255
-107
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
_ => {}
|
||||
|
||||
@@ -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
@@ -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