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:
+107
-1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user