"""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 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"]) if not lines: return None logbus.log("info", "scouting desk", hits=len(lines)) return ("SCOUTING DESK — structured recall for players in his message (cite it, " "don't invent; if unsure it's the same person, ask him):\n• " + "\n• ".join(lines)) except Exception as exc: # desk must never break a turn logbus.log("error", "scouting desk failed", error=str(exc)[:160]) return None