Files
project-lyra/lyra/poker_prompts.py
T
serversdown 5380a00395 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>
2026-07-05 02:54:59 +00:00

216 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"])