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:
+215
-75
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user