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:
+4
-1
@@ -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
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user