Files
project-lyra/lyra/poker.py
T
serversdown 9491951da0 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>
2026-06-17 23:11:46 +00:00

439 lines
16 KiB
Python

"""Poker domain pack: structured session / hand / villain storage + stats.
This is the poker-specific data layer — kept separate from the domain-agnostic
core memory so Lyra-the-agent stays general. It records real structured data
(money, hands, opponents) during a live session via tools Lyra calls, and
computes stats from that data. The narrative .md recap is generated on top of
this, not instead of it.
Tables live in the same SQLite file as everything else (one DB), created lazily.
Most tool-facing functions default to the current *live* session so Lyra rarely
needs to pass an id around.
"""
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from lyra import llm, memory
_SCHEMA = """
CREATE TABLE IF NOT EXISTS poker_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
ended_at TEXT,
venue TEXT,
game TEXT, -- NLH, PLO, Stud8, Mixed, ...
stakes TEXT, -- "1/3", "2/5"
format TEXT, -- cash | tournament
buy_in_total REAL NOT NULL DEFAULT 0,
cash_out REAL,
net REAL,
hours REAL,
mantra TEXT,
mood TEXT,
status TEXT NOT NULL DEFAULT 'live', -- live | closed
recap_md TEXT
);
CREATE TABLE IF NOT EXISTS poker_hands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
at TEXT NOT NULL,
position TEXT,
hole_cards TEXT,
board TEXT,
preflop TEXT,
flop TEXT,
turn TEXT,
river TEXT,
showdown TEXT,
pot REAL,
result REAL,
stack_after REAL,
tag TEXT, -- well_played | leak | cooler | confidence | notable
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);
-- Persistent villain file — survives across sessions/venues.
CREATE TABLE IF NOT EXISTS poker_players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
venue TEXT,
description TEXT,
tendencies TEXT,
adjustment TEXT,
category TEXT, -- feeder | risky | reg | unknown
updated_at TEXT NOT NULL
);
-- Per-session observations (the live 'reads'); player_id links to the file.
CREATE TABLE IF NOT EXISTS player_reads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER,
player_id INTEGER,
seat TEXT,
note TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
_ensured_for = None
def _c():
"""Shared connection with poker tables ensured (re-ensures after reconnect)."""
global _ensured_for
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
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
# --- sessions ---
def start_session(venue: str | None = None, stakes: str | None = None,
game: str = "NLH", fmt: str = "cash",
buy_in: float = 0.0, mantra: str | None = None) -> int:
"""Open a new live session. Returns its id."""
conn = _c()
with conn:
cur = conn.execute(
"INSERT INTO poker_sessions "
"(started_at, venue, game, stakes, format, buy_in_total, mantra, status) "
"VALUES (?, ?, ?, ?, ?, ?, ?, 'live')",
(_now(), venue, game, stakes, fmt, float(buy_in or 0), mantra),
)
return int(cur.lastrowid)
def live_session() -> dict | None:
"""The current open session, if any."""
r = _c().execute(
"SELECT * FROM poker_sessions WHERE status = 'live' ORDER BY id DESC LIMIT 1"
).fetchone()
return dict(r) if r else None
def _resolve(session_id: int | None) -> int | None:
if session_id is not None:
return session_id
live = live_session()
return live["id"] if live else None
def add_buyin(amount: float, session_id: int | None = None) -> float:
"""Add a buy-in/rebuy to a session. Returns the new total in."""
sid = _resolve(session_id)
if sid is None:
raise ValueError("no live session")
conn = _c()
with conn:
conn.execute(
"UPDATE poker_sessions SET buy_in_total = buy_in_total + ? WHERE id = ?",
(float(amount), sid),
)
return float(_c().execute(
"SELECT buy_in_total FROM poker_sessions WHERE id = ?", (sid,)
).fetchone()["buy_in_total"])
def end_session(cash_out: float, mood: str | None = None,
session_id: int | None = None) -> dict:
"""Close a session: record cashout, compute net + hours. Returns the row."""
sid = _resolve(session_id)
if sid is None:
raise ValueError("no live session")
row = _c().execute("SELECT * FROM poker_sessions WHERE id = ?", (sid,)).fetchone()
ended = _now()
hours = (datetime.fromisoformat(ended) - datetime.fromisoformat(row["started_at"])).total_seconds() / 3600
net = float(cash_out) - float(row["buy_in_total"])
conn = _c()
with conn:
conn.execute(
"UPDATE poker_sessions SET ended_at = ?, cash_out = ?, net = ?, hours = ?, "
"mood = COALESCE(?, mood), status = 'closed' WHERE id = ?",
(ended, float(cash_out), net, round(hours, 2), mood, sid),
)
return dict(_c().execute("SELECT * FROM poker_sessions WHERE id = ?", (sid,)).fetchone())
# --- hands ---
_HAND_FIELDS = ("position", "hole_cards", "board", "preflop", "flop", "turn",
"river", "showdown", "pot", "result", "stack_after", "tag", "lesson")
def log_hand(session_id: int | None = None, **fields) -> int:
"""Record a hand. All fields optional/partial — terse logging is fine."""
sid = _resolve(session_id)
if sid is None:
raise ValueError("no live session")
cols = ["session_id", "at"]
vals: list = [sid, _now()]
for f in _HAND_FIELDS:
if fields.get(f) not in (None, ""):
cols.append(f)
vals.append(fields[f])
conn = _c()
with conn:
cur = conn.execute(
f"INSERT INTO poker_hands ({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))})",
vals,
)
return int(cur.lastrowid)
def list_hands(session_id: int | None = None) -> list[dict]:
sid = _resolve(session_id)
if sid is None:
return []
return [dict(r) for r in _c().execute(
"SELECT * FROM poker_hands WHERE session_id = ? ORDER BY id", (sid,)
).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,
tendencies: str | None = None, adjustment: str | None = None,
category: str | None = None) -> int:
"""Create or update a player in the persistent villain file (matched by name)."""
conn = _c()
existing = conn.execute(
"SELECT id FROM poker_players WHERE name = ? COLLATE NOCASE", (name,)
).fetchone()
with conn:
if existing:
pid = existing["id"]
# only overwrite fields that were provided
for col, val in (("venue", venue), ("description", description),
("tendencies", tendencies), ("adjustment", adjustment),
("category", category)):
if val not in (None, ""):
conn.execute(f"UPDATE poker_players SET {col} = ? WHERE id = ?", (val, pid))
conn.execute("UPDATE poker_players SET updated_at = ? WHERE id = ?", (_now(), pid))
return int(pid)
cur = conn.execute(
"INSERT INTO poker_players (name, venue, description, tendencies, adjustment, "
"category, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(name, venue, description, tendencies, adjustment, category, _now()),
)
return int(cur.lastrowid)
def add_read(note: str, seat: str | None = None, name: str | None = None,
session_id: int | None = None, **player_fields) -> int:
"""Log a live read. If `name` is given, upsert the player and link the read."""
sid = _resolve(session_id)
pid = None
if name:
pid = upsert_player(name, **{k: v for k, v in player_fields.items()
if k in ("venue", "description", "tendencies",
"adjustment", "category")})
conn = _c()
with conn:
cur = conn.execute(
"INSERT INTO player_reads (session_id, player_id, seat, note, created_at) "
"VALUES (?, ?, ?, ?, ?)",
(sid, pid, seat, note, _now()),
)
return int(cur.lastrowid)
def get_villain_file(name: str | None = None, venue: str | None = None) -> list[dict]:
"""Pull villain dossiers, optionally filtered by name or venue."""
sql = "SELECT * FROM poker_players"
where, params = [], []
if name:
where.append("name LIKE ?")
params.append(f"%{name}%")
if venue:
where.append("venue LIKE ?")
params.append(f"%{venue}%")
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY updated_at DESC"
return [dict(r) for r in _c().execute(sql, params).fetchall()]
# --- stats ---
def session_stats(session_id: int | None = None) -> dict:
"""Money + hand summary for one session."""
sid = _resolve(session_id)
if sid is None:
return {}
s = _c().execute("SELECT * FROM poker_sessions WHERE id = ?", (sid,)).fetchone()
if not s:
return {}
s = dict(s)
hands = list_hands(sid)
tags: dict[str, int] = {}
for h in hands:
if h.get("tag"):
tags[h["tag"]] = tags.get(h["tag"], 0) + 1
hourly = round(s["net"] / s["hours"], 2) if s.get("net") is not None and s.get("hours") else None
return {
"session": s, "hands_logged": len(hands), "tags": tags,
"net": s.get("net"), "hours": s.get("hours"), "per_hour": hourly,
}
def running_stats(stakes: str | None = None, venue: str | None = None,
game: str | None = None, since: str | None = None) -> dict:
"""Cumulative stats over closed sessions, optionally filtered."""
sql = "SELECT net, hours, stakes, venue, game FROM poker_sessions WHERE status = 'closed' AND net IS NOT NULL"
params: list = []
for col, val in (("stakes", stakes), ("venue", venue), ("game", game)):
if val:
sql += f" AND {col} = ?"
params.append(val)
if since:
sql += " AND started_at >= ?"
params.append(since)
rows = [dict(r) for r in _c().execute(sql, params).fetchall()]
sessions = len(rows)
net = round(sum(r["net"] or 0 for r in rows), 2)
hours = round(sum(r["hours"] or 0 for r in rows), 2)
by_stake: dict[str, dict] = {}
for r in rows:
k = r["stakes"] or "?"
b = by_stake.setdefault(k, {"sessions": 0, "net": 0.0, "hours": 0.0})
b["sessions"] += 1
b["net"] = round(b["net"] + (r["net"] or 0), 2)
b["hours"] = round(b["hours"] + (r["hours"] or 0), 2)
return {
"sessions": sessions, "net": net, "hours": hours,
"per_hour": round(net / hours, 2) if hours else None,
"by_stake": by_stake,
}