From 9cd962625d2c1f404f6a8872dd4745f093dcf9fe Mon Sep 17 00:00:00 2001 From: serversdown Date: Fri, 3 Jul 2026 22:49:40 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20scouting=20desk=20=E2=80=94=20proactive?= =?UTF-8?q?=20villain=20recall=20injected=20before=20she=20replies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lyra/mind.py | 17 +++++- lyra/poker.py | 39 +++++++++++++ lyra/scouting.py | 124 +++++++++++++++++++++++++++++++++++++++++ tests/test_scouting.py | 73 ++++++++++++++++++++++++ 4 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 lyra/scouting.py create mode 100644 tests/test_scouting.py diff --git a/lyra/mind.py b/lyra/mind.py index 9579116..c91de57 100644 --- a/lyra/mind.py +++ b/lyra/mind.py @@ -16,12 +16,16 @@ from __future__ import annotations from dataclasses import dataclass, field -from lyra import clock, config, llm, logbus, memory, modes, perceive, persona, self_state, thoughts +from lyra import ( + clock, config, llm, logbus, memory, modes, perceive, persona, scouting, + self_state, thoughts, +) from lyra.llm import Backend, Message RECALL_K = 3 # raw cross-session "sharp detail" hits RECENT_N = 10 # raw turns of the current session SUMMARY_K = 3 # other-session gists +_POKER_MODES = {"poker_cash", "study"} # where the scouting desk runs # --- prompt parts (compose) ---------------------------------------------- @@ -167,6 +171,17 @@ def build_messages(session_id: str, user_msg: str, if moment and moment.get("note"): messages.append({"role": "system", "content": moment["note"]}) + # Scouting desk: proactive poker recall — if he names/describes a known player, + # slide his structured history in before she replies. Poker context only, and + # fully fail-safe (a desk error must never break the turn). + if mode and mode.key in _POKER_MODES: + try: + desk = scouting.scout(user_msg) + if desk: + messages.append({"role": "system", "content": desk}) + except Exception as exc: + logbus.log("error", "scouting desk skipped", error=str(exc)[:160]) + # When she is: current time + the gap since Brian last spoke (she has no clock). messages.append(_now_note()) diff --git a/lyra/poker.py b/lyra/poker.py index b536cb3..bed2df8 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -1511,6 +1511,45 @@ def get_villain_file(name: str | None = None, venue: str | None = None) -> list[ return [dict(r) for r in _c().execute(sql, params).fetchall()] +def villain_recall(player_id: int) -> dict | None: + """Episodic recall for one villain: who, how often/where seen, last seen, the + notable hands against him (with ids to link), reads, and stats. This is the + when/where/which-hand narrative the scouting desk surfaces (Gap 1).""" + p = _c().execute("SELECT * FROM poker_players WHERE id = ?", (player_id,)).fetchone() + if not p: + return None + p = dict(p) + obs = [dict(r) for r in _c().execute( + "SELECT o.*, s.venue AS s_venue, s.started_at AS s_at FROM player_observations o " + "LEFT JOIN poker_sessions s ON s.id = o.session_id WHERE o.player_id = ? " + "ORDER BY o.id DESC", (player_id,) + ).fetchall()] + reads = [dict(r) for r in _c().execute( + "SELECT note, created_at FROM player_reads WHERE player_id = ? ORDER BY id DESC LIMIT 8", + (player_id,) + ).fetchall()] + sessions_seen = sorted({o["session_id"] for o in obs if o.get("session_id")} | + {r_["session_id"] for r_ in _c().execute( + "SELECT session_id FROM player_reads WHERE player_id = ?", + (player_id,)).fetchall() if r_["session_id"]}) + last_at = max([o.get("s_at") or o.get("created_at") for o in obs] + + [r["created_at"] for r in reads] + [p.get("updated_at")], default=None) + # Notable hands: showdowns / anything with cards, most recent first, linkable. + notable = [{"hand_id": o["hand_id"], "session_id": o.get("session_id"), + "when": o.get("s_at") or o.get("created_at"), "cards": o.get("cards"), + "summary": o.get("summary")} + for o in obs if o.get("hand_id")][:6] + prof = player_profile(p["name"]) or {} + return { + "player": p, "named": bool(p.get("named")), + "times_seen": len(sessions_seen), "sessions_seen": sessions_seen, + "last_seen": last_at, "notable_hands": notable, + "reads": [r["note"] for r in reads], + "stats": prof.get("stats"), "observations": len(obs), + "descriptors": p.get("descriptors"), + } + + # --- stats --- def session_stats(session_id: int | None = None) -> dict: diff --git a/lyra/scouting.py b/lyra/scouting.py new file mode 100644 index 0000000..9eb06bd --- /dev/null +++ b/lyra/scouting.py @@ -0,0 +1,124 @@ +"""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 diff --git a/tests/test_scouting.py b/tests/test_scouting.py new file mode 100644 index 0000000..0d5b37e --- /dev/null +++ b/tests/test_scouting.py @@ -0,0 +1,73 @@ +"""The scouting desk: named + descriptor recall, ambiguous→queue, generic→silence.""" +from __future__ import annotations + +import importlib + +import numpy as np +import pytest + + +def _fake_embed(texts): + out = [] + for t in texts: + v = np.zeros(64, dtype=np.float32) + for w in t.lower().split(): + v[hash(w) % 64] += 1.0 + out.append((v if v.any() else np.full(64, 1e-6, dtype=np.float32)).tolist()) + return out + + +@pytest.fixture +def mods(tmp_path, monkeypatch): + monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db")) + from lyra import llm + monkeypatch.setattr(llm, "embed", _fake_embed) + import lyra.memory as memory + importlib.reload(memory) + import lyra.poker as poker + importlib.reload(poker) + import lyra.scouting as scouting + importlib.reload(scouting) + return poker, scouting + + +def test_named_player_surfaces_a_brief(mods): + poker, scouting = mods + sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300) + pid = poker.upsert_player("Sleepy John", venue="Meadows", category="reg") + poker._c().execute( + "INSERT INTO player_observations (player_id, session_id, cards, created_at) VALUES (?,?,?,?)", + (pid, sid, "As Ks", poker._now())) + poker._c().commit() + note = scouting.scout("sleepy john just sat down on my left", venue="Meadows") + assert note and "Sleepy John" in note and "SCOUTING DESK" in note + + +def test_descriptor_high_match_surfaces_with_confirm(mods): + poker, scouting = mods + poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows", category="reg") + note = scouting.scout("the neck tattoo sleeve guy just 3bet me again", venue="Meadows") + assert note and "confirm it's the same guy" in note + + +def test_ambiguous_descriptor_queues_instead_of_interrupting(mods): + poker, scouting = mods + poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows") + note = scouting.scout("the neck tattoo guy raised", venue="Meadows") + assert note is None # didn't interrupt + q = poker.list_identity_queue() + assert q and q[0]["kind"] == "needs_clarification" + + +def test_generic_descriptor_stays_silent(mods): + poker, scouting = mods + poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows") + note = scouting.scout("the mid aged white guy with glasses raised", venue="Meadows") + assert note is None + assert poker.list_identity_queue() == [] # no queue spam for a non-identifier + + +def test_no_player_reference_returns_nothing(mods): + poker, scouting = mods + poker.upsert_player("Sleepy John", venue="Meadows") + assert scouting.scout("i folded pocket kings to a 4bet", venue="Meadows") is None