fix: render flat-logged hands + on-demand "build replay"
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -95,11 +95,50 @@
|
||||
return `<span class="card${sm?' sm':''}${RED.has(s)?' red':''}"><span class="r">${r}</span><span>${SUIT[s]}</span></span>`;
|
||||
}
|
||||
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 = `
|
||||
<div class="summary" style="text-align:center">
|
||||
<div class="lbl">Hero ${esc(h.position||'')}${h.tag?' · '+esc(h.tag):''}</div>
|
||||
<div style="display:flex;gap:5px;justify-content:center;margin:10px 0">
|
||||
${hole.length?cards(hole):'<span class="card unknown">?</span>'}</div>
|
||||
${board.length?`<div class="lbl" style="margin-top:6px">Board</div>
|
||||
<div style="display:flex;gap:5px;justify-content:center;margin-top:6px">${cards(board)}</div>`:''}
|
||||
</div>
|
||||
${streets.length?`<div class="log">${streets.map(s=>`<div class="ln"><span class="st">${s[0]}</span>${esc(s[1])}</div>`).join('')}</div>`:''}
|
||||
${h.result!=null?`<div class="summary"><div class="lbl">Result</div>
|
||||
<div class="${h.result>=0?'net-pos':'net-neg'}">Hero net: ${h.result>=0?'+':''}${esc(h.result)}</div></div>`:''}
|
||||
${h.lesson?`<div class="summary"><div class="lbl">Lesson</div><div>${esc(h.lesson)}</div></div>`:''}
|
||||
<div class="controls">
|
||||
${canBuild?'<button id="build">▶ Build replay</button>':''}
|
||||
</div>
|
||||
<p style="color:var(--fade);text-align:center;font-size:.78rem;margin-top:10px">
|
||||
${canBuild?'Quick-logged hand (static). Build replay to reconstruct a step-through.':'Quick-logged hand — limited detail captured.'}</p>`;
|
||||
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 = '<p class="err">This hand has no structured data to replay.</p>'; 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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user