feat(prompting): Phase B step 1 — poker_prompts module (classifier + BASE + fragments)

The standalone piece, not yet wired. lyra/poker_prompts.py:
- classify(user_msg, roster_handles=()) — pure 7-type classifier
  (READ|HAND|TABLE|MENTAL|STATUS|LOG|CHAT), READ ranked above HAND so a villain's
  action lands on their file not Brian's. Roster-aware (seated handles passed in),
  handles poker shorthand (AKs) and strategy questions (→ CHAT not a logged hand).
  The swappable seam for an LLM/MI50 classifier later.
- BASE — lean always-on poker rules distilled from the monolith (log-first + tool
  routing, identity rules, rituals, equity, session_state).
- FRAGMENTS + fragment_for — per-type response-shape contracts.

13 unit tests incl. the READ↔HAND boundary. Full suite 193 green. Wiring next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 02:54:59 +00:00
parent ad1087e630
commit 5380a00395
2 changed files with 298 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
"""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).
_ACTION = re.compile(
r"\b(?:limp(?:ed|s)?|call(?:ed|s)?|rais(?:e|ed|es)|bet(?:s)?|check(?:ed|s)?|"
r"fold(?:ed|s)?|shov(?:e|ed|es)|jam(?:med|s)?|3-?bet(?:s|ted)?|4-?bet(?:s|ted)?|"
r"open(?:ed|s)?|straddl(?:e|ed|es)|stack(?:ed|s)?|flat(?:ted|s)?|donk(?:ed|s)?)\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)\b",
re.I,
)
# Bare money/result prose (a fact to log that slipped past the quick-capture box).
_MONEY = re.compile(
r"(?:\$?\d{2,5})\b|\b(?:stack|down to|up to|out for|cashed|rebought|rebuy|buy ?in|"
r"felted|stuck|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
# "the neck-tattoo guy 3bet" — a descriptor subject.
if re.search(r"\bthe .+?\b(?:guy|reg|kid|player|villain|man|woman|lady|fish|whale|nit|lag|maniac)\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 with a tool action.
if _TABLE.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.
if _MONEY.search(low):
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, \
then (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. Call analyze_spot for any close equity/who's-ahead spot — never eyeball. Name leaks \
plainly (owning value, missed value, sizing); give ONE real opinion. NO reflexive praise ("nice \
hand"). 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 12 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"])