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:
2026-06-17 23:11:46 +00:00
parent 16f3442640
commit 9491951da0
5 changed files with 412 additions and 3 deletions
+125 -2
View File
@@ -12,9 +12,11 @@ needs to pass an id around.
""" """
from __future__ import annotations from __future__ import annotations
import json
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from lyra import memory from lyra import llm, memory
_SCHEMA = """ _SCHEMA = """
CREATE TABLE IF NOT EXISTS poker_sessions ( CREATE TABLE IF NOT EXISTS poker_sessions (
@@ -51,7 +53,8 @@ CREATE TABLE IF NOT EXISTS poker_hands (
result REAL, result REAL,
stack_after REAL, stack_after REAL,
tag TEXT, -- well_played | leak | cooler | confidence | notable 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); CREATE INDEX IF NOT EXISTS idx_hands_session ON poker_hands(session_id);
@@ -87,6 +90,11 @@ def _c():
conn = memory._connection() conn = memory._connection()
if _ensured_for is not conn: if _ensured_for is not conn:
conn.executescript(_SCHEMA) 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 _ensured_for = conn
return conn return conn
@@ -198,6 +206,121 @@ def list_hands(session_id: int | None = None) -> list[dict]:
).fetchall()] ).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 --- # --- villain file ---
def upsert_player(name: str, venue: str | None = None, description: str | None = None, def upsert_player(name: str, venue: str | None = None, description: str | None = None,
+24
View File
@@ -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}" 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: def _villain_file(args: dict, ctx: dict) -> str:
vs = poker.get_villain_file(name=args.get("name"), venue=args.get("venue")) vs = poker.get_villain_file(name=args.get("name"), venue=args.get("venue"))
if not vs: if not vs:
@@ -231,6 +245,16 @@ TOOLS.update({
"game": {**_S, "description": "Filter by game type"}, "game": {**_S, "description": "Filter by game type"},
"since": {**_S, "description": "ISO date lower bound, e.g. '2026-06-01'"}}, "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": {"handler": _villain_file, "spec": _f(
"get_villain_file", "get_villain_file",
"Pull saved opponent dossiers (the villain file). Filter by name or venue, e.g. before sitting down.", "Pull saved opponent dossiers (the villain file). Filter by name or venue, e.g. before sitting down.",
+10 -1
View File
@@ -18,7 +18,7 @@ from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles 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 from lyra.llm import Backend
@@ -141,6 +141,15 @@ def create_app() -> FastAPI:
async def journal_data(limit: int = 300) -> dict: async def journal_data(limit: int = 300) -> dict:
return {"entries": memory.list_journal(limit=limit)} 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") @app.get("/stream/logs")
async def stream_logs(request: Request) -> StreamingResponse: async def stream_logs(request: Request) -> StreamingResponse:
"""Live activity feed: replay the recent buffer, then stream new events.""" """Live activity feed: replay the recent buffer, then stream new events."""
+222
View File
@@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0b0d12" />
<title>Lyra — Hand</title>
<style>
:root {
--bg:#0b0d12; --bg-elev:#141821; --border:#232936; --text:#e6e9ef;
--fade:#8b93a7; --accent:#7aa2ff; --felt:#16322a; --feltline:#0f5132;
--chip:#ffcf6b; --hero:#7aa2ff;
}
*{box-sizing:border-box;}
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%;}
header{position:sticky;top:0;z-index:10;background:var(--bg-elev);border-bottom:1px solid var(--border);
padding:env(safe-area-inset-top) 14px 0;}
.topbar{display:flex;align-items:baseline;gap:10px;padding:12px 0;flex-wrap:wrap;}
.topbar h1{font-size:1.02rem;margin:0;font-weight:600;}
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
.sub{color:var(--fade);font-size:.85rem;margin-left:auto;}
main{max-width:760px;margin:0 auto;padding:14px;}
.table-wrap{position:relative;width:100%;max-width:560px;margin:8px auto;aspect-ratio:1.45/1;}
.felt{position:absolute;inset:8%;background:radial-gradient(ellipse at center,#1c4a3c,var(--felt));
border:6px solid #25201a;border-radius:50%/50%;box-shadow:inset 0 0 40px rgba(0,0,0,.5);}
.center{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;width:80%;}
.board{display:flex;gap:5px;justify-content:center;min-height:46px;align-items:center;flex-wrap:wrap;}
.pot{margin-top:8px;color:var(--chip);font-size:.85rem;font-variant-numeric:tabular-nums;}
.street{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.6px;margin-bottom:4px;}
.card{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;
width:32px;height:44px;background:#f4f4f0;color:#111;border-radius:5px;font-weight:700;
box-shadow:0 1px 3px rgba(0,0,0,.4);line-height:1;}
.card.sm{width:26px;height:36px;font-size:.8rem;}
.card .r{font-size:1rem;}
.card.red{color:#c8102e;}
.card.back{background:#2a3550;color:#2a3550;}
.seat{position:absolute;transform:translate(-50%,-50%);width:96px;text-align:center;
background:rgba(13,16,22,.85);border:1px solid var(--border);border-radius:10px;padding:5px 4px;}
.seat.hero{border-color:var(--hero);box-shadow:0 0 10px rgba(122,162,255,.4);}
.seat.acting{border-color:var(--chip);box-shadow:0 0 12px rgba(255,207,107,.6);}
.seat .pos{font-size:.66rem;color:var(--accent);font-weight:700;letter-spacing:.4px;}
.seat .nm{font-size:.66rem;color:var(--fade);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.seat .cards{display:flex;gap:3px;justify-content:center;margin:3px 0;}
.seat .stack{font-size:.66rem;color:var(--text);font-variant-numeric:tabular-nums;}
.seat .act{font-size:.62rem;color:var(--chip);min-height:.8em;}
.seat.folded{opacity:.4;}
.controls{display:flex;gap:8px;align-items:center;justify-content:center;margin:14px 0 6px;}
.controls button{background:#1b2333;border:1px solid var(--border);color:var(--text);
border-radius:8px;padding:8px 14px;font-size:.95rem;cursor:pointer;-webkit-tap-highlight-color:transparent;}
.controls button:disabled{opacity:.4;}
.step-label{color:var(--fade);font-size:.8rem;min-width:80px;text-align:center;}
.now{text-align:center;color:var(--text);font-size:.95rem;min-height:1.3em;margin-bottom:6px;}
.log{margin-top:14px;border-top:1px solid var(--border);padding-top:10px;}
.log .ln{padding:5px 8px;border-radius:6px;font-size:.9rem;display:flex;gap:8px;}
.log .ln.cur{background:#1b2333;}
.log .ln.brd{color:var(--fade);font-style:italic;}
.log .st{color:var(--fade);font-size:.72rem;width:54px;flex:none;text-transform:uppercase;}
.summary{margin-top:14px;background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:12px;}
.summary .lbl{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.5px;}
.err{color:#ff6b6b;text-align:center;padding:40px;}
.net-pos{color:#5ad1a0;} .net-neg{color:#ff6b6b;}
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>🃏 Hand</h1>
<a class="back" href="/">← Chat</a>
<span class="sub" id="sub"></span>
</div>
</header>
<main id="root"><p class="err" id="boot">Loading hand…</p></main>
<script>
const SUIT = {s:"♠", h:"♥", d:"♦", c:"♣"};
const RED = new Set(["h", "d"]);
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
function cardEl(code, sm){
if(!code) return '';
const m = String(code).trim().match(/^(10|[2-9TJQKA])\s*([shdc])$/i);
if(!m) return `<span class="card${sm?' sm':''}">${esc(code)}</span>`;
const r = m[1].toUpperCase().replace('10','T'); const s = m[2].toLowerCase();
return `<span class="card${sm?' sm':''}${RED.has(s)?' red':''}"><span class="r">${r}</span><span>${SUIT[s]}</span></span>`;
}
const cards = (arr, sm) => (arr||[]).map(c=>cardEl(c,sm)).join('');
function render(h){
const sub = document.getElementById('sub');
const data = h.structured;
if(!data){ document.getElementById('root').innerHTML = '<p class="err">This hand has no structured data to replay.</p>'; return; }
const players = (data.players||[]).slice();
// order so hero sits at the bottom
let heroIdx = players.findIndex(p => p.pos === data.hero_pos);
if(heroIdx < 0) heroIdx = 0;
const ordered = players.slice(heroIdx).concat(players.slice(0, heroIdx));
const n = Math.max(ordered.length, 1);
const acts = data.actions || [];
let step = 0; // number of actions applied
sub.textContent = [data.stakes, data.game].filter(Boolean).join(' ');
const root = document.getElementById('root');
root.innerHTML = `
<div class="table-wrap" id="tw">
<div class="felt"></div>
<div class="center">
<div class="street" id="street"></div>
<div class="board" id="board"></div>
<div class="pot" id="pot"></div>
</div>
<div id="seats"></div>
</div>
<div class="now" id="now"></div>
<div class="controls">
<button id="prev">◀ Prev</button>
<span class="step-label" id="steplab"></span>
<button id="next">Next ▶</button>
<button id="all">End</button>
</div>
<div class="log" id="log"></div>
${data.result ? `<div class="summary"><div class="lbl">Result</div>
<div>${esc(data.result.summary||'')}</div>
${data.result.hero_net!=null ? `<div class="${data.result.hero_net>=0?'net-pos':'net-neg'}">Hero net: ${data.result.hero_net>=0?'+':''}${esc(data.result.hero_net)}</div>`:''}
</div>`:''}
`;
// place seats around the oval
const seatsEl = document.getElementById('seats');
ordered.forEach((p,i)=>{
const ang = (90 + i*(360/n)) * Math.PI/180; // bottom = 90deg
const x = 50 + 46*Math.cos(ang), y = 50 + 44*Math.sin(ang);
const el = document.createElement('div');
el.className = 'seat' + (p.pos===data.hero_pos?' hero':'');
el.style.left = x+'%'; el.style.top = y+'%';
el.dataset.pos = p.pos;
const hcards = (p.pos===data.hero_pos ? (p.cards||data.hero_cards) : p.cards);
el.innerHTML = `<div class="pos">${esc(p.pos||'')}</div>`
+ (p.name?`<div class="nm">${esc(p.name)}</div>`:'')
+ `<div class="cards">${hcards?cards(hcards,true):'<span class="card sm back">x</span><span class="card sm back">x</span>'}</div>`
+ (p.stack!=null?`<div class="stack">${esc(p.stack)}</div>`:'')
+ `<div class="act" data-act></div>`;
seatsEl.appendChild(el);
});
const boardEl=document.getElementById('board'), potEl=document.getElementById('pot'),
streetEl=document.getElementById('street'), nowEl=document.getElementById('now'),
logEl=document.getElementById('log'), steplab=document.getElementById('steplab');
// build the log
logEl.innerHTML = acts.map((a,idx)=>{
if(a.board) return `<div class="ln brd" data-i="${idx}"><span class="st">${esc(a.street)}</span>${cards(a.board,true)}</div>`;
const amt = a.amount!=null ? ' '+a.amount : '';
return `<div class="ln" data-i="${idx}"><span class="st">${esc(a.street||'')}</span>${esc(a.pos||'')} ${esc(a.action||'')}${amt}</div>`;
}).join('');
function draw(){
let pot = 0, board = [], street = 'Preflop';
const lastAct = {}, folded = {};
for(let i=0;i<step;i++){
const a = acts[i];
if(a.board){ board = a.board; street = a.street; continue; }
if(a.street) street = a.street;
if(a.amount!=null && ['call','bet','raise','allin','post'].includes(a.action)) pot += Number(a.amount)||0;
if(a.pos){ lastAct[a.pos] = (a.action||'') + (a.amount!=null?' '+a.amount:''); }
if(a.action==='fold' && a.pos) folded[a.pos]=true;
}
boardEl.innerHTML = cards(board);
potEl.textContent = pot ? ('Pot ~'+pot) : '';
streetEl.textContent = street;
document.querySelectorAll('.seat').forEach(s=>{
const pos=s.dataset.pos;
s.querySelector('[data-act]').textContent = lastAct[pos]||'';
s.classList.toggle('folded', !!folded[pos]);
s.classList.remove('acting');
});
const cur = acts[step-1];
if(cur && cur.pos){
const s = [...document.querySelectorAll('.seat')].find(x=>x.dataset.pos===cur.pos);
if(s) s.classList.add('acting');
}
nowEl.innerHTML = step===0 ? 'Cards dealt — preflop.'
: (cur.board ? `${cur.street[0].toUpperCase()+cur.street.slice(1)}: ${cards(cur.board,true)}`
: `${esc(cur.pos||'')} ${esc(cur.action||'')}${cur.amount!=null?' '+cur.amount:''}`);
steplab.textContent = `${step} / ${acts.length}`;
document.getElementById('prev').disabled = step===0;
document.getElementById('next').disabled = step>=acts.length;
logEl.querySelectorAll('.ln').forEach(l=>l.classList.toggle('cur', Number(l.dataset.i)===step-1));
const curln = logEl.querySelector('.ln.cur'); if(curln) curln.scrollIntoView({block:'nearest'});
}
document.getElementById('prev').onclick=()=>{if(step>0){step--;draw();}};
document.getElementById('next').onclick=()=>{if(step<acts.length){step++;draw();}};
document.getElementById('all').onclick=()=>{step=acts.length;draw();};
document.addEventListener('keydown',e=>{
if(e.key==='ArrowRight'){if(step<acts.length){step++;draw();}}
if(e.key==='ArrowLeft'){if(step>0){step--;draw();}}
});
logEl.querySelectorAll('.ln').forEach(l=>l.onclick=()=>{step=Number(l.dataset.i)+1;draw();});
draw();
}
async function load(){
const id = location.pathname.split('/')[2];
try{
const r = await fetch(`/hand/${id}/data`,{cache:'no-store'});
const h = await r.json();
if(!h || !h.id){ document.getElementById('root').innerHTML='<p class="err">Hand not found.</p>'; return; }
render(h);
}catch(e){ document.getElementById('root').innerHTML='<p class="err">Couldn\'t load the hand.</p>'; }
}
load();
</script>
</body>
</html>
+31
View File
@@ -64,6 +64,37 @@ def test_running_stats(lyra):
assert "1/3" in rs["by_stake"] 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): def test_poker_tools_dispatch(lyra):
from lyra import tools from lyra import tools
assert "started" in tools.dispatch("start_session", {"stakes": "1/3", "buy_in": 300}) assert "started" in tools.dispatch("start_session", {"stakes": "1/3", "buy_in": 300})