"""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 | review recap_md TEXT, chat_session_id TEXT -- links to the chat where it was played, for recap ); 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 ); -- One row per named player per recorded hand — structured enough to (a) build -- their qualitative dossier and (b) infer basic stats once the sample is big. CREATE TABLE IF NOT EXISTS player_observations ( id INTEGER PRIMARY KEY AUTOINCREMENT, player_id INTEGER NOT NULL, hand_id INTEGER, session_id INTEGER, pos TEXT, cards TEXT, vpip INTEGER, -- voluntarily put money in preflop pfr INTEGER, -- raised/3bet preflop saw_flop INTEGER, showed INTEGER, -- cards reached showdown / were shown summary TEXT, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_pobs_player ON player_observations(player_id); """ # Below this many observed hands, don't surface % stats (too small a sample). MIN_STATS_SAMPLE = 12 _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). for ddl in ("ALTER TABLE poker_hands ADD COLUMN structured TEXT", "ALTER TABLE poker_sessions ADD COLUMN chat_session_id TEXT"): try: conn.execute(ddl) 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, chat_session_id: 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, chat_session_id) " "VALUES (?, ?, ?, ?, ?, ?, ?, 'live', ?)", (_now(), venue, game, stakes, fmt, float(buy_in or 0), mantra, chat_session_id), ) return int(cur.lastrowid) def get_session(session_id: int) -> dict | None: r = _c().execute("SELECT * FROM poker_sessions WHERE id = ?", (session_id,)).fetchone() return dict(r) if r else None def import_session(date: str, venue: str | None = None, game: str = "NLH", stakes: str | None = None, fmt: str = "cash", buy_in_total: float = 0.0, cash_out: float | None = None, hours: float | None = None, mood: str | None = None, recap_md: str | None = None) -> int: """Insert a historical (already-closed) session with a real date. For backfill.""" started = f"{date}T20:00:00+00:00" # logs are evening sessions; time is approximate net = (cash_out or 0) - (buy_in_total or 0) if cash_out is not None else None conn = _c() with conn: cur = conn.execute( "INSERT INTO poker_sessions (started_at, ended_at, venue, game, stakes, format, " "buy_in_total, cash_out, net, hours, mood, status, recap_md) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'closed', ?)", (started, started, venue, game, stakes, fmt, buy_in_total or 0, cash_out, net, hours, mood, recap_md), ) return int(cur.lastrowid) def clear_all() -> dict: """Wipe all poker data (sessions/hands/players/reads/observations). For a clean reseed.""" conn = _c() counts = {} with conn: for t in ("poker_hands", "player_observations", "player_reads", "poker_players", "poker_sessions"): counts[t] = conn.execute(f"SELECT COUNT(*) n FROM {t}").fetchone()["n"] conn.execute(f"DELETE FROM {t}") return counts 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": "", "hero_pos": "", "hero_cards": ["As","Ax", ...], // rank+suit (s/h/d/c); 'x' suit if unknown e.g. "Ax"; "x" for a fully unknown card "players": [ // every player mentioned, incl. hero {"pos": "", "stack": , "name": , "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": "", "action": "post|fold|check|call|bet|raise|allin", "amount": } ], "board": ["..."], // full final board, 0-5 cards "result": {"pot": , "hero_net": , "summary": ""} } Rules: infer positions and street order sensibly. Amounts are plain numbers (no $). \ NEVER invent suits or cards. A card is rank+suit where suit is one of s/h/d/c; if the suit \ wasn't stated, use 'x' for the suit (e.g. "Ax","Kx","4x"); if a whole card wasn't stated, \ use "x". Examples: "AA with the ace of spades" -> hero_cards ["As","Ax"]; "AK on an A4x \ board" -> board ["Ax","4x","x"]. Each card is independent: a suit named for one card does \ NOT apply to another — e.g. your hole "ace of spades" is a different card from a board ace \ whose suit is unstated (that board ace is "Ax", not "As"). Use null/omit for non-card \ details not stated. Stay faithful to what's described — do not invent action that isn't implied. POSITIONS: resolve relative seat references ("N seats to my right/left") into real positions. \ Action moves clockwise, so a player to your RIGHT acts before you (toward the blinds/button) \ and a player to your LEFT acts after you (toward UTG). Going RIGHT from a player you pass, in \ order: SB, BTN, CO, HJ, LJ/MP, UTG+1, UTG. Example: hero in the BB, "a guy 2 seats to my right \ raises" -> that raiser is on the BTN (1 right = SB, 2 right = BTN). If it's genuinely \ ambiguous, give the most standard read. Only include players in "players" who are actually \ mentioned or take action in the hand — do NOT fill in unmentioned empty seats.""" 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) _SUIT_SYM = {"♥": "h", "♦": "d", "♣": "c", "♠": "s"} def _norm_card(c): if not isinstance(c, str): return c s = c.strip() for sym, ltr in _SUIT_SYM.items(): s = s.replace(sym, ltr) return s def _normalize_parsed(p: dict) -> dict: """Normalize card strings (unicode suits -> letters) across a parsed hand.""" if not isinstance(p, dict): return p for key in ("hero_cards", "board"): if isinstance(p.get(key), list): p[key] = [_norm_card(c) for c in p[key]] for pl in p.get("players") or []: if isinstance(pl, dict) and isinstance(pl.get("cards"), list): pl["cards"] = [_norm_card(c) for c in pl["cards"]] for a in p.get("actions") or []: if isinstance(a, dict) and isinstance(a.get("board"), list): a["board"] = [_norm_card(c) for c in a["board"]] return p 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.""" parsed = _normalize_parsed(parsed) 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) linked = link_hand_players(hid, parsed, session_id=session_id) # enrich villain files return {"id": hid, "parsed": parsed, "linked": linked} 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 def list_recent_hands(limit: int = 60) -> list[dict]: """Recent recorded hands with their session's venue/stakes, for browsing.""" rows = _c().execute( "SELECT h.id, h.position, h.hole_cards, h.board, h.result, h.tag, h.at, " "h.lesson, s.venue AS venue, s.stakes AS stakes " "FROM poker_hands h LEFT JOIN poker_sessions s ON s.id = h.session_id " "ORDER BY h.id DESC LIMIT ?", (limit,), ).fetchall() return [dict(r) for r in rows] # --- session recap (.md generation on top of structured data + conversation) --- _RECAP_PROMPT = """You are writing Brian's structured poker session log in Markdown, in his \ established format, from the session DATA and CONVERSATION provided. Output ONLY the Markdown \ — no preamble, no code fences. Use these sections (skip any with no material; don't pad): # YYYY-MM-DD — ## Session Header * Date / Casino / Game & stakes / Start–End / Buy-in(s) / Cash-out / Net result ## Money Flow (totals; break out by variant if multiple games were played) ## Session Overview (1-2 short narrative paragraphs) ## Timeline (bullets of how it went) ## Key Hands (### per notable hand — Action recap → brief analysis → **Assessment:** Well Played / Leak Candidate / Cooler / Confidence Bank) ## Table Dynamics & Villain Notes (### per opponent — profile + exploit) ## Confidence Bank (disciplined / good process plays) ## Scar Notes (mistakes and study points) ## Mental Game Notes ## Final Assessment (overall quality of play; biggest strength; biggest thing to improve; did the result match decision quality?) Base everything on the actual data and conversation — do NOT invent hands, villains, or results. \ Address Brian as "you" or "Brian", coach-to-player. Be concise but complete.""" def _resolve_recap(session_id: int | None) -> int | None: if session_id is not None: return session_id live = live_session() if live: return live["id"] r = _c().execute( "SELECT id FROM poker_sessions WHERE status = 'closed' ORDER BY id DESC LIMIT 1" ).fetchone() return int(r["id"]) if r else None def _hand_line(h: dict) -> str: bits = [h.get("position"), h.get("hole_cards"), (f"board {h['board']}") if h.get("board") else None, (f"result {h['result']:+g}") if h.get("result") is not None else None, (f"[{h['tag']}]") if h.get("tag") else None, h.get("lesson")] return " | ".join(str(b) for b in bits if b) def generate_recap(session_id: int | None = None, backend: str | None = None) -> dict | None: """Generate Brian's .md recap from a session's structured data + conversation, store it.""" backend = backend or "cloud" sid = _resolve_recap(session_id) if sid is None: return None s = get_session(sid) hands = list_hands(sid) reads = [dict(r) for r in _c().execute( "SELECT seat, note FROM player_reads WHERE session_id = ?", (sid,)).fetchall()] stats = session_stats(sid) convo = "" if s.get("chat_session_id"): exs = [e for e in memory.history(s["chat_session_id"]) if (e.created_at or "") >= (s.get("started_at") or "")] convo = "\n".join(f"{e.role}: {e.content}" for e in exs)[-12000:] body = ( "SESSION DATA:\n" f"- venue: {s.get('venue')} | game: {s.get('game')} | stakes: {s.get('stakes')} | format: {s.get('format')}\n" f"- started: {s.get('started_at')} | ended: {s.get('ended_at')} | hours: {s.get('hours')}\n" f"- buy-in total: {s.get('buy_in_total')} | cash out: {s.get('cash_out')} | net: {s.get('net')}\n" f"- mantra: {s.get('mantra')} | mood: {s.get('mood')} | " f"{stats.get('per_hour')}/hr | hands logged: {stats.get('hands_logged')} | tags: {stats.get('tags')}\n\n" "HANDS:\n" + ("\n".join("- " + _hand_line(h) for h in hands) or "(none logged)") + "\n\n" "READS:\n" + ("\n".join(f"- seat {r.get('seat')}: {r['note']}" for r in reads) or "(none)") + "\n\n" "CONVERSATION DURING SESSION:\n" + (convo or "(none captured)") ) md = llm.complete( [{"role": "system", "content": _RECAP_PROMPT}, {"role": "user", "content": body}], backend=backend, ) conn = _c() with conn: conn.execute("UPDATE poker_sessions SET recap_md = ? WHERE id = ?", (md, sid)) return {"id": sid, "markdown": md} # --- villain file --- _GENERIC_NAME = ("player", "guy", "villain", "caller", "drunk", "unknown", "hero", "seat", "the ", "aggro", "young", "older", "straddler", "opener", "brian") def _real_handle(name: str | None) -> bool: """A real, persistable player handle — not an anonymous descriptor or the hero.""" n = (name or "").strip().lower() if len(n) < 2 or n in {"utg", "utg1", "mp", "lj", "hj", "co", "btn", "sb", "bb"}: return False return not any(g in n for g in _GENERIC_NAME) def prune_anonymous_players() -> int: """Delete players (and their observations/reads) whose names aren't real handles.""" conn = _c() bad = [r["id"] for r in conn.execute("SELECT id, name FROM poker_players").fetchall() if not _real_handle(r["name"])] with conn: for pid in bad: conn.execute("DELETE FROM player_observations WHERE player_id = ?", (pid,)) conn.execute("DELETE FROM player_reads WHERE player_id = ?", (pid,)) conn.execute("DELETE FROM poker_players WHERE id = ?", (pid,)) return len(bad) 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 _player_flags(parsed: dict, pos: str | None) -> tuple[int, int, int]: """(vpip, pfr, saw_flop) for the player at `pos` in a parsed hand.""" acts = parsed.get("actions") or [] pre = [a for a in acts if a.get("street") == "preflop" and a.get("pos") == pos] post = [a for a in acts if a.get("pos") == pos and a.get("street") in ("flop", "turn", "river")] vol = {"call", "bet", "raise", "allin"} vpip = int(any(a.get("action") in vol for a in pre)) pfr = int(any(a.get("action") in {"raise", "allin"} for a in pre)) return vpip, pfr, int(bool(post)) def link_hand_players(hand_id: int, parsed: dict, session_id: int | None = None) -> int: """For each NAMED player in a parsed hand, upsert their file + log a structured observation. Returns how many players were linked.""" sid = _resolve(session_id) linked = 0 for pl in (parsed.get("players") or []): name = (pl.get("name") or "").strip() if not _real_handle(name): # skip anonymous descriptors + the hero continue pid = upsert_player(name) vpip, pfr, saw = _player_flags(parsed, pl.get("pos")) cards = " ".join(pl.get("cards") or []) or None acts = [a for a in (parsed.get("actions") or []) if a.get("pos") == pl.get("pos") and a.get("action")] astr = ", ".join(a["action"] + (f" {a['amount']}" if a.get("amount") is not None else "") for a in acts) summary = (pl.get("pos") or "?") + (f" ({cards})" if cards else "") + (f": {astr}" if astr else "") conn = _c() with conn: conn.execute( "INSERT INTO player_observations (player_id, hand_id, session_id, pos, cards, " "vpip, pfr, saw_flop, showed, summary, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)", (pid, hand_id, sid, pl.get("pos"), cards, vpip, pfr, saw, int(bool(cards)), summary, _now()), ) linked += 1 return linked def player_profile(name: str) -> dict | None: """Everything known about a player: dossier + observations, with inferred stats once the sample is large enough.""" p = _c().execute( "SELECT * FROM poker_players WHERE name LIKE ? COLLATE NOCASE ORDER BY updated_at DESC LIMIT 1", (f"%{name}%",), ).fetchone() if not p: return None p = dict(p) obs = [dict(r) for r in _c().execute( "SELECT * FROM player_observations WHERE player_id = ? ORDER BY id DESC", (p["id"],) ).fetchall()] reads = [r["note"] for r in _c().execute( "SELECT note FROM player_reads WHERE player_id = ? ORDER BY id DESC LIMIT 8", (p["id"],) ).fetchall()] n = len(obs) prof: dict = { "player": p, "observations": n, "recent": [o["summary"] for o in obs[:8] if o["summary"]], "showdowns": [o["cards"] for o in obs if o["cards"]][:10], "reads": reads, "stats": None, } if n >= MIN_STATS_SAMPLE: prof["stats"] = { "hands": n, "vpip_pct": round(100 * sum(o["vpip"] or 0 for o in obs) / n), "pfr_pct": round(100 * sum(o["pfr"] or 0 for o in obs) / n), "wtsd_pct": round(100 * sum(o["showed"] or 0 for o in obs) / n), } elif n: prof["small_sample"] = f"only {n} hand(s) logged — too few for reliable stats" return prof def list_players() -> list[dict]: """The villain file with observation counts, for browsing.""" rows = _c().execute( "SELECT p.*, (SELECT COUNT(*) FROM player_observations o WHERE o.player_id = p.id) AS obs " "FROM poker_players p ORDER BY p.updated_at DESC" ).fetchall() return [dict(r) for r in rows] 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, }