Files
project-lyra/lyra/scouting.py
T
serversdown 9cd962625d feat: scouting desk — proactive villain recall injected before she replies
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>
2026-07-03 22:49:40 +00:00

125 lines
4.9 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.
"""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