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")
|
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
@@ -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:
|
try:
|
||||||
self.titles = load_titles(cfg.titles_path)
|
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(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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user