diff --git a/.gitignore b/.gitignore index 5ce9c8b..6651622 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ .pytest_cache/ booth-data/ uv.lock +graphify-out/ diff --git a/CLAUDE.md b/CLAUDE.md index 7a72898..409541b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,31 +35,35 @@ When you commit, include any pending `persistent-memory.md` and floating uncommitted change while shipping other work — durable memory that lags the code defeats its own purpose. -## The five invariants +## The six invariants These are the ones a casual change breaks silently. Each has a test. -### 1. `links.py` and `asks.py` are stdlib-only, on purpose +### 1. `links.py`, `asks.py` and `marks.py` are stdlib-only, on purpose `scripts/booth` — the CLI every fleet session uses — imports them directly: ```sh -BOOTH_SRC=… python3 -c 'import sys; sys.path.insert(0, …); from booth.asks import write_ask' +BOOTH_SRC=… python3 -c 'import sys; sys.path.insert(0, …); from booth.marks import declare_pick' ``` It runs under the system `python3` with **no venv**. A single third-party -import in either module breaks `booth ask` / `booth answer` / `booth unlink` -on every host, and the failure surfaces in an agent's session, not in ours. +import in any of the three breaks `booth ask` / `booth marks` / `booth answer` / +`booth unlink` on every host, and the failure surfaces in an agent's session, +not in ours. -`items.py` and `app.py` are free to import what they like. Those two are not. +`items.py` and `app.py` are free to import what they like. Those three are not. +`test_stdlib_only` walks each module's AST imports and asserts it — the CLI +imports through a `python3 -c` heredoc that no AST extractor can see, so that +test is the only thing standing here. ### 2. The filesystem is the state No database. `ls ~/booth-data` tells you everything the service knows. Per-booth operator state is a **dotfile inside the booth**: `.forever` (keep), -`.blurred` (one rel per line), `.pins` (link-board pin ids), `.uploaded` -(upload-booth marker). `booth_items()` skips `name.startswith(".")`, so a new +`.blurred` (one rel per line), `.marks.json` + `.marks.lock` (judgment), `.pins` +(link-board pin ids), `.uploaded` (upload-booth marker). `booth_items()` skips `name.startswith(".")`, so a new dotfile costs nothing in item counts, galleries or zips. That skip is why the dotfile is the right shape for new operator state — use it rather than inventing a sidecar-per-item. @@ -91,12 +95,39 @@ would be found by a consumer, not by us. ### 5. Sidecar writes are atomic; text bodies stay raw Anything a session may read while the browser writes it goes through temp file -+ `os.replace` (see `asks.write_answer`). A reader never sees a partial file. ++ `os.replace` (see `marks._write_raw`). A reader never sees a partial file, and +a crash mid-write cannot truncate a file into a shorter — and therefore quieter +— set of marks or a more revealing blur set. `render_doc` returns **raw** text for the non-markdown case on purpose: the template escapes it inside `
`, and pre-escaping here double-encodes under Jinja autoescape. +### 6. Every ordered collection has a stated, deterministic order + +Operator directive, 2026-09-21. Not "usually stable" and not "whatever `rglob` +yields" — a rule you can write down in one line, producing the same sequence on +every render of the same state. Any defensible rule qualifies: byte order over a +path, creation time, an explicit number, an arbitrary-but-recorded sequence. No +rule at all does not. + +The Booth's job is comparison, which makes this load-bearing rather than tidy. +The operator judges tile 47 of `pancake-v3-full` against tile 47 of +`pancake-v4-full`, and refers to artifacts positionally — "the third one", "the +one after the banded one". If the order moves between renders, or differs +between the gallery, the zoom ring, the zip and the `marks` read, a flag or a +note lands on the wrong artifact. It never shows up as a crash; it shows up as +the operator's judgment being quietly misfiled. + +Current rules: items `sorted(rel)`; the zoom ring is that order filtered to +images; captions resolve over a sorted scan; marks `(created, id)`; legacy +import `(mtime, name)`; link rows pinned-then-newest. `ROADMAP.md` carries the +table and the two places still undecided (U7 sections and compare pairing, U6 +bench listing). + +When you add an ordered surface, state its rule in the docstring. If you cannot +state it in one line, it does not have one. + ## Multi-writer vs single-writer — don't inherit the wrong shape `links.md` is a **multi-writer** append log: 17 agent handles post to it diff --git a/README.md b/README.md index 4a9210f..5333f27 100644 --- a/README.md +++ b/README.md @@ -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: ``` -/ .ask.json the question (a session writes it) - / .answer.json the answer (the web UI writes it, atomically) + /.marks.json every mark in the booth (dotfile: never a tile, never in the zip) + /.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 (` .ask.json` / ` .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/ /answer` is what the form submits — fields `ask` plus -`choice` / `notes` (single) or `choice. ` / `notes. ` / `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 - - - - -``` - -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/ /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/ /` | 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`) | -| `GET /b/ /asks` | The asks panel on its own page — the only place a verbatim-`index.html` booth can show its asks | -| `POST /b/ /answer` | Answer an ask (fields `ask` = stem, `choice`/`choice. `, `notes`/`notes. `, `back`); writes ` .answer.json`, 303 back | +| `GET /b/ /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/ /marks.json` | Every mark as JSON, plus `open` — the read path for a session that is not on this host | +| `POST /b/ /answer` | Answer a pick (fields `ask` = mark id, `choice`/`choice. `, `notes`/`notes. `, `back`); 303 back | +| `POST /b/ /note` | Attach free text to an item (`target`) or to the booth (`target` empty); `text` required | +| `POST /b/ /flag` | Flag or unflag one item (`target`, `on`) — an upsert; unflagging removes the mark | +| `POST /b/ /unmark` | Withdraw one mark (`mark` = its id) | +| `POST /b/ /import-asks` | Import this booth's legacy `*.ask.json` sidecars; idempotent, deletes nothing | | `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/ROADMAP.md b/ROADMAP.md index 5af8c51..3fd62cc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,6 +22,43 @@ Ordering is dependency-driven, not priority-driven: **U1 → U2 → {U3, U4, U5} U7**, with **U6 independent** of all of them (different storage, different surface) and therefore the safest thing to land first or in parallel. +### Cross-cutting invariant — deterministic order, everywhere + +**Every ordered collection the Booth renders must have a stated, deterministic +order.** Not "usually stable", not "whatever the filesystem yields" — a rule +someone can name, that produces the same sequence on every render of the same +state. The rule itself is free to be anything defensible: byte order over a +path, creation time, an explicit number, even an arbitrary-but-recorded +sequence. What is forbidden is *no rule*. + +This matters more here than in most services because the Booth's whole job is +**comparison**. The operator is judging `pancake-v3-full` against +`pancake-v4-full`, tile 47 against tile 47. If the order shifts between two page +loads — or differs between the gallery, the zoom ring, the zip manifest and the +`marks` read — then every positional reference the operator makes ("the third +one from the left", "the one after the banded one") is silently wrong, and a +flag or a note lands on the wrong artifact. Non-determinism does not present as +a bug report; it presents as the operator's judgment being quietly misfiled. + +Where it already binds, and what the rule is in each case: + +| collection | rule | +|---|---| +| items in a booth | `sorted(rel)` — byte order over the booth-relative path (U1 INV-3) | +| the zoom prev/next ring | the item order, filtered to images — same sequence, one source | +| caption sidecar resolution | sorted scan, so two media files sharing a stem resolve the same way every time (a real non-determinism U1 removed) | +| marks in a booth | `(created, id)` — time, with the id as tie-break so two marks written in the same second cannot swap | +| legacy ask import | `(mtime, name)`, which is the order `list_asks` gave them | +| link board rows | pinned first, then newest-first | + +Where it is still to be decided, and must be before the unit ships: **U7's +section ordering and its compare pairing** (sections need a stated order among +themselves, not just within; pairing by filename needs a rule for what happens +to an unpaired file), and **U6's bench listing**. + +The test for any new ordered surface: *can you write the rule down in one line?* +If not, it does not have one yet. + ### Explicitly NOT in v1 - **Backward compatibility with the `ask` CLI verbs.** Pre-1.0, and `ask` / diff --git a/booth/app.py b/booth/app.py index de7be88..592c58f 100644 --- a/booth/app.py +++ b/booth/app.py @@ -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 ` .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. ` per question, optional `notes. `, - 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'' + frag + if mark.id not in seen: + seen.add(mark.id) + frag = f'' + 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 + ` .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": diff --git a/booth/asks.py b/booth/asks.py index 3b631b5..083ed39 100644 --- a/booth/asks.py +++ b/booth/asks.py @@ -1,53 +1,65 @@ -"""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. +"""Pick validation and answer shaping — the semantics, without the storage. -STDLIB ONLY, like links.py, so the `booth` CLI can write an ask and read an -answer without the service's venv. +A `pick` is one shape of MARK (see booth/marks.py): one of N options a session +declared in advance, chosen by the operator. This module owns what a declaration +is allowed to look like and what shape the recorded judgment takes; `marks.py` +owns where both are kept. -Filesystem is the state, same as everything else in the Booth: +The split exists because the storage changed and these semantics must not. They +are operator-settled (2026-09-09) and were moved rather than rewritten: - / .ask.json the question (written by a session) - / .answer.json the answer (written by the web UI) + normalize_ask(raw, id) -> dict validate a declaration; BOTH accepted + shapes come back as a `questions` list + build_answer(ask, ...) -> dict shape the operator's answer to one -Ask schema (what a session writes): +STDLIB ONLY, like links.py and marks.py: the `booth` CLI imports these under the +system python3 with no venv. + +Declaration, single-question (what a session writes): {"prompt": "Which render wins?", - "options": ["A — baseline", "B — cudaMallocAsync"], # ≥ 2, strings or + "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"} - -Multi-question form (one submit, one sidecar): +Declaration, multi-question — ONE form, ONE submit: {"title": "R18 batch review", "questions": [{"key": "q1", "prompt": "Render 1?", "options": ["keep", "drop"], "notes": true}, {"key": "q2", "prompt": "Render 2?", "options": ["keep", "drop"]}], "notes": true} - -> {"stem", "title", "answers": {"q1": {"prompt", "choice", "choice_index", "label", "notes"}, …}, - "unanswered": ["q2"], "complete": false, "notes", "answered_at", "answered_by"} -A question left blank is legal: it lands in `unanswered` and is absent from -`answers` (unless it carried a note). `complete` is false until every question -has a pick. Only a submission with no pick AND no notes anywhere is refused. +The recorded judgment: -Re-answering overwrites: the sidecar is the current answer, not a log. A -session that wants history keeps its own. + single {"stem", "prompt", "choice", "choice_index", "label", + "unanswered", "complete", "notes", "answered_at", "answered_by"} + multi {"stem", "title", "answers": {"q1": {"prompt", "choice", + "choice_index", "label", "notes"}, …}, + "unanswered": ["q2"], "complete": false, "notes", …} + +PARTIAL ANSWERS ARE LEGAL, and this is the part most likely to be "cleaned up" +by someone who has not read the ruling. A question left blank is a deliberate +outcome — "none of these", "I have not listened to that one yet", "ask me +later" — and refusing a four-question submission because one was skipped threw +away the three that were made. So a blank question lands in `unanswered`, is +absent from `answers` unless it carried a note, and `complete` stays false. The +ONE refusal is a submission carrying nothing at all: no choice anywhere and no +notes, which would flip an open pick to answered while recording no decision. +An offered-but-invalid option is still an error — a broken form, not a skip. + +Re-answering overwrites: a mark is the CURRENT judgment, not a log. A session +that wants history keeps its own. + +`ASK_SUFFIX` / `ANSWER_SUFFIX` / `is_ask_file` / `is_answer_file` / `ask_stem` +survive for exactly two consumers: the legacy importer in marks.py, and +`booth_items`, which still excludes those files from the tile list because the +migration does not delete them. """ from __future__ import annotations import json -import os import re from datetime import datetime from pathlib import Path @@ -211,55 +223,6 @@ def normalize_ask(raw: dict, stem: str) -> dict: } -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, "multi": False, "title": "", "prompt": None, "questions": [], - "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 _pick(options: list[dict], choice, where: str) -> tuple[int, dict]: idx = next((i for i, o in enumerate(options) if o["id"] == choice), None) if idx is None: @@ -278,11 +241,15 @@ def _clean_notes(text) -> str: return (text or "").replace("\r\n", "\n").strip()[:NOTES_MAX] -def write_answer(booth: Path, stem: str, choice, notes: str = "", who: str = "", +def build_answer(ask: dict, choice, notes: str = "", who: str = "", qnotes: dict | None = None) -> dict: - """Record the operator's answer. Validates the choices that were MADE, - writes ` .answer.json` via temp-file + os.replace so a reader never - sees a half-written document. Returns the answer written. + """Shape the operator's answer to a NORMALIZED ask. Pure — no I/O; the + caller owns storage. Validates the choices that were MADE. + + This is `write_answer`'s logic with the storage removed, extracted so + `booth.marks` can own the storage without re-implementing the semantics + below. `stem` comes off the ask (`normalize_ask` emits it), so the two + callers do not have to agree on a second source for it. PARTIAL ANSWERS ARE LEGAL (operator ruling 2026-09-09). A question left blank is a deliberate outcome — "none of these", "I did not listen to that @@ -303,7 +270,7 @@ def write_answer(booth: Path, stem: str, choice, notes: str = "", who: str = "", {key: option id} dict for a multi-question ask. `qnotes` is {key: text} for per-question notes fields (multi only). """ - ask = load_ask(booth, stem) # raises AskError if the ask is gone/invalid + stem = ask["stem"] stamp = { "answered_at": datetime.now().astimezone().isoformat(timespec="seconds"), "answered_by": who or "", @@ -358,28 +325,4 @@ def write_answer(booth: Path, stem: str, choice, notes: str = "", who: str = "", "notes": form_notes, **stamp, } - 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 | None = None, options: list | None = None, - notes: bool = True, notes_label: str = "notes", doc: dict | None = None) -> Path: - """Author an ask from code/CLI. Either (prompt, options, ...) for a - single-question ask, or `doc=` a full document (single or multi shape). - Validated 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") - if doc is None: - 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/booth/inline.py b/booth/inline.py index 060a074..c92fbb8 100644 --- a/booth/inline.py +++ b/booth/inline.py @@ -61,7 +61,7 @@ def form_id(stem: str) -> str: return f"bk-ask-form-{re.sub(r'[^A-Za-z0-9_-]', '-', stem)}" -def place(html: str, asks: list[dict], render) -> tuple[str, dict[str, set], set[str]]: +def place(html: str, asks: list, render) -> tuple[str, dict[str, set], set[str]]: """Substitute every placeholder with rendered ask HTML. `render(kind, ask, key)` returns the fragment for kind in @@ -80,7 +80,12 @@ def place(html: str, asks: list[dict], render) -> tuple[str, dict[str, set], set blanked: silently eating the author's markup would hide a typo'd stem, and an untouched empty div is invisible anyway. """ - by_stem = {a["stem"]: a for a in asks} + # Marks index by ATTRIBUTE, not subscript: `place` was the one consumer in + # the service that did `a["stem"]`, which a frozen dataclass refuses. Caught + # by the U2 seam review (SR-1) — the cold contract pass cannot see a sibling + # module's surface by design, so nothing else would have found it before the + # first verbatim booth 500'd. + by_stem = {a.id: a for a in asks} placed: dict[str, set] = {} submitted: set[str] = set() @@ -93,7 +98,7 @@ def place(html: str, asks: list[dict], render) -> tuple[str, dict[str, set], set placed.setdefault(stem, set()).add(None) submitted.add(stem) return render("whole", ask, None) - q = next((q for q in ask.get("questions", []) if q.get("key") == key), None) + q = next((q for q in ask.questions if q.get("key") == key), None) if q is None: return m.group(0) placed.setdefault(stem, set()).add(key) diff --git a/booth/marks.py b/booth/marks.py new file mode 100644 index 0000000..1f29338 --- /dev/null +++ b/booth/marks.py @@ -0,0 +1,581 @@ +"""Marks — ONE primitive for operator judgment attached to an artifact. + + An ask is the session asking the operator. An annotation is the operator + telling the session. A vote is the operator pointing at the good ones. + +All three are the same thing, and before this module they were three mechanisms: +asks had two JSON sidecars per question and a walk-the-booth read path, +annotations had nothing, and votes had nothing — so the operator picked winners +out of a 270-image set and told the session IN CHAT. `golden-candidates`, +`sindra-finalists` and the `pancake-*` ladders are all that loop, running +through conversation because the session that posted the set had no way to read +the judgment it asked for. + + MARK + target : the booth, or one item in it (an Item.rel — U1's identity, reused) + shape : pick — one of N options the session declared in advance + note — free text the operator volunteered + flag — this one + writer : the operator, in the browser + reader : the session — `booth marks [--wait]` + +One storage model (`.marks.json`), one read path (`marks_for`), one place +openness is computed (`open_marks`), one rendering slot (beside the artifact). + +STDLIB ONLY, like links.py and asks.py: `scripts/booth` imports this under the +system python3 with no venv. See docs/contracts/u2_marks.contract.md, INV-5. + +WHY ONE FILE PER BOOTH, and not a sidecar per mark (operator decision, +2026-09-21): U4 makes "does this booth still owe an answer?" a hot question — +the sweep asks it per booth per tick and the index asks it per card per page +load — so it has to be one read, not a walk of a booth that may hold 270 files. +And note the writer roles: a session writes pick declarations, the operator +writes judgments. That is two roles on one file, so the flock below is +load-bearing. It is NOT links.md's problem, though — links.md is an O_APPEND +content-hash log because 17 handles write it concurrently and locking its common +path would serialize them. A booth's marks see one session and one operator, so +locking the common path costs nothing. Same lock, deliberately not the same shape. +""" + +from __future__ import annotations + +import fcntl +import json +import os +from dataclasses import asdict, dataclass, field +from datetime import datetime +from pathlib import Path +from typing import IO, Literal, Sequence + +from booth.asks import ( + ANSWER_SUFFIX, + ASK_SUFFIX, + AskError, + NOTES_MAX, + ask_stem, + build_answer, + is_ask_file, + normalize_ask, + valid_stem, +) + +MARKS_FILE = ".marks.json" +MARKS_LOCK = ".marks.lock" +SCHEMA_VERSION = 1 + +PICK = "pick" +NOTE = "note" +FLAG = "flag" +SHAPES = (PICK, NOTE, FLAG) + +TEXT_MAX = NOTES_MAX # a note is the same kind of text as an ask's notes field + +# A target is an Item.rel — a booth-relative POSIX path — or None for the booth +# itself. No second addressing scheme: U1 established `rel` as item identity and +# a mark that invented its own would need a translation layer nobody wants. +_TARGET_MAX = 1024 + + +@dataclass(frozen=True) +class Mark: + """One piece of operator judgment, with every fact any surface needs. + + Wide and flat on purpose. A nested shape-specific bag would make every + template navigate it, and the three shapes share more than they differ. + """ + + id: str + shape: str + target: str | None + created: str + # --- pick: the session's declaration, normalized on READ --- + declaration: dict | None = None + prompt: str | None = None + title: str = "" + multi: bool = False + questions: list[dict] = field(default_factory=list) + options: list[dict] = field(default_factory=list) + notes_enabled: bool = True + notes_label: str = "notes" + # --- the operator's judgment --- + answer: dict | None = None + text: str = "" + flagged: bool = False + by: str = "" + error: str | None = None + + @property + def is_open(self) -> bool: + """Whether this mark still owes the session an answer. Delegates to the + module predicate so there is exactly one of them (INV-2).""" + return _is_open(self) + + +def now_stamp() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def _clean_text(text) -> str: + return (text or "").replace("\r\n", "\n").strip()[:TEXT_MAX] + + +def flag_id(target: str) -> str: + """A flag's id is derived from its target, which is what makes flagging an + UPSERT: one item has at most one flag state, so there is nothing to + accumulate. Unflagging removes the mark rather than storing `false` — an + absent flag and a false flag are the same judgment, and two representations + of one state is how `.forever` became a problem.""" + return f"flag:{target}" + + +def _note_id(existing: set[str]) -> str: + """A note gets a generated id because an item may carry several.""" + n = 1 + while f"note-{n}" in existing: + n += 1 + return f"note-{n}" + + +def _valid_target(target) -> bool: + if target is None: + return True + if not isinstance(target, str) or not target or len(target) > _TARGET_MAX: + return False + # A target names a file inside the booth. Absolute paths and traversal are + # not "unlikely", they are the first thing a fuzzer tries. + if target.startswith("/") or ".." in Path(target).parts: + return False + return True + + +# ---- storage ---------------------------------------------------------------- + + +def _read_raw(booth: Path) -> list[dict]: + """The stored mark entries, or [] for missing/corrupt. + + A booth with no marks and a booth whose mark file is truncated both render + as "no marks", and neither is a 500 — the same posture `read_blurred` takes, + for the same reason: a review surface that will not load is worse than one + that has lost an annotation. + """ + try: + raw = json.loads((Path(booth) / MARKS_FILE).read_text(encoding="utf-8")) + except (OSError, ValueError, UnicodeDecodeError): + return [] + if not isinstance(raw, dict): + return [] + entries = raw.get("marks") + if not isinstance(entries, list): + return [] + return [e for e in entries if isinstance(e, dict) and isinstance(e.get("id"), str)] + + +def _fingerprint(entries: list[dict]) -> str: + """A stable serialization used ONLY to decide whether a write is a no-op.""" + return json.dumps(entries, sort_keys=True, ensure_ascii=False) + + +def _write_raw(booth: Path, entries: list[dict]) -> None: + """Atomic replace, so a reader never sees a half-written document and a + crash mid-write cannot truncate the file into a shorter — and therefore + quieter — set of marks.""" + path = Path(booth) / MARKS_FILE + doc = {"version": SCHEMA_VERSION, "marks": entries} + 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) + + +class _Locked: + """Exclusive flock held across the whole read-modify-write. + + The lock lives on a sidecar dotfile rather than on `.marks.json` itself, + because the write path replaces that file — flock follows the inode, so + locking a file you are about to os.replace protects nothing after the swap. + Same reason `links.py` locks `.links.lock`. + """ + + def __init__(self, booth: Path): + self.booth = Path(booth) + self.entries: list[dict] = [] + self._lf: IO[str] | None = None + self._before: str = "" + self._made_lock = False + + def __enter__(self) -> "_Locked": + self.booth.mkdir(parents=True, exist_ok=True) + lock = self.booth / MARKS_LOCK + # `touch(exist_ok=True)` on an EXISTING file bumps its mtime, and a + # booth's TTL is measured from its newest mtime including dotfiles — so + # an unconditional touch would keep a booth alive just for being read + # through a write path. Create it only when it is not there. + if not lock.exists(): + lock.touch() + self._made_lock = True + self._lf = lock.open("r+") + fcntl.flock(self._lf, fcntl.LOCK_EX) + self.entries = _read_raw(self.booth) + self._before = _fingerprint(self.entries) + return self + + def __exit__(self, exc_type, exc, tb) -> Literal[False]: + """Never suppresses. The annotation is `Literal[False]` rather than + `bool` on purpose: a `bool` tells a type checker this manager MIGHT + swallow an exception, and a swallowed write error would report success + on a mark that never reached disk.""" + lf = self._lf + assert lf is not None, "__exit__ without __enter__" + try: + # Write only if something actually changed. Marking IS activity and + # SHOULD reset the booth's TTL — but a write that changes nothing is + # not activity, and unflagging something that was never flagged + # would otherwise keep a dead booth alive forever. + if exc_type is None and _fingerprint(self.entries) != self._before: + _write_raw(self.booth, self.entries) + elif self._made_lock and not (self.booth / MARKS_FILE).exists(): + # Nothing was written and this booth had no marks before: do not + # leave a lock file behind as the only trace of a no-op. + (self.booth / MARKS_LOCK).unlink(missing_ok=True) + finally: + fcntl.flock(lf, fcntl.LOCK_UN) + lf.close() + self._lf = None + return False + + def find(self, mark_id: str) -> dict | None: + return next((e for e in self.entries if e.get("id") == mark_id), None) + + +# ---- read ------------------------------------------------------------------- + + +def _hydrate(entry: dict) -> Mark: + """One stored entry -> one Mark, declarations normalized. + + A pick's declaration is stored RAW and normalized here, exactly as + `write_ask` + `load_ask` did: validated at write, re-read at render, so a + declaration that went bad on disk surfaces as `error` instead of being + unrepresentable. A broken question the session believes it posted has to be + visible — silently hiding it is the one outcome nobody can debug. + """ + mid = entry["id"] + shape = entry.get("shape") if entry.get("shape") in SHAPES else NOTE + target = entry.get("target") + if not _valid_target(target): + target = None + base = { + "id": mid, + "shape": shape, + "target": target, + "created": entry.get("created") or "", + "by": entry.get("by") or "", + } + + if shape == PICK: + decl = entry.get("declaration") + answer = entry.get("answer") if isinstance(entry.get("answer"), dict) else None + stored_error = entry.get("error") + if isinstance(stored_error, str) and stored_error: + # A reason recorded by whoever wrote the entry — the legacy importer + # discovers these, and the reason has to survive to the page or a + # question the session believes it posted disappears silently. + return Mark(**base, declaration=decl if isinstance(decl, dict) else None, + answer=answer, error=stored_error) + if not isinstance(decl, dict): + return Mark(**base, declaration=None, answer=answer, + error="pick has no declaration") + try: + norm = normalize_ask(decl, mid) + except AskError as exc: + return Mark(**base, declaration=decl, answer=answer, error=str(exc)) + return Mark( + **base, + declaration=decl, + prompt=norm["prompt"], + title=norm["title"], + multi=norm["multi"], + questions=norm["questions"], + options=norm.get("options", []), + notes_enabled=norm["notes"], # normalize_ask emits it as `notes` + notes_label=norm["notes_label"], + answer=answer, + ) + + if shape == FLAG: + return Mark(**base, flagged=True) + + return Mark(**base, text=_clean_text(entry.get("text"))) + + +def marks_for(booth: Path) -> list[Mark]: + """Every mark in a booth, oldest first, declarations normalized and answers + folded in. ONE file read — which is the whole point of the storage shape.""" + entries = _read_raw(booth) + marks = [_hydrate(e) for e in entries] + # (created, id) rather than created alone: two marks written in the same + # second would otherwise order by however json listed them. + marks.sort(key=lambda m: (m.created, m.id)) + return marks + + +def _is_open(mark: Mark) -> bool: + """THE openness predicate. Nothing else may spell this out. + + A partially-answered pick is STILL OPEN. Today's index badge tests + `answer is None` and so calls a half-answered four-question pick closed, + while the panel beside it renders that same pick `◐ partial` — the two + disagree about one booth. Open is the reading that makes U4 correct: a + lifetime rule that unpinned a booth on the first radio click would sweep a + review in flight. + """ + if mark.shape != PICK or mark.error is not None: + return False + if mark.answer is None: + return True + return not mark.answer.get("complete", False) + + +def open_marks(marks: Sequence[Mark]) -> list[Mark]: + """The marks still owed an answer. The index badge, the booth header, the + panel filter and U4's pin rule all call this rather than re-deriving it.""" + return [m for m in marks if _is_open(m)] + + +def marks_for_target(marks: Sequence[Mark], rel: str | None) -> list[Mark]: + """The marks attached to one item, or to the booth itself for None.""" + return [m for m in marks if m.target == rel] + + +def as_dict(mark: Mark) -> dict: + """The JSON boundary — `booth marks` output. Python consumers take the + dataclass, and so does Jinja (every template accesses marks by attribute); + this exists so the CLI has one serialization instead of one per verb.""" + return asdict(mark) + + +# ---- write ------------------------------------------------------------------ + + +def declare_pick(booth: Path, mark_id: str, doc: dict) -> Mark: + """A session poses a pick. + + Validated through `normalize_ask` BEFORE anything is written, so a session + cannot land a question the renderer would refuse. Re-declaring an existing + id replaces the declaration and CLEARS its answer: the question changed, so + the old judgment is not an answer to it. + """ + if not valid_stem(mark_id): + raise AskError("bad mark id: letters, digits, . _ - only") + normalize_ask(doc, mark_id) # raises AskError; nothing written yet + with _Locked(booth) as lk: + existing = lk.find(mark_id) + if existing is not None and existing.get("shape") != PICK: + raise AskError(f"{mark_id!r} is already a {existing.get('shape')}") + entry = { + "id": mark_id, + "shape": PICK, + "target": existing.get("target") if existing else None, + "created": existing.get("created") if existing else now_stamp(), + "declaration": doc, + "answer": None, + } + if existing is None: + lk.entries.append(entry) + else: + lk.entries[lk.entries.index(existing)] = entry + return _hydrate(entry) + + +def answer_pick(booth: Path, mark_id: str, choice, notes: str = "", who: str = "", + qnotes: dict | None = None) -> Mark: + """Record the operator's pick. Every semantic belongs to + `asks.build_answer`; this function owns storage and nothing else. + + Re-answering overwrites — the mark is the CURRENT judgment, not a log. + + The AskError for an id that names no live pick is raised HERE. It used to + come from `load_ask` inside `write_answer`; extracting the answer-builder + moved the path to this caller, and a stale form POST has to be a 400 rather + than a silent no-op. + """ + with _Locked(booth) as lk: + entry = lk.find(mark_id) + if entry is None or entry.get("shape") != PICK: + raise AskError("no such pick") + decl = entry.get("declaration") + if not isinstance(decl, dict): + raise AskError("pick has no declaration") + ask = normalize_ask(decl, mark_id) # raises AskError on a bad declaration + entry["answer"] = build_answer(ask, choice, notes, who, qnotes) + return _hydrate(entry) + + +def write_note(booth: Path, target: str | None, text: str, who: str = "") -> Mark: + """Attach free text to an item, or to the booth itself. + + The operator telling the session — the direction that had no mechanism at + all before this, which is why the loop ran through chat. Several notes per + target are legal (a review makes more than one remark about one image), so + each gets a generated id rather than upserting like a flag. + + Empty text after cleaning is refused, the same posture an empty pick + submission takes: recording a mark that says nothing is strictly worse for + the reading session than recording no mark. + """ + if not _valid_target(target): + raise AskError("a note's target must be a path inside the booth") + body = _clean_text(text) + if not body: + raise AskError("nothing to record — the note is empty") + with _Locked(booth) as lk: + entry = { + "id": _note_id({e.get("id") for e in lk.entries}), + "shape": NOTE, + "target": target, + "created": now_stamp(), + "text": body, + "by": who or "", + } + lk.entries.append(entry) + return _hydrate(entry) + + +def set_flag(booth: Path, target: str, on: bool, who: str = "") -> Mark | None: + """Flag or unflag one item — the operator pointing at the good ones. + + This is the shape that makes a 270-image booth tractable, and the one that + closes a loop currently running through conversation: `golden-candidates`, + `sindra-finalists` and the `pancake-*` ladders are all the operator + selecting winners and then telling the session by hand. + + UPSERT keyed by target (see `flag_id`): flagging twice is idempotent, and + unflagging REMOVES the mark and returns None rather than storing a false. + Unflagging something that was never flagged is not an error — it is the + state the caller asked for. + """ + if not _valid_target(target) or target is None: + raise AskError("a flag's target must be a path inside the booth") + mid = flag_id(target) + with _Locked(booth) as lk: + entry = lk.find(mid) + if not on: + if entry is not None: + lk.entries.remove(entry) + return None + if entry is not None: + return _hydrate(entry) # already flagged; nothing to change + entry = { + "id": mid, + "shape": FLAG, + "target": target, + "created": now_stamp(), + "by": who or "", + } + lk.entries.append(entry) + return _hydrate(entry) + + +def delete_mark(booth: Path, mark_id: str) -> bool: + """Remove one mark by id — the operator's undo. True if it was there. + + Deleting a mark is the operator withdrawing a judgment, which is his to do. + Nothing else in this module deletes anything. + """ + with _Locked(booth) as lk: + entry = lk.find(mark_id) + if entry is None: + return False + lk.entries.remove(entry) + return True + + +# ---- migration -------------------------------------------------------------- + + +def import_legacy_asks(booth: Path) -> list[Mark]: + """Read every `*.ask.json` / `*.answer.json` in a booth into `.marks.json`. + + EXPLICIT AND ONE-SHOT, not lazy. A read that writes would fire on every + index page load for every booth, which is the wrong trade for the four + sidecars that exist on the live service. + + IDEMPOTENT: an id already present as a mark is skipped outright, so a second + run is a no-op and a judgment recorded since the first run is never + clobbered by the older sidecar. + + NOTHING IS DELETED. The sidecars stay on disk — the ROADMAP's non-goal is + explicit about it, and `booth_items` already excludes them from the tile + list, so an imported-but-kept sidecar does not show up as a file. + + Returns the marks it created, oldest first. `created` is seeded from the + sidecar's MTIME rather than from now(): `list_asks` ordered by mtime and + `marks_for` orders by `created`, so seeding from now() would silently + reshuffle a booth's questions at the moment of migration. + """ + booth = Path(booth) + if not booth.is_dir(): + return [] + + found: list[tuple[float, str, dict | None, str | None]] = [] + for p in sorted(booth.iterdir()): + if not p.is_file() or p.name.startswith(".") or not is_ask_file(p.name): + continue + stem = ask_stem(p.name) + if not valid_stem(stem): + # Could not have been written by `booth ask`. Left alone rather than + # imported under an id nothing could address. + continue + # A sidecar that cannot be read is imported WITH ITS REASON rather than + # skipped. `list_asks` surfaced these as `⚠ broken` on the page, and + # dropping them on migration would turn a visible broken question into a + # question that was never there — found by a retargeted test, which is + # the whole argument for retargeting them instead of deleting them. + try: + mtime = p.stat().st_mtime + except OSError: + continue + try: + decl = json.loads(p.read_text(encoding="utf-8")) + except (OSError, ValueError, UnicodeDecodeError) as exc: + found.append((mtime, stem, None, f"unreadable ask: {exc}")) + continue + if not isinstance(decl, dict): + found.append((mtime, stem, None, "ask must be a JSON object")) + continue + found.append((mtime, stem, decl, None)) + + if not found: + return [] + found.sort(key=lambda t: (t[0], t[1])) # mtime, then name — never compares payloads + + created: list[dict] = [] + with _Locked(booth) as lk: + have = {e.get("id") for e in lk.entries} + for mtime, stem, decl, err in found: + if stem in have: + continue + answer = None + ap = booth / f"{stem}{ANSWER_SUFFIX}" + try: + loaded = json.loads(ap.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + answer = loaded + except (OSError, ValueError, UnicodeDecodeError): + pass + entry = { + "id": stem, + "shape": PICK, + "target": None, + "created": datetime.fromtimestamp(mtime).astimezone().isoformat(timespec="seconds"), + "declaration": decl, + "answer": answer, + } + if err: + entry["error"] = err + lk.entries.append(entry) + created.append(entry) + + # Hydrated AFTER the lock so a broken declaration surfaces as `error` here + # exactly as it does on a normal read, rather than through a second path. + return [_hydrate(e) for e in created] diff --git a/booth/templates/_ask_inline.html b/booth/templates/_ask_inline.html index f5c406e..192212b 100644 --- a/booth/templates/_ask_inline.html +++ b/booth/templates/_ask_inline.html @@ -57,7 +57,7 @@ {% set qa = (a.answer.answers.get(q.key) if a.multi else a.answer) if a.answer else None %} {% set picked = qa and qa.choice is not none %} {% set skipped = a.answer and not picked %} - +{% if picked %}✓ answered{% elif skipped %}— skipped{% else %}? your pick{% endif %}{{ q.prompt }}
{% if picked %}recorded: {{ qa.label }}{% if qa.notes %} — {{ qa.notes }}{% endif %}
@@ -84,14 +84,14 @@ {# The form element + hidden fields + overall notes + submit. Empty