9491951da0
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>
439 lines
16 KiB
Python
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,
|
|
}
|