diff --git a/docs/superpowers/plans/2026-06-28-poker-logging-service.md b/docs/superpowers/plans/2026-06-28-poker-logging-service.md new file mode 100644 index 0000000..cc55b71 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-poker-logging-service.md @@ -0,0 +1,720 @@ +# Poker Logging Service Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn `lyra/poker.py` into a standalone logging system-of-record with a complete REST API, a single source-of-truth tool/API contract, and a human UI to log and correct everything — usable by Brian with zero LLM dependency. + +**Architecture:** Thin FastAPI routes wrap the existing (already-working) `poker.py` store functions; a declarative `poker_contract.py` pins operation names + required args so the REST API and Lyra's LLM tool specs can't drift; the web UI gets dumb capture inputs (2nd stack box on chat, quick inputs on the HUD) and correction controls. This is sub-project 1 of 2; Lyra's classifier/prompts (sub-project 2) are parked. + +**Tech Stack:** Python 3.11+ (venv runs 3.14), FastAPI + uvicorn, SQLite (WAL), pytest, vanilla HTML/JS/CSS. + +## Global Constraints + +- Python files: start with `from __future__ import annotations`; 4-space indent; ruff `line-length = 100`, `target-version = "py311"`. +- **`lyra/web/static/index.html` uses CRLF (`\r\n`) line endings and mixed tabs/spaces.** Every other static file (`session.html`, `style.css`, `nav.js`) and all Python use **LF + spaces**. Match the file you edit or you produce a noisy diff. +- Pure data capture (stack / buy-in / cash-out / hand / read) must reach the store via the REST endpoints, **never** through the chat/LLM path. +- `poker_contract.py` is the single source of truth: REST routes and `tools.py` specs must agree with it (enforced by a conformance test). +- Web app runs via `lyra-web` (uvicorn) on `0.0.0.0:7078`. DB path from `LYRA_DB_PATH` (default `data/lyra.db`, WAL). +- Test idiom: fixture sets `LYRA_DB_PATH` to a `tmp_path` file, stubs `llm.embed` (and `llm.complete` where needed), then `importlib.reload(memory)` **then** `importlib.reload(poker)` (order matters), then `importlib.reload(server)` for endpoint tests. Run with `.venv/bin/pytest` (or `uv run pytest`). +- Existing store facts to respect: `start_session(...)` uses `fmt=` (column is `format`); `add_buyin` returns a float total; `log_stack` returns the `stack_state` dict `{current, buy_in, net}`; `end_session(cash_out, ...)` takes `cash_out` first; `hud()` returns `None` when no session; `_HAND_FIELDS = ("position","hole_cards","board","preflop","flop","turn","river","showdown","pot","result","stack_after","tag","lesson")`; `upsert_player(name, **fields)` returns an int player id; `tools.dispatch(name, args, ctx)` — `ctx` is a plain dict. + +--- + +### Task 1: Contract module + tool-spec conformance test + +**Files:** +- Create: `lyra/poker_contract.py` +- Create: `tests/test_poker_contract.py` + +**Interfaces:** +- Produces: `lyra.poker_contract.OPERATIONS: dict[str, dict]` and `CONTRACT_VERSION: int`. Each op value: `{"required": tuple[str,...], "llm_tool": str | None, "rest": tuple[str, str] | None}` where `rest` is `(METHOD, PATH)` with PATH exactly matching the FastAPI route template. + +- [ ] **Step 1: Write the contract module** + +`lyra/poker_contract.py`: +```python +from __future__ import annotations + +# Single source of truth for poker logging operations. The REST API, Lyra's LLM +# tool specs, the human UI, and (later) an MCP wrapper all derive from this. +# `required` MUST match the `required` list in the matching tools.py spec. +# `rest` PATH MUST match the FastAPI route template verbatim. +CONTRACT_VERSION = 1 + +OPERATIONS: dict[str, dict] = { + "start_session": {"required": (), "llm_tool": "start_session", "rest": ("POST", "/session")}, + "update_session": {"required": (), "llm_tool": "update_session", "rest": ("PATCH", "/session/{session_id}")}, + "end_session": {"required": ("cash_out",), "llm_tool": "end_session", "rest": None}, + "log_stack": {"required": ("amount",), "llm_tool": "log_stack", "rest": ("POST", "/session/stack")}, + "add_buyin": {"required": ("amount",), "llm_tool": "add_buyin", "rest": ("POST", "/session/buyin")}, + "log_hand": {"required": (), "llm_tool": "log_hand", "rest": ("POST", "/session/hand")}, + "update_hand": {"required": ("id",), "llm_tool": None, "rest": ("PATCH", "/hand/{hand_id}")}, + "add_read": {"required": ("note",), "llm_tool": "add_read", "rest": ("POST", "/session/read")}, + "update_player": {"required": ("id",), "llm_tool": None, "rest": ("PATCH", "/player/{player_id}")}, +} +``` + +- [ ] **Step 2: Write the failing conformance test** + +`tests/test_poker_contract.py`: +```python +from __future__ import annotations + +from lyra import tools +from lyra.poker_contract import OPERATIONS + + +def test_llm_tool_required_args_match_contract(): + for op, decl in OPERATIONS.items(): + name = decl["llm_tool"] + if not name: + continue + spec = tools.TOOLS[name]["spec"] + required = set(spec["function"]["parameters"]["required"]) + assert required == set(decl["required"]), ( + f"{op}: tools spec required {required} != contract {set(decl['required'])}" + ) +``` + +- [ ] **Step 3: Run the test** + +Run: `.venv/bin/pytest tests/test_poker_contract.py -v` +Expected: PASS (the contract's `required` tuples were copied from the live specs). + +- [ ] **Step 4: Commit** + +```bash +git add lyra/poker_contract.py tests/test_poker_contract.py +git commit -m "feat: poker operation contract + tool-spec conformance test" +``` + +--- + +### Task 2: Direct capture endpoints (stack / buy-in / start) + +**Files:** +- Modify: `lyra/web/server.py` (add three routes inside `create_app`, near the existing `PATCH /session/{session_id}` at server.py:116) +- Create: `tests/test_poker_api.py` + +**Interfaces:** +- Consumes: `poker.log_stack(amount, note=None)`, `poker.add_buyin(amount)`, `poker.start_session(venue=, stakes=, game=, fmt=, buy_in=, mantra=)`, `poker.live_session()`. +- Produces: `POST /session/stack` → `{ok, stack}` or `{ok:false, error}`; `POST /session/buyin` → `{ok, buy_in_total}`; `POST /session` → `{ok, id}`. + +- [ ] **Step 1: Write the failing endpoint tests** + +`tests/test_poker_api.py`: +```python +from __future__ import annotations + +import importlib + +import pytest + + +@pytest.fixture +def client(tmp_path, monkeypatch): + monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db")) + from lyra import llm + monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts]) + import lyra.memory as memory + importlib.reload(memory) + import lyra.poker as poker + importlib.reload(poker) + import lyra.web.server as server + importlib.reload(server) + from fastapi.testclient import TestClient + return TestClient(server.app), poker + + +def test_post_stack_logs_and_returns_state(client): + c, poker = client + poker.start_session(venue="Meadows", stakes="1/3", buy_in=400) + r = c.post("/session/stack", json={"amount": 373}) + assert r.status_code == 200 + body = r.json() + assert body["ok"] is True + assert body["stack"]["current"] == 373 + assert body["stack"]["net"] == pytest.approx(-27) + + +def test_post_stack_without_session_errors(client): + c, _ = client + r = c.post("/session/stack", json={"amount": 373}) + assert r.json()["ok"] is False + assert "error" in r.json() + + +def test_post_buyin_increments_total(client): + c, poker = client + poker.start_session(buy_in=400) + r = c.post("/session/buyin", json={"amount": 200}) + assert r.json()["buy_in_total"] == pytest.approx(600) + + +def test_post_session_starts_live(client): + c, poker = client + r = c.post("/session", json={"venue": "Wheeling", "stakes": "1/3", "buy_in": 400}) + sid = r.json()["id"] + assert poker.live_session()["id"] == sid +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `.venv/bin/pytest tests/test_poker_api.py -v` +Expected: FAIL with 404s (routes not defined). If it errors with "No module named 'httpx'", run `.venv/bin/pip install httpx` (TestClient needs it). + +- [ ] **Step 3: Add the three routes** + +In `lyra/web/server.py`, immediately after the `PATCH /session/{session_id}` handler (server.py:122), add: +```python + @app.post("/session/stack") + async def session_log_stack(request: Request) -> dict: + """Log Brian's current stack directly (no LLM). Server-stamps the time.""" + body = await request.json() + try: + amount = float(body.get("amount")) + except (TypeError, ValueError): + return {"ok": False, "error": "amount must be a number"} + note = (body.get("note") or "").strip() or None + try: + state = await asyncio.to_thread(poker.log_stack, amount, note) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + logbus.log("info", "stack logged (direct)", amount=amount) + return {"ok": True, "stack": state} + + @app.post("/session/buyin") + async def session_add_buyin(request: Request) -> dict: + """Add a buy-in/rebuy directly (no LLM).""" + body = await request.json() + try: + amount = float(body.get("amount")) + except (TypeError, ValueError): + return {"ok": False, "error": "amount must be a number"} + try: + total = await asyncio.to_thread(poker.add_buyin, amount) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + logbus.log("info", "buyin added (direct)", amount=amount) + return {"ok": True, "buy_in_total": total} + + @app.post("/session") + async def session_start(request: Request) -> dict: + """Open a new live session directly (no LLM).""" + body = await request.json() + sid = await asyncio.to_thread(lambda: poker.start_session( + venue=body.get("venue"), stakes=body.get("stakes"), + game=body.get("game") or "NLH", fmt=body.get("format") or "cash", + buy_in=body.get("buy_in") or 0, mantra=body.get("mantra"), + )) + logbus.log("info", "poker session started (direct)", id=sid) + return {"ok": True, "id": sid} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `.venv/bin/pytest tests/test_poker_api.py -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add lyra/web/server.py tests/test_poker_api.py +git commit -m "feat: direct REST endpoints for stack/buyin/start-session (no LLM)" +``` + +--- + +### Task 3: Hands API (log / edit / delete) + +**Files:** +- Modify: `lyra/poker.py` (add `update_hand` near `log_hand` at poker.py:558) +- Modify: `lyra/web/server.py` (add routes after the Task 2 routes) +- Modify: `tests/test_poker_api.py` (add tests) + +**Interfaces:** +- Consumes: `poker.log_hand(**fields)`, `poker.get_hand(id)`, `poker.delete_entry("hand", id)`, `_HAND_FIELDS`. +- Produces: `poker.update_hand(hand_id, **fields) -> dict | None`; `POST /session/hand` → `{ok, id}`; `PATCH /hand/{hand_id}` → `{ok, hand}`; `DELETE /hand/{hand_id}` → `{ok}`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_poker_api.py`: +```python +def test_post_hand_edit_and_delete(client): + c, poker = client + poker.start_session(buy_in=400) + r = c.post("/session/hand", json={"position": "BTN", "hole_cards": "22", "result": 120}) + assert r.json()["ok"] is True + hid = r.json()["id"] + r2 = c.patch(f"/hand/{hid}", json={"hole_cards": "2c2d"}) + assert r2.json()["ok"] is True + assert r2.json()["hand"]["hole_cards"] == "2c2d" + r3 = c.delete(f"/hand/{hid}") + assert r3.json()["ok"] is True + assert poker.get_hand(hid) is None +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `.venv/bin/pytest tests/test_poker_api.py::test_post_hand_edit_and_delete -v` +Expected: FAIL (404 on `/session/hand`). + +- [ ] **Step 3: Add `update_hand` to the store** + +In `lyra/poker.py`, immediately after `log_hand` (poker.py:558), add: +```python +def update_hand(hand_id: int, **fields) -> dict | None: + """Edit a logged hand's flat fields (fix a mislabeled board, result, villain). + Only known columns are touched. Returns the updated hand row or None.""" + sets, vals = [], [] + for k, v in fields.items(): + if k in _HAND_FIELDS and v is not None: + sets.append(f"{k} = ?") + vals.append(v) + if sets: + conn = _c() + with conn: + conn.execute(f"UPDATE poker_hands SET {', '.join(sets)} WHERE id = ?", + (*vals, hand_id)) + return get_hand(hand_id) +``` + +- [ ] **Step 4: Add the three routes** + +In `lyra/web/server.py`, after the Task 2 routes, add: +```python + @app.post("/session/hand") + async def session_log_hand(request: Request) -> dict: + """Log a hand directly with flat fields (no LLM parse).""" + body = await request.json() + try: + hid = await asyncio.to_thread(lambda: poker.log_hand(**body)) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + logbus.log("info", "hand logged (direct)", id=hid) + return {"ok": True, "id": hid} + + @app.patch("/hand/{hand_id}") + async def hand_update(hand_id: int, request: Request) -> dict: + """Edit a logged hand's flat fields.""" + body = await request.json() + h = await asyncio.to_thread(lambda: poker.update_hand(hand_id, **body)) + logbus.log("info", "hand edited", id=hand_id, fields=list(body)) + return {"ok": h is not None, "hand": h} + + @app.delete("/hand/{hand_id}") + async def hand_delete(hand_id: int) -> dict: + """Delete a logged hand.""" + ok = await asyncio.to_thread(poker.delete_entry, "hand", hand_id) + return {"ok": ok} +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `.venv/bin/pytest tests/test_poker_api.py -v` +Expected: PASS (all tests, including the new hand test). + +- [ ] **Step 6: Commit** + +```bash +git add lyra/poker.py lyra/web/server.py tests/test_poker_api.py +git commit -m "feat: hands API — log_hand endpoint, update_hand store fn, edit/delete routes" +``` + +--- + +### Task 4: Reads/players API + route conformance + +**Files:** +- Modify: `lyra/poker.py` (add `update_player` near `upsert_player`) +- Modify: `lyra/web/server.py` (add routes) +- Modify: `tests/test_poker_api.py` (add tests) +- Modify: `tests/test_poker_contract.py` (add route-coverage test) + +**Interfaces:** +- Consumes: `poker.add_read(note=, name=, ...)`, `poker.upsert_player(name, **fields)`. +- Produces: `poker.update_player(player_id, **fields) -> dict | None`; `POST /session/read` → `{ok, id}`; `PATCH /player/{player_id}` → `{ok, player}`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_poker_api.py`: +```python +def test_post_read(client): + c, poker = client + poker.start_session(buy_in=400) + r = c.post("/session/read", json={"note": "3-bets light", "name": "James K"}) + assert r.json()["ok"] is True + assert isinstance(r.json()["id"], int) + + +def test_rename_player_fixes_mislabel(client): + c, poker = client + pid = poker.upsert_player("Dave the rock", category="reg") + r = c.patch(f"/player/{pid}", json={"name": "Dave the mechanic"}) + assert r.json()["ok"] is True + assert r.json()["player"]["name"] == "Dave the mechanic" +``` + +Append to `tests/test_poker_contract.py`: +```python +def test_rest_routes_registered(): + import lyra.web.server as server + registered = set() + for route in server.app.routes: + methods = getattr(route, "methods", None) + path = getattr(route, "path", None) + if not methods or not path: + continue + for m in methods: + registered.add((m, path)) + for op, decl in OPERATIONS.items(): + if not decl["rest"]: + continue + method, path = decl["rest"] + assert (method, path) in registered, f"{op}: {method} {path} not registered" +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `.venv/bin/pytest tests/test_poker_api.py::test_rename_player_fixes_mislabel tests/test_poker_contract.py::test_rest_routes_registered -v` +Expected: FAIL (404 on `/player/...`; route-coverage missing several POST/PATCH paths). + +- [ ] **Step 3: Add `update_player` to the store** + +In `lyra/poker.py`, immediately after `upsert_player` (find it near poker.py:1010), add: +```python +_PLAYER_FIELDS = ("name", "venue", "description", "tendencies", "adjustment", "category") + + +def update_player(player_id: int, **fields) -> dict | None: + """Edit a player's dossier (rename, fix tendencies/category). Returns the row or None.""" + sets, vals = [], [] + for k, v in fields.items(): + if k in _PLAYER_FIELDS and v is not None: + sets.append(f"{k} = ?") + vals.append(v) + if sets: + conn = _c() + with conn: + conn.execute(f"UPDATE poker_players SET {', '.join(sets)} WHERE id = ?", + (*vals, player_id)) + row = _c().execute("SELECT * FROM poker_players WHERE id = ?", (player_id,)).fetchone() + return dict(row) if row else None +``` + +- [ ] **Step 4: Add the two routes** + +In `lyra/web/server.py`, after the Task 3 routes, add: +```python + @app.post("/session/read") + async def session_add_read(request: Request) -> dict: + """Log a read directly (no LLM); upserts the villain file when name is given.""" + body = await request.json() + rid = await asyncio.to_thread(lambda: poker.add_read( + note=body.get("note") or "", seat=body.get("seat"), name=body.get("name"), + tendencies=body.get("tendencies"), adjustment=body.get("adjustment"), + description=body.get("description"), category=body.get("category"), + venue=body.get("venue"), + )) + return {"ok": True, "id": rid} + + @app.patch("/player/{player_id}") + async def player_update(player_id: int, request: Request) -> dict: + """Edit a player's dossier (rename, fix tendencies).""" + body = await request.json() + p = await asyncio.to_thread(lambda: poker.update_player(player_id, **body)) + logbus.log("info", "player edited", id=player_id, fields=list(body)) + return {"ok": p is not None, "player": p} +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `.venv/bin/pytest tests/test_poker_api.py tests/test_poker_contract.py -v` +Expected: PASS (all API tests + both conformance tests). + +- [ ] **Step 6: Run the full suite (no regressions)** + +Run: `.venv/bin/pytest -q` +Expected: PASS (existing poker/tools/chat tests still green). + +- [ ] **Step 7: Commit** + +```bash +git add lyra/poker.py lyra/web/server.py tests/test_poker_api.py tests/test_poker_contract.py +git commit -m "feat: reads/players API + REST route conformance test" +``` + +--- + +### Task 5: Chat-page stack quick-capture (2nd input box) + +**Files:** +- Modify: `lyra/web/static/index.html` (**CRLF + tabs** — add markup + JS) +- Modify: `lyra/web/static/style.css` (LF + spaces — add styling) + +**Interfaces:** +- Consumes: `POST /session/stack` (Task 2). Reads `currentSession` and the Live Log DOM (`#thinkingContent`, `#thinkingEmpty`) already present in index.html. +- Produces: a stack-only input that logs without any chat/LLM call. + +- [ ] **Step 1: Add the input row markup** + +In `lyra/web/static/index.html`, insert **between** the `