diff --git a/lyra/poker_prompts.py b/lyra/poker_prompts.py new file mode 100644 index 0000000..0807f43 --- /dev/null +++ b/lyra/poker_prompts.py @@ -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 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"]) diff --git a/tests/test_poker_prompts.py b/tests/test_poker_prompts.py new file mode 100644 index 0000000..10c483e --- /dev/null +++ b/tests/test_poker_prompts.py @@ -0,0 +1,83 @@ +"""Poker-mode message classifier + fragment selection (pure, no DB).""" +from __future__ import annotations + +from lyra import poker_prompts as pp + + +def c(msg, roster=()): + return pp.classify(msg, roster) + + +# --- the spec's canonical cases --- + +def test_read_villain_action_beats_hand(): + # A villain's action carries cards+position+verb but is NOT Brian's hand. + assert c("TAG limped A4o in the SB (UTG straddled)") == "READ" + assert c("Jonathan called the 3bet") == "READ" + assert c("the neck-tattoo guy shoved the turn") == "READ" + + +def test_hand_is_first_person(): + assert c("Button straddle on. I limp UTG with 22. Flop 2d7cjh, I check-raise") == "HAND" + assert c("I flopped a set with 99 on 9h4c2d and bet the turn") == "HAND" + + +def test_hand_narrated_without_I_still_hand_not_read(): + # No "I", but leads with a poker verb (not a name) + street/action → his hand. + assert c("Flopped bottom set with 22, bet $40 on the river, he folded 88") == "HAND" + + +def test_table_ops(): + assert c("seat the table: TAG, Jonathan, Wheelz") == "TABLE" + assert c("table broke, I'm at a new table") == "TABLE" + assert c("I got moved to another table") == "TABLE" + + +def test_mental(): + assert c("I feel like I'm being mean when I raise") == "MENTAL" + assert c("ugh I'm so tilted, card dead all night") == "MENTAL" + + +def test_status_is_not_a_mood(): + assert c("it's 11:50pm, waiting for a seat") == "STATUS" + assert c("grabbing food, be right back") == "STATUS" + + +def test_log_bare_money(): + assert c("I'm at 317 now") == "LOG" + assert c("stack is 540") == "LOG" + + +def test_chat_default(): + assert c("should I have folded the river?") == "CHAT" + assert c("what do you think of this table so far") == "CHAT" + + +# --- the READ vs HAND boundary (the hard one) --- + +def test_roster_handle_forces_read(): + # A seated handle as the actor → READ even if lowercase / plain. + assert c("tag opened to 15 from the cutoff", roster=("TAG",)) == "READ" + + +def test_first_person_action_stays_hand_even_with_roster(): + # Brian is the actor → HAND, not a read on a seated player mentioned nearby. + assert c("I 3bet TAG's open with AKs", roster=("TAG",)) == "HAND" + + +def test_all_caps_handle_reads_without_roster(): + assert c("JD min-raised the button") == "READ" + + +# --- fragment selection --- + +def test_fragment_for_maps_each_type(): + for t in pp.MSG_TYPES: + assert pp.fragment_for(t) is pp.FRAGMENTS[t] + assert pp.fragment_for(None) is pp.FRAGMENTS["CHAT"] + assert pp.fragment_for("bogus") is pp.FRAGMENTS["CHAT"] + + +def test_base_is_nonempty_and_names_the_hard_rules(): + assert "LOG FIRST" in pp.BASE + assert "descriptor" in pp.BASE and "session_state" in pp.BASE