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 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}
|
||||
|
||||
Reference in New Issue
Block a user