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:
vh
2026-09-21 23:38:27 -07:00
parent 9272c9872e
commit c7f9437a64
23 changed files with 2677 additions and 650 deletions
+215 -75
View File
@@ -118,10 +118,20 @@ from booth.asks import ( # noqa: E402
AskError,
is_answer_file,
is_ask_file,
list_asks,
load_ask,
valid_stem,
write_answer,
)
from booth.marks import ( # noqa: E402
MARKS_FILE,
answer_pick,
as_dict,
declare_pick,
delete_mark,
import_legacy_asks,
marks_for,
marks_for_target,
open_marks,
set_flag,
write_note,
)
from booth.inline import ( # noqa: E402
form_id as ask_form_id,
@@ -240,9 +250,11 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
if not child.is_dir() or child.name.startswith("."):
continue
items = booth_items(child)
# Asks are questions, not items: counted separately so the index can
# flag a booth that is waiting on the operator.
asks = list_asks(child)
# Marks are judgment, not items: counted separately so the index can
# flag a booth that is waiting on the operator. ONE file read per booth
# — which is why marks live in one file per booth rather than a sidecar
# per mark. This loop runs on every index page load.
marks = marks_for(child)
kinds = {"image": 0, "video": 0, "audio": 0, "other": 0}
thumb_url = None
thumb_blurred = False
@@ -267,8 +279,11 @@ 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"]),
"marks_total": len(marks),
# `open_marks` and nothing else (INV-2). The count this replaced
# tested `answer is None`, so a half-answered pick read as closed
# here while the panel beside it rendered `◐ partial`.
"marks_open": len(open_marks(marks)),
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
"mtime": mtime,
}
@@ -610,6 +625,14 @@ def create_app(
except OSError:
pass
return FileResponse(str(own_index), media_type="text/html")
# links.md is rendered AS the board below, so it must not also appear as
# a markdown doc tile — that would show the same content twice, once
# interactive and once not.
gallery = [
it for it in build_gallery(booth)
if not ((booth / LINKS_FILE).is_file() and it["name"] == LINKS_FILE)
]
marks = marks_for(booth)
return templates.TemplateResponse(
request,
"booth.html",
@@ -620,13 +643,7 @@ def create_app(
# The page could not previously tell keep from release, so it
# offered neither and you had to go back to the index.
"kept": is_kept(booth),
# links.md is rendered AS the board below, so it must not also
# appear as a markdown doc tile — that would show the same
# content twice, once interactive and once not.
"items": [
it for it in build_gallery(booth)
if not ((booth / LINKS_FILE).is_file() and it["name"] == LINKS_FILE)
],
"items": gallery,
# A booth carrying links.md is the standing link board: render
# its rows as real UI (link, provenance, pin, per-row + bulk
# remove) instead of a markdown blob you can only edit by hand.
@@ -640,48 +657,135 @@ 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),
# Marks: operator judgment attached to this booth or to one of
# its items — a session's question (`pick`), the operator's own
# remark (`note`), the operator's selection (`flag`). Rendered
# as the panel above the gallery, and per item on each tile.
# See booth/marks.py.
"marks": marks,
"marks_open": len(open_marks(marks)),
# Per-item marks, keyed by rel, so a tile reads its own judgment
# without every tile re-filtering the whole list.
"item_marks": {
it["name"]: marks_for_target(marks, it["name"]) for it in gallery
},
"booth_marks": marks_for_target(marks, None),
"uploaded": (booth / UPLOAD_MARKER).exists(),
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
},
)
def _mark_redirect(name: str, form, anchor: str) -> RedirectResponse:
"""Land where the form was: the standalone marks page for a verbatim
booth (its own index.html cannot show the recorded judgment), else the
booth page, scrolled to the mark that was just written."""
base = f"/b/{quote(name, safe='')}/"
if form.get("back") == "marks":
base = f"/b/{quote(name, safe='')}/marks"
return RedirectResponse(url=f"{base}#{anchor}", status_code=303)
@app.post("/b/{name}/answer")
async def booth_answer(request: Request, name: str):
"""Record the operator's answer to one ask: validates every choice
against the ask and writes `<stem>.answer.json` atomically.
Re-submitting overwrites — the sidecar is the current answer.
"""Record the operator's pick — one of N options a session declared in
advance. Validates every choice against the declaration and rewrites
`.marks.json` atomically. Re-submitting overwrites: the mark is the
CURRENT judgment, not a log.
Form fields: `ask` (stem); single-question → `choice` + `notes`;
Form fields: `ask` (the mark id); single-question → `choice` + `notes`;
multi-question → `choice.<key>` per question, optional `notes.<key>`,
plus the form-level `notes`. 404 for an unknown/invalid stem, 400 for
a missing choice or one the ask does not offer.
plus the form-level `notes`. 404 for an unknown id, 400 for a missing
choice or one the declaration does not offer.
Kept at `/answer` with an `ask` field rather than renamed: the inline
fragments a report author has already marked up POST here, and breaking
every landed verbatim report to tidy a URL is not a trade worth making.
"""
booth = resolve_booth(name)
form = await request.form()
ask = form.get("ask")
if not isinstance(ask, str) or not valid_stem(ask) or not (booth / f"{ask}{ASK_SUFFIX}").is_file():
raise HTTPException(status_code=404, detail="no such ask")
mark_id = form.get("ask")
if not isinstance(mark_id, str) or not valid_stem(mark_id):
raise HTTPException(status_code=404, detail="no such pick")
spec = next((m for m in marks_for(booth) if m.id == mark_id and m.shape == "pick"), None)
if spec is None:
raise HTTPException(status_code=404, detail="no such pick")
if spec.error is not None:
raise HTTPException(status_code=400, detail=spec.error)
who = request.client.host if request.client else ""
try:
spec = load_ask(booth, ask)
if spec["multi"]:
choice = {q["key"]: form.get(f"choice.{q['key']}") for q in spec["questions"]}
qnotes = {q["key"]: form.get(f"notes.{q['key']}") for q in spec["questions"]}
write_answer(booth, ask, choice, form.get("notes", ""), who=who, qnotes=qnotes)
if spec.multi:
choice = {q["key"]: form.get(f"choice.{q['key']}") for q in spec.questions}
qnotes = {q["key"]: form.get(f"notes.{q['key']}") for q in spec.questions}
answer_pick(booth, mark_id, choice, form.get("notes", ""), who=who, qnotes=qnotes)
else:
write_answer(booth, ask, form.get("choice"), form.get("notes", ""), who=who)
answer_pick(booth, mark_id, form.get("choice"), form.get("notes", ""), who=who)
except AskError as exc:
raise HTTPException(status_code=400, detail=str(exc))
# Land where the form was: the standalone /asks page for a verbatim booth
# (its own index.html cannot show the recorded answer), else the booth.
base = f"/b/{quote(name, safe='')}/"
if form.get("back") == "asks":
base = f"/b/{quote(name, safe='')}/asks"
return RedirectResponse(url=f"{base}#ask-{quote(ask, safe='')}", status_code=303)
return _mark_redirect(name, form, f"mark-{quote(mark_id, safe='')}")
@app.post("/b/{name}/note")
async def booth_note(request: Request, name: str):
"""Attach free text to one item, or to the booth itself.
The operator telling the session — a direction that had no mechanism at
all before marks, which is exactly why it was running through chat.
`target` empty or absent means the booth. 400 on empty text.
"""
booth = resolve_booth(name)
form = await request.form()
raw_target = form.get("target")
target = raw_target if isinstance(raw_target, str) and raw_target else None
text = form.get("text")
try:
mark = write_note(booth, target, text if isinstance(text, str) else "",
who=request.client.host if request.client else "")
except AskError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return _mark_redirect(name, form, f"mark-{quote(mark.id, safe='')}")
@app.post("/b/{name}/flag")
async def booth_flag(request: Request, name: str):
"""Flag or unflag one item — the operator pointing at the good ones.
The shape that makes a 270-image booth tractable, and the one that
closes the loop `golden-candidates` / `sindra-finalists` / the
`pancake-*` ladders were running through conversation.
"""
booth = resolve_booth(name)
form = await request.form()
target = form.get("target")
if not isinstance(target, str) or not target:
raise HTTPException(status_code=400, detail="a flag needs a target")
on = str(form.get("on", "1")) not in ("0", "", "false", "off")
try:
set_flag(booth, target, on, who=request.client.host if request.client else "")
except AskError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return _mark_redirect(name, form, f"item-{quote(target, safe='')}")
@app.post("/b/{name}/unmark")
async def booth_unmark(request: Request, name: str):
"""Withdraw one mark — the operator's undo. Withdrawing a judgment is
his to do; nothing else here removes a mark."""
booth = resolve_booth(name)
form = await request.form()
mark_id = form.get("mark")
if not isinstance(mark_id, str) or not mark_id:
raise HTTPException(status_code=400, detail="which mark?")
delete_mark(booth, mark_id)
return _mark_redirect(name, form, "marks")
@app.post("/b/{name}/import-asks")
async def booth_import_asks(request: Request, name: str):
"""Import this booth's legacy `*.ask.json` sidecars into `.marks.json`.
Idempotent, and it deletes nothing — the sidecars stay on disk. Exposed
as a route as well as a CLI verb so a booth that predates marks can be
migrated from the page you are already looking at.
"""
booth = resolve_booth(name)
import_legacy_asks(booth)
form = await request.form()
return _mark_redirect(name, form, "marks")
_frag = templates.env.get_template("_ask_inline.html").module
@@ -695,77 +799,106 @@ def create_app(
whose questions were placed but whose submit block was not gets that
block appended, so a scattered form is always submittable.
"""
asks = list_asks(booth)
if not asks:
picks = [m for m in marks_for(booth) if m.shape == "pick"]
if not picks:
return html, ""
url = quote(name, safe="")
seen: set[str] = set()
def render(kind: str, ask: dict, key: str | None) -> str:
fid = ask_form_id(ask["stem"])
def render(kind: str, mark, key: str | None) -> str:
fid = ask_form_id(mark.id)
if kind == "whole":
frag = str(_frag.whole(ask, fid, url))
frag = str(_frag.whole(mark, fid, url))
elif kind == "submit":
frag = str(_frag.submit(ask, fid, url))
frag = str(_frag.submit(mark, fid, url))
else:
q = next(q for q in ask["questions"] if q.get("key") == key)
frag = str(_frag.question(ask, q, fid, url))
# An anchor on the FIRST fragment of each stem, wherever it landed,
q = next(q for q in mark.questions if q.get("key") == key)
frag = str(_frag.question(mark, q, fid, url))
# An anchor on the FIRST fragment of each pick, wherever it landed,
# so the floating chip can jump to it on a long report. Computed
# here rather than in the macros because only the caller knows
# which fragment came first.
if ask["stem"] not in seen:
seen.add(ask["stem"])
frag = f'<a id="bk-ask-{ask["stem"]}-top"></a>' + frag
if mark.id not in seen:
seen.add(mark.id)
frag = f'<a id="bk-ask-{mark.id}-top"></a>' + frag
return frag
tail = [str(_frag.styles())]
if has_placeholders(html):
html, placed, submitted = place_asks(html, asks, render)
for a in asks:
keys = placed.get(a["stem"])
html, placed, submitted = place_asks(html, picks, render)
for m in picks:
keys = placed.get(m.id)
if keys is None:
tail.append(render("whole", a, None)) # unmarked: never dropped
tail.append(render("whole", m, None)) # unmarked: never dropped
continue
if a["error"]:
if m.error:
continue
if None not in keys:
# Partially marked up: append every question the author did
# NOT place. A multi-question ask needs all of them or the
# NOT place. A multi-question pick needs all of them or the
# POST is a 400 — met only after the operator fills it in.
for q in a["questions"]:
for q in m.questions:
if q.get("key") not in keys:
tail.append(render("question", a, q.get("key")))
if a["stem"] not in submitted:
tail.append(render("submit", a, None)) # scattered but submittable
tail.append(render("question", m, q.get("key")))
if m.id not in submitted:
tail.append(render("submit", m, None)) # scattered but submittable
else:
for a in asks:
tail.append(render("whole", a, None))
for m in picks:
tail.append(render("whole", m, None))
# The chip is now a JUMP LINK to the inline block, not a way out to a
# The chip is a JUMP LINK to the inline block, not a way out to a
# separate page: on a long report the question can be well below the
# fold, and "there is a question waiting" still has to be visible at
# first paint.
first_open = next((a for a in asks if a["answer"] is None and not a["error"]), None)
open_n = sum(1 for a in asks if a["answer"] is None and not a["error"])
if first_open is not None:
tail.append(asks_chip(name, open_n, href=f'#bk-ask-{first_open["stem"]}-top'))
still_open = open_marks(picks) # INV-2: not re-derived here
if still_open:
tail.append(asks_chip(name, len(still_open),
href=f'#bk-ask-{still_open[0].id}-top'))
return html, "".join(tail)
@app.get("/b/{name}/asks", response_class=HTMLResponse)
def booth_asks_page(request: Request, name: str):
"""The asks panel on its own page. Reachable from any booth, and the ONLY
place a verbatim-index.html booth can show its asks — that page is served
@app.get("/b/{name}/marks", response_class=HTMLResponse)
def booth_marks_page(request: Request, name: str):
"""The marks panel on its own page. Reachable from any booth, and the ONLY
place a verbatim-index.html booth can show its marks — that page is served
untouched by design, so the inline panel never renders there."""
booth = resolve_booth(name)
marks = marks_for(booth)
return templates.TemplateResponse(
request,
"asks.html",
"marks.html",
{**base_ctx, "name": name, "name_url": quote(name, safe=""),
"asks": list_asks(booth), "asks_page": True},
"marks": marks, "marks_open": len(open_marks(marks)),
"booth_marks": marks_for_target(marks, None), "marks_page": True},
)
@app.get("/b/{name}/asks", include_in_schema=False)
def booth_asks_redirect(name: str):
"""`/asks` moved to `/marks` when asks became one shape of mark. A
redirect rather than a 404: the URL is in the operator's history and in
landed reports, and a dead link teaches nothing."""
return RedirectResponse(url=f"/b/{quote(name, safe='')}/marks", status_code=308)
@app.get("/b/{name}/marks.json")
def booth_marks_json(name: str):
"""Every mark in the booth, as JSON — the READ path for a session that
is not on this host.
`booth marks` covers a session with filesystem access; a session on
another box rsyncs its work in and has only HTTP. Before marks it polled
`<stem>.answer.json` and waited for a 404 to become a 200, which is why
this endpoint has to exist: without it, moving picks out of per-question
sidecars would take that capability away. One request now answers for
the whole booth instead of one question at a time.
"""
booth = resolve_booth(name)
marks = marks_for(booth)
return JSONResponse({
"booth": name,
"marks": [as_dict(m) for m in marks],
"open": [m.id for m in open_marks(marks)],
})
@app.get("/b/{name}/view", response_class=HTMLResponse)
def booth_view_file(request: Request, name: str, f: str):
"""Full-size view of ONE item — image zoom, or a doc as a readable page.
@@ -786,6 +919,8 @@ def create_app(
items = booth_items(booth)
item = find_item(items, f)
marks = marks_for(booth)
item_marks = marks_for_target(marks, f)
common = {
**base_ctx,
"name": name,
@@ -796,6 +931,11 @@ def create_app(
"caption": item.caption if item else None,
"section": item.section if item else None,
"blurred": item.blurred if item else False,
# INV-3, U1's rule extended from the caption to the judgment: the
# notes and the flag state travel to full size, which is the size at
# which the judgment is actually being made.
"marks": item_marks,
"flagged": any(m.shape == "flag" for m in item_marks),
}
if item is not None and item.kind == "image":