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:
+66
-19
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user