From 44a559c5f9ca0da285e4d20c27e74f4f34f81775 Mon Sep 17 00:00:00 2001 From: serversdown Date: Sun, 21 Jun 2026 06:02:10 +0000 Subject: [PATCH] fix: render flat-logged hands + on-demand "build replay" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quick-logged hands (log_hand) store flat fields with no structured JSON, so the hand viewer dead-ended with "no structured data to replay" — even when the full street-by-street action was captured (e.g. the KhQh-vs-Louis hand). Now: - hand.html renders a readable STATIC view of any flat hand (hero cards, board, street narratives, result, lesson) instead of erroring; also handles empty/garbage structured rows by falling back to the flat view. - "▶ Build replay" button + poker.reconstruct_hand + POST /hand/{id}/reconstruct: parse a flat hand's narrative into structured form on demand, making any quick-logged hand replayable without an LLM call per log during live play. - test_modes.py +1 (reconstruct wiring). (Also reconstructed the two live Meadows hands and removed one empty hand in the DB.) Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/poker.py | 32 ++++++++++++++++++++++++++++++ lyra/web/server.py | 7 +++++++ lyra/web/static/hand.html | 41 ++++++++++++++++++++++++++++++++++++++- tests/test_modes.py | 18 +++++++++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/lyra/poker.py b/lyra/poker.py index 19f2e31..cc195bc 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -713,6 +713,38 @@ def record_hand(shorthand: str, session_id: int | None = None, stakes: str | Non return {"id": hid, "parsed": parsed, "linked": linked} +def reconstruct_hand(hand_id: int, backend: str | None = None) -> dict | None: + """Upgrade a flat (log_hand) hand into a structured, replayable one by parsing + its captured street narratives. On-demand so quick-logged live hands can become + replayable without an LLM call per log during play.""" + h = get_hand(hand_id) + if not h: + return None + parts = [] + if h.get("position") or h.get("hole_cards"): + parts.append(f"Hero is {h.get('position') or '?'} with {h.get('hole_cards') or 'unknown'}.") + for st in ("preflop", "flop", "turn", "river", "showdown"): + if h.get(st): + parts.append(f"{st.capitalize()}: {h[st]}") + if h.get("board"): + parts.append(f"Final board: {h['board']}.") + if h.get("result") is not None: + parts.append(f"Hero net result: {h['result']}.") + shorthand = " ".join(parts).strip() + if not shorthand: + return None + parsed = parse_hand(shorthand, backend=backend) + if not parsed: + return None + parsed = _normalize_parsed(parsed) + conn = _c() + with conn: + conn.execute("UPDATE poker_hands SET structured = ? WHERE id = ?", + (json.dumps(parsed), hand_id)) + link_hand_players(hand_id, parsed, session_id=h.get("session_id")) + return {"id": hand_id, "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() diff --git a/lyra/web/server.py b/lyra/web/server.py index 79785c9..9ad248b 100644 --- a/lyra/web/server.py +++ b/lyra/web/server.py @@ -278,6 +278,13 @@ def create_app() -> FastAPI: async def hand_data(hand_id: int) -> dict: return poker.get_hand(hand_id) or {} + @app.post("/hand/{hand_id}/reconstruct") + async def hand_reconstruct(hand_id: int) -> dict: + """Parse a flat (quick-logged) hand's narrative into a replayable structure.""" + out = await asyncio.to_thread(poker.reconstruct_hand, hand_id) + logbus.log("info", "hand reconstructed", id=hand_id, ok=out is not None) + return {"ok": out is not None} + @app.get("/hands") async def hands_page() -> FileResponse: return FileResponse(str(_STATIC / "hands.html")) diff --git a/lyra/web/static/hand.html b/lyra/web/static/hand.html index 6a9b05b..0b475e7 100644 --- a/lyra/web/static/hand.html +++ b/lyra/web/static/hand.html @@ -95,11 +95,50 @@ return `${r}${SUIT[s]}`; } const cards = (arr, sm) => (arr||[]).map(c=>cardEl(c,sm)).join(''); + // Split a loose card string ("KhQh", "Qh Qc", "Tc 8s Js 6d", "Ax") into codes. + const parseCards = s => (String(s||'').match(/(10|[2-9TJQKA])[shdcx]/gi) || []); + + // Flat (quick-logged) hands have no structured replay — show a readable static + // view of everything that WAS captured, plus an on-demand "build replay". + function renderFlat(h){ + document.getElementById('sub').textContent = h.position || ''; + const hole = parseCards(h.hole_cards), board = parseCards(h.board); + const streets = [['Preflop',h.preflop],['Flop',h.flop],['Turn',h.turn],['River',h.river],['Showdown',h.showdown]] + .filter(x=>x[1]); + const canBuild = streets.length > 0; + document.getElementById('root').innerHTML = ` +
+
Hero ${esc(h.position||'')}${h.tag?' · '+esc(h.tag):''}
+
+ ${hole.length?cards(hole):'?'}
+ ${board.length?`
Board
+
${cards(board)}
`:''} +
+ ${streets.length?`
${streets.map(s=>`
${s[0]}${esc(s[1])}
`).join('')}
`:''} + ${h.result!=null?`
Result
+
Hero net: ${h.result>=0?'+':''}${esc(h.result)}
`:''} + ${h.lesson?`
Lesson
${esc(h.lesson)}
`:''} +
+ ${canBuild?'':''} +
+

+ ${canBuild?'Quick-logged hand (static). Build replay to reconstruct a step-through.':'Quick-logged hand — limited detail captured.'}

`; + const b = document.getElementById('build'); + if(b) b.onclick = async () => { + b.disabled = true; b.textContent = '… building'; + try{ + const r = await fetch(`/hand/${h.id}/reconstruct`,{method:'POST'}); + const d = await r.json(); + if(d.ok) location.reload(); else { b.disabled=false; b.textContent='▶ Build replay'; alert("Couldn't reconstruct this one."); } + }catch(e){ b.disabled=false; b.textContent='▶ Build replay'; alert('Failed: '+e.message); } + }; + } function render(h){ const sub = document.getElementById('sub'); const data = h.structured; - if(!data){ document.getElementById('root').innerHTML = '

This hand has no structured data to replay.

'; return; } + const hasReplay = data && (((data.players||[]).length) || ((data.actions||[]).length)); + if(!hasReplay){ renderFlat(h); return; } const players = (data.players||[]).slice(); // order so hero sits at the bottom diff --git a/tests/test_modes.py b/tests/test_modes.py index 0c2c21d..ff9d551 100644 --- a/tests/test_modes.py +++ b/tests/test_modes.py @@ -175,6 +175,24 @@ def test_session_state_readback(lyra): assert "great river fold" in out +def test_reconstruct_flat_hand(lyra, monkeypatch): + _, poker, _, _ = lyra + poker.start_session(stakes="1/3", buy_in=300) + hid = poker.log_hand(position="UTG", hole_cards="KhQh", + preflop="UTG raises, BTN calls", flop="Qd Qs Jc, bet, call", + river="Kd, all in, called", showdown="hero wins", result=225) + assert poker.get_hand(hid)["structured"] is None # flat (log_hand) — not replayable yet + monkeypatch.setattr(poker, "parse_hand", lambda *a, **k: { + "hero_pos": "UTG", "hero_cards": ["Kh", "Qh"], + "players": [{"pos": "UTG"}], + "actions": [{"street": "preflop", "pos": "UTG", "action": "raise"}], + "board": ["Qd", "Qs", "Jc", "6d", "Kd"]}) + out = poker.reconstruct_hand(hid) + assert out is not None + h = poker.get_hand(hid) + assert h["structured"]["hero_pos"] == "UTG" and len(h["structured"]["actions"]) == 1 + + def test_undo_last_and_delete_entry(lyra): _, poker, modes, tools = lyra assert "undo_last" in modes.CASH.tools