update to 0.2.0 stable #2
+315
@@ -0,0 +1,315 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from lyra import 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
|
||||||
|
);
|
||||||
|
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)
|
||||||
|
_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()]
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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,
|
||||||
|
}
|
||||||
+158
-1
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from lyra import logbus, memory
|
from lyra import logbus, memory, poker
|
||||||
|
|
||||||
|
|
||||||
def _journal_write(args: dict, ctx: dict) -> str:
|
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]:
|
def specs() -> list[dict]:
|
||||||
"""OpenAI-format tool definitions to offer the model."""
|
"""OpenAI-format tool definitions to offer the model."""
|
||||||
return [t["spec"] for t in TOOLS.values()]
|
return [t["spec"] for t in TOOLS.values()]
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Poker domain: structured session/hand/villain storage + stats, and the tools."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lyra(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||||
|
from lyra import llm
|
||||||
|
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
|
||||||
|
import lyra.memory as memory
|
||||||
|
importlib.reload(memory)
|
||||||
|
import lyra.poker as poker
|
||||||
|
importlib.reload(poker) # rebind to the reloaded memory + reset its schema flag
|
||||||
|
return poker
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_lifecycle_and_net(lyra):
|
||||||
|
poker = lyra
|
||||||
|
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
|
||||||
|
assert poker.live_session()["id"] == sid
|
||||||
|
poker.add_buyin(500) # rebuy -> total 900
|
||||||
|
s = poker.end_session(cash_out=627)
|
||||||
|
assert s["buy_in_total"] == 900
|
||||||
|
assert s["net"] == pytest.approx(-273)
|
||||||
|
assert s["status"] == "closed"
|
||||||
|
assert poker.live_session() is None # closed -> no live session
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_hand_partial_fields(lyra):
|
||||||
|
poker = lyra
|
||||||
|
poker.start_session(stakes="1/3", buy_in=300)
|
||||||
|
hid = poker.log_hand(position="BTN", hole_cards="AKs", result=120, tag="confidence")
|
||||||
|
hands = poker.list_hands()
|
||||||
|
assert len(hands) == 1 and hands[0]["id"] == hid
|
||||||
|
assert hands[0]["hole_cards"] == "AKs" and hands[0]["result"] == 120
|
||||||
|
assert hands[0]["board"] is None # unspecified fields stay null
|
||||||
|
|
||||||
|
|
||||||
|
def test_villain_file_upsert_and_read(lyra):
|
||||||
|
poker = lyra
|
||||||
|
poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
|
||||||
|
poker.add_read("limp-called K4s UTG", name="Sleepy John", seat="3",
|
||||||
|
tendencies="loose-passive, jackpot dreamer", category="feeder", venue="Meadows")
|
||||||
|
# update the same player
|
||||||
|
poker.add_read("cold-called a 3-bet with A2o", name="sleepy john")
|
||||||
|
file = poker.get_villain_file(name="Sleepy")
|
||||||
|
assert len(file) == 1 # matched by name, not duplicated
|
||||||
|
assert file[0]["category"] == "feeder"
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_stats(lyra):
|
||||||
|
poker = lyra
|
||||||
|
s1 = poker.start_session(stakes="1/3", buy_in=300)
|
||||||
|
poker.end_session(540, session_id=s1)
|
||||||
|
s2 = poker.start_session(stakes="1/3", buy_in=400)
|
||||||
|
poker.end_session(300, session_id=s2)
|
||||||
|
rs = poker.running_stats(stakes="1/3")
|
||||||
|
assert rs["sessions"] == 2
|
||||||
|
assert rs["net"] == pytest.approx(140) # +240 then -100
|
||||||
|
assert "1/3" in rs["by_stake"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_poker_tools_dispatch(lyra):
|
||||||
|
from lyra import tools
|
||||||
|
assert "started" in tools.dispatch("start_session", {"stakes": "1/3", "buy_in": 300})
|
||||||
|
assert "logged" in tools.dispatch("log_hand", {"position": "CO", "hole_cards": "QQ"})
|
||||||
|
assert "closed" in tools.dispatch("end_session", {"cash_out": 500})
|
||||||
|
# the poker tools are offered to the model
|
||||||
|
names = {s["function"]["name"] for s in tools.specs()}
|
||||||
|
assert {"start_session", "log_hand", "end_session", "running_stats", "get_villain_file"} <= names
|
||||||
Reference in New Issue
Block a user