d5c80f6153
Phases 5 & 6, completing the scouting desk. Phase 5 — the nightly consolidation (dream cycle, coherence block) now runs poker.scan_merge_candidates(), filing likely same-person merges to the review queue off the hot path. Fail-safe: a scan error never sinks the cycle. Phase 6 — "you've hit this leak before". Scar/confidence notes are embedded on write; recall_similar_rituals() finds past ones close to the current spot, excluding tonight. The scouting desk adds a pattern pass that surfaces them — but ONLY on genuine strategy/tilt talk (a length + cue gate), so routine logging like "stack 350" never pays for an embed. Respects the per-turn latency concern. 7 tests (deterministic embed stub to keep threshold assertions stable). Full suite 153 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
153 lines
6.3 KiB
Python
153 lines
6.3 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
|
||
|
||
# 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
|