build: --words repeats and layers; the desk's confirm slot says what it is

Two things the uhhm rounds ran into. Only the first --words was read,
so a round of hand changes had to be merged into the previous words
by hand; now each file layers over the last. And the desk's one
confirm slot was described like any button, so a model wrote "Send
invite" under the cases list; it now says it serves every list.
README: the lift is positional, and where that bit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-23 20:02:30 +02:00
co-authored by Claude Fable 5.1
parent 9e7f16c489
commit 9ff9924374
2 changed files with 19 additions and 6 deletions
+6 -2
View File
@@ -45,9 +45,13 @@ default.
`--words-from <repo>` lifts the words a repo already has back into the
words map by structure, so a rebuild keeps hand-edited wording and
adds only the new slots; `--words <json>` overrides slots by page;
adds only the new slots. The lift is by position, so it holds for a
rebuild of the same shape and not across a restructure: on uhhm.no it
put the old front door's three headings on three new cards. `--words
<json>` overrides slots by page and may repeat, each file layered over
the last (the words a build used, then a round of hand changes);
`words.json` in the output is the words as used. See
`runs/tomtervel-invites-2026-09-23`.
`runs/tomtervel-invites-2026-09-23` and `runs/uhhm-2026-09-23`.
| Same contractor, same local model (nemotron 30B-A3B on ergo) | Free-form `generate` | `build` |
|---|---|---|
+13 -4
View File
@@ -833,7 +833,11 @@ fn slots_in(value: &Value, key: &str, parent: &str) -> Value {
(_, "question") => "the one question this page asks its reader".to_string(),
(_, "heading" | "next_heading") => "a heading, at most eight words, saying what the reader gets".to_string(),
(_, "description" | "page_description" | "next_description") => "one or two short sentences".to_string(),
(_, "button" | "back" | "confirm") => "a button, one to three words, saying what pressing it does".to_string(),
(_, "button" | "back") => "a button, one to three words, saying what pressing it does".to_string(),
// One slot serves every list on the desk, so it cannot
// name any one of them: a model once wrote "Send invite"
// under the cases list.
(_, "confirm") => "the one button under every list on this desk, confirming a move the person has chosen; one word, such as Confirm".to_string(),
(_, "encouragement") => "one reassuring sentence: what happens with what they send, or how little it takes; or empty".to_string(),
(_, "icon") => "an icon name from the list at the end, the one that fits this best; or empty".to_string(),
(_, "for_whom") => "a few words naming who this is for, addressed to them; or empty".to_string(),
@@ -1296,7 +1300,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
pub fn run(argv: &[String]) -> anyhow::Result<()> {
let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned();
let usage = "usage: portal-trainer build --plan <plan.yaml> --out <dir> [--words-from <existing repo>] [--words <overrides.json>] [--llm 'cmd'] [--lint question_lint]";
let usage = "usage: portal-trainer build --plan <plan.yaml> --out <dir> [--words-from <existing repo>] [--words <words.json>]... [--llm 'cmd'] [--lint question_lint]";
let plan_path = value("--plan").ok_or_else(|| anyhow!(usage))?;
let out = std::path::PathBuf::from(value("--out").ok_or_else(|| anyhow!(usage))?);
let plan: Plan = serde_yaml::from_str(&std::fs::read_to_string(&plan_path).with_context(|| format!("reading {plan_path}"))?)
@@ -1313,8 +1317,13 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
Some(repo) => lift_words(&plan, Path::new(&repo))?,
None => fallback_words(&plan),
};
if let Some(path) = value("--words") {
let given: BTreeMap<String, Value> = serde_json::from_str(&std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?)
// `--words` may repeat: the words a previous build used, then a
// round of hand overrides on top, in the order given. Each is
// merged key by key over what is there, so a file names only the
// slots it changes.
let words_files: Vec<&String> = argv.iter().enumerate().filter(|(_, a)| *a == "--words").filter_map(|(i, _)| argv.get(i + 1)).collect();
for path in words_files {
let given: BTreeMap<String, Value> = serde_json::from_str(&std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?)
.with_context(|| format!("parsing {path}"))?;
for (page, v) in given {
match words.get(&page) {