Files
booth/booth/asks.py
T
vh c7f9437a64 feat(marks): one primitive for operator judgment, so the loop stops running through chat
Five mechanisms existed to get one question next to one artifact. Three of
them were the same thing wearing different clothes, and the third of the three
had no code at all: the operator picked winners out of a 270-image set and
told the session in conversation. `sindra-finalists` is 86 items, every one
captioned, with the selection encoded in the booth's NAME.

A MARK is operator judgment attached to a target — the booth, or one item in
it, addressed by the `rel` U1 established as item identity. Three shapes:

  pick — one of N options a session declared in advance   (was: an ask)
  note — free text the operator volunteered               (had nothing)
  flag — this one                                         (had nothing)

One file per booth, one read path, one place openness is computed, one slot
beside the artifact. The storage shape is the operator's call (2026-09-21) and
follows from U4: "does this booth still owe an answer?" gets asked per booth
per sweep tick and per card per index render, so it has to be one read and not
a walk of a booth holding 270 files. Marks are also not links.md — that is an
O_APPEND content-hash log because 17 handles write it concurrently, whereas a
booth's marks see one session and one operator, so locking the common path
costs nothing.

The 2026-09-09 pick semantics are preserved by NOT rewriting them: partial
answers legal, a blank question lands in `unanswered`, `complete` false until
every question has a pick, the only refusal a submission carrying nothing.
`write_answer` split into the pure `build_answer` plus the storage that went
away with the sidecar; `normalize_ask` untouched.

Three findings worth naming, because each was caught by a gate rather than by
reading the diff again:

  * The seam review found `inline.place` indexes asks by SUBSCRIPT — the only
    consumer in the service that does — so a frozen dataclass breaks it, and
    `inline.py` had been missing from the contract's scope entirely.
  * A retargeted test found a regression in the legacy importer: a malformed
    sidecar that renders "broken" today would have silently vanished on
    migration. It now imports carrying its reason.
  * A partially-answered pick counted as CLOSED on the index while the panel
    beside it rendered it "partial" — the two disagreed about one booth. Open
    is the reading U4 needs, and it is declared rather than smuggled in.

`GET /b/<n>/marks.json` is new and load-bearing: sessions on other hosts polled
`<stem>.answer.json` over HTTP, so removing the sidecar without it would have
taken that capability away. `/b/<n>/asks` 308s to `/marks`. Legacy sidecars are
imported, never deleted — four are live and unanswered.

Also records the operator's deterministic-order directive as a cross-cutting v1
invariant, in ROADMAP.md with the per-collection rule table and as CLAUDE.md
invariant 6. The Booth's job is comparison; an order that moves between renders
does not crash, it misfiles the judgment.

242 tests. No version bump — a release tier for this is the operator's call.
2026-09-21 23:38:27 -07:00

329 lines
14 KiB
Python

