update to 0.2.0 stable #2
+122
-11
@@ -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,10 +92,12 @@ 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).
|
||||||
try:
|
for ddl in ("ALTER TABLE poker_hands ADD COLUMN structured TEXT",
|
||||||
conn.execute("ALTER TABLE poker_hands ADD COLUMN structured TEXT")
|
"ALTER TABLE poker_sessions ADD COLUMN chat_session_id TEXT"):
|
||||||
except Exception:
|
try:
|
||||||
pass
|
conn.execute(ddl)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
_ensured_for = conn
|
_ensured_for = conn
|
||||||
return conn
|
return 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 / 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 ---
|
# --- 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,
|
||||||
|
|||||||
@@ -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
@@ -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."""
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,"&").replace(/</g,"<").replace(/>/g,">");}
|
||||||
|
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>
|
||||||
@@ -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})
|
||||||
|
|||||||
Reference in New Issue
Block a user