2be43848a7
When Brian narrates a hand he watched between OTHER players, the parser was still filling hero_pos/hero_cards — pinning someone else's cards, position, and result to him. Now: - parser prompt adds hero_involved detection: fill hero_pos/hero_cards ONLY if he was actually in the hand; a hand he only watched has hero_involved=false and null hero fields, with the other players recorded normally. - normalize_structured enforces it as a safety net (hero_involved=false → null hero_pos/cards, hero_net) even if the model slips. - record_hand tool confirms an observed hand as "not yours" instead of implying it was his. 2 tests. Restarting web for the live session. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
127 lines
5.0 KiB
Python
127 lines
5.0 KiB
Python
"""The canonical structured-hand contract (docs/HAND_HISTORY.md): normalize + export.
|
|
|
|
normalize_structured() is the single guarantee that every stored / replayed / exported
|
|
hand has the versioned shape RTO consumes.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def poker(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)
|
|
return poker
|
|
|
|
|
|
def _full_hand():
|
|
return {
|
|
"game": "NLH", "stakes": "1/3", "hero_pos": "BTN",
|
|
"hero_cards": ["ah", "kh"],
|
|
"players": [
|
|
{"pos": "BTN", "stack": 300, "name": "Hero"},
|
|
{"pos": "BB", "stack": 250, "name": "Sal", "cards": ["qs", "qd"]},
|
|
],
|
|
"actions": [
|
|
{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 15},
|
|
{"street": "flop", "board": ["7♦", "2♣", "5♥"]},
|
|
{"street": "flop", "pos": "BB", "action": "check"},
|
|
],
|
|
"board": ["7♦", "2♣", "5♥"],
|
|
"result": {"pot": 40, "hero_net": 25, "summary": "won at showdown"},
|
|
}
|
|
|
|
|
|
def test_stamps_version(poker):
|
|
out = poker.normalize_structured({"hero_pos": "CO"})
|
|
assert out["schema_version"] == poker.HAND_SCHEMA_VERSION
|
|
|
|
|
|
def test_observed_hand_never_attributed_to_hero(poker):
|
|
# Brian narrated a hand between two other players — hero_involved=false.
|
|
out = poker.normalize_structured({
|
|
"hero_involved": False,
|
|
"hero_pos": "CO", "hero_cards": ["Kx", "Kx"], # model slipped these in
|
|
"players": [{"pos": "CO", "cards": ["Kx", "Kx"]}, {"pos": "BB", "cards": ["Ax", "Ax"]}],
|
|
"result": {"pot": 600, "hero_net": 300},
|
|
})
|
|
assert out["hero_pos"] is None # not pinned to Brian
|
|
assert out["hero_cards"] == []
|
|
assert out["result"]["hero_net"] is None # a pot he wasn't in
|
|
assert not any(pl.get("hero") for pl in out["players"]) # nobody flagged hero
|
|
|
|
|
|
def test_hero_hand_still_attributed(poker):
|
|
out = poker.normalize_structured({
|
|
"hero_involved": True, "hero_pos": "BTN", "hero_cards": ["As", "Ks"],
|
|
"players": [{"pos": "BTN"}]})
|
|
assert out["hero_pos"] == "BTN"
|
|
hero = next(pl for pl in out["players"] if pl.get("pos") == "BTN")
|
|
assert hero.get("hero") and hero["cards"] == ["As", "Ks"]
|
|
|
|
|
|
def test_card_normalization(poker):
|
|
out = poker.normalize_structured(_full_hand())
|
|
assert out["hero_cards"] == ["Ah", "Kh"] # lowercased input -> canonical
|
|
assert out["board"] == ["7d", "2c", "5h"] # unicode suits -> letters
|
|
assert out["actions"][1]["board"] == ["7d", "2c", "5h"]
|
|
# ten + suit symbol together
|
|
assert poker.normalize_structured({"board": ["10♠"]})["board"] == ["Ts"]
|
|
|
|
|
|
def test_unknown_cards_preserved(poker):
|
|
out = poker.normalize_structured({"hero_cards": ["Ax", "x"], "board": ["Ax", "4x", "x"]})
|
|
assert out["hero_cards"] == ["Ax", "x"] # placeholders kept, not dropped
|
|
assert out["completeness"]["cards"] is False
|
|
assert out["completeness"]["board"] is False
|
|
|
|
|
|
def test_hero_synced_into_players(poker):
|
|
out = poker.normalize_structured(_full_hand())
|
|
hero = next(p for p in out["players"] if p["pos"] == "BTN")
|
|
assert hero["hero"] is True
|
|
assert hero["cards"] == ["Ah", "Kh"] # mirrored from hero_cards
|
|
assert sum(1 for p in out["players"] if p.get("hero")) == 1
|
|
|
|
|
|
def test_hero_inserted_when_missing_from_players(poker):
|
|
out = poker.normalize_structured({"hero_pos": "SB", "hero_cards": ["As", "Ad"], "players": []})
|
|
assert out["players"] == [{"pos": "SB", "hero": True, "cards": ["As", "Ad"]}]
|
|
|
|
|
|
def test_completeness_full_hand(poker):
|
|
c = poker.normalize_structured(_full_hand())["completeness"]
|
|
assert c == {"cards": True, "board": True, "actions": True}
|
|
|
|
|
|
def test_idempotent(poker):
|
|
once = poker.normalize_structured(_full_hand())
|
|
twice = poker.normalize_structured(once)
|
|
assert once == twice
|
|
|
|
|
|
def test_store_and_get_roundtrip_is_normalized(poker):
|
|
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
|
|
hid = poker.store_hand_history(_full_hand(), session_id=sid, tag="well_played")
|
|
got = poker.get_hand(hid)["structured"]
|
|
assert got["schema_version"] == poker.HAND_SCHEMA_VERSION
|
|
assert got["board"] == ["7d", "2c", "5h"]
|
|
assert got["completeness"]["cards"] is True
|
|
|
|
|
|
def test_list_recent_hands_flags_structured(poker):
|
|
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
|
|
structured_id = poker.store_hand_history(_full_hand(), session_id=sid)
|
|
flat_id = poker.log_hand(session_id=sid, position="CO", hole_cards="Jc Jd")
|
|
rows = {r["id"]: r for r in poker.list_recent_hands()}
|
|
assert rows[structured_id]["has_structured"] is True
|
|
assert rows[flat_id]["has_structured"] is False
|