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 6d36119bc5
commit 88d3cf436e
7 changed files with 179 additions and 35 deletions
+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")
+13 -5
View File
@@ -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);