Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc623db27a |
@@ -0,0 +1,48 @@
|
|||||||
|
# Decision log — Decide mode's learning layer
|
||||||
|
|
||||||
|
Built overnight on `feat/decision-log`. This is the **data layer + tools only**. The
|
||||||
|
prompt/mode wiring (the taste part) is left for you on purpose — no persona/card edits
|
||||||
|
were made.
|
||||||
|
|
||||||
|
## The idea
|
||||||
|
|
||||||
|
Decide mode is currently a one-shot tie-breaker. The learning layer gives it memory:
|
||||||
|
log the call Brian actually makes, record how it turned out, and recall similar past
|
||||||
|
calls so a new recommendation leans on his own track record instead of generic advice.
|
||||||
|
|
||||||
|
Lifecycle: **log** (when the call is made) → **resolve** (later, with the outcome) →
|
||||||
|
**recall** (next time something similar comes up).
|
||||||
|
|
||||||
|
## What's built
|
||||||
|
|
||||||
|
**Storage** (`lyra/memory.py`):
|
||||||
|
- `decisions` table — situation, options, choice, rationale, confidence (1-5), tags,
|
||||||
|
embedding (over situation+choice), outcome, outcome_rating (-1/0/+1), resolved_at.
|
||||||
|
- `Decision` dataclass (with a `.resolved` property).
|
||||||
|
- `log_decision(...) -> id`, `resolve_decision(id, outcome, rating) -> bool`,
|
||||||
|
`get_decision(id)`, `list_decisions(limit, open_only)`,
|
||||||
|
`recall_decisions(query, k)` (cosine over embeddings, each hit carries `.score`).
|
||||||
|
- Embedding failures never block a log (blob just stays NULL).
|
||||||
|
|
||||||
|
**Tools** (`lyra/tools.py`) — handlers + specs, wired into `dispatch`:
|
||||||
|
- `log_decision` (situation, choice, options?, rationale?, confidence?, tags?)
|
||||||
|
- `resolve_decision` (decision_id, outcome, rating?)
|
||||||
|
- `recall_decisions` (query, k?) — returns past calls with their verdicts
|
||||||
|
|
||||||
|
**Tests** (`tests/test_decisions.py`) — 9, covering roundtrip, resolve, open-only
|
||||||
|
filtering, similarity ranking, and all three tool handlers. Full suite green, ruff clean.
|
||||||
|
|
||||||
|
## What's left for you (the wiring)
|
||||||
|
|
||||||
|
1. **Allow-list** — add the three tools to `_DECIDE_TOOLS` in `lyra/modes.py`
|
||||||
|
(and decide whether `recall_decisions` also belongs in Study). One-liner, but it's
|
||||||
|
the gate that lets her actually call them.
|
||||||
|
2. **Decide card guidance** — tell her *when* to use them: recall similar decisions
|
||||||
|
before recommending, log once Brian commits to a call, and circle back to resolve
|
||||||
|
open ones. This is the part I didn't want to touch without you (no bandaids).
|
||||||
|
3. **Optional surfacing** — open/unresolved decisions are a natural thing for her to
|
||||||
|
raise (thought loop / ping), and a small UI panel could list them. Not built.
|
||||||
|
4. **Optional auto-prompt to resolve** — the dream loop could notice decisions that
|
||||||
|
have been open a while and nudge for an outcome.
|
||||||
|
|
||||||
|
Nothing here changes behavior until step 1 — the tools exist but no mode offers them.
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# Hand-history contract (Lyra → RTO)
|
|
||||||
|
|
||||||
The canonical structured shape for a poker hand. **Lyra owns hands** — it produces this
|
|
||||||
shape (LLM parser today; the tap recorder natively, going forward), stores it, replays it
|
|
||||||
in the viewer, and exports it. **RTO consumes it** over HTTP and never reaches into Lyra.
|
|
||||||
|
|
||||||
Ownership rule: whoever owns the data owns the tools that produce it. Lyra owns the hand
|
|
||||||
DB, the viewer, and the copilot loop, so hand capture lives here. RTO is a pure engine.
|
|
||||||
|
|
||||||
Coupling: **one arrow, Lyra → RTO, HTTP only.** RTO is a standalone service (solve /
|
|
||||||
exploit / estimate); Lyra POSTs to it when it wants analysis. No shared package, no shared
|
|
||||||
DB, no shared UI components. If RTO is down, Lyra skips analysis and nothing breaks.
|
|
||||||
|
|
||||||
## Schema (`schema_version: 1`)
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"game": "NLH", // NLH | PLO | ...
|
|
||||||
"stakes": "1/3", // or null
|
|
||||||
"hero_pos": "BTN", // one of POSITIONS
|
|
||||||
"hero_cards": ["Ah", "Kh"], // convenience mirror of the hero's players[].cards
|
|
||||||
"players": [ // every player in the hand, incl. hero
|
|
||||||
{"pos": "BTN", "stack": 300, "name": "Hero", "cards": ["Ah","Kh"], "hero": true},
|
|
||||||
{"pos": "BB", "stack": 250, "name": "Sal", "cards": null} // cards: null unless shown
|
|
||||||
],
|
|
||||||
"actions": [ // one flat chronological list across all streets
|
|
||||||
{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 15},
|
|
||||||
{"street": "flop", "board": ["7d","2c","5h"]}, // a street begins with its board reveal
|
|
||||||
{"street": "flop", "pos": "BB", "action": "check"}
|
|
||||||
],
|
|
||||||
"board": ["7d","2c","5h"], // full final board, 0–5 cards
|
|
||||||
"result": {"pot": 40, "hero_net": 25, "summary": "one line"},
|
|
||||||
"completeness": {"cards": true, "board": true, "actions": true}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Conventions (load-bearing)
|
|
||||||
|
|
||||||
- **Cards are lists of 2-char tokens**, `RankSuit`: rank in `23456789TJQKA` (ten = `T`),
|
|
||||||
suit in `c d h s` (lowercase). E.g. `["As","5d","2c"]`. RTO maps each token via
|
|
||||||
`pokercore.parse_card`. *(Chosen over space-joined strings: unambiguous, no re-splitting,
|
|
||||||
and it's what Lyra already stores + what the viewer reads.)*
|
|
||||||
- **Unknown cards are kept, not dropped:** `"Ax"` = known rank / unknown suit, `"x"` =
|
|
||||||
fully unknown card. The LLM parser emits these when Brian didn't state suits. The tap
|
|
||||||
recorder won't — it captures complete cards by construction — so `"x"` is an
|
|
||||||
import/parser-only concern.
|
|
||||||
- **`completeness`** tells a consumer what's safe to use: `cards`/`board` are `true` only
|
|
||||||
when every relevant card is fully specified (no `"x"`). RTO uses `false`-card hands for
|
|
||||||
positions/frequencies/pairs and skips suit-dependent math (flushes).
|
|
||||||
- **Hero appears in `players[]`** with `"hero": true` and is findable via `pos == hero_pos`.
|
|
||||||
`hero_cards` is a mirror for the viewer; `players[].cards` is the source of truth.
|
|
||||||
- **Positions:** `UTG UTG1 UTG2 MP LJ HJ CO BTN SB BB`.
|
|
||||||
- **Actions:** `post fold check call bet raise allin`. `amount` is a plain number (no `$`),
|
|
||||||
null for non-sized actions (fold/check). Street boards appear as `{street, board}` entries.
|
|
||||||
- **Streets:** `preflop flop turn river`.
|
|
||||||
|
|
||||||
`lyra/poker.py:normalize_structured()` is the single function that guarantees this shape.
|
|
||||||
It runs on store and on read, and is idempotent.
|
|
||||||
|
|
||||||
## Transport (HTTP, Lyra serves on :7078)
|
|
||||||
|
|
||||||
- `GET /hands/data?limit=N` → `{ "hands": [ {id, position, hole_cards, board, result, tag,
|
|
||||||
at, lesson, venue, stakes, has_structured}, ... ] }` — flat list for browsing. Use
|
|
||||||
`has_structured` to pick which hands have a replayable body worth fetching.
|
|
||||||
- `GET /hand/{id}/data` → the full hand row; `structured` is the object above (or `null`
|
|
||||||
for a flat quick-log that hasn't been reconstructed).
|
|
||||||
|
|
||||||
RTO's "Lyra bridge" (its `docs/estimator-design.md`, Phase B) walks `structured.actions`
|
|
||||||
to classify each villain decision into `checked_to` / `facing_bet` / `facing_raise`, and
|
|
||||||
uses shown `cards` + that street's `board` for board-relative categories. Everything that
|
|
||||||
walk needs is in the schema above.
|
|
||||||
+121
@@ -115,6 +115,26 @@ CREATE TABLE IF NOT EXISTS ratings (
|
|||||||
note TEXT
|
note TEXT
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ratings_created ON ratings(created_at);
|
CREATE INDEX IF NOT EXISTS idx_ratings_created ON ratings(created_at);
|
||||||
|
|
||||||
|
-- Decisions Lyra helped Brian make (Decide mode's learning layer). Logged when the
|
||||||
|
-- call is made; resolved later with how it actually turned out; recalled by semantic
|
||||||
|
-- similarity so a new call can lean on how similar ones went. embedding covers the
|
||||||
|
-- situation + choice. Resolved rows (with an outcome) are the signal worth recalling.
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
situation TEXT NOT NULL, -- what was being decided
|
||||||
|
options TEXT, -- the alternatives weighed (free text / newline list)
|
||||||
|
choice TEXT NOT NULL, -- the call that was made
|
||||||
|
rationale TEXT, -- why
|
||||||
|
confidence INTEGER, -- 1-5, how sure at the time (nullable)
|
||||||
|
tags TEXT, -- domain: poker | life | build | ... (comma-separated)
|
||||||
|
embedding BLOB,
|
||||||
|
outcome TEXT, -- filled in on resolve: what actually happened
|
||||||
|
outcome_rating INTEGER, -- -1 bad / 0 mixed / +1 good (nullable until resolved)
|
||||||
|
resolved_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_created ON decisions(created_at);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_conn: sqlite3.Connection | None = None
|
_conn: sqlite3.Connection | None = None
|
||||||
@@ -184,6 +204,26 @@ class Era:
|
|||||||
score: float | None = None
|
score: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Decision:
|
||||||
|
id: int
|
||||||
|
created_at: str
|
||||||
|
situation: str
|
||||||
|
choice: str
|
||||||
|
options: str | None = None
|
||||||
|
rationale: str | None = None
|
||||||
|
confidence: int | None = None
|
||||||
|
tags: str | None = None
|
||||||
|
outcome: str | None = None
|
||||||
|
outcome_rating: int | None = None
|
||||||
|
resolved_at: str | None = None
|
||||||
|
score: float | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resolved(self) -> bool:
|
||||||
|
return self.resolved_at is not None
|
||||||
|
|
||||||
|
|
||||||
def _to_blob(vec: list[float]) -> bytes:
|
def _to_blob(vec: list[float]) -> bytes:
|
||||||
return np.asarray(vec, dtype=np.float32).tobytes()
|
return np.asarray(vec, dtype=np.float32).tobytes()
|
||||||
|
|
||||||
@@ -645,6 +685,87 @@ def backfill_journal_embeddings(limit: int | None = None) -> int:
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
# --- decisions (Decide mode's learning layer) ---------------------------------
|
||||||
|
|
||||||
|
def _row_to_decision(r: sqlite3.Row) -> Decision:
|
||||||
|
return Decision(
|
||||||
|
id=r["id"], created_at=r["created_at"], situation=r["situation"],
|
||||||
|
choice=r["choice"], options=r["options"], rationale=r["rationale"],
|
||||||
|
confidence=r["confidence"], tags=r["tags"], outcome=r["outcome"],
|
||||||
|
outcome_rating=r["outcome_rating"], resolved_at=r["resolved_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def log_decision(situation: str, choice: str, options: str | None = None,
|
||||||
|
rationale: str | None = None, confidence: int | None = None,
|
||||||
|
tags: str | None = None) -> int:
|
||||||
|
"""Record a decision Brian made. Embeds situation+choice so similar future calls
|
||||||
|
can recall it. Returns the new row id. Resolve it later with resolve_decision."""
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
try:
|
||||||
|
[emb] = llm.embed([f"{situation}\nChose: {choice}"])
|
||||||
|
blob = _to_blob(emb)
|
||||||
|
except Exception:
|
||||||
|
blob = None # never block logging a decision on the embedder being down
|
||||||
|
conn = _connection()
|
||||||
|
with conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO decisions (created_at, situation, options, choice, rationale, "
|
||||||
|
"confidence, tags, embedding) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(now, situation, options, choice, rationale, confidence, tags, blob),
|
||||||
|
)
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_decision(decision_id: int, outcome: str, outcome_rating: int | None = None) -> bool:
|
||||||
|
"""Record how a past decision turned out. Returns False if the id is unknown."""
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
conn = _connection()
|
||||||
|
with conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE decisions SET outcome = ?, outcome_rating = ?, resolved_at = ? WHERE id = ?",
|
||||||
|
(outcome, outcome_rating, now, decision_id),
|
||||||
|
)
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_decision(decision_id: int) -> Decision | None:
|
||||||
|
r = _connection().execute("SELECT * FROM decisions WHERE id = ?", (decision_id,)).fetchone()
|
||||||
|
return _row_to_decision(r) if r else None
|
||||||
|
|
||||||
|
|
||||||
|
def list_decisions(limit: int = 20, open_only: bool = False) -> list[Decision]:
|
||||||
|
"""Recent decisions, newest first. open_only -> only those not yet resolved."""
|
||||||
|
sql = "SELECT * FROM decisions"
|
||||||
|
if open_only:
|
||||||
|
sql += " WHERE resolved_at IS NULL"
|
||||||
|
sql += " ORDER BY created_at DESC LIMIT ?"
|
||||||
|
rows = _connection().execute(sql, (limit,)).fetchall()
|
||||||
|
return [_row_to_decision(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def recall_decisions(query: str, k: int = 5) -> list[Decision]:
|
||||||
|
"""Top-k past decisions semantically similar to `query`, each with a `score` — so a
|
||||||
|
new call can lean on how similar ones went. Resolved rows carry the real signal."""
|
||||||
|
[q_vec] = llm.embed([query])
|
||||||
|
q = np.asarray(q_vec, dtype=np.float32)
|
||||||
|
rows = _connection().execute(
|
||||||
|
"SELECT * FROM decisions WHERE embedding IS NOT NULL"
|
||||||
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
matrix = np.stack([_from_blob(r["embedding"]) for r in rows])
|
||||||
|
norms = np.linalg.norm(matrix, axis=1)
|
||||||
|
scores = (matrix @ q) / (norms * np.linalg.norm(q) + 1e-9)
|
||||||
|
top_idx = np.argsort(scores)[::-1][:k]
|
||||||
|
out = []
|
||||||
|
for i in top_idx:
|
||||||
|
d = _row_to_decision(rows[i])
|
||||||
|
d.score = float(scores[i])
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def get_setting(key: str, default: str | None = None) -> str | None:
|
def get_setting(key: str, default: str | None = None) -> str | None:
|
||||||
"""A runtime setting value (UI-tunable), or `default` if unset."""
|
"""A runtime setting value (UI-tunable), or `default` if unset."""
|
||||||
r = _connection().execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
r = _connection().execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||||
|
|||||||
+15
-89
@@ -651,102 +651,38 @@ def _review_session_id() -> int:
|
|||||||
return int(cur.lastrowid)
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
# --- the canonical structured-hand contract (see docs/HAND_HISTORY.md) ---------
|
|
||||||
# This is the single shape that gets stored, replayed by the viewer, and exported to
|
|
||||||
# RTO. The LLM parser produces it today; the tap recorder will produce it natively.
|
|
||||||
HAND_SCHEMA_VERSION = 1
|
|
||||||
POSITIONS = ("UTG", "UTG1", "UTG2", "MP", "LJ", "HJ", "CO", "BTN", "SB", "BB")
|
|
||||||
ACTION_VERBS = ("post", "fold", "check", "call", "bet", "raise", "allin")
|
|
||||||
STREETS = ("preflop", "flop", "turn", "river")
|
|
||||||
|
|
||||||
_SUIT_SYM = {"♥": "h", "♦": "d", "♣": "c", "♠": "s"}
|
_SUIT_SYM = {"♥": "h", "♦": "d", "♣": "c", "♠": "s"}
|
||||||
|
|
||||||
|
|
||||||
def _norm_card(c):
|
def _norm_card(c):
|
||||||
"""Canonicalize one card string: unicode suit -> letter, '10' -> 'T', rank upper,
|
|
||||||
suit lower (e.g. '10♥' -> 'Th', 'as' -> 'As'). Unknown placeholders are preserved:
|
|
||||||
'Ax' = known rank/unknown suit, 'x' = fully unknown card."""
|
|
||||||
if not isinstance(c, str):
|
if not isinstance(c, str):
|
||||||
return c
|
return c
|
||||||
s = c.strip()
|
s = c.strip()
|
||||||
for sym, ltr in _SUIT_SYM.items():
|
for sym, ltr in _SUIT_SYM.items():
|
||||||
s = s.replace(sym, ltr)
|
s = s.replace(sym, ltr)
|
||||||
s = s.replace("10", "T")
|
|
||||||
if len(s) == 2:
|
|
||||||
s = s[0].upper() + s[1].lower() # 'Ax' stays 'Ax'; 'x' (len 1) untouched
|
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
def _card_known(c) -> bool:
|
def _normalize_parsed(p: dict) -> dict:
|
||||||
"""True only for a fully specified card (rank+suit, no 'x' placeholder)."""
|
"""Normalize card strings (unicode suits -> letters) across a parsed hand."""
|
||||||
return isinstance(c, str) and len(c) == 2 and "x" not in c.lower()
|
if not isinstance(p, dict):
|
||||||
|
return p
|
||||||
|
for key in ("hero_cards", "board"):
|
||||||
def _completeness(p: dict) -> dict:
|
if isinstance(p.get(key), list):
|
||||||
"""Which parts of the hand are fully specified — lets a consumer (RTO) use what it
|
p[key] = [_norm_card(c) for c in p[key]]
|
||||||
can and skip suit-dependent math (flushes) on hands where suits weren't recorded."""
|
|
||||||
shown = [c for pl in (p.get("players") or []) if isinstance(pl.get("cards"), list)
|
|
||||||
for c in pl["cards"]]
|
|
||||||
hole = list(p.get("hero_cards") or []) + shown
|
|
||||||
return {
|
|
||||||
"cards": bool(hole) and all(_card_known(c) for c in hole),
|
|
||||||
"board": all(_card_known(c) for c in (p.get("board") or [])),
|
|
||||||
"actions": bool(p.get("actions")),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_structured(parsed: dict) -> dict:
|
|
||||||
"""Canonicalize a structured hand — from the LLM parser OR (later) the tap recorder —
|
|
||||||
into the versioned contract shape: normalized cards, the hero synced into players[]
|
|
||||||
(RTO finds the hero via pos == hero_pos), a schema_version stamp, and a completeness
|
|
||||||
summary. Idempotent — the single shape stored, replayed, and exported."""
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
return parsed
|
|
||||||
p = dict(parsed)
|
|
||||||
p["schema_version"] = HAND_SCHEMA_VERSION
|
|
||||||
p["hero_cards"] = [_norm_card(c) for c in (p.get("hero_cards") or [])]
|
|
||||||
p["board"] = [_norm_card(c) for c in (p.get("board") or [])]
|
|
||||||
|
|
||||||
players = []
|
|
||||||
for pl in p.get("players") or []:
|
for pl in p.get("players") or []:
|
||||||
if not isinstance(pl, dict):
|
if isinstance(pl, dict) and isinstance(pl.get("cards"), list):
|
||||||
continue
|
|
||||||
pl = dict(pl)
|
|
||||||
if isinstance(pl.get("cards"), list):
|
|
||||||
pl["cards"] = [_norm_card(c) for c in pl["cards"]]
|
pl["cards"] = [_norm_card(c) for c in pl["cards"]]
|
||||||
pl.pop("hero", None) # recomputed below so it can't go stale
|
|
||||||
players.append(pl)
|
|
||||||
|
|
||||||
# Hero must appear in players[] (with cards) — RTO reads the hero off pos==hero_pos.
|
|
||||||
hero_pos = p.get("hero_pos")
|
|
||||||
if hero_pos:
|
|
||||||
hero = next((pl for pl in players if pl.get("pos") == hero_pos), None)
|
|
||||||
if hero is None:
|
|
||||||
hero = {"pos": hero_pos}
|
|
||||||
players.insert(0, hero)
|
|
||||||
hero["hero"] = True
|
|
||||||
if p["hero_cards"] and not hero.get("cards"):
|
|
||||||
hero["cards"] = list(p["hero_cards"])
|
|
||||||
p["players"] = players
|
|
||||||
|
|
||||||
actions = []
|
|
||||||
for a in p.get("actions") or []:
|
for a in p.get("actions") or []:
|
||||||
if not isinstance(a, dict):
|
if isinstance(a, dict) and isinstance(a.get("board"), list):
|
||||||
continue
|
|
||||||
a = dict(a)
|
|
||||||
if isinstance(a.get("board"), list):
|
|
||||||
a["board"] = [_norm_card(c) for c in a["board"]]
|
a["board"] = [_norm_card(c) for c in a["board"]]
|
||||||
actions.append(a)
|
|
||||||
p["actions"] = actions
|
|
||||||
|
|
||||||
p["completeness"] = _completeness(p)
|
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
def store_hand_history(parsed: dict, session_id: int | None = None,
|
def store_hand_history(parsed: dict, session_id: int | None = None,
|
||||||
tag: str | None = None, lesson: str | None = None) -> int:
|
tag: str | None = None, lesson: str | None = None) -> int:
|
||||||
"""Store a parsed hand: full JSON + extracted flat fields for stats/listing."""
|
"""Store a parsed hand: full JSON + extracted flat fields for stats/listing."""
|
||||||
parsed = normalize_structured(parsed)
|
parsed = _normalize_parsed(parsed)
|
||||||
sid = _resolve(session_id) or _review_session_id()
|
sid = _resolve(session_id) or _review_session_id()
|
||||||
hero_cards = parsed.get("hero_cards") or []
|
hero_cards = parsed.get("hero_cards") or []
|
||||||
board = parsed.get("board") or []
|
board = parsed.get("board") or []
|
||||||
@@ -800,7 +736,7 @@ def reconstruct_hand(hand_id: int, backend: str | None = None) -> dict | None:
|
|||||||
parsed = parse_hand(shorthand, backend=backend)
|
parsed = parse_hand(shorthand, backend=backend)
|
||||||
if not parsed:
|
if not parsed:
|
||||||
return None
|
return None
|
||||||
parsed = normalize_structured(parsed)
|
parsed = _normalize_parsed(parsed)
|
||||||
conn = _c()
|
conn = _c()
|
||||||
with conn:
|
with conn:
|
||||||
conn.execute("UPDATE poker_hands SET structured = ? WHERE id = ?",
|
conn.execute("UPDATE poker_hands SET structured = ? WHERE id = ?",
|
||||||
@@ -815,29 +751,19 @@ def get_hand(hand_id: int) -> dict | None:
|
|||||||
if not r:
|
if not r:
|
||||||
return None
|
return None
|
||||||
d = dict(r)
|
d = dict(r)
|
||||||
# Normalize on read too: legacy rows predate the contract, and it's idempotent for
|
d["structured"] = json.loads(d["structured"]) if d.get("structured") else None
|
||||||
# new ones — so /hand/{id}/data always serves the current versioned shape.
|
|
||||||
d["structured"] = normalize_structured(json.loads(d["structured"])) if d.get("structured") else None
|
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
def list_recent_hands(limit: int = 60) -> list[dict]:
|
def list_recent_hands(limit: int = 60) -> list[dict]:
|
||||||
"""Recent recorded hands with their session's venue/stakes, for browsing. Each carries
|
"""Recent recorded hands with their session's venue/stakes, for browsing."""
|
||||||
has_structured so a consumer (the export, RTO) knows which hands have a replayable
|
|
||||||
structured body worth fetching via /hand/{id}/data vs. flat quick-logs."""
|
|
||||||
rows = _c().execute(
|
rows = _c().execute(
|
||||||
"SELECT h.id, h.position, h.hole_cards, h.board, h.result, h.tag, h.at, "
|
"SELECT h.id, h.position, h.hole_cards, h.board, h.result, h.tag, h.at, "
|
||||||
"h.lesson, (h.structured IS NOT NULL) AS has_structured, "
|
"h.lesson, s.venue AS venue, s.stakes AS stakes "
|
||||||
"s.venue AS venue, s.stakes AS stakes "
|
|
||||||
"FROM poker_hands h LEFT JOIN poker_sessions s ON s.id = h.session_id "
|
"FROM poker_hands h LEFT JOIN poker_sessions s ON s.id = h.session_id "
|
||||||
"ORDER BY h.id DESC LIMIT ?", (limit,),
|
"ORDER BY h.id DESC LIMIT ?", (limit,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
out = []
|
return [dict(r) for r in rows]
|
||||||
for r in rows:
|
|
||||||
d = dict(r)
|
|
||||||
d["has_structured"] = bool(d["has_structured"])
|
|
||||||
out.append(d)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# --- session recap (.md generation on top of structured data + conversation) ---
|
# --- session recap (.md generation on top of structured data + conversation) ---
|
||||||
|
|||||||
@@ -81,6 +81,65 @@ def _thought_response(args: dict, ctx: dict) -> str:
|
|||||||
"next time I'm thinking.")
|
"next time I'm thinking.")
|
||||||
|
|
||||||
|
|
||||||
|
def _log_decision(args: dict, ctx: dict) -> str:
|
||||||
|
situation = (args.get("situation") or "").strip()
|
||||||
|
choice = (args.get("choice") or "").strip()
|
||||||
|
if not situation or not choice:
|
||||||
|
return "Need both what was being decided and the call you landed on."
|
||||||
|
conf = args.get("confidence")
|
||||||
|
try:
|
||||||
|
conf = int(conf) if conf is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
conf = None
|
||||||
|
did = memory.log_decision(
|
||||||
|
situation=situation, choice=choice,
|
||||||
|
options=(args.get("options") or "").strip() or None,
|
||||||
|
rationale=(args.get("rationale") or "").strip() or None,
|
||||||
|
confidence=conf, tags=(args.get("tags") or "").strip() or None,
|
||||||
|
)
|
||||||
|
logbus.log("info", "decision logged (tool)", id=did)
|
||||||
|
return (f"Logged decision #{did}. When you know how it played out, tell me and "
|
||||||
|
"I'll close the loop on it.")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_decision(args: dict, ctx: dict) -> str:
|
||||||
|
try:
|
||||||
|
did = int(args.get("decision_id"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "Which decision? I need its id (#number)."
|
||||||
|
outcome = (args.get("outcome") or "").strip()
|
||||||
|
if not outcome:
|
||||||
|
return "Tell me how it turned out so I can record the outcome."
|
||||||
|
rating = args.get("rating")
|
||||||
|
try:
|
||||||
|
rating = int(rating) if rating is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
rating = None
|
||||||
|
if not memory.resolve_decision(did, outcome, rating):
|
||||||
|
return f"(couldn't find decision #{did})"
|
||||||
|
logbus.log("info", "decision resolved (tool)", id=did, rating=rating)
|
||||||
|
return f"Closed the loop on decision #{did}. That goes into how I weigh the next one."
|
||||||
|
|
||||||
|
|
||||||
|
def _recall_decisions(args: dict, ctx: dict) -> str:
|
||||||
|
query = (args.get("query") or "").strip()
|
||||||
|
if not query:
|
||||||
|
return "Give me the gist of the call you're weighing and I'll pull similar past ones."
|
||||||
|
hits = memory.recall_decisions(query, k=int(args.get("k") or 4))
|
||||||
|
if not hits:
|
||||||
|
return "No comparable past decisions on record yet."
|
||||||
|
lines = []
|
||||||
|
for d in hits:
|
||||||
|
head = f"#{d.id} ({d.created_at[:10]}): {d.situation} → {d.choice}"
|
||||||
|
if d.resolved:
|
||||||
|
verdict = {1: "went well", 0: "mixed", -1: "went badly"}.get(d.outcome_rating, "resolved")
|
||||||
|
head += f" — {verdict}: {d.outcome}"
|
||||||
|
else:
|
||||||
|
head += " — outcome still open"
|
||||||
|
lines.append(head)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
# name -> {spec (OpenAI function tool), handler}
|
# name -> {spec (OpenAI function tool), handler}
|
||||||
TOOLS: dict[str, dict] = {
|
TOOLS: dict[str, dict] = {
|
||||||
"journal_write": {
|
"journal_write": {
|
||||||
@@ -481,6 +540,34 @@ TOOLS.update({
|
|||||||
{"thread_id": {**_N, "description": "The thread id (#number) of the thought he reacted to."},
|
{"thread_id": {**_N, "description": "The thread id (#number) of the thought he reacted to."},
|
||||||
"brian_said": {**_S, "description": "What Brian said / his take, in your words."}},
|
"brian_said": {**_S, "description": "What Brian said / his take, in your words."}},
|
||||||
["thread_id", "brian_said"])},
|
["thread_id", "brian_said"])},
|
||||||
|
"log_decision": {"handler": _log_decision, "spec": _f(
|
||||||
|
"log_decision",
|
||||||
|
"Record a real decision Brian lands on (especially in Decide mode) so it can "
|
||||||
|
"inform future calls. Capture it once he's settled — what he was deciding, the "
|
||||||
|
"call, and why. Outcome comes later via resolve_decision.",
|
||||||
|
{"situation": {**_S, "description": "What was being decided, in Brian's terms."},
|
||||||
|
"choice": {**_S, "description": "The call he landed on."},
|
||||||
|
"options": {**_S, "description": "The alternatives weighed (optional, newline/free text)."},
|
||||||
|
"rationale": {**_S, "description": "Why this call (optional)."},
|
||||||
|
"confidence": {**_N, "description": "How sure he was, 1-5 (optional)."},
|
||||||
|
"tags": {**_S, "description": "Domain, comma-separated: poker | life | build | ... (optional)."}},
|
||||||
|
["situation", "choice"])},
|
||||||
|
"resolve_decision": {"handler": _resolve_decision, "spec": _f(
|
||||||
|
"resolve_decision",
|
||||||
|
"Close the loop on a previously logged decision once Brian knows how it turned "
|
||||||
|
"out. This is what makes the decision log learn — outcomes sharpen future calls.",
|
||||||
|
{"decision_id": {**_N, "description": "The decision id (#number)."},
|
||||||
|
"outcome": {**_S, "description": "What actually happened, in Brian's terms."},
|
||||||
|
"rating": {**_N, "description": "How it went: 1 good / 0 mixed / -1 bad (optional)."}},
|
||||||
|
["decision_id", "outcome"])},
|
||||||
|
"recall_decisions": {"handler": _recall_decisions, "spec": _f(
|
||||||
|
"recall_decisions",
|
||||||
|
"Pull past decisions similar to one Brian's weighing now, with how they turned "
|
||||||
|
"out — so you can ground a recommendation in his own track record rather than "
|
||||||
|
"generic advice. Use it when he's deciding something with precedent.",
|
||||||
|
{"query": {**_S, "description": "The gist of the current call / situation."},
|
||||||
|
"k": {**_N, "description": "How many to pull (default 4)."}},
|
||||||
|
["query"])},
|
||||||
"start_session": {"handler": _start_session, "spec": _f(
|
"start_session": {"handler": _start_session, "spec": _f(
|
||||||
"start_session",
|
"start_session",
|
||||||
"Begin a live poker session. Call when Brian sits down to play.",
|
"Begin a live poker session. Call when Brian sits down to play.",
|
||||||
|
|||||||
@@ -701,16 +701,6 @@
|
|||||||
window.addEventListener("resize", nudgeAppHeight);
|
window.addEventListener("resize", nudgeAppHeight);
|
||||||
window.addEventListener("orientationchange", nudgeAppHeight);
|
window.addEventListener("orientationchange", nudgeAppHeight);
|
||||||
|
|
||||||
// A rotation reflows the chat and iOS drops the scroll to mid-history. If we
|
|
||||||
// were pinned to the latest message, snap back there once the layout settles
|
|
||||||
// (re-fire across the reflow since iOS reports stale dimensions mid-rotate).
|
|
||||||
window.addEventListener("orientationchange", () => {
|
|
||||||
const m = document.getElementById("messages");
|
|
||||||
const wasAtBottom = m.scrollHeight - m.scrollTop - m.clientHeight < 90;
|
|
||||||
if (!wasAtBottom) return; // respect the user's scroll-up position
|
|
||||||
[100, 300, 600].forEach((t) => setTimeout(() => { m.scrollTop = m.scrollHeight; }, t));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep the latest message in view when the keyboard opens/closes.
|
// Keep the latest message in view when the keyboard opens/closes.
|
||||||
const userInputEl = document.getElementById("userInput");
|
const userInputEl = document.getElementById("userInput");
|
||||||
userInputEl.addEventListener("focus", () => {
|
userInputEl.addEventListener("focus", () => {
|
||||||
|
|||||||
@@ -56,10 +56,6 @@ body.dark {
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
/* Stop iOS from inflating font sizes when the device rotates to landscape (and
|
|
||||||
leaving them big on rotate back). Every other page sets this; the chat didn't. */
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
text-size-adjust: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Decision log (Decide mode's learning layer): log -> resolve -> recall, + tools."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mem(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||||
|
from lyra import llm
|
||||||
|
# Deterministic, content-dependent embeddings so recall ordering is meaningful:
|
||||||
|
# "cleveland"/"tournament" cluster on axis 0, "stocks"/"money" on axis 1.
|
||||||
|
def fake_embed(texts):
|
||||||
|
out = []
|
||||||
|
for t in texts:
|
||||||
|
t = t.lower()
|
||||||
|
poker = sum(w in t for w in ("tournament", "cleveland", "poker", "buy-in"))
|
||||||
|
money = sum(w in t for w in ("stocks", "money", "invest", "sell"))
|
||||||
|
out.append([float(poker), float(money), 0.1])
|
||||||
|
return out
|
||||||
|
monkeypatch.setattr(llm, "embed", fake_embed)
|
||||||
|
import lyra.memory as memory
|
||||||
|
importlib.reload(memory)
|
||||||
|
return memory
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_and_get_roundtrip(mem):
|
||||||
|
did = mem.log_decision(
|
||||||
|
situation="Play the Cleveland turbo tournament tomorrow?",
|
||||||
|
choice="Yes, but only the noon flight",
|
||||||
|
options="skip it / noon flight / both flights",
|
||||||
|
rationale="20-min levels suit my aggression; one flight caps the variance",
|
||||||
|
confidence=4, tags="poker,tournament",
|
||||||
|
)
|
||||||
|
d = mem.get_decision(did)
|
||||||
|
assert d.situation.startswith("Play the Cleveland")
|
||||||
|
assert d.choice == "Yes, but only the noon flight"
|
||||||
|
assert d.confidence == 4 and d.tags == "poker,tournament"
|
||||||
|
assert not d.resolved and d.outcome is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_closes_the_loop(mem):
|
||||||
|
did = mem.log_decision(situation="Sell the stocks now?", choice="Hold")
|
||||||
|
assert mem.resolve_decision(did, "Recovered 12% the next week", outcome_rating=1)
|
||||||
|
d = mem.get_decision(did)
|
||||||
|
assert d.resolved and d.outcome_rating == 1
|
||||||
|
assert "Recovered" in d.outcome and d.resolved_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_unknown_id_is_false(mem):
|
||||||
|
assert mem.resolve_decision(999, "n/a") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_open_only_filters_resolved(mem):
|
||||||
|
a = mem.log_decision(situation="A?", choice="x")
|
||||||
|
mem.log_decision(situation="B?", choice="y")
|
||||||
|
mem.resolve_decision(a, "done", 0)
|
||||||
|
assert {d.situation for d in mem.list_decisions(open_only=True)} == {"B?"}
|
||||||
|
assert len(mem.list_decisions()) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_recall_ranks_by_similarity(mem):
|
||||||
|
mem.log_decision(situation="Which Cleveland tournament flight?", choice="noon")
|
||||||
|
mem.log_decision(situation="Should I sell the stocks?", choice="hold")
|
||||||
|
hits = mem.recall_decisions("another poker tournament buy-in", k=2)
|
||||||
|
assert hits[0].situation.startswith("Which Cleveland") # poker cluster ranks first
|
||||||
|
assert hits[0].score >= hits[1].score
|
||||||
|
|
||||||
|
|
||||||
|
# --- tool layer ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_log_decision_tool_persists(mem):
|
||||||
|
from lyra import tools
|
||||||
|
out = tools.dispatch("log_decision",
|
||||||
|
{"situation": "Move the MI50 to auto clocks?", "choice": "yes",
|
||||||
|
"confidence": "3", "tags": "build"})
|
||||||
|
assert "#1" in out
|
||||||
|
d = mem.get_decision(1)
|
||||||
|
assert d.choice == "yes" and d.confidence == 3 and d.tags == "build"
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_decision_tool_requires_both_fields(mem):
|
||||||
|
from lyra import tools
|
||||||
|
assert "Need both" in tools.dispatch("log_decision", {"situation": "just this"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_decision_tool(mem):
|
||||||
|
from lyra import tools
|
||||||
|
did = mem.log_decision(situation="X?", choice="y")
|
||||||
|
out = tools.dispatch("resolve_decision",
|
||||||
|
{"decision_id": did, "outcome": "worked out", "rating": "1"})
|
||||||
|
assert f"#{did}" in out
|
||||||
|
assert mem.get_decision(did).outcome_rating == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_recall_decisions_tool_surfaces_outcomes(mem):
|
||||||
|
from lyra import tools
|
||||||
|
did = mem.log_decision(situation="Cleveland tournament again?", choice="play")
|
||||||
|
mem.resolve_decision(did, "min-cashed", outcome_rating=0)
|
||||||
|
out = tools.dispatch("recall_decisions", {"query": "poker tournament tomorrow"})
|
||||||
|
assert "Cleveland" in out and "mixed" in out
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
"""The canonical structured-hand contract (docs/HAND_HISTORY.md): normalize + export.
|
|
||||||
|
|
||||||
normalize_structured() is the single guarantee that every stored / replayed / exported
|
|
||||||
hand has the versioned shape RTO consumes.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def poker(tmp_path, monkeypatch):
|
|
||||||
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
|
||||||
from lyra import llm
|
|
||||||
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
|
|
||||||
import lyra.memory as memory
|
|
||||||
importlib.reload(memory)
|
|
||||||
import lyra.poker as poker
|
|
||||||
importlib.reload(poker)
|
|
||||||
return poker
|
|
||||||
|
|
||||||
|
|
||||||
def _full_hand():
|
|
||||||
return {
|
|
||||||
"game": "NLH", "stakes": "1/3", "hero_pos": "BTN",
|
|
||||||
"hero_cards": ["ah", "kh"],
|
|
||||||
"players": [
|
|
||||||
{"pos": "BTN", "stack": 300, "name": "Hero"},
|
|
||||||
{"pos": "BB", "stack": 250, "name": "Sal", "cards": ["qs", "qd"]},
|
|
||||||
],
|
|
||||||
"actions": [
|
|
||||||
{"street": "preflop", "pos": "BTN", "action": "raise", "amount": 15},
|
|
||||||
{"street": "flop", "board": ["7♦", "2♣", "5♥"]},
|
|
||||||
{"street": "flop", "pos": "BB", "action": "check"},
|
|
||||||
],
|
|
||||||
"board": ["7♦", "2♣", "5♥"],
|
|
||||||
"result": {"pot": 40, "hero_net": 25, "summary": "won at showdown"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_stamps_version(poker):
|
|
||||||
out = poker.normalize_structured({"hero_pos": "CO"})
|
|
||||||
assert out["schema_version"] == poker.HAND_SCHEMA_VERSION
|
|
||||||
|
|
||||||
|
|
||||||
def test_card_normalization(poker):
|
|
||||||
out = poker.normalize_structured(_full_hand())
|
|
||||||
assert out["hero_cards"] == ["Ah", "Kh"] # lowercased input -> canonical
|
|
||||||
assert out["board"] == ["7d", "2c", "5h"] # unicode suits -> letters
|
|
||||||
assert out["actions"][1]["board"] == ["7d", "2c", "5h"]
|
|
||||||
# ten + suit symbol together
|
|
||||||
assert poker.normalize_structured({"board": ["10♠"]})["board"] == ["Ts"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_cards_preserved(poker):
|
|
||||||
out = poker.normalize_structured({"hero_cards": ["Ax", "x"], "board": ["Ax", "4x", "x"]})
|
|
||||||
assert out["hero_cards"] == ["Ax", "x"] # placeholders kept, not dropped
|
|
||||||
assert out["completeness"]["cards"] is False
|
|
||||||
assert out["completeness"]["board"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_hero_synced_into_players(poker):
|
|
||||||
out = poker.normalize_structured(_full_hand())
|
|
||||||
hero = next(p for p in out["players"] if p["pos"] == "BTN")
|
|
||||||
assert hero["hero"] is True
|
|
||||||
assert hero["cards"] == ["Ah", "Kh"] # mirrored from hero_cards
|
|
||||||
assert sum(1 for p in out["players"] if p.get("hero")) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_hero_inserted_when_missing_from_players(poker):
|
|
||||||
out = poker.normalize_structured({"hero_pos": "SB", "hero_cards": ["As", "Ad"], "players": []})
|
|
||||||
assert out["players"] == [{"pos": "SB", "hero": True, "cards": ["As", "Ad"]}]
|
|
||||||
|
|
||||||
|
|
||||||
def test_completeness_full_hand(poker):
|
|
||||||
c = poker.normalize_structured(_full_hand())["completeness"]
|
|
||||||
assert c == {"cards": True, "board": True, "actions": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_idempotent(poker):
|
|
||||||
once = poker.normalize_structured(_full_hand())
|
|
||||||
twice = poker.normalize_structured(once)
|
|
||||||
assert once == twice
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_and_get_roundtrip_is_normalized(poker):
|
|
||||||
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
|
|
||||||
hid = poker.store_hand_history(_full_hand(), session_id=sid, tag="well_played")
|
|
||||||
got = poker.get_hand(hid)["structured"]
|
|
||||||
assert got["schema_version"] == poker.HAND_SCHEMA_VERSION
|
|
||||||
assert got["board"] == ["7d", "2c", "5h"]
|
|
||||||
assert got["completeness"]["cards"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_recent_hands_flags_structured(poker):
|
|
||||||
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
|
|
||||||
structured_id = poker.store_hand_history(_full_hand(), session_id=sid)
|
|
||||||
flat_id = poker.log_hand(session_id=sid, position="CO", hole_cards="Jc Jd")
|
|
||||||
rows = {r["id"]: r for r in poker.list_recent_hands()}
|
|
||||||
assert rows[structured_id]["has_structured"] is True
|
|
||||||
assert rows[flat_id]["has_structured"] is False
|
|
||||||
Reference in New Issue
Block a user