feat: poker phase 2 — session recap (.md) generation, export, hands browser

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>
This commit is contained in:
2026-06-18 00:36:52 +00:00
parent fc06b24528
commit 7b65f81d7e
7 changed files with 353 additions and 12 deletions
+119 -8
View File
@@ -33,8 +33,9 @@ CREATE TABLE IF NOT EXISTS poker_sessions (
hours REAL, hours REAL,
mantra TEXT, mantra TEXT,
mood TEXT, mood TEXT,
status TEXT NOT NULL DEFAULT 'live', -- live | closed status TEXT NOT NULL DEFAULT 'live', -- live | closed | review
recap_md TEXT recap_md TEXT,
chat_session_id TEXT -- links to the chat where it was played, for recap
); );
CREATE TABLE IF NOT EXISTS poker_hands ( CREATE TABLE IF NOT EXISTS poker_hands (
@@ -91,8 +92,10 @@ def _c():
if _ensured_for is not conn: if _ensured_for is not conn:
conn.executescript(_SCHEMA) conn.executescript(_SCHEMA)
# Add columns introduced after a DB already had the tables (no-op if present). # 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: try:
conn.execute("ALTER TABLE poker_hands ADD COLUMN structured TEXT") conn.execute(ddl)
except Exception: except Exception:
pass pass
_ensured_for = conn _ensured_for = conn
@@ -106,20 +109,25 @@ def _now() -> str:
# --- sessions --- # --- sessions ---
def start_session(venue: str | None = None, stakes: str | None = None, def start_session(venue: str | None = None, stakes: str | None = None,
game: str = "NLH", fmt: str = "cash", game: str = "NLH", fmt: str = "cash", buy_in: float = 0.0,
buy_in: float = 0.0, mantra: str | None = None) -> int: mantra: str | None = None, chat_session_id: str | None = None) -> int:
"""Open a new live session. Returns its id.""" """Open a new live session. Returns its id."""
conn = _c() conn = _c()
with conn: with conn:
cur = conn.execute( cur = conn.execute(
"INSERT INTO poker_sessions " "INSERT INTO poker_sessions "
"(started_at, venue, game, stakes, format, buy_in_total, mantra, status) " "(started_at, venue, game, stakes, format, buy_in_total, mantra, status, chat_session_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, 'live')", "VALUES (?, ?, ?, ?, ?, ?, ?, 'live', ?)",
(_now(), venue, game, stakes, fmt, float(buy_in or 0), mantra), (_now(), venue, game, stakes, fmt, float(buy_in or 0), mantra, chat_session_id),
) )
return int(cur.lastrowid) 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: def live_session() -> dict | None:
"""The current open session, if any.""" """The current open session, if any."""
r = _c().execute( r = _c().execute(
@@ -326,6 +334,109 @@ def get_hand(hand_id: int) -> dict | None:
return d 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 / StartEnd / 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 --- # --- villain file ---
def upsert_player(name: str, venue: str | None = None, description: str | None = None, def upsert_player(name: str, venue: str | None = None, description: str | None = None,
+16
View File
@@ -90,6 +90,7 @@ def _start_session(args: dict, ctx: dict) -> str:
venue=args.get("venue"), stakes=args.get("stakes"), venue=args.get("venue"), stakes=args.get("stakes"),
game=args.get("game") or "NLH", fmt=args.get("format") or "cash", game=args.get("game") or "NLH", fmt=args.get("format") or "cash",
buy_in=args.get("buy_in") or 0, mantra=args.get("mantra"), 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")) logbus.log("info", "poker session started", id=sid, stakes=args.get("stakes"))
return (f"Session #{sid} started — {args.get('stakes') or '?'} " 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']}") 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: def _villain_file(args: dict, ctx: dict) -> str:
vs = poker.get_villain_file(name=args.get("name"), venue=args.get("venue")) vs = poker.get_villain_file(name=args.get("name"), venue=args.get("venue"))
if not vs: if not vs:
@@ -255,6 +265,12 @@ TOOLS.update({
"tag": {**_S, "description": "well_played | leak | cooler | confidence | notable"}, "tag": {**_S, "description": "well_played | leak | cooler | confidence | notable"},
"lesson": {**_S, "description": "Takeaway, if he stated one"}}, "lesson": {**_S, "description": "Takeaway, if he stated one"}},
["shorthand"])}, ["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": {"handler": _villain_file, "spec": _f(
"get_villain_file", "get_villain_file",
"Pull saved opponent dossiers (the villain file). Filter by name or venue, e.g. before sitting down.", "Pull saved opponent dossiers (the villain file). Filter by name or venue, e.g. before sitting down.",
+27 -1
View File
@@ -14,7 +14,7 @@ import json
import time import time
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request, Response
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -150,6 +150,32 @@ def create_app() -> FastAPI:
async def hand_data(hand_id: int) -> dict: async def hand_data(hand_id: int) -> dict:
return poker.get_hand(hand_id) or {} 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") @app.get("/stream/logs")
async def stream_logs(request: Request) -> StreamingResponse: async def stream_logs(request: Request) -> StreamingResponse:
"""Live activity feed: replay the recent buffer, then stream new events.""" """Live activity feed: replay the recent buffer, then stream new events."""
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0b0d12" />
<title>Lyra — Hands</title>
<style>
:root{--bg:#0b0d12;--bg-elev:#141821;--bg-line:#11141b;--border:#232936;--text:#e6e9ef;--fade:#8b93a7;--accent:#7aa2ff;}
*{box-sizing:border-box;}
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%;}
header{position:sticky;top:0;z-index:10;background:var(--bg-elev);border-bottom:1px solid var(--border);
padding:env(safe-area-inset-top) 14px 0;}
.topbar{display:flex;align-items:center;gap:10px;padding:13px 0;}
.topbar h1{font-size:1.05rem;margin:0;font-weight:600;}
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
.count{margin-left:auto;color:var(--fade);font-size:.8rem;}
main{max-width:640px;margin:0 auto;padding:12px 12px 40px;}
a.hand{display:flex;align-items:center;gap:12px;text-decoration:none;color:var(--text);
background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:8px;}
a.hand:active{background:#1b2333;}
.cards{display:flex;gap:4px;flex:none;}
.card{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;
width:24px;height:33px;background:#f4f4f0;color:#111;border-radius:4px;font-weight:700;font-size:.72rem;line-height:1;}
.card.red{color:#c8102e;} .card.unknown{background:#2a3550;color:#7c879e;}
.card .nosuit{color:#9aa3b5;}
.mid{flex:1;min-width:0;}
.ln1{font-size:.92rem;}
.ln2{font-size:.74rem;color:var(--fade);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.res{flex:none;font-variant-numeric:tabular-nums;font-weight:600;}
.pos-res{color:#5ad1a0;} .neg-res{color:#ff6b6b;}
.tag{font-size:.62rem;text-transform:uppercase;letter-spacing:.4px;color:var(--accent);}
.empty{color:var(--fade);text-align:center;padding:46px 16px;}
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>🃏 Hands</h1>
<a class="back" href="/">← Chat</a>
<span class="count" id="count"></span>
</div>
</header>
<main id="root"><p class="empty">Loading…</p></main>
<script>
const SUIT={s:"♠",h:"♥",d:"♦",c:"♣"}, RED=new Set(["h","d"]);
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
function cardEl(code){
if(!code) return '';
const c=String(code).trim();
if(c.toLowerCase()==='x') return '<span class="card unknown">?</span>';
const m=c.match(/^(10|[2-9TJQKA])\s*([shdcx])$/i);
if(!m) return `<span class="card">${esc(c)}</span>`;
const r=m[1].toUpperCase().replace('10','T'), s=m[2].toLowerCase();
if(s==='x') return `<span class="card"><span>${r}</span><span class="nosuit">·</span></span>`;
return `<span class="card${RED.has(s)?' red':''}"><span>${r}</span><span>${SUIT[s]}</span></span>`;
}
const cards=str=>(str?String(str).trim().split(/\s+/):[]).map(cardEl).join('');
async function load(){
try{
const r=await fetch('/hands/data',{cache:'no-store'});
const hands=(await r.json()).hands||[];
document.getElementById('count').textContent=`${hands.length} hand${hands.length===1?'':'s'}`;
if(!hands.length){document.getElementById('root').innerHTML='<p class="empty">No hands recorded yet. Tell Lyra: "log this hand: …"</p>';return;}
document.getElementById('root').innerHTML=hands.map(h=>{
const res=h.result!=null?`<span class="res ${h.result>=0?'pos-res':'neg-res'}">${h.result>=0?'+':''}${h.result}</span>`:'';
const meta=[h.stakes,h.venue,(h.at||'').slice(0,10)].filter(Boolean).join(' · ');
const tag=h.tag?` · <span class="tag">${esc(h.tag)}</span>`:'';
return `<a class="hand" href="/hand/${h.id}">
<span class="cards">${cards(h.hole_cards)||'<span class="card unknown">?</span>'}</span>
<span class="mid">
<div class="ln1">${esc(h.position||'')} ${h.board?'· '+'<span class="cards" style="display:inline-flex">'+cards(h.board)+'</span>':''}</div>
<div class="ln2">${esc(meta)}${tag}</div>
</span>${res}</a>`;
}).join('');
}catch(e){document.getElementById('root').innerHTML='<p class="empty">Couldn\'t load hands.</p>';}
}
load();
</script>
</body>
</html>
+5
View File
@@ -39,6 +39,7 @@
<button id="mobileFullLogBtn">⛶ Full Log</button> <button id="mobileFullLogBtn">⛶ Full Log</button>
<button id="mobileMindBtn">🧠 Read Her Mind</button> <button id="mobileMindBtn">🧠 Read Her Mind</button>
<button id="mobileJournalBtn">📔 Journal</button> <button id="mobileJournalBtn">📔 Journal</button>
<button id="mobileHandsBtn">🃏 Hands</button>
<button id="mobileSettingsBtn">⚙ Settings</button> <button id="mobileSettingsBtn">⚙ Settings</button>
<button id="mobileToggleThemeBtn">🌙 Toggle Theme</button> <button id="mobileToggleThemeBtn">🌙 Toggle Theme</button>
<button id="mobileForceReloadBtn">🔄 Force Reload</button> <button id="mobileForceReloadBtn">🔄 Force Reload</button>
@@ -74,6 +75,7 @@
<button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button> <button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button>
<a id="fullLogBtn" href="/logs" target="_blank" rel="noopener" title="Open the full-page log" role="button">⛶ Full Log</a> <a id="fullLogBtn" href="/logs" target="_blank" rel="noopener" title="Open the full-page log" role="button">⛶ Full Log</a>
<a id="mindBtn" href="/self" target="_blank" rel="noopener" title="Read her mind — Lyra's current self-state" role="button">🧠 Mind</a> <a id="mindBtn" href="/self" target="_blank" rel="noopener" title="Read her mind — Lyra's current self-state" role="button">🧠 Mind</a>
<a id="handsBtn" href="/hands" target="_blank" rel="noopener" title="Recorded poker hands" role="button">🃏 Hands</a>
</div> </div>
<!-- Status --> <!-- Status -->
@@ -827,6 +829,9 @@
document.getElementById("mobileJournalBtn").addEventListener("click", () => { document.getElementById("mobileJournalBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/journal"; closeMobileMenu(); window.location.href = "/journal";
}); });
document.getElementById("mobileHandsBtn").addEventListener("click", () => {
closeMobileMenu(); window.location.href = "/hands";
});
// Connect to the global live log on page load. // Connect to the global live log on page load.
connectThinkingStream(); connectThinkingStream();
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0b0d12" />
<title>Lyra — Recap</title>
<style>
:root{--bg:#0b0d12;--bg-elev:#141821;--bg-line:#11141b;--border:#232936;--text:#e6e9ef;--fade:#8b93a7;--accent:#7aa2ff;}
*{box-sizing:border-box;}
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%;}
header{position:sticky;top:0;z-index:10;background:var(--bg-elev);border-bottom:1px solid var(--border);
padding:env(safe-area-inset-top) 14px 0;}
.topbar{display:flex;align-items:center;gap:10px;padding:12px 0;flex-wrap:wrap;}
.topbar h1{font-size:1.02rem;margin:0;font-weight:600;}
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
.dl{margin-left:auto;background:#1b2333;border:1px solid var(--border);color:var(--accent);
border-radius:8px;padding:7px 12px;font-size:.85rem;text-decoration:none;}
main{max-width:740px;margin:0 auto;padding:18px 16px 48px;line-height:1.6;}
h1,h2,h3,h4{line-height:1.3;color:var(--text);}
main>h1:first-child{margin-top:0;}
h2{font-size:1.18rem;border-bottom:1px solid var(--border);padding-bottom:5px;margin-top:26px;color:var(--accent);}
h3{font-size:1.04rem;margin-top:18px;}
ul{padding-left:22px;} li{margin:3px 0;}
strong{color:var(--text);} hr{border:none;border-top:1px solid var(--border);margin:20px 0;}
code{background:rgba(255,255,255,.08);padding:1px 5px;border-radius:4px;font-size:.9em;}
.err{color:var(--fade);text-align:center;padding:46px 16px;}
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>📋 Recap</h1>
<a class="back" href="/">← Chat</a>
<a class="back" href="/hands">Hands</a>
<a class="dl" id="dl">⬇ .md</a>
</div>
</header>
<main id="root"><p class="err">Loading recap…</p></main>
<script>
const bt = String.fromCharCode(96);
function esc(s){return String(s==null?'':s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}
function inline(s){
const codeRe = new RegExp(bt+"([^"+bt+"]+)"+bt,"g");
return esc(s).replace(codeRe,"<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g,"<strong>$1</strong>")
.replace(/(^|[^*])\*([^*\n]+)\*/g,"$1<em>$2</em>");
}
function md(src){
const lines=String(src||"").replace(/\r\n/g,"\n").split("\n");
const out=[]; let list=null;
const flush=()=>{if(list){out.push("<ul>"+list.map(i=>"<li>"+inline(i)+"</li>").join("")+"</ul>");list=null;}};
for(const raw of lines){
const t=raw.replace(/\s+$/,""); let m;
if(!t.trim()){flush();continue;}
if(/^(-{3,}|\*{3,}|_{3,})$/.test(t.trim())){flush();out.push("<hr>");continue;}
if((m=t.match(/^(#{1,6})\s+(.*)$/))){flush();const n=m[1].length;out.push(`<h${n}>${inline(m[2])}</h${n}>`);continue;}
if((m=t.match(/^\s*[-*+]\s+(.*)$/))){(list=list||[]).push(m[1]);continue;}
flush();out.push("<p>"+inline(t)+"</p>");
}
flush(); return out.join("\n");
}
async function load(){
const id=location.pathname.split('/')[2];
document.getElementById('dl').href=`/recap/${id}/download`;
try{
const r=await fetch(`/recap/${id}/data`,{cache:'no-store'});
const d=await r.json();
if(!d.markdown){document.getElementById('root').innerHTML='<p class="err">No recap yet for this session. Ask Lyra to write one ("generate the recap").</p>';return;}
document.getElementById('root').innerHTML=md(d.markdown);
}catch(e){document.getElementById('root').innerHTML='<p class="err">Couldn\'t load the recap.</p>';}
}
load();
</script>
</body>
</html>
+21
View File
@@ -95,6 +95,27 @@ def test_record_hand_tool_parses_and_stores(lyra, monkeypatch):
assert h["result"] == -300 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): def test_poker_tools_dispatch(lyra):
from lyra import tools from lyra import tools
assert "started" in tools.dispatch("start_session", {"stakes": "1/3", "buy_in": 300}) assert "started" in tools.dispatch("start_session", {"stakes": "1/3", "buy_in": 300})