"""The scouting desk — proactive poker recall slid into Lyra's context before she replies, the way a broadcast stats desk hands the commentator a note. Two detectors run on the incoming message: known NAMES (deterministic) and physical DESCRIPTORS (fuzzy, via the identity resolver). A confident hit becomes a `SCOUTING DESK` system note she can cite; an ambiguous descriptor is filed to the review queue instead of interrupting. Everything here is best-effort and wrapped by the caller — it must never break a chat turn. Silence is the default. See docs/SCOUTING_DESK.md. """ from __future__ import annotations import re from lyra import clock, logbus, poker # Cues that a span names a *person at the table* worth resolving as a villain. _ROLE = r"(?:guy|dude|man|kid|reg|player|villain|fish|whale|nit|lag|tag|maniac)" _DESC_PATTERNS = ( re.compile(rf"\bthe ([\w][\w\s'’-]{{2,28}}?) {_ROLE}\b", re.I), re.compile(rf"\b{_ROLE} (?:with|in|who has|sporting|rocking) (?:the |a |an )?([\w\s'’-]{{3,28}})", re.I), ) _MIN_NAME = 3 # Cues that a message is a strategy/spot/tilt discussion — the only turns worth # paying an embed to recall past leaks. Keeps the pattern pass off routine logging. _STRAT_CUES = ( "fold", "call", "raise", "bluff", "river", "turn", "flop", "tilt", "punt", "leak", "should i", "hero", "value", "overbet", "spew", "stack off", "3bet", "4bet", "check-raise", "checkraise", "range", "board", "steaming", "felted", "all in", "all-in", "shoved", "jammed", "snap", "sizing", ) def _looks_strategic(msg: str) -> bool: low = msg.lower() return len(msg) >= 40 and any(c in low for c in _STRAT_CUES) def _named_hits(msg: str) -> list[int]: """Ids of known *named* villains whose name appears as a word in the message.""" low = msg.lower() hits = [] for r in poker._c().execute("SELECT id, name FROM poker_players WHERE named = 1").fetchall(): name = (r["name"] or "").strip() if len(name) < _MIN_NAME: continue if re.search(rf"\b{re.escape(name.lower())}\b", low): hits.append(r["id"]) return hits def _descriptor_spans(msg: str) -> list[str]: spans, seen = [], set() for pat in _DESC_PATTERNS: for m in pat.finditer(msg): span = m.group(1).strip(" '’-").lower() if span and span not in seen: seen.add(span) spans.append(span) return spans def _brief(player_id: int) -> str | None: """One compact line of episodic recall for a villain, or None if nothing known.""" rec = poker.villain_recall(player_id) if not rec: return None p = rec["player"] who = p["name"] if rec["named"] else f"“{p['name']}”" bits = [who] tags = [t for t in (p.get("venue"), p.get("category")) if t] if tags: bits.append("(" + ", ".join(tags) + ")") if rec["times_seen"]: seen = f"seen {rec['times_seen']}×" if rec["last_seen"]: seen += f", last {clock.short(rec['last_seen'])}" bits.append(seen) st = rec.get("stats") if st: bits.append(f"VPIP {st['vpip_pct']}/PFR {st['pfr_pct']} ({st['hands']}h)") line = " ".join(bits) if rec["reads"]: line += " — reads: " + "; ".join(rec["reads"][:3]) if rec["notable_hands"]: h = rec["notable_hands"][0] line += f" · notable hand #{h['hand_id']}" + (f" ({h['cards']})" if h.get("cards") else "") return line def scout(user_msg: str, venue: str | None = None, session_id: int | None = None) -> str | None: """Build the SCOUTING DESK note for this message, or None. Never raises for a caller that forgets to guard — but callers should guard anyway.""" try: msg = (user_msg or "").strip() if len(msg) < 3: return None if venue is None or session_id is None: live = poker.live_session() if live: venue = venue or live.get("venue") session_id = session_id or live.get("id") lines: list[str] = [] seen_ids: set[int] = set() for pid in _named_hits(msg): if pid in seen_ids: continue b = _brief(pid) if b: lines.append(b) seen_ids.add(pid) for span in _descriptor_spans(msg): res = poker.resolve_villain(span, venue=venue, session_id=session_id) if res["band"] == "high" and res["match_id"] and res["match_id"] not in seen_ids: b = _brief(res["match_id"]) if b: lines.append(b + " ← confirm it's the same guy") seen_ids.add(res["match_id"]) elif res["band"] == "ambiguous" and res["match_id"]: # Don't interrupt on a maybe — route it to the async review queue. poker.queue_identity_task( "needs_clarification", [res["match_id"]], descriptor=span, context=f'Brian referred to "{span}"', session_id=session_id, confidence=res["confidence"]) # Pattern desk: on genuine strategy talk, recall his own past leaks/wins in # similar spots. Gated so routine logging never pays for an embed. pattern: list[str] = [] if _looks_strategic(msg): for r in poker.recall_similar_rituals(msg, exclude_session=session_id): tag = "leak" if r["kind"] == "scar" else "banked" cls = f"/{r['classification']}" if r.get("classification") else "" when = f" ({str(r['when'])[:10]})" if r.get("when") else "" pattern.append(f"[{tag}{cls}]{when} {r['content']}") if not lines and not pattern: return None logbus.log("info", "scouting desk", hits=len(lines), patterns=len(pattern)) out = "SCOUTING DESK — structured recall for his message (cite it, don't invent; " \ "if unsure it's the same person, ask him):" if lines: out += "\n• " + "\n• ".join(lines) if pattern: out += "\nYou've been in a spot like this before —\n• " + "\n• ".join(pattern) return out except Exception as exc: # desk must never break a turn logbus.log("error", "scouting desk failed", error=str(exc)[:160]) return None