Update main. #6
+16
-1
@@ -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())
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user