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:
@@ -103,6 +103,12 @@ inventing a mechanism — same rule as not inventing numbers.
|
||||
qualitative read and flag that the exact number needs the calc. Approximate
|
||||
reasoning is fine if you label it as approximate.
|
||||
- You don't pretend to remember things you don't. If you're not sure, say so.
|
||||
- **You don't invent reads on players.** Before you say *anything* about a
|
||||
specific opponent, you MUST call the `player_profile` tool and answer ONLY from
|
||||
what it returns — never from memory, vibes, or generic "player types." If the
|
||||
file is thin or empty, say plainly that you've barely seen them (or have nothing
|
||||
yet) and report just the hand(s) on record. Never fabricate tendencies, stats,
|
||||
or a playing style. A made-up read is worse than "I don't know him yet."
|
||||
- You don't moralize about gambling. Brian's a serious player. Meet him there.
|
||||
|
||||
## Right now
|
||||
|
||||
+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"
|
||||
|
||||
@@ -173,6 +173,37 @@ def _generate_recap(args: dict, ctx: dict) -> str:
|
||||
f"at /recap/{out['id']}")
|
||||
|
||||
|
||||
def _player_profile(args: dict, ctx: dict) -> str:
|
||||
prof = poker.player_profile(args.get("name") or "")
|
||||
if not prof:
|
||||
return f"No file on {args.get('name')} yet."
|
||||
p = prof["player"]
|
||||
L = [p["name"] + (f" ({p['venue']})" if p.get("venue") else "")
|
||||
+ (f" [{p['category']}]" if p.get("category") else "")]
|
||||
thin = not (p.get("tendencies") or p.get("adjustment")) and not prof.get("stats")
|
||||
if thin:
|
||||
L.append("⚠ THIN FILE — no standing read on record. Report only the observed "
|
||||
"hand(s) below and tell Brian you've barely seen him. Do NOT generalize a style.")
|
||||
if p.get("description"):
|
||||
L.append(p["description"])
|
||||
if p.get("tendencies"):
|
||||
L.append(f"Tendencies: {p['tendencies']}")
|
||||
if p.get("adjustment"):
|
||||
L.append(f"Exploit: {p['adjustment']}")
|
||||
s = prof.get("stats")
|
||||
if s:
|
||||
L.append(f"Stats ({s['hands']} hands): VPIP {s['vpip_pct']}% · PFR {s['pfr_pct']}% · WTSD {s['wtsd_pct']}%")
|
||||
elif prof.get("small_sample"):
|
||||
L.append(prof["small_sample"])
|
||||
if prof.get("showdowns"):
|
||||
L.append("Shown down: " + ", ".join(prof["showdowns"][:6]))
|
||||
if prof.get("reads"):
|
||||
L.append("Notes: " + " | ".join(prof["reads"][:4]))
|
||||
if prof.get("recent"):
|
||||
L.append("Recent hands: " + " | ".join(prof["recent"][:4]))
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
def _villain_file(args: dict, ctx: dict) -> str:
|
||||
vs = poker.get_villain_file(name=args.get("name"), venue=args.get("venue"))
|
||||
if not vs:
|
||||
@@ -271,6 +302,13 @@ TOOLS.update({
|
||||
"data + this conversation. Use when he asks for the recap/writeup, usually "
|
||||
"after ending a session.",
|
||||
{}, [])},
|
||||
"player_profile": {"handler": _player_profile, "spec": _f(
|
||||
"player_profile",
|
||||
"Look up everything known about one opponent — dossier, reads, hands "
|
||||
"they've shown down, and (once enough hands are logged) inferred stats "
|
||||
"like VPIP/PFR. Use when Brian asks what's known about a player.",
|
||||
{"name": {**_S, "description": "Player name to look up"}},
|
||||
["name"])},
|
||||
"get_villain_file": {"handler": _villain_file, "spec": _f(
|
||||
"get_villain_file",
|
||||
"Pull saved opponent dossiers (the villain file). Filter by name or venue, e.g. before sitting down.",
|
||||
|
||||
Reference in New Issue
Block a user