From 3b3878ada19ccb1a4fbe167566a297acb96bcdd6 Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 3 Jul 2026 22:54:54 +0000 Subject: [PATCH] feat: /players browser + identity review queue UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4. A Players page that browses the whole villain file (named + nameless, expandable to episodic recall — reads, notable hands, stats, descriptors) and, at the top, the identity-resolution queue: possible-merges (same/different/dismiss) and needs-clarification tasks. Rename a nameless villain, set category, or run a dupe scan inline. - poker.players_overview() for the list. - routes: /players (page), /players/data, /player/{id}/data, POST /player/{id} (rename/retag), POST /identity/{id}/resolve, POST /players/scan. - nav: 👤 Players. Full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/poker.py | 13 +++ lyra/web/server.py | 39 ++++++++ lyra/web/static/nav.js | 1 + lyra/web/static/players.html | 166 +++++++++++++++++++++++++++++++++++ 4 files changed, 219 insertions(+) create mode 100644 lyra/web/static/players.html diff --git a/lyra/poker.py b/lyra/poker.py index dd345a8..917e575 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -1523,6 +1523,19 @@ def get_villain_file(name: str | None = None, venue: str | None = None) -> list[ return [dict(r) for r in _c().execute(sql, params).fetchall()] +def players_overview() -> list[dict]: + """All villains for the browser: identity + observation count + last seen, + named players first, then most-recently-updated.""" + rows = _c().execute( + "SELECT p.id, p.name, p.named, p.venue, p.category, p.descriptors, " + "p.tendencies, p.adjustment, p.updated_at, " + "(SELECT COUNT(*) FROM player_observations o WHERE o.player_id = p.id) AS obs, " + "(SELECT COUNT(*) FROM player_reads r WHERE r.player_id = p.id) AS reads " + "FROM poker_players p ORDER BY p.named DESC, p.updated_at DESC" + ).fetchall() + return [dict(r) for r in rows] + + def villain_recall(player_id: int) -> dict | None: """Episodic recall for one villain: who, how often/where seen, last seen, the notable hands against him (with ids to link), reads, and stats. This is the diff --git a/lyra/web/server.py b/lyra/web/server.py index 7af398b..d3d892c 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -436,6 +436,45 @@ def create_app() -> FastAPI: async def hands_data(limit: int = 60) -> dict: return {"hands": poker.list_recent_hands(limit=limit)} + @app.get("/players") + async def players_page() -> FileResponse: + """Villain file browser + the identity-resolution review queue.""" + return FileResponse(str(_STATIC / "players.html")) + + @app.get("/players/data") + async def players_data() -> dict: + return {"players": poker.players_overview(), + "queue": poker.list_identity_queue()} + + @app.get("/player/{player_id}/data") + async def player_data(player_id: int) -> dict: + return poker.villain_recall(player_id) or {} + + @app.post("/player/{player_id}") + async def player_update(player_id: int, request: Request) -> dict: + body = await request.json() + fields = {k: body[k] for k in ("name", "venue", "category", "tendencies", + "adjustment", "description") if body.get(k) is not None} + if body.get("name"): # naming via the browser flips it to a real handle + poker.name_villain(player_id, body["name"]) + fields.pop("name", None) + row = poker.update_player(player_id, **fields) if fields else None + return {"ok": True, "player": row} + + @app.post("/identity/{task_id}/resolve") + async def identity_resolve(task_id: int, request: Request) -> dict: + body = await request.json() + action = body.get("action") or "dismiss" + kw = {k: v for k, v in body.items() if k != "action"} + ok = await asyncio.to_thread(poker.resolve_identity_task, task_id, action, **kw) + logbus.log("info", "identity task resolved", id=task_id, action=action) + return {"ok": ok} + + @app.post("/players/scan") + async def players_scan() -> dict: + filed = await asyncio.to_thread(poker.scan_merge_candidates) + return {"ok": True, "filed": filed} + @app.get("/recap/{session_id}") async def recap_page() -> FileResponse: return FileResponse(str(_STATIC / "recap.html")) diff --git a/lyra/web/static/nav.js b/lyra/web/static/nav.js index 349b7e9..282836a 100644 --- a/lyra/web/static/nav.js +++ b/lyra/web/static/nav.js @@ -8,6 +8,7 @@ { href: "/session", icon: "♠", label: "Session" }, { href: "/history", icon: "📚", label: "History" }, { href: "/hands", icon: "🃏", label: "Hands" }, + { href: "/players", icon: "👤", label: "Players" }, { href: "/self", icon: "🧠", label: "Mind" }, { href: "/thoughts", icon: "💭", label: "Thoughts" }, { href: "/journal", icon: "📔", label: "Journal" }, diff --git a/lyra/web/static/players.html b/lyra/web/static/players.html new file mode 100644 index 0000000..213b070 --- /dev/null +++ b/lyra/web/static/players.html @@ -0,0 +1,166 @@ + + + + + + + Lyra — Players + + + +
+
+

👤 Players

+ ← Chat + +
+
+

Loading…

+ + + + +