diff --git a/services/booth/README.md b/services/booth/README.md index be610b6..3730f50 100644 --- a/services/booth/README.md +++ b/services/booth/README.md @@ -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//answer` is what the form submits — fields `ask` plus `choice` / `notes` (single) or `choice.` / `notes.` / `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 diff --git a/services/booth/booth/asks.py b/services/booth/booth/asks.py index 56d2885..3b631b5 100644 --- a/services/booth/booth/asks.py +++ b/services/booth/booth/asks.py @@ -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 `.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") diff --git a/services/booth/booth/templates/_ask_inline.html b/services/booth/booth/templates/_ask_inline.html index 3680c97..f5c406e 100644 --- a/services/booth/booth/templates/_ask_inline.html +++ b/services/booth/booth/templates/_ask_inline.html @@ -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 %} -
- {% if qa %}✓ answered{% else %}? your pick{% endif %} +{% set picked = qa and qa.choice is not none %} +{% set skipped = a.answer and not picked %} +
+ {% if picked %}✓ answered{% elif skipped %}— skipped{% else %}? your pick{% endif %}

{{ q.prompt }}

- {% if qa %}

recorded: {{ qa.label }}{% if qa.notes %} — {{ qa.notes }}{% endif %}

{% endif %} + {% if picked %}

recorded: {{ qa.label }}{% if qa.notes %} — {{ qa.notes }}{% endif %}

+ {% elif skipped %}

left blank — pick one any time, or leave it{% if qa and qa.notes %}; note: {{ qa.notes }}{% endif %}

{% endif %}
{% for o in q.options %}