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
|
qualitative read and flag that the exact number needs the calc. Approximate
|
||||||
reasoning is fine if you label it as 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 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.
|
- You don't moralize about gambling. Brian's a serious player. Meet him there.
|
||||||
|
|
||||||
## Right now
|
## Right now
|
||||||
|
|||||||
+107
-1
@@ -80,8 +80,29 @@ CREATE TABLE IF NOT EXISTS player_reads (
|
|||||||
note TEXT NOT NULL,
|
note TEXT NOT NULL,
|
||||||
created_at 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
|
_ensured_for = None
|
||||||
|
|
||||||
|
|
||||||
@@ -329,7 +350,8 @@ def record_hand(shorthand: str, session_id: int | None = None, stakes: str | Non
|
|||||||
if not parsed:
|
if not parsed:
|
||||||
return {"id": None, "parsed": None}
|
return {"id": None, "parsed": None}
|
||||||
hid = store_hand_history(parsed, session_id=session_id, tag=tag, lesson=lesson)
|
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:
|
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)
|
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]:
|
def get_villain_file(name: str | None = None, venue: str | None = None) -> list[dict]:
|
||||||
"""Pull villain dossiers, optionally filtered by name or venue."""
|
"""Pull villain dossiers, optionally filtered by name or venue."""
|
||||||
sql = "SELECT * FROM poker_players"
|
sql = "SELECT * FROM poker_players"
|
||||||
|
|||||||
@@ -173,6 +173,37 @@ def _generate_recap(args: dict, ctx: dict) -> str:
|
|||||||
f"at /recap/{out['id']}")
|
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:
|
def _villain_file(args: dict, ctx: dict) -> str:
|
||||||
vs = poker.get_villain_file(name=args.get("name"), venue=args.get("venue"))
|
vs = poker.get_villain_file(name=args.get("name"), venue=args.get("venue"))
|
||||||
if not vs:
|
if not vs:
|
||||||
@@ -271,6 +302,13 @@ TOOLS.update({
|
|||||||
"data + this conversation. Use when he asks for the recap/writeup, usually "
|
"data + this conversation. Use when he asks for the recap/writeup, usually "
|
||||||
"after ending a session.",
|
"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": {"handler": _villain_file, "spec": _f(
|
||||||
"get_villain_file",
|
"get_villain_file",
|
||||||
"Pull saved opponent dossiers (the villain file). Filter by name or venue, e.g. before sitting down.",
|
"Pull saved opponent dossiers (the villain file). Filter by name or venue, e.g. before sitting down.",
|
||||||
|
|||||||
@@ -116,6 +116,40 @@ def test_list_recent_hands(lyra):
|
|||||||
assert hh and hh[0]["hole_cards"] == "QQ" and hh[0]["stakes"] == "1/3"
|
assert hh and hh[0]["hole_cards"] == "QQ" and hh[0]["stakes"] == "1/3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_observation_and_profile(lyra):
|
||||||
|
poker = lyra
|
||||||
|
sid = poker.start_session(stakes="1/3", buy_in=300)
|
||||||
|
parsed = {"hero_pos": "BB",
|
||||||
|
"players": [{"pos": "BTN", "name": "Round Mike"}, {"pos": "BB", "name": None}],
|
||||||
|
"actions": [{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 12},
|
||||||
|
{"street": "preflop", "pos": "BB", "action": "call"},
|
||||||
|
{"street": "flop", "board": ["7d", "2c", "5h"]},
|
||||||
|
{"street": "flop", "pos": "BTN", "action": "bet", "amount": 15}]}
|
||||||
|
hid = poker.store_hand_history(parsed, session_id=sid)
|
||||||
|
assert poker.link_hand_players(hid, parsed, session_id=sid) == 1 # only the named player
|
||||||
|
prof = poker.player_profile("mike")
|
||||||
|
assert prof["player"]["name"] == "Round Mike"
|
||||||
|
assert prof["observations"] == 1
|
||||||
|
assert prof["stats"] is None and "small_sample" in prof # too few hands for stats
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_stats_emerge_with_sample(lyra):
|
||||||
|
poker = lyra
|
||||||
|
sid = poker.start_session(stakes="1/3", buy_in=300)
|
||||||
|
raised = {"players": [{"pos": "BTN", "name": "LAG"}],
|
||||||
|
"actions": [{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 10}]}
|
||||||
|
folded = {"players": [{"pos": "UTG", "name": "LAG"}],
|
||||||
|
"actions": [{"street": "preflop", "pos": "UTG", "action": "fold"}]}
|
||||||
|
for i in range(poker.MIN_STATS_SAMPLE):
|
||||||
|
p = raised if i % 2 == 0 else folded
|
||||||
|
hid = poker.store_hand_history(p, session_id=sid)
|
||||||
|
poker.link_hand_players(hid, p, session_id=sid)
|
||||||
|
prof = poker.player_profile("LAG")
|
||||||
|
assert prof["stats"] is not None
|
||||||
|
assert prof["stats"]["hands"] >= poker.MIN_STATS_SAMPLE
|
||||||
|
assert 30 <= prof["stats"]["vpip_pct"] <= 70 # ~half were voluntary
|
||||||
|
|
||||||
|
|
||||||
def test_poker_tools_dispatch(lyra):
|
def test_poker_tools_dispatch(lyra):
|
||||||
from lyra import tools
|
from lyra import tools
|
||||||
assert "started" in tools.dispatch("start_session", {"stakes": "1/3", "buy_in": 300})
|
assert "started" in tools.dispatch("start_session", {"stakes": "1/3", "buy_in": 300})
|
||||||
|
|||||||
Reference in New Issue
Block a user