366e71a384
The HAND fragment let her narrate a finished board from memory: she called
quad kings "kings full" and never noticed Brian FLOPPED quads, then wrapped it
in variance-evens-out / resilience filler. Two fixes to _F_HAND:
- Correctness: at a showdown where both hands are known, call analyze_spot on
the full board to confirm made hands + winner BEFORE commenting; name his hand
class by the street it mattered ("flopped quads"). The eval already existed —
she just never reached for it on a resolved hand. (It also catches impossible
cards, e.g. a villain card already on the board.)
- Register: ban reflexive praise / variance-evens-out / life-lesson / cross-hand
pep talk; if there's genuinely no leak, say so instead of inventing a takeaway.
Surgical here; the full voice pass stays with the persona branch.
Verified live on the exact quad-kings hand (cloud/gpt-4o-mini): she now logs,
calls analyze_spot, reads quads-vs-quads correctly, and calls it a cooler with
no leak. Guard tests added. Full suite 212 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
234 lines
13 KiB
Python
234 lines
13 KiB
Python
"""Poker-mode prompting: classify the turn, inject a small per-type contract.
|
||
|
||
Replaces the one big `_CASH_CARD` monolith (which was sent every turn) with a lean
|
||
always-on BASE + exactly ONE response-shape fragment chosen by `classify`. BASE
|
||
carries what's true regardless of the message (tool routing, identity rules,
|
||
rituals, equity); the fragment carries how to *respond* to this specific kind of
|
||
message. See docs/superpowers/specs/2026-07-01-poker-prompts-design.md.
|
||
|
||
`classify` is a pure function of (message, seated roster handles) — no DB, unit-
|
||
tested like `perceive.read`. It's the swappable seam: a heuristic today, an
|
||
LLM/MI50 classifier later behind the same signature.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
|
||
# --- classifier -----------------------------------------------------------
|
||
|
||
MSG_TYPES = ("READ", "HAND", "TABLE", "MENTAL", "STATUS", "LOG", "CHAT")
|
||
|
||
# A card like "As", "Td", "9c" (rank+suit). Two+ of these ≈ a described hand.
|
||
_CARD = re.compile(r"\b(?:10|[2-9TJQKA])[shdc]\b", re.I)
|
||
# Hand-class shorthand: AKs, QJo, T9s ("s"/"o" = suited/offsuit, not a suit).
|
||
_HANDCLASS = re.compile(r"\b[2-9TJQKA]{2}[so]\b", re.I)
|
||
# A question (strategy talk) rather than a hand narration to log.
|
||
_QUESTION = re.compile(r"\?\s*$|^\s*(?:should|would|could|was|were|is|are|do|did|how|what|why|when|which)\b", re.I)
|
||
# Table positions / structural poker terms.
|
||
_POS = re.compile(r"\b(?:utg|mp|lj|hj|co|btn|button|hijack|cutoff|sb|bb|straddle|straddled)\b", re.I)
|
||
_STREET = re.compile(r"\b(?:preflop|flop|turn|river|board|runout)\b", re.I)
|
||
# A poker ACTION a player takes (vs a table-op verb below). Includes -ing forms
|
||
# ("TAG's been limping") since those are common in live reads.
|
||
_ACTION = re.compile(
|
||
r"\b(?:limp(?:ed|s|ing)?|call(?:ed|s|ing)?|rais(?:e|ed|es|ing)|bet(?:s|ting)?|"
|
||
r"check(?:ed|s|ing)?|fold(?:ed|s|ing)?|shov(?:e|ed|es|ing)|jam(?:med|s|ming)?|"
|
||
r"3-?bet(?:s|ted|ting)?|4-?bet(?:s|ted|ting)?|open(?:ed|s|ing)?|"
|
||
r"straddl(?:e|ed|es|ing)|stack(?:ed|s|ing)?|flat(?:ted|s|ting)?|donk(?:ed|s|ing)?)\b",
|
||
re.I,
|
||
)
|
||
# A player LEAVING the table (departure) — routes to TABLE (unseat) when the actor
|
||
# isn't Brian himself.
|
||
_DEPART = re.compile(
|
||
r"\b(?:busted(?: out)?|left(?: the table)?|took off|racked up|stood up|got up|"
|
||
r"is gone|took a walk|quit(?:s|ting)?)\b", re.I)
|
||
_FIRST_PERSON = re.compile(r"\b(?:i|i'm|im|i've|my|me|myself|mine)\b", re.I)
|
||
# Leading capitalized words that are poker VERBS, not player names (so a hand
|
||
# narrated without "I" — "Flopped a set, bet the river" — isn't read as a villain).
|
||
_POKER_VERB_LEAD = frozenset((
|
||
"flopped", "turned", "rivered", "bet", "raised", "called", "folded", "checked",
|
||
"shoved", "jammed", "limped", "straddled", "opened", "hit", "made", "got", "had",
|
||
"won", "lost", "stacked", "flatted", "3bet", "4bet", "cold", "min",
|
||
))
|
||
|
||
# Roster/table operations — these DO something (seat/clear/unseat).
|
||
_TABLE = re.compile(
|
||
r"\b(?:seat the table|seat (?:me |them |him )?|table broke|they broke us|broke the table|"
|
||
r"got moved|moved tables|moved to (?:a |another )?(?:new )?table|switch(?:ed|ing)? tables|"
|
||
r"new table|table change|racked up and|busted out|left the table|sat down|new guy in seat)\b",
|
||
re.I,
|
||
)
|
||
# Feelings / mental game (first-person emotional state).
|
||
_MENTAL = re.compile(
|
||
r"\b(?:tilt(?:ed|ing)?|steam(?:ing|ed)?|on tilt|fried|tired|exhausted|frustrat(?:ed|ing)|"
|
||
r"pissed|angry|annoyed|stuck|bored|checked out|in my head|mental|rattled|spewy|"
|
||
r"confiden(?:t|ce)|steady|card ?dead|feel like|i feel|losing my mind|going crazy|"
|
||
r"cooler(?:ed)?|sick(?: of)?|brutal|run(?:ning)? (?:so |real |bad)|disgust(?:ed|ing)?|"
|
||
r"fed up|hate this|can'?t win|miserable|deflated|demoralized|over it)\b",
|
||
re.I,
|
||
)
|
||
# Bare money/result prose (a fact to log that slipped past the quick-capture box).
|
||
# Needs an actual number OR a strong result keyword — the bare word "stack" is too
|
||
# eager (it appears in questions like "should I stack off?").
|
||
_MONEY = re.compile(
|
||
r"\b\d{2,5}\b|\b(?:down to|up to|out for|cashed|rebought|rebuy|buy ?in|felted|booked)\b",
|
||
re.I,
|
||
)
|
||
# Pure logistics (no cards, no roster action) — a neutral update, not a mood.
|
||
_STATUS = re.compile(
|
||
r"\b(?:waiting for a seat|on the list|seat opened|heading (?:to|out)|grabbing|break|"
|
||
r"bathroom|food|dinner|lunch|be right back|brb|\d{1,2}[:.]?\d{0,2}\s*(?:am|pm)|"
|
||
r"o'?clock|almost|about to)\b", re.I,
|
||
)
|
||
|
||
|
||
def _has_action(low: str) -> bool:
|
||
return bool(_ACTION.search(low))
|
||
|
||
|
||
def _looks_like_hand(low: str, msg: str) -> bool:
|
||
"""Card content that reads as a described (loggable) hand — not a strategy question."""
|
||
if len(_CARD.findall(low)) >= 2 or _HANDCLASS.search(low) or _POS.search(low):
|
||
return True
|
||
# A street + action narration ("...bet $40 on the river, he folded") is a hand,
|
||
# but "should I have folded the river?" is a question → CHAT, not a logged hand.
|
||
return bool(_STREET.search(low)) and _has_action(low) and not _QUESTION.search(msg)
|
||
|
||
|
||
def _read_subject(msg: str, low: str, roster_handles) -> bool:
|
||
"""True if ANOTHER player (not Brian) is the actor — the signal for a READ."""
|
||
# A seated handle named in the message is the strongest signal.
|
||
for h in roster_handles or ():
|
||
h = (h or "").strip().lower()
|
||
if h and re.search(rf"\b{re.escape(h)}\b", low):
|
||
return True
|
||
# An ALL-CAPS handle (TAG, JD) used as a token — a Bravo-style name.
|
||
if re.search(r"\b[A-Z]{2,}\b", msg):
|
||
return True
|
||
# A leading proper noun that isn't a poker verb ("Jonathan called ...").
|
||
m = re.match(r"([A-Z][a-zA-Z'’.]+)\b", msg)
|
||
if m and m.group(1).lower() not in _POKER_VERB_LEAD:
|
||
return True
|
||
# A descriptor subject: "the neck-tattoo guy 3bet", or a bare "the whale called"
|
||
# (zero words between "the" and the noun).
|
||
if re.search(r"\bthe [\w\s'-]{0,24}?(?:guy|reg|kid|player|villain|man|woman|lady|"
|
||
r"fish|whale|nit|lag|maniac|donk|reg)\b", low):
|
||
return True
|
||
return False
|
||
|
||
|
||
def classify(user_msg: str, roster_handles=()) -> str:
|
||
"""Message type for poker mode. Pure; roster_handles are the seated players
|
||
(passed in by the caller) so a villain's action resolves as READ, not HAND."""
|
||
msg = (user_msg or "").strip()
|
||
if not msg:
|
||
return "CHAT"
|
||
low = msg.lower()
|
||
first_person = bool(_FIRST_PERSON.search(low))
|
||
|
||
# 1) READ — another player did a poker action (beats HAND).
|
||
if _has_action(low) and not first_person and _read_subject(msg, low, roster_handles):
|
||
return "READ"
|
||
# 2) HAND — Brian's hand (first-person card/position/street content).
|
||
if _looks_like_hand(low, msg):
|
||
return "HAND"
|
||
# 3) TABLE — roster ops (seat/clear) or another player leaving (departure).
|
||
if _TABLE.search(low) or (not first_person and _DEPART.search(low)):
|
||
return "TABLE"
|
||
# 4) MENTAL — first-person feeling / mental game.
|
||
if _MENTAL.search(low):
|
||
return "MENTAL"
|
||
# 5) STATUS — pure logistics, no cards, no roster action.
|
||
if _STATUS.search(low):
|
||
return "STATUS"
|
||
# 6) LOG — bare money/result fact (a statement, not a strategy question).
|
||
if _MONEY.search(low) and not _QUESTION.search(msg):
|
||
return "LOG"
|
||
# 7) CHAT — open talk / questions.
|
||
return "CHAT"
|
||
|
||
|
||
# --- always-on base (poker) ----------------------------------------------
|
||
|
||
BASE = """You are copiloting Brian's LIVE cash game — at the table with him, a session open. \
|
||
Two things are always true:
|
||
|
||
LOG FIRST, then reply. If his message contains anything trackable, call the tool BEFORE you \
|
||
answer — every time — and NEVER claim you logged/seated/cleared something without actually \
|
||
calling the tool. Routing: his stack → log_stack (pass `note` with the why if he gives one). \
|
||
His own hand → record_hand. A VILLAIN's action (someone else did something) → add_read, with \
|
||
`name` for a real handle or `descriptor` for an unnamed player. A rebuy → add_buyin. Who's at \
|
||
the table → seat_players / unseat_player / clear_table. Catching a name for a player you'd been \
|
||
describing → name_villain. Confirmed same/different person → link_villains (never merge on a \
|
||
guess). For any equity / who's-ahead / outs question → analyze_spot; never eyeball board math. \
|
||
When he asks where he's at (stack, net, gator) → session_state, answer from what it returns.
|
||
|
||
IDENTITY RULES (villains): `name` is a REAL handle only (what he calls a person — "Jonathan", \
|
||
"TAG"); a physical description NEVER goes in `name` (it spawns duplicates) — put the look in \
|
||
`descriptor`, a few distinctive tags. A handle like "TAG" (initials/all-caps off Bravo) is a \
|
||
PERSON, never the tight-aggressive style. If a SCOUTING DESK note is in context with a player's \
|
||
history, cite it — don't re-fetch or invent; if unsure two references are the same person, ASK.
|
||
|
||
RITUALS (his mental-game system — run them, don't just mention them): scar_note (a punt/leak to \
|
||
study — classify honestly punt vs cooler vs standard), confidence_bank (good process regardless \
|
||
of result), alligator_blood (adversity mode — suggest when he's card-dead/stuck), reset_ritual \
|
||
(circuit-breaker after tilt). Never invent one that didn't happen. Use `note` for session \
|
||
narration — factual beats of the night (table texture, his arc), not your feelings. Money is in \
|
||
dollars. Everything you log shows on his live HUD."""
|
||
|
||
|
||
# --- per-type response fragments -----------------------------------------
|
||
|
||
_F_READ = """MESSAGE TYPE: READ — a villain did something and he wants it on their file. Call \
|
||
add_read(name|descriptor, note) FIRST, before replying — this is the log that keeps getting \
|
||
missed. Attach to the seated handle if he named one; use `descriptor` if the player's unnamed. \
|
||
Confirm in ONE short line ("Noted on TAG — limped A4o SB."). At most one crisp exploit read if \
|
||
it's worth it; the log is mandatory, the commentary optional. Do NOT analyze it as Brian's hand."""
|
||
|
||
_F_HAND = """MESSAGE TYPE: HAND. First: was Brian IN this hand? If he only WATCHED it (no I/me/my \
|
||
holding cards — two other players), it's really observed: log the players' actions as reads / \
|
||
record it as an observed hand, and do NOT analyze it as his. If it's HIS hand → record_hand first. \
|
||
Then read the hand off the RECORDED cards, not by eye: name his made hand by the street it mattered \
|
||
(flopped/turned/rivered top pair / set / quads / etc.). At a SHOWDOWN where his and the caller's \
|
||
cards are both known, call analyze_spot(hero, villain, full board) to confirm the made hands and \
|
||
who won BEFORE you comment — NEVER eyeball a finished board (it also catches impossible cards). Same \
|
||
for any close equity / who's-ahead / outs spot. (NLH only) reason about BET INTENT: for each \
|
||
meaningful bet, what was it for (value / bluff / protection) and did it work — a fold to a value bet \
|
||
= value left behind; a call of a bluff = it failed. Name leaks plainly (owning value, missed value, \
|
||
sizing) and give ONE real opinion. If there's genuinely no leak (e.g. he flopped the near-nuts and \
|
||
stacked off), SAY so — don't manufacture a takeaway. NO reflexive praise ("nice hand"), NO \
|
||
variance-evens-out / resilience / life-lesson filler, NO cross-hand pep talk. If a named villain is \
|
||
referenced, use their profile/the scouting note — don't invent a read. PLO/non-NLH: log and replay \
|
||
it, offer at most a light read, do NOT attempt NLH-style equity. Prose, not a listicle."""
|
||
|
||
_F_TABLE = """MESSAGE TYPE: TABLE — roster management. "seat the table: …" → seat_players. A table \
|
||
change ("table broke", "I got moved", "switched tables") → clear_table, then wait for the new \
|
||
roster. Someone leaves/busts → unseat_player. Do the tool call, confirm ONE line, don't narrate. \
|
||
The session and his stack keep going through a table change — only who's seated resets."""
|
||
|
||
_F_MENTAL = """MESSAGE TYPE: MENTAL — he told you how he's feeling. This is when he needs you most. \
|
||
Drop the logging shorthand, full presence, your real voice — talk him down off tilt, hold him \
|
||
disciplined through a card-dead stretch, engage the mental game honestly. Suggest a ritual if it \
|
||
fits (alligator_blood when he's grinding adversity, reset_ritual after a tilt spike). Never a \
|
||
clipped confirmation, never bury him in analysis. Meet him first, then help."""
|
||
|
||
_F_STATUS = """MESSAGE TYPE: STATUS — pure logistics (time, waiting for a seat, a break). Acknowledge \
|
||
in 1–2 sentences, log a stack ONLY if a bare number is present, then stop. No coaching, no \
|
||
strategy dump, and do NOT read him as tilted/tired/impatient — a neutral update is not a mood."""
|
||
|
||
_F_LOG = """MESSAGE TYPE: LOG — a bare fact (stack / result / buyin) not already captured. Log it \
|
||
(log_stack / add_buyin), confirm in ONE short line ("$317 logged."), stop. No coaching."""
|
||
|
||
_F_CHAT = """MESSAGE TYPE: CHAT — open talk or a question that isn't a specific logged fact. Your \
|
||
real voice, an actual opinion, no filler sign-offs. If it's a concrete strategy spot with cards, \
|
||
engage it for real and call analyze_spot."""
|
||
|
||
FRAGMENTS = {
|
||
"READ": _F_READ, "HAND": _F_HAND, "TABLE": _F_TABLE, "MENTAL": _F_MENTAL,
|
||
"STATUS": _F_STATUS, "LOG": _F_LOG, "CHAT": _F_CHAT,
|
||
}
|
||
|
||
|
||
def fragment_for(msg_type: str | None) -> str:
|
||
"""The response-shape contract for a message type (CHAT is the fallback)."""
|
||
return FRAGMENTS.get(msg_type or "", FRAGMENTS["CHAT"])
|