From 9491951da010c8318c43780c3f334ab52c4ef139 Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 23:11:46 +0000 Subject: [PATCH] feat: hand-history reconstruction + replayable table viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brian's idea: vomit rough shorthand, Lyra rebuilds it into a structured, replayable hand history. - poker.parse_hand(): focused LLM pass turning shorthand into a canonical hand JSON (positions, stacks, hero cards, chronological actions w/ board reveals, result); store_hand_history() persists JSON + extracted flat fields; record_hand() = parse+store; standalone hands attach to a 'Hand Reviews' session - poker_hands gains a `structured` JSON column (ALTER-migrated for existing DBs) - record_hand tool wired into chat: "log this hand: ..." -> reconstructed + a /hand/{id} link - web: GET /hand/{id} viewer + /hand/{id}/data — a felt table with seats placed around the oval (hero at bottom), hole cards, progressive board reveal, and prev/next/end step-through of the action with running pot - tests: store/get roundtrip, record_hand tool (stubbed parse) Verified live: parsed a real AKs hand (BTN, 14 actions, full board) end to end. Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/poker.py | 127 +++++++++++++++++++++- lyra/tools.py | 24 +++++ lyra/web/server.py | 11 +- lyra/web/static/hand.html | 222 ++++++++++++++++++++++++++++++++++++++ tests/test_poker.py | 31 ++++++ 5 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 lyra/web/static/hand.html diff --git a/lyra/poker.py b/lyra/poker.py index ab1f8b8..b645564 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -12,9 +12,11 @@ needs to pass an id around. """ from __future__ import annotations +import json +import re from datetime import datetime, timezone -from lyra import memory +from lyra import llm, memory _SCHEMA = """ CREATE TABLE IF NOT EXISTS poker_sessions ( @@ -51,7 +53,8 @@ CREATE TABLE IF NOT EXISTS poker_hands ( result REAL, stack_after REAL, tag TEXT, -- well_played | leak | cooler | confidence | notable - lesson TEXT + lesson TEXT, + structured TEXT -- full parsed hand-history JSON (for the viewer) ); CREATE INDEX IF NOT EXISTS idx_hands_session ON poker_hands(session_id); @@ -87,6 +90,11 @@ def _c(): conn = memory._connection() if _ensured_for is not conn: conn.executescript(_SCHEMA) + # Add columns introduced after a DB already had the tables (no-op if present). + try: + conn.execute("ALTER TABLE poker_hands ADD COLUMN structured TEXT") + except Exception: + pass _ensured_for = conn return conn @@ -198,6 +206,121 @@ def list_hands(session_id: int | None = None) -> list[dict]: ).fetchall()] +# --- hand-history parsing (rough shorthand -> structured JSON) --- + +_HAND_PARSE_PROMPT = """You convert a player's rough shorthand description of a poker hand \ +into a structured JSON hand history. Output ONLY valid JSON — no prose, no code fences. + +Schema: +{ + "game": "NLH" | "PLO" | ..., + "stakes": "", + "hero_pos": "", + "hero_cards": ["Rs", ...], // rank+suit; suit one of s h d c; null if unknown + "players": [ // every player mentioned, incl. hero + {"pos": "", "stack": , "name": , "cards": [".."]|null} + ], + "actions": [ // chronological, across all streets + // when a street begins, FIRST emit its board reveal: + {"street": "flop", "board": ["7d","2c","5h"]}, // turn/river: one card in the array + {"street": "preflop|flop|turn|river", "pos": "", "action": "post|fold|check|call|bet|raise|allin", "amount": } + ], + "board": ["..."], // full final board, 0-5 cards + "result": {"pot": , "hero_net": , "summary": ""} +} + +Rules: infer positions and street order sensibly. Amounts are plain numbers (no $). \ +Normalize cards like "Ah","Td","9s". Use null/omit for anything not stated. Stay faithful \ +to what's described — do not invent action that isn't implied.""" + + +def _safe_json(s: str) -> dict | None: + try: + return json.loads(s) + except (json.JSONDecodeError, TypeError): + m = re.search(r"\{.*\}", s or "", re.S) + if m: + try: + return json.loads(m.group()) + except json.JSONDecodeError: + return None + return None + + +def parse_hand(shorthand: str, stakes: str | None = None, + backend: str | None = None) -> dict | None: + """Turn rough shorthand into a structured hand-history dict via an LLM pass.""" + backend = backend or "cloud" + ctx = f"Stakes: {stakes}\n\n" if stakes else "" + parsed = _safe_json(llm.complete( + [{"role": "system", "content": _HAND_PARSE_PROMPT}, + {"role": "user", "content": ctx + shorthand}], + backend=backend, + )) + if parsed and stakes and not parsed.get("stakes"): + parsed["stakes"] = stakes + return parsed + + +def _review_session_id() -> int: + """A standing 'Hand Reviews' session to attach standalone parsed hands to.""" + conn = _c() + r = conn.execute( + "SELECT id FROM poker_sessions WHERE venue = 'Hand Reviews' AND status = 'review'" + ).fetchone() + if r: + return int(r["id"]) + with conn: + cur = conn.execute( + "INSERT INTO poker_sessions (started_at, venue, status, buy_in_total) " + "VALUES (?, 'Hand Reviews', 'review', 0)", + (_now(),), + ) + return int(cur.lastrowid) + + +def store_hand_history(parsed: dict, session_id: int | None = None, + tag: str | None = None, lesson: str | None = None) -> int: + """Store a parsed hand: full JSON + extracted flat fields for stats/listing.""" + sid = _resolve(session_id) or _review_session_id() + hero_cards = parsed.get("hero_cards") or [] + board = parsed.get("board") or [] + result = (parsed.get("result") or {}) + conn = _c() + with conn: + cur = conn.execute( + "INSERT INTO poker_hands (session_id, at, position, hole_cards, board, " + "pot, result, tag, lesson, structured) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (sid, _now(), parsed.get("hero_pos"), + " ".join(hero_cards) if hero_cards else None, + " ".join(board) if board else None, + result.get("pot"), result.get("hero_net"), tag, lesson, + json.dumps(parsed)), + ) + return int(cur.lastrowid) + + +def record_hand(shorthand: str, session_id: int | None = None, stakes: str | None = None, + tag: str | None = None, lesson: str | None = None, + backend: str | None = None) -> dict: + """Parse shorthand -> structured hand -> store. Returns {id, parsed} (id None on parse fail).""" + parsed = parse_hand(shorthand, stakes=stakes, backend=backend) + if not parsed: + return {"id": None, "parsed": None} + hid = store_hand_history(parsed, session_id=session_id, tag=tag, lesson=lesson) + return {"id": hid, "parsed": parsed} + + +def get_hand(hand_id: int) -> dict | None: + """A stored hand with its structured JSON parsed back into a dict.""" + r = _c().execute("SELECT * FROM poker_hands WHERE id = ?", (hand_id,)).fetchone() + if not r: + return None + d = dict(r) + d["structured"] = json.loads(d["structured"]) if d.get("structured") else None + return d + + # --- villain file --- def upsert_player(name: str, venue: str | None = None, description: str | None = None, diff --git a/lyra/tools.py b/lyra/tools.py index d88a50b..f4a6858 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -149,6 +149,20 @@ def _running_stats(args: dict, ctx: dict) -> str: return f"{rs['sessions']} sessions, {rs['hours']:g}h, net {rs['net']:+.0f}{hourly}. By stake: {by}" +def _record_hand(args: dict, ctx: dict) -> str: + out = poker.record_hand( + args.get("shorthand") or "", stakes=args.get("stakes"), + tag=args.get("tag"), lesson=args.get("lesson"), + ) + if not out["id"]: + return "I couldn't parse that hand — give it to me again with a little more detail?" + p = out["parsed"] + cards = " ".join(p.get("hero_cards") or []) + logbus.log("info", "hand reconstructed", id=out["id"], hero=p.get("hero_pos")) + return (f"Hand #{out['id']} reconstructed — {p.get('hero_pos') or '?'} " + f"{cards}. View/replay it at /hand/{out['id']}") + + def _villain_file(args: dict, ctx: dict) -> str: vs = poker.get_villain_file(name=args.get("name"), venue=args.get("venue")) if not vs: @@ -231,6 +245,16 @@ TOOLS.update({ "game": {**_S, "description": "Filter by game type"}, "since": {**_S, "description": "ISO date lower bound, e.g. '2026-06-01'"}}, [])}, + "record_hand": {"handler": _record_hand, "spec": _f( + "record_hand", + "Reconstruct a hand from Brian's rough shorthand into a structured, " + "replayable hand history. Use when he describes/vomits a hand he wants " + "saved or to review. Pass his description verbatim as 'shorthand'.", + {"shorthand": {**_S, "description": "Brian's rough description of the hand, verbatim"}, + "stakes": {**_S, "description": "Stakes if known, e.g. '1/3'"}, + "tag": {**_S, "description": "well_played | leak | cooler | confidence | notable"}, + "lesson": {**_S, "description": "Takeaway, if he stated one"}}, + ["shorthand"])}, "get_villain_file": {"handler": _villain_file, "spec": _f( "get_villain_file", "Pull saved opponent dossiers (the villain file). Filter by name or venue, e.g. before sitting down.", diff --git a/lyra/web/server.py b/lyra/web/server.py index 8df6d16..0a8c6f3 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -18,7 +18,7 @@ from fastapi import FastAPI, Request from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles -from lyra import chat, logbus, memory, self_state, summary +from lyra import chat, logbus, memory, poker, self_state, summary from lyra.llm import Backend @@ -141,6 +141,15 @@ def create_app() -> FastAPI: async def journal_data(limit: int = 300) -> dict: return {"entries": memory.list_journal(limit=limit)} + @app.get("/hand/{hand_id}") + async def hand_page(hand_id: int) -> FileResponse: + """Replayable hand-history viewer.""" + return FileResponse(str(_STATIC / "hand.html")) + + @app.get("/hand/{hand_id}/data") + async def hand_data(hand_id: int) -> dict: + return poker.get_hand(hand_id) or {} + @app.get("/stream/logs") async def stream_logs(request: Request) -> StreamingResponse: """Live activity feed: replay the recent buffer, then stream new events.""" diff --git a/lyra/web/static/hand.html b/lyra/web/static/hand.html new file mode 100644 index 0000000..67197ca --- /dev/null +++ b/lyra/web/static/hand.html @@ -0,0 +1,222 @@ + + + + + + + Lyra — Hand + + + +
+
+

🃏 Hand

+ ← Chat + +
+
+

Loading hand…

+ + + + diff --git a/tests/test_poker.py b/tests/test_poker.py index 514a6cd..ba18a49 100644 --- a/tests/test_poker.py +++ b/tests/test_poker.py @@ -64,6 +64,37 @@ def test_running_stats(lyra): assert "1/3" in rs["by_stake"] +def test_hand_history_store_and_get(lyra): + poker = lyra + parsed = {"game": "NLH", "stakes": "1/3", "hero_pos": "BTN", "hero_cards": ["As", "Ks"], + "players": [{"pos": "BTN", "cards": ["As", "Ks"]}, {"pos": "BB"}], + "actions": [{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 12}, + {"street": "flop", "board": ["As", "7d", "2s"]}], + "board": ["As", "7d", "2s"], "result": {"pot": 80, "hero_net": 330, "summary": "won"}} + hid = poker.store_hand_history(parsed) # no live session -> attaches to a review session + h = poker.get_hand(hid) + assert h["position"] == "BTN" and h["hole_cards"] == "As Ks" + assert h["result"] == 330 + assert h["structured"]["actions"][0]["amount"] == 12 + + +def test_record_hand_tool_parses_and_stores(lyra, monkeypatch): + import re + + from lyra import llm, tools + hand_json = ('{"hero_pos":"CO","hero_cards":["Js","Jd"],' + '"players":[{"pos":"CO","cards":["Js","Jd"]},{"pos":"BB","name":"drunk"}],' + '"actions":[{"street":"preflop","pos":"CO","action":"raise","amount":45}],' + '"board":[],"result":{"hero_net":-300,"summary":"lost to a straight"}}') + monkeypatch.setattr(llm, "complete", lambda messages, backend=None, model=None: hand_json) + out = tools.dispatch("record_hand", {"shorthand": "JJ in CO, lost to a straight", "stakes": "1/3"}) + assert "/hand/" in out + hid = int(re.search(r"/hand/(\d+)", out).group(1)) + h = lyra.get_hand(hid) + assert h["structured"]["hero_pos"] == "CO" + assert h["result"] == -300 + + def test_poker_tools_dispatch(lyra): from lyra import tools assert "started" in tools.dispatch("start_session", {"stakes": "1/3", "buy_in": 300})