fix(l1): answer buttons must match the question — yes_label/no_label end-to-end

Live walk defect: the builder generated alternatives questions ("Is Jane's
account a Microsoft account or a local account?") while the UI could only
offer Yes/No. Root cause: SYSTEM_PROMPT mandated a label-less
'<yes/no question>' shape with no way to express the two answers.

- SYSTEM_PROMPT: question nodes must carry yes_label/no_label — the literal
  button texts; alternatives questions must use the alternatives as labels.
- validate_node: labels hard-floor-scanned, must be distinct non-empty strings.
- _ensure_labels: server defaults missing labels to Yes/No.
- advance_ai_build: records answer_label (and both labels) in walked_path,
  derived from the server-held pending_node — never client-supplied.
- _build_context: LLM context shows the chosen label, not a bare yes/no
  (a raw "-> yes" on an alternatives question degrades the next generation).
- normalize_walked_path: captured flywheel trees keep question labels.
- Frontend: buttons render yes_label/no_label; walk transcript and
  L1EscalationsSection render answer_label.

Phase 2A backend set: 137 passed / 0 failed / 8 deselected. tsc, eslint,
vite build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 15:03:15 -04:00
parent db446e1fd6
commit 9c34d1e82d
7 changed files with 182 additions and 9 deletions

View File

@@ -38,11 +38,19 @@ HARD RULES:
- When you run out of safe in-scope steps, DO NOT GUESS. Emit an "escalate" node.
Return ONLY a JSON object for ONE node, one of:
{"node_type":"question","text":"<yes/no question>"}
{"node_type":"question","text":"<binary question>","yes_label":"<button text>","no_label":"<button text>"}
{"node_type":"instruction","text":"<one safe reversible action>"}
{"node_type":"resolved","text":"<confirmation the issue is fixed>"}
{"node_type":"escalate","reason_category":"exhausted_safe_steps","text":"<why>"}
No prose, no markdown fences.
QUESTION LABELS: yes_label and no_label are the literal button texts the tech
clicks — each must be a direct, complete answer to the question. For a plain
yes/no question use "Yes"/"No". If the question offers two alternatives
("Is it X or Y?"), the labels MUST be those alternatives (yes_label = the
first), e.g. {"text":"Is the account a Microsoft account or a local account?",
"yes_label":"Microsoft account","no_label":"Local account"}. Never pair an
alternatives question with Yes/No labels. Keep labels under 6 words.
"""
@@ -60,12 +68,27 @@ def _assign_id(node: dict[str, Any]) -> dict[str, Any]:
return node
def _ensure_labels(node: dict[str, Any]) -> dict[str, Any]:
"""Default question labels to Yes/No when the model omits them.
Labels are the literal button texts; downstream (UI, walked_path
answer_label, LLM context) assumes every served question carries both.
"""
if node.get("node_type") == "question":
node["yes_label"] = (node.get("yes_label") or "Yes").strip() or "Yes"
node["no_label"] = (node.get("no_label") or "No").strip() or "No"
return node
def _build_context(problem_text: str, category: str, walked_path: list[dict]) -> str:
lines = [f"PROBLEM: {problem_text}", f"CATEGORY: {category}", "STEPS SO FAR:"]
if not walked_path:
lines.append("(none yet — produce the first diagnostic question)")
for i, step in enumerate(walked_path, 1):
ans = step.get("answer")
# Prefer the chosen label: for an alternatives question
# ("Microsoft account or local account?"), a raw "yes" is ambiguous
# and degrades the next generation.
ans = step.get("answer_label") or step.get("answer")
suffix = f" -> {ans}" if ans else ""
lines.append(f"{i}. [{step.get('node_type','?')}] {step.get('text','')}{suffix}")
return "\n".join(lines)
@@ -79,6 +102,17 @@ def validate_node(node: dict[str, Any]) -> dict[str, Any]:
for pat in HARD_FLOOR_TEXT_PATTERNS:
if pat in text:
raise UnsafeNodeError(f"hard-floor pattern '{pat}' in node text")
labels = [node.get(k) for k in ("yes_label", "no_label") if node.get(k) is not None]
if labels:
if not all(isinstance(lb, str) and lb.strip() for lb in labels):
raise UnsafeNodeError(f"malformed answer labels: {labels!r}")
if len(labels) == 2 and labels[0].strip().lower() == labels[1].strip().lower():
raise UnsafeNodeError(f"indistinct answer labels: {labels!r}")
for lb in labels:
low = lb.lower()
for pat in HARD_FLOOR_TEXT_PATTERNS:
if pat in low:
raise UnsafeNodeError(f"hard-floor pattern '{pat}' in answer label")
return node
@@ -111,7 +145,7 @@ async def generate_next_node(
max_tokens=1024,
)
node = parse_llm_json(raw)
return _assign_id(validate_node(node))
return _assign_id(_ensure_labels(validate_node(node)))
except Exception as e:
logger.warning("ai_tree_builder node attempt %d failed: %s", attempt + 1, e)
continue
@@ -147,6 +181,10 @@ def normalize_walked_path(walked_path: list[dict]) -> dict[str, Any]:
if step.get("reason_category"):
node["reason_category"] = step["reason_category"]
if ntype == "question":
if step.get("yes_label"):
node["yes_label"] = step["yes_label"]
if step.get("no_label"):
node["no_label"] = step["no_label"]
answer = (step.get("answer") or "").lower()
stub_seq += 1
stub_id = f"review-{stub_seq}"