build: slots instead of placeholders, and the first worded run

Fallback words read like finished copy, and nemotron handed the front
door straight back. The prompt now shows every string as a slot saying
what belongs there; a slot echoed back counts as no answer and keeps
the fallback.

runs/contractor-build-2026-09-21: nemotron-3.5-lightning:30b-a3b, the
model that produced no loadable site free-form, words a complete
contractor site in 130 s. Verifier green by construction; held-out
personas route 8/8 with descriptions and 8/8 on headings alone
(qwen3:8b as the visitor). "What happens next" is still 0/2 on the
front door: no heading or description says who reads a submission.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-21 21:08:08 +02:00
co-authored by Claude Fable 5.1
parent 8c286a8483
commit aef0fe1396
24 changed files with 784 additions and 3 deletions
+32 -3
View File
@@ -291,11 +291,40 @@ pub fn merge_words(fallback: &Value, given: &Value) -> Value {
(Value::Object(f), Value::Object(g)) => {
Value::Object(f.iter().map(|(k, v)| (k.clone(), g.get(k).map_or_else(|| v.clone(), |gv| merge_words(v, gv)))).collect())
}
(Value::String(_), Value::String(s)) if !s.trim().is_empty() => Value::String(s.trim().to_string()),
// An unfilled slot echoed back is no answer.
(Value::String(_), Value::String(s)) if !s.trim().is_empty() && !s.contains(SLOT) => {
Value::String(s.trim().to_string())
}
_ => fallback.clone(),
}
}
const SLOT: &str = "<WRITE";
/// The page's wording with every string replaced by a slot that says
/// what belongs there. Shown to the model in place of the fallbacks:
/// fallbacks read like finished copy, and a small model hands finished
/// copy straight back (measured: nemotron returned the front door
/// unchanged). A slot cannot be mistaken for an answer.
fn slots(value: &Value, key: &str) -> Value {
match value {
Value::Object(map) => Value::Object(map.iter().map(|(k, v)| (k.clone(), slots(v, k))).collect()),
Value::String(_) => Value::String(format!(
"{SLOT}: {}>",
match key {
"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".to_string(),
"form_title" => "a title for the form, two to four words".to_string(),
other if other.contains("->") => format!("a button, two to four words, for a case going {other}"),
other => format!("a form label for the field `{other}`"),
}
)),
other => other.clone(),
}
}
fn s(v: &Value) -> &str {
v.as_str().unwrap_or_default()
}
@@ -470,14 +499,14 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
.map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> ")))
.collect();
format!(
"You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page's wording as JSON, filled with placeholder text. Return the same JSON with every string rewritten. Keep every key exactly as it is; change only the string values.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- Write the way people in this trade and country actually speak.\n\n{}\n\nReply with the JSON object only.",
"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- Write the way people in this trade and country actually speak.\n\n{}\n\nReply with the JSON object only.",
plan.business,
plan.about.trim(),
plan.contact.name,
plan.contact.email,
plan.language,
context.join("\n"),
serde_json::to_string_pretty(fallback).unwrap_or_default(),
serde_json::to_string_pretty(&slots(fallback, "")).unwrap_or_default(),
)
}