9cd962625d
Phase 2. On every poker-context turn, detect players named or described in the message and slide their structured history into her prompt as a SCOUTING DESK note (cite-don't-invent). Fail-safe: any desk error is swallowed, never breaks the turn; silence is the default. - poker.villain_recall(id): episodic brief — times/where seen, last seen, notable hands (linkable ids), reads, stats (Gap 1: the when/where/which-hand narrative). - scouting.scout(): named hits (deterministic word match) + descriptor spans (regex → resolve_villain). High → surface + "confirm it's the same guy"; ambiguous → file needs_clarification to the queue instead of interrupting; generic → stay silent. - mind.build_messages wires it in, gated to poker modes (poker_cash/study). 5 tests. Full suite 146 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
125 lines
4.9 KiB
Python
125 lines
4.9 KiB
Python
"""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
|