feat: poker copilot — structured session/hand/villain tracking + stats

The real upgrade over the ChatGPT prose-recap workflow: structured data capture
via tools Lyra drives during a live session, with stats computed from real data.

- lyra/poker.py: domain pack (separate from core memory) — poker_sessions,
  poker_hands, persistent poker_players (villain file) + player_reads; functions
  for session lifecycle (start/buyin/end with net+hours), tolerant hand logging,
  villain upsert/reads, and session/running stats ($/hr, by stake/venue/game)
- tools.py: 8 poker tools wired into the chat tool loop (start_session,
  add_buyin, log_hand, add_read, end_session, session_stats, running_stats,
  get_villain_file) — partial/terse input tolerated
- import/: Brian's real .md session-log format (reference for the phase-2 recap)
- tests: lifecycle/net math, partial hand logging, villain upsert, running
  stats, tool dispatch

Verified live: a full talk-through session persisted as structured rows
(session +240, AKs hand, seat-5 read) — she drove the tools from natural chat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 20:43:51 +00:00
parent a5477ae15c
commit 49b88af3cc
3 changed files with 547 additions and 1 deletions
+158 -1
View File
@@ -11,7 +11,7 @@ from __future__ import annotations
import json
from lyra import logbus, memory
from lyra import logbus, memory, poker
def _journal_write(args: dict, ctx: dict) -> str:
@@ -83,6 +83,163 @@ TOOLS: dict[str, dict] = {
}
# --- Poker copilot tools -----------------------------------------------------
def _start_session(args: dict, ctx: dict) -> str:
sid = poker.start_session(
venue=args.get("venue"), stakes=args.get("stakes"),
game=args.get("game") or "NLH", fmt=args.get("format") or "cash",
buy_in=args.get("buy_in") or 0, mantra=args.get("mantra"),
)
logbus.log("info", "poker session started", id=sid, stakes=args.get("stakes"))
return (f"Session #{sid} started — {args.get('stakes') or '?'} "
f"{args.get('game') or 'NLH'} at {args.get('venue') or 'unknown'}, "
f"in for {args.get('buy_in') or 0}.")
def _add_buyin(args: dict, ctx: dict) -> str:
total = poker.add_buyin(float(args.get("amount") or 0))
return f"Added {args.get('amount')}. Total in this session: {total:g}."
def _log_hand(args: dict, ctx: dict) -> str:
fields = {k: args.get(k) for k in poker._HAND_FIELDS if args.get(k) not in (None, "")}
hid = poker.log_hand(**fields)
bits = " ".join(str(fields[k]) for k in ("position", "hole_cards") if k in fields)
return f"Hand #{hid} logged{('' + bits) if bits else ''}."
def _add_read(args: dict, ctx: dict) -> str:
poker.add_read(
note=args.get("note") or "", seat=args.get("seat"), name=args.get("name"),
tendencies=args.get("tendencies"), adjustment=args.get("adjustment"),
description=args.get("description"), category=args.get("category"),
venue=args.get("venue"),
)
who = f" on {args['name']}" if args.get("name") else ""
return f"Read logged{who}."
def _end_session(args: dict, ctx: dict) -> str:
s = poker.end_session(cash_out=float(args.get("cash_out") or 0), mood=args.get("mood"))
hourly = f", {s['net'] / s['hours']:+.0f}/hr" if s.get("hours") else ""
logbus.log("info", "poker session closed", id=s["id"], net=s["net"])
return f"Session #{s['id']} closed — net {s['net']:+.0f} over {s['hours']}h{hourly}."
def _session_stats(args: dict, ctx: dict) -> str:
st = poker.session_stats()
if not st:
return "No session found."
s = st["session"]
tags = ", ".join(f"{k}:{v}" for k, v in st["tags"].items()) or "none"
return (f"Session #{s['id']} ({s.get('stakes')} {s.get('game')} @ {s.get('venue')}): "
f"in {s.get('buy_in_total'):g}, net {st['net'] if st['net'] is not None else ''}, "
f"{st['hands_logged']} hands logged (tags: {tags}).")
def _running_stats(args: dict, ctx: dict) -> str:
rs = poker.running_stats(stakes=args.get("stakes"), venue=args.get("venue"),
game=args.get("game"), since=args.get("since"))
if not rs["sessions"]:
return "No closed sessions match that filter yet."
by = " | ".join(f"{k}: {v['net']:+.0f} in {v['hours']:g}h ({v['sessions']})"
for k, v in rs["by_stake"].items())
hourly = f" ({rs['per_hour']:+.0f}/hr)" if rs["per_hour"] is not None else ""
return f"{rs['sessions']} sessions, {rs['hours']:g}h, net {rs['net']:+.0f}{hourly}. By stake: {by}"
def _villain_file(args: dict, ctx: dict) -> str:
vs = poker.get_villain_file(name=args.get("name"), venue=args.get("venue"))
if not vs:
return "No villain notes match."
lines = []
for v in vs[:8]:
lines.append(
f"- {v['name']}" + (f" ({v['venue']})" if v.get("venue") else "")
+ (f" [{v['category']}]" if v.get("category") else "")
+ (f": {v['tendencies']}" if v.get("tendencies") else "")
+ (f"{v['adjustment']}" if v.get("adjustment") else "")
)
return "\n".join(lines)
def _f(name, desc, props, required):
return {"type": "function", "function": {
"name": name, "description": desc,
"parameters": {"type": "object", "properties": props, "required": required}}}
_S = {"type": "string"}
_N = {"type": "number"}
TOOLS.update({
"start_session": {"handler": _start_session, "spec": _f(
"start_session",
"Begin a live poker session. Call when Brian sits down to play.",
{"venue": {**_S, "description": "Casino/room, e.g. 'Meadows'"},
"stakes": {**_S, "description": "e.g. '1/3', '2/5'"},
"game": {**_S, "description": "NLH, PLO, Stud8, Mixed (default NLH)"},
"format": {**_S, "description": "'cash' or 'tournament' (default cash)"},
"buy_in": {**_N, "description": "Initial buy-in amount"},
"mantra": {**_S, "description": "Optional pre-session focus/anchor"}},
[])},
"add_buyin": {"handler": _add_buyin, "spec": _f(
"add_buyin", "Record a rebuy / additional buy-in in the live session.",
{"amount": {**_N, "description": "Amount added"}}, ["amount"])},
"log_hand": {"handler": _log_hand, "spec": _f(
"log_hand",
"Log a hand in the live session. All fields optional — capture whatever Brian gives you, even terse.",
{"position": {**_S, "description": "e.g. 'BTN', 'UTG', 'BB'"},
"hole_cards": {**_S, "description": "e.g. 'AKs', 'JJ', '8d9s'"},
"board": {**_S, "description": "Final board if known"},
"preflop": {**_S, "description": "Preflop action narrative"},
"flop": {**_S, "description": "Flop board + action"},
"turn": {**_S, "description": "Turn card + action"},
"river": {**_S, "description": "River card + action"},
"showdown": {**_S, "description": "Showdown / result detail"},
"pot": {**_N, "description": "Pot size"},
"result": {**_N, "description": "Net chips won(+)/lost(-) on the hand"},
"tag": {**_S, "description": "well_played | leak | cooler | confidence | notable"},
"lesson": {**_S, "description": "Takeaway/analysis"}},
[])},
"add_read": {"handler": _add_read, "spec": _f(
"add_read",
"Log a read on an opponent. If you give a name, it's saved to the persistent villain file.",
{"note": {**_S, "description": "The observation / what they showed down"},
"name": {**_S, "description": "Player name/handle if known (creates/updates their dossier)"},
"seat": {**_S, "description": "Seat or relative position"},
"tendencies": {**_S, "description": "Standing read on how they play"},
"adjustment": {**_S, "description": "How Brian should exploit them"},
"description": {**_S, "description": "Physical marker, e.g. 'motorized chair'"},
"category": {**_S, "description": "feeder | risky | reg | unknown"},
"venue": {**_S, "description": "Where they play"}},
["note"])},
"end_session": {"handler": _end_session, "spec": _f(
"end_session", "Close the live session: record cashout, compute net + hours.",
{"cash_out": {**_N, "description": "Final cashout amount"},
"mood": {**_S, "description": "Mental-game note for the session"}},
["cash_out"])},
"session_stats": {"handler": _session_stats, "spec": _f(
"session_stats", "Get money + hand summary for the current/most-recent session.",
{}, [])},
"running_stats": {"handler": _running_stats, "spec": _f(
"running_stats",
"Cumulative results across closed sessions (net, $/hr, by stake). Optionally filter.",
{"stakes": {**_S, "description": "Filter by stakes, e.g. '1/3'"},
"venue": {**_S, "description": "Filter by venue"},
"game": {**_S, "description": "Filter by game type"},
"since": {**_S, "description": "ISO date lower bound, e.g. '2026-06-01'"}},
[])},
"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.",
{"name": {**_S, "description": "Player name to look up"},
"venue": {**_S, "description": "Venue to pull the local pool for"}},
[])},
})
def specs() -> list[dict]:
"""OpenAI-format tool definitions to offer the model."""
return [t["spec"] for t in TOOLS.values()]