fix(booth): a partial ask answer is recorded, not refused

Operator: the form failed when a question was left blank. Refusing the whole
submission over one blank threw away the picks that were made, and the HTML
`required` on the radios blocked it in the browser before the server saw it.

- answered questions recorded; blank ones land in `unanswered`; `complete` says
  whether the set is finished; a blank question carrying a note keeps the note
- `required` dropped from both templates so the browser cannot block a partial
- refused only when there is no pick anywhere AND no notes (a 400 — that would
  flip an open ask to answered with no decision recorded); a choice outside the
  option list is still an error
- new ◐ partial state with an n/N count; skipped questions render as skipped
- README + global CLAUDE.md tell reading sessions to check `complete`
- 154 tests; v0.1.15
This commit is contained in:
vh
2026-09-09 15:05:18 -07:00
parent 784c555dbd
commit c335c38c19
7 changed files with 179 additions and 35 deletions
+20 -1
View File
@@ -141,8 +141,14 @@ EOF
# "choice_index", "label", "notes"}, "r2": {...}, "seed": {...}}, "notes", "answered_at", "answered_by"}
```
Both shapes also carry **`unanswered`** (the question keys left blank; `[null]`
for a blank single-question ask) and **`complete`** (false until every question
has a pick). A reading session should check `complete` before acting on a
multi-question answer, and treat a key in `unanswered` as "not decided", never
as "declined".
The single-question answer: `{"stem", "prompt", "choice", "choice_index", "label", "notes",
"answered_at", "answered_by"}` — `choice` is the option id (the label itself
"unanswered", "complete", "answered_at", "answered_by"}` — `choice` is the option id (the label itself
for string options), `choice_index` its 0-based position, `answered_by` the
client address. `POST /b/<name>/answer` is what the form submits — fields `ask` plus
`choice` / `notes` (single) or `choice.<key>` / `notes.<key>` / `notes` (multi);
@@ -156,6 +162,19 @@ Rules of the primitive:
`questions` when the picks belong together and should land as one answer.
- **Re-answering overwrites.** The sidecar is the *current* answer, not a log.
The page shows the recorded answer with a collapsed *change answer* form.
- **Blanks are legal — a partial answer is recorded, not refused.** Leaving a
question alone is a real outcome ("none of these", "not listened to yet"), and
refusing the whole submission over one blank threw away the picks that WERE
made. So every answered question is recorded, every blank one lands in
`unanswered`, and `complete` says whether the set is finished. The radios carry
no HTML `required`, so the browser does not block the submit either. A question
left blank but carrying a note keeps the note (`choice: null`). The one refusal
is a submission with **no pick anywhere and no notes** — a 400, because it would
flip an open ask to "answered" while recording no decision, which is worse for
the reading session than leaving it open. A choice that is not in the option
list is still an error: that is a broken form, not a skipped question.
Partially-answered asks show as `◐ partial` with an `n/N` count; re-submitting
fills in the rest.
- **Open asks are flagged** — an amber `? N asks` badge on the index card and in
the booth header — so a waiting question is visible from the front page.
- **A broken ask is shown as broken**, not hidden: if the JSON does not
+66 -19
View File
@@ -34,7 +34,11 @@ Multi-question form (one submit, one sidecar):
{"key": "q2", "prompt": "Render 2?", "options": ["keep", "drop"]}],
"notes": true}
-> {"stem", "title", "answers": {"q1": {"prompt", "choice", "choice_index", "label", "notes"}, …},
"notes", "answered_at", "answered_by"}
"unanswered": ["q2"], "complete": false, "notes", "answered_at", "answered_by"}
A question left blank is legal: it lands in `unanswered` and is absent from
`answers` (unless it carried a note). `complete` is false until every question
has a pick. Only a submission with no pick AND no notes anywhere is refused.
Re-answering overwrites: the sidecar is the current answer, not a log. A
session that wants history keeps its own.
@@ -257,60 +261,103 @@ def list_asks(booth: Path) -> list[dict]:
def _pick(options: list[dict], choice, where: str) -> tuple[int, dict]:
if not isinstance(choice, str):
raise AskError(f"{where}: no choice given")
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 write_answer(booth: Path, stem: str, choice, notes: str = "", who: str = "",
qnotes: dict | None = None) -> dict:
"""Record the operator's answer. Validates every choice against the ask,
"""Record the operator's answer. Validates the choices that were MADE,
writes `<stem>.answer.json` via temp-file + os.replace so a reader never
sees a half-written document. Returns the answer written.
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 — every question required.
`qnotes` is {key: text} for per-question notes fields (multi only).
{key: option id} dict for a multi-question ask. `qnotes` is {key: text} for
per-question notes fields (multi only).
"""
ask = load_ask(booth, stem) # raises AskError if the ask is gone/invalid
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"]:
idx, opt = _pick(q["options"], choice.get(q["key"]), f"question {q['key']!r}")
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": _clean_notes(qnotes.get(q["key"])) if q["notes"] else "",
"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,
"notes": _clean_notes(notes) if ask["notes"] else "", **stamp}
"unanswered": unanswered, "complete": not unanswered,
"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"],
"notes": _clean_notes(notes) if ask["notes"] else "",
**stamp,
}
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,
}
path = Path(booth) / f"{stem}{ANSWER_SUFFIX}"
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(answer, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
@@ -18,6 +18,8 @@
border-top:2px solid #e0b93c;border-radius:9px;background:rgba(128,140,160,.07);
font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
.bk-ask.bk-done{border-top-color:#3fae6a}
.bk-ask.bk-skip{border-top-color:#6f7c8c}
.bk-ask.bk-skip .bk-ask-tag{color:#8a97a6}
.bk-ask-tag{display:block;margin-bottom:.5rem;font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;
letter-spacing:.12em;text-transform:uppercase;color:#c9a227}
.bk-ask.bk-done .bk-ask-tag{color:#3fae6a}
@@ -53,14 +55,17 @@
{% macro question(a, q, form_id, name_url, standalone=False) %}
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
{% set qa = (a.answer.answers.get(q.key) if a.multi else a.answer) if a.answer else None %}
<div class="bk-ask{% if qa %} bk-done{% endif %}" id="bk-ask-{{ a.stem }}{% if q.key %}-{{ q.key }}{% endif %}">
<span class="bk-ask-tag">{% if qa %}✓ answered{% else %}? your pick{% endif %}</span>
{% set picked = qa and qa.choice is not none %}
{% set skipped = a.answer and not picked %}
<div class="bk-ask{% if picked %} bk-done{% elif skipped %} bk-skip{% endif %}" id="bk-ask-{{ a.stem }}{% if q.key %}-{{ q.key }}{% endif %}">
<span class="bk-ask-tag">{% if picked %}✓ answered{% elif skipped %}— skipped{% else %}? your pick{% endif %}</span>
<p class="bk-ask-prompt">{{ q.prompt }}</p>
{% if qa %}<p class="bk-ask-was">recorded: <b>{{ qa.label }}</b>{% if qa.notes %} — {{ qa.notes }}{% endif %}</p>{% endif %}
{% if picked %}<p class="bk-ask-was">recorded: <b>{{ qa.label }}</b>{% if qa.notes %} — {{ qa.notes }}{% endif %}</p>
{% elif skipped %}<p class="bk-ask-was">left blank — pick one any time, or leave it{% if qa and qa.notes %}; note: {{ qa.notes }}{% endif %}</p>{% endif %}
<div class="bk-ask-opts">
{% for o in q.options %}
<label class="bk-ask-opt">
<input type="radio" name="{{ field }}" value="{{ o.id }}" required
<input type="radio" name="{{ field }}" value="{{ o.id }}"
{% if not standalone %}form="{{ form_id }}"{% endif %}
{% if qa and qa.choice == o.id %}checked{% endif %}>
<span class="bk-ask-lab"><span>{{ o.label }}</span>
@@ -82,7 +87,10 @@
<div class="bk-ask{% if a.answer %} bk-done{% endif %}" id="bk-ask-{{ a.stem }}-submit">
<form id="{{ form_id }}" method="post" action="/b/{{ name_url }}/answer"></form>
<input type="hidden" name="ask" value="{{ a.stem }}" form="{{ form_id }}">
<span class="bk-ask-tag">{% if a.answer %}✓ answered {{ a.answer.answered_at }}{% else %}? submit your picks{% endif %}</span>
<span class="bk-ask-tag">{% if a.answer and a.answer.complete %}✓ answered {{ a.answer.answered_at }}
{%- elif a.answer %}◐ {{ a.questions|length - (a.answer.unanswered|length) }} of {{ a.questions|length }} answered · {{ a.answer.answered_at }}
{%- else %}? submit your picks{% endif %}</span>
{% if not a.answer %}<p class="bk-ask-was">Answer what you can — blanks are fine, and you can come back.</p>{% endif %}
{% if a.notes %}
<textarea class="bk-ask-notes" name="notes" rows="3" form="{{ form_id }}"
placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
+5 -4
View File
@@ -8,11 +8,12 @@
collapsed "change" form, since the sidecar is the CURRENT answer. #}
<section class="asks">
{% for a in asks %}
<article class="ask{% if a.answer %} is-answered{% elif a.error %} is-broken{% endif %}" id="ask-{{ a.stem }}">
<article class="ask{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="ask-{{ a.stem }}">
<header class="ask-head">
<span class="ask-state">{% if a.error %}⚠ broken{% elif a.answer %}✓ answered{% else %}? open{% endif %}</span>
<span class="ask-state">{% if a.error %}⚠ broken{% elif a.answer and a.answer.complete %}✓ answered{% elif a.answer %}◐ partial{% else %}? open{% endif %}</span>
<span class="ask-stem"><code>{{ a.stem }}.ask.json</code>{% if a.multi %} · {{ a.questions|length }} questions{% endif %}</span>
<span class="board-spacer"></span>
{% if a.answer and not a.answer.complete %}<span class="ask-part">{{ (a.questions|length) - (a.answer.unanswered|length) }}/{{ a.questions|length }}</span>{% endif %}
{% if a.answer %}<span class="ask-when">{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}</span>{% endif %}
</header>
{% if a.error %}
@@ -26,7 +27,7 @@
{% for q in a.questions %}{% set qa = a.answer.answers.get(q.key) %}
<div class="ask-answer-q">
<span class="ask-answer-qprompt">{{ q.prompt }}</span>
<div class="ask-answer-choice">{{ qa.label if qa else '—' }}</div>
<div class="ask-answer-choice{% if not (qa and qa.choice is not none) %} is-skipped{% endif %}">{{ qa.label if (qa and qa.choice is not none) else 'left blank' }}</div>
{% if qa and qa.notes %}<pre class="ask-answer-notes">{{ qa.notes }}</pre>{% endif %}
</div>
{% endfor %}
@@ -52,7 +53,7 @@
<div class="ask-options">
{% for o in q.options %}
<label class="ask-opt{% if qa and qa.choice == o.id %} is-current{% endif %}">
<input type="radio" name="{{ field }}" value="{{ o.id }}" required
<input type="radio" name="{{ field }}" value="{{ o.id }}"
{% if qa and qa.choice == o.id %}checked{% endif %}>
<span class="ask-opt-main">
<span class="ask-opt-label">{{ o.label }}</span>
+8
View File
@@ -267,6 +267,14 @@
.ask{border:1px solid var(--border-subtle);border-top:2px solid var(--aus-bright-yellow);
border-radius:.5rem;background:var(--rk-panel);overflow:hidden}
.ask.is-answered{border-top-color:var(--aus-bright-green)}
/* Partial: answered SOME questions. Not a failure and not done — blanks are a
legal outcome (operator ruling 2026-09-09), so it gets its own state rather
than being forced into one of the other two. */
.ask.is-partial{border-top-color:var(--aus-bright-blue)}
.ask.is-partial .ask-state{color:var(--aus-bright-blue)}
.ask-part{font-family:var(--font-mono);font-size:.68rem;color:var(--aus-bright-blue);font-weight:700}
.ask-answer-choice.is-skipped{opacity:.55;font-style:italic}
.ask-answer-choice.is-skipped::before{content:"— ";color:var(--fg-3)}
.ask.is-broken{border-top-color:var(--aus-bright-red)}
.ask-head{display:flex;align-items:center;gap:.6rem;padding:.4rem .8rem;
border-bottom:1px solid var(--border-subtle);background:var(--rk-well);
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "booth"
version = "0.1.14"
version = "0.1.15"
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
requires-python = ">=3.11"
dependencies = [
+66 -5
View File
@@ -264,10 +264,40 @@ def test_normalize_multi_rejects(doc):
normalize_ask(doc, "s")
def test_write_answer_multi_requires_every_question(tmp_path):
def test_write_answer_multi_accepts_a_partial_answer(tmp_path):
"""Blanks are legal (operator ruling 2026-09-09): refusing the whole
submission because one of four was skipped threw away the three that were
made."""
_multi(tmp_path)
a = write_answer(tmp_path, "batch", {"r1": "keep"}) # r2 not submitted at all
assert a["complete"] is False and a["unanswered"] == ["r2"]
assert list(a["answers"]) == ["r1"]
b = write_answer(tmp_path, "batch", {"r1": "keep", "r2": ""}) # r2 an empty radio group
assert b["unanswered"] == ["r2"] and b["complete"] is False
# a note without a pick is still worth keeping
c = write_answer(tmp_path, "batch", {"r1": "", "r2": "k"}, qnotes={"r1": "undecided"})
assert c["answers"]["r1"] == {"prompt": "Render 1?", "choice": None,
"choice_index": None, "label": "", "notes": "undecided"}
assert c["unanswered"] == ["r1"]
# nothing at all is refused: it would flip the ask to answered with no decision
with pytest.raises(AskError):
write_answer(tmp_path, "batch", {"r1": "keep"}) # r2 missing
write_answer(tmp_path, "batch", {"r1": "", "r2": ""})
# ...but notes alone are a real submission
d = write_answer(tmp_path, "batch", {"r1": "", "r2": ""}, "ask me tomorrow")
assert d["complete"] is False and d["notes"] == "ask me tomorrow" and d["answers"] == {}
def test_single_ask_may_be_answered_with_notes_only(tmp_path):
_ask(tmp_path)
with pytest.raises(AskError):
write_answer(tmp_path, "winner", "")
a = write_answer(tmp_path, "winner", "", "neither is right, rerun")
assert a["choice"] is None and a["complete"] is False
assert a["notes"] == "neither is right, rerun"
def test_write_answer_multi_still_rejects_a_bad_option(tmp_path):
_multi(tmp_path)
with pytest.raises(AskError):
write_answer(tmp_path, "batch", {"r1": "keep", "r2": "nope"})
with pytest.raises(AskError):
@@ -290,9 +320,12 @@ def test_multi_page_and_route(client):
assert 'name="choice.r1"' in html and 'name="choice.r2"' in html
assert 'name="notes.r1"' in html and 'name="notes.r2"' not in html
assert 'name="notes"' in html
# incomplete submission → 400, nothing written
assert c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep"}).status_code == 400
assert not (data / "b" / f"batch{ANSWER_SUFFIX}").exists()
# a partial submission is RECORDED, not refused
assert c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep"},
follow_redirects=False).status_code == 303
part = json.loads((data / "b" / f"batch{ANSWER_SUFFIX}").read_text())
assert part["complete"] is False and part["unanswered"] == ["r2"]
assert "1/2" in c.get("/b/b/").text and "partial" in c.get("/b/b/").text
r = c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep", "notes.r1": "crisp",
"choice.r2": "d", "notes": "ship r1"}, follow_redirects=False)
assert r.status_code == 303
@@ -467,3 +500,31 @@ def test_styles_are_emitted_once(client):
b = _multi(data / "b")
(b / "index.html").write_text(REPORT)
assert c.get("/b/b/").text.count(".bk-ask-opt:has(input:checked)") == 1
def test_radios_are_not_html_required_anywhere(client):
"""The browser must not block a partial submit — `required` on a radio group
is exactly what stopped the operator leaving one blank."""
c, data = client
b = _multi(data / "b")
assert "required" not in c.get("/b/b/").text
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch"></div></body>')
assert "required" not in c.get("/b/b/").text
assert "required" not in c.get("/b/b/asks").text
def test_partial_answer_renders_as_skipped_inline(client):
c, data = client
b = _multi(data / "b")
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch"></div></body>')
c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep"})
html = c.get("/b/b/").text
assert "bk-skip" in html and "left blank" in html
assert "1 of 2 answered" in html
def test_empty_submission_is_refused_with_400(client):
c, data = client
b = _multi(data / "b")
assert c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "", "choice.r2": ""}).status_code == 400
assert read_answer(b, "batch") is None # the ask stays OPEN, not falsely answered