feat(booth): asks — a multiple-choice question a session poses in a booth, answered by the operator as a radio form + notes, written back as an answer sidecar

- booth/asks.py (stdlib): <stem>.ask.json question / <stem>.answer.json answer; normalise+validate, atomic write, list with answer folded in, broken asks surfaced not hidden
- POST /b/<name>/answer: validates choice against the ask (400), unknown stem 404, re-answer overwrites
- booth.html asks panel above the gallery; amber open / green answered; JS-off form POST; index card + booth header badge for open asks
- CLI: booth ask / asks / answer [--wait [SECS]]; remote sessions poll <stem>.answer.json over HTTP
- ask/answer files excluded from gallery items and item counts; 23 tests; v0.1.9
This commit is contained in:
vh
2026-09-09 07:24:16 -07:00
parent 8060f8cb9a
commit 97589dd062
9 changed files with 721 additions and 6 deletions
+47 -2
View File
@@ -75,6 +75,16 @@ KEEP_MARKER = ".forever"
# The link-board logic lives in booth/links.py (stdlib only) so the `booth` CLI
# can use it without pulling FastAPI in. Re-exported here because call sites and
# tests already reference these names through app.
from booth.asks import ( # noqa: E402
ANSWER_SUFFIX,
ASK_SUFFIX,
AskError,
is_answer_file,
is_ask_file,
list_asks,
valid_stem,
write_answer,
)
from booth.links import ( # noqa: E402
LINK_LOCK,
LINKS_FILE,
@@ -215,7 +225,14 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
for child in data_dir.iterdir():
if not child.is_dir() or child.name.startswith("."):
continue
files = [p for p in child.rglob("*") if p.is_file() and not p.name.startswith(".")]
files = [
p for p in child.rglob("*")
if p.is_file() and not p.name.startswith(".")
and not is_ask_file(p.name) and not is_answer_file(p.name)
]
# Asks are questions, not items: counted separately so the index can
# flag a booth that is waiting on the operator.
asks = list_asks(child)
kinds = {"image": 0, "video": 0, "audio": 0, "other": 0}
thumb_url = None
for f in files:
@@ -234,6 +251,8 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
"has_index": (child / "index.html").is_file(),
"uploaded": (child / UPLOAD_MARKER).exists(),
"kept": is_kept(child),
"asks_total": len(asks),
"asks_open": sum(1 for a in asks if a["answer"] is None and not a["error"]),
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
"mtime": mtime,
}
@@ -249,7 +268,12 @@ def build_gallery(child: Path) -> list[dict]:
next to `a.png`) is consumed as that item's caption rather than shown itself —
the natural way to label an A/B pair.
"""
all_files = [p for p in child.rglob("*") if p.is_file() and not p.name.startswith(".")]
all_files = [
p for p in child.rglob("*")
if p.is_file() and not p.name.startswith(".")
# `*.ask.json` / `*.answer.json` render as the asks panel, not as tiles
and not is_ask_file(p.name) and not is_answer_file(p.name)
]
by_rel = {p.relative_to(child).as_posix(): p for p in all_files}
caption: dict[str, str] = {}
sidecars: set[str] = set()
@@ -620,11 +644,32 @@ def create_app(
)
if (booth / LINKS_FILE).is_file() else []
),
# Asks: multiple-choice questions a session left for the
# operator, rendered as forms above the gallery (open ones)
# or as their recorded answer. See booth/asks.py.
"asks": list_asks(booth),
"uploaded": (booth / UPLOAD_MARKER).exists(),
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
},
)
@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."""
booth = resolve_booth(name)
if 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)
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)
@app.get("/b/{name}/view", response_class=HTMLResponse)
def booth_view_file(request: Request, name: str, f: str):
booth = resolve_booth(name)
+212
View File
@@ -0,0 +1,212 @@
"""Asks: a session poses a multiple-choice question in a booth; the operator
answers it in the browser; the answer lands as a sidecar the session reads.
STDLIB ONLY, like links.py, so the `booth` CLI can write an ask and read an
answer without the service's venv.
Filesystem is the state, same as everything else in the Booth:
<booth>/<stem>.ask.json the question (written by a session)
<booth>/<stem>.answer.json the answer (written by the web UI)
Ask schema (what a session writes):
{"prompt": "Which render wins?",
"options": ["A — baseline", "B — cudaMallocAsync"], # ≥ 2, strings or
# [{"id": "a", "label": "A — baseline", "detail": "…"}, …]
"notes": true, # optional, default true: show a free-text field
"notes_label": "why?"} # optional placeholder for that field
Answer schema (what the operator's submit writes, atomically):
{"stem": "winner", "prompt": "…",
"choice": "b", # the option id (== label for string options)
"choice_index": 1, # 0-based position in `options`
"label": "B — cudaMallocAsync",
"notes": "less banding on the gradient",
"answered_at": "2026-09-09T07:12:03-07:00",
"answered_by": "10.100.10.20"}
Re-answering overwrites: the sidecar is the current answer, not a log. A
session that wants history keeps its own.
"""
from __future__ import annotations
import json
import os
import re
from datetime import datetime
from pathlib import Path
ASK_SUFFIX = ".ask.json"
ANSWER_SUFFIX = ".answer.json"
PROMPT_MAX = 2000
LABEL_MAX = 400
DETAIL_MAX = 1000
NOTES_MAX = 8000
MAX_OPTIONS = 40
_STEM_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$")
class AskError(ValueError):
"""An ask file that cannot be rendered — reported, never a crash."""
def is_ask_file(name: str) -> bool:
return name.endswith(ASK_SUFFIX) and len(name) > len(ASK_SUFFIX)
def is_answer_file(name: str) -> bool:
return name.endswith(ANSWER_SUFFIX) and len(name) > len(ANSWER_SUFFIX)
def ask_stem(name: str) -> str:
return name[: -len(ASK_SUFFIX)]
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")
if not isinstance(opts_in, list) or len(opts_in) < 2:
raise AskError("ask needs a list `options` with at least 2 entries")
if len(opts_in) > MAX_OPTIONS:
raise AskError(f"too many options (max {MAX_OPTIONS})")
options: list[dict] = []
seen: set[str] = set()
for i, o in enumerate(opts_in):
if isinstance(o, str):
oid, label, detail = o, o, ""
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`")
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`")
if not isinstance(detail, str):
raise AskError(f"option {i} has a non-string `detail`")
else:
raise AskError(f"option {i} must be a string or an object")
oid = oid.strip()
if oid in seen:
raise AskError(f"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")
notes_label = raw.get("notes_label", "notes")
if not isinstance(notes_label, str):
raise AskError("`notes_label` must be a string")
return {
"stem": stem,
"prompt": prompt.strip()[:PROMPT_MAX],
"options": options,
"notes": notes,
"notes_label": notes_label.strip()[:80] or "notes",
}
def load_ask(booth: Path, stem: str) -> dict:
"""Parsed + normalised ask for `stem`. Raises AskError if unreadable/invalid."""
path = Path(booth) / f"{stem}{ASK_SUFFIX}"
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
raise AskError("no such ask")
except (OSError, ValueError) as exc:
raise AskError(f"unreadable ask: {exc}")
return normalize_ask(raw, stem)
def read_answer(booth: Path, stem: str) -> dict | None:
path = Path(booth) / f"{stem}{ANSWER_SUFFIX}"
try:
data = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return None
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def list_asks(booth: Path) -> list[dict]:
"""Every ask in a booth (top level only), oldest first by file mtime, each
with its current answer folded in (`answer` is None while open). An invalid
ask file is returned with `error` set so the page can say so instead of
silently hiding the question a session thinks it posted."""
booth = Path(booth)
out: list[dict] = []
if not booth.is_dir():
return out
files = [p for p in booth.iterdir() if p.is_file() and not p.name.startswith(".") and is_ask_file(p.name)]
files.sort(key=lambda p: (p.stat().st_mtime, p.name))
for p in files:
stem = ask_stem(p.name)
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})
continue
ask["error"] = None
ask["answer"] = read_answer(booth, stem)
out.append(ask)
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)
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 "",
"answered_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"answered_by": who or "",
}
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")
os.replace(tmp, path)
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."""
if not valid_stem(stem):
raise AskError("bad stem: letters, digits, . _ - only")
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)
path = booth / f"{stem}{ASK_SUFFIX}"
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(tmp, path)
return path
+54
View File
@@ -257,6 +257,60 @@
backdrop-filter:blur(6px);transition:.14s var(--ease-out)}
.wipe button:hover{border-color:var(--aus-red);color:#fff;background:var(--aus-red)}
/* Asks — a session's multiple-choice question awaiting the operator.
Amber = "needs you" while open (the one colour the page does not otherwise
use for state), green check once answered; the accent is a TOP edge, per
Australis, never a coloured left border. */
.badge-ask{background:var(--aus-bright-yellow);color:var(--fg-on-accent)}
.thumb .badge+.badge-ask{top:2.2rem}
.asks{display:flex;flex-direction:column;gap:.9rem;margin:.2rem 0 1.4rem}
.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)}
.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);
font-family:var(--font-mono);font-size:.68rem;color:var(--fg-3)}
.ask-state{font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--aus-bright-yellow)}
.ask.is-answered .ask-state{color:var(--aus-bright-green)}
.ask.is-broken .ask-state{color:var(--aus-bright-red)}
.ask-when{white-space:nowrap}
.ask-prompt{margin:.85rem .9rem .5rem;font-size:1.02rem;font-weight:600;color:var(--fg-0);white-space:pre-wrap}
.ask-error{margin:.8rem .9rem;color:var(--aus-bright-red);font-size:.85rem}
.ask-answer{margin:.2rem .9rem .6rem;padding:.55rem .75rem;border:1px solid var(--border-subtle);
border-radius:var(--radius-md);background:rgba(81,224,138,.06)}
.ask-answer-choice{font-weight:600;color:var(--fg-0)}
.ask-answer-choice::before{content:"✓ ";color:var(--aus-bright-green)}
.ask-answer-notes{margin:.4rem 0 0;white-space:pre-wrap;font-family:var(--font-sans);font-size:.86rem;
color:var(--fg-1)}
.ask-answer-file{display:block;margin-top:.35rem;font-family:var(--font-mono);font-size:.68rem;color:var(--fg-3)}
.ask-answer-file a{color:var(--fg-2)}
.ask-formwrap{margin:0 .9rem .8rem}
.ask-change{cursor:pointer;font-family:var(--font-mono);font-size:.7rem;letter-spacing:.06em;
text-transform:uppercase;color:var(--fg-3);list-style:none;user-select:none}
.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-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);
transition:border-color .12s,background .12s}
.ask-opt:hover{border-color:var(--border-strong)}
.ask-opt:has(input:checked){border-color:var(--aus-bright-cyan);background:rgba(66,220,209,.07)}
.ask-opt input{margin:.2rem 0 0;accent-color:var(--aus-bright-cyan);flex:0 0 auto}
.ask-opt-main{display:flex;flex-direction:column;gap:.1rem;min-width:0}
.ask-opt-label{font-size:.92rem;color:var(--fg-0)}
.ask-opt-detail{font-size:.76rem;color:var(--fg-3);white-space:pre-wrap}
.ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;
font:inherit;font-size:.88rem;color:var(--fg-0);background:var(--rk-well);
border:1px solid var(--border-subtle);border-radius:var(--radius-md);resize:vertical}
.ask-notes:focus{outline:none;border-color:var(--aus-bright-cyan);box-shadow:var(--glow-cyan)}
.ask-actions{display:flex;justify-content:flex-end;margin-top:.6rem}
.ask-submit{cursor:pointer;font-family:var(--font-mono);font-size:.74rem;letter-spacing:.06em;
padding:.42rem .9rem;border-radius:var(--radius-sm);border:1px solid var(--aus-bright-cyan);
background:var(--aus-bright-cyan);color:var(--fg-on-accent);font-weight:700;transition:.14s var(--ease-out)}
.ask-submit:hover{background:var(--aus-cyan);border-color:var(--aus-cyan)}
/* booth page */
.boothhead{display:flex;align-items:center;gap:1rem;flex-wrap:wrap;
padding-bottom:1rem;margin-bottom:1.4rem;border-bottom:1px solid var(--border-subtle)}
+58 -2
View File
@@ -4,7 +4,7 @@
<div class="boothhead">
<a class="back" href="/">‹ all booths</a>
<h1>{{ name }}</h1>
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% else %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% else %}{% set open_asks = asks|selectattr('answer', 'none')|rejectattr('error')|list|length %}{% if open_asks %}<span class="badge badge-ask">{{ open_asks }} open ask{{ '' if open_asks == 1 else 's' }}</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
{# A durable multi-writer board gets no one-click wipe — same rule as the
kept lane on the index. Remove rows with the per-row ×, or release the
@@ -25,6 +25,62 @@
</div>
{% endif %}
{% if asks %}
{# ASKS. A session left multiple-choice questions here for the operator
(`<stem>.ask.json`). Open ones render as a radio form; answering POSTs to
/answer, which writes `<stem>.answer.json` for the session to read. Works
with JS off — plain form POST. Answered asks show the recorded answer and a
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 }}">
<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="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>
{% if a.error %}
<p class="ask-error">This ask could not be read: {{ a.error }}</p>
{% else %}
<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.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>
{% endif %}
<details class="ask-formwrap"{% if not a.answer %} open{% endif %}>
<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>
{% 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 %}
<div class="ask-actions">
<button type="submit" class="ask-submit">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
</div>
</form>
</details>
{% endif %}
</article>
{% endfor %}
</section>
{% endif %}
{% if board %}
{# THE STANDING LINK BOARD. Every agent session on the fleet appends here, so
this is the one booth where the useful granularity is the ROW, not the
@@ -77,7 +133,7 @@
</form>
{% endif %}
{% if not items and not board %}
{% if not items and not board and not asks %}
<div class="empty">This booth is empty.</div>
{% elif items %}
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its
+1
View File
@@ -81,6 +81,7 @@
<div class="ph">◆ files</div>
{% endif %}
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
{% if b.asks_open %}<span class="badge badge-ask">? {{ b.asks_open }} ask{{ '' if b.asks_open == 1 else 's' }}</span>{% endif %}
</a>
<div class="meta">
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>