feat: auto-accumulating villain dossiers + player lookup (poker B)

Named players in recorded hands now auto-enrich a persistent dossier, and stats
emerge once the sample is big enough — laying groundwork for A.

- poker: player_observations table (per named player per hand: vpip/pfr/saw_flop/
  showed/cards/summary); record_hand auto-links named players via link_hand_players;
  player_profile(name) returns dossier + reads + shown hands, with inferred
  VPIP/PFR/WTSD gated behind MIN_STATS_SAMPLE (12) so thin samples don't lie;
  list_players()
- player_profile tool ("what do I know about X"); thin files return a blunt
  "don't generalize" directive
- persona: she MUST call player_profile before discussing an opponent and answer
  only from it — fixes observed confabulation (she invented a whole read from one
  hand / from memory). Verified: now reports only the real logged hand.
- tests: observation linking, profile, stat-emergence at sample threshold

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 04:33:16 +00:00
parent 6a911423a2
commit c7d2279f8d
4 changed files with 185 additions and 1 deletions
+107 -1
View File
@@ -80,8 +80,29 @@ CREATE TABLE IF NOT EXISTS player_reads (
note TEXT NOT NULL,
created_at TEXT NOT NULL
);
-- One row per named player per recorded hand — structured enough to (a) build
-- their qualitative dossier and (b) infer basic stats once the sample is big.
CREATE TABLE IF NOT EXISTS player_observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_id INTEGER NOT NULL,
hand_id INTEGER,
session_id INTEGER,
pos TEXT,
cards TEXT,
vpip INTEGER, -- voluntarily put money in preflop
pfr INTEGER, -- raised/3bet preflop
saw_flop INTEGER,
showed INTEGER, -- cards reached showdown / were shown
summary TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pobs_player ON player_observations(player_id);
"""
# Below this many observed hands, don't surface % stats (too small a sample).
MIN_STATS_SAMPLE = 12
_ensured_for = None
@@ -329,7 +350,8 @@ def record_hand(shorthand: str, session_id: int | None = None, stakes: str | Non
if not parsed:
return {"id": None, "parsed": None}
hid = store_hand_history(parsed, session_id=session_id, tag=tag, lesson=lesson)
return {"id": hid, "parsed": parsed}
linked = link_hand_players(hid, parsed, session_id=session_id) # enrich villain files
return {"id": hid, "parsed": parsed, "linked": linked}
def get_hand(hand_id: int) -> dict | None:
@@ -493,6 +515,90 @@ def add_read(note: str, seat: str | None = None, name: str | None = None,
return int(cur.lastrowid)
def _player_flags(parsed: dict, pos: str | None) -> tuple[int, int, int]:
"""(vpip, pfr, saw_flop) for the player at `pos` in a parsed hand."""
acts = parsed.get("actions") or []
pre = [a for a in acts if a.get("street") == "preflop" and a.get("pos") == pos]
post = [a for a in acts if a.get("pos") == pos and a.get("street") in ("flop", "turn", "river")]
vol = {"call", "bet", "raise", "allin"}
vpip = int(any(a.get("action") in vol for a in pre))
pfr = int(any(a.get("action") in {"raise", "allin"} for a in pre))
return vpip, pfr, int(bool(post))
def link_hand_players(hand_id: int, parsed: dict, session_id: int | None = None) -> int:
"""For each NAMED player in a parsed hand, upsert their file + log a structured
observation. Returns how many players were linked."""
sid = _resolve(session_id)
linked = 0
for pl in (parsed.get("players") or []):
name = (pl.get("name") or "").strip()
if not name:
continue
pid = upsert_player(name)
vpip, pfr, saw = _player_flags(parsed, pl.get("pos"))
cards = " ".join(pl.get("cards") or []) or None
acts = [a for a in (parsed.get("actions") or [])
if a.get("pos") == pl.get("pos") and a.get("action")]
astr = ", ".join(a["action"] + (f" {a['amount']}" if a.get("amount") is not None else "")
for a in acts)
summary = (pl.get("pos") or "?") + (f" ({cards})" if cards else "") + (f": {astr}" if astr else "")
conn = _c()
with conn:
conn.execute(
"INSERT INTO player_observations (player_id, hand_id, session_id, pos, cards, "
"vpip, pfr, saw_flop, showed, summary, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(pid, hand_id, sid, pl.get("pos"), cards, vpip, pfr, saw, int(bool(cards)),
summary, _now()),
)
linked += 1
return linked
def player_profile(name: str) -> dict | None:
"""Everything known about a player: dossier + observations, with inferred
stats once the sample is large enough."""
p = _c().execute(
"SELECT * FROM poker_players WHERE name LIKE ? COLLATE NOCASE ORDER BY updated_at DESC LIMIT 1",
(f"%{name}%",),
).fetchone()
if not p:
return None
p = dict(p)
obs = [dict(r) for r in _c().execute(
"SELECT * FROM player_observations WHERE player_id = ? ORDER BY id DESC", (p["id"],)
).fetchall()]
reads = [r["note"] for r in _c().execute(
"SELECT note FROM player_reads WHERE player_id = ? ORDER BY id DESC LIMIT 8", (p["id"],)
).fetchall()]
n = len(obs)
prof: dict = {
"player": p, "observations": n,
"recent": [o["summary"] for o in obs[:8] if o["summary"]],
"showdowns": [o["cards"] for o in obs if o["cards"]][:10],
"reads": reads, "stats": None,
}
if n >= MIN_STATS_SAMPLE:
prof["stats"] = {
"hands": n,
"vpip_pct": round(100 * sum(o["vpip"] or 0 for o in obs) / n),
"pfr_pct": round(100 * sum(o["pfr"] or 0 for o in obs) / n),
"wtsd_pct": round(100 * sum(o["showed"] or 0 for o in obs) / n),
}
elif n:
prof["small_sample"] = f"only {n} hand(s) logged — too few for reliable stats"
return prof
def list_players() -> list[dict]:
"""The villain file with observation counts, for browsing."""
rows = _c().execute(
"SELECT p.*, (SELECT COUNT(*) FROM player_observations o WHERE o.player_id = p.id) AS obs "
"FROM poker_players p ORDER BY p.updated_at DESC"
).fetchall()
return [dict(r) for r in rows]
def get_villain_file(name: str | None = None, venue: str | None = None) -> list[dict]:
"""Pull villain dossiers, optionally filtered by name or venue."""
sql = "SELECT * FROM poker_players"