feat: hand-history reconstruction + replayable table viewer
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) <noreply@anthropic.com>
This commit is contained in:
+125
-2
@@ -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": "<e.g. 1/3, or null>",
|
||||
"hero_pos": "<UTG|UTG1|MP|LJ|HJ|CO|BTN|SB|BB, hero's position>",
|
||||
"hero_cards": ["Rs", ...], // rank+suit; suit one of s h d c; null if unknown
|
||||
"players": [ // every player mentioned, incl. hero
|
||||
{"pos": "<position>", "stack": <number|null>, "name": <string|null>, "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": "<pos>", "action": "post|fold|check|call|bet|raise|allin", "amount": <number|null>}
|
||||
],
|
||||
"board": ["..."], // full final board, 0-5 cards
|
||||
"result": {"pot": <number|null>, "hero_net": <number|null>, "summary": "<one line>"}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user