From 3fe01225a93563e4e5b075cfda339135094a5795 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 9 Sep 2026 07:24:16 -0700 Subject: [PATCH] =?UTF-8?q?feat(booth):=20asks=20=E2=80=94=20a=20multiple-?= =?UTF-8?q?choice=20question=20a=20session=20poses=20in=20a=20booth,=20ans?= =?UTF-8?q?wered=20by=20the=20operator=20as=20a=20radio=20form=20+=20notes?= =?UTF-8?q?,=20written=20back=20as=20an=20answer=20sidecar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - booth/asks.py (stdlib): .ask.json question / .answer.json answer; normalise+validate, atomic write, list with answer folded in, broken asks surfaced not hidden - POST /b//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 .answer.json over HTTP - ask/answer files excluded from gallery items and item counts; 23 tests; v0.1.9 --- services/booth/README.md | 55 ++++++ services/booth/booth/app.py | 49 ++++- services/booth/booth/asks.py | 212 +++++++++++++++++++++ services/booth/booth/templates/base.html | 54 ++++++ services/booth/booth/templates/booth.html | 60 +++++- services/booth/booth/templates/index.html | 1 + services/booth/pyproject.toml | 2 +- services/booth/scripts/booth | 78 +++++++- services/booth/tests/test_asks.py | 216 ++++++++++++++++++++++ 9 files changed, 721 insertions(+), 6 deletions(-) create mode 100644 services/booth/booth/asks.py create mode 100644 services/booth/tests/test_asks.py diff --git a/services/booth/README.md b/services/booth/README.md index 769672f..bf9b958 100644 --- a/services/booth/README.md +++ b/services/booth/README.md @@ -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 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: + +``` +/.ask.json the question (a session writes it) +/.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//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 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//` | A booth (its `index.html`, else auto-gallery) | | `GET /b//` | Serve a file out of the booth | | `POST /upload` | Upload files → new pickup booth; 303-redirects to `/b//` (id in `Location`) | +| `POST /b//answer` | Answer an ask (form fields `ask` = stem, `choice` = option id, `notes`); writes `.answer.json`, 303 back to the booth | | `POST /b//delete` | Wipe a booth (the UI's "Wipe now" button) | | `POST /b//keep` | Pin a booth — exempt from the sweep | | `POST /b//unkeep` | Release the pin (the UI's "release" button on kept cards) | diff --git a/services/booth/booth/app.py b/services/booth/booth/app.py index f888763..5f54f86 100644 --- a/services/booth/booth/app.py +++ b/services/booth/booth/app.py @@ -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 `.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) diff --git a/services/booth/booth/asks.py b/services/booth/booth/asks.py new file mode 100644 index 0000000..580007c --- /dev/null +++ b/services/booth/booth/asks.py @@ -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: + + /.ask.json the question (written by a session) + /.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 `.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 diff --git a/services/booth/booth/templates/base.html b/services/booth/booth/templates/base.html index e8a8e39..a41f705 100644 --- a/services/booth/booth/templates/base.html +++ b/services/booth/booth/templates/base.html @@ -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)} diff --git a/services/booth/booth/templates/booth.html b/services/booth/booth/templates/booth.html index 5fed6c6..ea4f814 100644 --- a/services/booth/booth/templates/booth.html +++ b/services/booth/booth/templates/booth.html @@ -4,7 +4,7 @@
‹ all booths

{{ name }}

- {% if uploaded %}⬆ pickup {% 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 %} + {% if uploaded %}⬆ pickup {% 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 %}{{ open_asks }} open ask{{ '' if open_asks == 1 else 's' }} · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %} {% if items %}⬇ zip{% 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 @@
{% endif %} +{% if asks %} + {# ASKS. A session left multiple-choice questions here for the operator + (`.ask.json`). Open ones render as a radio form; answering POSTs to + /answer, which writes `.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. #} +
+ {% for a in asks %} +
+
+ {% if a.error %}⚠ broken{% elif a.answer %}✓ answered{% else %}? open{% endif %} + {{ a.stem }}.ask.json + + {% if a.answer %}{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}{% endif %} +
+ {% if a.error %} +

This ask could not be read: {{ a.error }}

+ {% else %} +

{{ a.prompt }}

+ {% if a.answer %} +
+
{{ a.answer.label }}
+ {% if a.answer.notes %}
{{ a.answer.notes }}
{% endif %} + {{ a.stem }}.answer.json +
+ {% endif %} +
+ {% if a.answer %}change answer{% else %}answer{% endif %} +
+ +
+ {% for o in a.options %} + + {% endfor %} +
+ {% if a.notes %} + + {% endif %} +
+ +
+
+
+ {% endif %} +
+ {% endfor %} +
+{% 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 @@ {% endif %} -{% if not items and not board %} +{% if not items and not board and not asks %}
This booth is empty.
{% elif items %} {# `elif items` and not a bare `else`: a board booth has NO gallery items (its diff --git a/services/booth/booth/templates/index.html b/services/booth/booth/templates/index.html index e4b8377..21540ff 100644 --- a/services/booth/booth/templates/index.html +++ b/services/booth/booth/templates/index.html @@ -81,6 +81,7 @@
◆ files
{% endif %} {% if b.uploaded %}⬆ pickup{% endif %} + {% if b.asks_open %}? {{ b.asks_open }} ask{{ '' if b.asks_open == 1 else 's' }}{% endif %}
{{ b.name }} diff --git a/services/booth/pyproject.toml b/services/booth/pyproject.toml index 5e8880b..167a1d6 100644 --- a/services/booth/pyproject.toml +++ b/services/booth/pyproject.toml @@ -1,6 +1,6 @@ [project] 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." requires-python = ">=3.11" dependencies = [ diff --git a/services/booth/scripts/booth b/services/booth/scripts/booth index 04cf248..b221b42 100755 --- a/services/booth/scripts/booth +++ b/services/booth/scripts/booth @@ -15,6 +15,22 @@ # booth links list the board, numbered, with entry ids # booth unlink remove ONE link from the board # +# booth ask