44a559c5f9
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>
314 lines
13 KiB
Python
314 lines
13 KiB
Python
"""Conversation modes: tool gating, mode persistence, stack tracking + HUD."""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def lyra(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
|
from lyra import llm
|
|
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
|
|
import lyra.memory as memory
|
|
importlib.reload(memory)
|
|
import lyra.poker as poker
|
|
importlib.reload(poker)
|
|
import lyra.modes as modes
|
|
importlib.reload(modes)
|
|
import lyra.tools as tools
|
|
importlib.reload(tools)
|
|
return memory, poker, modes, tools
|
|
|
|
|
|
def _names(specs):
|
|
return {s["function"]["name"] for s in specs}
|
|
|
|
|
|
def test_tool_gating_by_mode(lyra):
|
|
_, _, modes, tools = lyra
|
|
talk = _names(tools.specs(modes.TALK.tools))
|
|
cash = _names(tools.specs(modes.CASH.tools))
|
|
|
|
# Cash is the full live toolset.
|
|
assert {"log_hand", "log_stack", "analyze_spot", "end_session"} <= cash
|
|
# Talk hides the live write tools...
|
|
assert "log_hand" not in talk and "log_stack" not in talk
|
|
# ...but keeps her agency + read-only lookups + the session entry point.
|
|
assert {"journal_write", "note", "player_profile", "start_session"} <= talk
|
|
# No allow-list = every registered tool.
|
|
assert _names(tools.specs()) == set(tools.TOOLS)
|
|
|
|
|
|
def test_every_mode_tool_exists(lyra):
|
|
_, _, modes, tools = lyra
|
|
for mode in modes.MODES.values():
|
|
assert set(mode.tools) <= set(tools.TOOLS), f"{mode.key} references unknown tools"
|
|
|
|
|
|
def test_mode_resolution_and_persistence(lyra):
|
|
memory, _, modes, _ = lyra
|
|
assert modes.get(None).key == modes.DEFAULT
|
|
assert modes.get("nonsense").key == modes.DEFAULT
|
|
assert modes.get("poker_cash") is modes.CASH
|
|
|
|
memory.ensure_session("s1")
|
|
assert memory.get_session_mode("s1") is None # unset -> caller applies default
|
|
memory.set_session_mode("s1", "poker_cash")
|
|
assert memory.get_session_mode("s1") == "poker_cash"
|
|
# set on an unknown session creates the row
|
|
memory.set_session_mode("s2", "conversation")
|
|
assert memory.get_session_mode("s2") == "conversation"
|
|
|
|
|
|
def test_stack_log_and_live_net(lyra):
|
|
_, poker, _, _ = lyra
|
|
poker.start_session(venue="Meadows", stakes="2/5", buy_in=500)
|
|
assert poker.current_stack() is None # nothing logged yet
|
|
|
|
st = poker.log_stack(700)
|
|
assert st["current"] == 700 and st["net"] == 200 # up 200 on a 500 buy-in
|
|
poker.log_stack(350)
|
|
assert poker.current_stack() == 350
|
|
assert poker.stack_state()["net"] == -150
|
|
assert len(poker.stack_log()) == 2
|
|
|
|
|
|
def test_log_stack_requires_live_session(lyra):
|
|
_, poker, _, _ = lyra
|
|
with pytest.raises(ValueError):
|
|
poker.log_stack(300)
|
|
|
|
|
|
def test_hud_bundle(lyra):
|
|
_, poker, _, _ = lyra
|
|
assert poker.hud() is None # no session -> nothing to show
|
|
|
|
sid = poker.start_session(venue="Meadows", stakes="2/5", game="NLH", buy_in=500)
|
|
poker.log_stack(620)
|
|
poker.log_hand(position="BTN", hole_cards="AKs", result=120, tag="confidence")
|
|
poker.add_read(note="3bets light from the SB", name="Round Mike", seat="SB")
|
|
|
|
hud = poker.hud()
|
|
assert hud["session"]["id"] == sid and hud["session"]["stakes"] == "2/5"
|
|
assert hud["stack"]["current"] == 620 and hud["stack"]["net"] == 120
|
|
assert len(hud["stack"]["log"]) == 1
|
|
assert len(hud["hands"]) == 1 and hud["hands"][0]["hole_cards"] == "AKs"
|
|
assert any(v["name"] == "Round Mike" for v in hud["villains"])
|
|
assert hud["stats"]["hands_logged"] == 1
|
|
|
|
|
|
def test_log_stack_tool_handler(lyra):
|
|
_, poker, _, tools = lyra
|
|
poker.start_session(stakes="1/3", buy_in=300)
|
|
out = tools.dispatch("log_stack", {"amount": 450}, {})
|
|
assert "450" in out and "150" in out # confirms stack + live net
|
|
# graceful when there's no number
|
|
assert "number" in tools.dispatch("log_stack", {}, {}).lower()
|
|
|
|
|
|
# --- mental-game rituals ---
|
|
|
|
def test_ritual_tools_in_cash_only(lyra):
|
|
_, _, modes, tools = lyra
|
|
cash = _names(tools.specs(modes.CASH.tools))
|
|
talk = _names(tools.specs(modes.TALK.tools))
|
|
rituals = {"scar_note", "confidence_bank", "alligator_blood", "reset_ritual"}
|
|
assert rituals <= cash
|
|
assert not (rituals & talk)
|
|
|
|
|
|
def test_scar_and_confidence_capture(lyra):
|
|
_, poker, _, tools = lyra
|
|
poker.start_session(stakes="2/5", buy_in=500)
|
|
tools.dispatch("scar_note", {"content": "punted bottom set", "classification": "punt"}, {})
|
|
tools.dispatch("scar_note", {"content": "ran KK into AA", "classification": "cooler"}, {})
|
|
tools.dispatch("confidence_bank", {"content": "disciplined river fold"}, {})
|
|
|
|
scars = poker.list_rituals(kinds=("scar",))
|
|
assert len(scars) == 2
|
|
assert {s["classification"] for s in scars} == {"punt", "cooler"}
|
|
conf = poker.list_rituals(kinds=("confidence",))
|
|
assert len(conf) == 1 and "fold" in conf[0]["content"]
|
|
# bogus classification is dropped, not stored
|
|
tools.dispatch("scar_note", {"content": "x", "classification": "nonsense"}, {})
|
|
assert poker.list_rituals(kinds=("scar",))[-1]["classification"] is None
|
|
|
|
|
|
def test_alligator_toggle_and_state(lyra):
|
|
_, poker, _, tools = lyra
|
|
poker.start_session(stakes="2/5", buy_in=500)
|
|
assert poker.alligator_active() is False
|
|
tools.dispatch("alligator_blood", {"on": True}, {})
|
|
assert poker.alligator_active() is True
|
|
tools.dispatch("alligator_blood", {"on": False}, {})
|
|
assert poker.alligator_active() is False # latest toggle wins
|
|
|
|
|
|
def test_rituals_in_hud(lyra):
|
|
_, poker, _, tools = lyra
|
|
poker.start_session(stakes="2/5", buy_in=500)
|
|
tools.dispatch("scar_note", {"content": "overplayed top pair"}, {})
|
|
tools.dispatch("confidence_bank", {"content": "good value bet"}, {})
|
|
tools.dispatch("reset_ritual", {"content": "lost a flip"}, {})
|
|
tools.dispatch("alligator_blood", {"on": True}, {})
|
|
|
|
r = poker.hud()["rituals"]
|
|
assert r["alligator"] is True
|
|
assert len(r["scars"]) == 1 and len(r["confidence"]) == 1 and len(r["resets"]) == 1
|
|
|
|
|
|
def test_session_state_readback(lyra):
|
|
_, poker, _, tools = lyra
|
|
assert "no live session" in tools.dispatch("session_state", {}, {}).lower()
|
|
|
|
poker.start_session(venue="Meadows", stakes="2/5", buy_in=500)
|
|
tools.dispatch("log_stack", {"amount": 720}, {})
|
|
tools.dispatch("confidence_bank", {"content": "great river fold"}, {})
|
|
tools.dispatch("alligator_blood", {"on": True}, {})
|
|
|
|
out = tools.dispatch("session_state", {}, {})
|
|
assert "720" in out # current stack
|
|
assert "+220" in out or "220" in out # live net
|
|
assert "Alligator Blood is ON" in out
|
|
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
|
|
poker.start_session(venue="Meadows", stakes="2/5", buy_in=500)
|
|
poker.log_hand(position="UTG", hole_cards="AA")
|
|
poker.log_hand(position="BTN", hole_cards="72o")
|
|
poker.log_stack(600)
|
|
poker.log_stack(420)
|
|
poker.log_ritual("scar", content="punted")
|
|
poker.log_ritual("confidence", content="good fold")
|
|
|
|
# undo removes the most recent of each kind
|
|
assert "72o" in poker.undo_last("hand")
|
|
assert [h["hole_cards"] for h in poker.list_hands()] == ["AA"] # h2 gone, h1 stays
|
|
assert "420" in poker.undo_last("stack")
|
|
assert poker.current_stack() == 600
|
|
assert "punted" in poker.undo_last("scar")
|
|
assert not poker.list_rituals(kinds=("scar",))
|
|
assert poker.list_rituals(kinds=("confidence",)) # untouched
|
|
assert poker.undo_last("hand") is not None # h1
|
|
assert poker.undo_last("hand") is None # nothing left
|
|
|
|
# direct delete-by-id dispatch
|
|
assert poker.delete_entry("ritual", poker.list_rituals(kinds=("confidence",))[0]["id"]) is True
|
|
assert poker.delete_entry("bogus", 1) is False
|
|
|
|
|
|
def test_undo_last_tool(lyra):
|
|
_, poker, _, tools = lyra
|
|
poker.start_session(stakes="1/3", buy_in=300)
|
|
poker.log_hand(position="CO", hole_cards="KK")
|
|
out = tools.dispatch("undo_last", {"what": "hand"}, {})
|
|
assert "scratched" in out.lower() and poker.list_hands() == []
|
|
# no live session -> graceful
|
|
poker.end_session(cash_out=300)
|
|
assert "no live session" in tools.dispatch("undo_last", {"what": "hand"}, {}).lower()
|
|
# nonsense target
|
|
poker.start_session(stakes="1/3", buy_in=100)
|
|
assert "one of" in tools.dispatch("undo_last", {"what": "banana"}, {}).lower()
|
|
|
|
|
|
def test_update_session_edit(lyra):
|
|
_, poker, modes, tools = lyra
|
|
assert "update_session" in modes.CASH.tools
|
|
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
|
|
s = poker.update_session(sid, stakes="2/5", buy_in_total=600, cash_out=900, venue="Bellagio")
|
|
assert s["stakes"] == "2/5" and s["venue"] == "Bellagio"
|
|
assert s["buy_in_total"] == 600 and s["cash_out"] == 900
|
|
assert s["net"] == 300 # recomputed from cash_out - buy_in
|
|
# via the tool (edits the live/most-recent session)
|
|
out = tools.dispatch("update_session", {"mood": "locked in"}, {})
|
|
assert "updated" in out.lower() and poker.get_session(sid)["mood"] == "locked in"
|
|
assert "what to change" in tools.dispatch("update_session", {}, {}).lower()
|
|
|
|
|
|
def test_review_session_and_post_close_rituals(lyra):
|
|
_, poker, _, tools = lyra
|
|
sid = poker.start_session(venue="Meadows", stakes="2/5", buy_in=500)
|
|
poker.end_session(cash_out=720)
|
|
assert poker.live_session() is None
|
|
assert poker.review_session_id() == sid # most-recent closed session
|
|
|
|
# rituals attach to the closed session during review (no live session needed)
|
|
out = tools.dispatch("scar_note", {"content": "should've folded turn", "classification": "punt"}, {})
|
|
assert "logged" in out.lower()
|
|
tools.dispatch("confidence_bank", {"content": "good thin value river"}, {})
|
|
assert len(poker.list_rituals(session_id=sid, kinds=("scar",))) == 1
|
|
assert len(poker.list_rituals(session_id=sid, kinds=("confidence",))) == 1
|
|
|
|
|
|
def test_hud_for_past_session(lyra):
|
|
_, poker, _, _ = lyra
|
|
sid = poker.start_session(venue="Meadows", stakes="2/5", buy_in=500)
|
|
poker.log_hand(position="BTN", hole_cards="AKs")
|
|
poker.end_session(cash_out=650)
|
|
# a *new* live session so live HUD != the one we query
|
|
poker.start_session(venue="Wynn", stakes="1/3", buy_in=300)
|
|
past = poker.hud(sid)
|
|
assert past["session"]["id"] == sid and past["session"]["is_live"] is False
|
|
assert past["session"]["net"] == 150 and len(past["hands"]) == 1
|
|
assert poker.hud()["session"]["venue"] == "Wynn" # live one unaffected
|
|
|
|
|
|
def test_list_and_delete_session(lyra):
|
|
_, poker, _, tools = lyra
|
|
keep = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
|
|
poker.end_session(cash_out=400, session_id=keep)
|
|
drop = poker.start_session(venue="Bellagio", stakes="2/5", buy_in=500)
|
|
poker.log_hand(position="BTN", hole_cards="AKs", session_id=drop)
|
|
poker.log_stack(620, session_id=drop)
|
|
poker.log_ritual("scar", content="punt", session_id=drop)
|
|
|
|
sessions = poker.list_sessions()
|
|
assert {s["id"] for s in sessions} == {keep, drop}
|
|
assert next(s for s in sessions if s["id"] == drop)["hands"] == 1
|
|
|
|
removed = poker.delete_session(drop)
|
|
assert removed["poker_sessions"] == 1 and removed["poker_hands"] == 1
|
|
assert removed["poker_stack_log"] == 1 and removed["poker_rituals"] == 1
|
|
assert {s["id"] for s in poker.list_sessions()} == {keep} # only the survivor
|
|
assert poker.get_session(drop) is None
|
|
|
|
|
|
def test_recent_sessions_tool(lyra):
|
|
_, poker, modes, tools = lyra
|
|
assert "recent_sessions" in modes.TALK.tools # available even when just talking
|
|
poker.import_session(date="2026-06-01", venue="Meadows", stakes="1/3",
|
|
buy_in_total=300, cash_out=520, hours=5)
|
|
out = tools.dispatch("recent_sessions", {}, {})
|
|
assert "Meadows" in out and "+220" in out
|
|
|
|
|
|
def test_rituals_require_a_session(lyra):
|
|
_, poker, _, tools = lyra
|
|
# with no session at all, the tool degrades gracefully (no exception)
|
|
assert "no session" in tools.dispatch("scar_note", {"content": "x"}, {}).lower()
|
|
with pytest.raises(ValueError):
|
|
poker.log_ritual("scar", content="x")
|