Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions tests/unit/test_verifier_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,223 @@ async def test_save_writes_are_atomic_no_tmp_left_behind(serve_app, tmp_path: Pa
assert tmp_files == [], f"unexpected tmp files left: {tmp_files}"


async def test_save_writes_status_draft_by_default(serve_app, tmp_path: Path) -> None:
"""A Save with no `status` field writes corrections.json with
`status: "draft"` — the default for a partial / in-progress page."""
async with await _client(serve_app.app) as c:
r = await c.post(
"/api/save",
json={
"stem": "draft-default",
"verified": _page_result_dict(),
"corrections": _corrections_dict(),
},
)
assert r.status_code == 200
assert r.json()["status"] == "draft"
on_disk = json.loads(
(tmp_path / "data" / "verifier" / "draft-default.corrections.json").read_text()
)
assert on_disk["status"] == "draft"


async def test_save_writes_status_complete_when_requested(serve_app, tmp_path: Path) -> None:
"""An explicit `status: "complete"` from the UI's Mark complete button
persists as `"complete"`."""
async with await _client(serve_app.app) as c:
r = await c.post(
"/api/save",
json={
"stem": "mark-done",
"status": "complete",
"verified": _page_result_dict(),
"corrections": _corrections_dict(),
},
)
assert r.status_code == 200
assert r.json()["status"] == "complete"


async def test_save_preserves_complete_on_subsequent_draft_save(serve_app, tmp_path: Path) -> None:
"""Once a page is `complete`, a subsequent plain Save (default
`draft` or omitted status) does NOT downgrade it. Refining details
on a completed page is a tweak-in-place, not a status change."""
body_draft = {
"stem": "preserve",
"verified": _page_result_dict(),
"corrections": _corrections_dict(),
}
body_complete = {**body_draft, "status": "complete"}
async with await _client(serve_app.app) as c:
await c.post("/api/save", json=body_complete)
# Now save again with no status — should stay complete.
r = await c.post("/api/save", json=body_draft)
assert r.status_code == 200
assert r.json()["status"] == "complete"
on_disk = json.loads((tmp_path / "data" / "verifier" / "preserve.corrections.json").read_text())
assert on_disk["status"] == "complete"


async def test_save_rejects_invalid_status(serve_app, tmp_path: Path) -> None:
"""Unknown status values are rejected — no silent fallback."""
async with await _client(serve_app.app) as c:
r = await c.post(
"/api/save",
json={
"stem": "bad-status",
"status": "in-progress", # not a valid value
"verified": _page_result_dict(),
"corrections": _corrections_dict(),
},
)
assert r.status_code == 400
assert "status" in r.json()["detail"]


# -- /api/bundles ----------------------------------------------------------


def _write_bundle(verifier_dir: Path, stem: str, page_date_raw: str | None) -> None:
"""Drop a minimal bundle.json under the verifier directory for the
/api/bundles enumeration tests."""
verifier_dir.mkdir(parents=True, exist_ok=True)
(verifier_dir / f"{stem}.bundle.json").write_text(
json.dumps(
{
"schema_version": 2,
"stem": stem,
"image_path": f"../tests/golden/{stem}.png",
"pdf_path": None,
"page_number": None,
"model_version": "test",
"extracted_at": "2026-05-12T00:00:00Z",
"page_date_raw": page_date_raw,
"comments_raw": None,
"oddities": [],
"quadrants": [],
}
)
)


async def test_list_bundles_empty_when_no_dir(serve_app, tmp_path: Path) -> None:
"""No data/verifier/ directory → empty bundle list, not a 500."""
async with await _client(serve_app.app) as c:
r = await c.get("/api/bundles")
assert r.status_code == 200
assert r.json() == {"bundles": []}


async def test_list_bundles_classifies_three_states(serve_app, tmp_path: Path) -> None:
"""Three bundles → three states: incomplete (no corrections file),
partial (corrections with status=draft), complete (corrections with
status=complete). Sorted alphabetically by stem."""
verifier_dir = tmp_path / "data" / "verifier"
_write_bundle(verifier_dir, "a-untouched", "A")
_write_bundle(verifier_dir, "b-draft", "B")
_write_bundle(verifier_dir, "c-complete", "C")
(verifier_dir / "b-draft.corrections.json").write_text(json.dumps({"status": "draft"}))
(verifier_dir / "c-complete.corrections.json").write_text(json.dumps({"status": "complete"}))

async with await _client(serve_app.app) as c:
r = await c.get("/api/bundles")
bundles = r.json()["bundles"]
assert [b["stem"] for b in bundles] == ["a-untouched", "b-draft", "c-complete"]
assert [b["status"] for b in bundles] == ["incomplete", "partial", "complete"]
assert [b["page_date_raw"] for b in bundles] == ["A", "B", "C"]
assert bundles[0]["url"] == "/verifier/?bundle=/data/verifier/a-untouched.bundle.json"


async def test_list_bundles_legacy_corrections_without_status_is_partial(
serve_app, tmp_path: Path
) -> None:
"""A corrections.json from before status tracking landed (no `status`
field) is classified as `partial` — they were saved, just not done."""
verifier_dir = tmp_path / "data" / "verifier"
_write_bundle(verifier_dir, "legacy", None)
(verifier_dir / "legacy.corrections.json").write_text(json.dumps({"row_corrections": []}))
async with await _client(serve_app.app) as c:
r = await c.get("/api/bundles")
assert r.json()["bundles"][0]["status"] == "partial"


async def test_list_bundles_surfaces_verified_at_timestamp(serve_app, tmp_path: Path) -> None:
"""`verified_at` reflects when the last Save / Mark-complete fired.
Sourced from the verified.json mtime so the same /api/save flow keeps
it accurate."""
verifier_dir = tmp_path / "data" / "verifier"
_write_bundle(verifier_dir, "stamped", None)
(verifier_dir / "stamped.corrections.json").write_text(json.dumps({"status": "draft"}))
(verifier_dir / "stamped.verified.json").write_text("{}")

async with await _client(serve_app.app) as c:
r = await c.get("/api/bundles")
bundle = r.json()["bundles"][0]
assert bundle["verified_at"] is not None
# ISO format with timezone.
assert "T" in bundle["verified_at"]


async def test_save_then_bundles_reflects_status_round_trip(serve_app, tmp_path: Path) -> None:
"""End-to-end: save with status=complete, then /api/bundles classifies
that bundle as complete; save another with default status, listed as
partial; un-saved bundle stays incomplete. Closes the integration gap
between the save path and the listing path (they read/write the same
corrections.json from different code paths)."""
verifier_dir = tmp_path / "data" / "verifier"
_write_bundle(verifier_dir, "a-incomplete", "A")
_write_bundle(verifier_dir, "b-partial", "B")
_write_bundle(verifier_dir, "c-complete", "C")

async with await _client(serve_app.app) as c:
# Save as draft.
r = await c.post(
"/api/save",
json={
"stem": "b-partial",
"verified": _page_result_dict(),
"corrections": _corrections_dict(),
},
)
assert r.json()["status"] == "draft"
# Save as complete.
r = await c.post(
"/api/save",
json={
"stem": "c-complete",
"status": "complete",
"verified": _page_result_dict(),
"corrections": _corrections_dict(),
},
)
assert r.json()["status"] == "complete"
# Now ask /api/bundles to classify all three.
r = await c.get("/api/bundles")
by_stem = {b["stem"]: b["status"] for b in r.json()["bundles"]}
assert by_stem == {
"a-incomplete": "incomplete",
"b-partial": "partial",
"c-complete": "complete",
}


async def test_list_bundles_malformed_bundle_doesnt_break_index(serve_app, tmp_path: Path) -> None:
"""If one bundle.json is corrupted, the index still lists it (so the
user can spot the problem) but with null metadata."""
verifier_dir = tmp_path / "data" / "verifier"
verifier_dir.mkdir(parents=True)
(verifier_dir / "broken.bundle.json").write_text("not json {{ \\")
_write_bundle(verifier_dir, "good", "ok")
async with await _client(serve_app.app) as c:
r = await c.get("/api/bundles")
bundles = r.json()["bundles"]
by_stem = {b["stem"]: b for b in bundles}
assert "broken" in by_stem
assert by_stem["broken"]["page_date_raw"] is None
assert by_stem["broken"]["status"] == "incomplete"
assert by_stem["good"]["page_date_raw"] == "ok"


async def test_save_skips_db_when_no_jobs_db_file(serve_app, tmp_path: Path) -> None:
"""If `data/jobs.db` doesn't exist (no pipeline has run), Save still
succeeds — no DB integration is attempted."""
Expand Down
52 changes: 41 additions & 11 deletions verifier/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

A static, dependency-free single-page app for manually verifying flowsheet extraction output. Each row's cropped image strip is shown next to the model-detected text in an editable field. Hand-correct typos, mark hallucinated rows, add missed rows, then export a `verified.json` that flows back into the pipeline as ground truth.

> **Local-dev tool.** `verifier/serve.py` binds to `127.0.0.1` and has no authentication or CSRF protection on `/api/save` or `/api/bundles`. Do not expose it on a non-loopback interface; in particular, do not run it from a tmux/ssh session forwarded to a shared host without adding auth in front. The bundle-stem path-traversal guard is the only sanitization on writes.

## Run

The verifier ships with a tiny FastAPI server that does two things:
Expand All @@ -14,15 +16,15 @@ The verifier ships with a tiny FastAPI server that does two things:
.venv/bin/python verifier/serve.py
# default port is 8765; override with VERIFIER_PORT=9000 .venv/bin/python verifier/serve.py

# then open in a browser:
open "http://localhost:8765/verifier/?bundle=/data/verifier/<stem>.bundle.json"
# then open the index:
open "http://localhost:8765/verifier/"
```

If you want only the static side and don't need the artist-lookup button, `python -m http.server 8765` from the repo root still works — the Check-artists button will return 404s but everything else functions.
The index page lists every bundle in `data/verifier/` with its verification state and an **Open next page that needs work** button. Click a row to open it. The status badge on each row mirrors the same state machine as the in-edit pill: `incomplete` (no save yet), `partial` (saved as draft), `complete` (marked complete).

The `?bundle=...` URL param is the recommended path: the UI fetches the bundle, then resolves the bundle's `image_path` (relative path inside the JSON) and fetches the image too.
The `?bundle=<path>` URL is still the way to deep-link a specific page (e.g., bookmarks, share links). Edit-mode navigation also exposes Prev / Next buttons and the keyboard shortcuts (`?` to see all).

You can also load a bundle via the **Load bundle** file picker, in which case a second **Load image** picker appears. This path works without a server but you must pick both files manually.
You can also load a bundle via the **Load image** file picker if the page is served statically and the relative image path can't be fetched.

## File layout

Expand Down Expand Up @@ -104,15 +106,25 @@ tests/golden/<stem>.truth.json # derive_truth output (optional destinat

## Saving

Clicking **Save** POSTs the current edit state to the server's `/api/save` endpoint, which:
Two buttons share the right side of the header: **Save** and **Mark complete**. Both POST to `/api/save`. The only difference is the `status` field in the body — `Save` omits it (treated as `draft`), `Mark complete` sends `"complete"`.

Status semantics:

- **Incomplete** — no `<stem>.corrections.json` on disk. The bundle has never been saved.
- **Partial** — `corrections.json` exists with `"status": "draft"`. The user is in progress.
- **Complete** — `corrections.json` has `"status": "complete"`. The user explicitly marked the page done.

The server runs a small preservation rule so a plain Save on an already-complete page **does not** downgrade it. The user can refine details on a complete page without re-marking it. (If we ever need a "Revert to draft" affordance, that's a separate ticket.)

Save's three side effects:

1. Writes `data/verifier/<stem>.verified.json` — `PageResult`-shaped JSON validating against `core.schema.PageResult`. Bundle-only fields (`schema_version`, `stem`, `image_path`, `pdf_path`, `page_number`, per-entry `row_bbox`) are stripped before validation. Rows marked ✗ are excluded. Rows added via **+ add row** are included.
2. Writes `data/verifier/<stem>.corrections.json` — the delta between the loaded bundle and the verified state (shape below).
3. If the bundle has a non-null `pdf_path` + `page_number` (production-pipeline pages do; test fixtures don't), updates the matching `jobs.db` row via `JobStore.mark_verified` — setting `verified_at`, `verified_path`, and `corrections_path`.
1. Writes `data/verifier/<stem>.verified.json` — `PageResult`-shaped JSON validating against `core.schema.PageResult`. Bundle-only fields are stripped before validation. Rows marked ✗ are excluded; rows added via **+ add row** are included.
2. Writes `data/verifier/<stem>.corrections.json` — the delta between the loaded bundle and the verified state, plus a top-level `"status"` field.
3. If the bundle has a non-null `pdf_path` + `page_number`, updates the matching `jobs.db` row via `JobStore.mark_verified`.

The status bar reports the destination files and whether `jobs.db` was updated:
The status bar reports the destination files, status, and whether `jobs.db` was updated:

> Saved data/verifier/X.verified.json + data/verifier/X.corrections.json · 4 field correction(s), 0 added, 0 deleted · jobs.db updated.
> Saved as complete · data/verifier/X.verified.json + data/verifier/X.corrections.json · 4 field correction(s), 0 added, 0 deleted · jobs.db updated.

If you'd rather have a downloadable file, open the saved JSON from `data/verifier/` directly.

Expand Down Expand Up @@ -166,6 +178,24 @@ Badge states:

The lookup goes through request-o-matic's LLM-driven request parser (artist normalization, fuzzy matching) before hitting the LML library search. The badge reflects request-o-matic's `library_results` and `artwork` fields — not LML's `/api/v1/lookup` directly, since the LLM correction layer is the load-bearing piece.

## Keyboard shortcuts

Press `?` anywhere in the editor to see the overlay. The current set:

| Key | Action |
|---|---|
| ⌘S / Ctrl+S | Save (draft) |
| ⌘⇧S / Ctrl+Shift+S | Mark complete |
| j / ⌘↓ | Focus next row's `raw_text` |
| k / ⌘↑ | Focus previous row |
| ⌘D / Ctrl+D | Toggle ✗ (delete) on focused row |
| n | Next bundle |
| p | Previous bundle |
| ? | Toggle shortcut overlay |
| Esc | Close overlay |

The single-letter keys (`j`, `k`, `n`, `p`, `?`) are ignored when the keyboard focus is in an `<input>`, `<textarea>`, or `<select>` — so typing in a row's text field works normally.

## Known rough edges (v1)

- **No autosave / localStorage.** Close the tab and unsaved edits are lost. Export before navigating away.
Expand Down
Loading
Loading