"""Pick validation and answer shaping — the semantics, without the storage.
A `pick` is one shape of MARK (see booth/marks.py): one of N options a session
declared in advance, chosen by the operator. This module owns what a declaration
is allowed to look like and what shape the recorded judgment takes; `marks.py`
owns where both are kept.
The split exists because the storage changed and these semantics must not. They
are operator-settled (2026-09-09) and were moved rather than rewritten:
normalize_ask(raw, id) -> dict validate a declaration; BOTH accepted
shapes come back as a `questions` list
build_answer(ask, ...) -> dict shape the operator's answer to one
STDLIB ONLY, like links.py and marks.py: the `booth` CLI imports these under the
system python3 with no venv.
Declaration, single-question (what a session writes):
{"prompt": "Which render wins?",
"options": ["A — baseline", "B — cudaMallocAsync"], # >= 2, strings or
# [{"id": "a", "label": "A — baseline", "detail": "…"}, …]
"notes": true, # optional, default true: show a free-text field
"notes_label": "why?"} # optional placeholder for that field
Declaration, multi-question — ONE form, ONE submit:
{"title": "R18 batch review",
"questions": [{"key": "q1", "prompt": "Render 1?", "options": ["keep", "drop"], "notes": true},
{"key": "q2", "prompt": "Render 2?", "options": ["keep", "drop"]}],
"notes": true}
The recorded judgment:
single {"stem", "prompt", "choice", "choice_index", "label",
"unanswered", "complete", "notes", "answered_at", "answered_by"}
multi {"stem", "title", "answers": {"q1": {"prompt", "choice",
"choice_index", "label", "notes"}, …},
"unanswered": ["q2"], "complete": false, "notes", …}
PARTIAL ANSWERS ARE LEGAL, and this is the part most likely to be "cleaned up"
by someone who has not read the ruling. A question left blank is a deliberate
outcome — "none of these", "I have not listened to that one yet", "ask me
later" — and refusing a four-question submission because one was skipped threw
away the three that were made. So a blank question lands in `unanswered`, is
absent from `answers` unless it carried a note, and `complete` stays false. The
ONE refusal is a submission carrying nothing at all: no choice anywhere and no
notes, which would flip an open pick to answered while recording no decision.
An offered-but-invalid option is still an error — a broken form, not a skip.
Re-answering overwrites: a mark is the CURRENT judgment, not a log. A session
that wants history keeps its own.
`ASK_SUFFIX` / `ANSWER_SUFFIX` / `is_ask_file` / `is_answer_file` / `ask_stem`
survive for exactly two consumers: the legacy importer in marks.py, and
`booth_items`, which still excludes those files from the tile list because the
migration does not delete them.
"""
from __future__ import annotations
import json
import re
from datetime import datetime
from pathlib import Path
ASK_SUFFIX = ".ask.json"
ANSWER_SUFFIX = ".answer.json"
PROMPT_MAX = 2000
LABEL_MAX = 400
DETAIL_MAX = 1000
NOTES_MAX = 8000
MAX_OPTIONS = 40
MAX_QUESTIONS = 30
_STEM_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$")
_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,60}$")
class AskError(ValueError):
"""An ask file that cannot be rendered — reported, never a crash."""
def is_ask_file(name: str) -> bool:
return name.endswith(ASK_SUFFIX) and len(name) > len(ASK_SUFFIX)
def is_answer_file(name: str) -> bool:
return name.endswith(ANSWER_SUFFIX) and len(name) > len(ANSWER_SUFFIX)
def ask_stem(name: str) -> str:
return name[: -len(ASK_SUFFIX)]
def valid_stem(stem: str) -> bool:
return bool(_STEM_RE.match(stem)) and ".." not in stem
def _normalize_options(opts_in, where: str) -> list[dict]:
if not isinstance(opts_in, list) or len(opts_in) < 2:
raise AskError(f"{where} needs a list `options` with at least 2 entries")
if len(opts_in) > MAX_OPTIONS:
raise AskError(f"{where}: too many options (max {MAX_OPTIONS})")
options: list[dict] = []
seen: set[str] = set()
for i, o in enumerate(opts_in):
if isinstance(o, str):
oid, label, detail = o, o, ""
elif isinstance(o, dict):
label = o.get("label")
if not isinstance(label, str) or not label.strip():
raise AskError(f"{where} option {i} needs a non-empty string `label`")
oid = o.get("id", label)
detail = o.get("detail", "") or ""
if not isinstance(oid, str) or not oid.strip():
raise AskError(f"{where} option {i} has a bad `id`")
if not isinstance(detail, str):
raise AskError(f"{where} option {i} has a non-string `detail`")
else:
raise AskError(f"{where} option {i} must be a string or an object")
oid = oid.strip()
if oid in seen:
raise AskError(f"{where}: duplicate option id {oid!r}")
seen.add(oid)
options.append({"id": oid, "label": label.strip()[:LABEL_MAX], "detail": detail.strip()[:DETAIL_MAX]})
return options
def _bool(raw: dict, key: str, default: bool, where: str) -> bool:
v = raw.get(key, default)
if not isinstance(v, bool):
raise AskError(f"{where}: `{key}` must be true/false")
return v
def normalize_ask(raw: dict, stem: str) -> dict:
"""Validate + normalise an ask document. Raises AskError on anything the
renderer could not honour.
Two shapes are accepted and both come back as `questions: [...]`:
single {"prompt", "options", "notes"?, "notes_label"?}
-> one question, key None, `multi` False. Its answer keeps the
flat {choice, choice_index, label, notes} shape.
multi {"title"?, "questions": [{"key", "prompt", "options", "notes"?}, ...],
"notes"?, "notes_label"?}
-> one FORM, one submit, every question required; the answer is
{answers: {key: {...}}, notes}. Per-question `notes` (default
false) adds a small text field under that question; the
form-level `notes` (default true) is one field for the whole ask.
"""
if not isinstance(raw, dict):
raise AskError("ask must be a JSON object")
notes = _bool(raw, "notes", True, "ask")
notes_label = raw.get("notes_label", "notes")
if not isinstance(notes_label, str):
raise AskError("`notes_label` must be a string")
notes_label = notes_label.strip()[:80] or "notes"
if "questions" in raw:
if "prompt" in raw or "options" in raw:
raise AskError("an ask has EITHER `prompt`+`options` OR `questions`, not both")
qs_in = raw.get("questions")
if not isinstance(qs_in, list) or not qs_in:
raise AskError("`questions` must be a non-empty list")
if len(qs_in) > MAX_QUESTIONS:
raise AskError(f"too many questions (max {MAX_QUESTIONS})")
title = raw.get("title", "")
if not isinstance(title, str):
raise AskError("`title` must be a string")
questions: list[dict] = []
keys: set[str] = set()
for i, q in enumerate(qs_in):
where = f"question {i}"
if not isinstance(q, dict):
raise AskError(f"{where} must be an object")
key = q.get("key")
if not isinstance(key, str) or not _KEY_RE.match(key):
raise AskError(f"{where} needs a `key` (letters, digits, . _ -)")
if key in keys:
raise AskError(f"duplicate question key {key!r}")
keys.add(key)
prompt = q.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
raise AskError(f"{where} needs a non-empty string `prompt`")
questions.append({
"key": key,
"prompt": prompt.strip()[:PROMPT_MAX],
"options": _normalize_options(q.get("options"), where),
"notes": _bool(q, "notes", False, where),
})
return {
"stem": stem,
"multi": True,
"title": title.strip()[:PROMPT_MAX],
"prompt": title.strip()[:PROMPT_MAX] or f"{len(questions)} questions",
"questions": questions,
"notes": notes,
"notes_label": notes_label,
}
prompt = raw.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
raise AskError("ask needs a non-empty string `prompt` (or a `questions` list)")
options = _normalize_options(raw.get("options"), "ask")
# `title` is optional on a single-question ask too — a short label above the
# question. It used to be accepted and silently dropped, which is worse than
# rejecting it: the session sees no error and the operator sees no title.
title = raw.get("title", "")
if not isinstance(title, str):
raise AskError("`title` must be a string")
return {
"stem": stem,
"multi": False,
"title": title.strip()[:PROMPT_MAX],
"prompt": prompt.strip()[:PROMPT_MAX],
"questions": [{"key": None, "prompt": prompt.strip()[:PROMPT_MAX], "options": options, "notes": False}],
"options": options, # kept for single-question callers
"notes": notes,
"notes_label": notes_label,
}
def _pick(options: list[dict], choice, where: str) -> tuple[int, dict]:
idx = next((i for i, o in enumerate(options) if o["id"] == choice), None)
if idx is None:
raise AskError(f"{where}: choice is not one of the options")
return idx, options[idx]
def _blank(choice) -> bool:
"""A question the operator left alone. An empty string is what an unchecked
radio group posts, and None is what a missing field looks like — both mean
'no pick', neither is an error."""
return choice is None or (isinstance(choice, str) and not choice.strip())
def _clean_notes(text) -> str:
return (text or "").replace("\r\n", "\n").strip()[:NOTES_MAX]
def build_answer(ask: dict, choice, notes: str = "", who: str = "",
qnotes: dict | None = None) -> dict:
"""Shape the operator's answer to a NORMALIZED ask. Pure — no I/O; the
caller owns storage. Validates the choices that were MADE.
This is `write_answer`'s logic with the storage removed, extracted so
`booth.marks` can own the storage without re-implementing the semantics
below. `stem` comes off the ask (`normalize_ask` emits it), so the two
callers do not have to agree on a second source for it.
PARTIAL ANSWERS ARE LEGAL (operator ruling 2026-09-09). A question left
blank is a deliberate outcome — "none of these", "I did not listen to that
one yet", "ask me later" — and refusing the whole submission because one of
four was skipped threw away the three that were made. So:
* every question the operator DID answer is recorded and validated;
* every one left blank is listed in `unanswered`, absent from `answers`;
* `complete` says whether all of them were answered.
The one thing refused is a submission carrying NOTHING — no choice anywhere
and no notes. That would flip an open ask to "answered" while recording no
decision, which is strictly worse for the reading session than leaving it
open. A choice that is offered but not in the option list is still an error:
that is a broken form, not a skipped question.
`choice` is the option id (str) for a single-question ask, or a
{key: option id} dict for a multi-question ask. `qnotes` is {key: text} for
per-question notes fields (multi only).
"""
stem = ask["stem"]
stamp = {
"answered_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"answered_by": who or "",
}
form_notes = _clean_notes(notes) if ask["notes"] else ""
if ask["multi"]:
if not isinstance(choice, dict):
raise AskError("a multi-question ask needs a {key: choice} mapping")
qnotes = qnotes or {}
answers: dict[str, dict] = {}
unanswered: list[str] = []
for q in ask["questions"]:
c = choice.get(q["key"])
note = _clean_notes(qnotes.get(q["key"])) if q["notes"] else ""
if _blank(c):
unanswered.append(q["key"])
if note: # a note without a pick is still worth keeping
answers[q["key"]] = {"prompt": q["prompt"], "choice": None,
"choice_index": None, "label": "", "notes": note}
continue
idx, opt = _pick(q["options"], c, f"question {q['key']!r}")
answers[q["key"]] = {
"prompt": q["prompt"],
"choice": opt["id"],
"choice_index": idx,
"label": opt["label"],
"notes": note,
}
picked = [k for k, v in answers.items() if v["choice"] is not None]
if not picked and not form_notes and not any(v["notes"] for v in answers.values()):
raise AskError("nothing to record — no choice made and no notes")
answer = {"stem": stem, "title": ask["title"], "answers": answers,
"unanswered": unanswered, "complete": not unanswered,
"notes": form_notes, **stamp}
else:
if _blank(choice):
if not form_notes:
raise AskError("nothing to record — no choice made and no notes")
answer = {"stem": stem, "prompt": ask["prompt"], "choice": None,
"choice_index": None, "label": "", "unanswered": [None],
"complete": False, "notes": form_notes, **stamp}
else:
idx, opt = _pick(ask["options"], choice, "ask")
answer = {
"stem": stem,
"prompt": ask["prompt"],
"choice": opt["id"],
"choice_index": idx,
"label": opt["label"],
"unanswered": [],
"complete": True,
"notes": form_notes,
**stamp,
}
return answer