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:
@@ -5,3 +5,4 @@ __pycache__/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
booth-data/
|
booth-data/
|
||||||
uv.lock
|
uv.lock
|
||||||
|
graphify-out/
|
||||||
|
|||||||
@@ -35,31 +35,35 @@ When you commit, include any pending `persistent-memory.md` and
|
|||||||
floating uncommitted change while shipping other work — durable memory that
|
floating uncommitted change while shipping other work — durable memory that
|
||||||
lags the code defeats its own purpose.
|
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.
|
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:
|
`scripts/booth` — the CLI every fleet session uses — imports them directly:
|
||||||
|
|
||||||
```sh
|
```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
|
It runs under the system `python3` with **no venv**. A single third-party
|
||||||
import in either module breaks `booth ask` / `booth answer` / `booth unlink`
|
import in any of the three breaks `booth ask` / `booth marks` / `booth answer` /
|
||||||
on every host, and the failure surfaces in an agent's session, not in ours.
|
`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
|
### 2. The filesystem is the state
|
||||||
|
|
||||||
No database. `ls ~/booth-data` tells you everything the service knows.
|
No database. `ls ~/booth-data` tells you everything the service knows.
|
||||||
|
|
||||||
Per-booth operator state is a **dotfile inside the booth**: `.forever` (keep),
|
Per-booth operator state is a **dotfile inside the booth**: `.forever` (keep),
|
||||||
`.blurred` (one rel per line), `.pins` (link-board pin ids), `.uploaded`
|
`.blurred` (one rel per line), `.marks.json` + `.marks.lock` (judgment), `.pins`
|
||||||
(upload-booth marker). `booth_items()` skips `name.startswith(".")`, so a new
|
(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 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
|
dotfile is the right shape for new operator state — use it rather than
|
||||||
inventing a sidecar-per-item.
|
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
|
### 5. Sidecar writes are atomic; text bodies stay raw
|
||||||
|
|
||||||
Anything a session may read while the browser writes it goes through temp file
|
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
|
`render_doc` returns **raw** text for the non-markdown case on purpose: the
|
||||||
template escapes it inside `<pre>`, and pre-escaping here double-encodes under
|
template escapes it inside `<pre>`, and pre-escaping here double-encodes under
|
||||||
Jinja autoescape.
|
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
|
## 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
|
`links.md` is a **multi-writer** append log: 17 agent handles post to it
|
||||||
|
|||||||
@@ -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
|
any editor, greppable, and trivially prunable by hand, which is the whole point
|
||||||
of the Booth's filesystem-is-the-state model.
|
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
|
The one **interactive** primitive, and one primitive for what used to be three
|
||||||
render wins, which plan, go/no-go — and wants to act on it without a chat
|
jobs:
|
||||||
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
|
| shape | who writes it | what it is |
|
||||||
session reads. Filesystem is still the state:
|
|---|---|---|
|
||||||
|
| **`pick`** | a session declares the options, the operator chooses | "which render wins?" — this is what `booth ask` poses |
|
||||||
|
| **`note`** | the operator, in the browser | free text for the session that posted the work |
|
||||||
|
| **`flag`** | the operator, in the browser | *this one* — selecting winners out of a set |
|
||||||
|
|
||||||
|
All three are the operator judging something and the session reading the
|
||||||
|
judgment back. They live in **one file per booth**, so "does this booth still owe
|
||||||
|
me an answer?" is a single read:
|
||||||
|
|
||||||
```
|
```
|
||||||
<booth>/<stem>.ask.json the question (a session writes it)
|
<booth>/.marks.json every mark in the booth (dotfile: never a tile, never in the zip)
|
||||||
<booth>/<stem>.answer.json the answer (the web UI writes it, atomically)
|
<booth>/.marks.lock the write lock (a session declares, the operator answers)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
There are **no `note` or `flag` CLI verbs**, on purpose: the CLI is the session's
|
||||||
|
side of the loop, and a session does not author the operator's judgment. It reads
|
||||||
|
it.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# On nh3-dev — pose, then block until answered (default 1h), then act on it:
|
# 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 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 --wait # prints the answer JSON when it lands
|
||||||
booth answer r18-ab winner # non-blocking: exit 1 while unanswered
|
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
|
# Options can carry an id + detail line instead of a bare label. Write the whole
|
||||||
# JSON yourself (booth.asks.write_ask validates the same way):
|
# declaration yourself and it is validated by the same normaliser the page uses:
|
||||||
cat > ~/booth-data/r18-ab/plan.ask.json <<'EOF'
|
booth ask r18-ab plan "Ship which?" "Plan A" "Plan B" # or, for detail lines:
|
||||||
{"title": "optional short label above the question",
|
python3 -c '
|
||||||
"prompt": "Ship which?",
|
import pathlib, sys; sys.path.insert(0, "/home/lkraven/development/booth")
|
||||||
"options": [{"id": "a", "label": "Plan A", "detail": "smaller diff, no migration"},
|
from booth.marks import declare_pick
|
||||||
{"id": "b", "label": "Plan B", "detail": "cleaner, needs the DB change"}],
|
declare_pick(pathlib.Path("/home/lkraven/booth-data/r18-ab"), "plan", {
|
||||||
"notes": true, "notes_label": "why / conditions"}
|
"title": "optional short label above the question",
|
||||||
EOF
|
"prompt": "Ship which?",
|
||||||
|
"options": [{"id": "a", "label": "Plan A", "detail": "smaller diff, no migration"},
|
||||||
|
{"id": "b", "label": "Plan B", "detail": "cleaner, needs the DB change"}],
|
||||||
|
"notes": True, "notes_label": "why / conditions"})'
|
||||||
|
|
||||||
# From another host: rsync the ask in, then poll the sidecar over HTTP:
|
# From another host: rsync your work in, then read the judgment over HTTP.
|
||||||
curl -sf http://10.100.10.50:8090/b/r18-ab/winner.answer.json # 404 until answered
|
# 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
|
**Several questions, one form.** Give the declaration a `questions` list instead
|
||||||
`prompt`+`options`; the page renders one form with a radio group per question
|
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
|
and a single submit. Per-question `notes: true` adds a small text field under
|
||||||
a small text field under that question; the form-level `notes` stays one field
|
that question; the form-level `notes` stays one field for the whole pick. The
|
||||||
for the whole ask. The answer is keyed by question:
|
answer is keyed by question:
|
||||||
|
|
||||||
|
```python
|
||||||
|
declare_pick(booth, "batch", {
|
||||||
|
"title": "R18 batch review",
|
||||||
|
"questions": [
|
||||||
|
{"key": "r1", "prompt": "Render 1 — keep?", "options": ["keep", "drop"], "notes": True},
|
||||||
|
{"key": "r2", "prompt": "Render 2 — keep?", "options": ["keep", "drop"]},
|
||||||
|
{"key": "seed", "prompt": "Reseed the batch?", "options": ["yes", "no"]}],
|
||||||
|
"notes": True, "notes_label": "anything else"})
|
||||||
|
# -> answer: {"stem", "title", "answers": {"r1": {"prompt", "choice",
|
||||||
|
# "choice_index", "label", "notes"}, "r2": {...}, "seed": {...}},
|
||||||
|
# "unanswered", "complete", "notes", "answered_at", "answered_by"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**A partial answer is recorded, not refused.** A question left blank is a
|
||||||
|
deliberate outcome — "none of these", "not yet", "ask me later" — so it lands in
|
||||||
|
`unanswered`, stays absent from `answers` unless it carried a note, and
|
||||||
|
`complete` stays false. **A partially-answered pick still counts as open**, which
|
||||||
|
is what the index badge reports. The one refusal is a submission carrying nothing
|
||||||
|
at all: no choice anywhere and no notes.
|
||||||
|
|
||||||
|
**Migrating a booth that predates marks.** The old two-sidecars-per-question
|
||||||
|
files (`<stem>.ask.json` / `<stem>.answer.json`) are imported, never deleted:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cat > ~/booth-data/r18-ab/batch.ask.json <<'EOF'
|
booth marks-import r18-ab # idempotent; the sidecars stay on disk
|
||||||
{"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"}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Both shapes also carry **`unanswered`** (the question keys left blank; `[null]`
|
|
||||||
for a blank single-question ask) and **`complete`** (false until every question
|
|
||||||
has a pick). A reading session should check `complete` before acting on a
|
|
||||||
multi-question answer, and treat a key in `unanswered` as "not decided", never
|
|
||||||
as "declined".
|
|
||||||
|
|
||||||
The single-question answer: `{"stem", "prompt", "choice", "choice_index", "label", "notes",
|
|
||||||
"unanswered", "complete", "answered_at", "answered_by"}` — `choice` is the option id (the label itself
|
|
||||||
for string options), `choice_index` its 0-based position, `answered_by` the
|
|
||||||
client address. `POST /b/<name>/answer` is what the form submits — fields `ask` plus
|
|
||||||
`choice` / `notes` (single) or `choice.<key>` / `notes.<key>` / `notes` (multi);
|
|
||||||
a missing or bad choice is a 400, an unknown stem a 404.
|
|
||||||
|
|
||||||
Rules of the primitive:
|
|
||||||
|
|
||||||
- **Radio, one pick per question.** ≥ 2 options, ≤ 40 per question, ≤ 30
|
|
||||||
questions per ask. No multi-select checkboxes (not yet asked for). Many asks
|
|
||||||
per booth are fine — each is its own form and its own sidecar; use
|
|
||||||
`questions` when the picks belong together and should land as one answer.
|
|
||||||
- **Re-answering overwrites.** The sidecar is the *current* answer, not a log.
|
|
||||||
The page shows the recorded answer with a collapsed *change answer* form.
|
|
||||||
- **Blanks are legal — a partial answer is recorded, not refused.** Leaving a
|
|
||||||
question alone is a real outcome ("none of these", "not listened to yet"), and
|
|
||||||
refusing the whole submission over one blank threw away the picks that WERE
|
|
||||||
made. So every answered question is recorded, every blank one lands in
|
|
||||||
`unanswered`, and `complete` says whether the set is finished. The radios carry
|
|
||||||
no HTML `required`, so the browser does not block the submit either. A question
|
|
||||||
left blank but carrying a note keeps the note (`choice: null`). The one refusal
|
|
||||||
is a submission with **no pick anywhere and no notes** — a 400, because it would
|
|
||||||
flip an open ask to "answered" while recording no decision, which is worse for
|
|
||||||
the reading session than leaving it open. A choice that is not in the option
|
|
||||||
list is still an error: that is a broken form, not a skipped question.
|
|
||||||
Partially-answered asks show as `◐ partial` with an `n/N` count; re-submitting
|
|
||||||
fills in the rest.
|
|
||||||
- **Open asks are flagged** — an amber `? N asks` badge on the index card and in
|
|
||||||
the booth header — so a waiting question is visible from the front page.
|
|
||||||
- **A broken ask is shown as broken**, not hidden: if the JSON does not
|
|
||||||
validate, the page says why, so a session never thinks it posted a question
|
|
||||||
the operator cannot see.
|
|
||||||
- Ask/answer files are not gallery items and do not count toward the booth's
|
|
||||||
item count; they render as the panel above the gallery. Answering bumps the
|
|
||||||
booth's mtime, so it lives another TTL — the session has 24h to read it.
|
|
||||||
- Works with JavaScript off (plain form POST). No auth, same as everything here.
|
|
||||||
|
|
||||||
### Where the form renders
|
|
||||||
|
|
||||||
Two booth shapes, two placements. Either way the ask is never invisible — that
|
|
||||||
is the guarantee; markup only moves it somewhere better.
|
|
||||||
|
|
||||||
**Auto-gallery booth** (no `index.html` of its own): the asks panel renders
|
|
||||||
above the gallery, styled like the rest of the Booth. Nothing to do.
|
|
||||||
|
|
||||||
**A booth serving its own `index.html`**: that page is returned verbatim, so the
|
|
||||||
Booth substitutes **placeholders in your markup** rather than rendering a panel
|
|
||||||
above a gallery that does not exist. The question then sits with the artifact it
|
|
||||||
is about (operator ruling 2026-09-09: *"the asks should be inline with the
|
|
||||||
artifacts, not on a separate page"*).
|
|
||||||
|
|
||||||
**When inline is worth the markup, and when it is not.** The test is whether the
|
|
||||||
artifact can be held in the head while the form is on screen. Two short images
|
|
||||||
side by side — no, the appended form is fine. Twenty audio clips, five per voice
|
|
||||||
across four voices — yes: on a separate page the operator is choosing from
|
|
||||||
*memory of the audio*, not from the audio, and by the fourth voice that memory is
|
|
||||||
gone. That is the case this mechanism exists for (framing owed to tts-dev,
|
|
||||||
2026-09-09, from the `redo-anchors` audition).
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div data-booth-ask="anchors"></div> <!-- the whole ask: every question + submit -->
|
|
||||||
<div data-booth-ask="anchors:lawson"></div> <!-- just that one question's radios -->
|
|
||||||
<div data-booth-ask-submit="anchors"></div> <!-- the notes field + submit button -->
|
|
||||||
<!-- booth:ask anchors:lawson --> <!-- comment form, identical behaviour -->
|
|
||||||
```
|
|
||||||
|
|
||||||
Per-question fragments bind to **one** form via the HTML5 `form=` attribute, so a
|
|
||||||
four-voice audition puts each radio group under that voice's audio and still
|
|
||||||
submits every pick in a single POST — which is what a multi-question ask
|
|
||||||
requires. Fragments ship their own scoped styles, inherit nothing from your page,
|
|
||||||
and use no JavaScript.
|
|
||||||
|
|
||||||
⚠ **Put the placeholder outside any CSS grid or flex container**, or it becomes a
|
|
||||||
cell in it — measured on `redo-anchors`, where the first attempt rendered as a
|
|
||||||
224 px sixth grid cell wedged between two audio players. A sibling of the block
|
|
||||||
it belongs to is right.
|
|
||||||
|
|
||||||
The fallbacks, so a page can never strand a question:
|
|
||||||
|
|
||||||
| you marked up | what happens |
|
|
||||||
|---|---|
|
|
||||||
| nothing | the whole ask is appended at the end of the page |
|
|
||||||
| some questions, no submit | the rest of the questions **and** a submit block are appended |
|
|
||||||
| a stem this booth does not have | your markup is left alone, untouched; the real ask is still appended |
|
|
||||||
|
|
||||||
An amber `? N open asks` chip floats top-right as a jump link to the first open
|
|
||||||
ask, and `GET /b/<name>/asks` still renders every ask on a plain page of its own
|
|
||||||
— useful when you want to hand someone only the question.
|
|
||||||
|
|
||||||
## Upload for pickup
|
## Upload for pickup
|
||||||
|
|
||||||
The reverse direction — put files in through the web, pick them up by id:
|
The reverse direction — put files in through the web, pick them up by id:
|
||||||
@@ -386,8 +320,13 @@ to a safe basename (no path traversal).
|
|||||||
| `GET /b/<name>/` | A booth (its `index.html`, else auto-gallery) |
|
| `GET /b/<name>/` | A booth (its `index.html`, else auto-gallery) |
|
||||||
| `GET /b/<name>/<file>` | Serve a file out of the booth |
|
| `GET /b/<name>/<file>` | Serve a file out of the booth |
|
||||||
| `POST /upload` | Upload files → new pickup booth; 303-redirects to `/b/<id>/` (id in `Location`) |
|
| `POST /upload` | Upload files → new pickup booth; 303-redirects to `/b/<id>/` (id in `Location`) |
|
||||||
| `GET /b/<name>/asks` | The asks panel on its own page — the only place a verbatim-`index.html` booth can show its asks |
|
| `GET /b/<name>/marks` | The marks panel on its own page — the only place a verbatim-`index.html` booth can show its marks (`/asks` 308s here) |
|
||||||
| `POST /b/<name>/answer` | Answer an ask (fields `ask` = stem, `choice`/`choice.<key>`, `notes`/`notes.<key>`, `back`); writes `<stem>.answer.json`, 303 back |
|
| `GET /b/<name>/marks.json` | Every mark as JSON, plus `open` — the read path for a session that is not on this host |
|
||||||
|
| `POST /b/<name>/answer` | Answer a pick (fields `ask` = mark id, `choice`/`choice.<key>`, `notes`/`notes.<key>`, `back`); 303 back |
|
||||||
|
| `POST /b/<name>/note` | Attach free text to an item (`target`) or to the booth (`target` empty); `text` required |
|
||||||
|
| `POST /b/<name>/flag` | Flag or unflag one item (`target`, `on`) — an upsert; unflagging removes the mark |
|
||||||
|
| `POST /b/<name>/unmark` | Withdraw one mark (`mark` = its id) |
|
||||||
|
| `POST /b/<name>/import-asks` | Import this booth's legacy `*.ask.json` sidecars; idempotent, deletes nothing |
|
||||||
| `POST /b/<name>/delete` | Wipe a booth (the UI's "Wipe now" button) |
|
| `POST /b/<name>/delete` | Wipe a booth (the UI's "Wipe now" button) |
|
||||||
| `POST /b/<name>/keep` | Pin a booth — exempt from the sweep |
|
| `POST /b/<name>/keep` | Pin a booth — exempt from the sweep |
|
||||||
| `POST /b/<name>/unkeep` | Release the pin (the UI's "release" button on kept cards) |
|
| `POST /b/<name>/unkeep` | Release the pin (the UI's "release" button on kept cards) |
|
||||||
|
|||||||
+37
@@ -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
|
U7**, with **U6 independent** of all of them (different storage, different
|
||||||
surface) and therefore the safest thing to land first or in parallel.
|
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
|
### Explicitly NOT in v1
|
||||||
|
|
||||||
- **Backward compatibility with the `ask` CLI verbs.** Pre-1.0, and `ask` /
|
- **Backward compatibility with the `ask` CLI verbs.** Pre-1.0, and `ask` /
|
||||||
|
|||||||
+215
-75
@@ -118,10 +118,20 @@ from booth.asks import ( # noqa: E402
|
|||||||
AskError,
|
AskError,
|
||||||
is_answer_file,
|
is_answer_file,
|
||||||
is_ask_file,
|
is_ask_file,
|
||||||
list_asks,
|
|
||||||
load_ask,
|
|
||||||
valid_stem,
|
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
|
from booth.inline import ( # noqa: E402
|
||||||
form_id as ask_form_id,
|
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("."):
|
if not child.is_dir() or child.name.startswith("."):
|
||||||
continue
|
continue
|
||||||
items = booth_items(child)
|
items = booth_items(child)
|
||||||
# Asks are questions, not items: counted separately so the index can
|
# Marks are judgment, not items: counted separately so the index can
|
||||||
# flag a booth that is waiting on the operator.
|
# flag a booth that is waiting on the operator. ONE file read per booth
|
||||||
asks = list_asks(child)
|
# — 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}
|
kinds = {"image": 0, "video": 0, "audio": 0, "other": 0}
|
||||||
thumb_url = None
|
thumb_url = None
|
||||||
thumb_blurred = False
|
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(),
|
"has_index": (child / "index.html").is_file(),
|
||||||
"uploaded": (child / UPLOAD_MARKER).exists(),
|
"uploaded": (child / UPLOAD_MARKER).exists(),
|
||||||
"kept": is_kept(child),
|
"kept": is_kept(child),
|
||||||
"asks_total": len(asks),
|
"marks_total": len(marks),
|
||||||
"asks_open": sum(1 for a in asks if a["answer"] is None and not a["error"]),
|
# `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)),
|
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
|
||||||
"mtime": mtime,
|
"mtime": mtime,
|
||||||
}
|
}
|
||||||
@@ -610,6 +625,14 @@ def create_app(
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return FileResponse(str(own_index), media_type="text/html")
|
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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"booth.html",
|
"booth.html",
|
||||||
@@ -620,13 +643,7 @@ def create_app(
|
|||||||
# The page could not previously tell keep from release, so it
|
# The page could not previously tell keep from release, so it
|
||||||
# offered neither and you had to go back to the index.
|
# offered neither and you had to go back to the index.
|
||||||
"kept": is_kept(booth),
|
"kept": is_kept(booth),
|
||||||
# links.md is rendered AS the board below, so it must not also
|
"items": gallery,
|
||||||
# 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)
|
|
||||||
],
|
|
||||||
# A booth carrying links.md is the standing link board: render
|
# A booth carrying links.md is the standing link board: render
|
||||||
# its rows as real UI (link, provenance, pin, per-row + bulk
|
# its rows as real UI (link, provenance, pin, per-row + bulk
|
||||||
# remove) instead of a markdown blob you can only edit by hand.
|
# 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 []
|
if (booth / LINKS_FILE).is_file() else []
|
||||||
),
|
),
|
||||||
# Asks: multiple-choice questions a session left for the
|
# Marks: operator judgment attached to this booth or to one of
|
||||||
# operator, rendered as forms above the gallery (open ones)
|
# its items — a session's question (`pick`), the operator's own
|
||||||
# or as their recorded answer. See booth/asks.py.
|
# remark (`note`), the operator's selection (`flag`). Rendered
|
||||||
"asks": list_asks(booth),
|
# 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(),
|
"uploaded": (booth / UPLOAD_MARKER).exists(),
|
||||||
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
|
"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")
|
@app.post("/b/{name}/answer")
|
||||||
async def booth_answer(request: Request, name: str):
|
async def booth_answer(request: Request, name: str):
|
||||||
"""Record the operator's answer to one ask: validates every choice
|
"""Record the operator's pick — one of N options a session declared in
|
||||||
against the ask and writes `<stem>.answer.json` atomically.
|
advance. Validates every choice against the declaration and rewrites
|
||||||
Re-submitting overwrites — the sidecar is the current answer.
|
`.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>`,
|
multi-question → `choice.<key>` per question, optional `notes.<key>`,
|
||||||
plus the form-level `notes`. 404 for an unknown/invalid stem, 400 for
|
plus the form-level `notes`. 404 for an unknown id, 400 for a missing
|
||||||
a missing choice or one the ask does not offer.
|
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)
|
booth = resolve_booth(name)
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
ask = form.get("ask")
|
mark_id = form.get("ask")
|
||||||
if not isinstance(ask, str) or not valid_stem(ask) or not (booth / f"{ask}{ASK_SUFFIX}").is_file():
|
if not isinstance(mark_id, str) or not valid_stem(mark_id):
|
||||||
raise HTTPException(status_code=404, detail="no such ask")
|
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 ""
|
who = request.client.host if request.client else ""
|
||||||
try:
|
try:
|
||||||
spec = load_ask(booth, ask)
|
if spec.multi:
|
||||||
if spec["multi"]:
|
choice = {q["key"]: form.get(f"choice.{q['key']}") for q in spec.questions}
|
||||||
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}
|
||||||
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)
|
||||||
write_answer(booth, ask, choice, form.get("notes", ""), who=who, qnotes=qnotes)
|
|
||||||
else:
|
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:
|
except AskError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
# Land where the form was: the standalone /asks page for a verbatim booth
|
return _mark_redirect(name, form, f"mark-{quote(mark_id, safe='')}")
|
||||||
# (its own index.html cannot show the recorded answer), else the booth.
|
|
||||||
base = f"/b/{quote(name, safe='')}/"
|
@app.post("/b/{name}/note")
|
||||||
if form.get("back") == "asks":
|
async def booth_note(request: Request, name: str):
|
||||||
base = f"/b/{quote(name, safe='')}/asks"
|
"""Attach free text to one item, or to the booth itself.
|
||||||
return RedirectResponse(url=f"{base}#ask-{quote(ask, safe='')}", status_code=303)
|
|
||||||
|
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
|
_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
|
whose questions were placed but whose submit block was not gets that
|
||||||
block appended, so a scattered form is always submittable.
|
block appended, so a scattered form is always submittable.
|
||||||
"""
|
"""
|
||||||
asks = list_asks(booth)
|
picks = [m for m in marks_for(booth) if m.shape == "pick"]
|
||||||
if not asks:
|
if not picks:
|
||||||
return html, ""
|
return html, ""
|
||||||
url = quote(name, safe="")
|
url = quote(name, safe="")
|
||||||
|
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
|
|
||||||
def render(kind: str, ask: dict, key: str | None) -> str:
|
def render(kind: str, mark, key: str | None) -> str:
|
||||||
fid = ask_form_id(ask["stem"])
|
fid = ask_form_id(mark.id)
|
||||||
if kind == "whole":
|
if kind == "whole":
|
||||||
frag = str(_frag.whole(ask, fid, url))
|
frag = str(_frag.whole(mark, fid, url))
|
||||||
elif kind == "submit":
|
elif kind == "submit":
|
||||||
frag = str(_frag.submit(ask, fid, url))
|
frag = str(_frag.submit(mark, fid, url))
|
||||||
else:
|
else:
|
||||||
q = next(q for q in ask["questions"] if q.get("key") == key)
|
q = next(q for q in mark.questions if q.get("key") == key)
|
||||||
frag = str(_frag.question(ask, q, fid, url))
|
frag = str(_frag.question(mark, q, fid, url))
|
||||||
# An anchor on the FIRST fragment of each stem, wherever it landed,
|
# 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
|
# so the floating chip can jump to it on a long report. Computed
|
||||||
# here rather than in the macros because only the caller knows
|
# here rather than in the macros because only the caller knows
|
||||||
# which fragment came first.
|
# which fragment came first.
|
||||||
if ask["stem"] not in seen:
|
if mark.id not in seen:
|
||||||
seen.add(ask["stem"])
|
seen.add(mark.id)
|
||||||
frag = f'<a id="bk-ask-{ask["stem"]}-top"></a>' + frag
|
frag = f'<a id="bk-ask-{mark.id}-top"></a>' + frag
|
||||||
return frag
|
return frag
|
||||||
|
|
||||||
tail = [str(_frag.styles())]
|
tail = [str(_frag.styles())]
|
||||||
if has_placeholders(html):
|
if has_placeholders(html):
|
||||||
html, placed, submitted = place_asks(html, asks, render)
|
html, placed, submitted = place_asks(html, picks, render)
|
||||||
for a in asks:
|
for m in picks:
|
||||||
keys = placed.get(a["stem"])
|
keys = placed.get(m.id)
|
||||||
if keys is None:
|
if keys is None:
|
||||||
tail.append(render("whole", a, None)) # unmarked: never dropped
|
tail.append(render("whole", m, None)) # unmarked: never dropped
|
||||||
continue
|
continue
|
||||||
if a["error"]:
|
if m.error:
|
||||||
continue
|
continue
|
||||||
if None not in keys:
|
if None not in keys:
|
||||||
# Partially marked up: append every question the author did
|
# 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.
|
# 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:
|
if q.get("key") not in keys:
|
||||||
tail.append(render("question", a, q.get("key")))
|
tail.append(render("question", m, q.get("key")))
|
||||||
if a["stem"] not in submitted:
|
if m.id not in submitted:
|
||||||
tail.append(render("submit", a, None)) # scattered but submittable
|
tail.append(render("submit", m, None)) # scattered but submittable
|
||||||
else:
|
else:
|
||||||
for a in asks:
|
for m in picks:
|
||||||
tail.append(render("whole", a, None))
|
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
|
# 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
|
# fold, and "there is a question waiting" still has to be visible at
|
||||||
# first paint.
|
# first paint.
|
||||||
first_open = next((a for a in asks if a["answer"] is None and not a["error"]), None)
|
still_open = open_marks(picks) # INV-2: not re-derived here
|
||||||
open_n = sum(1 for a in asks if a["answer"] is None and not a["error"])
|
if still_open:
|
||||||
if first_open is not None:
|
tail.append(asks_chip(name, len(still_open),
|
||||||
tail.append(asks_chip(name, open_n, href=f'#bk-ask-{first_open["stem"]}-top'))
|
href=f'#bk-ask-{still_open[0].id}-top'))
|
||||||
return html, "".join(tail)
|
return html, "".join(tail)
|
||||||
|
|
||||||
@app.get("/b/{name}/asks", response_class=HTMLResponse)
|
@app.get("/b/{name}/marks", response_class=HTMLResponse)
|
||||||
def booth_asks_page(request: Request, name: str):
|
def booth_marks_page(request: Request, name: str):
|
||||||
"""The asks panel on its own page. Reachable from any booth, and the ONLY
|
"""The marks 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
|
place a verbatim-index.html booth can show its marks — that page is served
|
||||||
untouched by design, so the inline panel never renders there."""
|
untouched by design, so the inline panel never renders there."""
|
||||||
booth = resolve_booth(name)
|
booth = resolve_booth(name)
|
||||||
|
marks = marks_for(booth)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"asks.html",
|
"marks.html",
|
||||||
{**base_ctx, "name": name, "name_url": quote(name, safe=""),
|
{**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)
|
@app.get("/b/{name}/view", response_class=HTMLResponse)
|
||||||
def booth_view_file(request: Request, name: str, f: str):
|
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.
|
"""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)
|
items = booth_items(booth)
|
||||||
item = find_item(items, f)
|
item = find_item(items, f)
|
||||||
|
marks = marks_for(booth)
|
||||||
|
item_marks = marks_for_target(marks, f)
|
||||||
common = {
|
common = {
|
||||||
**base_ctx,
|
**base_ctx,
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -796,6 +931,11 @@ def create_app(
|
|||||||
"caption": item.caption if item else None,
|
"caption": item.caption if item else None,
|
||||||
"section": item.section if item else None,
|
"section": item.section if item else None,
|
||||||
"blurred": item.blurred if item else False,
|
"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":
|
if item is not None and item.kind == "image":
|
||||||
|
|||||||
+49
-106
@@ -1,53 +1,65 @@
|
|||||||
"""Asks: a session poses a multiple-choice question in a booth; the operator
|
"""Pick validation and answer shaping — the semantics, without the storage.
|
||||||
answers it in the browser; the answer lands as a sidecar the session reads.
|
|
||||||
|
|
||||||
STDLIB ONLY, like links.py, so the `booth` CLI can write an ask and read an
|
A `pick` is one shape of MARK (see booth/marks.py): one of N options a session
|
||||||
answer without the service's venv.
|
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:
|
||||||
|
|
||||||
<booth>/<stem>.ask.json the question (written by a session)
|
normalize_ask(raw, id) -> dict validate a declaration; BOTH accepted
|
||||||
<booth>/<stem>.answer.json the answer (written by the web UI)
|
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?",
|
{"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": "…"}, …]
|
# [{"id": "a", "label": "A — baseline", "detail": "…"}, …]
|
||||||
"notes": true, # optional, default true: show a free-text field
|
"notes": true, # optional, default true: show a free-text field
|
||||||
"notes_label": "why?"} # optional placeholder for that field
|
"notes_label": "why?"} # optional placeholder for that field
|
||||||
|
|
||||||
Answer schema (what the operator's submit writes, atomically):
|
Declaration, multi-question — ONE form, ONE submit:
|
||||||
|
|
||||||
{"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):
|
|
||||||
|
|
||||||
{"title": "R18 batch review",
|
{"title": "R18 batch review",
|
||||||
"questions": [{"key": "q1", "prompt": "Render 1?", "options": ["keep", "drop"], "notes": true},
|
"questions": [{"key": "q1", "prompt": "Render 1?", "options": ["keep", "drop"], "notes": true},
|
||||||
{"key": "q2", "prompt": "Render 2?", "options": ["keep", "drop"]}],
|
{"key": "q2", "prompt": "Render 2?", "options": ["keep", "drop"]}],
|
||||||
"notes": true}
|
"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
|
The recorded judgment:
|
||||||
`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.
|
|
||||||
|
|
||||||
Re-answering overwrites: the sidecar is the current answer, not a log. A
|
single {"stem", "prompt", "choice", "choice_index", "label",
|
||||||
session that wants history keeps its own.
|
"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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
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]:
|
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)
|
idx = next((i for i, o in enumerate(options) if o["id"] == choice), None)
|
||||||
if idx is None:
|
if idx is None:
|
||||||
@@ -278,11 +241,15 @@ def _clean_notes(text) -> str:
|
|||||||
return (text or "").replace("\r\n", "\n").strip()[:NOTES_MAX]
|
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:
|
qnotes: dict | None = None) -> dict:
|
||||||
"""Record the operator's answer. Validates the choices that were MADE,
|
"""Shape the operator's answer to a NORMALIZED ask. Pure — no I/O; the
|
||||||
writes `<stem>.answer.json` via temp-file + os.replace so a reader never
|
caller owns storage. Validates the choices that were MADE.
|
||||||
sees a half-written document. Returns the answer written.
|
|
||||||
|
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
|
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
|
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
|
{key: option id} dict for a multi-question ask. `qnotes` is {key: text} for
|
||||||
per-question notes fields (multi only).
|
per-question notes fields (multi only).
|
||||||
"""
|
"""
|
||||||
ask = load_ask(booth, stem) # raises AskError if the ask is gone/invalid
|
stem = ask["stem"]
|
||||||
stamp = {
|
stamp = {
|
||||||
"answered_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
"answered_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
"answered_by": who or "",
|
"answered_by": who or "",
|
||||||
@@ -358,28 +325,4 @@ def write_answer(booth: Path, stem: str, choice, notes: str = "", who: str = "",
|
|||||||
"notes": form_notes,
|
"notes": form_notes,
|
||||||
**stamp,
|
**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
|
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
|
|
||||||
|
|||||||
+8
-3
@@ -61,7 +61,7 @@ def form_id(stem: str) -> str:
|
|||||||
return f"bk-ask-form-{re.sub(r'[^A-Za-z0-9_-]', '-', stem)}"
|
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.
|
"""Substitute every placeholder with rendered ask HTML.
|
||||||
|
|
||||||
`render(kind, ask, key)` returns the fragment for kind in
|
`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
|
blanked: silently eating the author's markup would hide a typo'd stem, and
|
||||||
an untouched empty div is invisible anyway.
|
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] = {}
|
placed: dict[str, set] = {}
|
||||||
submitted: set[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)
|
placed.setdefault(stem, set()).add(None)
|
||||||
submitted.add(stem)
|
submitted.add(stem)
|
||||||
return render("whole", ask, None)
|
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:
|
if q is None:
|
||||||
return m.group(0)
|
return m.group(0)
|
||||||
placed.setdefault(stem, set()).add(key)
|
placed.setdefault(stem, set()).add(key)
|
||||||
|
|||||||
+581
@@ -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 <booth> [--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]
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
{% set qa = (a.answer.answers.get(q.key) if a.multi else a.answer) if a.answer else None %}
|
{% 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 picked = qa and qa.choice is not none %}
|
||||||
{% set skipped = a.answer and not picked %}
|
{% set skipped = a.answer and not picked %}
|
||||||
<div class="bk-ask{% if picked %} bk-done{% elif skipped %} bk-skip{% endif %}" id="bk-ask-{{ a.stem }}{% if q.key %}-{{ q.key }}{% endif %}">
|
<div class="bk-ask{% if picked %} bk-done{% elif skipped %} bk-skip{% endif %}" id="bk-ask-{{ a.id }}{% if q.key %}-{{ q.key }}{% endif %}">
|
||||||
<span class="bk-ask-tag">{% if picked %}✓ answered{% elif skipped %}— skipped{% else %}? your pick{% endif %}</span>
|
<span class="bk-ask-tag">{% if picked %}✓ answered{% elif skipped %}— skipped{% else %}? your pick{% endif %}</span>
|
||||||
<p class="bk-ask-prompt">{{ q.prompt }}</p>
|
<p class="bk-ask-prompt">{{ q.prompt }}</p>
|
||||||
{% if picked %}<p class="bk-ask-was">recorded: <b>{{ qa.label }}</b>{% if qa.notes %} — {{ qa.notes }}{% endif %}</p>
|
{% if picked %}<p class="bk-ask-was">recorded: <b>{{ qa.label }}</b>{% if qa.notes %} — {{ qa.notes }}{% endif %}</p>
|
||||||
@@ -84,14 +84,14 @@
|
|||||||
{# The form element + hidden fields + overall notes + submit. Empty <form> on
|
{# The form element + hidden fields + overall notes + submit. Empty <form> on
|
||||||
purpose: the question groups above bind to it by id from wherever they sit. #}
|
purpose: the question groups above bind to it by id from wherever they sit. #}
|
||||||
{% macro submit(a, form_id, name_url) %}
|
{% macro submit(a, form_id, name_url) %}
|
||||||
<div class="bk-ask{% if a.answer %} bk-done{% endif %}" id="bk-ask-{{ a.stem }}-submit">
|
<div class="bk-ask{% if a.answer %} bk-done{% endif %}" id="bk-ask-{{ a.id }}-submit">
|
||||||
<form id="{{ form_id }}" method="post" action="/b/{{ name_url }}/answer"></form>
|
<form id="{{ form_id }}" method="post" action="/b/{{ name_url }}/answer"></form>
|
||||||
<input type="hidden" name="ask" value="{{ a.stem }}" form="{{ form_id }}">
|
<input type="hidden" name="ask" value="{{ a.id }}" form="{{ form_id }}">
|
||||||
<span class="bk-ask-tag">{% if a.answer and a.answer.complete %}✓ answered {{ a.answer.answered_at }}
|
<span class="bk-ask-tag">{% if a.answer and a.answer.complete %}✓ answered {{ a.answer.answered_at }}
|
||||||
{%- elif a.answer %}◐ {{ a.questions|length - (a.answer.unanswered|length) }} of {{ a.questions|length }} answered · {{ a.answer.answered_at }}
|
{%- elif a.answer %}◐ {{ a.questions|length - (a.answer.unanswered|length) }} of {{ a.questions|length }} answered · {{ a.answer.answered_at }}
|
||||||
{%- else %}? submit your picks{% endif %}</span>
|
{%- else %}? submit your picks{% endif %}</span>
|
||||||
{% if not a.answer %}<p class="bk-ask-was">Answer what you can — blanks are fine, and you can come back.</p>{% endif %}
|
{% if not a.answer %}<p class="bk-ask-was">Answer what you can — blanks are fine, and you can come back.</p>{% endif %}
|
||||||
{% if a.notes %}
|
{% if a.notes_enabled %}
|
||||||
<textarea class="bk-ask-notes" name="notes" rows="3" form="{{ form_id }}"
|
<textarea class="bk-ask-notes" name="notes" rows="3" form="{{ form_id }}"
|
||||||
placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
|
placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -103,9 +103,9 @@
|
|||||||
{% macro whole(a, form_id, name_url) %}
|
{% macro whole(a, form_id, name_url) %}
|
||||||
{% if a.error %}
|
{% if a.error %}
|
||||||
<div class="bk-ask"><span class="bk-ask-tag">⚠ broken ask</span>
|
<div class="bk-ask"><span class="bk-ask-tag">⚠ broken ask</span>
|
||||||
<p class="bk-ask-err">{{ a.stem }}.ask.json could not be read: {{ a.error }}</p></div>
|
<p class="bk-ask-err">this question could not be read: {{ a.error }}</p></div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if a.title %}<p class="bk-ask-title" id="bk-ask-{{ a.stem }}">{{ a.title }}</p>{% endif %}
|
{% if a.title %}<p class="bk-ask-title" id="bk-ask-{{ a.id }}">{{ a.title }}</p>{% endif %}
|
||||||
{% for q in a.questions %}{{ question(a, q, form_id, name_url) }}{% endfor %}
|
{% for q in a.questions %}{{ question(a, q, form_id, name_url) }}{% endfor %}
|
||||||
{{ submit(a, form_id, name_url) }}
|
{{ submit(a, form_id, name_url) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
{# Shared asks panel — included by booth.html (auto-gallery view) and by
|
|
||||||
asks.html (the standalone page a VERBATIM index.html booth links to, since
|
|
||||||
a verbatim page is served as-is and can never render this inline). #}
|
|
||||||
{# ASKS. A session left multiple-choice questions here for the operator
|
|
||||||
(`<stem>.ask.json`). Open ones render as a radio form; answering POSTs to
|
|
||||||
/answer, which writes `<stem>.answer.json` for the session to read. Works
|
|
||||||
with JS off — plain form POST. Answered asks show the recorded answer and a
|
|
||||||
collapsed "change" form, since the sidecar is the CURRENT answer. #}
|
|
||||||
<section class="asks">
|
|
||||||
{% for a in asks %}
|
|
||||||
<article class="ask{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="ask-{{ a.stem }}">
|
|
||||||
<header class="ask-head">
|
|
||||||
<span class="ask-state">{% if a.error %}⚠ broken{% elif a.answer and a.answer.complete %}✓ answered{% elif a.answer %}◐ partial{% else %}? open{% endif %}</span>
|
|
||||||
<span class="ask-stem"><code>{{ a.stem }}.ask.json</code>{% if a.multi %} · {{ a.questions|length }} questions{% endif %}</span>
|
|
||||||
<span class="board-spacer"></span>
|
|
||||||
{% if a.answer and not a.answer.complete %}<span class="ask-part">{{ (a.questions|length) - (a.answer.unanswered|length) }}/{{ a.questions|length }}</span>{% endif %}
|
|
||||||
{% if a.answer %}<span class="ask-when">{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}</span>{% endif %}
|
|
||||||
</header>
|
|
||||||
{% if a.error %}
|
|
||||||
<p class="ask-error">This ask could not be read: {{ a.error }}</p>
|
|
||||||
{% else %}
|
|
||||||
{% if a.title and not a.multi %}<p class="ask-title">{{ a.title }}</p>{% endif %}
|
|
||||||
<p class="ask-prompt">{{ a.prompt }}</p>
|
|
||||||
{% if a.answer %}
|
|
||||||
<div class="ask-answer">
|
|
||||||
{% if a.multi %}
|
|
||||||
{% for q in a.questions %}{% set qa = a.answer.answers.get(q.key) %}
|
|
||||||
<div class="ask-answer-q">
|
|
||||||
<span class="ask-answer-qprompt">{{ q.prompt }}</span>
|
|
||||||
<div class="ask-answer-choice{% if not (qa and qa.choice is not none) %} is-skipped{% endif %}">{{ qa.label if (qa and qa.choice is not none) else 'left blank' }}</div>
|
|
||||||
{% if qa and qa.notes %}<pre class="ask-answer-notes">{{ qa.notes }}</pre>{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<div class="ask-answer-choice">{{ a.answer.label }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if a.answer.notes %}<pre class="ask-answer-notes">{{ a.answer.notes }}</pre>{% endif %}
|
|
||||||
<span class="ask-answer-file">→ <a href="{{ a.stem }}.answer.json">{{ a.stem }}.answer.json</a></span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<details class="ask-formwrap"{% if not a.answer %} open{% endif %}>
|
|
||||||
<summary class="ask-change">{% if a.answer %}change answer{% else %}answer{% endif %}</summary>
|
|
||||||
<form class="ask-form" method="post" action="/b/{{ name_url }}/answer">
|
|
||||||
<input type="hidden" name="ask" value="{{ a.stem }}">
|
|
||||||
{# On the standalone page, come back HERE — the booth's own page is a
|
|
||||||
verbatim report that cannot show the recorded answer. #}
|
|
||||||
{% if asks_page %}<input type="hidden" name="back" value="asks">{% endif %}
|
|
||||||
{% for q in a.questions %}
|
|
||||||
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
|
|
||||||
{% set qa = a.answer.answers.get(q.key) if (a.answer and a.multi) else a.answer %}
|
|
||||||
<fieldset class="ask-q">
|
|
||||||
{% if a.multi %}<legend class="ask-q-prompt">{{ loop.index }}. {{ q.prompt }}</legend>{% endif %}
|
|
||||||
<div class="ask-options">
|
|
||||||
{% for o in q.options %}
|
|
||||||
<label class="ask-opt{% if qa and qa.choice == o.id %} is-current{% endif %}">
|
|
||||||
<input type="radio" name="{{ field }}" value="{{ o.id }}"
|
|
||||||
{% if qa and qa.choice == o.id %}checked{% endif %}>
|
|
||||||
<span class="ask-opt-main">
|
|
||||||
<span class="ask-opt-label">{{ o.label }}</span>
|
|
||||||
{% if o.detail %}<span class="ask-opt-detail">{{ o.detail }}</span>{% endif %}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% if q.notes %}
|
|
||||||
<textarea class="ask-notes ask-qnotes" name="notes.{{ q.key }}" rows="2" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
|
|
||||||
{% endif %}
|
|
||||||
</fieldset>
|
|
||||||
{% endfor %}
|
|
||||||
{% if a.notes %}
|
|
||||||
<textarea class="ask-notes" name="notes" rows="3" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
|
|
||||||
{% endif %}
|
|
||||||
<div class="ask-actions">
|
|
||||||
<button type="submit" class="ask-submit">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
{% endif %}
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
</section>
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
{# Shared MARKS panel — included by booth.html (auto-gallery view) and by
|
||||||
|
marks.html (the standalone page a VERBATIM index.html booth links to, since
|
||||||
|
a verbatim page is served as-is and can never render this inline).
|
||||||
|
|
||||||
|
One primitive, three shapes, one slot:
|
||||||
|
pick — a session declared N options; the operator chooses. Renders as a
|
||||||
|
radio form; answering POSTs to /answer and rewrites .marks.json.
|
||||||
|
note — free text the operator volunteered, in either direction.
|
||||||
|
flag — the operator pointing at one item. Rendered on the item's tile
|
||||||
|
rather than here, so the judgment sits beside the artifact; the
|
||||||
|
count below is the way back to them.
|
||||||
|
|
||||||
|
Works with JS off — plain form POST, every shape. An answered pick shows the
|
||||||
|
recorded judgment and a collapsed "change" form, because the mark is the
|
||||||
|
CURRENT judgment and not a log. #}
|
||||||
|
{% set picks = marks | selectattr('shape', 'equalto', 'pick') | list %}
|
||||||
|
{% set notes = marks | selectattr('shape', 'equalto', 'note') | list %}
|
||||||
|
{% set flags = marks | selectattr('shape', 'equalto', 'flag') | list %}
|
||||||
|
<section class="marks">
|
||||||
|
|
||||||
|
{% for a in picks %}
|
||||||
|
<article class="mark mark-pick{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="mark-{{ a.id }}">
|
||||||
|
<header class="mark-head">
|
||||||
|
<span class="mark-state">{% if a.error %}⚠ broken{% elif a.answer and a.answer.complete %}✓ answered{% elif a.answer %}◐ partial{% else %}? open{% endif %}</span>
|
||||||
|
<span class="mark-id"><code>{{ a.id }}</code>{% if a.multi %} · {{ a.questions|length }} questions{% endif %}</span>
|
||||||
|
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>{% endif %}
|
||||||
|
<span class="board-spacer"></span>
|
||||||
|
{% if a.answer and not a.answer.complete %}<span class="mark-part">{{ (a.questions|length) - (a.answer.unanswered|length) }}/{{ a.questions|length }}</span>{% endif %}
|
||||||
|
{% if a.answer %}<span class="mark-when">{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}</span>{% endif %}
|
||||||
|
</header>
|
||||||
|
{% if a.error %}
|
||||||
|
<p class="mark-error">This question could not be read: {{ a.error }}</p>
|
||||||
|
{% else %}
|
||||||
|
{% if a.title and not a.multi %}<p class="mark-title">{{ a.title }}</p>{% endif %}
|
||||||
|
<p class="mark-prompt">{{ a.prompt }}</p>
|
||||||
|
{% if a.answer %}
|
||||||
|
<div class="mark-answer">
|
||||||
|
{% if a.multi %}
|
||||||
|
{% for q in a.questions %}{% set qa = a.answer.answers.get(q.key) %}
|
||||||
|
<div class="mark-answer-q">
|
||||||
|
<span class="mark-answer-qprompt">{{ q.prompt }}</span>
|
||||||
|
<div class="mark-answer-choice{% if not (qa and qa.choice is not none) %} is-skipped{% endif %}">{{ qa.label if (qa and qa.choice is not none) else 'left blank' }}</div>
|
||||||
|
{% if qa and qa.notes %}<pre class="mark-answer-notes">{{ qa.notes }}</pre>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="mark-answer-choice">{{ a.answer.label }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if a.answer.notes %}<pre class="mark-answer-notes">{{ a.answer.notes }}</pre>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<details class="mark-formwrap"{% if not a.answer %} open{% endif %}>
|
||||||
|
<summary class="mark-change">{% if a.answer %}change answer{% else %}answer{% endif %}</summary>
|
||||||
|
<form class="mark-form" method="post" action="/b/{{ name_url }}/answer">
|
||||||
|
{# The field is still `ask`: inline fragments in reports the operator
|
||||||
|
has already published POST that name, and breaking every landed
|
||||||
|
verbatim report to tidy a form field is not a trade worth making. #}
|
||||||
|
<input type="hidden" name="ask" value="{{ a.id }}">
|
||||||
|
{# On the standalone page, come back HERE — the booth's own page is a
|
||||||
|
verbatim report that cannot show the recorded judgment. #}
|
||||||
|
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||||||
|
{% for q in a.questions %}
|
||||||
|
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
|
||||||
|
{% set qa = a.answer.answers.get(q.key) if (a.answer and a.multi) else a.answer %}
|
||||||
|
<fieldset class="mark-q">
|
||||||
|
{% if a.multi %}<legend class="mark-q-prompt">{{ loop.index }}. {{ q.prompt }}</legend>{% endif %}
|
||||||
|
<div class="mark-options">
|
||||||
|
{% for o in q.options %}
|
||||||
|
<label class="mark-opt{% if qa and qa.choice == o.id %} is-current{% endif %}">
|
||||||
|
<input type="radio" name="{{ field }}" value="{{ o.id }}"
|
||||||
|
{% if qa and qa.choice == o.id %}checked{% endif %}>
|
||||||
|
<span class="mark-opt-main">
|
||||||
|
<span class="mark-opt-label">{{ o.label }}</span>
|
||||||
|
{% if o.detail %}<span class="mark-opt-detail">{{ o.detail }}</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if q.notes %}
|
||||||
|
<textarea class="mark-notes mark-qnotes" name="notes.{{ q.key }}" rows="2" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
{% endfor %}
|
||||||
|
{% if a.notes_enabled %}
|
||||||
|
<textarea class="mark-notes" name="notes" rows="3" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mark-actions">
|
||||||
|
<button type="submit" class="mark-submit">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% for a in notes %}
|
||||||
|
<article class="mark mark-note" id="mark-{{ a.id }}">
|
||||||
|
<header class="mark-head">
|
||||||
|
<span class="mark-state mark-state-note">note</span>
|
||||||
|
{% if a.target %}<span class="mark-target">on <a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></span>
|
||||||
|
{% else %}<span class="mark-target">on this booth</span>{% endif %}
|
||||||
|
<span class="board-spacer"></span>
|
||||||
|
<span class="mark-when">{{ a.created }}{% if a.by %} · {{ a.by }}{% endif %}</span>
|
||||||
|
<form class="mark-undo" method="post" action="/b/{{ name_url }}/unmark">
|
||||||
|
<input type="hidden" name="mark" value="{{ a.id }}">
|
||||||
|
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||||||
|
<button type="submit" class="mark-x" title="withdraw this note">×</button>
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
<pre class="mark-text">{{ a.text }}</pre>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if flags %}
|
||||||
|
<article class="mark mark-flags" id="mark-flags">
|
||||||
|
<header class="mark-head">
|
||||||
|
<span class="mark-state mark-state-flag">✔ flagged</span>
|
||||||
|
<span class="mark-id">{{ flags|length }} item{{ '' if flags|length == 1 else 's' }}</span>
|
||||||
|
</header>
|
||||||
|
<ul class="mark-flaglist">
|
||||||
|
{% for a in flags %}
|
||||||
|
<li><a href="view?f={{ a.target|urlencode }}">{{ a.target }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# The operator volunteering a remark, which before marks had no mechanism at
|
||||||
|
all — this is the direction that was running through chat. #}
|
||||||
|
<form class="mark-add" method="post" action="/b/{{ name_url }}/note">
|
||||||
|
{% if marks_page %}<input type="hidden" name="back" value="marks">{% endif %}
|
||||||
|
<textarea name="text" rows="2" placeholder="a note on this booth, for the session that posted it"></textarea>
|
||||||
|
<button type="submit">Add note</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}{{ name }} · asks · The Booth{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
{# The asks page for a booth whose own index.html is served VERBATIM. That page
|
|
||||||
cannot render the panel inline (it is returned untouched by design), so the
|
|
||||||
injected chip links here instead. Same forms, same POST target — only the
|
|
||||||
redirect differs, so answering lands back here rather than on the report. #}
|
|
||||||
<div class="boothhead">
|
|
||||||
<a class="back" href="/b/{{ name_url }}/">‹ {{ name }}</a>
|
|
||||||
<h1>Asks</h1>
|
|
||||||
{% set open_asks = asks|selectattr('answer', 'none')|rejectattr('error')|list|length %}
|
|
||||||
<span class="sub">{% if open_asks %}<span class="badge badge-ask">{{ open_asks }} open</span> · {% endif %}{{ asks|length }} ask{{ '' if asks|length == 1 else 's' }}</span>
|
|
||||||
</div>
|
|
||||||
{% if asks %}
|
|
||||||
{% include "_asks.html" %}
|
|
||||||
{% else %}
|
|
||||||
<div class="empty">This booth has no asks.</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
+71
-50
@@ -313,72 +313,93 @@
|
|||||||
Amber = "needs you" while open (the one colour the page does not otherwise
|
Amber = "needs you" while open (the one colour the page does not otherwise
|
||||||
use for state), green check once answered; the accent is a TOP edge, per
|
use for state), green check once answered; the accent is a TOP edge, per
|
||||||
Australis, never a coloured left border. */
|
Australis, never a coloured left border. */
|
||||||
.badge-ask{background:var(--aus-bright-yellow);color:var(--fg-on-accent)}
|
.badge-mark{background:var(--aus-bright-yellow);color:var(--fg-on-accent)}
|
||||||
.thumb .badge+.badge-ask{top:2.2rem}
|
.thumb .badge+.badge-mark{top:2.2rem}
|
||||||
.asks{display:flex;flex-direction:column;gap:.9rem;margin:.2rem 0 1.4rem}
|
.marks{display:flex;flex-direction:column;gap:.9rem;margin:.2rem 0 1.4rem}
|
||||||
.ask{border:1px solid var(--border-subtle);border-top:2px solid var(--aus-bright-yellow);
|
.mark{border:1px solid var(--border-subtle);border-top:2px solid var(--aus-bright-yellow);
|
||||||
border-radius:.5rem;background:var(--rk-panel);overflow:hidden}
|
border-radius:.5rem;background:var(--rk-panel);overflow:hidden}
|
||||||
.ask.is-answered{border-top-color:var(--aus-bright-green)}
|
.mark.is-answered{border-top-color:var(--aus-bright-green)}
|
||||||
/* Partial: answered SOME questions. Not a failure and not done — blanks are a
|
/* Partial: answered SOME questions. Not a failure and not done — blanks are a
|
||||||
legal outcome (operator ruling 2026-09-09), so it gets its own state rather
|
legal outcome (operator ruling 2026-09-09), so it gets its own state rather
|
||||||
than being forced into one of the other two. */
|
than being forced into one of the other two. */
|
||||||
.ask.is-partial{border-top-color:var(--aus-bright-blue)}
|
.mark.is-partial{border-top-color:var(--aus-bright-blue)}
|
||||||
.ask.is-partial .ask-state{color:var(--aus-bright-blue)}
|
.mark.is-partial .mark-state{color:var(--aus-bright-blue)}
|
||||||
.ask-part{font-family:var(--font-mono);font-size:.68rem;color:var(--aus-bright-blue);font-weight:700}
|
.mark-part{font-family:var(--font-mono);font-size:.68rem;color:var(--aus-bright-blue);font-weight:700}
|
||||||
.ask-answer-choice.is-skipped{opacity:.55;font-style:italic}
|
.mark-answer-choice.is-skipped{opacity:.55;font-style:italic}
|
||||||
.ask-answer-choice.is-skipped::before{content:"— ";color:var(--fg-3)}
|
.mark-answer-choice.is-skipped::before{content:"— ";color:var(--fg-3)}
|
||||||
.ask.is-broken{border-top-color:var(--aus-bright-red)}
|
.mark.is-broken{border-top-color:var(--aus-bright-red)}
|
||||||
.ask-head{display:flex;align-items:center;gap:.6rem;padding:.4rem .8rem;
|
.mark-head{display:flex;align-items:center;gap:.6rem;padding:.4rem .8rem;
|
||||||
border-bottom:1px solid var(--border-subtle);background:var(--rk-well);
|
border-bottom:1px solid var(--border-subtle);background:var(--rk-well);
|
||||||
font-family:var(--font-mono);font-size:.68rem;color:var(--fg-3)}
|
font-family:var(--font-mono);font-size:.68rem;color:var(--fg-3)}
|
||||||
.ask-state{font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--aus-bright-yellow)}
|
.mark-state{font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--aus-bright-yellow)}
|
||||||
.ask.is-answered .ask-state{color:var(--aus-bright-green)}
|
.mark.is-answered .mark-state{color:var(--aus-bright-green)}
|
||||||
.ask.is-broken .ask-state{color:var(--aus-bright-red)}
|
.mark.is-broken .mark-state{color:var(--aus-bright-red)}
|
||||||
.ask-when{white-space:nowrap}
|
.mark-when{white-space:nowrap}
|
||||||
.ask-title{margin:.8rem .9rem -.35rem;font-family:var(--font-mono);font-size:.7rem;
|
/* ---- marks: the per-item controls and the note slots ------------------- */
|
||||||
|
.mark-state-note{color:var(--aus-bright-cyan,#42dcd1)}
|
||||||
|
.mark-state-flag{color:var(--aus-bright-green)}
|
||||||
|
.mark-text,.vnote pre,.item-note pre{margin:.3rem 0;padding:.5rem .7rem;white-space:pre-wrap;
|
||||||
|
background:var(--bg-2,rgba(128,140,160,.10));border-radius:6px;font-size:.82rem}
|
||||||
|
.mark-flaglist{margin:.3rem 0 .2rem;padding-left:1.1rem;font-size:.84rem}
|
||||||
|
.mark-add,.vaddnote,.item-addnote form{display:flex;gap:.5rem;align-items:flex-start;margin:.6rem 0}
|
||||||
|
.mark-add textarea,.vaddnote textarea,.item-addnote textarea{flex:1;min-width:0}
|
||||||
|
.mark-x{background:none;border:0;color:var(--fg-3);cursor:pointer;font-size:1rem;line-height:1;padding:0 .3rem}
|
||||||
|
.mark-x:hover{color:var(--aus-bright-red)}
|
||||||
|
.mark-undo{margin-left:.4rem}
|
||||||
|
.flagtoggle button{background:none;border:1px solid var(--border-subtle);border-radius:6px;
|
||||||
|
padding:.12rem .45rem;font-size:.72rem;color:var(--fg-2);cursor:pointer}
|
||||||
|
.flagtoggle button:hover{border-color:var(--aus-bright-green);color:var(--aus-bright-green)}
|
||||||
|
.item.is-flagged{outline:2px solid var(--aus-bright-green);outline-offset:2px}
|
||||||
|
.item-note{display:flex;align-items:flex-start;gap:.3rem}
|
||||||
|
.item-addnote summary{cursor:pointer;font-size:.72rem;color:var(--fg-3);padding:.15rem 0}
|
||||||
|
.vmarks{margin:.6rem auto 0;max-width:min(92vw,900px);display:flex;flex-direction:column;gap:.4rem}
|
||||||
|
.vflag{display:flex}
|
||||||
|
.vbtn.is-flagged{color:var(--aus-bright-green);border-color:var(--aus-bright-green)}
|
||||||
|
.vnote{display:flex;align-items:flex-start;gap:.3rem}
|
||||||
|
.mark-title{margin:.8rem .9rem -.35rem;font-family:var(--font-mono);font-size:.7rem;
|
||||||
letter-spacing:.08em;text-transform:uppercase;color:var(--fg-3)}
|
letter-spacing:.08em;text-transform:uppercase;color:var(--fg-3)}
|
||||||
.ask-prompt{margin:.85rem .9rem .5rem;font-size:1.02rem;font-weight:600;color:var(--fg-0);white-space:pre-wrap}
|
.mark-prompt{margin:.85rem .9rem .5rem;font-size:1.02rem;font-weight:600;color:var(--fg-0);white-space:pre-wrap}
|
||||||
.ask-error{margin:.8rem .9rem;color:var(--aus-bright-red);font-size:.85rem}
|
.mark-error{margin:.8rem .9rem;color:var(--aus-bright-red);font-size:.85rem}
|
||||||
.ask-answer{margin:.2rem .9rem .6rem;padding:.55rem .75rem;border:1px solid var(--border-subtle);
|
.mark-answer{margin:.2rem .9rem .6rem;padding:.55rem .75rem;border:1px solid var(--border-subtle);
|
||||||
border-radius:var(--radius-md);background:rgba(81,224,138,.06)}
|
border-radius:var(--radius-md);background:rgba(81,224,138,.06)}
|
||||||
.ask-answer-choice{font-weight:600;color:var(--fg-0)}
|
.mark-answer-choice{font-weight:600;color:var(--fg-0)}
|
||||||
.ask-answer-choice::before{content:"✓ ";color:var(--aus-bright-green)}
|
.mark-answer-choice::before{content:"✓ ";color:var(--aus-bright-green)}
|
||||||
.ask-answer-notes{margin:.4rem 0 0;white-space:pre-wrap;font-family:var(--font-sans);font-size:.86rem;
|
.mark-answer-notes{margin:.4rem 0 0;white-space:pre-wrap;font-family:var(--font-sans);font-size:.86rem;
|
||||||
color:var(--fg-1)}
|
color:var(--fg-1)}
|
||||||
.ask-answer-file{display:block;margin-top:.35rem;font-family:var(--font-mono);font-size:.68rem;color:var(--fg-3)}
|
.mark-answer-file{display:block;margin-top:.35rem;font-family:var(--font-mono);font-size:.68rem;color:var(--fg-3)}
|
||||||
.ask-answer-file a{color:var(--fg-2)}
|
.mark-answer-file a{color:var(--fg-2)}
|
||||||
.ask-formwrap{margin:0 .9rem .8rem}
|
.mark-formwrap{margin:0 .9rem .8rem}
|
||||||
.ask-change{cursor:pointer;font-family:var(--font-mono);font-size:.7rem;letter-spacing:.06em;
|
.mark-change{cursor:pointer;font-family:var(--font-mono);font-size:.7rem;letter-spacing:.06em;
|
||||||
text-transform:uppercase;color:var(--fg-3);list-style:none;user-select:none}
|
text-transform:uppercase;color:var(--fg-3);list-style:none;user-select:none}
|
||||||
.ask-change::-webkit-details-marker{display:none}
|
.mark-change::-webkit-details-marker{display:none}
|
||||||
.ask-formwrap[open]>.ask-change{margin-bottom:.4rem}
|
.mark-formwrap[open]>.mark-change{margin-bottom:.4rem}
|
||||||
.ask-formwrap:not([open])>.ask-change{color:var(--aus-bright-cyan)}
|
.mark-formwrap:not([open])>.mark-change{color:var(--aus-bright-cyan)}
|
||||||
.ask-q{border:0;margin:0 0 .7rem;padding:0;min-width:0}
|
.mark-q{border:0;margin:0 0 .7rem;padding:0;min-width:0}
|
||||||
.ask-q:last-of-type{margin-bottom:0}
|
.mark-q:last-of-type{margin-bottom:0}
|
||||||
.ask-q-prompt{padding:0;margin:0 0 .35rem;font-size:.9rem;font-weight:600;color:var(--fg-0)}
|
.mark-q-prompt{padding:0;margin:0 0 .35rem;font-size:.9rem;font-weight:600;color:var(--fg-0)}
|
||||||
.ask-qnotes{margin-top:.35rem;font-size:.82rem}
|
.mark-qnotes{margin-top:.35rem;font-size:.82rem}
|
||||||
.ask-answer-q{padding:.3rem 0;border-bottom:1px dashed var(--border-subtle)}
|
.mark-answer-q{padding:.3rem 0;border-bottom:1px dashed var(--border-subtle)}
|
||||||
.ask-answer-q:last-of-type{border-bottom:0}
|
.mark-answer-q:last-of-type{border-bottom:0}
|
||||||
.ask-answer-qprompt{display:block;font-size:.76rem;color:var(--fg-3)}
|
.mark-answer-qprompt{display:block;font-size:.76rem;color:var(--fg-3)}
|
||||||
.ask-options{display:flex;flex-direction:column;gap:.35rem}
|
.mark-options{display:flex;flex-direction:column;gap:.35rem}
|
||||||
.ask-opt{display:flex;align-items:flex-start;gap:.6rem;padding:.5rem .65rem;cursor:pointer;
|
.mark-opt{display:flex;align-items:flex-start;gap:.6rem;padding:.5rem .65rem;cursor:pointer;
|
||||||
border:1px solid var(--border-subtle);border-radius:var(--radius-md);background:var(--rk-well);
|
border:1px solid var(--border-subtle);border-radius:var(--radius-md);background:var(--rk-well);
|
||||||
transition:border-color .12s,background .12s}
|
transition:border-color .12s,background .12s}
|
||||||
.ask-opt:hover{border-color:var(--border-strong)}
|
.mark-opt:hover{border-color:var(--border-strong)}
|
||||||
.ask-opt:has(input:checked){border-color:var(--aus-bright-cyan);background:rgba(66,220,209,.07)}
|
.mark-opt:has(input:checked){border-color:var(--aus-bright-cyan);background:rgba(66,220,209,.07)}
|
||||||
.ask-opt input{margin:.2rem 0 0;accent-color:var(--aus-bright-cyan);flex:0 0 auto}
|
.mark-opt input{margin:.2rem 0 0;accent-color:var(--aus-bright-cyan);flex:0 0 auto}
|
||||||
.ask-opt-main{display:flex;flex-direction:column;gap:.1rem;min-width:0}
|
.mark-opt-main{display:flex;flex-direction:column;gap:.1rem;min-width:0}
|
||||||
.ask-opt-label{font-size:.92rem;color:var(--fg-0)}
|
.mark-opt-label{font-size:.92rem;color:var(--fg-0)}
|
||||||
.ask-opt-detail{font-size:.76rem;color:var(--fg-3);white-space:pre-wrap}
|
.mark-opt-detail{font-size:.76rem;color:var(--fg-3);white-space:pre-wrap}
|
||||||
.ask-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;
|
.mark-notes{display:block;width:100%;box-sizing:border-box;margin:.6rem 0 0;padding:.5rem .6rem;
|
||||||
font:inherit;font-size:.88rem;color:var(--fg-0);background:var(--rk-well);
|
font:inherit;font-size:.88rem;color:var(--fg-0);background:var(--rk-well);
|
||||||
border:1px solid var(--border-subtle);border-radius:var(--radius-md);resize:vertical}
|
border:1px solid var(--border-subtle);border-radius:var(--radius-md);resize:vertical}
|
||||||
.ask-notes:focus{outline:none;border-color:var(--aus-bright-cyan);box-shadow:var(--glow-cyan)}
|
.mark-notes:focus{outline:none;border-color:var(--aus-bright-cyan);box-shadow:var(--glow-cyan)}
|
||||||
.ask-actions{display:flex;justify-content:flex-end;margin-top:.6rem}
|
.mark-actions{display:flex;justify-content:flex-end;margin-top:.6rem}
|
||||||
.ask-submit{cursor:pointer;font-family:var(--font-mono);font-size:.74rem;letter-spacing:.06em;
|
.mark-submit{cursor:pointer;font-family:var(--font-mono);font-size:.74rem;letter-spacing:.06em;
|
||||||
padding:.42rem .9rem;border-radius:var(--radius-sm);border:1px solid var(--aus-bright-cyan);
|
padding:.42rem .9rem;border-radius:var(--radius-sm);border:1px solid var(--aus-bright-cyan);
|
||||||
background:var(--aus-bright-cyan);color:var(--fg-on-accent);font-weight:700;transition:.14s var(--ease-out)}
|
background:var(--aus-bright-cyan);color:var(--fg-on-accent);font-weight:700;transition:.14s var(--ease-out)}
|
||||||
.ask-submit:hover{background:var(--aus-cyan);border-color:var(--aus-cyan)}
|
.mark-submit:hover{background:var(--aus-cyan);border-color:var(--aus-cyan)}
|
||||||
|
|
||||||
/* booth page */
|
/* booth page */
|
||||||
.boothhead{display:flex;align-items:center;gap:1rem;flex-wrap:wrap;
|
.boothhead{display:flex;align-items:center;gap:1rem;flex-wrap:wrap;
|
||||||
|
|||||||
@@ -13,12 +13,50 @@
|
|||||||
</form>
|
</form>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{# The per-item MARK controls: flag (the operator pointing at this one) and a
|
||||||
|
note field. Same macro discipline as blurtoggle above — three item branches,
|
||||||
|
one definition. `marks` here is THIS item's marks, from item_marks. #}
|
||||||
|
{% macro markcontrols(name_url, it, marks, cls='') -%}
|
||||||
|
{% set flagged = marks | selectattr('shape', 'equalto', 'flag') | list | length > 0 %}
|
||||||
|
<form class="flagtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/flag">
|
||||||
|
<input type="hidden" name="target" value="{{ it.name }}">
|
||||||
|
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
|
||||||
|
<button title="{{ 'un-flag this item' if flagged else 'flag this one — the session that posted it can read the selection' }}"
|
||||||
|
aria-label="{{ 'un-flag' if flagged else 'flag' }} {{ it.name }}"
|
||||||
|
>{{ '✔ flagged' if flagged else '○ flag' }}</button>
|
||||||
|
</form>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{# An item's notes, rendered BESIDE the artifact — the 2026-09-09 ruling that a
|
||||||
|
judgment belongs with the thing it is about, applied to notes as well as
|
||||||
|
picks. The add-field is a <details> so 270 tiles do not each carry an open
|
||||||
|
textarea. #}
|
||||||
|
{% macro marknotes(name_url, it, marks) -%}
|
||||||
|
{% for m in marks if m.shape == 'note' %}
|
||||||
|
<div class="item-note" id="mark-{{ m.id }}">
|
||||||
|
<pre>{{ m.text }}</pre>
|
||||||
|
<form method="post" action="/b/{{ name_url }}/unmark">
|
||||||
|
<input type="hidden" name="mark" value="{{ m.id }}">
|
||||||
|
<button class="mark-x" title="withdraw this note">×</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<details class="item-addnote">
|
||||||
|
<summary>+ note</summary>
|
||||||
|
<form method="post" action="/b/{{ name_url }}/note">
|
||||||
|
<input type="hidden" name="target" value="{{ it.name }}">
|
||||||
|
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
{% block title %}{{ name }} · The Booth{% endblock %}
|
{% block title %}{{ name }} · The Booth{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="boothhead">
|
<div class="boothhead">
|
||||||
<a class="back" href="/">‹ all booths</a>
|
<a class="back" href="/">‹ all booths</a>
|
||||||
<h1>{{ name }}</h1>
|
<h1>{{ name }}</h1>
|
||||||
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% else %}{% set open_asks = asks|selectattr('answer', 'none')|rejectattr('error')|list|length %}{% if open_asks %}<span class="badge badge-ask">{{ open_asks }} open ask{{ '' if open_asks == 1 else 's' }}</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
|
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
|
||||||
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
|
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
|
||||||
{# A durable multi-writer board gets no one-click wipe — same rule as the
|
{# A durable multi-writer board gets no one-click wipe — same rule as the
|
||||||
kept lane on the index. Remove rows with the per-row ×, or release the
|
kept lane on the index. Remove rows with the per-row ×, or release the
|
||||||
@@ -52,8 +90,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if asks %}
|
{# The marks panel: the session's questions, the operator's notes, and the way
|
||||||
{% include "_asks.html" %}
|
back to the flagged items. Always rendered on a gallery booth — the add-note
|
||||||
|
field is a control, not a result, so it has to be there before the first
|
||||||
|
mark exists. #}
|
||||||
|
{% if not board %}
|
||||||
|
{% include "_marks.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if board %}
|
{% if board %}
|
||||||
@@ -108,7 +150,7 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not items and not board and not asks %}
|
{% if not items and not board and not marks %}
|
||||||
<div class="empty">This booth is empty.</div>
|
<div class="empty">This booth is empty.</div>
|
||||||
{% elif items %}
|
{% elif items %}
|
||||||
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its
|
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its
|
||||||
@@ -121,7 +163,7 @@
|
|||||||
separate page. <details open> is native collapse (works with JS off);
|
separate page. <details open> is native collapse (works with JS off);
|
||||||
the ✕ hides the item for the session (JS, progressive enhancement).
|
the ✕ hides the item for the session (JS, progressive enhancement).
|
||||||
The item spans the full grid width so prose has room to read. #}
|
The item spans the full grid width so prose has room to read. #}
|
||||||
<figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}">
|
<figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}" id="item-{{ it.name }}">
|
||||||
{% if it.blurred %}
|
{% if it.blurred %}
|
||||||
{# Inline docs need this MORE than images, not less: a rendered doc puts
|
{# Inline docs need this MORE than images, not less: a rendered doc puts
|
||||||
its text straight on the page, so "blur the picture" logic that skips
|
its text straight on the page, so "blur the picture" logic that skips
|
||||||
@@ -137,6 +179,7 @@
|
|||||||
<a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a>
|
<a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a>
|
||||||
<a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
|
<a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
|
||||||
{{ blurtoggle(name_url, it, 'doc-act') }}
|
{{ blurtoggle(name_url, it, 'doc-act') }}
|
||||||
|
{{ markcontrols(name_url, it, item_marks.get(it.name, []), 'doc-act') }}
|
||||||
<button type="button" class="doc-act doc-close" title="close (hide for now)" aria-label="close">✕</button>
|
<button type="button" class="doc-act doc-close" title="close (hide for now)" aria-label="close">✕</button>
|
||||||
</summary>
|
</summary>
|
||||||
{% if it.rendered_html %}
|
{% if it.rendered_html %}
|
||||||
@@ -147,7 +190,7 @@
|
|||||||
</details>
|
</details>
|
||||||
</figure>
|
</figure>
|
||||||
{% else %}
|
{% else %}
|
||||||
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}" data-item="{{ it.name }}">
|
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if item_marks.get(it.name, []) | selectattr('shape', 'equalto', 'flag') | list %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.name }}">
|
||||||
{% if it.blurred %}
|
{% if it.blurred %}
|
||||||
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
|
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
|
||||||
a reload re-hides it. No-JS degrades to STAYS BLURRED, which is the
|
a reload re-hides it. No-JS degrades to STAYS BLURRED, which is the
|
||||||
@@ -175,13 +218,17 @@
|
|||||||
<figcaption>
|
<figcaption>
|
||||||
{% if it.caption %}<span class="cap-text">{{ it.caption }}</span>{% endif %}
|
{% if it.caption %}<span class="cap-text">{{ it.caption }}</span>{% endif %}
|
||||||
{{ blurtoggle(name_url, it) }}
|
{{ blurtoggle(name_url, it) }}
|
||||||
|
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
|
||||||
</figcaption>
|
</figcaption>
|
||||||
|
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<figcaption>
|
<figcaption>
|
||||||
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
|
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
|
||||||
<span class="cap-text">{{ it.caption or it.name }}</span>
|
<span class="cap-text">{{ it.caption or it.name }}</span>
|
||||||
{{ blurtoggle(name_url, it) }}
|
{{ blurtoggle(name_url, it) }}
|
||||||
|
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
|
||||||
</figcaption>
|
</figcaption>
|
||||||
|
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</figure>
|
</figure>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
{# Same record, same reason as the image viewer: the sidecar that says what
|
{# Same record, same reason as the image viewer: the sidecar that says what
|
||||||
this doc IS travels with it to full-page view. #}
|
this doc IS travels with it to full-page view. #}
|
||||||
{% if caption %}<div class="doccap">{{ caption }}</div>{% endif %}
|
{% if caption %}<div class="doccap">{{ caption }}</div>{% endif %}
|
||||||
|
{% if marks %}
|
||||||
|
<div class="docmarks">
|
||||||
|
{% for m in marks if m.shape == 'note' %}<pre class="vnote">{{ m.text }}</pre>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if is_html %}
|
{% if is_html %}
|
||||||
<article class="markdown-body">{{ body|safe }}</article>
|
<article class="markdown-body">{{ body|safe }}</article>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@
|
|||||||
<div class="ph">◆ files</div>
|
<div class="ph">◆ files</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
|
{% if b.uploaded %}<span class="badge">⬆ pickup</span>{% endif %}
|
||||||
{% if b.asks_open %}<span class="badge badge-ask">? {{ b.asks_open }} ask{{ '' if b.asks_open == 1 else 's' }}</span>{% endif %}
|
{% if b.marks_open %}<span class="badge badge-mark">? {{ b.marks_open }} open</span>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ name }} · marks · The Booth{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
{# The marks page for a booth whose own index.html is served VERBATIM. That page
|
||||||
|
cannot render the panel inline (it is returned untouched by design), so the
|
||||||
|
injected chip links here instead. Same forms, same POST targets — only the
|
||||||
|
redirect differs, so answering lands back here rather than on the report. #}
|
||||||
|
<div class="boothhead">
|
||||||
|
<a class="back" href="/b/{{ name_url }}/">‹ {{ name }}</a>
|
||||||
|
<h1>Marks</h1>
|
||||||
|
{# `marks_open` comes from open_marks() — the ONE openness predicate (INV-2).
|
||||||
|
This used to re-derive it in Jinja as `selectattr('answer', 'none')`, which
|
||||||
|
read a half-answered pick as closed. #}
|
||||||
|
<span class="sub">{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ marks|length }} mark{{ '' if marks|length == 1 else 's' }}</span>
|
||||||
|
</div>
|
||||||
|
{% if marks %}
|
||||||
|
{% include "_marks.html" %}
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">This booth has no marks.</div>
|
||||||
|
{% include "_marks.html" %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -19,6 +19,31 @@
|
|||||||
A caption is most useful at the size where you are actually judging the
|
A caption is most useful at the size where you are actually judging the
|
||||||
thing, so it belongs here at least as much as in the grid. #}
|
thing, so it belongs here at least as much as in the grid. #}
|
||||||
{% if caption %}<div class="vcap">{{ caption }}</div>{% endif %}
|
{% if caption %}<div class="vcap">{{ caption }}</div>{% endif %}
|
||||||
|
{# INV-3: the JUDGMENT travels to full size too, not just the caption. This is
|
||||||
|
the size at which the operator is actually deciding, so the flag toggle and
|
||||||
|
the notes belong here at least as much as on the tile. #}
|
||||||
|
<div class="vmarks">
|
||||||
|
<form class="vflag" method="post" action="/b/{{ name_url }}/flag">
|
||||||
|
<input type="hidden" name="target" value="{{ file }}">
|
||||||
|
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
|
||||||
|
<button class="vbtn{% if flagged %} is-flagged{% endif %}"
|
||||||
|
title="{{ 'un-flag this item' if flagged else 'flag this one' }}"
|
||||||
|
>{{ '✔ flagged' if flagged else '○ flag' }}</button>
|
||||||
|
</form>
|
||||||
|
{% for m in marks if m.shape == 'note' %}
|
||||||
|
<div class="vnote"><pre>{{ m.text }}</pre>
|
||||||
|
<form method="post" action="/b/{{ name_url }}/unmark">
|
||||||
|
<input type="hidden" name="mark" value="{{ m.id }}">
|
||||||
|
<button class="mark-x" title="withdraw this note">×</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note">
|
||||||
|
<input type="hidden" name="target" value="{{ file }}">
|
||||||
|
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
|
||||||
|
<button type="submit">Add note</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
<style>
|
||||||
.vnav{position:fixed;top:50%;transform:translateY(-50%);z-index:40;display:flex;
|
.vnav{position:fixed;top:50%;transform:translateY(-50%);z-index:40;display:flex;
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
---
|
||||||
|
contract_version: "1.0"
|
||||||
|
module: "booth.marks"
|
||||||
|
purpose: "ONE primitive for operator judgment attached to an artifact, replacing three tacked-on mechanisms. A MARK has a target (the booth, or one item in it) and a shape -- `pick` (one of N options the session declared in advance; was: an ask), `note` (free text the operator volunteered; was: a comment), `flag` (this one / not this one; was: a vote). All three are the same thing -- the operator judging something and the session reading the judgment -- and today they are three storage models, three read paths and, for `flag`, no code at all: the operator picks winners from a 270-image set and tells the session IN CHAT. One storage model (`.marks.json` per booth), one read path (`marks_for`), one place `is anything still open?` is computed (`open_marks`), one rendering slot (beside the artifact)."
|
||||||
|
depends_on:
|
||||||
|
- "booth.asks (normalize_ask, build_answer, AskError, is_ask_file, is_answer_file -- the pick DECLARATION validator and the ANSWER builder. Their semantics are operator-settled 2026-09-09 and are preserved by NOT rewriting them; this unit extracts the pure answer-building out of write_answer's I/O and otherwise leaves the validator alone.)"
|
||||||
|
- "booth.items (Item.rel -- a mark's target for an item-scoped mark IS the rel U1 established as item identity; no second addressing scheme)"
|
||||||
|
- "booth.inline (place, form_id -- the verbatim-booth placement engine. `place` indexes its input by SUBSCRIPT (`{a[\"stem\"]: a}`), which is why it is in `touches`: a frozen dataclass raises TypeError there. See the seam-review note SR-1.)"
|
||||||
|
language: "python"
|
||||||
|
complexity: "high"
|
||||||
|
estimated_loc: 620
|
||||||
|
confidence: 0.75
|
||||||
|
used_by:
|
||||||
|
- "booth.app.list_booths (the index card's open-mark count -- was `asks_open`, one read of one file instead of a per-booth walk of ask sidecars)"
|
||||||
|
- "booth.app.booth_view (the marks panel + per-item marks on the gallery)"
|
||||||
|
- "booth.app.booth_view_file (the zoom view's marks slot -- an item's notes and flag state at the size where the judgment is actually made)"
|
||||||
|
- "booth.app.booth_answer -> booth_mark_pick (the operator's pick submit)"
|
||||||
|
- "booth.app.booth_mark_note / booth_mark_flag (NEW routes -- note and flag have no write path today)"
|
||||||
|
- "booth.app.inject_asks (verbatim-booth injection: reads marks instead of list_asks; DELETED at U3, not here)"
|
||||||
|
- "scripts/booth (`marks` verb + `ask` / `asks` / `answer` as thin aliases over it; `marks import` for the legacy sidecars)"
|
||||||
|
- "booth.app.list_booths open-mark count is ALSO U4's pin input -- U4 reads open_marks, it does not re-derive openness"
|
||||||
|
touches:
|
||||||
|
- "booth/marks.py (new -- the record, the storage, the read path)"
|
||||||
|
- "booth/asks.py (write_answer split: the pure `build_answer(ask, choice, notes, who, qnotes) -> dict` keeps every line of the 2026-09-09 semantics, the os.replace half goes away with the sidecar. `write_ask` / `load_ask` / `read_answer` / `list_asks` DELETED -- sidecar storage, replaced. `normalize_ask` and its helpers UNTOUCHED.)"
|
||||||
|
- "booth/inline.py (ONE line: `place`'s `by_stem = {a[\"stem\"]: a for a in asks}` becomes `{m.id: m for m in marks}`. Missed in the first draft of this contract and caught by the seam review -- `place` is the only consumer that indexes an ask by subscript rather than attribute, so it is the only one a dataclass breaks. The module is otherwise untouched and is DELETED at U3.)"
|
||||||
|
- "booth/app.py (imports; list_booths open-mark count; booth_view passes marks; booth_view_file gains the marks slot; booth_answer becomes the pick route over marks; TWO new routes for note and flag; inject_asks reads marks)"
|
||||||
|
- "booth/templates/_marks.html (new -- replaces _asks.html; the panel, all three shapes)"
|
||||||
|
- "booth/templates/_ask_inline.html (the injected pick fragment: reads a Mark, not an ask dict. Kept because U3 deletes it, not this unit.)"
|
||||||
|
- "booth/templates/booth.html (the panel include; per-item flag + note controls on each tile)"
|
||||||
|
- "booth/templates/view.html (the zoom view's marks slot -- notes and flag state for THIS item)"
|
||||||
|
- "booth/templates/index.html (the open-mark badge: `marks_open` replaces `asks_open`)"
|
||||||
|
- "booth/templates/asks.html -> marks.html (the standalone page; DELETED at U3, renamed here so nothing dangles)"
|
||||||
|
- "booth/templates/base.html (the .mark-* CSS; the .ask-* block is renamed, not extended)"
|
||||||
|
- "scripts/booth (the `marks` verb, the three aliases, `marks import`; usage line; the header doc block)"
|
||||||
|
- "tests/test_marks.py (new)"
|
||||||
|
- "tests/test_asks.py (the sidecar-storage tests retarget to marks storage; the NORMALIZER and ANSWER-BUILDER tests keep asserting the same semantics against the same functions)"
|
||||||
|
- "tests/test_booth.py (asks_open -> marks_open on the index; the gallery-hides-sidecars assertions)"
|
||||||
|
assumptions:
|
||||||
|
- "ONE FILE HOLDS BOTH THE DECLARATION AND THE JUDGMENT, and this is a derived consequence of the operator's 2026-09-21 storage decision rather than a fresh choice. A pick's OPTIONS are declared by the session; the CHOICE is made by the operator. If the declaration lived in its own file, `is anything open?` would again be a directory walk -- the exact cost the per-booth decision was made to avoid, since U4 asks it per booth per sweep tick and the index asks it per card per page load. So a declared pick with no answer IS the open mark, and one read of `.marks.json` answers the question for the whole booth."
|
||||||
|
- "THE 2026-09-09 PICK SEMANTICS ARE PRESERVED BY NOT REWRITING THEM. Partial answers legal; a blank question lands in `unanswered` and is absent from `answers` unless it carried a note; `complete` is false until every question has a pick; the ONLY refusal is a submission with no choice anywhere AND no notes; an offered-but-invalid option is still an error (a broken form, not a skipped question); single and multi shapes both normalize to a `questions` list. This unit MOVES that code; it does not improve it. Any behaviour change inside the answer-shaping logic is a separate unit."
|
||||||
|
- "THE `build_answer` EXTRACTION IS THREE EDITS, NOT ONE -- corrected by the seam review (SR-3), which is the whole reason that gate exists. The first draft said `write_answer` minus its last four lines. It is also minus its FIRST line (`ask = load_ask(booth, stem)`), plus an `ask: dict` parameter in place of `(booth, stem)`, and `stem` sourced from `ask[\"stem\"]` -- which `normalize_ask` does emit, so no new plumbing. Everything between those edits is byte-identical. The consequence that is easy to miss: `load_ask` was the thing that raised `AskError` for a missing or invalid ask, so THAT ERROR PATH MOVES TO THE CALLER. `answer_pick` must raise `AskError` when `mark_id` names no live pick, or a stale form POST becomes a silent no-op instead of a 400."
|
||||||
|
- "MARKS ARE SINGLE-WRITER-ROLE, NOT SINGLE-PROCESS. The operator's browser writes judgments; a session writes pick declarations. That is two roles on one file, so the fcntl read-modify-write lock is load-bearing and not ceremony -- but it is NOT `links.md`'s problem. links.md is an O_APPEND content-hash log because 17 handles write it concurrently and a lock on the common path would serialize them; a booth's marks see one session and one operator, so locking the common path costs nothing. Reuse the flock pattern from `links.remove_link_entry`; do NOT reuse the append-log shape."
|
||||||
|
- "STDLIB ONLY. `booth/marks.py` is imported by `scripts/booth` under the system python3 with no venv, exactly like `links.py` and `asks.py`. json, os, fcntl, re, dataclasses, datetime, pathlib. This is the invariant graphify cannot see (the CLI imports through a `python3 -c` heredoc, invisible to AST extraction) and therefore the one most likely to be broken by a later change that looks safe."
|
||||||
|
- "A FLAG IS AN UPSERT KEYED BY TARGET; A NOTE IS NOT. One item has at most one flag state, so setting a flag replaces it and clearing it removes the mark rather than storing `false` (an absent flag and a false flag are the same judgment, and storing both makes two representations of one state). An item may carry several notes, so each gets a generated id. A pick's id is the session-supplied stem, validated by the existing `valid_stem` -- which keeps today's `#ask-<stem>` anchors, the inline placeholder specs and the CLI's argument shape working unchanged."
|
||||||
|
- "THE LEGACY SIDECARS ARE IMPORTED, NEVER DELETED. Four `*.ask.json` files are live and unanswered right now (dfa-concepts, sc-iso-spread, sindra-voice-1, run07-decisions; zero `*.answer.json`). `marks import <booth>` is an explicit, idempotent one-shot that reads them into `.marks.json` and LEAVES THEM ON DISK -- per the ROADMAP's `a migration that deletes anything` non-goal. The read path does NOT know about legacy files: a read that writes would fire on every index page load, which is the wrong trade for four files. `booth_items` keeps excluding `*.ask.json` / `*.answer.json` from the item list so an imported-but-not-deleted sidecar does not appear as a tile."
|
||||||
|
- "NOTE AND FLAG SHIP WITH CONTROLS, NOT WITH KEYBOARD. U2 gives every item a flag toggle and a note field, and the booth itself a note field. The grid keyboard (`f` flags, `n` opens a note, arrows move, Enter zooms) is U7's navigation work and is NOT in this unit -- U2 makes the judgment writable, U7 makes it fast at 270 items. Stated because `flag` is the capability that makes a 270-image booth tractable and it is tempting to pull U7's keyboard forward with it."
|
||||||
|
- "U3's DELETIONS DO NOT HAPPEN HERE. `inline.py`, `wrap_verbatim_html` and its six regexes, both floating chips and the standalone page all survive this unit, reading marks instead of asks. U2 changes the primitive underneath them; U3 deletes the mechanism. Doing both at once would mean a rewrite whose failures cannot be attributed to either change."
|
||||||
|
open_questions:
|
||||||
|
- "Whether a `note` on the booth (target None) should also render on the index card, or only inside the booth. Deferred to U5, which is the unit that redesigns the index card and can weigh it against `.booth.json` provenance."
|
||||||
|
- "Whether `flag` needs a negative state (`not this one`) distinct from absent. The IA names the shape `this one / not this one`, but every live use is positive selection (golden-candidates, sindra-finalists, the pancake ladders). Shipping positive-only, with the storage shape able to carry a value later; revisit if the operator asks for a reject pass."
|
||||||
|
---
|
||||||
|
|
||||||
|
# U2 — marks
|
||||||
|
|
||||||
|
## The defect, stated precisely
|
||||||
|
|
||||||
|
Five mechanisms exist to get one question next to one artifact. Three of them
|
||||||
|
are the same primitive wearing different clothes, and the third of the three
|
||||||
|
does not exist in code at all:
|
||||||
|
|
||||||
|
| job | today | storage | read path |
|
||||||
|
|---|---|---|---|
|
||||||
|
| the session asks the operator | asks | `<stem>.ask.json` + `<stem>.answer.json`, two files per question | `list_asks(booth)` walks the booth, `load_ask` + `read_answer` per ask |
|
||||||
|
| the operator tells the session | — | *nothing* | *nothing* |
|
||||||
|
| the operator points at the good ones | — | *nothing* | **a chat message** |
|
||||||
|
|
||||||
|
The third row is the expensive one. `golden-candidates`, `sindra-finalists` and
|
||||||
|
the `pancake-*` ladders are all the operator selecting winners from a set and
|
||||||
|
then telling the session in conversation — and `sindra-finalists` is 86 items,
|
||||||
|
every one captioned, with the selection encoded in *the booth's name*. The
|
||||||
|
session that posted the set cannot read the judgment it asked for.
|
||||||
|
|
||||||
|
`asks_open` on the index card is the same defect from the other end: computing
|
||||||
|
"does this booth owe an answer?" means walking every booth and parsing two JSON
|
||||||
|
files per ask, on every index page load. U4 makes that question load-bearing —
|
||||||
|
an open mark pins its booth — so it has to be one read.
|
||||||
|
|
||||||
|
## The record
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Mark:
|
||||||
|
id: str # pick: the session-supplied stem. note: generated. flag: derived from target.
|
||||||
|
shape: str # "pick" | "note" | "flag"
|
||||||
|
target: str | None # an Item.rel, or None for the booth itself
|
||||||
|
created: str # ISO8601 local, seconds
|
||||||
|
# --- pick: the session's declaration, normalized on READ (as load_ask does today) ---
|
||||||
|
declaration: dict | None # the raw posted document; None for note/flag
|
||||||
|
prompt: str | None # normalized: the prompt, or the multi-form title
|
||||||
|
title: str
|
||||||
|
multi: bool
|
||||||
|
questions: list[dict] # normalized; a single-question pick is a 1-list with key None
|
||||||
|
options: list[dict] # single-question picks only, as normalize_ask emits
|
||||||
|
notes_enabled: bool # <- normalize_ask emits this as `notes` (a bool). SR-2.
|
||||||
|
notes_label: str
|
||||||
|
# --- the operator's judgment ---
|
||||||
|
answer: dict | None # pick: build_answer's output. None while OPEN.
|
||||||
|
text: str # note: the body. "" otherwise.
|
||||||
|
flagged: bool # flag: always True (see open_questions). False otherwise.
|
||||||
|
by: str # who recorded it (request client host), "" for a declaration
|
||||||
|
error: str | None # a malformed pick declaration, SURFACED not hidden
|
||||||
|
```
|
||||||
|
|
||||||
|
`error` is not defensive decoration: today a broken `*.ask.json` is returned by
|
||||||
|
`list_asks` with `error` set precisely so the page can say so, rather than
|
||||||
|
silently hiding a question the session believes it posted. Preserved.
|
||||||
|
|
||||||
|
## The stored document
|
||||||
|
|
||||||
|
`<booth>/.marks.json` — a dotfile, so `booth_items`' existing
|
||||||
|
`name.startswith(".")` skip keeps it out of item counts, galleries and zips
|
||||||
|
with no new exclusion rule.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"marks": [
|
||||||
|
{"id": "decisions", "shape": "pick", "target": null,
|
||||||
|
"created": "2026-09-21T14:02:11-07:00",
|
||||||
|
"declaration": {"prompt": "Which run ships?", "options": ["run07", "run08"]},
|
||||||
|
"answer": null},
|
||||||
|
{"id": "n-4f3a91", "shape": "note", "target": "v3/DSC03389.jpg",
|
||||||
|
"created": "...", "text": "banding in the gradient", "by": "10.100.10.20"},
|
||||||
|
{"id": "flag:DSC03403.jpg", "shape": "flag", "target": "DSC03403.jpg",
|
||||||
|
"created": "...", "by": "10.100.10.20"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The declaration is stored **raw** and normalized at read time — exactly what
|
||||||
|
`write_ask` + `load_ask` do today, and the reason a broken declaration surfaces
|
||||||
|
as `error` at render rather than being unrepresentable on disk.
|
||||||
|
|
||||||
|
## Signatures
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ---- read -------------------------------------------------------------------
|
||||||
|
def marks_for(booth: Path) -> list[Mark]:
|
||||||
|
"""Every mark in a booth, oldest first by `created`, declarations normalized
|
||||||
|
and answers folded in. ONE file read. A missing or unparseable `.marks.json`
|
||||||
|
returns [] -- a booth with no marks and a booth whose mark file is corrupt
|
||||||
|
both render as "no marks", and neither is a 500."""
|
||||||
|
|
||||||
|
def open_marks(marks: Sequence[Mark]) -> list[Mark]:
|
||||||
|
"""The marks still owed an answer: shape == "pick" and (answer is None or
|
||||||
|
not answer["complete"]). THE one place openness is computed -- the index
|
||||||
|
badge, the booth header, the panel filter and U4's pin rule all call this
|
||||||
|
rather than re-deriving the predicate."""
|
||||||
|
|
||||||
|
def marks_for_target(marks: Sequence[Mark], rel: str | None) -> list[Mark]:
|
||||||
|
"""The marks attached to one item (or to the booth, for None). The gallery
|
||||||
|
tile, the zoom view and the injected fragment all slot by this."""
|
||||||
|
|
||||||
|
def as_dict(mark: Mark) -> dict:
|
||||||
|
"""The JSON boundary -- `booth marks` output and nothing else (SR-8). Python
|
||||||
|
consumers take the dataclass; Jinja takes the dataclass too, since every
|
||||||
|
template accesses marks by attribute. This exists so the CLI has one
|
||||||
|
serialization and not a hand-rolled dict per verb."""
|
||||||
|
|
||||||
|
# ---- write (each takes the lock, rewrites atomically, returns the new Mark) --
|
||||||
|
def declare_pick(booth: Path, stem: str, doc: dict) -> Mark:
|
||||||
|
"""A session poses a pick. Validated through `normalize_ask` BEFORE the
|
||||||
|
write, so a session cannot land a question the renderer would refuse.
|
||||||
|
Re-declaring the same stem replaces the declaration and CLEARS its answer
|
||||||
|
-- the question changed, so the old judgment is not an answer to it."""
|
||||||
|
|
||||||
|
def answer_pick(booth: Path, mark_id: str, choice, notes: str = "",
|
||||||
|
who: str = "", qnotes: dict | None = None) -> Mark:
|
||||||
|
"""Record the operator's pick. Delegates every semantic to
|
||||||
|
`asks.build_answer`; this function owns storage and nothing else.
|
||||||
|
Re-answering overwrites -- the mark is the CURRENT judgment, not a log."""
|
||||||
|
|
||||||
|
def write_note(booth: Path, target: str | None, text: str, who: str = "") -> Mark:
|
||||||
|
"""Attach free text to an item, or to the booth. Empty text after cleaning
|
||||||
|
is refused (nothing to record), same posture as an empty pick submission."""
|
||||||
|
|
||||||
|
def set_flag(booth: Path, target: str, on: bool, who: str = "") -> Mark | None:
|
||||||
|
"""Flag or unflag one item. Upsert keyed by target: flagging twice is
|
||||||
|
idempotent, unflagging REMOVES the mark and returns None (an absent flag and
|
||||||
|
a false flag are the same judgment; two representations of one state is how
|
||||||
|
`.forever` became a problem)."""
|
||||||
|
|
||||||
|
def delete_mark(booth: Path, mark_id: str) -> bool:
|
||||||
|
"""Remove one mark by id. True if it was there. The operator's undo."""
|
||||||
|
|
||||||
|
# ---- migration --------------------------------------------------------------
|
||||||
|
def import_legacy_asks(booth: Path) -> list[Mark]:
|
||||||
|
"""Read every `*.ask.json` / `*.answer.json` into `.marks.json` as picks and
|
||||||
|
their answers. Idempotent: a stem already present as a mark is skipped, so
|
||||||
|
running it twice is a no-op and never clobbers a newer judgment. LEAVES THE
|
||||||
|
SIDECARS ON DISK -- nothing here deletes the operator's data."""
|
||||||
|
```
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- **INV-1 — one storage model.** No module reads or writes a `*.ask.json` /
|
||||||
|
`*.answer.json` except `import_legacy_asks`. *Falsifiable:* `ASK_SUFFIX` and
|
||||||
|
`ANSWER_SUFFIX` appear in `marks.py` only inside the importer; in `app.py`
|
||||||
|
only as the re-export; and in `items.py` only as the tile exclusion, which
|
||||||
|
imports `is_ask_file` from `booth.asks` **directly** rather than through
|
||||||
|
`app` (SR-9 — the first draft's wording said "nowhere in `app.py`" and would
|
||||||
|
have read `items.py` as a violation).
|
||||||
|
- **INV-2 — one place openness is computed.** The predicate
|
||||||
|
`shape == "pick" and not answered-completely` exists exactly once, in
|
||||||
|
`open_marks`. *Falsifiable:* `grep -c "selectattr('answer'" booth/templates/`
|
||||||
|
is 0 and `grep -c 'answer.*is None' booth/app.py` is 0. Today the predicate
|
||||||
|
exists in **five** places, not two (SR-6): two in Jinja — `booth.html:21`,
|
||||||
|
`asks.html:11` — and three in Python — `app.py:271` (the index badge),
|
||||||
|
`app.py:750` and `app.py:751` (the verbatim-booth chip).
|
||||||
|
- **INV-3 — the judgment travels, like the caption.** U1's rule, extended:
|
||||||
|
every surface that renders an item renders that item's marks. Gallery tile,
|
||||||
|
zoom view, doc view. *Falsifiable:* fetch `/b/<n>/view?f=<img>` for a flagged
|
||||||
|
item carrying a note and assert both the flag state and the note text are in
|
||||||
|
the served HTML.
|
||||||
|
- **INV-4 — the pick semantics are byte-identical.** `build_answer` produces,
|
||||||
|
for every input, the document `write_answer` produced. *Falsifiable:* the
|
||||||
|
existing `test_asks.py` answer assertions pass against `build_answer` with
|
||||||
|
their expectations unchanged.
|
||||||
|
- **INV-5 — stdlib only.** `booth/marks.py` imports nothing outside the standard
|
||||||
|
library. *Falsifiable:* a test that walks `marks.py`'s AST imports and asserts
|
||||||
|
every root module is in `sys.stdlib_module_names` — the same guard is added
|
||||||
|
for `asks.py` and `links.py`, since the constraint has been convention-only
|
||||||
|
until now and graphify structurally cannot see the CLI consumer that depends
|
||||||
|
on it.
|
||||||
|
- **INV-6 — the lock is held for the whole read-modify-write.** Every writer
|
||||||
|
goes through one `_with_marks(booth)` helper holding an exclusive flock on
|
||||||
|
`.marks.lock` across read, mutate and atomic replace. *Falsifiable:* no write
|
||||||
|
function calls `_read_unlocked` or `os.replace` directly.
|
||||||
|
- **INV-7 — nothing deletes a legacy sidecar.** *Falsifiable:* after
|
||||||
|
`import_legacy_asks`, the `*.ask.json` file still exists.
|
||||||
|
|
||||||
|
## Seam review — 2026-09-21, caller-side, against the real module surfaces
|
||||||
|
|
||||||
|
`/heid-contract-review` is artifact-only by design: the arms read this file and
|
||||||
|
are forbidden the `depends_on` siblings, so the cold pass structurally cannot
|
||||||
|
check a single cross-module seam. This is that check — every symbol, field and
|
||||||
|
comparand this contract borrows from `booth.asks`, `booth.items` and
|
||||||
|
`booth.inline`, read against the actual `.py` rather than against prose.
|
||||||
|
|
||||||
|
Nine findings. Two changed the contract's scope or its stated behaviour; the
|
||||||
|
rest are field-level corrections that would each have been a mid-implementation
|
||||||
|
`KeyError` or `TypeError`.
|
||||||
|
|
||||||
|
| id | finding | disposition |
|
||||||
|
|---|---|---|
|
||||||
|
| **SR-1** | `inline.place` indexes its input with `by_stem = {a["stem"]: a for a in asks}` — a **subscript**. Every other consumer, templates included, uses attribute access. A frozen dataclass raises `TypeError` there. | **Scope miss.** `booth/inline.py` added to `touches` and `depends_on`. One line. |
|
||||||
|
| **SR-2** | `normalize_ask` emits `notes` (bool) and `stem`; this contract's record calls them `notes_enabled` and `id`. | Mapping stated inline in the record. |
|
||||||
|
| **SR-3** | "`build_answer` is `write_answer` minus the last four lines" is **false** — it also loses its first line, gains an `ask` parameter, and sources `stem` from `ask["stem"]`. | Assumption rewritten; see the `build_answer` assumption. |
|
||||||
|
| **SR-4** | `load_ask` was what raised `AskError` for a missing/invalid ask. Extracting it moves that error path **to the caller**. | `answer_pick` must raise `AskError` for an unknown `mark_id`; a test asserts it. |
|
||||||
|
| **SR-5** | `list_asks` orders by **file mtime**; `marks_for` orders by the stored `created` string. For the four live sidecars those are different orderings. | `import_legacy_asks` seeds `created` from the sidecar's mtime. Test added. |
|
||||||
|
| **SR-6** | Openness is re-derived in **five** places, not the two this contract claimed: 2 Jinja + 3 Python (`app.py:271`, `:750`, `:751`). | INV-2's falsifiable now covers `app.py` as well as templates. |
|
||||||
|
| **SR-7** | The three Python re-derivations test `answer is None`, so **a partially-answered pick counts as closed** — while `_asks.html` renders that same pick as `◐ partial`. The index badge and the panel disagree about the same booth today. | **Declared behaviour change**, below. Not smuggled in. |
|
||||||
|
| **SR-8** | The CLI's `asks` verb reads `a["stem"]`, `a["answer"]["label"]`, `a["answer"]["answers"]` — dict subscripts — and `booth marks` must emit JSON. | `marks.as_dict(mark)` (over `dataclasses.asdict`) is the CLI/JSON boundary; the dataclass is the Python boundary. |
|
||||||
|
| **SR-9** | `items.py` imports `is_ask_file` from `booth.asks` **directly**, not via `app`. | INV-1's falsifiable reworded to name it. |
|
||||||
|
|
||||||
|
**Good news worth recording so nobody defensively rewrites it:** every Jinja
|
||||||
|
template accesses asks by attribute (`a.stem`, `a.answer.complete`) and not one
|
||||||
|
uses subscript syntax — verified by grep across `booth/templates/*.html`. So
|
||||||
|
`Mark` dataclasses drop into the templates unchanged, and `answer` staying a
|
||||||
|
plain `dict` is fine because Jinja's dot access falls back to `__getitem__`.
|
||||||
|
`inline.py` is the single exception, which is SR-1.
|
||||||
|
|
||||||
|
### The one declared behaviour change (SR-7)
|
||||||
|
|
||||||
|
`open_marks` counts a **partially-answered pick as still open**. Today's index
|
||||||
|
badge does not, and that is an inconsistency inside the live service rather than
|
||||||
|
a decision: the panel already renders a partial answer as `◐ partial` with an
|
||||||
|
`n/m` counter, while the badge that is supposed to say "this booth owes you
|
||||||
|
something" reports zero.
|
||||||
|
|
||||||
|
Counting partial as open is the reading that makes U4 correct — a booth with a
|
||||||
|
half-answered four-question pick still owes an answer, and a lifetime rule that
|
||||||
|
unpins it on the first radio click would sweep a review mid-flight. It is called
|
||||||
|
out here because it is a visible change to what the index shows, it is the kind
|
||||||
|
of thing that looks like a bug when it lands, and the operator should get to
|
||||||
|
veto it rather than discover it.
|
||||||
|
|
||||||
|
## Slices
|
||||||
|
|
||||||
|
Vertical, each one shippable and green before the next starts.
|
||||||
|
|
||||||
|
| # | slice | tracer |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `marks.py` storage + `Mark` + `marks_for` / `open_marks` + the lock helper; `asks.build_answer` extracted | a declared pick round-trips through `.marks.json` and reads back normalized |
|
||||||
|
| 2 | `flag` end to end — `set_flag`, the route, the tile control, the zoom slot | the operator flags an item in the browser and `booth marks` shows it |
|
||||||
|
| 3 | `note` end to end — `write_note`, the route, the item + booth fields | same, for text |
|
||||||
|
| 4 | `pick` migrated — `declare_pick` / `answer_pick`, the panel, the submit route, `import_legacy_asks` | the four live asks import and answer through marks |
|
||||||
|
| 5 | the CLI — `booth marks [--wait]`, the three aliases, `marks import` | a session poses and reads back without touching a sidecar |
|
||||||
|
| 6 | the seams — index badge, booth header, `inject_asks` reading marks, template rename | 192 existing tests green, no Jinja re-derives openness |
|
||||||
|
|
||||||
|
Slice 2 is the tracer bullet deliberately: `flag` is the shape with no existing
|
||||||
|
code, the simplest payload, and the one that closes the loop currently running
|
||||||
|
through chat. If the storage seam is wrong, flag finds it cheapest.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
| test | asserts |
|
||||||
|
|---|---|
|
||||||
|
| `pick_round_trips_through_marks_json` | declare → read → normalized questions, answer None |
|
||||||
|
| `open_marks_is_the_only_openness_predicate` | INV-2, incl. a partial answer counting as open |
|
||||||
|
| `build_answer_matches_write_answer_for_every_shape` | INV-4 — single, multi, partial, notes-only, bad-option |
|
||||||
|
| `partial_answer_is_recorded_not_refused` | the 2026-09-09 ruling, as a regression test |
|
||||||
|
| `nothing_to_record_is_still_refused` | no choice and no notes anywhere → AskError |
|
||||||
|
| `redeclaring_a_pick_clears_its_answer` | the question changed; the old judgment is not an answer |
|
||||||
|
| `flag_is_an_upsert_and_unflag_removes` | idempotent set; clear deletes the mark, not `value: false` |
|
||||||
|
| `note_allows_several_per_target` | generated ids, both survive |
|
||||||
|
| `booth_level_mark_has_target_none` | the booth itself is a legal target |
|
||||||
|
| `zoom_carries_the_marks` | INV-3 — flag state + note text in `/view?f=…` HTML |
|
||||||
|
| `marks_json_is_not_an_item` | the dotfile skip already covers it; asserted, not assumed |
|
||||||
|
| `corrupt_marks_json_renders_as_empty` | `marks_for` returns `[]`, the page is a 200 |
|
||||||
|
| `broken_pick_declaration_surfaces_error` | `error` set, question not silently hidden |
|
||||||
|
| `concurrent_writes_do_not_lose_a_mark` | INV-6 — two interleaved writers, both marks present |
|
||||||
|
| `import_is_idempotent_and_keeps_the_sidecar` | INV-7; second run is a no-op |
|
||||||
|
| `import_does_not_clobber_a_newer_answer` | a stem already marked is skipped |
|
||||||
|
| `import_preserves_mtime_ordering` | SR-5 — `created` seeded from the sidecar mtime, so the four live asks keep the order `list_asks` gave them |
|
||||||
|
| `answering_an_unknown_pick_raises` | SR-4 — the `AskError` path `load_ask` used to own now lives in `answer_pick` |
|
||||||
|
| `a_partial_answer_counts_as_open` | SR-7 — the declared behaviour change, asserted at the index badge and in `open_marks` |
|
||||||
|
| `as_dict_round_trips_through_json` | SR-8 — the CLI boundary serializes without a hand-rolled dict |
|
||||||
|
| `inline_place_indexes_marks_by_id` | SR-1 — the one subscript that a dataclass breaks |
|
||||||
|
| `marks_py_is_stdlib_only` | INV-5, over `marks.py`, `asks.py`, `links.py` |
|
||||||
|
| `index_badge_counts_open_marks` | `marks_open` replaces `asks_open`, one file read |
|
||||||
|
| `index_does_not_walk_ask_sidecars` | monkeypatch the importer to raise, load `/` |
|
||||||
+64
-9
@@ -26,15 +26,27 @@ _As of 2026-09-21:_
|
|||||||
`doc_kind` / `read_blurred` / `render_doc` call survives in a route body),
|
`doc_kind` / `read_blurred` / `render_doc` call survives in a route body),
|
||||||
the zoom and doc templates render the caption they now receive, the
|
the zoom and doc templates render the caption they now receive, the
|
||||||
re-exports are asserted by a test. 192 tests green, `0.1.15`.
|
re-exports are asserted by a test. 192 tests green, `0.1.15`.
|
||||||
- **U2 (marks) is next**, and its storage shape is settled (see the
|
- **U2 (marks) has landed** — `booth/marks.py`, contract at
|
||||||
2026-09-21 decision below). Not yet started: no contract written, no
|
`docs/contracts/u2_marks.contract.md`, 242 tests green. Not yet deployed.
|
||||||
blast-radius pass run.
|
- **Two things are outstanding on U2 and both need the operator:**
|
||||||
- **Next concrete step:** graphify + grep blast-radius pass over `booth.asks`'s
|
1. **Deploy + migrate, in that order.** The live service still runs the old
|
||||||
surface and every `ask` / `answer` call-site (including `scripts/booth`),
|
code, and four unanswered `*.ask.json` sidecars are live
|
||||||
then author `docs/contracts/u2_marks.contract.md`. Full House Code Discipline
|
(`dfa-concepts`, `sc-iso-spread`, `sindra-voice-1`, `run07-decisions`).
|
||||||
— U2 is a new primitive with a CLI surface and 17 consuming handles, nowhere
|
Migrating BEFORE deploying is the hazard: the old code would keep serving
|
||||||
near surgical. The **seam review** is the load-bearing gate: U2's whole risk
|
the sidecar, an answer written there would land in the sidecar, and
|
||||||
is that `pick` claims to preserve ask semantics and then drifts from them.
|
`import_legacy_asks` skips a stem it has already imported — so that answer
|
||||||
|
would be lost. Restart the service first, then `booth marks-import <name>`
|
||||||
|
on each of the four.
|
||||||
|
2. **The release tier.** U2 changes the CLI surface for 17 consuming handles
|
||||||
|
(`booth asks` → `booth marks`, new `marks-import`) and is a v1 unit, so it
|
||||||
|
reads minor-worthy — which needs explicit operator approval per the SemVer
|
||||||
|
rule. Nothing is bumped or tagged; the work is committed as SHAs.
|
||||||
|
- **`/heid-contract-review` on the U2 contract is still in flight** (panel mode,
|
||||||
|
posted 2026-09-21, redacted copy at
|
||||||
|
`/tmp/heid-contract-review/booth-20260922-061015/`). Triage it when it lands —
|
||||||
|
the code is written, so findings land as follow-up fixes rather than contract
|
||||||
|
edits. The seam review ran in-session and its nine findings are already folded
|
||||||
|
into the contract and the code.
|
||||||
- **Open, operator's call:** whether U6 (benches) runs in parallel with U2 or
|
- **Open, operator's call:** whether U6 (benches) runs in parallel with U2 or
|
||||||
strictly after it. Nothing blocks on the answer; U6 touches different storage
|
strictly after it. Nothing blocks on the answer; U6 touches different storage
|
||||||
and a different surface, so it cannot be broken by U2.
|
and a different surface, so it cannot be broken by U2.
|
||||||
@@ -42,6 +54,49 @@ _As of 2026-09-21:_
|
|||||||
|
|
||||||
## Recent decisions
|
## Recent decisions
|
||||||
|
|
||||||
|
- `[2026-09-21]` **Deterministic order is a cross-cutting v1 invariant** —
|
||||||
|
operator directive, mid-implementation. Every ordered collection the Booth
|
||||||
|
renders must have a *stated* rule producing the same sequence on every render
|
||||||
|
of the same state; the rule can be anything defensible (byte order, time, an
|
||||||
|
explicit number, an arbitrary-but-recorded sequence), but no rule at all is
|
||||||
|
forbidden. It binds harder here than elsewhere because the Booth's job is
|
||||||
|
**comparison** — the operator judges tile 47 against tile 47 and refers to
|
||||||
|
artifacts positionally, so an order that moves between renders misfiles a flag
|
||||||
|
or a note rather than crashing. Recorded as `ROADMAP.md` § "Cross-cutting
|
||||||
|
invariant" (with the per-collection table) and `CLAUDE.md` invariant 6, and
|
||||||
|
tested. Still undecided and must be settled before those units ship: **U7's
|
||||||
|
section ordering and compare pairing**, and **U6's bench listing**.
|
||||||
|
- `[2026-09-21]` **U2 (marks) landed.** One primitive replacing three
|
||||||
|
mechanisms. `pick` / `note` / `flag` in one `.marks.json` per booth, one read
|
||||||
|
path (`marks_for`), one openness predicate (`open_marks`), rendered beside the
|
||||||
|
artifact on the tile, at full size in the zoom, and in the panel. `flag` and
|
||||||
|
`note` had no write path at all before this — the selection loop
|
||||||
|
(`golden-candidates`, `sindra-finalists`, the `pancake-*` ladders) was running
|
||||||
|
through chat. 242 tests. Details worth carrying: `asks.py` kept `normalize_ask`
|
||||||
|
and gained `build_answer` (the 2026-09-09 partial-answer semantics preserved by
|
||||||
|
moving, not rewriting) and LOST its five sidecar-storage functions;
|
||||||
|
`GET /b/<n>/marks.json` was added because remote sessions polled
|
||||||
|
`<stem>.answer.json` over HTTP and the sidecar's removal would have taken that
|
||||||
|
capability with it; `/b/<n>/asks` 308s to `/marks`.
|
||||||
|
- `[2026-09-21]` **A partially-answered pick now counts as OPEN** — declared, not
|
||||||
|
smuggled. The old index badge tested `answer is None`, so a half-answered
|
||||||
|
four-question ask read as closed on the index while the panel beside it
|
||||||
|
rendered `◐ partial`: the two disagreed about the same 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.
|
||||||
|
- `[2026-09-21]` **The U2 seam review earned its place, and the record should
|
||||||
|
say how.** Nine findings against the real `booth.asks` / `booth.items` /
|
||||||
|
`booth.inline` surfaces, two of which changed scope or behaviour: `inline.py`
|
||||||
|
was missing from `touches` entirely (its `place()` indexes asks by
|
||||||
|
**subscript**, which a frozen dataclass refuses — nothing else in the service
|
||||||
|
does that), and the partial-answer inconsistency above. The cold
|
||||||
|
`/heid-contract-review` pass is artifact-only by design and structurally
|
||||||
|
cannot see a sibling module, so neither it nor a same-model self-review would
|
||||||
|
have found either. Two more surfaced later and are worth the same note: a
|
||||||
|
SECOND subscript in `inline.place` the seam review undercounted, and a
|
||||||
|
regression in my own legacy importer that a retargeted test caught — a
|
||||||
|
malformed sidecar that renders `⚠ broken` today would have silently vanished
|
||||||
|
on migration.
|
||||||
- `[2026-09-21]` **Marks are stored as one `.marks.json` per booth**, atomic
|
- `[2026-09-21]` **Marks are stored as one `.marks.json` per booth**, atomic
|
||||||
temp-file + `os.replace`, `fcntl` lock on the read-modify-write — operator
|
temp-file + `os.replace`, `fcntl` lock on the read-modify-write — operator
|
||||||
decision, this session. Two alternatives were weighed and lost: a sidecar
|
decision, this session. Two alternatives were weighed and lost: a sidecar
|
||||||
|
|||||||
+106
-62
@@ -15,24 +15,36 @@
|
|||||||
# booth links list the board, numbered, with entry ids
|
# booth links list the board, numbered, with entry ids
|
||||||
# booth unlink <id|index> remove ONE link from the board
|
# booth unlink <id|index> remove ONE link from the board
|
||||||
#
|
#
|
||||||
# booth ask <name> <stem> <prompt> <option>... [--no-notes]
|
# booth ask <name> <id> <prompt> <option>... [--no-notes]
|
||||||
# pose a multiple-choice question in a booth
|
# pose a multiple-choice question in a booth
|
||||||
# booth asks <name> list a booth's asks and whether each is answered
|
# booth marks <name> [--wait [SECS]] print every mark in a booth as JSON;
|
||||||
# booth answer <name> <stem> [--wait [SECS]]
|
# --wait blocks while any pick is still open
|
||||||
# print the answer JSON (exit 1 if unanswered);
|
# booth answer <name> <id> [--wait [SECS]]
|
||||||
|
# print ONE pick's answer (exit 1 if unanswered);
|
||||||
# --wait polls until it lands (default 3600 s)
|
# --wait polls until it lands (default 3600 s)
|
||||||
|
# booth marks-import <name> import legacy *.ask.json into .marks.json
|
||||||
|
# booth asks <name> alias for `marks` (deprecated)
|
||||||
#
|
#
|
||||||
# ASKS. A session needs the operator to pick one of N things — which render,
|
# MARKS. One primitive for operator judgment attached to an artifact:
|
||||||
# which plan, go/no-go — and act on the pick. `ask` writes <stem>.ask.json into
|
# pick — one of N options a session declared in advance (this is `ask`)
|
||||||
# a booth; the page renders it as a radio form with a notes field; submitting
|
# note — free text the operator volunteered
|
||||||
# writes <stem>.answer.json next to it. `answer --wait` blocks until that file
|
# flag — the operator pointing at one item
|
||||||
# exists and prints it, so a session can `booth ask … && booth answer --wait …`
|
# All three are written by the OPERATOR IN THE BROWSER and read by the session.
|
||||||
# and carry on. Re-answering overwrites: the sidecar is the CURRENT answer.
|
# There are no `note` / `flag` verbs here on purpose: this CLI is the session's
|
||||||
# Several questions in ONE form: write <stem>.ask.json by hand with a
|
# side of the loop, and a session does not author the operator's judgment.
|
||||||
# `questions` list (see services/booth/README.md § Asks); `asks` and `answer`
|
#
|
||||||
# handle both shapes.
|
# A session needs the operator to pick one of N things — which render, which
|
||||||
# Remote sessions: rsync the ask in, then poll
|
# plan, go/no-go — and act on the pick. `ask` declares it; the page renders a
|
||||||
# http://10.100.10.50:8090/b/<name>/<stem>.answer.json (404 until answered).
|
# radio form with a notes field; submitting records the judgment. `answer --wait`
|
||||||
|
# blocks until it lands and prints it, so a session can
|
||||||
|
# `booth ask … && booth answer --wait …` and carry on. Re-answering overwrites:
|
||||||
|
# a mark is the CURRENT judgment, not a log. Several questions in ONE form: pass
|
||||||
|
# a `questions` list (see README § Asks); every verb handles both shapes.
|
||||||
|
#
|
||||||
|
# Marks live in ONE file per booth, `<booth>/.marks.json`, so "does this booth
|
||||||
|
# still owe an answer?" is a single read. Remote sessions have no filesystem
|
||||||
|
# access, so they poll the HTTP mirror instead:
|
||||||
|
# http://10.100.10.50:8090/b/<name>/marks.json
|
||||||
#
|
#
|
||||||
# THE 24h RULE AND ITS ONE EXCEPTION. Every booth is wiped 24h after its last
|
# THE 24h RULE AND ITS ONE EXCEPTION. Every booth is wiped 24h after its last
|
||||||
# activity — that is the contract, and it is why nobody has to clean up after
|
# activity — that is the contract, and it is why nobody has to clean up after
|
||||||
@@ -67,7 +79,7 @@ BLUR=".blurred" # one booth-relative item path per line; see `blur` below
|
|||||||
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
|
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <stem> <prompt> <option>... [--no-notes]|asks <name>|answer <name> <stem> [--wait [SECS]]}" >&2
|
echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <id> <prompt> <option>... [--no-notes]|marks <name> [--wait [SECS]]|answer <name> <id> [--wait [SECS]]|marks-import <name>}" >&2
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,69 +241,101 @@ print("removed: %s %s" % (removed["desc"], removed["url"]))
|
|||||||
' "$board" "$target"
|
' "$board" "$target"
|
||||||
;;
|
;;
|
||||||
ask)
|
ask)
|
||||||
# booth ask <name> <stem> <prompt> <opt>... [--no-notes]
|
# booth ask <name> <id> <prompt> <opt>... [--no-notes]
|
||||||
[ $# -ge 5 ] || usage
|
[ $# -ge 5 ] || usage
|
||||||
name="$1"; stem="$2"; prompt="$3"; shift 3
|
name="$1"; mid="$2"; prompt="$3"; shift 3
|
||||||
notes=1; opts=()
|
notes=1; opts=()
|
||||||
for a in "$@"; do
|
for a in "$@"; do
|
||||||
case "$a" in --no-notes) notes=0 ;; *) opts+=("$a") ;; esac
|
case "$a" in --no-notes) notes=0 ;; *) opts+=("$a") ;; esac
|
||||||
done
|
done
|
||||||
[ "${#opts[@]}" -ge 2 ] || { echo "an ask needs at least 2 options" >&2; exit 1; }
|
[ "${#opts[@]}" -ge 2 ] || { echo "a pick needs at least 2 options" >&2; exit 1; }
|
||||||
# Validated through the SAME normaliser the page uses, so a session cannot
|
# Validated through the SAME normaliser the page uses, so a session cannot
|
||||||
# post a question the renderer would refuse. stdlib only — no venv needed.
|
# declare a question the renderer would refuse. stdlib only — no venv needed.
|
||||||
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" ASK_NOTES="$notes" python3 -c '
|
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" ASK_NOTES="$notes" python3 -c '
|
||||||
import os, pathlib, sys
|
import os, pathlib, sys
|
||||||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||||||
from booth.asks import AskError, write_ask
|
from booth.asks import AskError
|
||||||
booth, stem, prompt, *opts = sys.argv[1:]
|
from booth.marks import declare_pick
|
||||||
|
booth, mid, prompt, *opts = sys.argv[1:]
|
||||||
try:
|
try:
|
||||||
write_ask(pathlib.Path(booth), stem, prompt, opts, notes=os.environ["ASK_NOTES"] == "1")
|
declare_pick(pathlib.Path(booth), mid,
|
||||||
|
{"prompt": prompt, "options": opts,
|
||||||
|
"notes": os.environ["ASK_NOTES"] == "1"})
|
||||||
except AskError as exc:
|
except AskError as exc:
|
||||||
sys.exit("bad ask: %s" % exc)
|
sys.exit("bad pick: %s" % exc)
|
||||||
' "$DATA/$name" "$stem" "$prompt" "${opts[@]}"
|
' "$DATA/$name" "$mid" "$prompt" "${opts[@]}"
|
||||||
echo "$URL/b/$name/#ask-$stem"
|
echo "$URL/b/$name/#mark-$mid"
|
||||||
;;
|
;;
|
||||||
asks)
|
marks|asks)
|
||||||
|
# booth marks <name> [--wait [SECS]] (`asks` is the deprecated alias)
|
||||||
|
[ $# -ge 1 ] || usage
|
||||||
|
name="$1"; shift
|
||||||
|
wait_s=0
|
||||||
|
if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
|
||||||
|
# Poll, do not inotify: the judgment is written by a different process via
|
||||||
|
# os.replace, and a 2 s cadence is plenty for a human clicking a radio.
|
||||||
|
deadline=$(( $(date +%s) + wait_s ))
|
||||||
|
while :; do
|
||||||
|
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
|
||||||
|
import json, os, pathlib, sys
|
||||||
|
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||||||
|
from booth.marks import as_dict, marks_for, open_marks
|
||||||
|
marks = marks_for(pathlib.Path(sys.argv[1]))
|
||||||
|
print(json.dumps({"marks": [as_dict(m) for m in marks],
|
||||||
|
"open": [m.id for m in open_marks(marks)]},
|
||||||
|
ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1 if open_marks(marks) else 0)
|
||||||
|
' "$DATA/$name" && exit 0
|
||||||
|
# exit 1 from the reader means at least one pick is still open
|
||||||
|
if [ "$wait_s" -eq 0 ]; then exit 0; fi
|
||||||
|
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||||
|
echo "timed out after ${wait_s}s with marks still open in $name" >&2; exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
answer)
|
||||||
|
# booth answer <name> <id> [--wait [SECS]]
|
||||||
|
[ $# -ge 2 ] || usage
|
||||||
|
name="$1"; mid="$2"; shift 2
|
||||||
|
wait_s=0
|
||||||
|
if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
|
||||||
|
deadline=$(( $(date +%s) + wait_s ))
|
||||||
|
while :; do
|
||||||
|
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
|
||||||
|
import json, os, pathlib, sys
|
||||||
|
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||||||
|
from booth.marks import marks_for
|
||||||
|
booth, mid = sys.argv[1:3]
|
||||||
|
m = next((x for x in marks_for(pathlib.Path(booth)) if x.id == mid), None)
|
||||||
|
if m is None:
|
||||||
|
sys.exit(2)
|
||||||
|
if m.answer is None:
|
||||||
|
sys.exit(1)
|
||||||
|
print(json.dumps(m.answer, ensure_ascii=False, indent=2))
|
||||||
|
' "$DATA/$name" "$mid" && exit 0
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -eq 2 ]; then echo "no such pick: $name/$mid" >&2; exit 1; fi
|
||||||
|
if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#mark-$mid" >&2; exit 1; fi
|
||||||
|
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||||
|
echo "timed out after ${wait_s}s waiting on $name/$mid" >&2; exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
marks-import)
|
||||||
|
# booth marks-import <name> — idempotent, and it deletes nothing
|
||||||
[ $# -ge 1 ] || usage
|
[ $# -ge 1 ] || usage
|
||||||
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
|
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
|
||||||
import os, pathlib, sys
|
import os, pathlib, sys
|
||||||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||||||
from booth.asks import list_asks
|
from booth.marks import import_legacy_asks
|
||||||
asks = list_asks(pathlib.Path(sys.argv[1]))
|
made = import_legacy_asks(pathlib.Path(sys.argv[1]))
|
||||||
if not asks:
|
if not made:
|
||||||
print("no asks in this booth")
|
print("nothing to import (or already imported)")
|
||||||
for a in asks:
|
for m in made:
|
||||||
if a["error"]:
|
print("imported %-24s %s" % (m.id, m.error or ("answered" if m.answer else "open")))
|
||||||
state = "BROKEN " + a["error"]
|
|
||||||
elif a["answer"] and a["multi"]:
|
|
||||||
picks = ", ".join("%s=%s" % (k, v["label"]) for k, v in a["answer"]["answers"].items())
|
|
||||||
state = "answered %s (%s)" % (picks, a["answer"]["answered_at"])
|
|
||||||
elif a["answer"]:
|
|
||||||
state = "answered %s (%s)" % (a["answer"]["label"], a["answer"]["answered_at"])
|
|
||||||
elif a["multi"]:
|
|
||||||
state = "open (%d questions)" % len(a["questions"])
|
|
||||||
else:
|
|
||||||
state = "open"
|
|
||||||
print("%-24s %s" % (a["stem"], state))
|
|
||||||
' "$DATA/$1"
|
' "$DATA/$1"
|
||||||
;;
|
;;
|
||||||
answer)
|
|
||||||
# booth answer <name> <stem> [--wait [SECS]]
|
|
||||||
[ $# -ge 2 ] || usage
|
|
||||||
name="$1"; stem="$2"; shift 2
|
|
||||||
wait_s=0
|
|
||||||
if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
|
|
||||||
f="$DATA/$name/$stem.answer.json"
|
|
||||||
[ -f "$DATA/$name/$stem.ask.json" ] || { echo "no such ask: $name/$stem" >&2; exit 1; }
|
|
||||||
# Poll, do not inotify: the answer is written by a different process via
|
|
||||||
# os.replace, and a 2 s cadence is plenty for a human clicking a radio.
|
|
||||||
deadline=$(( $(date +%s) + wait_s ))
|
|
||||||
while [ ! -f "$f" ]; do
|
|
||||||
if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#ask-$stem" >&2; exit 1; fi
|
|
||||||
if [ "$(date +%s)" -ge "$deadline" ]; then echo "timed out after ${wait_s}s waiting on $name/$stem" >&2; exit 1; fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
cat -- "$f"
|
|
||||||
;;
|
|
||||||
*) usage ;;
|
*) usage ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
+149
-90
@@ -1,5 +1,12 @@
|
|||||||
"""Asks: session poses a multiple-choice question; operator answers in the
|
"""Picks: a session poses a multiple-choice question; the operator answers it in
|
||||||
browser; the answer lands as a sidecar the session reads."""
|
the browser; the answer lands where the session reads it.
|
||||||
|
|
||||||
|
A `pick` is one shape of MARK (see tests/test_marks.py and booth/marks.py). What
|
||||||
|
stays here is what did not move: `normalize_ask`, the declaration validator, and
|
||||||
|
`build_answer`, which together carry the operator-settled 2026-09-09 semantics —
|
||||||
|
plus the page and route integration, retargeted from the two-sidecars-per-question
|
||||||
|
storage that marks replaced.
|
||||||
|
"""
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
@@ -11,16 +18,31 @@ from booth.asks import (
|
|||||||
ANSWER_SUFFIX,
|
ANSWER_SUFFIX,
|
||||||
ASK_SUFFIX,
|
ASK_SUFFIX,
|
||||||
AskError,
|
AskError,
|
||||||
list_asks,
|
build_answer,
|
||||||
load_ask,
|
|
||||||
normalize_ask,
|
normalize_ask,
|
||||||
read_answer,
|
)
|
||||||
write_answer,
|
from booth.marks import (
|
||||||
write_ask,
|
answer_pick,
|
||||||
|
declare_pick,
|
||||||
|
import_legacy_asks,
|
||||||
|
marks_for,
|
||||||
|
open_marks,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _ask(booth, stem="winner", **kw):
|
def _ask(booth, stem="winner", **kw):
|
||||||
|
"""Declare a pick, the way a session does now."""
|
||||||
|
doc = {"prompt": "Which render wins?", "options": ["A — baseline", "B — async"]}
|
||||||
|
doc.update(kw)
|
||||||
|
booth.mkdir(parents=True, exist_ok=True)
|
||||||
|
declare_pick(booth, stem, doc)
|
||||||
|
return booth
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar(booth, stem="winner", **kw):
|
||||||
|
"""Write a LEGACY `<stem>.ask.json`. Only for the tests that are about the
|
||||||
|
legacy files themselves — they are still excluded from the item list, and
|
||||||
|
still importable."""
|
||||||
doc = {"prompt": "Which render wins?", "options": ["A — baseline", "B — async"]}
|
doc = {"prompt": "Which render wins?", "options": ["A — baseline", "B — async"]}
|
||||||
doc.update(kw)
|
doc.update(kw)
|
||||||
booth.mkdir(parents=True, exist_ok=True)
|
booth.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -76,72 +98,79 @@ def test_normalize_rejects(doc):
|
|||||||
# ---- files ------------------------------------------------------------------
|
# ---- files ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_load_ask_reports_bad_json(tmp_path):
|
def test_a_malformed_declaration_is_refused(tmp_path):
|
||||||
(tmp_path / f"x{ASK_SUFFIX}").write_text("{not json")
|
"""The unreadable-FILE case moved to the legacy importer, which surfaces it
|
||||||
|
as a broken mark rather than raising — see test_marks.py. What is left here
|
||||||
|
is the declaration itself being wrong, which is refused at the door."""
|
||||||
with pytest.raises(AskError):
|
with pytest.raises(AskError):
|
||||||
load_ask(tmp_path, "x")
|
normalize_ask("not an object", "x")
|
||||||
with pytest.raises(AskError):
|
with pytest.raises(AskError):
|
||||||
load_ask(tmp_path, "missing")
|
declare_pick(tmp_path, "x", {"prompt": "no options"})
|
||||||
|
|
||||||
|
|
||||||
def test_list_asks_folds_answer_and_surfaces_errors(tmp_path):
|
def test_marks_for_folds_answer_and_surfaces_errors(tmp_path):
|
||||||
_ask(tmp_path, "one")
|
_ask(tmp_path, "one")
|
||||||
_ask(tmp_path, "two")
|
_ask(tmp_path, "two")
|
||||||
(tmp_path / f"broken{ASK_SUFFIX}").write_text("[]")
|
(tmp_path / f"broken{ASK_SUFFIX}").write_text("[]")
|
||||||
(tmp_path / ".hidden.ask.json").write_text("{}") # dotfiles never listed
|
(tmp_path / ".hidden.ask.json").write_text("{}") # dotfiles never listed
|
||||||
write_answer(tmp_path, "two", "B — async", "less banding", who="10.0.0.1")
|
answer_pick(tmp_path, "two", "B — async", "less banding", who="10.0.0.1")
|
||||||
|
|
||||||
asks = list_asks(tmp_path)
|
by = {m.id: m for m in marks_for(tmp_path)}
|
||||||
by = {a["stem"]: a for a in asks}
|
assert set(by) == {"one", "two"} # the broken SIDECAR is not a mark
|
||||||
assert set(by) == {"one", "two", "broken"}
|
assert by["one"].answer is None and by["one"].error is None
|
||||||
assert by["one"]["answer"] is None and by["one"]["error"] is None
|
assert by["two"].answer["choice"] == "B — async"
|
||||||
assert by["two"]["answer"]["choice"] == "B — async"
|
assert by["two"].answer["choice_index"] == 1
|
||||||
assert by["two"]["answer"]["choice_index"] == 1
|
assert by["two"].answer["notes"] == "less banding"
|
||||||
assert by["two"]["answer"]["notes"] == "less banding"
|
assert by["two"].answer["answered_by"] == "10.0.0.1"
|
||||||
assert by["two"]["answer"]["answered_by"] == "10.0.0.1"
|
|
||||||
assert by["broken"]["error"] and by["broken"]["options"] == []
|
# The broken legacy sidecar is not silently swallowed either — importing it
|
||||||
|
# lands a mark carrying the error, so a question the session believes it
|
||||||
|
# posted stays visible instead of vanishing.
|
||||||
|
import_legacy_asks(tmp_path)
|
||||||
|
broken = next(m for m in marks_for(tmp_path) if m.id == "broken")
|
||||||
|
assert broken.error and broken.options == []
|
||||||
|
|
||||||
|
|
||||||
def test_write_answer_validates_choice_and_is_atomic(tmp_path):
|
def test_answer_validates_the_choice_that_was_made(tmp_path):
|
||||||
_ask(tmp_path)
|
_sidecar(tmp_path)
|
||||||
with pytest.raises(AskError):
|
with pytest.raises(AskError):
|
||||||
write_answer(tmp_path, "winner", "C — nope")
|
_shape("C — nope")
|
||||||
with pytest.raises(AskError):
|
with pytest.raises(AskError):
|
||||||
write_answer(tmp_path, "nosuch", "A — baseline")
|
_shape("A — baseline", doc={"prompt": "p", "options": ["only"]})
|
||||||
ans = write_answer(tmp_path, "winner", "A — baseline", " ok \r\n")
|
ans = _shape("A — baseline", notes=" ok \r\n")
|
||||||
assert ans["notes"] == "ok"
|
assert ans["notes"] == "ok"
|
||||||
assert ans["answered_at"]
|
assert ans["answered_at"]
|
||||||
assert read_answer(tmp_path, "winner") == ans
|
|
||||||
assert not (tmp_path / f"winner{ANSWER_SUFFIX}.tmp").exists()
|
assert not (tmp_path / f"winner{ANSWER_SUFFIX}.tmp").exists()
|
||||||
# re-answer overwrites — the sidecar is the CURRENT answer, not a log
|
# re-answer overwrites — the sidecar is the CURRENT answer, not a log
|
||||||
write_answer(tmp_path, "winner", "B — async")
|
assert _shape("B — async")["choice_index"] == 1
|
||||||
assert read_answer(tmp_path, "winner")["choice_index"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_answer_drops_notes_when_ask_disables_them(tmp_path):
|
def test_answer_drops_notes_when_the_pick_disables_them(tmp_path):
|
||||||
_ask(tmp_path, notes=False)
|
_sidecar(tmp_path, notes=False)
|
||||||
assert write_answer(tmp_path, "winner", "A — baseline", "ignored")["notes"] == ""
|
assert _shape("A — baseline", doc=dict(SINGLE, notes=False), notes="ignored")["notes"] == ""
|
||||||
|
|
||||||
|
|
||||||
def test_write_ask_roundtrip_and_stem_guard(tmp_path):
|
def test_declare_pick_roundtrip_and_id_guard(tmp_path):
|
||||||
p = write_ask(tmp_path / "b", "pick", "Pick one", ["x", {"id": "y", "label": "Y"}], notes=False)
|
declare_pick(tmp_path / "b", "pick",
|
||||||
assert p.name == f"pick{ASK_SUFFIX}"
|
{"prompt": "Pick one", "options": ["x", {"id": "y", "label": "Y"}], "notes": False})
|
||||||
a = load_ask(tmp_path / "b", "pick")
|
a = next(m for m in marks_for(tmp_path / "b") if m.id == "pick")
|
||||||
assert [o["id"] for o in a["options"]] == ["x", "y"] and a["notes"] is False
|
assert [o["id"] for o in a.options] == ["x", "y"] and a.notes_enabled is False
|
||||||
for bad in ("../x", ".hidden", "a/b", ""):
|
for bad in ("../x", ".hidden", "a/b", ""):
|
||||||
with pytest.raises(AskError):
|
with pytest.raises(AskError):
|
||||||
write_ask(tmp_path / "b", bad, "p", ["a", "b"])
|
declare_pick(tmp_path / "b", bad, {"prompt": "p", "options": ["a", "b"]})
|
||||||
with pytest.raises(AskError):
|
with pytest.raises(AskError):
|
||||||
write_ask(tmp_path / "b", "ok", "p", ["solo"])
|
declare_pick(tmp_path / "b", "ok", {"prompt": "p", "options": ["solo"]})
|
||||||
|
|
||||||
|
|
||||||
# ---- gallery + index integration -------------------------------------------
|
# ---- gallery + index integration -------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_gallery_hides_ask_and_answer_files(tmp_path):
|
def test_gallery_hides_legacy_ask_and_answer_files(tmp_path):
|
||||||
b = _ask(tmp_path / "b")
|
"""The legacy sidecars are never deleted (the ROADMAP says so), so they are
|
||||||
|
still on disk in live booths and must still not render as tiles."""
|
||||||
|
b = _sidecar(tmp_path / "b")
|
||||||
(b / "a.png").write_bytes(b"x")
|
(b / "a.png").write_bytes(b"x")
|
||||||
write_answer(b, "winner", "A — baseline")
|
(b / f"winner{ANSWER_SUFFIX}").write_text(json.dumps({"stem": "winner"}))
|
||||||
names = {it["name"] for it in build_gallery(b)}
|
names = {it["name"] for it in build_gallery(b)}
|
||||||
assert names == {"a.png"}
|
assert names == {"a.png"}
|
||||||
|
|
||||||
@@ -149,11 +178,11 @@ def test_gallery_hides_ask_and_answer_files(tmp_path):
|
|||||||
def test_list_booths_counts_open_asks(tmp_path):
|
def test_list_booths_counts_open_asks(tmp_path):
|
||||||
b = _ask(tmp_path / "b", "one")
|
b = _ask(tmp_path / "b", "one")
|
||||||
_ask(b, "two")
|
_ask(b, "two")
|
||||||
write_answer(b, "two", "A — baseline")
|
answer_pick(b, "two", "A — baseline")
|
||||||
(tmp_path / "plain").mkdir()
|
(tmp_path / "plain").mkdir()
|
||||||
by = {x["name"]: x for x in list_booths(tmp_path, 3600)}
|
by = {x["name"]: x for x in list_booths(tmp_path, 3600)}
|
||||||
assert by["b"]["asks_open"] == 1 and by["b"]["asks_total"] == 2
|
assert by["b"]["marks_open"] == 1 and by["b"]["marks_total"] == 2
|
||||||
assert by["plain"]["asks_open"] == 0 and by["plain"]["asks_total"] == 0
|
assert by["plain"]["marks_open"] == 0 and by["plain"]["marks_total"] == 0
|
||||||
assert by["b"]["count"] == 0 # ask/answer files are not "items"
|
assert by["b"]["count"] == 0 # ask/answer files are not "items"
|
||||||
|
|
||||||
|
|
||||||
@@ -169,23 +198,26 @@ def test_booth_page_renders_open_ask_as_form(client):
|
|||||||
assert 'value="B — async"' in html
|
assert 'value="B — async"' in html
|
||||||
assert 'action="/b/b/answer"' in html
|
assert 'action="/b/b/answer"' in html
|
||||||
assert "<textarea" in html
|
assert "<textarea" in html
|
||||||
assert "1 open ask" in html
|
assert "1 open" in html
|
||||||
|
|
||||||
|
|
||||||
def test_answer_route_writes_sidecar_and_page_shows_it(client):
|
def test_answer_route_records_the_pick_and_page_shows_it(client):
|
||||||
c, data = client
|
c, data = client
|
||||||
_ask(data / "b")
|
_ask(data / "b")
|
||||||
r = c.post("/b/b/answer", data={"ask": "winner", "choice": "B — async", "notes": "less banding"},
|
r = c.post("/b/b/answer", data={"ask": "winner", "choice": "B — async", "notes": "less banding"},
|
||||||
follow_redirects=False)
|
follow_redirects=False)
|
||||||
assert r.status_code == 303 and r.headers["location"] == "/b/b/#ask-winner"
|
assert r.status_code == 303 and r.headers["location"] == "/b/b/#mark-winner"
|
||||||
ans = json.loads((data / "b" / f"winner{ANSWER_SUFFIX}").read_text())
|
ans = _answer_of(data / "b", "winner")
|
||||||
assert ans["choice"] == "B — async" and ans["notes"] == "less banding"
|
assert ans["choice"] == "B — async" and ans["notes"] == "less banding"
|
||||||
assert ans["answered_by"] # TestClient's client addr
|
assert ans["answered_by"] # TestClient's client addr
|
||||||
html = c.get("/b/b/").text
|
html = c.get("/b/b/").text
|
||||||
assert "answered" in html and "less banding" in html
|
assert "answered" in html and "less banding" in html
|
||||||
assert "1 open ask" not in html
|
assert "1 open" not in html
|
||||||
# the sidecar is fetchable over HTTP for remote sessions
|
# a session on ANOTHER host reads the judgment over HTTP — the capability the
|
||||||
assert c.get("/b/b/winner.answer.json").json()["choice"] == "B — async"
|
# per-stem sidecar used to carry, now one request for the whole booth
|
||||||
|
doc = c.get("/b/b/marks.json").json()
|
||||||
|
assert doc["open"] == []
|
||||||
|
assert doc["marks"][0]["answer"]["choice"] == "B — async"
|
||||||
|
|
||||||
|
|
||||||
def test_answer_route_rejects_bad_choice_and_unknown_ask(client):
|
def test_answer_route_rejects_bad_choice_and_unknown_ask(client):
|
||||||
@@ -206,14 +238,16 @@ def test_answer_json_404s_until_answered(client):
|
|||||||
def test_notes_field_hidden_when_disabled(client):
|
def test_notes_field_hidden_when_disabled(client):
|
||||||
c, data = client
|
c, data = client
|
||||||
_ask(data / "b", notes=False)
|
_ask(data / "b", notes=False)
|
||||||
assert "<textarea" not in c.get("/b/b/").text
|
html = c.get("/b/b/").text
|
||||||
|
assert 'name="notes"' not in html # the pick's free-text field
|
||||||
|
assert 'name="text"' in html # the add-a-note control stays
|
||||||
|
|
||||||
|
|
||||||
def test_index_card_shows_open_ask_badge(client):
|
def test_index_card_shows_open_mark_badge(client):
|
||||||
c, data = client
|
c, data = client
|
||||||
_ask(data / "b")
|
_ask(data / "b")
|
||||||
html = c.get("/").text
|
html = c.get("/").text
|
||||||
assert "1 ask" in html
|
assert "1 open" in html
|
||||||
|
|
||||||
|
|
||||||
# ---- multi-question asks ----------------------------------------------------
|
# ---- multi-question asks ----------------------------------------------------
|
||||||
@@ -230,6 +264,32 @@ MULTI = {
|
|||||||
|
|
||||||
|
|
||||||
def _multi(booth, stem="batch", **kw):
|
def _multi(booth, stem="batch", **kw):
|
||||||
|
"""Declare a multi-question pick, the way a session does now."""
|
||||||
|
doc = json.loads(json.dumps(MULTI)); doc.update(kw)
|
||||||
|
booth.mkdir(parents=True, exist_ok=True)
|
||||||
|
declare_pick(booth, stem, doc)
|
||||||
|
return booth
|
||||||
|
|
||||||
|
|
||||||
|
SINGLE = {"prompt": "Which render wins?", "options": ["A — baseline", "B — async"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _shape(choice, doc=None, stem="winner", **kw):
|
||||||
|
"""`build_answer` over a normalized declaration — the 2026-09-09 semantics
|
||||||
|
with no storage under them."""
|
||||||
|
return build_answer(normalize_ask(json.loads(json.dumps(doc or SINGLE)), stem), choice, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def _answer_of(booth, mark_id):
|
||||||
|
"""The recorded judgment for one pick — read back through marks, which is the
|
||||||
|
one read path now that `<stem>.answer.json` is gone."""
|
||||||
|
m = next(x for x in marks_for(booth) if x.id == mark_id)
|
||||||
|
return m.answer
|
||||||
|
|
||||||
|
|
||||||
|
def _multi_sidecar(booth, stem="batch", **kw):
|
||||||
|
"""The LEGACY multi sidecar — for the tests that are about the legacy files
|
||||||
|
themselves (still excluded from the item list, still importable)."""
|
||||||
doc = json.loads(json.dumps(MULTI)); doc.update(kw)
|
doc = json.loads(json.dumps(MULTI)); doc.update(kw)
|
||||||
booth.mkdir(parents=True, exist_ok=True)
|
booth.mkdir(parents=True, exist_ok=True)
|
||||||
(booth / f"{stem}{ASK_SUFFIX}").write_text(json.dumps(doc))
|
(booth / f"{stem}{ASK_SUFFIX}").write_text(json.dumps(doc))
|
||||||
@@ -264,52 +324,51 @@ def test_normalize_multi_rejects(doc):
|
|||||||
normalize_ask(doc, "s")
|
normalize_ask(doc, "s")
|
||||||
|
|
||||||
|
|
||||||
def test_write_answer_multi_accepts_a_partial_answer(tmp_path):
|
def test_multi_accepts_a_partial_answer(tmp_path):
|
||||||
"""Blanks are legal (operator ruling 2026-09-09): refusing the whole
|
"""Blanks are legal (operator ruling 2026-09-09): refusing the whole
|
||||||
submission because one of four was skipped threw away the three that were
|
submission because one of four was skipped threw away the three that were
|
||||||
made."""
|
made."""
|
||||||
_multi(tmp_path)
|
_multi_sidecar(tmp_path)
|
||||||
a = write_answer(tmp_path, "batch", {"r1": "keep"}) # r2 not submitted at all
|
a = _shape({"r1": "keep"}, MULTI, "batch") # r2 not submitted at all
|
||||||
assert a["complete"] is False and a["unanswered"] == ["r2"]
|
assert a["complete"] is False and a["unanswered"] == ["r2"]
|
||||||
assert list(a["answers"]) == ["r1"]
|
assert list(a["answers"]) == ["r1"]
|
||||||
b = write_answer(tmp_path, "batch", {"r1": "keep", "r2": ""}) # r2 an empty radio group
|
b = _shape({"r1": "keep", "r2": ""}, MULTI, "batch") # r2 an empty radio group
|
||||||
assert b["unanswered"] == ["r2"] and b["complete"] is False
|
assert b["unanswered"] == ["r2"] and b["complete"] is False
|
||||||
# a note without a pick is still worth keeping
|
# a note without a pick is still worth keeping
|
||||||
c = write_answer(tmp_path, "batch", {"r1": "", "r2": "k"}, qnotes={"r1": "undecided"})
|
c = _shape({"r1": "", "r2": "k"}, MULTI, "batch", qnotes={"r1": "undecided"})
|
||||||
assert c["answers"]["r1"] == {"prompt": "Render 1?", "choice": None,
|
assert c["answers"]["r1"] == {"prompt": "Render 1?", "choice": None,
|
||||||
"choice_index": None, "label": "", "notes": "undecided"}
|
"choice_index": None, "label": "", "notes": "undecided"}
|
||||||
assert c["unanswered"] == ["r1"]
|
assert c["unanswered"] == ["r1"]
|
||||||
# nothing at all is refused: it would flip the ask to answered with no decision
|
# nothing at all is refused: it would flip the ask to answered with no decision
|
||||||
with pytest.raises(AskError):
|
with pytest.raises(AskError):
|
||||||
write_answer(tmp_path, "batch", {"r1": "", "r2": ""})
|
_shape({"r1": "", "r2": ""}, MULTI, "batch")
|
||||||
# ...but notes alone are a real submission
|
# ...but notes alone are a real submission
|
||||||
d = write_answer(tmp_path, "batch", {"r1": "", "r2": ""}, "ask me tomorrow")
|
d = _shape({"r1": "", "r2": ""}, MULTI, "batch", notes="ask me tomorrow")
|
||||||
assert d["complete"] is False and d["notes"] == "ask me tomorrow" and d["answers"] == {}
|
assert d["complete"] is False and d["notes"] == "ask me tomorrow" and d["answers"] == {}
|
||||||
|
|
||||||
|
|
||||||
def test_single_ask_may_be_answered_with_notes_only(tmp_path):
|
def test_single_ask_may_be_answered_with_notes_only(tmp_path):
|
||||||
_ask(tmp_path)
|
_sidecar(tmp_path)
|
||||||
with pytest.raises(AskError):
|
with pytest.raises(AskError):
|
||||||
write_answer(tmp_path, "winner", "")
|
answer_pick(tmp_path, "winner", "")
|
||||||
a = write_answer(tmp_path, "winner", "", "neither is right, rerun")
|
a = _shape("", notes="neither is right, rerun")
|
||||||
assert a["choice"] is None and a["complete"] is False
|
assert a["choice"] is None and a["complete"] is False
|
||||||
assert a["notes"] == "neither is right, rerun"
|
assert a["notes"] == "neither is right, rerun"
|
||||||
|
|
||||||
|
|
||||||
def test_write_answer_multi_still_rejects_a_bad_option(tmp_path):
|
def test_multi_still_rejects_a_bad_option(tmp_path):
|
||||||
_multi(tmp_path)
|
_multi_sidecar(tmp_path)
|
||||||
with pytest.raises(AskError):
|
with pytest.raises(AskError):
|
||||||
write_answer(tmp_path, "batch", {"r1": "keep", "r2": "nope"})
|
_shape({"r1": "keep", "r2": "nope"}, MULTI, "batch")
|
||||||
with pytest.raises(AskError):
|
with pytest.raises(AskError):
|
||||||
write_answer(tmp_path, "batch", "keep") # wrong shape
|
_shape("keep", MULTI, "batch") # wrong shape
|
||||||
ans = write_answer(tmp_path, "batch", {"r1": "drop", "r2": "k"}, "overall fine",
|
ans = _shape({"r1": "drop", "r2": "k"}, MULTI, "batch", notes="overall fine",
|
||||||
qnotes={"r1": "banding", "r2": "ignored: notes off"})
|
qnotes={"r1": "banding", "r2": "ignored: notes off"})
|
||||||
assert list(ans["answers"]) == ["r1", "r2"]
|
assert list(ans["answers"]) == ["r1", "r2"]
|
||||||
assert ans["answers"]["r1"] == {"prompt": "Render 1?", "choice": "drop", "choice_index": 1,
|
assert ans["answers"]["r1"] == {"prompt": "Render 1?", "choice": "drop", "choice_index": 1,
|
||||||
"label": "drop", "notes": "banding"}
|
"label": "drop", "notes": "banding"}
|
||||||
assert ans["answers"]["r2"]["choice"] == "k" and ans["answers"]["r2"]["notes"] == ""
|
assert ans["answers"]["r2"]["choice"] == "k" and ans["answers"]["r2"]["notes"] == ""
|
||||||
assert ans["notes"] == "overall fine" and ans["title"] == "R18 batch review"
|
assert ans["notes"] == "overall fine" and ans["title"] == "R18 batch review"
|
||||||
assert read_answer(tmp_path, "batch") == ans
|
|
||||||
|
|
||||||
|
|
||||||
def test_multi_page_and_route(client):
|
def test_multi_page_and_route(client):
|
||||||
@@ -323,24 +382,24 @@ def test_multi_page_and_route(client):
|
|||||||
# a partial submission is RECORDED, not refused
|
# a partial submission is RECORDED, not refused
|
||||||
assert c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep"},
|
assert c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep"},
|
||||||
follow_redirects=False).status_code == 303
|
follow_redirects=False).status_code == 303
|
||||||
part = json.loads((data / "b" / f"batch{ANSWER_SUFFIX}").read_text())
|
part = _answer_of(data / "b", "batch")
|
||||||
assert part["complete"] is False and part["unanswered"] == ["r2"]
|
assert part["complete"] is False and part["unanswered"] == ["r2"]
|
||||||
assert "1/2" in c.get("/b/b/").text and "partial" in c.get("/b/b/").text
|
assert "1/2" in c.get("/b/b/").text and "partial" in c.get("/b/b/").text
|
||||||
r = c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep", "notes.r1": "crisp",
|
r = c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep", "notes.r1": "crisp",
|
||||||
"choice.r2": "d", "notes": "ship r1"}, follow_redirects=False)
|
"choice.r2": "d", "notes": "ship r1"}, follow_redirects=False)
|
||||||
assert r.status_code == 303
|
assert r.status_code == 303
|
||||||
ans = c.get("/b/b/batch.answer.json").json()
|
ans = _answer_of(data / "b", "batch")
|
||||||
assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r1"]["notes"] == "crisp"
|
assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r1"]["notes"] == "crisp"
|
||||||
assert ans["answers"]["r2"]["choice"] == "d" and ans["notes"] == "ship r1"
|
assert ans["answers"]["r2"]["choice"] == "d" and ans["notes"] == "ship r1"
|
||||||
html = c.get("/b/b/").text
|
html = c.get("/b/b/").text
|
||||||
assert "answered" in html and "crisp" in html and "ship r1" in html
|
assert "answered" in html and "crisp" in html and "ship r1" in html
|
||||||
|
|
||||||
|
|
||||||
def test_write_ask_accepts_full_doc(tmp_path):
|
def test_declare_pick_accepts_a_full_multi_doc(tmp_path):
|
||||||
write_ask(tmp_path / "b", "batch", doc=MULTI)
|
declare_pick(tmp_path / "b", "batch", MULTI)
|
||||||
assert load_ask(tmp_path / "b", "batch")["multi"] is True
|
assert next(m for m in marks_for(tmp_path / "b") if m.id == "batch").multi is True
|
||||||
with pytest.raises(AskError):
|
with pytest.raises(AskError):
|
||||||
write_ask(tmp_path / "b", "bad", doc={"questions": []})
|
declare_pick(tmp_path / "b", "bad", {"questions": []})
|
||||||
|
|
||||||
|
|
||||||
# ---- verbatim-index booths ---------------------------------------------------
|
# ---- verbatim-index booths ---------------------------------------------------
|
||||||
@@ -368,7 +427,7 @@ def test_verbatim_chip_disappears_once_answered(client):
|
|||||||
c, data = client
|
c, data = client
|
||||||
b = _ask(data / "b")
|
b = _ask(data / "b")
|
||||||
(b / "index.html").write_text("<!doctype html><body>hi</body>")
|
(b / "index.html").write_text("<!doctype html><body>hi</body>")
|
||||||
write_answer(b, "winner", "A — baseline")
|
answer_pick(b, "winner", "A — baseline")
|
||||||
assert "booth-nav-asks" not in c.get("/b/b/").text
|
assert "booth-nav-asks" not in c.get("/b/b/").text
|
||||||
|
|
||||||
|
|
||||||
@@ -383,20 +442,20 @@ def test_asks_page_renders_forms_and_answers_back_to_itself(client):
|
|||||||
c, data = client
|
c, data = client
|
||||||
b = _ask(data / "b")
|
b = _ask(data / "b")
|
||||||
(b / "index.html").write_text("<!doctype html><body>hi</body>")
|
(b / "index.html").write_text("<!doctype html><body>hi</body>")
|
||||||
page = c.get("/b/b/asks").text
|
page = c.get("/b/b/marks").text
|
||||||
assert "Which render wins?" in page and 'type="radio"' in page
|
assert "Which render wins?" in page and 'type="radio"' in page
|
||||||
assert 'name="back" value="asks"' in page
|
assert 'name="back" value="marks"' in page
|
||||||
r = c.post("/b/b/answer", data={"ask": "winner", "choice": "B — async", "back": "asks"},
|
r = c.post("/b/b/answer", data={"ask": "winner", "choice": "B — async", "back": "marks"},
|
||||||
follow_redirects=False)
|
follow_redirects=False)
|
||||||
assert r.headers["location"] == "/b/b/asks#ask-winner"
|
assert r.headers["location"] == "/b/b/marks#mark-winner"
|
||||||
assert read_answer(b, "winner")["choice"] == "B — async"
|
assert _answer_of(b, "winner")["choice"] == "B — async"
|
||||||
assert "answered" in c.get("/b/b/asks").text
|
assert "answered" in c.get("/b/b/marks").text
|
||||||
|
|
||||||
|
|
||||||
def test_asks_page_on_a_booth_with_none(client):
|
def test_asks_page_on_a_booth_with_none(client):
|
||||||
c, data = client
|
c, data = client
|
||||||
(data / "b").mkdir()
|
(data / "b").mkdir()
|
||||||
assert "no asks" in c.get("/b/b/asks").text
|
assert "no marks" in c.get("/b/b/marks").text
|
||||||
|
|
||||||
|
|
||||||
def test_asks_page_404s_for_unknown_booth(client):
|
def test_asks_page_404s_for_unknown_booth(client):
|
||||||
@@ -415,7 +474,7 @@ def test_single_ask_keeps_its_title(tmp_path):
|
|||||||
def test_asks_page_shows_a_single_ask_title(client):
|
def test_asks_page_shows_a_single_ask_title(client):
|
||||||
c, data = client
|
c, data = client
|
||||||
_ask(data / "b", title="emmie — pick the anchor")
|
_ask(data / "b", title="emmie — pick the anchor")
|
||||||
assert "emmie — pick the anchor" in c.get("/b/b/asks").text
|
assert "emmie — pick the anchor" in c.get("/b/b/marks").text
|
||||||
|
|
||||||
|
|
||||||
# ---- inline placement in a verbatim report -----------------------------------
|
# ---- inline placement in a verbatim report -----------------------------------
|
||||||
@@ -460,7 +519,7 @@ def test_inline_form_submits_every_question_in_one_post(client):
|
|||||||
r = c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep", "notes.r1": "crisp",
|
r = c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep", "notes.r1": "crisp",
|
||||||
"choice.r2": "d", "notes": "ship r1"}, follow_redirects=False)
|
"choice.r2": "d", "notes": "ship r1"}, follow_redirects=False)
|
||||||
assert r.status_code == 303
|
assert r.status_code == 303
|
||||||
ans = read_answer(b, "batch")
|
ans = _answer_of(b, "batch")
|
||||||
assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r2"]["choice"] == "d"
|
assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r2"]["choice"] == "d"
|
||||||
# and the recorded pick now shows inline, on the report itself
|
# and the recorded pick now shows inline, on the report itself
|
||||||
html = c.get("/b/b/").text
|
html = c.get("/b/b/").text
|
||||||
@@ -510,7 +569,7 @@ def test_radios_are_not_html_required_anywhere(client):
|
|||||||
assert "required" not in c.get("/b/b/").text
|
assert "required" not in c.get("/b/b/").text
|
||||||
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch"></div></body>')
|
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch"></div></body>')
|
||||||
assert "required" not in c.get("/b/b/").text
|
assert "required" not in c.get("/b/b/").text
|
||||||
assert "required" not in c.get("/b/b/asks").text
|
assert "required" not in c.get("/b/b/marks").text
|
||||||
|
|
||||||
|
|
||||||
def test_partial_answer_renders_as_skipped_inline(client):
|
def test_partial_answer_renders_as_skipped_inline(client):
|
||||||
@@ -527,4 +586,4 @@ def test_empty_submission_is_refused_with_400(client):
|
|||||||
c, data = client
|
c, data = client
|
||||||
b = _multi(data / "b")
|
b = _multi(data / "b")
|
||||||
assert c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "", "choice.r2": ""}).status_code == 400
|
assert c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "", "choice.r2": ""}).status_code == 400
|
||||||
assert read_answer(b, "batch") is None # the ask stays OPEN, not falsely answered
|
assert _answer_of(b, "batch") is None # the pick stays OPEN, not falsely answered
|
||||||
|
|||||||
@@ -0,0 +1,715 @@
|
|||||||
|
"""Marks: one primitive for operator judgment attached to an artifact.
|
||||||
|
|
||||||
|
`pick` (the session asks), `note` (the operator tells), `flag` (the operator
|
||||||
|
points at the good ones) — three shapes, one storage model, one read path.
|
||||||
|
|
||||||
|
See docs/contracts/u2_marks.contract.md.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from booth.asks import ASK_SUFFIX, AskError, build_answer, normalize_ask
|
||||||
|
from booth.marks import (
|
||||||
|
MARKS_FILE,
|
||||||
|
Mark,
|
||||||
|
answer_pick,
|
||||||
|
as_dict,
|
||||||
|
declare_pick,
|
||||||
|
marks_for,
|
||||||
|
open_marks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _single():
|
||||||
|
return {"prompt": "Which render wins?", "options": ["A — baseline", "B — async"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _multi():
|
||||||
|
return {
|
||||||
|
"title": "R18 batch review",
|
||||||
|
"questions": [
|
||||||
|
{"key": "q1", "prompt": "Render 1?", "options": ["keep", "drop"]},
|
||||||
|
{"key": "q2", "prompt": "Render 2?", "options": ["keep", "drop"]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- slice 1: storage, the record, the read path ----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_round_trips_through_marks_json(tmp_path):
|
||||||
|
"""A declared pick lands in ONE file and reads back normalized."""
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
declare_pick(booth, "winner", _single())
|
||||||
|
|
||||||
|
assert (booth / MARKS_FILE).is_file()
|
||||||
|
marks = marks_for(booth)
|
||||||
|
assert len(marks) == 1
|
||||||
|
m = marks[0]
|
||||||
|
assert isinstance(m, Mark)
|
||||||
|
assert (m.id, m.shape, m.target) == ("winner", "pick", None)
|
||||||
|
assert m.answer is None
|
||||||
|
assert m.prompt == "Which render wins?"
|
||||||
|
assert m.multi is False
|
||||||
|
# normalize_ask emits `notes` (bool); the record carries it as notes_enabled (SR-2)
|
||||||
|
assert m.notes_enabled is True
|
||||||
|
assert m.notes_label == "notes"
|
||||||
|
assert [o["id"] for o in m.options] == ["A — baseline", "B — async"]
|
||||||
|
assert len(m.questions) == 1 and m.questions[0]["key"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_marks_json_is_a_dotfile_so_it_is_not_an_item(tmp_path):
|
||||||
|
"""The dotfile skip in booth_items already covers it. Asserted, not assumed."""
|
||||||
|
from booth.items import booth_items
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
(booth / "a.png").write_bytes(b"\x89PNG")
|
||||||
|
declare_pick(booth, "winner", _single())
|
||||||
|
|
||||||
|
assert MARKS_FILE.startswith(".")
|
||||||
|
assert [i.rel for i in booth_items(booth)] == ["a.png"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_marks_json_renders_as_empty(tmp_path):
|
||||||
|
"""A booth with no marks and a booth with a broken mark file both render as
|
||||||
|
'no marks'. Neither is a 500."""
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
(booth / MARKS_FILE).write_text("{not json at all")
|
||||||
|
assert marks_for(booth) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_booth_and_missing_file_are_empty(tmp_path):
|
||||||
|
assert marks_for(tmp_path / "nope") == []
|
||||||
|
(tmp_path / "b").mkdir()
|
||||||
|
assert marks_for(tmp_path / "b") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_pick_declaration_surfaces_error(tmp_path):
|
||||||
|
"""A declaration that cannot be rendered comes back with `error` set, so the
|
||||||
|
page can SAY so rather than silently hiding a question the session believes
|
||||||
|
it posted. Preserved from list_asks."""
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
(booth / MARKS_FILE).write_text(json.dumps({
|
||||||
|
"version": 1,
|
||||||
|
"marks": [{"id": "bad", "shape": "pick", "target": None,
|
||||||
|
"created": "2026-09-21T10:00:00-07:00",
|
||||||
|
"declaration": {"prompt": "no options"}, "answer": None}],
|
||||||
|
}))
|
||||||
|
m = marks_for(booth)[0]
|
||||||
|
assert m.error is not None
|
||||||
|
assert m.id == "bad"
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_pick_validates_before_writing(tmp_path):
|
||||||
|
"""A session cannot land a question the renderer would refuse."""
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
with pytest.raises(AskError):
|
||||||
|
declare_pick(booth, "bad", {"prompt": "only one", "options": ["just me"]})
|
||||||
|
assert not (booth / MARKS_FILE).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_declare_pick_rejects_a_bad_id(tmp_path):
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
with pytest.raises(AskError):
|
||||||
|
declare_pick(booth, "../escape", _single())
|
||||||
|
|
||||||
|
|
||||||
|
def test_redeclaring_a_pick_clears_its_answer(tmp_path):
|
||||||
|
"""The question changed, so the old judgment is not an answer to it."""
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
declare_pick(booth, "winner", _single())
|
||||||
|
answer_pick(booth, "winner", "A — baseline")
|
||||||
|
assert marks_for(booth)[0].answer is not None
|
||||||
|
|
||||||
|
declare_pick(booth, "winner", {"prompt": "Which one now?", "options": ["X", "Y"]})
|
||||||
|
marks = marks_for(booth)
|
||||||
|
assert len(marks) == 1
|
||||||
|
assert marks[0].answer is None
|
||||||
|
assert marks[0].prompt == "Which one now?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_marks_are_oldest_first_by_created(tmp_path):
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
declare_pick(booth, "first", _single())
|
||||||
|
declare_pick(booth, "second", _single())
|
||||||
|
assert [m.id for m in marks_for(booth)] == ["first", "second"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- openness: THE one predicate (INV-2) ------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_marks_counts_an_unanswered_pick(tmp_path):
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
declare_pick(booth, "winner", _single())
|
||||||
|
assert [m.id for m in open_marks(marks_for(booth))] == ["winner"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_marks_drops_a_complete_answer(tmp_path):
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
declare_pick(booth, "winner", _single())
|
||||||
|
answer_pick(booth, "winner", "A — baseline")
|
||||||
|
assert open_marks(marks_for(booth)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_partial_answer_counts_as_open(tmp_path):
|
||||||
|
"""SR-7, the declared behaviour change. Today the index badge tests
|
||||||
|
`answer is None` and so reports a half-answered four-question pick as
|
||||||
|
closed, while the panel renders it `◐ partial`. A booth that still owes an
|
||||||
|
answer is open — which is also what makes U4's pin rule correct."""
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
declare_pick(booth, "batch", _multi())
|
||||||
|
answer_pick(booth, "batch", {"q1": "keep"}) # q2 left alone
|
||||||
|
|
||||||
|
m = marks_for(booth)[0]
|
||||||
|
assert m.answer is not None
|
||||||
|
assert m.answer["complete"] is False
|
||||||
|
assert m.answer["unanswered"] == ["q2"]
|
||||||
|
assert [x.id for x in open_marks(marks_for(booth))] == ["batch"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the pick semantics, preserved by NOT rewriting them (INV-4) ------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_answer_is_recorded_not_refused(tmp_path):
|
||||||
|
"""Operator ruling 2026-09-09, as a regression test."""
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
declare_pick(booth, "batch", _multi())
|
||||||
|
m = answer_pick(booth, "batch", {"q1": "keep", "q2": ""})
|
||||||
|
assert m.answer["answers"]["q1"]["label"] == "keep"
|
||||||
|
assert "q2" not in m.answer["answers"]
|
||||||
|
assert m.answer["unanswered"] == ["q2"]
|
||||||
|
assert m.answer["complete"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_to_record_is_still_refused(tmp_path):
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
declare_pick(booth, "batch", _multi())
|
||||||
|
with pytest.raises(AskError):
|
||||||
|
answer_pick(booth, "batch", {"q1": "", "q2": ""})
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_offered_but_invalid_option_is_an_error(tmp_path):
|
||||||
|
"""A broken form, not a skipped question."""
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
declare_pick(booth, "winner", _single())
|
||||||
|
with pytest.raises(AskError):
|
||||||
|
answer_pick(booth, "winner", "C — never offered")
|
||||||
|
|
||||||
|
|
||||||
|
def test_answering_an_unknown_pick_raises(tmp_path):
|
||||||
|
"""SR-4: load_ask used to own this AskError. Extracting build_answer moved
|
||||||
|
the path to the caller, and a stale form POST must be a 400, not a silent
|
||||||
|
no-op."""
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
with pytest.raises(AskError):
|
||||||
|
answer_pick(booth, "ghost", "A — baseline")
|
||||||
|
|
||||||
|
|
||||||
|
def test_re_answering_overwrites(tmp_path):
|
||||||
|
"""The mark is the CURRENT judgment, not a log."""
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
declare_pick(booth, "winner", _single())
|
||||||
|
answer_pick(booth, "winner", "A — baseline")
|
||||||
|
m = answer_pick(booth, "winner", "B — async")
|
||||||
|
assert m.answer["label"] == "B — async"
|
||||||
|
assert len(marks_for(booth)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_answer_matches_the_old_sidecar_document(tmp_path):
|
||||||
|
"""INV-4. build_answer is write_answer's logic with the I/O removed; the
|
||||||
|
document it produces is the one the sidecar carried."""
|
||||||
|
ask = normalize_ask(_single(), "winner")
|
||||||
|
doc = build_answer(ask, "B — async", notes="less banding", who="10.0.0.1")
|
||||||
|
assert doc["stem"] == "winner"
|
||||||
|
assert doc["choice"] == "B — async"
|
||||||
|
assert doc["choice_index"] == 1
|
||||||
|
assert doc["label"] == "B — async"
|
||||||
|
assert doc["notes"] == "less banding"
|
||||||
|
assert doc["complete"] is True
|
||||||
|
assert doc["unanswered"] == []
|
||||||
|
assert doc["answered_by"] == "10.0.0.1"
|
||||||
|
assert "answered_at" in doc
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_answer_sources_stem_from_the_ask(tmp_path):
|
||||||
|
"""SR-3: the extraction takes a pre-loaded ask and reads `stem` off it."""
|
||||||
|
ask = normalize_ask(_multi(), "batch")
|
||||||
|
doc = build_answer(ask, {"q1": "keep", "q2": "drop"})
|
||||||
|
assert doc["stem"] == "batch"
|
||||||
|
assert doc["complete"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the JSON boundary (SR-8) -----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_as_dict_round_trips_through_json(tmp_path):
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
declare_pick(booth, "winner", _single())
|
||||||
|
m = marks_for(booth)[0]
|
||||||
|
out = json.loads(json.dumps(as_dict(m)))
|
||||||
|
assert out["id"] == "winner"
|
||||||
|
assert out["shape"] == "pick"
|
||||||
|
assert out["answer"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the stdlib-only invariant (INV-5) --------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("module", ["marks", "asks", "links"])
|
||||||
|
def test_stdlib_only(module):
|
||||||
|
"""INV-5. scripts/booth imports these under the system python3 with NO venv,
|
||||||
|
through a `python3 -c` heredoc that no AST extractor can see — so nothing
|
||||||
|
but this test stands between a casual third-party import and `booth ask`
|
||||||
|
breaking on every fleet host."""
|
||||||
|
src = pathlib.Path(__file__).parent.parent / "booth" / f"{module}.py"
|
||||||
|
tree = ast.parse(src.read_text())
|
||||||
|
roots = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
roots.update(a.name.split(".")[0] for a in node.names)
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
||||||
|
roots.add(node.module.split(".")[0])
|
||||||
|
outside = {r for r in roots if r != "booth" and r not in sys.stdlib_module_names}
|
||||||
|
assert not outside, f"booth/{module}.py imports non-stdlib: {sorted(outside)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- slice 2: flag — the shape with no existing code ------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_is_an_upsert_and_unflag_removes(tmp_path):
|
||||||
|
"""One item has at most one flag state, so flagging twice is idempotent and
|
||||||
|
clearing REMOVES the mark rather than storing `value: false` — an absent
|
||||||
|
flag and a false flag are the same judgment."""
|
||||||
|
from booth.marks import set_flag
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
m = set_flag(booth, "DSC03389.jpg", True, who="10.0.0.1")
|
||||||
|
assert m is not None and m.shape == "flag" and m.flagged is True
|
||||||
|
assert m.target == "DSC03389.jpg"
|
||||||
|
|
||||||
|
set_flag(booth, "DSC03389.jpg", True) # again
|
||||||
|
assert len(marks_for(booth)) == 1
|
||||||
|
|
||||||
|
assert set_flag(booth, "DSC03389.jpg", False) is None
|
||||||
|
assert marks_for(booth) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unflagging_something_unflagged_is_not_an_error(tmp_path):
|
||||||
|
from booth.marks import set_flag
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
assert set_flag(booth, "nope.png", False) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_flags_on_different_items_coexist(tmp_path):
|
||||||
|
from booth.marks import marks_for_target, set_flag
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
set_flag(booth, "a.png", True)
|
||||||
|
set_flag(booth, "b.png", True)
|
||||||
|
marks = marks_for(booth)
|
||||||
|
assert len(marks) == 2
|
||||||
|
assert [m.id for m in marks_for_target(marks, "a.png")] == ["flag:a.png"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_flag_never_counts_as_open(tmp_path):
|
||||||
|
"""Only a pick can owe an answer. A flag is born resolved."""
|
||||||
|
from booth.marks import set_flag
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
set_flag(booth, "a.png", True)
|
||||||
|
assert open_marks(marks_for(booth)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_flag_target_cannot_escape_the_booth(tmp_path):
|
||||||
|
from booth.marks import set_flag
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
for bad in ("../outside.png", "/etc/passwd", ""):
|
||||||
|
with pytest.raises(AskError):
|
||||||
|
set_flag(booth, bad, True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- slice 3: note ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_allows_several_per_target(tmp_path):
|
||||||
|
"""An item may carry several notes, so each gets a generated id."""
|
||||||
|
from booth.marks import marks_for_target, write_note
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
write_note(booth, "a.png", "banding in the gradient")
|
||||||
|
write_note(booth, "a.png", "and the highlight clips")
|
||||||
|
marks = marks_for(booth)
|
||||||
|
assert len(marks) == 2
|
||||||
|
assert {m.id for m in marks} == {"note-1", "note-2"}
|
||||||
|
assert [m.text for m in marks_for_target(marks, "a.png")] == [
|
||||||
|
"banding in the gradient", "and the highlight clips"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_booth_level_note_has_target_none(tmp_path):
|
||||||
|
"""The booth itself is a legal target."""
|
||||||
|
from booth.marks import marks_for_target, write_note
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
write_note(booth, None, "ship v3, not v4")
|
||||||
|
m = marks_for(booth)[0]
|
||||||
|
assert m.target is None
|
||||||
|
assert marks_for_target(marks_for(booth), None) == [m]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_note_is_refused(tmp_path):
|
||||||
|
"""Nothing to record — the same posture as an empty pick submission."""
|
||||||
|
from booth.marks import write_note
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
for blank in ("", " ", "\n\n", None):
|
||||||
|
with pytest.raises(AskError):
|
||||||
|
write_note(booth, "a.png", blank)
|
||||||
|
assert marks_for(booth) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_note_never_counts_as_open(tmp_path):
|
||||||
|
from booth.marks import write_note
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
write_note(booth, "a.png", "seen it")
|
||||||
|
assert open_marks(marks_for(booth)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_mark_is_the_operators_undo(tmp_path):
|
||||||
|
from booth.marks import delete_mark, write_note
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
write_note(booth, "a.png", "wrong call")
|
||||||
|
assert delete_mark(booth, "note-1") is True
|
||||||
|
assert marks_for(booth) == []
|
||||||
|
assert delete_mark(booth, "note-1") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the lock (INV-6) -------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_writers_do_not_lose_a_mark(tmp_path):
|
||||||
|
"""INV-6. Two processes writing the same booth's marks at once: both land.
|
||||||
|
Processes, not threads — the flock is what is under test, and a thread-level
|
||||||
|
test would pass on the GIL alone."""
|
||||||
|
import subprocess
|
||||||
|
import sys as _sys
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
repo = str(pathlib.Path(__file__).parent.parent)
|
||||||
|
prog = (
|
||||||
|
"import sys; sys.path.insert(0, %r);"
|
||||||
|
"from booth.marks import set_flag;"
|
||||||
|
"set_flag(%r, sys.argv[1], True)" % (repo, str(booth))
|
||||||
|
)
|
||||||
|
procs = [subprocess.Popen([_sys.executable, "-c", prog, f"item{i}.png"])
|
||||||
|
for i in range(8)]
|
||||||
|
for p in procs:
|
||||||
|
assert p.wait() == 0
|
||||||
|
|
||||||
|
got = {m.target for m in marks_for(booth)}
|
||||||
|
assert got == {f"item{i}.png" for i in range(8)}, f"lost a mark: {sorted(got)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- slice 4: the legacy sidecars — imported, NEVER deleted ----------------
|
||||||
|
|
||||||
|
|
||||||
|
def _sidecar(booth, stem, doc, mtime=None):
|
||||||
|
booth.mkdir(parents=True, exist_ok=True)
|
||||||
|
p = booth / f"{stem}{ASK_SUFFIX}"
|
||||||
|
p.write_text(json.dumps(doc))
|
||||||
|
if mtime is not None:
|
||||||
|
import os as _os
|
||||||
|
_os.utime(p, (mtime, mtime))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_reads_a_sidecar_into_marks(tmp_path):
|
||||||
|
from booth.marks import import_legacy_asks
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
_sidecar(booth, "winner", _single())
|
||||||
|
got = import_legacy_asks(booth)
|
||||||
|
|
||||||
|
assert [m.id for m in got] == ["winner"]
|
||||||
|
m = marks_for(booth)[0]
|
||||||
|
assert m.shape == "pick"
|
||||||
|
assert m.prompt == "Which render wins?"
|
||||||
|
assert m.answer is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_is_idempotent_and_keeps_the_sidecar(tmp_path):
|
||||||
|
"""INV-7. The ROADMAP's non-goal is explicit: no migration that deletes
|
||||||
|
anything. Four of these are live and unanswered right now."""
|
||||||
|
from booth.marks import import_legacy_asks
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
p = _sidecar(booth, "winner", _single())
|
||||||
|
import_legacy_asks(booth)
|
||||||
|
assert import_legacy_asks(booth) == [] # second run is a no-op
|
||||||
|
assert len(marks_for(booth)) == 1
|
||||||
|
assert p.is_file(), "the importer deleted the operator's sidecar"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_carries_an_existing_answer(tmp_path):
|
||||||
|
from booth.asks import ANSWER_SUFFIX, build_answer, normalize_ask
|
||||||
|
from booth.marks import import_legacy_asks
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
_sidecar(booth, "winner", _single())
|
||||||
|
# The legacy answer sidecar, written the way the retired `write_answer` did.
|
||||||
|
doc = build_answer(normalize_ask(_single(), "winner"), "B — async", notes="less banding")
|
||||||
|
(booth / f"winner{ANSWER_SUFFIX}").write_text(json.dumps(doc))
|
||||||
|
import_legacy_asks(booth)
|
||||||
|
|
||||||
|
m = marks_for(booth)[0]
|
||||||
|
assert m.answer["label"] == "B — async"
|
||||||
|
assert m.answer["notes"] == "less banding"
|
||||||
|
assert open_marks(marks_for(booth)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_does_not_clobber_a_newer_answer(tmp_path):
|
||||||
|
"""A stem already present as a mark is skipped outright."""
|
||||||
|
from booth.marks import import_legacy_asks
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
_sidecar(booth, "winner", _single())
|
||||||
|
declare_pick(booth, "winner", _single())
|
||||||
|
answer_pick(booth, "winner", "A — baseline")
|
||||||
|
|
||||||
|
assert import_legacy_asks(booth) == []
|
||||||
|
assert marks_for(booth)[0].answer["label"] == "A — baseline"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_preserves_mtime_ordering(tmp_path):
|
||||||
|
"""SR-5. list_asks ordered by file mtime; marks_for orders by the stored
|
||||||
|
`created`. Seed it from the sidecar's mtime or the four live asks silently
|
||||||
|
reorder on import."""
|
||||||
|
from booth.marks import import_legacy_asks
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
_sidecar(booth, "later", _single(), mtime=2_000_000_000)
|
||||||
|
_sidecar(booth, "earlier", _single(), mtime=1_000_000_000)
|
||||||
|
import_legacy_asks(booth)
|
||||||
|
assert [m.id for m in marks_for(booth)] == ["earlier", "later"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_surfaces_a_broken_sidecar_instead_of_dropping_it(tmp_path):
|
||||||
|
"""A question the session believes it posted stays visible."""
|
||||||
|
from booth.marks import import_legacy_asks
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
_sidecar(booth, "broke", {"prompt": "no options here"})
|
||||||
|
import_legacy_asks(booth)
|
||||||
|
m = marks_for(booth)[0]
|
||||||
|
assert m.id == "broke"
|
||||||
|
assert m.error is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_ignores_a_bad_stem(tmp_path):
|
||||||
|
"""A filename that could not have been written by `booth ask` is left alone
|
||||||
|
rather than becoming a mark with an unusable id."""
|
||||||
|
from booth.marks import import_legacy_asks
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
(booth / f"..evil{ASK_SUFFIX}").write_text(json.dumps(_single()))
|
||||||
|
assert import_legacy_asks(booth) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_on_a_booth_with_nothing_is_empty(tmp_path):
|
||||||
|
from booth.marks import import_legacy_asks
|
||||||
|
|
||||||
|
assert import_legacy_asks(tmp_path / "nope") == []
|
||||||
|
(tmp_path / "b").mkdir()
|
||||||
|
assert import_legacy_asks(tmp_path / "b") == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the deterministic-order invariant (operator directive, 2026-09-21) ------
|
||||||
|
|
||||||
|
|
||||||
|
def test_marks_order_is_deterministic_across_reads(tmp_path):
|
||||||
|
"""Every ordered collection the Booth renders needs a STATED rule, because
|
||||||
|
the operator judges positionally — "the third one", "the one after the
|
||||||
|
banded one" — and an order that moves between renders misfiles the judgment
|
||||||
|
instead of crashing. Marks order by `(created, id)`."""
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
declare_pick(booth, "b-pick", _single())
|
||||||
|
from booth.marks import set_flag, write_note
|
||||||
|
write_note(booth, "z.png", "later note")
|
||||||
|
set_flag(booth, "a.png", True)
|
||||||
|
write_note(booth, None, "booth note")
|
||||||
|
|
||||||
|
first = [m.id for m in marks_for(booth)]
|
||||||
|
for _ in range(5):
|
||||||
|
assert [m.id for m in marks_for(booth)] == first
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_second_marks_tie_break_on_id(tmp_path):
|
||||||
|
"""The `created` stamp is second-resolution, so two marks written inside one
|
||||||
|
second would order by whatever json listed them. The id is the tie-break."""
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
stamp = "2026-09-21T12:00:00-07:00"
|
||||||
|
(booth / MARKS_FILE).write_text(json.dumps({"version": 1, "marks": [
|
||||||
|
{"id": "zeta", "shape": "note", "target": None, "created": stamp, "text": "z"},
|
||||||
|
{"id": "alpha", "shape": "note", "target": None, "created": stamp, "text": "a"},
|
||||||
|
]}))
|
||||||
|
assert [m.id for m in marks_for(booth)] == ["alpha", "zeta"]
|
||||||
|
# and reversing the stored order changes nothing
|
||||||
|
(booth / MARKS_FILE).write_text(json.dumps({"version": 1, "marks": [
|
||||||
|
{"id": "alpha", "shape": "note", "target": None, "created": stamp, "text": "a"},
|
||||||
|
{"id": "zeta", "shape": "note", "target": None, "created": stamp, "text": "z"},
|
||||||
|
]}))
|
||||||
|
assert [m.id for m in marks_for(booth)] == ["alpha", "zeta"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_marks_preserves_the_read_order(tmp_path):
|
||||||
|
"""The filtered view must not re-order — the panel and the badge have to
|
||||||
|
agree with the list they came from."""
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
for mid in ("c", "a", "b"):
|
||||||
|
declare_pick(booth, mid, _single())
|
||||||
|
marks = marks_for(booth)
|
||||||
|
assert [m.id for m in open_marks(marks)] == [m.id for m in marks]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- INV-3: the judgment travels, like the caption --------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _png(p):
|
||||||
|
p.write_bytes(
|
||||||
|
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||||
|
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00"
|
||||||
|
b"\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from booth.app import create_app
|
||||||
|
return TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False)), tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_zoom_carries_the_marks(client):
|
||||||
|
"""U1's rule, extended from the caption to the judgment: full size is where
|
||||||
|
the operator is actually deciding, so the flag state and the notes have to
|
||||||
|
be there."""
|
||||||
|
from booth.marks import set_flag, write_note
|
||||||
|
|
||||||
|
c, data = client
|
||||||
|
b = data / "b"
|
||||||
|
b.mkdir()
|
||||||
|
_png(b / "a.png")
|
||||||
|
set_flag(b, "a.png", True)
|
||||||
|
write_note(b, "a.png", "banding in the gradient")
|
||||||
|
|
||||||
|
html = c.get("/b/b/view?f=a.png").text
|
||||||
|
assert "banding in the gradient" in html
|
||||||
|
assert "✔ flagged" in html
|
||||||
|
assert 'action="/b/b/flag"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_tile_carries_the_marks(client):
|
||||||
|
from booth.marks import set_flag, write_note
|
||||||
|
|
||||||
|
c, data = client
|
||||||
|
b = data / "b"
|
||||||
|
b.mkdir()
|
||||||
|
_png(b / "a.png")
|
||||||
|
_png(b / "b.png")
|
||||||
|
set_flag(b, "a.png", True)
|
||||||
|
write_note(b, "b.png", "this one is soft")
|
||||||
|
|
||||||
|
html = c.get("/b/b/").text
|
||||||
|
assert "✔ flagged" in html and "○ flag" in html # a flagged one and an unflagged one
|
||||||
|
assert "this one is soft" in html
|
||||||
|
assert 'id="item-a.png"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_and_note_round_trip_through_the_browser(client):
|
||||||
|
c, data = client
|
||||||
|
b = data / "b"
|
||||||
|
b.mkdir()
|
||||||
|
_png(b / "a.png")
|
||||||
|
|
||||||
|
assert c.post("/b/b/flag", data={"target": "a.png", "on": "1"},
|
||||||
|
follow_redirects=False).status_code == 303
|
||||||
|
assert c.post("/b/b/note", data={"target": "a.png", "text": "soft"},
|
||||||
|
follow_redirects=False).status_code == 303
|
||||||
|
doc = c.get("/b/b/marks.json").json()
|
||||||
|
assert {m["shape"] for m in doc["marks"]} == {"flag", "note"}
|
||||||
|
assert doc["open"] == [] # neither owes an answer
|
||||||
|
|
||||||
|
# and the operator can withdraw one
|
||||||
|
note_id = next(m["id"] for m in doc["marks"] if m["shape"] == "note")
|
||||||
|
assert c.post("/b/b/unmark", data={"mark": note_id},
|
||||||
|
follow_redirects=False).status_code == 303
|
||||||
|
assert [m["shape"] for m in c.get("/b/b/marks.json").json()["marks"]] == ["flag"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_no_op_write_does_not_touch_the_booth(tmp_path):
|
||||||
|
"""A booth's TTL is measured from its newest mtime, INCLUDING dotfiles — so
|
||||||
|
writing `.marks.json` resets the clock. Marking IS activity and should; a
|
||||||
|
write that changes nothing is not activity and must not. Unflagging
|
||||||
|
something never flagged would otherwise keep a dead booth alive."""
|
||||||
|
from booth.marks import delete_mark, set_flag
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
assert set_flag(booth, "ghost.png", False) is None
|
||||||
|
assert delete_mark(booth, "nothing") is False
|
||||||
|
assert not (booth / MARKS_FILE).exists(), "a no-op write created the mark file"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_real_write_then_a_no_op_leaves_the_file_alone(tmp_path):
|
||||||
|
import os
|
||||||
|
from booth.marks import set_flag
|
||||||
|
|
||||||
|
booth = tmp_path / "b"
|
||||||
|
booth.mkdir()
|
||||||
|
set_flag(booth, "a.png", True)
|
||||||
|
path = booth / MARKS_FILE
|
||||||
|
os.utime(path, (1_000_000_000, 1_000_000_000))
|
||||||
|
before = path.stat().st_mtime
|
||||||
|
|
||||||
|
set_flag(booth, "a.png", True) # idempotent: already flagged
|
||||||
|
assert path.stat().st_mtime == before, "an idempotent flag rewrote the file"
|
||||||
Reference in New Issue
Block a user