# 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 `