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
+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>