feat(marks): one primitive for operator judgment, so the loop stops running through chat
Five mechanisms existed to get one question next to one artifact. Three of
them were the same thing wearing different clothes, and the third of the three
had no code at all: the operator picked winners out of a 270-image set and
told the session in conversation. `sindra-finalists` is 86 items, every one
captioned, with the selection encoded in the booth's NAME.
A MARK is operator judgment attached to a target — the booth, or one item in
it, addressed by the `rel` U1 established as item identity. Three shapes:
pick — one of N options a session declared in advance (was: an ask)
note — free text the operator volunteered (had nothing)
flag — this one (had nothing)
One file per booth, one read path, one place openness is computed, one slot
beside the artifact. The storage shape is the operator's call (2026-09-21) and
follows from U4: "does this booth still owe an answer?" gets asked per booth
per sweep tick and per card per index render, so it has to be one read and not
a walk of a booth holding 270 files. Marks are also not links.md — that is an
O_APPEND content-hash log because 17 handles write it concurrently, whereas a
booth's marks see one session and one operator, so locking the common path
costs nothing.
The 2026-09-09 pick semantics are preserved by NOT rewriting them: partial
answers legal, a blank question lands in `unanswered`, `complete` false until
every question has a pick, the only refusal a submission carrying nothing.
`write_answer` split into the pure `build_answer` plus the storage that went
away with the sidecar; `normalize_ask` untouched.
Three findings worth naming, because each was caught by a gate rather than by
reading the diff again:
* The seam review found `inline.place` indexes asks by SUBSCRIPT — the only
consumer in the service that does — so a frozen dataclass breaks it, and
`inline.py` had been missing from the contract's scope entirely.
* A retargeted test found a regression in the legacy importer: a malformed
sidecar that renders "broken" today would have silently vanished on
migration. It now imports carrying its reason.
* A partially-answered pick counted as CLOSED on the index while the panel
beside it rendered it "partial" — the two disagreed about one booth. Open
is the reading U4 needs, and it is declared rather than smuggled in.
`GET /b/<n>/marks.json` is new and load-bearing: sessions on other hosts polled
`<stem>.answer.json` over HTTP, so removing the sidecar without it would have
taken that capability away. `/b/<n>/asks` 308s to `/marks`. Legacy sidecars are
imported, never deleted — four are live and unanswered.
Also records the operator's deterministic-order directive as a cross-cutting v1
invariant, in ROADMAP.md with the per-collection rule table and as CLAUDE.md
invariant 6. The Booth's job is comparison; an order that moves between renders
does not crash, it misfiles the judgment.
242 tests. No version bump — a release tier for this is the operator's call.
This commit is contained in:
@@ -189,155 +189,89 @@ 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
|
||||
## Marks — operator judgment, attached to an artifact
|
||||
|
||||
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:
|
||||
The one **interactive** primitive, and one primitive for what used to be three
|
||||
jobs:
|
||||
|
||||
| shape | who writes it | what it is |
|
||||
|---|---|---|
|
||||
| **`pick`** | a session declares the options, the operator chooses | "which render wins?" — this is what `booth ask` poses |
|
||||
| **`note`** | the operator, in the browser | free text for the session that posted the work |
|
||||
| **`flag`** | the operator, in the browser | *this one* — selecting winners out of a set |
|
||||
|
||||
All three are the operator judging something and the session reading the
|
||||
judgment back. They live in **one file per booth**, so "does this booth still owe
|
||||
me an answer?" is a single read:
|
||||
|
||||
```
|
||||
<booth>/<stem>.ask.json the question (a session writes it)
|
||||
<booth>/<stem>.answer.json the answer (the web UI writes it, atomically)
|
||||
<booth>/.marks.json every mark in the booth (dotfile: never a tile, never in the zip)
|
||||
<booth>/.marks.lock the write lock (a session declares, the operator answers)
|
||||
```
|
||||
|
||||
There are **no `note` or `flag` CLI verbs**, on purpose: the CLI is the session's
|
||||
side of the loop, and a session does not author the operator's judgment. It reads
|
||||
it.
|
||||
|
||||
```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
|
||||
booth marks r18-ab # every mark in the booth, as JSON
|
||||
booth marks r18-ab --wait # block while any pick is still open
|
||||
|
||||
# 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'
|
||||
{"title": "optional short label above the question",
|
||||
"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
|
||||
# Options can carry an id + detail line instead of a bare label. Write the whole
|
||||
# declaration yourself and it is validated by the same normaliser the page uses:
|
||||
booth ask r18-ab plan "Ship which?" "Plan A" "Plan B" # or, for detail lines:
|
||||
python3 -c '
|
||||
import pathlib, sys; sys.path.insert(0, "/home/lkraven/development/booth")
|
||||
from booth.marks import declare_pick
|
||||
declare_pick(pathlib.Path("/home/lkraven/booth-data/r18-ab"), "plan", {
|
||||
"title": "optional short label above the question",
|
||||
"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"})'
|
||||
|
||||
# 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
|
||||
# From another host: rsync your work in, then read the judgment over HTTP.
|
||||
# ONE request for the whole booth, rather than one per question.
|
||||
curl -sf http://10.100.10.50:8090/b/r18-ab/marks.json | jq '.open, .marks[].answer'
|
||||
```
|
||||
|
||||
**Several questions, one form.** Give the ask a `questions` list instead of
|
||||
`prompt`+`options`; the page renders one form with a radio group per question
|
||||
and a single submit, every question required. Per-question `notes: true` adds
|
||||
a small text field under that question; the form-level `notes` stays one field
|
||||
for the whole ask. The answer is keyed by question:
|
||||
**Several questions, one form.** Give the declaration a `questions` list instead
|
||||
of `prompt`+`options`; the page renders one form with a radio group per question
|
||||
and a single submit. Per-question `notes: true` adds a small text field under
|
||||
that question; the form-level `notes` stays one field for the whole pick. The
|
||||
answer is keyed by question:
|
||||
|
||||
```python
|
||||
declare_pick(booth, "batch", {
|
||||
"title": "R18 batch review",
|
||||
"questions": [
|
||||
{"key": "r1", "prompt": "Render 1 — keep?", "options": ["keep", "drop"], "notes": True},
|
||||
{"key": "r2", "prompt": "Render 2 — keep?", "options": ["keep", "drop"]},
|
||||
{"key": "seed", "prompt": "Reseed the batch?", "options": ["yes", "no"]}],
|
||||
"notes": True, "notes_label": "anything else"})
|
||||
# -> answer: {"stem", "title", "answers": {"r1": {"prompt", "choice",
|
||||
# "choice_index", "label", "notes"}, "r2": {...}, "seed": {...}},
|
||||
# "unanswered", "complete", "notes", "answered_at", "answered_by"}
|
||||
```
|
||||
|
||||
**A partial answer is recorded, not refused.** A question left blank is a
|
||||
deliberate outcome — "none of these", "not yet", "ask me later" — so it lands in
|
||||
`unanswered`, stays absent from `answers` unless it carried a note, and
|
||||
`complete` stays false. **A partially-answered pick still counts as open**, which
|
||||
is what the index badge reports. The one refusal is a submission carrying nothing
|
||||
at all: no choice anywhere and no notes.
|
||||
|
||||
**Migrating a booth that predates marks.** The old two-sidecars-per-question
|
||||
files (`<stem>.ask.json` / `<stem>.answer.json`) are imported, never deleted:
|
||||
|
||||
```bash
|
||||
cat > ~/booth-data/r18-ab/batch.ask.json <<'EOF'
|
||||
{"title": "R18 batch review",
|
||||
"questions": [
|
||||
{"key": "r1", "prompt": "Render 1 — keep?", "options": ["keep", "drop"], "notes": true},
|
||||
{"key": "r2", "prompt": "Render 2 — keep?", "options": ["keep", "drop"]},
|
||||
{"key": "seed", "prompt": "Reseed the batch?", "options": ["yes", "no"]}],
|
||||
"notes": true, "notes_label": "anything else"}
|
||||
EOF
|
||||
# -> batch.answer.json: {"stem", "title", "answers": {"r1": {"prompt", "choice",
|
||||
# "choice_index", "label", "notes"}, "r2": {...}, "seed": {...}}, "notes", "answered_at", "answered_by"}
|
||||
booth marks-import r18-ab # idempotent; the sidecars stay on disk
|
||||
```
|
||||
|
||||
Both shapes also carry **`unanswered`** (the question keys left blank; `[null]`
|
||||
for a blank single-question ask) and **`complete`** (false until every question
|
||||
has a pick). A reading session should check `complete` before acting on a
|
||||
multi-question answer, and treat a key in `unanswered` as "not decided", never
|
||||
as "declined".
|
||||
|
||||
The single-question answer: `{"stem", "prompt", "choice", "choice_index", "label", "notes",
|
||||
"unanswered", "complete", "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` is what the form submits — fields `ask` plus
|
||||
`choice` / `notes` (single) or `choice.<key>` / `notes.<key>` / `notes` (multi);
|
||||
a missing or bad choice is a 400, an unknown stem a 404.
|
||||
|
||||
Rules of the primitive:
|
||||
|
||||
- **Radio, one pick per question.** ≥ 2 options, ≤ 40 per question, ≤ 30
|
||||
questions per ask. No multi-select checkboxes (not yet asked for). Many asks
|
||||
per booth are fine — each is its own form and its own sidecar; use
|
||||
`questions` when the picks belong together and should land as one answer.
|
||||
- **Re-answering overwrites.** The sidecar is the *current* answer, not a log.
|
||||
The page shows the recorded answer with a collapsed *change answer* form.
|
||||
- **Blanks are legal — a partial answer is recorded, not refused.** Leaving a
|
||||
question alone is a real outcome ("none of these", "not listened to yet"), and
|
||||
refusing the whole submission over one blank threw away the picks that WERE
|
||||
made. So every answered question is recorded, every blank one lands in
|
||||
`unanswered`, and `complete` says whether the set is finished. The radios carry
|
||||
no HTML `required`, so the browser does not block the submit either. A question
|
||||
left blank but carrying a note keeps the note (`choice: null`). The one refusal
|
||||
is a submission with **no pick anywhere and no notes** — a 400, because it would
|
||||
flip an open ask to "answered" while recording no decision, which is worse for
|
||||
the reading session than leaving it open. A choice that is not in the option
|
||||
list is still an error: that is a broken form, not a skipped question.
|
||||
Partially-answered asks show as `◐ partial` with an `n/N` count; re-submitting
|
||||
fills in the rest.
|
||||
- **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.
|
||||
|
||||
### Where the form renders
|
||||
|
||||
Two booth shapes, two placements. Either way the ask is never invisible — that
|
||||
is the guarantee; markup only moves it somewhere better.
|
||||
|
||||
**Auto-gallery booth** (no `index.html` of its own): the asks panel renders
|
||||
above the gallery, styled like the rest of the Booth. Nothing to do.
|
||||
|
||||
**A booth serving its own `index.html`**: that page is returned verbatim, so the
|
||||
Booth substitutes **placeholders in your markup** rather than rendering a panel
|
||||
above a gallery that does not exist. The question then sits with the artifact it
|
||||
is about (operator ruling 2026-09-09: *"the asks should be inline with the
|
||||
artifacts, not on a separate page"*).
|
||||
|
||||
**When inline is worth the markup, and when it is not.** The test is whether the
|
||||
artifact can be held in the head while the form is on screen. Two short images
|
||||
side by side — no, the appended form is fine. Twenty audio clips, five per voice
|
||||
across four voices — yes: on a separate page the operator is choosing from
|
||||
*memory of the audio*, not from the audio, and by the fourth voice that memory is
|
||||
gone. That is the case this mechanism exists for (framing owed to tts-dev,
|
||||
2026-09-09, from the `redo-anchors` audition).
|
||||
|
||||
```html
|
||||
<div data-booth-ask="anchors"></div> <!-- the whole ask: every question + submit -->
|
||||
<div data-booth-ask="anchors:lawson"></div> <!-- just that one question's radios -->
|
||||
<div data-booth-ask-submit="anchors"></div> <!-- the notes field + submit button -->
|
||||
<!-- booth:ask anchors:lawson --> <!-- comment form, identical behaviour -->
|
||||
```
|
||||
|
||||
Per-question fragments bind to **one** form via the HTML5 `form=` attribute, so a
|
||||
four-voice audition puts each radio group under that voice's audio and still
|
||||
submits every pick in a single POST — which is what a multi-question ask
|
||||
requires. Fragments ship their own scoped styles, inherit nothing from your page,
|
||||
and use no JavaScript.
|
||||
|
||||
⚠ **Put the placeholder outside any CSS grid or flex container**, or it becomes a
|
||||
cell in it — measured on `redo-anchors`, where the first attempt rendered as a
|
||||
224 px sixth grid cell wedged between two audio players. A sibling of the block
|
||||
it belongs to is right.
|
||||
|
||||
The fallbacks, so a page can never strand a question:
|
||||
|
||||
| you marked up | what happens |
|
||||
|---|---|
|
||||
| nothing | the whole ask is appended at the end of the page |
|
||||
| some questions, no submit | the rest of the questions **and** a submit block are appended |
|
||||
| a stem this booth does not have | your markup is left alone, untouched; the real ask is still appended |
|
||||
|
||||
An amber `? N open asks` chip floats top-right as a jump link to the first open
|
||||
ask, and `GET /b/<name>/asks` still renders every ask on a plain page of its own
|
||||
— useful when you want to hand someone only the question.
|
||||
|
||||
## Upload for pickup
|
||||
|
||||
The reverse direction — put files in through the web, pick them up by id:
|
||||
@@ -386,8 +320,13 @@ to a safe basename (no path traversal).
|
||||
| `GET /b/<name>/` | A booth (its `index.html`, else auto-gallery) |
|
||||
| `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`) |
|
||||
| `GET /b/<name>/asks` | The asks panel on its own page — the only place a verbatim-`index.html` booth can show its asks |
|
||||
| `POST /b/<name>/answer` | Answer an ask (fields `ask` = stem, `choice`/`choice.<key>`, `notes`/`notes.<key>`, `back`); writes `<stem>.answer.json`, 303 back |
|
||||
| `GET /b/<name>/marks` | The marks panel on its own page — the only place a verbatim-`index.html` booth can show its marks (`/asks` 308s here) |
|
||||
| `GET /b/<name>/marks.json` | Every mark as JSON, plus `open` — the read path for a session that is not on this host |
|
||||
| `POST /b/<name>/answer` | Answer a pick (fields `ask` = mark id, `choice`/`choice.<key>`, `notes`/`notes.<key>`, `back`); 303 back |
|
||||
| `POST /b/<name>/note` | Attach free text to an item (`target`) or to the booth (`target` empty); `text` required |
|
||||
| `POST /b/<name>/flag` | Flag or unflag one item (`target`, `on`) — an upsert; unflagging removes the mark |
|
||||
| `POST /b/<name>/unmark` | Withdraw one mark (`mark` = its id) |
|
||||
| `POST /b/<name>/import-asks` | Import this booth's legacy `*.ask.json` sidecars; idempotent, deletes nothing |
|
||||
| `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>/unkeep` | Release the pin (the UI's "release" button on kept cards) |
|
||||
|
||||
Reference in New Issue
Block a user