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:
@@ -88,6 +88,60 @@ Deliberately **not** a database. The board is a markdown file — editable with
|
|||||||
any editor, greppable, and trivially prunable by hand, which is the whole point
|
any editor, greppable, and trivially prunable by hand, which is the whole point
|
||||||
of the Booth's filesystem-is-the-state model.
|
of the Booth's filesystem-is-the-state model.
|
||||||
|
|
||||||
|
## Asks — let the operator pick one of N, and read the pick back
|
||||||
|
|
||||||
|
The one **interactive** primitive. A session needs a human decision — which
|
||||||
|
render wins, which plan, go/no-go — and wants to act on it without a chat
|
||||||
|
round-trip. Drop a question in a booth; the page renders it as a radio form
|
||||||
|
with a notes field; the operator's submit writes an **answer sidecar** the
|
||||||
|
session reads. Filesystem is still the state:
|
||||||
|
|
||||||
|
```
|
||||||
|
<booth>/<stem>.ask.json the question (a session writes it)
|
||||||
|
<booth>/<stem>.answer.json the answer (the web UI writes it, atomically)
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On nh3-dev — pose, then block until answered (default 1h), then act on it:
|
||||||
|
booth ask r18-ab winner "Which render wins?" "A — baseline" "B — cudaMallocAsync"
|
||||||
|
booth answer r18-ab winner --wait # prints the answer JSON when it lands
|
||||||
|
booth answer r18-ab winner # non-blocking: exit 1 while unanswered
|
||||||
|
booth asks r18-ab # list a booth's asks + state
|
||||||
|
|
||||||
|
# Options can carry an id + detail line instead of a bare label — write the
|
||||||
|
# JSON yourself (booth.asks.write_ask validates the same way):
|
||||||
|
cat > ~/booth-data/r18-ab/plan.ask.json <<'EOF'
|
||||||
|
{"prompt": "Ship which?",
|
||||||
|
"options": [{"id": "a", "label": "Plan A", "detail": "smaller diff, no migration"},
|
||||||
|
{"id": "b", "label": "Plan B", "detail": "cleaner, needs the DB change"}],
|
||||||
|
"notes": true, "notes_label": "why / conditions"}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# From another host: rsync the ask in, then poll the sidecar over HTTP:
|
||||||
|
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",
|
||||||
|
"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.
|
||||||
|
|
||||||
|
Rules of the primitive:
|
||||||
|
|
||||||
|
- **Radio, one pick.** ≥ 2 options, ≤ 40. No multi-select (not yet asked for).
|
||||||
|
- **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
|
||||||
|
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
|
||||||
|
validate, the page says why, so a session never thinks it posted a question
|
||||||
|
the operator cannot see.
|
||||||
|
- Ask/answer files are not gallery items and do not count toward the booth's
|
||||||
|
item count; they render as the panel above the gallery. Answering bumps the
|
||||||
|
booth's mtime, so it lives another TTL — the session has 24h to read it.
|
||||||
|
- Works with JavaScript off (plain form POST). No auth, same as everything here.
|
||||||
|
|
||||||
## Upload for pickup
|
## Upload for pickup
|
||||||
|
|
||||||
The reverse direction — put files in through the web, pick them up by id:
|
The reverse direction — put files in through the web, pick them up by id:
|
||||||
@@ -136,6 +190,7 @@ to a safe basename (no path traversal).
|
|||||||
| `GET /b/<name>/` | A booth (its `index.html`, else auto-gallery) |
|
| `GET /b/<name>/` | A booth (its `index.html`, else auto-gallery) |
|
||||||
| `GET /b/<name>/<file>` | Serve a file out of the booth |
|
| `GET /b/<name>/<file>` | Serve a file out of the booth |
|
||||||
| `POST /upload` | Upload files → new pickup booth; 303-redirects to `/b/<id>/` (id in `Location`) |
|
| `POST /upload` | Upload files → new pickup booth; 303-redirects to `/b/<id>/` (id in `Location`) |
|
||||||
|
| `POST /b/<name>/answer` | Answer an ask (form fields `ask` = stem, `choice` = option id, `notes`); writes `<stem>.answer.json`, 303 back to the booth |
|
||||||
| `POST /b/<name>/delete` | Wipe a booth (the UI's "Wipe now" button) |
|
| `POST /b/<name>/delete` | Wipe a booth (the UI's "Wipe now" button) |
|
||||||
| `POST /b/<name>/keep` | Pin a booth — exempt from the sweep |
|
| `POST /b/<name>/keep` | Pin a booth — exempt from the sweep |
|
||||||
| `POST /b/<name>/unkeep` | Release the pin (the UI's "release" button on kept cards) |
|
| `POST /b/<name>/unkeep` | Release the pin (the UI's "release" button on kept cards) |
|
||||||
|
|||||||
@@ -75,6 +75,16 @@ KEEP_MARKER = ".forever"
|
|||||||
# The link-board logic lives in booth/links.py (stdlib only) so the `booth` CLI
|
# 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
|
# can use it without pulling FastAPI in. Re-exported here because call sites and
|
||||||
# tests already reference these names through app.
|
# 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
|
from booth.links import ( # noqa: E402
|
||||||
LINK_LOCK,
|
LINK_LOCK,
|
||||||
LINKS_FILE,
|
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():
|
for child in data_dir.iterdir():
|
||||||
if not child.is_dir() or child.name.startswith("."):
|
if not child.is_dir() or child.name.startswith("."):
|
||||||
continue
|
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}
|
kinds = {"image": 0, "video": 0, "audio": 0, "other": 0}
|
||||||
thumb_url = None
|
thumb_url = None
|
||||||
for f in files:
|
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(),
|
"has_index": (child / "index.html").is_file(),
|
||||||
"uploaded": (child / UPLOAD_MARKER).exists(),
|
"uploaded": (child / UPLOAD_MARKER).exists(),
|
||||||
"kept": is_kept(child),
|
"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)),
|
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
|
||||||
"mtime": 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 —
|
next to `a.png`) is consumed as that item's caption rather than shown itself —
|
||||||
the natural way to label an A/B pair.
|
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}
|
by_rel = {p.relative_to(child).as_posix(): p for p in all_files}
|
||||||
caption: dict[str, str] = {}
|
caption: dict[str, str] = {}
|
||||||
sidecars: set[str] = set()
|
sidecars: set[str] = set()
|
||||||
@@ -620,11 +644,32 @@ def create_app(
|
|||||||
)
|
)
|
||||||
if (booth / LINKS_FILE).is_file() else []
|
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(),
|
"uploaded": (booth / UPLOAD_MARKER).exists(),
|
||||||
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
|
"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)
|
@app.get("/b/{name}/view", response_class=HTMLResponse)
|
||||||
def booth_view_file(request: Request, name: str, f: str):
|
def booth_view_file(request: Request, name: str, f: str):
|
||||||
booth = resolve_booth(name)
|
booth = resolve_booth(name)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -257,6 +257,60 @@
|
|||||||
backdrop-filter:blur(6px);transition:.14s var(--ease-out)}
|
backdrop-filter:blur(6px);transition:.14s var(--ease-out)}
|
||||||
.wipe button:hover{border-color:var(--aus-red);color:#fff;background:var(--aus-red)}
|
.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 */
|
/* booth page */
|
||||||
.boothhead{display:flex;align-items:center;gap:1rem;flex-wrap:wrap;
|
.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)}
|
padding-bottom:1rem;margin-bottom:1.4rem;border-bottom:1px solid var(--border-subtle)}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="boothhead">
|
<div class="boothhead">
|
||||||
<a class="back" href="/">‹ all booths</a>
|
<a class="back" href="/">‹ all booths</a>
|
||||||
<h1>{{ name }}</h1>
|
<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 %}
|
{% 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
|
{# 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
|
kept lane on the index. Remove rows with the per-row ×, or release the
|
||||||
@@ -25,6 +25,62 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if board %}
|
||||||
{# THE STANDING LINK BOARD. Every agent session on the fleet appends here, so
|
{# 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
|
this is the one booth where the useful granularity is the ROW, not the
|
||||||
@@ -77,7 +133,7 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not items and not board %}
|
{% if not items and not board and not asks %}
|
||||||
<div class="empty">This booth is empty.</div>
|
<div class="empty">This booth is empty.</div>
|
||||||
{% elif items %}
|
{% elif items %}
|
||||||
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its
|
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its
|
||||||
|
|||||||
@@ -81,6 +81,7 @@
|
|||||||
<div class="ph">◆ files</div>
|
<div class="ph">◆ files</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% 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>
|
</a>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "booth"
|
name = "booth"
|
||||||
version = "0.1.8"
|
version = "0.1.9"
|
||||||
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."
|
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"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -15,6 +15,22 @@
|
|||||||
# booth links list the board, numbered, with entry ids
|
# booth links list the board, numbered, with entry ids
|
||||||
# booth unlink <id|index> remove ONE link from the board
|
# booth unlink <id|index> remove ONE link from the board
|
||||||
#
|
#
|
||||||
|
# booth ask <name> <stem> <prompt> <option>... [--no-notes]
|
||||||
|
# pose a multiple-choice question in a booth
|
||||||
|
# booth asks <name> list a booth's asks and whether each is answered
|
||||||
|
# booth answer <name> <stem> [--wait [SECS]]
|
||||||
|
# print the answer JSON (exit 1 if unanswered);
|
||||||
|
# --wait polls until it lands (default 3600 s)
|
||||||
|
#
|
||||||
|
# ASKS. A session needs the operator to pick one of N things — which render,
|
||||||
|
# which plan, go/no-go — and act on the pick. `ask` writes <stem>.ask.json into
|
||||||
|
# a booth; the page renders it as a radio form with a notes field; submitting
|
||||||
|
# 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.
|
||||||
|
# Remote sessions: rsync the ask in, then poll
|
||||||
|
# http://10.100.10.50:8090/b/<name>/<stem>.answer.json (404 until answered).
|
||||||
|
#
|
||||||
# THE 24h RULE AND ITS ONE EXCEPTION. Every booth is wiped 24h after its last
|
# THE 24h RULE AND ITS ONE EXCEPTION. Every booth is wiped 24h after its last
|
||||||
# activity — that is the contract, and it is why nobody has to clean up after
|
# activity — that is the contract, and it is why nobody has to clean up after
|
||||||
# themselves. `keep` drops a `.forever` sentinel that exempts one booth from the
|
# themselves. `keep` drops a `.forever` sentinel that exempts one booth from the
|
||||||
@@ -47,7 +63,7 @@ KEEP=".forever" # must match KEEP_MARKER in b
|
|||||||
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
|
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|link <url> [description]|links|unlink <id|index>}" >&2
|
echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|link <url> [description]|links|unlink <id|index>|ask <name> <stem> <prompt> <option>... [--no-notes]|asks <name>|answer <name> <stem> [--wait [SECS]]}" >&2
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,5 +191,65 @@ if removed is None:
|
|||||||
print("removed: %s %s" % (removed["desc"], removed["url"]))
|
print("removed: %s %s" % (removed["desc"], removed["url"]))
|
||||||
' "$board" "$target"
|
' "$board" "$target"
|
||||||
;;
|
;;
|
||||||
|
ask)
|
||||||
|
# booth ask <name> <stem> <prompt> <opt>... [--no-notes]
|
||||||
|
[ $# -ge 5 ] || usage
|
||||||
|
name="$1"; stem="$2"; prompt="$3"; shift 3
|
||||||
|
notes=1; opts=()
|
||||||
|
for a in "$@"; do
|
||||||
|
case "$a" in --no-notes) notes=0 ;; *) opts+=("$a") ;; esac
|
||||||
|
done
|
||||||
|
[ "${#opts[@]}" -ge 2 ] || { echo "an ask needs at least 2 options" >&2; exit 1; }
|
||||||
|
# Validated through the SAME normaliser the page uses, so a session cannot
|
||||||
|
# post a question the renderer would refuse. stdlib only — no venv needed.
|
||||||
|
BOOTH_SRC="$(cd "$(dirname -- "$0")/.." && pwd)" ASK_NOTES="$notes" python3 -c '
|
||||||
|
import os, pathlib, sys
|
||||||
|
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||||||
|
from booth.asks import AskError, write_ask
|
||||||
|
booth, stem, prompt, *opts = sys.argv[1:]
|
||||||
|
try:
|
||||||
|
write_ask(pathlib.Path(booth), stem, prompt, opts, notes=os.environ["ASK_NOTES"] == "1")
|
||||||
|
except AskError as exc:
|
||||||
|
sys.exit("bad ask: %s" % exc)
|
||||||
|
' "$DATA/$name" "$stem" "$prompt" "${opts[@]}"
|
||||||
|
echo "$URL/b/$name/#ask-$stem"
|
||||||
|
;;
|
||||||
|
asks)
|
||||||
|
[ $# -ge 1 ] || usage
|
||||||
|
BOOTH_SRC="$(cd "$(dirname -- "$0")/.." && pwd)" python3 -c '
|
||||||
|
import os, pathlib, sys
|
||||||
|
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||||||
|
from booth.asks import list_asks
|
||||||
|
asks = list_asks(pathlib.Path(sys.argv[1]))
|
||||||
|
if not asks:
|
||||||
|
print("no asks in this booth")
|
||||||
|
for a in asks:
|
||||||
|
if a["error"]:
|
||||||
|
state = "BROKEN " + a["error"]
|
||||||
|
elif a["answer"]:
|
||||||
|
state = "answered %s (%s)" % (a["answer"]["label"], a["answer"]["answered_at"])
|
||||||
|
else:
|
||||||
|
state = "open"
|
||||||
|
print("%-24s %s" % (a["stem"], state))
|
||||||
|
' "$DATA/$1"
|
||||||
|
;;
|
||||||
|
answer)
|
||||||
|
# booth answer <name> <stem> [--wait [SECS]]
|
||||||
|
[ $# -ge 2 ] || usage
|
||||||
|
name="$1"; stem="$2"; shift 2
|
||||||
|
wait_s=0
|
||||||
|
if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
|
||||||
|
f="$DATA/$name/$stem.answer.json"
|
||||||
|
[ -f "$DATA/$name/$stem.ask.json" ] || { echo "no such ask: $name/$stem" >&2; exit 1; }
|
||||||
|
# Poll, do not inotify: the answer is written by a different process via
|
||||||
|
# os.replace, and a 2 s cadence is plenty for a human clicking a radio.
|
||||||
|
deadline=$(( $(date +%s) + wait_s ))
|
||||||
|
while [ ! -f "$f" ]; do
|
||||||
|
if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#ask-$stem" >&2; exit 1; fi
|
||||||
|
if [ "$(date +%s)" -ge "$deadline" ]; then echo "timed out after ${wait_s}s waiting on $name/$stem" >&2; exit 1; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
cat -- "$f"
|
||||||
|
;;
|
||||||
*) usage ;;
|
*) usage ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Asks: session poses a multiple-choice question; operator answers in the
|
||||||
|
browser; the answer lands as a sidecar the session reads."""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from booth.app import build_gallery, create_app, list_booths
|
||||||
|
from booth.asks import (
|
||||||
|
ANSWER_SUFFIX,
|
||||||
|
ASK_SUFFIX,
|
||||||
|
AskError,
|
||||||
|
list_asks,
|
||||||
|
load_ask,
|
||||||
|
normalize_ask,
|
||||||
|
read_answer,
|
||||||
|
write_answer,
|
||||||
|
write_ask,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ask(booth, stem="winner", **kw):
|
||||||
|
doc = {"prompt": "Which render wins?", "options": ["A — baseline", "B — async"]}
|
||||||
|
doc.update(kw)
|
||||||
|
booth.mkdir(parents=True, exist_ok=True)
|
||||||
|
(booth / f"{stem}{ASK_SUFFIX}").write_text(json.dumps(doc))
|
||||||
|
return booth
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path):
|
||||||
|
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||||
|
return TestClient(app), tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---- normalisation ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_string_options():
|
||||||
|
a = normalize_ask({"prompt": " Pick ", "options": ["x", "y"]}, "s")
|
||||||
|
assert a["prompt"] == "Pick"
|
||||||
|
assert a["options"] == [{"id": "x", "label": "x", "detail": ""}, {"id": "y", "label": "y", "detail": ""}]
|
||||||
|
assert a["notes"] is True and a["notes_label"] == "notes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_object_options_and_flags():
|
||||||
|
a = normalize_ask(
|
||||||
|
{"prompt": "p", "options": [{"id": "a", "label": "A", "detail": "d"}, {"label": "B"}],
|
||||||
|
"notes": False, "notes_label": "why"},
|
||||||
|
"s",
|
||||||
|
)
|
||||||
|
assert a["options"][0] == {"id": "a", "label": "A", "detail": "d"}
|
||||||
|
assert a["options"][1] == {"id": "B", "label": "B", "detail": ""}
|
||||||
|
assert a["notes"] is False and a["notes_label"] == "why"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"doc",
|
||||||
|
[
|
||||||
|
{"options": ["a", "b"]},
|
||||||
|
{"prompt": "", "options": ["a", "b"]},
|
||||||
|
{"prompt": "p", "options": ["only"]},
|
||||||
|
{"prompt": "p", "options": "a,b"},
|
||||||
|
{"prompt": "p", "options": ["a", "a"]},
|
||||||
|
{"prompt": "p", "options": [{"id": "a"}, "b"]},
|
||||||
|
{"prompt": "p", "options": ["a", "b"], "notes": "yes"},
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_normalize_rejects(doc):
|
||||||
|
with pytest.raises(AskError):
|
||||||
|
normalize_ask(doc, "s")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- files ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_ask_reports_bad_json(tmp_path):
|
||||||
|
(tmp_path / f"x{ASK_SUFFIX}").write_text("{not json")
|
||||||
|
with pytest.raises(AskError):
|
||||||
|
load_ask(tmp_path, "x")
|
||||||
|
with pytest.raises(AskError):
|
||||||
|
load_ask(tmp_path, "missing")
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_asks_folds_answer_and_surfaces_errors(tmp_path):
|
||||||
|
_ask(tmp_path, "one")
|
||||||
|
_ask(tmp_path, "two")
|
||||||
|
(tmp_path / f"broken{ASK_SUFFIX}").write_text("[]")
|
||||||
|
(tmp_path / ".hidden.ask.json").write_text("{}") # dotfiles never listed
|
||||||
|
write_answer(tmp_path, "two", "B — async", "less banding", who="10.0.0.1")
|
||||||
|
|
||||||
|
asks = list_asks(tmp_path)
|
||||||
|
by = {a["stem"]: a for a in asks}
|
||||||
|
assert set(by) == {"one", "two", "broken"}
|
||||||
|
assert by["one"]["answer"] is None and by["one"]["error"] is None
|
||||||
|
assert by["two"]["answer"]["choice"] == "B — async"
|
||||||
|
assert by["two"]["answer"]["choice_index"] == 1
|
||||||
|
assert by["two"]["answer"]["notes"] == "less banding"
|
||||||
|
assert by["two"]["answer"]["answered_by"] == "10.0.0.1"
|
||||||
|
assert by["broken"]["error"] and by["broken"]["options"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_answer_validates_choice_and_is_atomic(tmp_path):
|
||||||
|
_ask(tmp_path)
|
||||||
|
with pytest.raises(AskError):
|
||||||
|
write_answer(tmp_path, "winner", "C — nope")
|
||||||
|
with pytest.raises(AskError):
|
||||||
|
write_answer(tmp_path, "nosuch", "A — baseline")
|
||||||
|
ans = write_answer(tmp_path, "winner", "A — baseline", " ok \r\n")
|
||||||
|
assert ans["notes"] == "ok"
|
||||||
|
assert ans["answered_at"]
|
||||||
|
assert read_answer(tmp_path, "winner") == ans
|
||||||
|
assert not (tmp_path / f"winner{ANSWER_SUFFIX}.tmp").exists()
|
||||||
|
# re-answer overwrites — the sidecar is the CURRENT answer, not a log
|
||||||
|
write_answer(tmp_path, "winner", "B — async")
|
||||||
|
assert read_answer(tmp_path, "winner")["choice_index"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_answer_drops_notes_when_ask_disables_them(tmp_path):
|
||||||
|
_ask(tmp_path, notes=False)
|
||||||
|
assert write_answer(tmp_path, "winner", "A — baseline", "ignored")["notes"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_ask_roundtrip_and_stem_guard(tmp_path):
|
||||||
|
p = write_ask(tmp_path / "b", "pick", "Pick one", ["x", {"id": "y", "label": "Y"}], notes=False)
|
||||||
|
assert p.name == f"pick{ASK_SUFFIX}"
|
||||||
|
a = load_ask(tmp_path / "b", "pick")
|
||||||
|
assert [o["id"] for o in a["options"]] == ["x", "y"] and a["notes"] is False
|
||||||
|
for bad in ("../x", ".hidden", "a/b", ""):
|
||||||
|
with pytest.raises(AskError):
|
||||||
|
write_ask(tmp_path / "b", bad, "p", ["a", "b"])
|
||||||
|
with pytest.raises(AskError):
|
||||||
|
write_ask(tmp_path / "b", "ok", "p", ["solo"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---- gallery + index integration -------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_gallery_hides_ask_and_answer_files(tmp_path):
|
||||||
|
b = _ask(tmp_path / "b")
|
||||||
|
(b / "a.png").write_bytes(b"x")
|
||||||
|
write_answer(b, "winner", "A — baseline")
|
||||||
|
names = {it["name"] for it in build_gallery(b)}
|
||||||
|
assert names == {"a.png"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_booths_counts_open_asks(tmp_path):
|
||||||
|
b = _ask(tmp_path / "b", "one")
|
||||||
|
_ask(b, "two")
|
||||||
|
write_answer(b, "two", "A — baseline")
|
||||||
|
(tmp_path / "plain").mkdir()
|
||||||
|
by = {x["name"]: x for x in list_booths(tmp_path, 3600)}
|
||||||
|
assert by["b"]["asks_open"] == 1 and by["b"]["asks_total"] == 2
|
||||||
|
assert by["plain"]["asks_open"] == 0 and by["plain"]["asks_total"] == 0
|
||||||
|
assert by["b"]["count"] == 0 # ask/answer files are not "items"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- routes -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_booth_page_renders_open_ask_as_form(client):
|
||||||
|
c, data = client
|
||||||
|
_ask(data / "b")
|
||||||
|
html = c.get("/b/b/").text
|
||||||
|
assert "Which render wins?" in html
|
||||||
|
assert 'type="radio"' in html and 'name="choice"' in html
|
||||||
|
assert 'value="B — async"' in html
|
||||||
|
assert 'action="/b/b/answer"' in html
|
||||||
|
assert "<textarea" in html
|
||||||
|
assert "1 open ask" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_route_writes_sidecar_and_page_shows_it(client):
|
||||||
|
c, data = client
|
||||||
|
_ask(data / "b")
|
||||||
|
r = c.post("/b/b/answer", data={"ask": "winner", "choice": "B — async", "notes": "less banding"},
|
||||||
|
follow_redirects=False)
|
||||||
|
assert r.status_code == 303 and r.headers["location"] == "/b/b/#ask-winner"
|
||||||
|
ans = json.loads((data / "b" / f"winner{ANSWER_SUFFIX}").read_text())
|
||||||
|
assert ans["choice"] == "B — async" and ans["notes"] == "less banding"
|
||||||
|
assert ans["answered_by"] # TestClient's client addr
|
||||||
|
html = c.get("/b/b/").text
|
||||||
|
assert "answered" in html and "less banding" in html
|
||||||
|
assert "1 open ask" not in html
|
||||||
|
# the sidecar is fetchable over HTTP for remote sessions
|
||||||
|
assert c.get("/b/b/winner.answer.json").json()["choice"] == "B — async"
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_route_rejects_bad_choice_and_unknown_ask(client):
|
||||||
|
c, data = client
|
||||||
|
_ask(data / "b")
|
||||||
|
assert c.post("/b/b/answer", data={"ask": "winner", "choice": "Z"}).status_code == 400
|
||||||
|
assert c.post("/b/b/answer", data={"ask": "nosuch", "choice": "A — baseline"}).status_code == 404
|
||||||
|
assert c.post("/b/b/answer", data={"ask": "../x", "choice": "A — baseline"}).status_code == 404
|
||||||
|
assert not (data / "b" / f"winner{ANSWER_SUFFIX}").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_answer_json_404s_until_answered(client):
|
||||||
|
c, data = client
|
||||||
|
_ask(data / "b")
|
||||||
|
assert c.get("/b/b/winner.answer.json").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_notes_field_hidden_when_disabled(client):
|
||||||
|
c, data = client
|
||||||
|
_ask(data / "b", notes=False)
|
||||||
|
assert "<textarea" not in c.get("/b/b/").text
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_card_shows_open_ask_badge(client):
|
||||||
|
c, data = client
|
||||||
|
_ask(data / "b")
|
||||||
|
html = c.get("/").text
|
||||||
|
assert "1 ask" in html
|
||||||
Reference in New Issue
Block a user