From 2be43848a7081de491f4f68ae0698c11640faa95 Mon Sep 17 00:00:00 2001 From: serversdown Date: Sat, 4 Jul 2026 02:58:02 +0000 Subject: [PATCH] fix: don't attribute observed hands to the hero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lyra/poker.py | 23 +++++++++++++++++++++-- lyra/tools.py | 10 +++++++++- tests/test_hand_schema.py | 23 +++++++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/lyra/poker.py b/lyra/poker.py index dfe02b3..8d928c6 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -662,8 +662,9 @@ Schema: { "game": "NLH" | "PLO" | ..., "stakes": "", - "hero_pos": "", - "hero_cards": ["As","Ax", ...], // rank+suit (s/h/d/c); 'x' suit if unknown e.g. "Ax"; "x" for a fully unknown card + "hero_involved": true | false, // is the narrator actually IN this hand? (see HERO rule) + "hero_pos": "", + "hero_cards": ["As","Ax", ...], // hero's cards; [] / null if hero not involved. rank+suit (s/h/d/c); 'x' suit if unknown e.g. "Ax"; "x" for a fully unknown card "players": [ // every player mentioned, incl. hero {"pos": "", "stack": , "name": , "cards": [".."]|null} ], @@ -676,6 +677,17 @@ Schema: "result": {"pot": , "hero_net": , "summary": ""} } +HERO — who the hand belongs to. The narrator writes as "I/me/my". Set \ +hero_involved=true and fill hero_pos + hero_cards ONLY if he was actually dealt in and \ +playing THIS hand. If he's describing a hand he WATCHED between other players — he wasn't \ +in it (e.g. "two guys got it in", "the neck-tattoo reg stacked the whale", it's all names/\ +seats with no I/me/my holding cards) — set hero_involved=false, hero_pos=null, hero_cards=null, \ +and just record those players in players[] and their action. NEVER attribute another player's \ +cards, position, or result to the hero. When hero isn't involved, result.hero_net=null (a pot \ +he wasn't in didn't win or lose him anything). Example: "the lag in the CO stacked off KK vs \ +the nit's AA" -> hero_involved=false, hero_pos=null, hero_cards=null, players=[{pos:CO,cards:\ +["Kx","Kx"]},{pos:?,cards:["Ax","Ax"]}]. + Rules: infer positions and street order sensibly. Amounts are plain numbers (no $). \ NEVER invent suits or cards. A card is rank+suit where suit is one of s/h/d/c; if the suit \ wasn't stated, use 'x' for the suit (e.g. "Ax","Kx","4x"); if a whole card wasn't stated, \ @@ -792,6 +804,13 @@ def normalize_structured(parsed: dict) -> dict: return parsed p = dict(parsed) p["schema_version"] = HAND_SCHEMA_VERSION + # Observed hand (Brian watched it, wasn't in it): never pin cards/position/result + # to the hero. Explicit false is the signal; a null hero_pos is treated the same. + if p.get("hero_involved") is False: + p["hero_pos"] = None + p["hero_cards"] = [] + if isinstance(p.get("result"), dict): + p["result"] = {**p["result"], "hero_net": None} 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 [])] diff --git a/lyra/tools.py b/lyra/tools.py index 32742e6..3b94fd4 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -425,8 +425,16 @@ def _record_hand(args: dict, ctx: dict) -> str: if not out["id"]: return "I couldn't parse that hand — give it to me again with a little more detail?" p = out["parsed"] + hero_in = p.get("hero_involved") is not False and bool(p.get("hero_pos")) + logbus.log("info", "hand reconstructed", id=out["id"], hero=p.get("hero_pos"), + hero_involved=hero_in) + if not hero_in: + # A hand Brian watched between other players — not his. + who = ", ".join(pl.get("name") or pl.get("pos") or "?" + for pl in (p.get("players") or [])[:3]) or "the table" + return (f"Logged hand #{out['id']} — an observed hand ({who}), not yours. " + f"View it at /hand/{out['id']}") cards = " ".join(p.get("hero_cards") or []) - logbus.log("info", "hand reconstructed", id=out["id"], hero=p.get("hero_pos")) return (f"Hand #{out['id']} reconstructed — {p.get('hero_pos') or '?'} " f"{cards}. View/replay it at /hand/{out['id']}") diff --git a/tests/test_hand_schema.py b/tests/test_hand_schema.py index 99a5039..d16cca8 100644 --- a/tests/test_hand_schema.py +++ b/tests/test_hand_schema.py @@ -45,6 +45,29 @@ def test_stamps_version(poker): 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