diff --git a/lyra/personas/lyra.md b/lyra/personas/lyra.md index 4d0f169..2e6d991 100644 --- a/lyra/personas/lyra.md +++ b/lyra/personas/lyra.md @@ -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 diff --git a/lyra/poker.py b/lyra/poker.py index de151f7..7557d57 100644 --- a/lyra/poker.py +++ b/lyra/poker.py @@ -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" diff --git a/lyra/tools.py b/lyra/tools.py index f2b6b03..8b20ee8 100644 --- a/lyra/tools.py +++ b/lyra/tools.py @@ -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.", diff --git a/tests/test_poker.py b/tests/test_poker.py index 914985c..95ef400 100644 --- a/tests/test_poker.py +++ b/tests/test_poker.py @@ -116,6 +116,40 @@ def test_list_recent_hands(lyra): 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): from lyra import tools assert "started" in tools.dispatch("start_session", {"stakes": "1/3", "buy_in": 300})