diff --git a/lyra/poker.py b/lyra/poker.py index bd17040..dddbbcd 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -14,7 +14,7 @@ from __future__ import annotations import json import re -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import numpy as np @@ -730,6 +730,13 @@ NOT apply to another — e.g. your hole "ace of spades" is a different card from whose suit is unstated (that board ace is "Ax", not "As"). Use null/omit for non-card \ details not stated. Stay faithful to what's described — do not invent action that isn't implied. +STRADDLES: a straddle is a voluntary blind posted before the deal — always record it as a \ +preflop `post` action by the straddler with its amount, and respect the action order it creates. \ +A BUTTON straddle ("btn straddle", "straddle on the button") means the button posts (usually 2x \ +the BB) and acts LAST preflop, with first preflop action starting in the SB. A UTG straddle posts \ +from UTG and action starts to their left (UTG+1), the straddler acting last preflop. Keep the \ +straddler in players[] at their real seat; never drop the straddle. + POSITIONS: resolve relative seat references ("N seats to my right/left") into real positions. \ Action moves clockwise, so a player to your RIGHT acts before you (toward the blinds/button) \ and a player to your LEFT acts after you (toward UTG). Going RIGHT from a player you pass, in \ @@ -905,13 +912,36 @@ def store_hand_history(parsed: dict, session_id: int | None = None, return int(cur.lastrowid) +def _recent_duplicate_hand(parsed: dict, session_id: int | None, window_sec: int = 180) -> int | None: + """Id of an identical hand (same session, hole cards, board) recorded in the last few + minutes, else None. The chat turn can execute TWICE — the SSE stream and the blocking + fallback both run server-side — which would double-log the same hand; a system-of-record + must record an event once. `IS` is NULL-safe so a boardless/cardless hand matches too.""" + p = normalize_structured(parsed) + sid = _resolve(session_id) or _review_session_id() + hole = " ".join(p.get("hero_cards") or []) or None + board = " ".join(p.get("board") or []) or None + cutoff = (datetime.now(timezone.utc) - timedelta(seconds=window_sec)).isoformat() + row = _c().execute( + "SELECT id FROM poker_hands WHERE session_id = ? AND at >= ? " + "AND hole_cards IS ? AND board IS ? ORDER BY id DESC LIMIT 1", + (sid, cutoff, hole, board), + ).fetchone() + return int(row["id"]) if row else None + + def record_hand(shorthand: str, session_id: int | None = None, stakes: str | None = None, tag: str | None = None, lesson: str | None = None, backend: str | None = None) -> dict: - """Parse shorthand -> structured hand -> store. Returns {id, parsed} (id None on parse fail).""" + """Parse shorthand -> structured hand -> store. Returns {id, parsed} (id None on parse fail). + Idempotent: if this exact hand was just logged for the session (double turn execution), + returns the existing one instead of inserting a duplicate.""" parsed = parse_hand(shorthand, stakes=stakes, backend=backend) if not parsed: return {"id": None, "parsed": None} + dup = _recent_duplicate_hand(parsed, session_id) + if dup is not None: + return {"id": dup, "parsed": parsed, "linked": 0, "deduped": True} hid = store_hand_history(parsed, session_id=session_id, tag=tag, lesson=lesson) linked = link_hand_players(hid, parsed, session_id=session_id) # enrich villain files return {"id": hid, "parsed": parsed, "linked": linked} diff --git a/tests/test_hand_dedup.py b/tests/test_hand_dedup.py new file mode 100644 index 0000000..3e339bd --- /dev/null +++ b/tests/test_hand_dedup.py @@ -0,0 +1,67 @@ +"""record_hand idempotency + straddle parse coverage. + +The chat turn can execute twice — the SSE stream and the blocking fallback both run +server-side (two 'chat request' lines, 1s apart) — which double-logged the same hand +once logging became guaranteed. A system-of-record must record an event once.""" +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 + + +_PARSED = { + "game": "NLH", "hero_pos": "SB", "hero_cards": ["Ah", "Kh"], + "board": ["Kd", "9d", "4c", "2s"], "players": [], "actions": [], + "result": {"hero_net": -200, "pot": 400}, +} + + +def test_record_hand_is_idempotent_across_double_execution(poker, monkeypatch): + sid = poker.start_session(venue="Borgata", stakes="1/3", buy_in=400) + monkeypatch.setattr(poker, "parse_hand", lambda *a, **k: dict(_PARSED)) + first = poker.record_hand("i have AhKh in the SB, btn straddle, ...") + second = poker.record_hand("i have AhKh in the SB, btn straddle, ...") # the duplicate turn + assert first["id"] == second["id"] + assert second.get("deduped") is True + assert len(poker.list_hands(sid)) == 1 # ledger holds ONE, not two + + +def test_record_hand_does_not_dedupe_a_genuinely_different_hand(poker, monkeypatch): + sid = poker.start_session(venue="Borgata", stakes="1/3", buy_in=400) + monkeypatch.setattr(poker, "parse_hand", lambda *a, **k: dict(_PARSED)) + poker.record_hand("hand one") + other = dict(_PARSED, hero_cards=["Qs", "Qd"], board=["Qh", "7c", "2s"]) + monkeypatch.setattr(poker, "parse_hand", lambda *a, **k: dict(other)) + poker.record_hand("a different hand entirely") + assert len(poker.list_hands(sid)) == 2 # distinct hands both land + + +def test_dedupe_handles_boardless_hand(poker, monkeypatch): + # NULL-safe match: a preflop-only hand (no board) still dedupes. + sid = poker.start_session(venue="Borgata", buy_in=400) + preflop = {"game": "NLH", "hero_pos": "BTN", "hero_cards": ["As", "Ks"], + "board": [], "players": [], "actions": [], "result": {"hero_net": 30}} + monkeypatch.setattr(poker, "parse_hand", lambda *a, **k: dict(preflop)) + a = poker.record_hand("AKs btn, i open everyone folds") + b = poker.record_hand("AKs btn, i open everyone folds") + assert a["id"] == b["id"] and len(poker.list_hands(sid)) == 1 + + +def test_parse_prompt_records_straddles(): + from lyra import poker as pk + p = pk._HAND_PARSE_PROMPT.lower() + assert "straddle" in p and "button straddle" in p + assert "acts last preflop" in p or "act last preflop" in p