feat: canonical structured-hand contract (Lyra->RTO transport)
Solidify hand histories into one versioned shape that gets stored, replayed, and exported — the foundation the tap recorder will emit into and RTO consumes. - normalize_structured(): single guarantee of the contract shape — canonical cards (unicode/10/case -> RankSuit tokens, unknown 'Ax'/'x' preserved), hero synced into players[] (RTO finds hero via pos==hero_pos), schema_version stamp, and a completeness summary so consumers skip suit-dependent math on partial hands. Idempotent; runs on store AND read (legacy rows conform on the way out). - list_recent_hands: has_structured flag so the export/RTO knows which hands have a replayable body worth fetching. - docs/HAND_HISTORY.md: the shared contract both repos cite (schema, conventions, ownership rule, one-way HTTP coupling, transport endpoints). - replaces the narrow _normalize_parsed (unicode-only) everywhere. Card format chosen: lists of 2-char tokens (unambiguous, matches what Lyra already stores + the viewer reads). Unknowns kept + flagged rather than dropped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+89
-15
@@ -651,38 +651,102 @@ def _review_session_id() -> int:
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
# --- the canonical structured-hand contract (see docs/HAND_HISTORY.md) ---------
|
||||
# This is the single shape that gets stored, replayed by the viewer, and exported to
|
||||
# RTO. The LLM parser produces it today; the tap recorder will produce it natively.
|
||||
HAND_SCHEMA_VERSION = 1
|
||||
POSITIONS = ("UTG", "UTG1", "UTG2", "MP", "LJ", "HJ", "CO", "BTN", "SB", "BB")
|
||||
ACTION_VERBS = ("post", "fold", "check", "call", "bet", "raise", "allin")
|
||||
STREETS = ("preflop", "flop", "turn", "river")
|
||||
|
||||
_SUIT_SYM = {"♥": "h", "♦": "d", "♣": "c", "♠": "s"}
|
||||
|
||||
|
||||
def _norm_card(c):
|
||||
"""Canonicalize one card string: unicode suit -> letter, '10' -> 'T', rank upper,
|
||||
suit lower (e.g. '10♥' -> 'Th', 'as' -> 'As'). Unknown placeholders are preserved:
|
||||
'Ax' = known rank/unknown suit, 'x' = fully unknown card."""
|
||||
if not isinstance(c, str):
|
||||
return c
|
||||
s = c.strip()
|
||||
for sym, ltr in _SUIT_SYM.items():
|
||||
s = s.replace(sym, ltr)
|
||||
s = s.replace("10", "T")
|
||||
if len(s) == 2:
|
||||
s = s[0].upper() + s[1].lower() # 'Ax' stays 'Ax'; 'x' (len 1) untouched
|
||||
return s
|
||||
|
||||
|
||||
def _normalize_parsed(p: dict) -> dict:
|
||||
"""Normalize card strings (unicode suits -> letters) across a parsed hand."""
|
||||
if not isinstance(p, dict):
|
||||
return p
|
||||
for key in ("hero_cards", "board"):
|
||||
if isinstance(p.get(key), list):
|
||||
p[key] = [_norm_card(c) for c in p[key]]
|
||||
def _card_known(c) -> bool:
|
||||
"""True only for a fully specified card (rank+suit, no 'x' placeholder)."""
|
||||
return isinstance(c, str) and len(c) == 2 and "x" not in c.lower()
|
||||
|
||||
|
||||
def _completeness(p: dict) -> dict:
|
||||
"""Which parts of the hand are fully specified — lets a consumer (RTO) use what it
|
||||
can and skip suit-dependent math (flushes) on hands where suits weren't recorded."""
|
||||
shown = [c for pl in (p.get("players") or []) if isinstance(pl.get("cards"), list)
|
||||
for c in pl["cards"]]
|
||||
hole = list(p.get("hero_cards") or []) + shown
|
||||
return {
|
||||
"cards": bool(hole) and all(_card_known(c) for c in hole),
|
||||
"board": all(_card_known(c) for c in (p.get("board") or [])),
|
||||
"actions": bool(p.get("actions")),
|
||||
}
|
||||
|
||||
|
||||
def normalize_structured(parsed: dict) -> dict:
|
||||
"""Canonicalize a structured hand — from the LLM parser OR (later) the tap recorder —
|
||||
into the versioned contract shape: normalized cards, the hero synced into players[]
|
||||
(RTO finds the hero via pos == hero_pos), a schema_version stamp, and a completeness
|
||||
summary. Idempotent — the single shape stored, replayed, and exported."""
|
||||
if not isinstance(parsed, dict):
|
||||
return parsed
|
||||
p = dict(parsed)
|
||||
p["schema_version"] = HAND_SCHEMA_VERSION
|
||||
p["hero_cards"] = [_norm_card(c) for c in (p.get("hero_cards") or [])]
|
||||
p["board"] = [_norm_card(c) for c in (p.get("board") or [])]
|
||||
|
||||
players = []
|
||||
for pl in p.get("players") or []:
|
||||
if isinstance(pl, dict) and isinstance(pl.get("cards"), list):
|
||||
if not isinstance(pl, dict):
|
||||
continue
|
||||
pl = dict(pl)
|
||||
if isinstance(pl.get("cards"), list):
|
||||
pl["cards"] = [_norm_card(c) for c in pl["cards"]]
|
||||
pl.pop("hero", None) # recomputed below so it can't go stale
|
||||
players.append(pl)
|
||||
|
||||
# Hero must appear in players[] (with cards) — RTO reads the hero off pos==hero_pos.
|
||||
hero_pos = p.get("hero_pos")
|
||||
if hero_pos:
|
||||
hero = next((pl for pl in players if pl.get("pos") == hero_pos), None)
|
||||
if hero is None:
|
||||
hero = {"pos": hero_pos}
|
||||
players.insert(0, hero)
|
||||
hero["hero"] = True
|
||||
if p["hero_cards"] and not hero.get("cards"):
|
||||
hero["cards"] = list(p["hero_cards"])
|
||||
p["players"] = players
|
||||
|
||||
actions = []
|
||||
for a in p.get("actions") or []:
|
||||
if isinstance(a, dict) and isinstance(a.get("board"), list):
|
||||
if not isinstance(a, dict):
|
||||
continue
|
||||
a = dict(a)
|
||||
if isinstance(a.get("board"), list):
|
||||
a["board"] = [_norm_card(c) for c in a["board"]]
|
||||
actions.append(a)
|
||||
p["actions"] = actions
|
||||
|
||||
p["completeness"] = _completeness(p)
|
||||
return p
|
||||
|
||||
|
||||
def store_hand_history(parsed: dict, session_id: int | None = None,
|
||||
tag: str | None = None, lesson: str | None = None) -> int:
|
||||
"""Store a parsed hand: full JSON + extracted flat fields for stats/listing."""
|
||||
parsed = _normalize_parsed(parsed)
|
||||
parsed = normalize_structured(parsed)
|
||||
sid = _resolve(session_id) or _review_session_id()
|
||||
hero_cards = parsed.get("hero_cards") or []
|
||||
board = parsed.get("board") or []
|
||||
@@ -736,7 +800,7 @@ def reconstruct_hand(hand_id: int, backend: str | None = None) -> dict | None:
|
||||
parsed = parse_hand(shorthand, backend=backend)
|
||||
if not parsed:
|
||||
return None
|
||||
parsed = _normalize_parsed(parsed)
|
||||
parsed = normalize_structured(parsed)
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute("UPDATE poker_hands SET structured = ? WHERE id = ?",
|
||||
@@ -751,19 +815,29 @@ def get_hand(hand_id: int) -> dict | None:
|
||||
if not r:
|
||||
return None
|
||||
d = dict(r)
|
||||
d["structured"] = json.loads(d["structured"]) if d.get("structured") else None
|
||||
# Normalize on read too: legacy rows predate the contract, and it's idempotent for
|
||||
# new ones — so /hand/{id}/data always serves the current versioned shape.
|
||||
d["structured"] = normalize_structured(json.loads(d["structured"])) if d.get("structured") else None
|
||||
return d
|
||||
|
||||
|
||||
def list_recent_hands(limit: int = 60) -> list[dict]:
|
||||
"""Recent recorded hands with their session's venue/stakes, for browsing."""
|
||||
"""Recent recorded hands with their session's venue/stakes, for browsing. Each carries
|
||||
has_structured so a consumer (the export, RTO) knows which hands have a replayable
|
||||
structured body worth fetching via /hand/{id}/data vs. flat quick-logs."""
|
||||
rows = _c().execute(
|
||||
"SELECT h.id, h.position, h.hole_cards, h.board, h.result, h.tag, h.at, "
|
||||
"h.lesson, s.venue AS venue, s.stakes AS stakes "
|
||||
"h.lesson, (h.structured IS NOT NULL) AS has_structured, "
|
||||
"s.venue AS venue, s.stakes AS stakes "
|
||||
"FROM poker_hands h LEFT JOIN poker_sessions s ON s.id = h.session_id "
|
||||
"ORDER BY h.id DESC LIMIT ?", (limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["has_structured"] = bool(d["has_structured"])
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
# --- session recap (.md generation on top of structured data + conversation) ---
|
||||
|
||||
Reference in New Issue
Block a user