feat(booth): multi-question asks — a questions list renders one form with a radio group per question and lands as one answer sidecar keyed by question
- asks.py: single {prompt, options} and multi {title, questions:[{key, prompt, options, notes?}]} both normalise to questions[]; per-question notes; every question required on submit
- /answer reads choice.<key> / notes.<key> / notes for multi; single shape unchanged
- booth asks prints per-question picks; README + CLI header; install step symlinks the CLI to ~/.local/bin; v0.1.10; 135 tests
This commit is contained in:
@@ -121,15 +121,38 @@ EOF
|
||||
curl -sf http://10.100.10.50:8090/b/r18-ab/winner.answer.json # 404 until answered
|
||||
```
|
||||
|
||||
The answer: `{"stem", "prompt", "choice", "choice_index", "label", "notes",
|
||||
**Several questions, one form.** Give the ask a `questions` list instead of
|
||||
`prompt`+`options`; the page renders one form with a radio group per question
|
||||
and a single submit, every question required. Per-question `notes: true` adds
|
||||
a small text field under that question; the form-level `notes` stays one field
|
||||
for the whole ask. The answer is keyed by question:
|
||||
|
||||
```bash
|
||||
cat > ~/booth-data/r18-ab/batch.ask.json <<'EOF'
|
||||
{"title": "R18 batch review",
|
||||
"questions": [
|
||||
{"key": "r1", "prompt": "Render 1 — keep?", "options": ["keep", "drop"], "notes": true},
|
||||
{"key": "r2", "prompt": "Render 2 — keep?", "options": ["keep", "drop"]},
|
||||
{"key": "seed", "prompt": "Reseed the batch?", "options": ["yes", "no"]}],
|
||||
"notes": true, "notes_label": "anything else"}
|
||||
EOF
|
||||
# -> batch.answer.json: {"stem", "title", "answers": {"r1": {"prompt", "choice",
|
||||
# "choice_index", "label", "notes"}, "r2": {...}, "seed": {...}}, "notes", "answered_at", "answered_by"}
|
||||
```
|
||||
|
||||
The single-question answer: `{"stem", "prompt", "choice", "choice_index", "label", "notes",
|
||||
"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` (form fields `ask`, `choice`, `notes`)
|
||||
is what the form submits; a bad choice is a 400, an unknown stem a 404.
|
||||
client address. `POST /b/<name>/answer` is what the form submits — fields `ask` plus
|
||||
`choice` / `notes` (single) or `choice.<key>` / `notes.<key>` / `notes` (multi);
|
||||
a missing or bad choice is a 400, an unknown stem a 404.
|
||||
|
||||
Rules of the primitive:
|
||||
|
||||
- **Radio, one pick.** ≥ 2 options, ≤ 40. No multi-select (not yet asked for).
|
||||
- **Radio, one pick per question.** ≥ 2 options, ≤ 40 per question, ≤ 30
|
||||
questions per ask. No multi-select checkboxes (not yet asked for). Many asks
|
||||
per booth are fine — each is its own form and its own sidecar; use
|
||||
`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.
|
||||
- **Open asks are flagged** — an amber `? N asks` badge on the index card and in
|
||||
@@ -293,6 +316,7 @@ Config is env in the unit (`booth.service`):
|
||||
```bash
|
||||
cd services/booth
|
||||
uv venv && uv pip install fastapi "uvicorn[standard]" jinja2 python-multipart # runtime deps
|
||||
ln -sfn "$PWD/scripts/booth" ~/.local/bin/booth # the `booth` CLI on PATH (nh3-dev has this)
|
||||
cp booth.service ~/.config/systemd/user/booth.service
|
||||
systemctl --user daemon-reload && systemctl --user enable --now booth.service
|
||||
```
|
||||
|
||||
@@ -82,6 +82,7 @@ from booth.asks import ( # noqa: E402
|
||||
is_answer_file,
|
||||
is_ask_file,
|
||||
list_asks,
|
||||
load_ask,
|
||||
valid_stem,
|
||||
write_answer,
|
||||
)
|
||||
@@ -654,18 +655,30 @@ def create_app(
|
||||
)
|
||||
|
||||
@app.post("/b/{name}/answer")
|
||||
def booth_answer(request: Request, name: str, ask: str = Form(...), choice: str = Form(...),
|
||||
notes: str = Form("")):
|
||||
"""Record the operator's answer to one ask: validates the choice
|
||||
against the ask's options and writes `<stem>.answer.json` atomically.
|
||||
Re-submitting overwrites — the sidecar is the current answer. 404 for
|
||||
an unknown/invalid stem, 400 for a choice the ask does not offer."""
|
||||
async def booth_answer(request: Request, name: str):
|
||||
"""Record the operator's answer to one ask: validates every choice
|
||||
against the ask and writes `<stem>.answer.json` atomically.
|
||||
Re-submitting overwrites — the sidecar is the current answer.
|
||||
|
||||
Form fields: `ask` (stem); single-question → `choice` + `notes`;
|
||||
multi-question → `choice.<key>` per question, optional `notes.<key>`,
|
||||
plus the form-level `notes`. 404 for an unknown/invalid stem, 400 for
|
||||
a missing choice or one the ask does not offer.
|
||||
"""
|
||||
booth = resolve_booth(name)
|
||||
if not valid_stem(ask) or not (booth / f"{ask}{ASK_SUFFIX}").is_file():
|
||||
form = await request.form()
|
||||
ask = form.get("ask")
|
||||
if not isinstance(ask, str) or not valid_stem(ask) or not (booth / f"{ask}{ASK_SUFFIX}").is_file():
|
||||
raise HTTPException(status_code=404, detail="no such ask")
|
||||
who = request.client.host if request.client else ""
|
||||
try:
|
||||
write_answer(booth, ask, choice, notes, who=who)
|
||||
spec = load_ask(booth, ask)
|
||||
if spec["multi"]:
|
||||
choice = {q["key"]: form.get(f"choice.{q['key']}") for q in spec["questions"]}
|
||||
qnotes = {q["key"]: form.get(f"notes.{q['key']}") for q in spec["questions"]}
|
||||
write_answer(booth, ask, choice, form.get("notes", ""), who=who, qnotes=qnotes)
|
||||
else:
|
||||
write_answer(booth, ask, form.get("choice"), form.get("notes", ""), who=who)
|
||||
except AskError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return RedirectResponse(url=f"/b/{quote(name, safe='')}/#ask-{quote(ask, safe='')}", status_code=303)
|
||||
|
||||
+163
-43
@@ -27,6 +27,15 @@ Answer schema (what the operator's submit writes, atomically):
|
||||
"answered_at": "2026-09-09T07:12:03-07:00",
|
||||
"answered_by": "10.100.10.20"}
|
||||
|
||||
Multi-question form (one submit, one sidecar):
|
||||
|
||||
{"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}
|
||||
-> {"stem", "title", "answers": {"q1": {"prompt", "choice", "choice_index", "label", "notes"}, …},
|
||||
"notes", "answered_at", "answered_by"}
|
||||
|
||||
Re-answering overwrites: the sidecar is the current answer, not a log. A
|
||||
session that wants history keeps its own.
|
||||
"""
|
||||
@@ -47,8 +56,10 @@ 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):
|
||||
@@ -71,19 +82,11 @@ def valid_stem(stem: str) -> bool:
|
||||
return bool(_STEM_RE.match(stem)) and ".." not in stem
|
||||
|
||||
|
||||
def normalize_ask(raw: dict, stem: str) -> dict:
|
||||
"""Validate + normalise an ask document. Raises AskError on anything the
|
||||
renderer could not honour. Options come back as [{id, label, detail}]."""
|
||||
if not isinstance(raw, dict):
|
||||
raise AskError("ask must be a JSON object")
|
||||
prompt = raw.get("prompt")
|
||||
if not isinstance(prompt, str) or not prompt.strip():
|
||||
raise AskError("ask needs a non-empty string `prompt`")
|
||||
opts_in = raw.get("options")
|
||||
def _normalize_options(opts_in, where: str) -> list[dict]:
|
||||
if not isinstance(opts_in, list) or len(opts_in) < 2:
|
||||
raise AskError("ask needs a list `options` with at least 2 entries")
|
||||
raise AskError(f"{where} needs a list `options` with at least 2 entries")
|
||||
if len(opts_in) > MAX_OPTIONS:
|
||||
raise AskError(f"too many options (max {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):
|
||||
@@ -92,32 +95,109 @@ def normalize_ask(raw: dict, stem: str) -> dict:
|
||||
elif isinstance(o, dict):
|
||||
label = o.get("label")
|
||||
if not isinstance(label, str) or not label.strip():
|
||||
raise AskError(f"option {i} needs a non-empty string `label`")
|
||||
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"option {i} has a bad `id`")
|
||||
raise AskError(f"{where} option {i} has a bad `id`")
|
||||
if not isinstance(detail, str):
|
||||
raise AskError(f"option {i} has a non-string `detail`")
|
||||
raise AskError(f"{where} option {i} has a non-string `detail`")
|
||||
else:
|
||||
raise AskError(f"option {i} must be a string or an object")
|
||||
raise AskError(f"{where} option {i} must be a string or an object")
|
||||
oid = oid.strip()
|
||||
if oid in seen:
|
||||
raise AskError(f"duplicate option id {oid!r}")
|
||||
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]})
|
||||
notes = raw.get("notes", True)
|
||||
if not isinstance(notes, bool):
|
||||
raise AskError("`notes` must be true/false")
|
||||
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")
|
||||
return {
|
||||
"stem": stem,
|
||||
"multi": False,
|
||||
"title": "",
|
||||
"prompt": prompt.strip()[:PROMPT_MAX],
|
||||
"options": options,
|
||||
"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.strip()[:80] or "notes",
|
||||
"notes_label": notes_label,
|
||||
}
|
||||
|
||||
|
||||
@@ -160,8 +240,9 @@ def list_asks(booth: Path) -> list[dict]:
|
||||
try:
|
||||
ask = load_ask(booth, stem)
|
||||
except AskError as exc:
|
||||
out.append({"stem": stem, "prompt": None, "options": [], "notes": False,
|
||||
"notes_label": "notes", "error": str(exc), "answer": None})
|
||||
out.append({"stem": stem, "multi": False, "title": "", "prompt": None, "questions": [],
|
||||
"options": [], "notes": False, "notes_label": "notes",
|
||||
"error": str(exc), "answer": None})
|
||||
continue
|
||||
ask["error"] = None
|
||||
ask["answer"] = read_answer(booth, stem)
|
||||
@@ -169,25 +250,61 @@ def list_asks(booth: Path) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def write_answer(booth: Path, stem: str, choice: str, notes: str = "", who: str = "") -> dict:
|
||||
"""Record the operator's answer. Validates the choice against the ask,
|
||||
writes `<stem>.answer.json` via temp-file + os.replace so a reader never
|
||||
sees a half-written document. Returns the answer written."""
|
||||
ask = load_ask(booth, stem) # raises AskError if the ask is gone/invalid
|
||||
idx = next((i for i, o in enumerate(ask["options"]) if o["id"] == choice), None)
|
||||
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("choice is not one of the ask's options")
|
||||
notes = (notes or "").replace("\r\n", "\n").strip()[:NOTES_MAX]
|
||||
answer = {
|
||||
"stem": stem,
|
||||
"prompt": ask["prompt"],
|
||||
"choice": choice,
|
||||
"choice_index": idx,
|
||||
"label": ask["options"][idx]["label"],
|
||||
"notes": notes if ask["notes"] else "",
|
||||
raise AskError(f"{where}: choice is not one of the options")
|
||||
return idx, options[idx]
|
||||
|
||||
|
||||
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,
|
||||
writes `<stem>.answer.json` via temp-file + os.replace so a reader never
|
||||
sees a half-written document. Returns the answer written.
|
||||
|
||||
`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).
|
||||
"""
|
||||
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 "",
|
||||
}
|
||||
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] = {}
|
||||
for q in ask["questions"]:
|
||||
idx, opt = _pick(q["options"], choice.get(q["key"]), 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 "",
|
||||
}
|
||||
answer = {"stem": stem, "title": ask["title"], "answers": answers,
|
||||
"notes": _clean_notes(notes) if ask["notes"] else "", **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,
|
||||
}
|
||||
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")
|
||||
@@ -195,13 +312,16 @@ def write_answer(booth: Path, stem: str, choice: str, notes: str = "", who: str
|
||||
return answer
|
||||
|
||||
|
||||
def write_ask(booth: Path, stem: str, prompt: str, options: list, notes: bool = True,
|
||||
notes_label: str = "notes") -> Path:
|
||||
"""Author an ask from code/CLI. Validates through the same normaliser the
|
||||
renderer uses, so a session cannot post a question the page would reject."""
|
||||
def write_ask(booth: Path, stem: str, prompt: str | None = None, options: list | None = None,
|
||||
notes: bool = True, notes_label: str = "notes", doc: dict | None = None) -> Path:
|
||||
"""Author an ask from code/CLI. Either (prompt, options, ...) for a
|
||||
single-question ask, or `doc=` a full document (single or multi shape).
|
||||
Validated through the same normaliser the renderer uses, so a session
|
||||
cannot post a question the page would reject."""
|
||||
if not valid_stem(stem):
|
||||
raise AskError("bad stem: letters, digits, . _ - only")
|
||||
doc = {"prompt": prompt, "options": options, "notes": notes, "notes_label": notes_label}
|
||||
if doc is None:
|
||||
doc = {"prompt": prompt, "options": options, "notes": notes, "notes_label": notes_label}
|
||||
normalize_ask(doc, stem)
|
||||
booth = Path(booth)
|
||||
booth.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -291,6 +291,13 @@
|
||||
.ask-change::-webkit-details-marker{display:none}
|
||||
.ask-formwrap[open]>.ask-change{margin-bottom:.4rem}
|
||||
.ask-formwrap:not([open])>.ask-change{color:var(--aus-bright-cyan)}
|
||||
.ask-q{border:0;margin:0 0 .7rem;padding:0;min-width:0}
|
||||
.ask-q:last-of-type{margin-bottom:0}
|
||||
.ask-q-prompt{padding:0;margin:0 0 .35rem;font-size:.9rem;font-weight:600;color:var(--fg-0)}
|
||||
.ask-qnotes{margin-top:.35rem;font-size:.82rem}
|
||||
.ask-answer-q{padding:.3rem 0;border-bottom:1px dashed var(--border-subtle)}
|
||||
.ask-answer-q:last-of-type{border-bottom:0}
|
||||
.ask-answer-qprompt{display:block;font-size:.76rem;color:var(--fg-3)}
|
||||
.ask-options{display:flex;flex-direction:column;gap:.35rem}
|
||||
.ask-opt{display:flex;align-items:flex-start;gap:.6rem;padding:.5rem .65rem;cursor:pointer;
|
||||
border:1px solid var(--border-subtle);border-radius:var(--radius-md);background:var(--rk-well);
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<article class="ask{% if a.answer %} is-answered{% 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-stem"><code>{{ a.stem }}.ask.json</code></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 %}<span class="ask-when">{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}</span>{% endif %}
|
||||
</header>
|
||||
@@ -46,7 +46,17 @@
|
||||
<p class="ask-prompt">{{ a.prompt }}</p>
|
||||
{% if a.answer %}
|
||||
<div class="ask-answer">
|
||||
<div class="ask-answer-choice">{{ a.answer.label }}</div>
|
||||
{% if a.multi %}
|
||||
{% 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>
|
||||
{% if qa and qa.notes %}<pre class="ask-answer-notes">{{ qa.notes }}</pre>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="ask-answer-choice">{{ a.answer.label }}</div>
|
||||
{% endif %}
|
||||
{% if a.answer.notes %}<pre class="ask-answer-notes">{{ a.answer.notes }}</pre>{% endif %}
|
||||
<span class="ask-answer-file">→ <a href="{{ a.stem }}.answer.json">{{ a.stem }}.answer.json</a></span>
|
||||
</div>
|
||||
@@ -55,18 +65,28 @@
|
||||
<summary class="ask-change">{% if a.answer %}change answer{% else %}answer{% endif %}</summary>
|
||||
<form class="ask-form" method="post" action="/b/{{ name_url }}/answer">
|
||||
<input type="hidden" name="ask" value="{{ a.stem }}">
|
||||
<div class="ask-options">
|
||||
{% for o in a.options %}
|
||||
<label class="ask-opt{% if a.answer and a.answer.choice == o.id %} is-current{% endif %}">
|
||||
<input type="radio" name="choice" value="{{ o.id }}" required
|
||||
{% if a.answer and a.answer.choice == o.id %}checked{% endif %}>
|
||||
<span class="ask-opt-main">
|
||||
<span class="ask-opt-label">{{ o.label }}</span>
|
||||
{% if o.detail %}<span class="ask-opt-detail">{{ o.detail }}</span>{% endif %}
|
||||
</span>
|
||||
</label>
|
||||
{% for q in a.questions %}
|
||||
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
|
||||
{% set qa = a.answer.answers.get(q.key) if (a.answer and a.multi) else a.answer %}
|
||||
<fieldset class="ask-q">
|
||||
{% if a.multi %}<legend class="ask-q-prompt">{{ loop.index }}. {{ q.prompt }}</legend>{% endif %}
|
||||
<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
|
||||
{% if qa and qa.choice == o.id %}checked{% endif %}>
|
||||
<span class="ask-opt-main">
|
||||
<span class="ask-opt-label">{{ o.label }}</span>
|
||||
{% if o.detail %}<span class="ask-opt-detail">{{ o.detail }}</span>{% endif %}
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if q.notes %}
|
||||
<textarea class="ask-notes ask-qnotes" name="notes.{{ q.key }}" rows="2" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if a.notes %}
|
||||
<textarea class="ask-notes" name="notes" rows="3" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "booth"
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
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 = [
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
# writes <stem>.answer.json next to it. `answer --wait` blocks until that file
|
||||
# exists and prints it, so a session can `booth ask … && booth answer --wait …`
|
||||
# and carry on. Re-answering overwrites: the sidecar is the CURRENT answer.
|
||||
# Several questions in ONE form: write <stem>.ask.json by hand with a
|
||||
# `questions` list (see services/booth/README.md § Asks); `asks` and `answer`
|
||||
# handle both shapes.
|
||||
# Remote sessions: rsync the ask in, then poll
|
||||
# http://10.100.10.50:8090/b/<name>/<stem>.answer.json (404 until answered).
|
||||
#
|
||||
@@ -226,8 +229,13 @@ if not asks:
|
||||
for a in asks:
|
||||
if a["error"]:
|
||||
state = "BROKEN " + a["error"]
|
||||
elif a["answer"] and a["multi"]:
|
||||
picks = ", ".join("%s=%s" % (k, v["label"]) for k, v in a["answer"]["answers"].items())
|
||||
state = "answered %s (%s)" % (picks, a["answer"]["answered_at"])
|
||||
elif a["answer"]:
|
||||
state = "answered %s (%s)" % (a["answer"]["label"], a["answer"]["answered_at"])
|
||||
elif a["multi"]:
|
||||
state = "open (%d questions)" % len(a["questions"])
|
||||
else:
|
||||
state = "open"
|
||||
print("%-24s %s" % (a["stem"], state))
|
||||
|
||||
@@ -214,3 +214,97 @@ def test_index_card_shows_open_ask_badge(client):
|
||||
_ask(data / "b")
|
||||
html = c.get("/").text
|
||||
assert "1 ask" in html
|
||||
|
||||
|
||||
# ---- multi-question asks ----------------------------------------------------
|
||||
|
||||
|
||||
MULTI = {
|
||||
"title": "R18 batch review",
|
||||
"questions": [
|
||||
{"key": "r1", "prompt": "Render 1?", "options": ["keep", "drop"], "notes": True},
|
||||
{"key": "r2", "prompt": "Render 2?", "options": [{"id": "k", "label": "keep"}, {"id": "d", "label": "drop"}]},
|
||||
],
|
||||
"notes": True,
|
||||
}
|
||||
|
||||
|
||||
def _multi(booth, stem="batch", **kw):
|
||||
doc = json.loads(json.dumps(MULTI)); doc.update(kw)
|
||||
booth.mkdir(parents=True, exist_ok=True)
|
||||
(booth / f"{stem}{ASK_SUFFIX}").write_text(json.dumps(doc))
|
||||
return booth
|
||||
|
||||
|
||||
def test_normalize_multi():
|
||||
a = normalize_ask(MULTI, "batch")
|
||||
assert a["multi"] is True and a["title"] == "R18 batch review"
|
||||
assert [q["key"] for q in a["questions"]] == ["r1", "r2"]
|
||||
assert a["questions"][0]["notes"] is True and a["questions"][1]["notes"] is False
|
||||
assert a["questions"][1]["options"][0] == {"id": "k", "label": "keep", "detail": ""}
|
||||
# single stays single, and exposes ONE question with key None
|
||||
s = normalize_ask({"prompt": "p", "options": ["a", "b"]}, "s")
|
||||
assert s["multi"] is False and s["questions"][0]["key"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"doc",
|
||||
[
|
||||
{"questions": []},
|
||||
{"questions": [{"prompt": "p", "options": ["a", "b"]}]}, # no key
|
||||
{"questions": [{"key": "bad key", "prompt": "p", "options": ["a", "b"]}]},
|
||||
{"questions": [{"key": "x", "prompt": "p", "options": ["a", "b"]},
|
||||
{"key": "x", "prompt": "q", "options": ["a", "b"]}]}, # dup key
|
||||
{"questions": [{"key": "x", "prompt": "p", "options": ["only"]}]},
|
||||
{"prompt": "p", "options": ["a", "b"], "questions": [{"key": "x", "prompt": "p", "options": ["a", "b"]}]},
|
||||
],
|
||||
)
|
||||
def test_normalize_multi_rejects(doc):
|
||||
with pytest.raises(AskError):
|
||||
normalize_ask(doc, "s")
|
||||
|
||||
|
||||
def test_write_answer_multi_requires_every_question(tmp_path):
|
||||
_multi(tmp_path)
|
||||
with pytest.raises(AskError):
|
||||
write_answer(tmp_path, "batch", {"r1": "keep"}) # r2 missing
|
||||
with pytest.raises(AskError):
|
||||
write_answer(tmp_path, "batch", {"r1": "keep", "r2": "nope"})
|
||||
with pytest.raises(AskError):
|
||||
write_answer(tmp_path, "batch", "keep") # wrong shape
|
||||
ans = write_answer(tmp_path, "batch", {"r1": "drop", "r2": "k"}, "overall fine",
|
||||
qnotes={"r1": "banding", "r2": "ignored: notes off"})
|
||||
assert list(ans["answers"]) == ["r1", "r2"]
|
||||
assert ans["answers"]["r1"] == {"prompt": "Render 1?", "choice": "drop", "choice_index": 1,
|
||||
"label": "drop", "notes": "banding"}
|
||||
assert ans["answers"]["r2"]["choice"] == "k" and ans["answers"]["r2"]["notes"] == ""
|
||||
assert ans["notes"] == "overall fine" and ans["title"] == "R18 batch review"
|
||||
assert read_answer(tmp_path, "batch") == ans
|
||||
|
||||
|
||||
def test_multi_page_and_route(client):
|
||||
c, data = client
|
||||
_multi(data / "b")
|
||||
html = c.get("/b/b/").text
|
||||
assert "R18 batch review" in html and "2 questions" in html
|
||||
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()
|
||||
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
|
||||
ans = c.get("/b/b/batch.answer.json").json()
|
||||
assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r1"]["notes"] == "crisp"
|
||||
assert ans["answers"]["r2"]["choice"] == "d" and ans["notes"] == "ship r1"
|
||||
html = c.get("/b/b/").text
|
||||
assert "answered" in html and "crisp" in html and "ship r1" in html
|
||||
|
||||
|
||||
def test_write_ask_accepts_full_doc(tmp_path):
|
||||
write_ask(tmp_path / "b", "batch", doc=MULTI)
|
||||
assert load_ask(tmp_path / "b", "batch")["multi"] is True
|
||||
with pytest.raises(AskError):
|
||||
write_ask(tmp_path / "b", "bad", doc={"questions": []})
|
||||
|
||||
Reference in New Issue
Block a user