Big poker mode changes and hotfixes. #5

Merged
serversdown merged 39 commits from fix/mi50-summary-cap-fallback into dev 2026-07-04 15:09:33 -04:00
4 changed files with 252 additions and 1 deletions
Showing only changes of commit 9cd962625d - Show all commits
+16 -1
View File
@@ -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())
+39
View File
@@ -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:
+124
View File
@@ -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
+73
View File
@@ -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