From 7b65f81d7e569ed7ba86fb9a0a2dc89818764d51 Mon Sep 17 00:00:00 2001 From: serversdown Date: Thu, 18 Jun 2026 00:36:52 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20poker=20phase=202=20=E2=80=94=20session?= =?UTF-8?q?=20recap=20(.md)=20generation,=20export,=20hands=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lyra/poker.py | 133 ++++++++++++++++++++++++++++++++++--- lyra/tools.py | 16 +++++ lyra/web/server.py | 28 +++++++- lyra/web/static/hands.html | 84 +++++++++++++++++++++++ lyra/web/static/index.html | 5 ++ lyra/web/static/recap.html | 78 ++++++++++++++++++++++ tests/test_poker.py | 21 ++++++ 7 files changed, 353 insertions(+), 12 deletions(-) create mode 100644 lyra/web/static/hands.html create mode 100644 lyra/web/static/recap.html diff --git a/lyra/poker.py b/lyra/poker.py index 6adfe4f..4d881ac 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -33,8 +33,9 @@ CREATE TABLE IF NOT EXISTS poker_sessions ( hours REAL, mantra TEXT, mood TEXT, - status TEXT NOT NULL DEFAULT 'live', -- live | closed - recap_md 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 ( @@ -91,10 +92,12 @@ def _c(): if _ensured_for is not conn: 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 + 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 @@ -106,20 +109,25 @@ def _now() -> str: # --- 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: + 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) " - "VALUES (?, ?, ?, ?, ?, ?, ?, 'live')", - (_now(), venue, game, stakes, fmt, float(buy_in or 0), mantra), + "(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( @@ -326,6 +334,109 @@ def get_hand(hand_id: int) -> dict | 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 --- def upsert_player(name: str, venue: str | None = None, description: str | None = None, diff --git a/lyra/tools.py b/lyra/tools.py index f4a6858..f2b6b03 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -90,6 +90,7 @@ def _start_session(args: dict, ctx: dict) -> str: 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"), + chat_session_id=ctx.get("session_id"), ) logbus.log("info", "poker session started", id=sid, stakes=args.get("stakes")) return (f"Session #{sid} started — {args.get('stakes') or '?'} " @@ -163,6 +164,15 @@ def _record_hand(args: dict, ctx: dict) -> str: f"{cards}. View/replay it at /hand/{out['id']}") +def _generate_recap(args: dict, ctx: dict) -> str: + out = poker.generate_recap() + if not out: + return "No session to recap yet — start (and ideally finish) one first." + logbus.log("info", "recap generated", id=out["id"], chars=len(out["markdown"])) + return (f"Recap written for session #{out['id']} — view or download the .md " + f"at /recap/{out['id']}") + + def _villain_file(args: dict, ctx: dict) -> str: vs = poker.get_villain_file(name=args.get("name"), venue=args.get("venue")) if not vs: @@ -255,6 +265,12 @@ TOOLS.update({ "tag": {**_S, "description": "well_played | leak | cooler | confidence | notable"}, "lesson": {**_S, "description": "Takeaway, if he stated one"}}, ["shorthand"])}, + "generate_recap": {"handler": _generate_recap, "spec": _f( + "generate_recap", + "Write up the full session recap (.md) in Brian's format from the logged " + "data + this conversation. Use when he asks for the recap/writeup, usually " + "after ending a session.", + {}, [])}, "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.", diff --git a/lyra/web/server.py b/lyra/web/server.py index 0a8c6f3..f2a3b1c 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -14,7 +14,7 @@ import json import time from pathlib import Path -from fastapi import FastAPI, Request +from fastapi import FastAPI, Request, Response from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles @@ -150,6 +150,32 @@ def create_app() -> FastAPI: async def hand_data(hand_id: int) -> dict: return poker.get_hand(hand_id) or {} + @app.get("/hands") + async def hands_page() -> FileResponse: + return FileResponse(str(_STATIC / "hands.html")) + + @app.get("/hands/data") + async def hands_data(limit: int = 60) -> dict: + return {"hands": poker.list_recent_hands(limit=limit)} + + @app.get("/recap/{session_id}") + async def recap_page() -> FileResponse: + return FileResponse(str(_STATIC / "recap.html")) + + @app.get("/recap/{session_id}/data") + async def recap_data(session_id: int) -> dict: + s = poker.get_session(session_id) or {} + return {"session": s, "markdown": s.get("recap_md")} + + @app.get("/recap/{session_id}/download") + async def recap_download(session_id: int) -> Response: + s = poker.get_session(session_id) or {} + md = s.get("recap_md") or "# No recap generated yet\n" + date = (s.get("started_at") or "session")[:10] + fname = f"pokerlog_{date}_s{session_id}.md" + return Response(content=md, media_type="text/markdown", + headers={"Content-Disposition": f'attachment; filename="{fname}"'}) + @app.get("/stream/logs") async def stream_logs(request: Request) -> StreamingResponse: """Live activity feed: replay the recent buffer, then stream new events.""" diff --git a/lyra/web/static/hands.html b/lyra/web/static/hands.html new file mode 100644 index 0000000..7055f51 --- /dev/null +++ b/lyra/web/static/hands.html @@ -0,0 +1,84 @@ + + + + + + + Lyra — Hands + + + +
+
+

🃏 Hands

+ ← Chat + +
+
+

Loading…

+ + + + diff --git a/lyra/web/static/index.html b/lyra/web/static/index.html index fad6703..5ad59ac 100644 --- a/lyra/web/static/index.html +++ b/lyra/web/static/index.html @@ -39,6 +39,7 @@ + @@ -74,6 +75,7 @@ ⛶ Full Log 🧠 Mind + 🃏 Hands @@ -827,6 +829,9 @@ document.getElementById("mobileJournalBtn").addEventListener("click", () => { closeMobileMenu(); window.location.href = "/journal"; }); + document.getElementById("mobileHandsBtn").addEventListener("click", () => { + closeMobileMenu(); window.location.href = "/hands"; + }); // Connect to the global live log on page load. connectThinkingStream(); diff --git a/lyra/web/static/recap.html b/lyra/web/static/recap.html new file mode 100644 index 0000000..12c8878 --- /dev/null +++ b/lyra/web/static/recap.html @@ -0,0 +1,78 @@ + + + + + + + Lyra — Recap + + + +
+
+

📋 Recap

+ ← Chat + Hands + ⬇ .md +
+
+

Loading recap…

+ + + + diff --git a/tests/test_poker.py b/tests/test_poker.py index ba18a49..914985c 100644 --- a/tests/test_poker.py +++ b/tests/test_poker.py @@ -95,6 +95,27 @@ def test_record_hand_tool_parses_and_stores(lyra, monkeypatch): assert h["result"] == -300 +def test_generate_recap(lyra, monkeypatch): + poker = lyra + from lyra import llm + monkeypatch.setattr(llm, "complete", + lambda messages, backend=None, model=None: "# Recap\n## Final Assessment\nGood session.") + sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300) + poker.log_hand(position="BTN", hole_cards="AKs", result=180, tag="confidence") + poker.end_session(540, session_id=sid) + out = poker.generate_recap(session_id=sid) + assert out["id"] == sid and "Final Assessment" in out["markdown"] + assert "Recap" in poker.get_session(sid)["recap_md"] + + +def test_list_recent_hands(lyra): + poker = lyra + poker.start_session(stakes="1/3", buy_in=300) + poker.log_hand(position="CO", hole_cards="QQ", result=-50) + hh = poker.list_recent_hands() + assert hh and hh[0]["hole_cards"] == "QQ" and hh[0]["stakes"] == "1/3" + + def test_poker_tools_dispatch(lyra): from lyra import tools assert "started" in tools.dispatch("start_session", {"stakes": "1/3", "buy_in": 300})