fix(poker): idempotent hand logging + straddle capture
Two issues from live testing:
- Double-logged hand. The chat turn can execute TWICE — the SSE stream and the
blocking fallback both run server-side (two 'chat request' lines, 1s apart) — a
pre-existing double-execution (it also duplicated user messages) that the new
logging guarantee turned into duplicate HANDS. record_hand is now idempotent:
_recent_duplicate_hand returns an identical hand (same session, hole cards, board;
NULL-safe) recorded in the last few minutes, so the second run reuses it instead
of inserting. A system-of-record records an event once.
- Button straddle dropped. The parse prompt had no straddle logic. Added a STRADDLES
rule: record any straddle as a preflop `post` by the straddler with its amount and
respect the action order (button straddle acts last preflop, action opens in the
SB; UTG straddle opens to its left). Verified: a btn-straddle hand now parses the
straddle as {pos: BTN, action: post, amount: 6}.
Note: the underlying double turn-execution (stream + fallback) is a separate web-layer
bug worth fixing at the source — it wastes a full LLM turn and still double-persists
chat messages. Filed for a follow-up. 6 tests; suite 223 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+32
-2
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import numpy as np
|
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 \
|
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.
|
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. \
|
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) \
|
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 \
|
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)
|
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,
|
def record_hand(shorthand: str, session_id: int | None = None, stakes: str | None = None,
|
||||||
tag: str | None = None, lesson: str | None = None,
|
tag: str | None = None, lesson: str | None = None,
|
||||||
backend: str | None = None) -> dict:
|
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)
|
parsed = parse_hand(shorthand, stakes=stakes, backend=backend)
|
||||||
if not parsed:
|
if not parsed:
|
||||||
return {"id": None, "parsed": None}
|
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)
|
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
|
linked = link_hand_players(hid, parsed, session_id=session_id) # enrich villain files
|
||||||
return {"id": hid, "parsed": parsed, "linked": linked}
|
return {"id": hid, "parsed": parsed, "linked": linked}
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user