docs: implementation plan for poker logging service (sub-project 1)
Seven tasks: contract module + conformance test, direct capture endpoints (stack/buyin/start), hands API, reads/players API + route conformance, chat-page stack quick-capture box, HUD quick inputs + villain rename, and the iOS-PWA bottom safe-area fix. TDD with real test/impl code grounded in the actual poker.py signatures and FastAPI route patterns. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G796GsLCvJQKVN7hwV2cDx
This commit is contained in:
@@ -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 `<div id="input">…</div>` block (ends ~index.html:125) and `<nav id="tabbar">` (index.html:128). **Use CRLF + tab indentation to match the file.**
|
||||||
|
```html
|
||||||
|
<!-- Stack quick-capture (no LLM): type a number -> logs current stack -->
|
||||||
|
<div id="stackQuick">
|
||||||
|
<input id="stackQuickInput" type="number" inputmode="decimal" placeholder="Stack $" aria-label="Log current stack">
|
||||||
|
<button id="stackQuickBtn" type="button" title="Log stack (no chat)">Log</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the JS**
|
||||||
|
|
||||||
|
In the `<script>` of `index.html`, near `sendMessage` (index.html:299), add (CRLF + tabs):
|
||||||
|
```javascript
|
||||||
|
function liveLogLine(text) {
|
||||||
|
const content = document.getElementById("thinkingContent");
|
||||||
|
const empty = document.getElementById("thinkingEmpty");
|
||||||
|
if (empty) empty.style.display = "none";
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "thinking-event";
|
||||||
|
div.textContent = text;
|
||||||
|
content.appendChild(div);
|
||||||
|
content.scrollTop = content.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logStackQuick() {
|
||||||
|
const el = document.getElementById("stackQuickInput");
|
||||||
|
const raw = (el.value || "").replace(/[^0-9.]/g, "");
|
||||||
|
if (!raw) return;
|
||||||
|
const amount = Number(raw);
|
||||||
|
try {
|
||||||
|
const r = await fetch("/session/stack", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ amount })
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
if (!data.ok) { liveLogLine("⚠ " + (data.error || "stack not logged")); return; }
|
||||||
|
const t = new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||||
|
const net = (data.stack && data.stack.net != null)
|
||||||
|
? ` (net ${data.stack.net >= 0 ? "+" : ""}${data.stack.net})` : "";
|
||||||
|
liveLogLine(`💰 $${amount} logged · ${t}${net}`);
|
||||||
|
el.value = "";
|
||||||
|
} catch (e) {
|
||||||
|
liveLogLine("⚠ stack log failed: " + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById("stackQuickBtn").addEventListener("click", logStackQuick);
|
||||||
|
document.getElementById("stackQuickInput").addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); logStackQuick(); }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add styling**
|
||||||
|
|
||||||
|
In `lyra/web/static/style.css` (LF + spaces), add:
|
||||||
|
```css
|
||||||
|
#stackQuick {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-top: 1px solid var(--border, #222);
|
||||||
|
}
|
||||||
|
#stackQuick input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--panel, #111);
|
||||||
|
color: inherit;
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
#stackQuick button {
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--accent, #ff7a18);
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify manually**
|
||||||
|
|
||||||
|
Start the app: `.venv/bin/python -m lyra.web.server` (serves on :7078). With a live session (start one via the HUD or `curl -XPOST localhost:7078/session -d '{"buy_in":400}' -H 'Content-Type: application/json'`):
|
||||||
|
- The stack box appears below the message input, above the nav icons.
|
||||||
|
- Type `350`, press Enter → a `💰 $350 logged · …` line appears in the Live Log, the box clears, and **no chat bubble is added**.
|
||||||
|
- Confirm persisted: `curl -s localhost:7078/session/data | python -m json.tool` shows `stack.current == 350`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lyra/web/static/index.html lyra/web/static/style.css
|
||||||
|
git commit -m "feat: stack quick-capture box on chat page (no LLM)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: HUD quick-capture + correction controls
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lyra/web/static/session.html` (LF + spaces — Stack card markup, villain rename control, JS functions)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `POST /session/stack`, `POST /session/buyin` (Task 2), `PATCH /session/{id}` (existing), `PATCH /player/{id}` (Task 4). Reads existing globals `curSession`, `refresh()`, and the villain render block.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add quick inputs to the Stack card**
|
||||||
|
|
||||||
|
In `lyra/web/static/session.html`, replace the Stack card block (session.html:280-289) with the same block plus a `quick` row before its closing `</div>`:
|
||||||
|
```javascript
|
||||||
|
<div class="card">
|
||||||
|
<p class="label">Stack</p>
|
||||||
|
<div class="stack-row">
|
||||||
|
<span class="stack-now">${stack.current == null ? '—' : money(stack.current)}</span>
|
||||||
|
<span class="net ${netClass(stack.net)}">${stack.net == null ? '' : signed(stack.net)}</span>
|
||||||
|
<span class="stack-meta">bought in ${money(stack.buy_in)}<br>${(stack.log||[]).length} update(s)</span>
|
||||||
|
</div>
|
||||||
|
${sparkline(stack.log || [])}
|
||||||
|
<div class="quick">
|
||||||
|
<input id="qStack" type="number" inputmode="decimal" placeholder="Stack $" onkeydown="if(event.key==='Enter')postStack()">
|
||||||
|
<button onclick="postStack()">Log stack</button>
|
||||||
|
<input id="qBuyin" type="number" inputmode="decimal" placeholder="Buy-in $" onkeydown="if(event.key==='Enter')postBuyin()">
|
||||||
|
<button onclick="postBuyin()">Add buy-in</button>
|
||||||
|
<input id="qCashout" type="number" inputmode="decimal" placeholder="Cash out $" onkeydown="if(event.key==='Enter')postCashout()">
|
||||||
|
<button onclick="postCashout()">Cash out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the quick-capture + rename JS functions**
|
||||||
|
|
||||||
|
In the `<script>` of `session.html`, near `saveEdit()` (session.html:192), add:
|
||||||
|
```javascript
|
||||||
|
async function postQuick(url, amount, body){
|
||||||
|
const r = await fetch(url, { method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify(body || { amount }) });
|
||||||
|
const d = await r.json();
|
||||||
|
if(!d.ok){ alert(d.error || 'failed'); return false; }
|
||||||
|
refresh(); return true;
|
||||||
|
}
|
||||||
|
function numVal(id){ const el = document.getElementById(id); return Number((el.value||'').replace(/[^0-9.]/g,'')); }
|
||||||
|
async function postStack(){ const v = numVal('qStack'); if(v) { if(await postQuick('/session/stack', v)) document.getElementById('qStack').value=''; } }
|
||||||
|
async function postBuyin(){ const v = numVal('qBuyin'); if(v) { if(await postQuick('/session/buyin', v)) document.getElementById('qBuyin').value=''; } }
|
||||||
|
async function postCashout(){
|
||||||
|
if(!curSession) return;
|
||||||
|
const v = numVal('qCashout'); if(!v) return;
|
||||||
|
const r = await fetch('/session/'+curSession.id, { method:'PATCH', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ cash_out: v }) });
|
||||||
|
if(!(await r.json()).ok){ alert('failed'); return; }
|
||||||
|
document.getElementById('qCashout').value=''; refresh();
|
||||||
|
}
|
||||||
|
async function renamePlayer(id, current){
|
||||||
|
const name = prompt('Rename player', current || ''); if(!name) return;
|
||||||
|
const r = await fetch('/player/'+id, { method:'PATCH', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ name }) });
|
||||||
|
if(!(await r.json()).ok){ alert('failed'); return; }
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the rename control to the villains list**
|
||||||
|
|
||||||
|
In `session.html`, find the villains render block in `render(data)` (it maps over `data.villains` / the `villains` array). For each villain item, add a rename affordance next to the name, using the player id field present on the villain row (commonly `v.id` or `v.player_id` — use whichever the bundle provides):
|
||||||
|
```javascript
|
||||||
|
<button class="mini" title="Rename / fix" onclick="renamePlayer(${v.id}, '${esc(v.name||'')}')">✎</button>
|
||||||
|
```
|
||||||
|
Read the existing villain block first to splice this in cleanly and confirm the id field name.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add minimal styling**
|
||||||
|
|
||||||
|
In the inline `<style>` of `session.html`, add:
|
||||||
|
```css
|
||||||
|
.quick { display:flex; flex-wrap:wrap; gap:6px; margin-top:12px; }
|
||||||
|
.quick input { width:96px; padding:7px 9px; background:#111; color:inherit; border:1px solid #333; border-radius:8px; }
|
||||||
|
.quick button { padding:7px 11px; background:var(--accent,#ff7a18); color:#000; border:none; border-radius:8px; font-weight:600; }
|
||||||
|
button.mini { background:transparent; border:none; color:#888; cursor:pointer; padding:0 4px; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify manually**
|
||||||
|
|
||||||
|
With the app running and a live session, open `/session`:
|
||||||
|
- Log a stack via `qStack` → sparkline + net update without a chat call.
|
||||||
|
- Add a buy-in via `qBuyin` → "bought in" total rises.
|
||||||
|
- Enter a cash-out via `qCashout` → session net updates.
|
||||||
|
- Click ✎ on a villain, rename it → name changes after refresh. Confirm via `curl -s localhost:7078/session/data`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lyra/web/static/session.html
|
||||||
|
git commit -m "feat: HUD quick-capture (stack/buyin/cashout) + villain rename"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: iOS-PWA bottom safe-area gap fix
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lyra/web/static/style.css` (bottom nav / container safe-area)
|
||||||
|
|
||||||
|
**Interfaces:** none (visual fix). The empty band below the nav icons is the home-indicator inset not being consumed by `#tabbar`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Load the iOS-PWA skill**
|
||||||
|
|
||||||
|
Invoke the `building-ios-pwas` skill and follow its guidance for safe-area / `100dvh` handling before editing. The current `#tabbar` (style.css:921-952) applies `env(safe-area-inset-left/right)` and `padding-bottom: 6px`, but does **not** add `env(safe-area-inset-bottom)` — the likely cause.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Apply the safe-area fix**
|
||||||
|
|
||||||
|
In `lyra/web/static/style.css`, in the mobile `#tabbar` rule (style.css:921-929), change the bottom padding to consume the inset, and ensure the bar is pinned:
|
||||||
|
```css
|
||||||
|
#tabbar {
|
||||||
|
/* …existing flex/border rules… */
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding-bottom: calc(6px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
And ensure the chat scroll container reserves space for the bar so content isn't hidden behind it (match the container selector used at style.css:836-852):
|
||||||
|
```css
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#messages {
|
||||||
|
padding-bottom: calc(64px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify on device**
|
||||||
|
|
||||||
|
Open the PWA (Add to Home Screen) on iPhone:
|
||||||
|
- The empty band below the icons is gone; the nav sits flush above the home indicator.
|
||||||
|
- The stack quick-capture box (Task 5) sits directly above the nav.
|
||||||
|
- Open the keyboard: `body.kb` still hides the tabbar (style.css:952) and the input pins to the keyboard — confirm no regression.
|
||||||
|
- If the gap persists or content clips, follow the `building-ios-pwas` skill's `100dvh`/`visualViewport` guidance and iterate.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add lyra/web/static/style.css
|
||||||
|
git commit -m "fix: consume iOS home-indicator safe-area inset under bottom nav"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- Complete API surface (create/update/delete per entity) → Tasks 2 (stack/buyin/start), 3 (hands), 4 (reads/players); existing PATCH/DELETE session + entry routes retained.
|
||||||
|
- Single documented/versioned tool-API contract → Task 1 (`poker_contract.py`, `CONTRACT_VERSION`) + conformance tests (Tasks 1, 4).
|
||||||
|
- Human UI to log + edit/correct → Tasks 5 (chat 2nd box), 6 (HUD quick inputs + villain rename + existing edit form/delete).
|
||||||
|
- Pure capture never touches LLM → all capture goes through REST endpoints (Tasks 2–6); verified in manual steps (no chat bubble).
|
||||||
|
- 2nd input box + PWA fix → Tasks 5, 7.
|
||||||
|
- Non-goals respected: no classifier/prompts, no MI50 tool enablement, no MCP, buy-in stays scalar (`add_buyin` increments `buy_in_total`).
|
||||||
|
|
||||||
|
**Placeholder scan:** All code steps contain complete code. The one "locate the block" instruction (Task 6 Step 3, villain rename) provides the exact button snippet and names the id-field ambiguity to resolve by reading the file — not a placeholder, a grounded splice.
|
||||||
|
|
||||||
|
**Type consistency:** `poker_contract.OPERATIONS` shape is consistent across Tasks 1 and 4; REST paths in the contract (`/session/stack`, `/session/buyin`, `/session`, `/session/hand`, `/hand/{hand_id}`, `/session/read`, `/player/{player_id}`, `/session/{session_id}`) match the routes added in Tasks 2–4 exactly; `update_hand`/`update_player` signatures match their callers; response shapes (`{ok, stack}`, `{ok, buy_in_total}`, `{ok, id}`, `{ok, hand}`, `{ok, player}`) are used consistently in tests and routes.
|
||||||
|
|
||||||
|
**Open implementation note:** Task 6 Step 3 requires reading `session.html`'s villain render to confirm the player id field name (`v.id` vs `v.player_id`) before splicing the rename button.
|
||||||
Reference in New Issue
Block a user