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") bool, typer.Option("--web", help="Serve the review UI on localhost")
] = False, ] = False,
port: Annotated[int, typer.Option("--port", help="Port for --web")] = 8377, 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, config: ConfigOpt = None,
) -> None: ) -> None:
"""Stage 3: human review of ambiguous/unmatched items.""" """Stage 3: human review of ambiguous/unmatched items."""
@@ -64,7 +67,7 @@ def review(
if web: if web:
from bggpipe.webreview import run_web_review 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: else:
from bggpipe.review import run_review from bggpipe.review import run_review
+27 -3
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import json import json
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path
import httpx import httpx
from rich.console import Console from rich.console import Console
@@ -57,15 +58,37 @@ class ReviewSession:
self.console = console or Console() self.console = console or Console()
self.input_fn = input_fn or (lambda prompt: self.console.input(prompt)) 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.client = client or BGGClient(cfg.cache_dir, cfg.rate_limit_seconds)
self.rows = read_matches(cfg.matches_path)
self.decisions = 0 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: try:
self.titles = load_titles(cfg.titles_path) self.titles = load_titles(self.cfg.titles_path)
except FileNotFoundError: except FileNotFoundError:
self.titles = [] self.titles = []
self._titles = {e.title_raw: e for e in 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: def _ask(self, prompt: str) -> str:
try: try:
@@ -78,6 +101,7 @@ class ReviewSession:
def _save(self) -> None: def _save(self) -> None:
write_matches(self.cfg.matches_path, self.rows) write_matches(self.cfg.matches_path, self.rows)
self._loaded_mtimes = self._data_mtimes() # own writes aren't "changes"
self.decisions += 1 self.decisions += 1
def _apply_choice(self, row: dict, candidate: dict) -> None: def _apply_choice(self, row: dict, candidate: dict) -> None:
+17
View File
@@ -520,6 +520,23 @@ document.addEventListener("keydown", e => {
}); });
refresh(); 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> </script>
</body> </body>
</html> </html>
+44 -3
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import io import io
import json import json
import os
from importlib import resources from importlib import resources
from pathlib import Path from pathlib import Path
@@ -113,7 +114,15 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI:
thumbnails = load_thumbnails(cfg.cache_dir) thumbnails = load_thumbnails(cfg.cache_dir)
dismissed = DismissStore(cfg.data_dir / "unidentified_dismissed.json") 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: def find_row(title_raw: str, source_photos: str) -> dict:
freshen()
for row in session.rows: for row in session.rows:
if row["title_raw"] == title_raw and row["source_photos"] == source_photos: if row["title_raw"] == title_raw and row["source_photos"] == source_photos:
return row return row
@@ -154,6 +163,7 @@ def create_app(cfg: Config, *, client: BGGClient | None = None) -> FastAPI:
} }
def state() -> dict: def state() -> dict:
freshen()
counts: dict[str, int] = {} counts: dict[str, int] = {}
for row in session.rows: for row in session.rows:
counts[row["match_status"]] = counts.get(row["match_status"], 0) + 1 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 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 import uvicorn
app = create_app(cfg)
typer.echo( typer.echo(
f"Review UI: http://127.0.0.1:{port}/ (localhost only; every " f"Review UI: http://127.0.0.1:{port}/ (localhost only; every "
"decision saves to matches.csv immediately — Ctrl-C anytime)" "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/favicon.png").status_code == 200
assert web.get("/static/nope.js").status_code == 404 assert web.get("/static/nope.js").status_code == 404
assert web.get("/static/..%2Ftemplates%2Freview.html").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)