Review web UI: live-follow data files, --dev code reload

ReviewSession re-reads matches.csv/titles.json on mtime change so
external extract/resolve runs show up per-request (and stale in-memory
rows can no longer overwrite them); the page polls state every 3s,
re-rendering only on change and never mid-typing. --web --dev adds
uvicorn source-watch restarts, scoped to the package dir so decision
writes to data/ don't trigger them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-02 13:25:46 -04:00
parent 96637cfa7f
commit 02a7ac262c
5 changed files with 141 additions and 7 deletions
+4 -1
View File
@@ -57,6 +57,9 @@ def review(
bool, typer.Option("--web", help="Serve the review UI on localhost")
] = False,
port: Annotated[int, typer.Option("--port", help="Port for --web")] = 8377,
dev: Annotated[
bool, typer.Option("--dev", help="With --web: restart on source changes")
] = False,
config: ConfigOpt = None,
) -> None:
"""Stage 3: human review of ambiguous/unmatched items."""
@@ -64,7 +67,7 @@ def review(
if web:
from bggpipe.webreview import run_web_review
run_web_review(cfg, port=port)
run_web_review(cfg, port=port, dev=dev, config_path=config)
else:
from bggpipe.review import run_review
+27 -3
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import json
from collections.abc import Callable
from pathlib import Path
import httpx
from rich.console import Console
@@ -57,15 +58,37 @@ class ReviewSession:
self.console = console or Console()
self.input_fn = input_fn or (lambda prompt: self.console.input(prompt))
self.client = client or BGGClient(cfg.cache_dir, cfg.rate_limit_seconds)
self.rows = read_matches(cfg.matches_path)
self.decisions = 0
self._load()
# -- plumbing -------------------------------------------------------
def _data_mtimes(self) -> tuple[int | None, int | None]:
def mtime(path: Path) -> int | None:
try:
return path.stat().st_mtime_ns
except FileNotFoundError:
return None
return (mtime(self.cfg.matches_path), mtime(self.cfg.titles_path))
def _load(self) -> None:
self.rows = read_matches(self.cfg.matches_path)
try:
self.titles = load_titles(cfg.titles_path)
self.titles = load_titles(self.cfg.titles_path)
except FileNotFoundError:
self.titles = []
self._titles = {e.title_raw: e for e in self.titles}
self._loaded_mtimes = self._data_mtimes()
# -- plumbing -------------------------------------------------------
def reload_if_changed(self) -> bool:
"""Re-read matches.csv/titles.json when another process (extract,
resolve, a git pull) rewrote them, so the web UI never serves — or
saves decisions over — stale rows. Returns True when it reloaded."""
if self._data_mtimes() == self._loaded_mtimes:
return False
self._load()
return True
def _ask(self, prompt: str) -> str:
try:
@@ -78,6 +101,7 @@ class ReviewSession:
def _save(self) -> None:
write_matches(self.cfg.matches_path, self.rows)
self._loaded_mtimes = self._data_mtimes() # own writes aren't "changes"
self.decisions += 1
def _apply_choice(self, row: dict, candidate: dict) -> None:
+17
View File
@@ -520,6 +520,23 @@ document.addEventListener("keydown", e => {
});
refresh();
// Live-follow the data files: extract/resolve runs in another terminal show
// up on the next poll. Only re-render on an actual change (keeps the
// keyboard cursor stable) and never mid-typing in a manual-ID input.
setInterval(async () => {
const el = document.activeElement;
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) return;
try {
const fresh = await (await fetch("/api/state")).json();
if (JSON.stringify(fresh) !== JSON.stringify(STATE)) {
STATE = fresh;
render();
}
} catch {
/* server restarting (--dev) — retry on the next tick */
}
}, 3000);
</script>
</body>
</html>
+44 -3
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import io
import json
import os
from importlib import resources
from pathlib import Path
@@ -113,7 +114,15 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI:
thumbnails = load_thumbnails(cfg.cache_dir)
dismissed = DismissStore(cfg.data_dir / "unidentified_dismissed.json")
def freshen() -> None:
"""Serve every request from the current file state: an extract or
resolve run in another terminal must show up without a restart."""
nonlocal thumbnails
if session.reload_if_changed():
thumbnails = load_thumbnails(cfg.cache_dir)
def find_row(title_raw: str, source_photos: str) -> dict:
freshen()
for row in session.rows:
if row["title_raw"] == title_raw and row["source_photos"] == source_photos:
return row
@@ -154,6 +163,7 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI:
}
def state() -> dict:
freshen()
counts: dict[str, int] = {}
for row in session.rows:
counts[row["match_status"]] = counts.get(row["match_status"], 0) + 1
@@ -303,12 +313,43 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI:
return app
def run_web_review(cfg: Config, *, port: int = DEFAULT_PORT) -> None:
def _dev_app() -> FastAPI:
"""uvicorn factory for --dev code reload (config path via env var,
since uvicorn re-imports this module in a fresh process)."""
from bggpipe.config import load_config
path = os.environ.get("BGGPIPE_CONFIG") or None
return create_app(load_config(Path(path) if path else None))
def run_web_review(
cfg: Config,
*,
port: int = DEFAULT_PORT,
dev: bool = False,
config_path: Path | None = None,
) -> None:
import uvicorn
app = create_app(cfg)
typer.echo(
f"Review UI: http://127.0.0.1:{port}/ (localhost only; every "
"decision saves to matches.csv immediately — Ctrl-C anytime)"
)
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
if dev:
# Code hot-reload. Watch only the package source: uvicorn's default
# (cwd, recursive) would restart the server on every matches.csv
# write — i.e. on every review decision.
if config_path:
os.environ["BGGPIPE_CONFIG"] = str(config_path)
typer.echo(" --dev: restarting on source changes")
uvicorn.run(
"bggpipe.webreview:_dev_app",
factory=True,
reload=True,
reload_dirs=[str(Path(__file__).parent)],
host="127.0.0.1",
port=port,
log_level="warning",
)
else:
uvicorn.run(create_app(cfg), host="127.0.0.1", port=port, log_level="warning")
+49
View File
@@ -334,3 +334,52 @@ def test_static_assets_served_with_allowlist(tmp_path):
assert web.get("/static/favicon.png").status_code == 200
assert web.get("/static/nope.js").status_code == 404
assert web.get("/static/..%2Ftemplates%2Freview.html").status_code == 404
# -- live data reload ---------------------------------------------------
def test_state_picks_up_external_matches_rewrite(tmp_path):
web, cfg = make_client(tmp_path)
assert web.get("/api/state").json()["summary"]["total"] == 3
rows = read_matches(cfg.matches_path)
rows.append(_row(title_raw="Newcomer", match_status="unmatched"))
write_matches(cfg.matches_path, rows)
fresh = web.get("/api/state").json()
assert fresh["summary"]["total"] == 4
assert any(p["title_raw"] == "Newcomer" for p in fresh["pending"])
def test_decision_lands_on_externally_added_row(tmp_path):
# A row that did not exist at server start is still reviewable.
web, cfg = make_client(tmp_path)
rows = read_matches(cfg.matches_path)
rows.append(_row(title_raw="Newcomer", match_status="unmatched"))
write_matches(cfg.matches_path, rows)
res = web.post(
"/api/decision",
json={
"title_raw": "Newcomer",
"source_photos": "shelf.jpg",
"action": "reject",
},
)
assert res.status_code == 200
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
assert saved["Newcomer"]["match_status"] == "rejected"
def test_own_saves_do_not_count_as_external_changes(tmp_path):
from bggpipe.review import ReviewSession
cfg = make_cfg(tmp_path)
session = ReviewSession(cfg, client=unauthorized_client(tmp_path))
row = next(r for r in session.rows if r["title_raw"] == "Mystery")
session.decide_reject(row)
assert session.reload_if_changed() is False # my own write
write_matches(cfg.matches_path, session.rows + [_row(title_raw="X")])
assert session.reload_if_changed() is True # someone else's
assert any(r["title_raw"] == "X" for r in session.rows)