7b65f81d7e
Completes the poker copilot loop: talk through a session -> structured capture
-> generated writeup in Brian's format, remembered + exportable.
- poker.generate_recap(): LLM produces Brian's .md log (Session Header, Money
Flow, Overview, Timeline, Key Hands w/ assessments, Villain Notes, Confidence
Bank, Scar Notes, Mental Game, Final Assessment) from the session's structured
data + the linked chat conversation; stored on poker_sessions.recap_md
- sessions now capture chat_session_id (via tool ctx) to pull the right convo;
list_recent_hands() for browsing
- generate_recap tool ("write up the recap")
- web: /recap/{id} (renders the md) + /recap/{id}/download (.md attachment) +
/hands browser (recent hands -> /hand/{id}); nav links added (desktop + mobile)
- tests: recap generation (stubbed), recent-hands listing
Verified live: recap for the Meadows session rendered + downloaded; all pages 200.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
555 lines
21 KiB
Python
555 lines
21 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 | 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
|
||
);
|
||
"""
|
||
|
||
_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 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": ["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": "<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 $). \
|
||
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."""
|
||
|
||
|
||
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
|
||
|
||
|
||
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 — <venue + game/stakes>
|
||
## 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 ---
|
||
|
||
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,
|
||
}
|