Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 865834a8ae | |||
| fb6b44a82e | |||
| 393ca65dee | |||
| 14480c40b2 | |||
| 8d709b9554 | |||
| 52839a9bc8 | |||
| 8ad4bc4ce0 | |||
| 8031c277a2 | |||
| 36f2aa76b3 | |||
| a2835500bc | |||
| 5c63175a3c | |||
| 86f3d2dc0a | |||
| abac42c344 | |||
| 44bb8687f7 | |||
| cb4ed10c1a | |||
| ba00530caf | |||
| 66dd880f93 | |||
| a7901a66ae |
@@ -1,48 +0,0 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,720 @@
|
||||
# Poker Logging Service Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Turn `lyra/poker.py` into a standalone logging system-of-record with a complete REST API, a single source-of-truth tool/API contract, and a human UI to log and correct everything — usable by Brian with zero LLM dependency.
|
||||
|
||||
**Architecture:** Thin FastAPI routes wrap the existing (already-working) `poker.py` store functions; a declarative `poker_contract.py` pins operation names + required args so the REST API and Lyra's LLM tool specs can't drift; the web UI gets dumb capture inputs (2nd stack box on chat, quick inputs on the HUD) and correction controls. This is sub-project 1 of 2; Lyra's classifier/prompts (sub-project 2) are parked.
|
||||
|
||||
**Tech Stack:** Python 3.11+ (venv runs 3.14), FastAPI + uvicorn, SQLite (WAL), pytest, vanilla HTML/JS/CSS.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Python files: start with `from __future__ import annotations`; 4-space indent; ruff `line-length = 100`, `target-version = "py311"`.
|
||||
- **`lyra/web/static/index.html` uses CRLF (`\r\n`) line endings and mixed tabs/spaces.** Every other static file (`session.html`, `style.css`, `nav.js`) and all Python use **LF + spaces**. Match the file you edit or you produce a noisy diff.
|
||||
- Pure data capture (stack / buy-in / cash-out / hand / read) must reach the store via the REST endpoints, **never** through the chat/LLM path.
|
||||
- `poker_contract.py` is the single source of truth: REST routes and `tools.py` specs must agree with it (enforced by a conformance test).
|
||||
- Web app runs via `lyra-web` (uvicorn) on `0.0.0.0:7078`. DB path from `LYRA_DB_PATH` (default `data/lyra.db`, WAL).
|
||||
- Test idiom: fixture sets `LYRA_DB_PATH` to a `tmp_path` file, stubs `llm.embed` (and `llm.complete` where needed), then `importlib.reload(memory)` **then** `importlib.reload(poker)` (order matters), then `importlib.reload(server)` for endpoint tests. Run with `.venv/bin/pytest` (or `uv run pytest`).
|
||||
- Existing store facts to respect: `start_session(...)` uses `fmt=` (column is `format`); `add_buyin` returns a float total; `log_stack` returns the `stack_state` dict `{current, buy_in, net}`; `end_session(cash_out, ...)` takes `cash_out` first; `hud()` returns `None` when no session; `_HAND_FIELDS = ("position","hole_cards","board","preflop","flop","turn","river","showdown","pot","result","stack_after","tag","lesson")`; `upsert_player(name, **fields)` returns an int player id; `tools.dispatch(name, args, ctx)` — `ctx` is a plain dict.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Contract module + tool-spec conformance test
|
||||
|
||||
**Files:**
|
||||
- Create: `lyra/poker_contract.py`
|
||||
- Create: `tests/test_poker_contract.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `lyra.poker_contract.OPERATIONS: dict[str, dict]` and `CONTRACT_VERSION: int`. Each op value: `{"required": tuple[str,...], "llm_tool": str | None, "rest": tuple[str, str] | None}` where `rest` is `(METHOD, PATH)` with PATH exactly matching the FastAPI route template.
|
||||
|
||||
- [ ] **Step 1: Write the contract module**
|
||||
|
||||
`lyra/poker_contract.py`:
|
||||
```python
|
||||
from __future__ import annotations
|
||||
|
||||
# Single source of truth for poker logging operations. The REST API, Lyra's LLM
|
||||
# tool specs, the human UI, and (later) an MCP wrapper all derive from this.
|
||||
# `required` MUST match the `required` list in the matching tools.py spec.
|
||||
# `rest` PATH MUST match the FastAPI route template verbatim.
|
||||
CONTRACT_VERSION = 1
|
||||
|
||||
OPERATIONS: dict[str, dict] = {
|
||||
"start_session": {"required": (), "llm_tool": "start_session", "rest": ("POST", "/session")},
|
||||
"update_session": {"required": (), "llm_tool": "update_session", "rest": ("PATCH", "/session/{session_id}")},
|
||||
"end_session": {"required": ("cash_out",), "llm_tool": "end_session", "rest": None},
|
||||
"log_stack": {"required": ("amount",), "llm_tool": "log_stack", "rest": ("POST", "/session/stack")},
|
||||
"add_buyin": {"required": ("amount",), "llm_tool": "add_buyin", "rest": ("POST", "/session/buyin")},
|
||||
"log_hand": {"required": (), "llm_tool": "log_hand", "rest": ("POST", "/session/hand")},
|
||||
"update_hand": {"required": ("id",), "llm_tool": None, "rest": ("PATCH", "/hand/{hand_id}")},
|
||||
"add_read": {"required": ("note",), "llm_tool": "add_read", "rest": ("POST", "/session/read")},
|
||||
"update_player": {"required": ("id",), "llm_tool": None, "rest": ("PATCH", "/player/{player_id}")},
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing conformance test**
|
||||
|
||||
`tests/test_poker_contract.py`:
|
||||
```python
|
||||
from __future__ import annotations
|
||||
|
||||
from lyra import tools
|
||||
from lyra.poker_contract import OPERATIONS
|
||||
|
||||
|
||||
def test_llm_tool_required_args_match_contract():
|
||||
for op, decl in OPERATIONS.items():
|
||||
name = decl["llm_tool"]
|
||||
if not name:
|
||||
continue
|
||||
spec = tools.TOOLS[name]["spec"]
|
||||
required = set(spec["function"]["parameters"]["required"])
|
||||
assert required == set(decl["required"]), (
|
||||
f"{op}: tools spec required {required} != contract {set(decl['required'])}"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the test**
|
||||
|
||||
Run: `.venv/bin/pytest tests/test_poker_contract.py -v`
|
||||
Expected: PASS (the contract's `required` tuples were copied from the live specs).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add lyra/poker_contract.py tests/test_poker_contract.py
|
||||
git commit -m "feat: poker operation contract + tool-spec conformance test"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Direct capture endpoints (stack / buy-in / start)
|
||||
|
||||
**Files:**
|
||||
- Modify: `lyra/web/server.py` (add three routes inside `create_app`, near the existing `PATCH /session/{session_id}` at server.py:116)
|
||||
- Create: `tests/test_poker_api.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `poker.log_stack(amount, note=None)`, `poker.add_buyin(amount)`, `poker.start_session(venue=, stakes=, game=, fmt=, buy_in=, mantra=)`, `poker.live_session()`.
|
||||
- Produces: `POST /session/stack` → `{ok, stack}` or `{ok:false, error}`; `POST /session/buyin` → `{ok, buy_in_total}`; `POST /session` → `{ok, id}`.
|
||||
|
||||
- [ ] **Step 1: Write the failing endpoint tests**
|
||||
|
||||
`tests/test_poker_api.py`:
|
||||
```python
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(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)
|
||||
import lyra.web.server as server
|
||||
importlib.reload(server)
|
||||
from fastapi.testclient import TestClient
|
||||
return TestClient(server.app), poker
|
||||
|
||||
|
||||
def test_post_stack_logs_and_returns_state(client):
|
||||
c, poker = client
|
||||
poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
|
||||
r = c.post("/session/stack", json={"amount": 373})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["stack"]["current"] == 373
|
||||
assert body["stack"]["net"] == pytest.approx(-27)
|
||||
|
||||
|
||||
def test_post_stack_without_session_errors(client):
|
||||
c, _ = client
|
||||
r = c.post("/session/stack", json={"amount": 373})
|
||||
assert r.json()["ok"] is False
|
||||
assert "error" in r.json()
|
||||
|
||||
|
||||
def test_post_buyin_increments_total(client):
|
||||
c, poker = client
|
||||
poker.start_session(buy_in=400)
|
||||
r = c.post("/session/buyin", json={"amount": 200})
|
||||
assert r.json()["buy_in_total"] == pytest.approx(600)
|
||||
|
||||
|
||||
def test_post_session_starts_live(client):
|
||||
c, poker = client
|
||||
r = c.post("/session", json={"venue": "Wheeling", "stakes": "1/3", "buy_in": 400})
|
||||
sid = r.json()["id"]
|
||||
assert poker.live_session()["id"] == sid
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `.venv/bin/pytest tests/test_poker_api.py -v`
|
||||
Expected: FAIL with 404s (routes not defined). If it errors with "No module named 'httpx'", run `.venv/bin/pip install httpx` (TestClient needs it).
|
||||
|
||||
- [ ] **Step 3: Add the three routes**
|
||||
|
||||
In `lyra/web/server.py`, immediately after the `PATCH /session/{session_id}` handler (server.py:122), add:
|
||||
```python
|
||||
@app.post("/session/stack")
|
||||
async def session_log_stack(request: Request) -> dict:
|
||||
"""Log Brian's current stack directly (no LLM). Server-stamps the time."""
|
||||
body = await request.json()
|
||||
try:
|
||||
amount = float(body.get("amount"))
|
||||
except (TypeError, ValueError):
|
||||
return {"ok": False, "error": "amount must be a number"}
|
||||
note = (body.get("note") or "").strip() or None
|
||||
try:
|
||||
state = await asyncio.to_thread(poker.log_stack, amount, note)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
logbus.log("info", "stack logged (direct)", amount=amount)
|
||||
return {"ok": True, "stack": state}
|
||||
|
||||
@app.post("/session/buyin")
|
||||
async def session_add_buyin(request: Request) -> dict:
|
||||
"""Add a buy-in/rebuy directly (no LLM)."""
|
||||
body = await request.json()
|
||||
try:
|
||||
amount = float(body.get("amount"))
|
||||
except (TypeError, ValueError):
|
||||
return {"ok": False, "error": "amount must be a number"}
|
||||
try:
|
||||
total = await asyncio.to_thread(poker.add_buyin, amount)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
logbus.log("info", "buyin added (direct)", amount=amount)
|
||||
return {"ok": True, "buy_in_total": total}
|
||||
|
||||
@app.post("/session")
|
||||
async def session_start(request: Request) -> dict:
|
||||
"""Open a new live session directly (no LLM)."""
|
||||
body = await request.json()
|
||||
sid = await asyncio.to_thread(lambda: poker.start_session(
|
||||
venue=body.get("venue"), stakes=body.get("stakes"),
|
||||
game=body.get("game") or "NLH", fmt=body.get("format") or "cash",
|
||||
buy_in=body.get("buy_in") or 0, mantra=body.get("mantra"),
|
||||
))
|
||||
logbus.log("info", "poker session started (direct)", id=sid)
|
||||
return {"ok": True, "id": sid}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes**
|
||||
|
||||
Run: `.venv/bin/pytest tests/test_poker_api.py -v`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lyra/web/server.py tests/test_poker_api.py
|
||||
git commit -m "feat: direct REST endpoints for stack/buyin/start-session (no LLM)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Hands API (log / edit / delete)
|
||||
|
||||
**Files:**
|
||||
- Modify: `lyra/poker.py` (add `update_hand` near `log_hand` at poker.py:558)
|
||||
- Modify: `lyra/web/server.py` (add routes after the Task 2 routes)
|
||||
- Modify: `tests/test_poker_api.py` (add tests)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `poker.log_hand(**fields)`, `poker.get_hand(id)`, `poker.delete_entry("hand", id)`, `_HAND_FIELDS`.
|
||||
- Produces: `poker.update_hand(hand_id, **fields) -> dict | None`; `POST /session/hand` → `{ok, id}`; `PATCH /hand/{hand_id}` → `{ok, hand}`; `DELETE /hand/{hand_id}` → `{ok}`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/test_poker_api.py`:
|
||||
```python
|
||||
def test_post_hand_edit_and_delete(client):
|
||||
c, poker = client
|
||||
poker.start_session(buy_in=400)
|
||||
r = c.post("/session/hand", json={"position": "BTN", "hole_cards": "22", "result": 120})
|
||||
assert r.json()["ok"] is True
|
||||
hid = r.json()["id"]
|
||||
r2 = c.patch(f"/hand/{hid}", json={"hole_cards": "2c2d"})
|
||||
assert r2.json()["ok"] is True
|
||||
assert r2.json()["hand"]["hole_cards"] == "2c2d"
|
||||
r3 = c.delete(f"/hand/{hid}")
|
||||
assert r3.json()["ok"] is True
|
||||
assert poker.get_hand(hid) is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `.venv/bin/pytest tests/test_poker_api.py::test_post_hand_edit_and_delete -v`
|
||||
Expected: FAIL (404 on `/session/hand`).
|
||||
|
||||
- [ ] **Step 3: Add `update_hand` to the store**
|
||||
|
||||
In `lyra/poker.py`, immediately after `log_hand` (poker.py:558), add:
|
||||
```python
|
||||
def update_hand(hand_id: int, **fields) -> dict | None:
|
||||
"""Edit a logged hand's flat fields (fix a mislabeled board, result, villain).
|
||||
Only known columns are touched. Returns the updated hand row or None."""
|
||||
sets, vals = [], []
|
||||
for k, v in fields.items():
|
||||
if k in _HAND_FIELDS and v is not None:
|
||||
sets.append(f"{k} = ?")
|
||||
vals.append(v)
|
||||
if sets:
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(f"UPDATE poker_hands SET {', '.join(sets)} WHERE id = ?",
|
||||
(*vals, hand_id))
|
||||
return get_hand(hand_id)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the three routes**
|
||||
|
||||
In `lyra/web/server.py`, after the Task 2 routes, add:
|
||||
```python
|
||||
@app.post("/session/hand")
|
||||
async def session_log_hand(request: Request) -> dict:
|
||||
"""Log a hand directly with flat fields (no LLM parse)."""
|
||||
body = await request.json()
|
||||
try:
|
||||
hid = await asyncio.to_thread(lambda: poker.log_hand(**body))
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
logbus.log("info", "hand logged (direct)", id=hid)
|
||||
return {"ok": True, "id": hid}
|
||||
|
||||
@app.patch("/hand/{hand_id}")
|
||||
async def hand_update(hand_id: int, request: Request) -> dict:
|
||||
"""Edit a logged hand's flat fields."""
|
||||
body = await request.json()
|
||||
h = await asyncio.to_thread(lambda: poker.update_hand(hand_id, **body))
|
||||
logbus.log("info", "hand edited", id=hand_id, fields=list(body))
|
||||
return {"ok": h is not None, "hand": h}
|
||||
|
||||
@app.delete("/hand/{hand_id}")
|
||||
async def hand_delete(hand_id: int) -> dict:
|
||||
"""Delete a logged hand."""
|
||||
ok = await asyncio.to_thread(poker.delete_entry, "hand", hand_id)
|
||||
return {"ok": ok}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify it passes**
|
||||
|
||||
Run: `.venv/bin/pytest tests/test_poker_api.py -v`
|
||||
Expected: PASS (all tests, including the new hand test).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add lyra/poker.py lyra/web/server.py tests/test_poker_api.py
|
||||
git commit -m "feat: hands API — log_hand endpoint, update_hand store fn, edit/delete routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Reads/players API + route conformance
|
||||
|
||||
**Files:**
|
||||
- Modify: `lyra/poker.py` (add `update_player` near `upsert_player`)
|
||||
- Modify: `lyra/web/server.py` (add routes)
|
||||
- Modify: `tests/test_poker_api.py` (add tests)
|
||||
- Modify: `tests/test_poker_contract.py` (add route-coverage test)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `poker.add_read(note=, name=, ...)`, `poker.upsert_player(name, **fields)`.
|
||||
- Produces: `poker.update_player(player_id, **fields) -> dict | None`; `POST /session/read` → `{ok, id}`; `PATCH /player/{player_id}` → `{ok, player}`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/test_poker_api.py`:
|
||||
```python
|
||||
def test_post_read(client):
|
||||
c, poker = client
|
||||
poker.start_session(buy_in=400)
|
||||
r = c.post("/session/read", json={"note": "3-bets light", "name": "James K"})
|
||||
assert r.json()["ok"] is True
|
||||
assert isinstance(r.json()["id"], int)
|
||||
|
||||
|
||||
def test_rename_player_fixes_mislabel(client):
|
||||
c, poker = client
|
||||
pid = poker.upsert_player("Dave the rock", category="reg")
|
||||
r = c.patch(f"/player/{pid}", json={"name": "Dave the mechanic"})
|
||||
assert r.json()["ok"] is True
|
||||
assert r.json()["player"]["name"] == "Dave the mechanic"
|
||||
```
|
||||
|
||||
Append to `tests/test_poker_contract.py`:
|
||||
```python
|
||||
def test_rest_routes_registered():
|
||||
import lyra.web.server as server
|
||||
registered = set()
|
||||
for route in server.app.routes:
|
||||
methods = getattr(route, "methods", None)
|
||||
path = getattr(route, "path", None)
|
||||
if not methods or not path:
|
||||
continue
|
||||
for m in methods:
|
||||
registered.add((m, path))
|
||||
for op, decl in OPERATIONS.items():
|
||||
if not decl["rest"]:
|
||||
continue
|
||||
method, path = decl["rest"]
|
||||
assert (method, path) in registered, f"{op}: {method} {path} not registered"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `.venv/bin/pytest tests/test_poker_api.py::test_rename_player_fixes_mislabel tests/test_poker_contract.py::test_rest_routes_registered -v`
|
||||
Expected: FAIL (404 on `/player/...`; route-coverage missing several POST/PATCH paths).
|
||||
|
||||
- [ ] **Step 3: Add `update_player` to the store**
|
||||
|
||||
In `lyra/poker.py`, immediately after `upsert_player` (find it near poker.py:1010), add:
|
||||
```python
|
||||
_PLAYER_FIELDS = ("name", "venue", "description", "tendencies", "adjustment", "category")
|
||||
|
||||
|
||||
def update_player(player_id: int, **fields) -> dict | None:
|
||||
"""Edit a player's dossier (rename, fix tendencies/category). Returns the row or None."""
|
||||
sets, vals = [], []
|
||||
for k, v in fields.items():
|
||||
if k in _PLAYER_FIELDS and v is not None:
|
||||
sets.append(f"{k} = ?")
|
||||
vals.append(v)
|
||||
if sets:
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(f"UPDATE poker_players SET {', '.join(sets)} WHERE id = ?",
|
||||
(*vals, player_id))
|
||||
row = _c().execute("SELECT * FROM poker_players WHERE id = ?", (player_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the two routes**
|
||||
|
||||
In `lyra/web/server.py`, after the Task 3 routes, add:
|
||||
```python
|
||||
@app.post("/session/read")
|
||||
async def session_add_read(request: Request) -> dict:
|
||||
"""Log a read directly (no LLM); upserts the villain file when name is given."""
|
||||
body = await request.json()
|
||||
rid = await asyncio.to_thread(lambda: poker.add_read(
|
||||
note=body.get("note") or "", seat=body.get("seat"), name=body.get("name"),
|
||||
tendencies=body.get("tendencies"), adjustment=body.get("adjustment"),
|
||||
description=body.get("description"), category=body.get("category"),
|
||||
venue=body.get("venue"),
|
||||
))
|
||||
return {"ok": True, "id": rid}
|
||||
|
||||
@app.patch("/player/{player_id}")
|
||||
async def player_update(player_id: int, request: Request) -> dict:
|
||||
"""Edit a player's dossier (rename, fix tendencies)."""
|
||||
body = await request.json()
|
||||
p = await asyncio.to_thread(lambda: poker.update_player(player_id, **body))
|
||||
logbus.log("info", "player edited", id=player_id, fields=list(body))
|
||||
return {"ok": p is not None, "player": p}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify it passes**
|
||||
|
||||
Run: `.venv/bin/pytest tests/test_poker_api.py tests/test_poker_contract.py -v`
|
||||
Expected: PASS (all API tests + both conformance tests).
|
||||
|
||||
- [ ] **Step 6: Run the full suite (no regressions)**
|
||||
|
||||
Run: `.venv/bin/pytest -q`
|
||||
Expected: PASS (existing poker/tools/chat tests still green).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add lyra/poker.py lyra/web/server.py tests/test_poker_api.py tests/test_poker_contract.py
|
||||
git commit -m "feat: reads/players API + REST route conformance test"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Chat-page stack quick-capture (2nd input box)
|
||||
|
||||
**Files:**
|
||||
- Modify: `lyra/web/static/index.html` (**CRLF + tabs** — add markup + JS)
|
||||
- Modify: `lyra/web/static/style.css` (LF + spaces — add styling)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `POST /session/stack` (Task 2). Reads `currentSession` and the Live Log DOM (`#thinkingContent`, `#thinkingEmpty`) already present in index.html.
|
||||
- Produces: a stack-only input that logs without any chat/LLM call.
|
||||
|
||||
- [ ] **Step 1: Add the input row markup**
|
||||
|
||||
In `lyra/web/static/index.html`, insert **between** the `<div id="input">…</div>` block (ends ~index.html:125) and `<nav id="tabbar">` (index.html:128). **Use CRLF + tab indentation to match the file.**
|
||||
```html
|
||||
<!-- Stack quick-capture (no LLM): type a number -> logs current stack -->
|
||||
<div id="stackQuick">
|
||||
<input id="stackQuickInput" type="number" inputmode="decimal" placeholder="Stack $" aria-label="Log current stack">
|
||||
<button id="stackQuickBtn" type="button" title="Log stack (no chat)">Log</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the JS**
|
||||
|
||||
In the `<script>` of `index.html`, near `sendMessage` (index.html:299), add (CRLF + tabs):
|
||||
```javascript
|
||||
function liveLogLine(text) {
|
||||
const content = document.getElementById("thinkingContent");
|
||||
const empty = document.getElementById("thinkingEmpty");
|
||||
if (empty) empty.style.display = "none";
|
||||
const div = document.createElement("div");
|
||||
div.className = "thinking-event";
|
||||
div.textContent = text;
|
||||
content.appendChild(div);
|
||||
content.scrollTop = content.scrollHeight;
|
||||
}
|
||||
|
||||
async function logStackQuick() {
|
||||
const el = document.getElementById("stackQuickInput");
|
||||
const raw = (el.value || "").replace(/[^0-9.]/g, "");
|
||||
if (!raw) return;
|
||||
const amount = Number(raw);
|
||||
try {
|
||||
const r = await fetch("/session/stack", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ amount })
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!data.ok) { liveLogLine("⚠ " + (data.error || "stack not logged")); return; }
|
||||
const t = new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
const net = (data.stack && data.stack.net != null)
|
||||
? ` (net ${data.stack.net >= 0 ? "+" : ""}${data.stack.net})` : "";
|
||||
liveLogLine(`💰 $${amount} logged · ${t}${net}`);
|
||||
el.value = "";
|
||||
} catch (e) {
|
||||
liveLogLine("⚠ stack log failed: " + e.message);
|
||||
}
|
||||
}
|
||||
document.getElementById("stackQuickBtn").addEventListener("click", logStackQuick);
|
||||
document.getElementById("stackQuickInput").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); logStackQuick(); }
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add styling**
|
||||
|
||||
In `lyra/web/static/style.css` (LF + spaces), add:
|
||||
```css
|
||||
#stackQuick {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--border, #222);
|
||||
}
|
||||
#stackQuick input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
background: var(--panel, #111);
|
||||
color: inherit;
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 8px;
|
||||
}
|
||||
#stackQuick button {
|
||||
padding: 8px 14px;
|
||||
background: var(--accent, #ff7a18);
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify manually**
|
||||
|
||||
Start the app: `.venv/bin/python -m lyra.web.server` (serves on :7078). With a live session (start one via the HUD or `curl -XPOST localhost:7078/session -d '{"buy_in":400}' -H 'Content-Type: application/json'`):
|
||||
- The stack box appears below the message input, above the nav icons.
|
||||
- Type `350`, press Enter → a `💰 $350 logged · …` line appears in the Live Log, the box clears, and **no chat bubble is added**.
|
||||
- Confirm persisted: `curl -s localhost:7078/session/data | python -m json.tool` shows `stack.current == 350`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add lyra/web/static/index.html lyra/web/static/style.css
|
||||
git commit -m "feat: stack quick-capture box on chat page (no LLM)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: HUD quick-capture + correction controls
|
||||
|
||||
**Files:**
|
||||
- Modify: `lyra/web/static/session.html` (LF + spaces — Stack card markup, villain rename control, JS functions)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `POST /session/stack`, `POST /session/buyin` (Task 2), `PATCH /session/{id}` (existing), `PATCH /player/{id}` (Task 4). Reads existing globals `curSession`, `refresh()`, and the villain render block.
|
||||
|
||||
- [ ] **Step 1: Add quick inputs to the Stack card**
|
||||
|
||||
In `lyra/web/static/session.html`, replace the Stack card block (session.html:280-289) with the same block plus a `quick` row before its closing `</div>`:
|
||||
```javascript
|
||||
<div class="card">
|
||||
<p class="label">Stack</p>
|
||||
<div class="stack-row">
|
||||
<span class="stack-now">${stack.current == null ? '—' : money(stack.current)}</span>
|
||||
<span class="net ${netClass(stack.net)}">${stack.net == null ? '' : signed(stack.net)}</span>
|
||||
<span class="stack-meta">bought in ${money(stack.buy_in)}<br>${(stack.log||[]).length} update(s)</span>
|
||||
</div>
|
||||
${sparkline(stack.log || [])}
|
||||
<div class="quick">
|
||||
<input id="qStack" type="number" inputmode="decimal" placeholder="Stack $" onkeydown="if(event.key==='Enter')postStack()">
|
||||
<button onclick="postStack()">Log stack</button>
|
||||
<input id="qBuyin" type="number" inputmode="decimal" placeholder="Buy-in $" onkeydown="if(event.key==='Enter')postBuyin()">
|
||||
<button onclick="postBuyin()">Add buy-in</button>
|
||||
<input id="qCashout" type="number" inputmode="decimal" placeholder="Cash out $" onkeydown="if(event.key==='Enter')postCashout()">
|
||||
<button onclick="postCashout()">Cash out</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the quick-capture + rename JS functions**
|
||||
|
||||
In the `<script>` of `session.html`, near `saveEdit()` (session.html:192), add:
|
||||
```javascript
|
||||
async function postQuick(url, amount, body){
|
||||
const r = await fetch(url, { method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(body || { amount }) });
|
||||
const d = await r.json();
|
||||
if(!d.ok){ alert(d.error || 'failed'); return false; }
|
||||
refresh(); return true;
|
||||
}
|
||||
function numVal(id){ const el = document.getElementById(id); return Number((el.value||'').replace(/[^0-9.]/g,'')); }
|
||||
async function postStack(){ const v = numVal('qStack'); if(v) { if(await postQuick('/session/stack', v)) document.getElementById('qStack').value=''; } }
|
||||
async function postBuyin(){ const v = numVal('qBuyin'); if(v) { if(await postQuick('/session/buyin', v)) document.getElementById('qBuyin').value=''; } }
|
||||
async function postCashout(){
|
||||
if(!curSession) return;
|
||||
const v = numVal('qCashout'); if(!v) return;
|
||||
const r = await fetch('/session/'+curSession.id, { method:'PATCH', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ cash_out: v }) });
|
||||
if(!(await r.json()).ok){ alert('failed'); return; }
|
||||
document.getElementById('qCashout').value=''; refresh();
|
||||
}
|
||||
async function renamePlayer(id, current){
|
||||
const name = prompt('Rename player', current || ''); if(!name) return;
|
||||
const r = await fetch('/player/'+id, { method:'PATCH', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ name }) });
|
||||
if(!(await r.json()).ok){ alert('failed'); return; }
|
||||
refresh();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the rename control to the villains list**
|
||||
|
||||
In `session.html`, find the villains render block in `render(data)` (it maps over `data.villains` / the `villains` array). For each villain item, add a rename affordance next to the name, using the player id field present on the villain row (commonly `v.id` or `v.player_id` — use whichever the bundle provides):
|
||||
```javascript
|
||||
<button class="mini" title="Rename / fix" onclick="renamePlayer(${v.id}, '${esc(v.name||'')}')">✎</button>
|
||||
```
|
||||
Read the existing villain block first to splice this in cleanly and confirm the id field name.
|
||||
|
||||
- [ ] **Step 4: Add minimal styling**
|
||||
|
||||
In the inline `<style>` of `session.html`, add:
|
||||
```css
|
||||
.quick { display:flex; flex-wrap:wrap; gap:6px; margin-top:12px; }
|
||||
.quick input { width:96px; padding:7px 9px; background:#111; color:inherit; border:1px solid #333; border-radius:8px; }
|
||||
.quick button { padding:7px 11px; background:var(--accent,#ff7a18); color:#000; border:none; border-radius:8px; font-weight:600; }
|
||||
button.mini { background:transparent; border:none; color:#888; cursor:pointer; padding:0 4px; }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify manually**
|
||||
|
||||
With the app running and a live session, open `/session`:
|
||||
- Log a stack via `qStack` → sparkline + net update without a chat call.
|
||||
- Add a buy-in via `qBuyin` → "bought in" total rises.
|
||||
- Enter a cash-out via `qCashout` → session net updates.
|
||||
- Click ✎ on a villain, rename it → name changes after refresh. Confirm via `curl -s localhost:7078/session/data`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add lyra/web/static/session.html
|
||||
git commit -m "feat: HUD quick-capture (stack/buyin/cashout) + villain rename"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: iOS-PWA bottom safe-area gap fix
|
||||
|
||||
**Files:**
|
||||
- Modify: `lyra/web/static/style.css` (bottom nav / container safe-area)
|
||||
|
||||
**Interfaces:** none (visual fix). The empty band below the nav icons is the home-indicator inset not being consumed by `#tabbar`.
|
||||
|
||||
- [ ] **Step 1: Load the iOS-PWA skill**
|
||||
|
||||
Invoke the `building-ios-pwas` skill and follow its guidance for safe-area / `100dvh` handling before editing. The current `#tabbar` (style.css:921-952) applies `env(safe-area-inset-left/right)` and `padding-bottom: 6px`, but does **not** add `env(safe-area-inset-bottom)` — the likely cause.
|
||||
|
||||
- [ ] **Step 2: Apply the safe-area fix**
|
||||
|
||||
In `lyra/web/static/style.css`, in the mobile `#tabbar` rule (style.css:921-929), change the bottom padding to consume the inset, and ensure the bar is pinned:
|
||||
```css
|
||||
#tabbar {
|
||||
/* …existing flex/border rules… */
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding-bottom: calc(6px + env(safe-area-inset-bottom));
|
||||
}
|
||||
```
|
||||
And ensure the chat scroll container reserves space for the bar so content isn't hidden behind it (match the container selector used at style.css:836-852):
|
||||
```css
|
||||
@media (max-width: 768px) {
|
||||
#messages {
|
||||
padding-bottom: calc(64px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify on device**
|
||||
|
||||
Open the PWA (Add to Home Screen) on iPhone:
|
||||
- The empty band below the icons is gone; the nav sits flush above the home indicator.
|
||||
- The stack quick-capture box (Task 5) sits directly above the nav.
|
||||
- Open the keyboard: `body.kb` still hides the tabbar (style.css:952) and the input pins to the keyboard — confirm no regression.
|
||||
- If the gap persists or content clips, follow the `building-ios-pwas` skill's `100dvh`/`visualViewport` guidance and iterate.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add lyra/web/static/style.css
|
||||
git commit -m "fix: consume iOS home-indicator safe-area inset under bottom nav"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- Complete API surface (create/update/delete per entity) → Tasks 2 (stack/buyin/start), 3 (hands), 4 (reads/players); existing PATCH/DELETE session + entry routes retained.
|
||||
- Single documented/versioned tool-API contract → Task 1 (`poker_contract.py`, `CONTRACT_VERSION`) + conformance tests (Tasks 1, 4).
|
||||
- Human UI to log + edit/correct → Tasks 5 (chat 2nd box), 6 (HUD quick inputs + villain rename + existing edit form/delete).
|
||||
- Pure capture never touches LLM → all capture goes through REST endpoints (Tasks 2–6); verified in manual steps (no chat bubble).
|
||||
- 2nd input box + PWA fix → Tasks 5, 7.
|
||||
- Non-goals respected: no classifier/prompts, no MI50 tool enablement, no MCP, buy-in stays scalar (`add_buyin` increments `buy_in_total`).
|
||||
|
||||
**Placeholder scan:** All code steps contain complete code. The one "locate the block" instruction (Task 6 Step 3, villain rename) provides the exact button snippet and names the id-field ambiguity to resolve by reading the file — not a placeholder, a grounded splice.
|
||||
|
||||
**Type consistency:** `poker_contract.OPERATIONS` shape is consistent across Tasks 1 and 4; REST paths in the contract (`/session/stack`, `/session/buyin`, `/session`, `/session/hand`, `/hand/{hand_id}`, `/session/read`, `/player/{player_id}`, `/session/{session_id}`) match the routes added in Tasks 2–4 exactly; `update_hand`/`update_player` signatures match their callers; response shapes (`{ok, stack}`, `{ok, buy_in_total}`, `{ok, id}`, `{ok, hand}`, `{ok, player}`) are used consistently in tests and routes.
|
||||
|
||||
**Open implementation note:** Task 6 Step 3 requires reading `session.html`'s villain render to confirm the player id field name (`v.id` vs `v.player_id`) before splicing the rename button.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Poker logging service + message-type prompts
|
||||
|
||||
- **Date:** 2026-06-28
|
||||
- **Status:** Sub-project 1 spec ready for review; sub-project 2 parked.
|
||||
- **Branch:** `feat/poker-mode-prompts`
|
||||
|
||||
## Origin
|
||||
|
||||
This started as "make Lyra's poker replies less generic" (message-type-specific prompts). During design we decided to **build the logging tool first** as a standalone system of record with a clean API and a human-usable UI, then wire Lyra in as a *client* of it. Rationale:
|
||||
|
||||
- Brian can log and **correct** data himself, independent of whether Lyra parsed it right (she mislabeled "Dave the rock" vs "Dave the mechanic" mid-session).
|
||||
- The data stops being hostage to the agent. Lyra becomes one client among potentially several.
|
||||
- It's reusable: RTO (the solver) and a **fine-tuned poker model on the MI50** could consume the same hand/session data through the same contract.
|
||||
|
||||
## Decomposition
|
||||
|
||||
Two sub-projects, built and shipped in order.
|
||||
|
||||
### Sub-project 1 — Poker logging service *(this spec)*
|
||||
Harden `lyra/poker.py` into a well-bounded store, expose a **complete REST API** over it, define a **stable, documented tool/API contract**, and build the human UI to log/edit/correct everything. Fully usable by Brian alone, zero LLM dependency.
|
||||
|
||||
### Sub-project 2 — Lyra wiring *(parked; summarized at the end)*
|
||||
Message-type classifier + type-specific prompt fragments; Lyra's tools call the sub-project 1 service. Separately, enabling tool-calling on the MI50 backend so a fine-tuned poker model can drive the same contract.
|
||||
|
||||
**Why the contract is first-class:** in every design (in-process, REST, MCP) the *model* never calls the API directly — it emits a tool-call and the host app executes it. So what lets the cloud model, the MI50 fine-tune, RTO, and a human UI all interoperate is a single **stable tool/API schema** (operation names + JSON arg schemas). That contract is the training target for the fine-tune and the seam for every backend. MCP is deferred: it's a thin wrap over the same service, worth adding only when a *second host application* appears.
|
||||
|
||||
---
|
||||
|
||||
# Sub-project 1 — Poker logging service
|
||||
|
||||
## Goals
|
||||
|
||||
1. A complete API surface over the poker data model — create/read/update/delete for every entity, not just the few edit/delete endpoints exposed today.
|
||||
2. A single **documented, versioned tool/API contract** that the REST API, Lyra's LLM tools, the human UI, a future MCP wrapper, and the MI50 fine-tune all share.
|
||||
3. A human UI to **log** (fast capture) and **edit/correct** (fix Lyra's mistakes) every entity.
|
||||
4. The 2nd input box (stack quick-capture) and the iOS-PWA bottom safe-area fix.
|
||||
5. Pure data capture never touches the LLM.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Lyra's classifier / prompt fragments (sub-project 2).
|
||||
- Enabling tools on the MI50 backend (sub-project 2).
|
||||
- MCP wrapper (deferred until a second host app exists).
|
||||
- Itemized buy-in history — buy-ins stay a single `buy_in_total` scalar.
|
||||
- Rewriting the SQLite schema; we build on the existing tables.
|
||||
|
||||
## Current state (what exists)
|
||||
|
||||
- **Store & logic:** `lyra/poker.py` — schema at `poker.py:21` (tables `poker_sessions`, `poker_hands`, `poker_stack_log`, `poker_rituals`, `poker_players`, `player_reads`, `player_observations`). Functions: `start_session` (157), `add_buyin` (389), `log_stack` (407), `stack_state` (446), `update_session` (362), `end_session` (515), `log_hand` (541, flat/no-LLM), `record_hand` (770, LLM-parses shorthand), `add_read` (1010), `hud` (1245).
|
||||
- **Exposed endpoints (`lyra/web/server.py`):** `GET /session/data` (hud), `PATCH /session/{id}`, `DELETE /session/entry/{kind}/{id}`, `GET/DELETE /history`, `GET /hand/{id}/data`, `POST /hand/{id}/reconstruct`, `GET /hands/data`, `GET /recap/...`. **No** direct create endpoint for stack/buyin/hand/read/session — those are reachable only through chat → tool-calling.
|
||||
- **UI:** `index.html` (chat), `session.html` (live HUD: stack card + sparkline + a PATCH-based edit form via `saveEdit()` at `session.html:192`, and `del(kind,id)` at `211`), `history.html`, `hand.html`.
|
||||
- **Tool specs:** `lyra/tools.py` already defines arg schemas for each operation (`_f(...)` specs, `tools.py:469-658`) — the embryo of the contract.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Store layer — harden `poker.py`
|
||||
|
||||
Keep the existing functions and tables; tighten the module into a clean service boundary so both the REST layer and Lyra's tools call the *same* functions. Each operation: validates input, resolves the target session (`_resolve`), writes, returns a consistent dict. No behavior change to existing callers; this is consolidation, not a rewrite.
|
||||
|
||||
### 2. The tool/API contract *(first-class deliverable)*
|
||||
|
||||
A single source-of-truth document + schema defining every operation: name, purpose, JSON arg schema, return shape, and which REST route + which LLM tool map to it. Versioned (e.g. `contract_version: 1`). Lives at `docs/POKER_API.md` (or a machine-readable `poker_contract.py` that both the REST routes and `tools.py` specs derive from — preferred, so they can't drift).
|
||||
|
||||
Operations (the canonical set):
|
||||
|
||||
| Operation | Args | Entity |
|
||||
|---|---|---|
|
||||
| `start_session` | venue, stakes, game, format, buy_in, mantra | session |
|
||||
| `update_session` | venue, stakes, game, format, buy_in_total, cash_out, mantra, mood | session |
|
||||
| `end_session` | cash_out, mood | session |
|
||||
| `delete_session` | id | session |
|
||||
| `log_stack` | amount, note | stack entry |
|
||||
| `delete_stack` | id | stack entry |
|
||||
| `add_buyin` | amount | session (increments buy_in_total) |
|
||||
| `log_hand` | position, hole_cards, board, streets…, pot, result, tag, lesson | hand |
|
||||
| `record_hand` | shorthand (LLM-parsed) | hand |
|
||||
| `update_hand` | id, any hand field | hand |
|
||||
| `delete_hand` | id | hand |
|
||||
| `add_read` | note, name, seat, tendencies, adjustment, category, venue | player/read |
|
||||
| `update_read` / `update_player` | id, fields | player/read |
|
||||
| `delete_read` | id | player/read |
|
||||
| rituals: `scar_note`, `confidence_bank`, `alligator_blood`, `reset_ritual` | … | ritual |
|
||||
|
||||
### 3. REST API — complete the surface (`lyra/web/server.py`)
|
||||
|
||||
Add the missing **create/update** routes so the human UI (and any non-LLM client) can do everything:
|
||||
|
||||
- `POST /session/stack` → `log_stack(amount, note?)`; server-stamped time; returns `stack_state()`.
|
||||
- `POST /session/buyin` → `add_buyin(amount)`; returns `buy_in_total`.
|
||||
- `POST /session` → `start_session(...)`.
|
||||
- `POST /session/hand` → `log_hand(...)` (flat) and/or `record_hand(shorthand)`.
|
||||
- `PATCH /hand/{id}` → `update_hand(...)`; `DELETE /hand/{id}`.
|
||||
- `POST /session/read` → `add_read(...)`; `PATCH /read/{id}`; `DELETE` via existing entry-delete.
|
||||
- Keep existing `PATCH /session/{id}`, `DELETE /session/entry/{kind}/{id}`, `GET /session/data`.
|
||||
|
||||
All return `{ok, ...}` and a clear error on "no live session." Routes are thin wrappers over the store, mirroring the contract one-to-one.
|
||||
|
||||
### 4. Human UI — log + edit/correct
|
||||
|
||||
**Fast capture:**
|
||||
- **2nd input box** (`index.html`): slim row **below the message input, above the bottom nav icons**. Type a number → `POST /session/stack` → time-stamped, sparkline updates, a one-line confirmation drops into the **Live Log**. **No chat message, no LLM call.** Stack-only in v1. Tolerates `$685`/`685`.
|
||||
- **HUD widget** (`session.html`, in the Stack card at `:280`, mirroring `saveEdit()` at `:192`): stack field (`POST /session/stack`), buy-in field (`POST /session/buyin`), cash-out field (existing PATCH).
|
||||
|
||||
**Edit / correct (fix Lyra's mistakes):**
|
||||
- Edit any session field (exists via the PATCH edit form — verify coverage).
|
||||
- Hands list with edit + delete (`hand.html` + new PATCH/DELETE) — fix mislabeled villains, wrong board, wrong result.
|
||||
- Reads/players list with edit + delete — rename "Dave the rock" ≠ "Dave the mechanic", fix tendencies.
|
||||
- Stack entries deletable (exists via `del('stack', id)`) — verify.
|
||||
|
||||
### 5. iOS-PWA bottom safe-area fix
|
||||
|
||||
The empty band below the nav icons is a safe-area issue (likely `100vh` not accounting for `env(safe-area-inset-bottom)` / the home indicator). Fix the layout container + bottom nav CSS so the app fills the viewport with the icons seated above the home indicator. Use the `building-ios-pwas` skill at implementation time.
|
||||
|
||||
## Testing / verification
|
||||
|
||||
- **Contract conformance:** a test asserting each REST route and each `tools.py` spec matches the canonical contract (names, required args) — catches drift between the human API and the LLM API.
|
||||
- **Endpoint round-trips:** create → read → update → delete for stack, buyin, hand, read against a test session; assert rows written, time stamped, `stack_state()`/`hud()` reflect changes; assert clean error with no live session.
|
||||
- **UI manual pass:** log a stack via the 2nd box and confirm it lands in Live Log + sparkline without a chat reply; edit a hand's villain and confirm persistence; delete a bad read.
|
||||
- **PWA:** on the iOS PWA, confirm the bottom gap is gone and the 2nd input box sits above the nav with the keyboard open.
|
||||
|
||||
---
|
||||
|
||||
# Sub-project 2 — Lyra wiring *(parked)*
|
||||
|
||||
Detail preserved here; gets its own spec → plan after sub-project 1 is MVP'd.
|
||||
|
||||
## Why it exists (diagnosis from real sessions)
|
||||
|
||||
Evidence from `sess-dff2s91c` (2026-06-27 Meadows, 2026-06-28 Wheeling):
|
||||
|
||||
- **Coaching essay on every turn, including pure data** — `Stack=$685` drew 4–6 sentences of "keep that momentum rolling." (Sub-project 1's dumb capture removes these from the LLM entirely.)
|
||||
- **False tilt/fatigue reads** — "table broke, it's 11:50pm" → repeated "late-night fatigue… mental reset"; Brian: *"you seem to be reading me as tilted."* Cause: the `_route` mood nudge (`mind.py:328`) firing on non-mood messages.
|
||||
- **No bet-intent reasoning** — a value bet ($40, full house) that folded out 88 was praised as "the power of representing something stronger." It was value *lost*, not a successful rep.
|
||||
- **Eyeballs instead of `analyze_spot`** — 77 multiway got "a disciplined fold might have been better," no math, violating the persona's "never eyeball poker math" rule.
|
||||
- **Even her sharp reads leak bad logic** — the Connie read included "limp-checking in position" (contradictory).
|
||||
|
||||
Root cause: one broad per-turn card (`_CASH_CARD`, `modes.py:66`) describes traits; the model satisfies trait language with safe abstraction.
|
||||
|
||||
## Planned approach
|
||||
|
||||
- **Classifier** (`lyra/poker_classify.py`): `classify(message) -> HAND | STATUS | MENTAL | LOG | CHAT`. Heuristic v1 (card-token regex, position/street keywords, feeling phrases, time/venue), swappable for an LLM/MI50 classifier behind the same signature. Ambiguous → CHAT.
|
||||
- **Pipeline:** a `_classify` step in `mind.PIPELINE` sets `ctx.msg_type` (poker mode only); `build_messages` injects the **type fragment** in the seat now held by `mode.card` (`mind.py:152`) instead of the whole `_CASH_CARD`.
|
||||
- **Fragments** (`lyra/poker_prompts.py`, `POKER_FRAGMENTS`): concrete rules + response shape per type.
|
||||
- **HAND:** log it → `analyze_spot` if close → reason about **bet intent** (value/bluff/protection — did it work? a fold to a value bet = value lost, flag it) → name leaks (value-owning, missed value, sizing) → one opinion. No reflexive praise. Pull `player_profile` before referencing a villain. Seeded with 2 real-hand exemplars (the $40 value bet; the 77 spot).
|
||||
- **STATUS:** 1–2 sentences, no coaching, no tilt-reading.
|
||||
- **MENTAL:** full presence (already works — preserve).
|
||||
- **LOG:** one line.
|
||||
- **CHAT:** full voice, real opinion.
|
||||
- **Pipeline fixes:** kill the misfiring `_route` mood nudge (`mind.py:328`); trim the always-on mode-menu note (`mind.py:77`) in poker mode.
|
||||
- **MI50 tool-calling:** enable tools on the MI50 backend (`chat.py` `TOOL_BACKENDS = {"cloud"}` at `:21`) so a fine-tuned, tool-calling poker model can drive the contract. Requires the fine-tune to emit the contract's tool-call format.
|
||||
|
||||
## HAND exemplars (preserved for sub-project 2)
|
||||
|
||||
> *Brian:* "Flopped bottom set with 22, turned a full house, bet $40 on the river, he folded 88."
|
||||
> *Lyra:* "That's a value bet that got no value — 88 was never folding to a smaller stab. $40 into that pot was too much; ~$20–25 gets called by the over-pair and worse two-pairs. The hand's a monster, the leak is the sizing."
|
||||
|
||||
> *Brian:* "77 multiway, flop 4h5c3c, HJ bets 75, BTN shoves 74, I call. Turn/river check through, HJ wins with 64."
|
||||
> *Lyra:* "Against a bet and a shove on 4-5-3 you're drawing thin — sets, two pair, and the made wheel are all ahead, and you block almost none of it. The stack-depth read (he only had ~150 behind) is real, but that's a reason to fold and wait, not to call off light. This is the value-owning spot you flagged yourself."
|
||||
+21
-2
@@ -9,20 +9,39 @@ a long silence *means* to her is left to her own reflection, not prescribed here
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lyra import config
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _local_tz() -> ZoneInfo | timezone:
|
||||
"""Brian's configured local zone (falls back to UTC if it can't be loaded)."""
|
||||
try:
|
||||
return ZoneInfo(config.load().timezone)
|
||||
except Exception:
|
||||
return timezone.utc
|
||||
|
||||
|
||||
def _parse(iso: str) -> datetime:
|
||||
dt = datetime.fromisoformat(iso)
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def short(iso_or_dt: str | datetime | None = None) -> str:
|
||||
"""Local time-of-day like '10:45pm', for timeline rows."""
|
||||
dt = _parse(iso_or_dt) if isinstance(iso_or_dt, str) else (iso_or_dt or now())
|
||||
return dt.astimezone(_local_tz()).strftime("%-I:%M%p").lower()
|
||||
|
||||
|
||||
def stamp(dt: datetime | None = None) -> str:
|
||||
"""Wall-clock stamp, e.g. 'Wednesday, 17 Jun 2026, 01:50 UTC'."""
|
||||
return (dt or now()).strftime("%A, %d %b %Y, %H:%M UTC")
|
||||
"""Wall-clock stamp in Brian's local timezone, e.g.
|
||||
'Friday, 27 Jun 2026, 01:50 EDT'. Times are stored UTC; this is what she *reads*,
|
||||
so 'what time is it' answers in his time, not UTC."""
|
||||
return (dt or now()).astimezone(_local_tz()).strftime("%A, %d %b %Y, %H:%M %Z")
|
||||
|
||||
|
||||
def gap_seconds(since_iso: str | None, ref: datetime | None = None) -> float | None:
|
||||
|
||||
+49
-13
@@ -2,11 +2,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Iterator, Literal, TypedDict
|
||||
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
from lyra import logbus
|
||||
from lyra.config import load
|
||||
|
||||
|
||||
@@ -18,30 +20,54 @@ class Message(TypedDict):
|
||||
Backend = Literal["local", "cloud", "mi50"]
|
||||
|
||||
|
||||
def _approx_tok(messages: list) -> int:
|
||||
"""Rough prompt size (chars/4) — enough to see what's loading a backend."""
|
||||
total = 0
|
||||
for m in messages or []:
|
||||
if isinstance(m, dict) and isinstance(m.get("content"), str):
|
||||
total += len(m["content"])
|
||||
return total // 4
|
||||
|
||||
|
||||
def _resolved_model(cfg, backend: Backend, model: str | None) -> str:
|
||||
if backend == "cloud":
|
||||
return model or cfg.cloud_model
|
||||
if backend == "mi50":
|
||||
return model or cfg.mi50_model
|
||||
return model or cfg.local_model
|
||||
|
||||
|
||||
def complete(messages: list[Message], backend: Backend = "local", model: str | None = None) -> str:
|
||||
"""Generate a completion. `model` overrides the backend's default model
|
||||
(used so live chat can run a stronger cloud model than bulk consolidation)."""
|
||||
cfg = load()
|
||||
mdl = _resolved_model(cfg, backend, model)
|
||||
logbus.log("info", "llm call", kind="complete", backend=backend, model=mdl, tok=_approx_tok(messages))
|
||||
t0 = time.monotonic()
|
||||
|
||||
if backend == "cloud":
|
||||
if not cfg.openai_api_key:
|
||||
raise RuntimeError("OPENAI_API_KEY is not set")
|
||||
client = OpenAI(api_key=cfg.openai_api_key)
|
||||
resp = client.chat.completions.create(model=model or cfg.cloud_model, messages=messages)
|
||||
return resp.choices[0].message.content or ""
|
||||
|
||||
if backend == "mi50":
|
||||
resp = client.chat.completions.create(model=mdl, messages=messages)
|
||||
out = resp.choices[0].message.content or ""
|
||||
elif backend == "mi50":
|
||||
# MI50 box runs an OpenAI-compatible llama.cpp server; key is unused.
|
||||
client = OpenAI(api_key="not-needed", base_url=cfg.mi50_base_url)
|
||||
resp = client.chat.completions.create(model=model or cfg.mi50_model, messages=messages)
|
||||
return resp.choices[0].message.content or ""
|
||||
resp = client.chat.completions.create(model=mdl, messages=messages)
|
||||
out = resp.choices[0].message.content or ""
|
||||
else:
|
||||
resp = httpx.post(
|
||||
f"{cfg.local_base_url}/api/chat",
|
||||
json={"model": mdl, "messages": messages, "stream": False},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
out = resp.json()["message"]["content"]
|
||||
|
||||
resp = httpx.post(
|
||||
f"{cfg.local_base_url}/api/chat",
|
||||
json={"model": model or cfg.local_model, "messages": messages, "stream": False},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["message"]["content"]
|
||||
logbus.log("info", "llm done", kind="complete", backend=backend,
|
||||
ms=int((time.monotonic() - t0) * 1000), out=len(out))
|
||||
return out
|
||||
|
||||
|
||||
def chat_call(
|
||||
@@ -68,6 +94,8 @@ def chat_call(
|
||||
kwargs: dict = {"model": mdl, "messages": messages}
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
logbus.log("info", "llm call", kind="chat", backend=backend, model=mdl, tok=_approx_tok(messages))
|
||||
t0 = time.monotonic()
|
||||
msg = client.chat.completions.create(**kwargs).choices[0].message
|
||||
tcs = None
|
||||
if getattr(msg, "tool_calls", None):
|
||||
@@ -75,6 +103,9 @@ def chat_call(
|
||||
{"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
logbus.log("info", "llm done", kind="chat", backend=backend,
|
||||
ms=int((time.monotonic() - t0) * 1000), out=len(msg.content or ""),
|
||||
tools=[t["name"] for t in tcs] if tcs else None)
|
||||
return msg.model_dump(), tcs
|
||||
|
||||
# local (Ollama): no tool-calling here — return plain content.
|
||||
@@ -105,6 +136,8 @@ def chat_call_stream(
|
||||
kwargs: dict = {"model": mdl, "messages": messages, "stream": True}
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
logbus.log("info", "llm call", kind="chat-stream", backend=backend, model=mdl, tok=_approx_tok(messages))
|
||||
t0 = time.monotonic()
|
||||
parts: list[str] = []
|
||||
frags: dict[int, dict] = {} # tool-call fragments accumulated by index
|
||||
for chunk in client.chat.completions.create(**kwargs):
|
||||
@@ -123,6 +156,9 @@ def chat_call_stream(
|
||||
if tc.function and tc.function.arguments:
|
||||
slot["arguments"] += tc.function.arguments
|
||||
content = "".join(parts)
|
||||
logbus.log("info", "llm done", kind="chat-stream", backend=backend,
|
||||
ms=int((time.monotonic() - t0) * 1000), out=len(content),
|
||||
tools=[frags[i]["name"] for i in sorted(frags)] if frags else None)
|
||||
if frags:
|
||||
calls = [frags[i] for i in sorted(frags)]
|
||||
assistant = {
|
||||
|
||||
-121
@@ -115,26 +115,6 @@ CREATE TABLE IF NOT EXISTS ratings (
|
||||
note TEXT
|
||||
);
|
||||
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
|
||||
@@ -204,26 +184,6 @@ class Era:
|
||||
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:
|
||||
return np.asarray(vec, dtype=np.float32).tobytes()
|
||||
|
||||
@@ -685,87 +645,6 @@ def backfill_journal_embeddings(limit: int | None = None) -> int:
|
||||
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:
|
||||
"""A runtime setting value (UI-tunable), or `default` if unset."""
|
||||
r = _connection().execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||
|
||||
+11
-5
@@ -67,11 +67,17 @@ _CASH_CARD = """You are copiloting Brian's LIVE cash game right now — you're a
|
||||
a session is (or should be) open. You move between two registers depending on what he's doing:
|
||||
|
||||
• HE HANDS YOU FACTS TO TRACK — his stack, a hand, a read on someone, a rebuy, a result. \
|
||||
Log it with the right tool and confirm in ONE short line ("$350 stack logged."). Don't \
|
||||
narrate, don't explain what logging is, don't ask permission — just do it. He says his \
|
||||
current stack → log_stack. He describes a hand → log_hand (terse) or record_hand (a full \
|
||||
hand he wants saved/replayable). A read on a player → add_read. A rebuy → add_buyin. This is \
|
||||
the quiet, fast half of the job; he shouldn't feel you working.
|
||||
LOGGING IS THE JOB: if his message contains anything trackable, you MUST call the tool \
|
||||
FIRST, before you reply — every single time. Logging and talking are not either/or; do \
|
||||
BOTH. Never let a conversational reply take the place of the log. A described hand ALWAYS \
|
||||
gets logged, even mid-banter, even if he's just telling a story about it — don't skip the \
|
||||
hand because you're busy reacting to it. Then confirm in ONE short line ("$350 stack \
|
||||
logged."). Don't narrate, don't explain logging, don't ask permission — just do it. \
|
||||
Routing: current stack → log_stack (and pass `note` with the why if he gives one — "card \
|
||||
dead", "doubled up vs the LAG"). A hand he describes → record_hand (a real, replayable \
|
||||
hand) — prefer this over log_hand so it lands on his timeline with a link. A read on a \
|
||||
player → add_read. A rebuy → add_buyin. A result/pot → it rides with the hand. This is the \
|
||||
quiet, fast half of the job; he shouldn't feel you working, but it must always happen.
|
||||
|
||||
• HE ASKS FOR ADVICE, OR TELLS YOU HOW HE'S FEELING — tilted, steaming, card-dead, bored, \
|
||||
stuck, "should I have folded the river?" THIS is when he needs you most. Drop the shorthand \
|
||||
|
||||
+181
-24
@@ -16,7 +16,7 @@ import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from lyra import llm, memory
|
||||
from lyra import clock, llm, memory
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS poker_sessions (
|
||||
@@ -138,7 +138,8 @@ def _c():
|
||||
conn.executescript(_SCHEMA)
|
||||
# Add columns introduced after a DB already had the tables (no-op if present).
|
||||
for ddl in ("ALTER TABLE poker_hands ADD COLUMN structured TEXT",
|
||||
"ALTER TABLE poker_sessions ADD COLUMN chat_session_id TEXT"):
|
||||
"ALTER TABLE poker_sessions ADD COLUMN chat_session_id TEXT",
|
||||
"ALTER TABLE poker_stack_log ADD COLUMN note TEXT"):
|
||||
try:
|
||||
conn.execute(ddl)
|
||||
except Exception:
|
||||
@@ -403,17 +404,18 @@ def add_buyin(amount: float, session_id: int | None = None) -> float:
|
||||
|
||||
# --- stack tracking ---
|
||||
|
||||
def log_stack(amount: float, session_id: int | None = None) -> dict:
|
||||
"""Record Brian's current chip stack. Returns {current, buy_in, net} where net
|
||||
is his live net while sitting (current stack − total bought in)."""
|
||||
def log_stack(amount: float, note: str | None = None, session_id: int | None = None) -> dict:
|
||||
"""Record Brian's current chip stack, optionally with the why ("card dead",
|
||||
"doubled up vs Sal") — that context becomes the session-timeline line. Returns
|
||||
{current, buy_in, net} where net is his live net while sitting."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
raise ValueError("no live session")
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT INTO poker_stack_log (session_id, amount, created_at) VALUES (?, ?, ?)",
|
||||
(sid, float(amount), _now()),
|
||||
"INSERT INTO poker_stack_log (session_id, amount, note, created_at) VALUES (?, ?, ?, ?)",
|
||||
(sid, float(amount), (note or "").strip() or None, _now()),
|
||||
)
|
||||
return stack_state(sid)
|
||||
|
||||
@@ -436,7 +438,7 @@ def stack_log(session_id: int | None = None) -> list[dict]:
|
||||
if sid is None:
|
||||
return []
|
||||
return [dict(r) for r in _c().execute(
|
||||
"SELECT id, amount, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id",
|
||||
"SELECT id, amount, note, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id",
|
||||
(sid,),
|
||||
).fetchall()]
|
||||
|
||||
@@ -556,6 +558,22 @@ def log_hand(session_id: int | None = None, **fields) -> int:
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def update_hand(hand_id: int, **fields) -> dict | None:
|
||||
"""Edit a logged hand's flat fields (fix a mislabeled board, result, villain).
|
||||
Only known columns are touched. Returns the updated hand row or None."""
|
||||
sets, vals = [], []
|
||||
for k, v in fields.items():
|
||||
if k in _HAND_FIELDS and v is not None:
|
||||
sets.append(f"{k} = ?")
|
||||
vals.append(v)
|
||||
if sets:
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(f"UPDATE poker_hands SET {', '.join(sets)} WHERE id = ?",
|
||||
(*vals, hand_id))
|
||||
return get_hand(hand_id)
|
||||
|
||||
|
||||
def list_hands(session_id: int | None = None) -> list[dict]:
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
@@ -651,38 +669,102 @@ def _review_session_id() -> int:
|
||||
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"}
|
||||
|
||||
|
||||
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):
|
||||
return c
|
||||
s = c.strip()
|
||||
for sym, ltr in _SUIT_SYM.items():
|
||||
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
|
||||
|
||||
|
||||
def _normalize_parsed(p: dict) -> dict:
|
||||
"""Normalize card strings (unicode suits -> letters) across a parsed hand."""
|
||||
if not isinstance(p, dict):
|
||||
return p
|
||||
for key in ("hero_cards", "board"):
|
||||
if isinstance(p.get(key), list):
|
||||
p[key] = [_norm_card(c) for c in p[key]]
|
||||
def _card_known(c) -> bool:
|
||||
"""True only for a fully specified card (rank+suit, no 'x' placeholder)."""
|
||||
return isinstance(c, str) and len(c) == 2 and "x" not in c.lower()
|
||||
|
||||
|
||||
def _completeness(p: dict) -> dict:
|
||||
"""Which parts of the hand are fully specified — lets a consumer (RTO) use what it
|
||||
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 []:
|
||||
if isinstance(pl, dict) and isinstance(pl.get("cards"), list):
|
||||
if not isinstance(pl, dict):
|
||||
continue
|
||||
pl = dict(pl)
|
||||
if isinstance(pl.get("cards"), list):
|
||||
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 []:
|
||||
if isinstance(a, dict) and isinstance(a.get("board"), list):
|
||||
if not isinstance(a, dict):
|
||||
continue
|
||||
a = dict(a)
|
||||
if isinstance(a.get("board"), list):
|
||||
a["board"] = [_norm_card(c) for c in a["board"]]
|
||||
actions.append(a)
|
||||
p["actions"] = actions
|
||||
|
||||
p["completeness"] = _completeness(p)
|
||||
return p
|
||||
|
||||
|
||||
def store_hand_history(parsed: dict, session_id: int | None = None,
|
||||
tag: str | None = None, lesson: str | None = None) -> int:
|
||||
"""Store a parsed hand: full JSON + extracted flat fields for stats/listing."""
|
||||
parsed = _normalize_parsed(parsed)
|
||||
parsed = normalize_structured(parsed)
|
||||
sid = _resolve(session_id) or _review_session_id()
|
||||
hero_cards = parsed.get("hero_cards") or []
|
||||
board = parsed.get("board") or []
|
||||
@@ -736,7 +818,7 @@ def reconstruct_hand(hand_id: int, backend: str | None = None) -> dict | None:
|
||||
parsed = parse_hand(shorthand, backend=backend)
|
||||
if not parsed:
|
||||
return None
|
||||
parsed = _normalize_parsed(parsed)
|
||||
parsed = normalize_structured(parsed)
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute("UPDATE poker_hands SET structured = ? WHERE id = ?",
|
||||
@@ -751,19 +833,29 @@ def get_hand(hand_id: int) -> dict | None:
|
||||
if not r:
|
||||
return None
|
||||
d = dict(r)
|
||||
d["structured"] = json.loads(d["structured"]) if d.get("structured") else None
|
||||
# Normalize on read too: legacy rows predate the contract, and it's idempotent for
|
||||
# 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
|
||||
|
||||
|
||||
def list_recent_hands(limit: int = 60) -> list[dict]:
|
||||
"""Recent recorded hands with their session's venue/stakes, for browsing."""
|
||||
"""Recent recorded hands with their session's venue/stakes, for browsing. Each carries
|
||||
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(
|
||||
"SELECT h.id, h.position, h.hole_cards, h.board, h.result, h.tag, h.at, "
|
||||
"h.lesson, s.venue AS venue, s.stakes AS stakes "
|
||||
"h.lesson, (h.structured IS NOT NULL) AS has_structured, "
|
||||
"s.venue AS venue, s.stakes AS stakes "
|
||||
"FROM poker_hands h LEFT JOIN poker_sessions s ON s.id = h.session_id "
|
||||
"ORDER BY h.id DESC LIMIT ?", (limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
out = []
|
||||
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) ---
|
||||
@@ -931,6 +1023,25 @@ def upsert_player(name: str, venue: str | None = None, description: str | None =
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
_PLAYER_FIELDS = ("name", "venue", "description", "tendencies", "adjustment", "category")
|
||||
|
||||
|
||||
def update_player(player_id: int, **fields) -> dict | None:
|
||||
"""Edit a player's dossier (rename, fix tendencies/category). Returns the row or None."""
|
||||
sets, vals = [], []
|
||||
for k, v in fields.items():
|
||||
if k in _PLAYER_FIELDS and v is not None:
|
||||
sets.append(f"{k} = ?")
|
||||
vals.append(v)
|
||||
if sets:
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(f"UPDATE poker_players SET {', '.join(sets)} WHERE id = ?",
|
||||
(*vals, player_id))
|
||||
row = _c().execute("SELECT * FROM poker_players WHERE id = ?", (player_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def add_read(note: str, seat: str | None = None, name: str | None = None,
|
||||
session_id: int | None = None, **player_fields) -> int:
|
||||
"""Log a live read. If `name` is given, upsert the player and link the read."""
|
||||
@@ -1105,10 +1216,55 @@ def running_stats(stakes: str | None = None, venue: str | None = None,
|
||||
|
||||
# --- live session HUD (everything tracked in the current session, for the UI) ---
|
||||
|
||||
def timeline(session_id: int | None = None) -> list[dict]:
|
||||
"""The session's running log: start, stack updates (+context), hands (linkable),
|
||||
reads, and rituals, interleaved chronologically with local time-of-day stamps.
|
||||
This is what Brian sees as the night's story — '10:45 start … 12:00a doubled up,
|
||||
$750 (hand)'. Each entry: {time, at, kind, text, hand_id?, amount?, result?}."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
return []
|
||||
s = get_session(sid) or {}
|
||||
events: list[dict] = []
|
||||
|
||||
if s.get("started_at"):
|
||||
bits = [s.get("stakes"), s.get("game"), f"at {s['venue']}" if s.get("venue") else None]
|
||||
label = " ".join(b for b in bits if b)
|
||||
events.append({"at": s["started_at"], "kind": "start",
|
||||
"text": ("Session start — " + label) if label else "Session start"})
|
||||
|
||||
for r in stack_log(sid):
|
||||
events.append({"at": r["created_at"], "kind": "stack",
|
||||
"amount": r.get("amount"), "text": r.get("note") or "stack update"})
|
||||
|
||||
for h in list_hands(sid):
|
||||
desc = " ".join(b for b in (h.get("position"), h.get("hole_cards")) if b)
|
||||
events.append({"at": h["at"], "kind": "hand", "hand_id": h["id"],
|
||||
"result": h.get("result"), "text": desc or "hand"})
|
||||
|
||||
for r in _c().execute(
|
||||
"SELECT pr.created_at AS at, pr.seat AS seat, pr.note AS note, p.name AS name "
|
||||
"FROM player_reads pr LEFT JOIN poker_players p ON p.id = pr.player_id "
|
||||
"WHERE pr.session_id = ?", (sid,),
|
||||
).fetchall():
|
||||
who = r["name"] or (f"seat {r['seat']}" if r["seat"] else "villain")
|
||||
events.append({"at": r["at"], "kind": "read", "text": f"Read — {who}: {r['note']}"})
|
||||
|
||||
for r in list_rituals(sid):
|
||||
tag = f"[{r['classification']}] " if r.get("classification") else ""
|
||||
events.append({"at": r["created_at"], "kind": r["kind"],
|
||||
"text": tag + (r.get("content") or r["kind"]), "hand_id": r.get("hand_id")})
|
||||
|
||||
events.sort(key=lambda e: e["at"] or "")
|
||||
for e in events:
|
||||
e["time"] = clock.short(e["at"])
|
||||
return events
|
||||
|
||||
|
||||
def _session_villains(sid: int) -> list[dict]:
|
||||
"""Players read this session, with their standing dossier fields."""
|
||||
rows = _c().execute(
|
||||
"SELECT p.name AS name, p.category AS category, p.tendencies AS tendencies, "
|
||||
"SELECT p.id AS id, p.name AS name, p.category AS category, p.tendencies AS tendencies, "
|
||||
"p.adjustment AS adjustment, "
|
||||
"(SELECT note FROM player_reads r2 WHERE r2.player_id = p.id "
|
||||
" AND r2.session_id = ? ORDER BY r2.id DESC LIMIT 1) AS last_note "
|
||||
@@ -1177,6 +1333,7 @@ def hud(session_id: int | None = None) -> dict | None:
|
||||
},
|
||||
"hands": hands,
|
||||
"villains": _session_villains(sid),
|
||||
"timeline": timeline(sid),
|
||||
"notes": notes,
|
||||
"rituals": {
|
||||
"alligator": alligator_active(sid),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# Single source of truth for poker logging operations. The REST API, Lyra's LLM
|
||||
# tool specs, the human UI, and (later) an MCP wrapper all derive from this.
|
||||
# `required` MUST match the `required` list in the matching tools.py spec.
|
||||
# `rest` PATH MUST match the FastAPI route template verbatim.
|
||||
CONTRACT_VERSION = 1
|
||||
|
||||
OPERATIONS: dict[str, dict] = {
|
||||
"start_session": {"required": (), "llm_tool": "start_session", "rest": ("POST", "/session")},
|
||||
"update_session": {"required": (), "llm_tool": "update_session", "rest": ("PATCH", "/session/{session_id}")},
|
||||
"end_session": {"required": ("cash_out",), "llm_tool": "end_session", "rest": None},
|
||||
"log_stack": {"required": ("amount",), "llm_tool": "log_stack", "rest": ("POST", "/session/stack")},
|
||||
"add_buyin": {"required": ("amount",), "llm_tool": "add_buyin", "rest": ("POST", "/session/buyin")},
|
||||
"log_hand": {"required": (), "llm_tool": "log_hand", "rest": ("POST", "/session/hand")},
|
||||
"update_hand": {"required": ("id",), "llm_tool": None, "rest": ("PATCH", "/hand/{hand_id}")},
|
||||
"add_read": {"required": ("note",), "llm_tool": "add_read", "rest": ("POST", "/session/read")},
|
||||
"update_player": {"required": ("id",), "llm_tool": None, "rest": ("PATCH", "/player/{player_id}")},
|
||||
}
|
||||
+9
-5
@@ -129,16 +129,20 @@ def maybe_summarize_async(session_id: str, backend: Backend | None = None) -> No
|
||||
|
||||
|
||||
def summarize_all(
|
||||
backend: Backend | None = None, limit: int | None = None, workers: int = 8
|
||||
backend: Backend | None = None, limit: int | None = None, workers: int | None = None
|
||||
) -> dict:
|
||||
"""Summarize every session that needs it. Idempotent and resumable.
|
||||
|
||||
LLM summarization runs concurrently across `workers` threads (great for a
|
||||
cloud backend). DB reads (loading transcripts) and writes (store_summary,
|
||||
which also embeds) happen on the main thread, so the single SQLite
|
||||
connection is never touched from multiple threads.
|
||||
Concurrency is backend-aware: the cloud API parallelizes happily, but the
|
||||
local/MI50 GPU servers run a single slot (llama.cpp --parallel 1) — firing N
|
||||
requests at them just queues, blows the client timeout, and thrashes the KV
|
||||
cache (wasted compute + heat). So GPU backends run serially unless overridden.
|
||||
DB reads/writes (store_summary embeds) stay on the main thread, so the single
|
||||
SQLite connection is never touched from multiple threads.
|
||||
"""
|
||||
backend = backend or config.load().summary_backend
|
||||
if workers is None:
|
||||
workers = 8 if backend == "cloud" else 1
|
||||
|
||||
# Main thread: collect the work (transcripts) for sessions needing a summary.
|
||||
todo: list[tuple[str, str, int]] = []
|
||||
|
||||
+7
-90
@@ -81,65 +81,6 @@ def _thought_response(args: dict, ctx: dict) -> str:
|
||||
"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}
|
||||
TOOLS: dict[str, dict] = {
|
||||
"journal_write": {
|
||||
@@ -243,8 +184,9 @@ def _log_stack(args: dict, ctx: dict) -> str:
|
||||
amount = float(args.get("amount"))
|
||||
except (TypeError, ValueError):
|
||||
return "Give me a number for the stack."
|
||||
note = (args.get("note") or "").strip() or None
|
||||
try:
|
||||
st = poker.log_stack(amount)
|
||||
st = poker.log_stack(amount, note=note)
|
||||
except ValueError:
|
||||
return "No live session — start one first, then I'll track your stack."
|
||||
net = st.get("net")
|
||||
@@ -540,34 +482,6 @@ TOOLS.update({
|
||||
{"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."}},
|
||||
["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",
|
||||
"Begin a live poker session. Call when Brian sits down to play.",
|
||||
@@ -606,8 +520,11 @@ TOOLS.update({
|
||||
"log_stack",
|
||||
"Record Brian's CURRENT total chip stack in the live session. Call whenever "
|
||||
"he states his stack ('I'm at 350', 'down to 220', 'stacked off to 900'). "
|
||||
"Tracks his stack over time and his live net while he's still sitting.",
|
||||
{"amount": {**_N, "description": "Current total chip stack, in dollars"}},
|
||||
"Tracks his stack over time and his live net while he's still sitting. Pass "
|
||||
"`note` with the WHY when he gives it ('card dead', 'doubled up vs the LAG') — "
|
||||
"it becomes the line in his session timeline.",
|
||||
{"amount": {**_N, "description": "Current total chip stack, in dollars"},
|
||||
"note": {**_S, "description": "Optional context for the change, e.g. 'card dead', 'doubled up'"}},
|
||||
["amount"])},
|
||||
"scar_note": {"handler": _scar_note, "spec": _f(
|
||||
"scar_note",
|
||||
|
||||
@@ -121,6 +121,94 @@ def create_app() -> FastAPI:
|
||||
logbus.log("info", "session edited", id=session_id, fields=list(body))
|
||||
return {"ok": s is not None, "session": s}
|
||||
|
||||
@app.post("/session/stack")
|
||||
async def session_log_stack(request: Request) -> dict:
|
||||
"""Log Brian's current stack directly (no LLM). Server-stamps the time."""
|
||||
body = await request.json()
|
||||
try:
|
||||
amount = float(body.get("amount"))
|
||||
except (TypeError, ValueError):
|
||||
return {"ok": False, "error": "amount must be a number"}
|
||||
note = (body.get("note") or "").strip() or None
|
||||
try:
|
||||
state = await asyncio.to_thread(poker.log_stack, amount, note)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
logbus.log("info", "stack logged (direct)", amount=amount)
|
||||
return {"ok": True, "stack": state}
|
||||
|
||||
@app.post("/session/buyin")
|
||||
async def session_add_buyin(request: Request) -> dict:
|
||||
"""Add a buy-in/rebuy directly (no LLM)."""
|
||||
body = await request.json()
|
||||
try:
|
||||
amount = float(body.get("amount"))
|
||||
except (TypeError, ValueError):
|
||||
return {"ok": False, "error": "amount must be a number"}
|
||||
try:
|
||||
total = await asyncio.to_thread(poker.add_buyin, amount)
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
logbus.log("info", "buyin added (direct)", amount=amount)
|
||||
return {"ok": True, "buy_in_total": total}
|
||||
|
||||
@app.post("/session")
|
||||
async def session_start(request: Request) -> dict:
|
||||
"""Open a new live session directly (no LLM)."""
|
||||
body = await request.json()
|
||||
sid = await asyncio.to_thread(lambda: poker.start_session(
|
||||
venue=body.get("venue"), stakes=body.get("stakes"),
|
||||
game=body.get("game") or "NLH", fmt=body.get("format") or "cash",
|
||||
buy_in=body.get("buy_in") or 0, mantra=body.get("mantra"),
|
||||
))
|
||||
logbus.log("info", "poker session started (direct)", id=sid)
|
||||
return {"ok": True, "id": sid}
|
||||
|
||||
@app.post("/session/hand")
|
||||
async def session_log_hand(request: Request) -> dict:
|
||||
"""Log a hand directly with flat fields (no LLM parse)."""
|
||||
body = await request.json()
|
||||
try:
|
||||
hid = await asyncio.to_thread(lambda: poker.log_hand(**body))
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
logbus.log("info", "hand logged (direct)", id=hid)
|
||||
return {"ok": True, "id": hid}
|
||||
|
||||
@app.patch("/hand/{hand_id}")
|
||||
async def hand_update(hand_id: int, request: Request) -> dict:
|
||||
"""Edit a logged hand's flat fields."""
|
||||
body = await request.json()
|
||||
h = await asyncio.to_thread(lambda: poker.update_hand(hand_id, **body))
|
||||
logbus.log("info", "hand edited", id=hand_id, fields=list(body))
|
||||
return {"ok": h is not None, "hand": h}
|
||||
|
||||
@app.delete("/hand/{hand_id}")
|
||||
async def hand_delete(hand_id: int) -> dict:
|
||||
"""Delete a logged hand."""
|
||||
ok = await asyncio.to_thread(poker.delete_entry, "hand", hand_id)
|
||||
return {"ok": ok}
|
||||
|
||||
@app.post("/session/read")
|
||||
async def session_add_read(request: Request) -> dict:
|
||||
"""Log a read directly (no LLM); upserts the villain file when name is given."""
|
||||
body = await request.json()
|
||||
rid = await asyncio.to_thread(lambda: poker.add_read(
|
||||
note=body.get("note") or "", seat=body.get("seat"), name=body.get("name"),
|
||||
tendencies=body.get("tendencies"), adjustment=body.get("adjustment"),
|
||||
description=body.get("description"), category=body.get("category"),
|
||||
venue=body.get("venue"),
|
||||
))
|
||||
return {"ok": True, "id": rid}
|
||||
|
||||
@app.patch("/player/{player_id}")
|
||||
async def player_update(player_id: int, request: Request) -> dict:
|
||||
"""Edit a player's dossier (rename, fix tendencies)."""
|
||||
body = await request.json()
|
||||
p = await asyncio.to_thread(lambda: poker.update_player(player_id, **body))
|
||||
logbus.log("info", "player edited", id=player_id, fields=list(body))
|
||||
return {"ok": p is not None, "player": p}
|
||||
|
||||
@app.delete("/session/entry/{kind}/{entry_id}")
|
||||
async def delete_entry(kind: str, entry_id: int) -> dict:
|
||||
"""Delete one HUD entry (hand | stack | read | ritual) by id."""
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Lyra Core Chat</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<link rel="stylesheet" href="style.css?v=8" />
|
||||
<!-- PWA -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Lyra" />
|
||||
<meta name="theme-color" content="#070707" />
|
||||
<meta name="theme-color" content="#141414" />
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" href="icon-192.png" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
@@ -124,6 +124,12 @@
|
||||
<button id="sendBtn" aria-label="Send" title="Send (or ⌘/Ctrl+Enter)">↑</button>
|
||||
</div>
|
||||
|
||||
<!-- Stack quick-capture (no LLM): type a number -> logs current stack -->
|
||||
<div id="stackQuick">
|
||||
<input id="stackQuickInput" type="number" inputmode="decimal" placeholder="Stack $" aria-label="Log current stack">
|
||||
<button id="stackQuickBtn" type="button" title="Log stack (no chat)">Log</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom tab bar (mobile only; hides while the keyboard is open) -->
|
||||
<nav id="tabbar" aria-label="Primary navigation">
|
||||
<a class="tab active" href="/" aria-current="page"><span class="ti">💬</span><span class="tl">Chat</span></a>
|
||||
@@ -209,6 +215,72 @@
|
||||
const API_URL = `${RELAY_BASE}/v1/chat/completions`;
|
||||
const STREAM_URL = `${RELAY_BASE}/v1/chat/stream`;
|
||||
|
||||
// Stack quick-capture (no LLM): type a number -> POST /session/stack.
|
||||
function stackQuickLog() {
|
||||
const el = document.getElementById("stackQuickInput");
|
||||
if (!el) return;
|
||||
const raw = (el.value || "").replace(/[^0-9.]/g, "");
|
||||
if (!raw) return;
|
||||
const amount = Number(raw);
|
||||
const content = document.getElementById("thinkingContent");
|
||||
const empty = document.getElementById("thinkingEmpty");
|
||||
fetch("/session/stack", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ amount })
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (empty && empty.parentNode) empty.parentNode.removeChild(empty);
|
||||
const line = document.createElement("div");
|
||||
const t = new Date().toLocaleTimeString();
|
||||
if (!data.ok) {
|
||||
line.className = "log-line log-error";
|
||||
line.textContent = "⚠ " + (data.error || "stack not logged");
|
||||
} else {
|
||||
line.className = "log-line log-info";
|
||||
const net = (data.stack && data.stack.net != null)
|
||||
? " (net " + (data.stack.net >= 0 ? "+" : "") + data.stack.net + ")" : "";
|
||||
line.textContent = t + " 💰 $" + amount + " logged" + net;
|
||||
el.value = "";
|
||||
}
|
||||
if (content) { content.appendChild(line); content.scrollTop = content.scrollHeight; }
|
||||
}).catch(e => {
|
||||
if (content) {
|
||||
const line = document.createElement("div");
|
||||
line.className = "log-line log-error";
|
||||
line.textContent = "⚠ stack log failed: " + e.message;
|
||||
content.appendChild(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Only show the stack quick-logger when a poker session is actually live —
|
||||
// otherwise logging just errors ("no live session").
|
||||
function updateStackQuickVisibility() {
|
||||
const box = document.getElementById("stackQuick");
|
||||
if (!box) return;
|
||||
fetch("/session/data", { cache: "no-store" })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
const live = !!(data && data.session && data.session.is_live);
|
||||
box.style.display = live ? "flex" : "none";
|
||||
})
|
||||
.catch(function () { box.style.display = "none"; });
|
||||
}
|
||||
(function wireStackQuick() {
|
||||
const box = document.getElementById("stackQuick");
|
||||
const btn = document.getElementById("stackQuickBtn");
|
||||
const inp = document.getElementById("stackQuickInput");
|
||||
if (box) box.style.display = "none"; // hidden until a live session is confirmed
|
||||
if (btn) btn.addEventListener("click", stackQuickLog);
|
||||
if (inp) inp.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") { e.preventDefault(); stackQuickLog(); }
|
||||
});
|
||||
updateStackQuickVisibility();
|
||||
setInterval(updateStackQuickVisibility, 10000);
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (!document.hidden) updateStackQuickVisibility();
|
||||
});
|
||||
})();
|
||||
|
||||
function generateSessionId() {
|
||||
return "sess-" + Math.random().toString(36).substring(2, 10);
|
||||
}
|
||||
@@ -701,6 +773,16 @@
|
||||
window.addEventListener("resize", 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.
|
||||
const userInputEl = document.getElementById("userInput");
|
||||
userInputEl.addEventListener("focus", () => {
|
||||
|
||||
@@ -104,6 +104,26 @@
|
||||
.big-empty { text-align: center; padding: 50px 20px; color: var(--fade); }
|
||||
.big-empty .ico { font-size: 2.4rem; }
|
||||
.big-empty a { color: var(--accent); text-decoration: none; }
|
||||
/* running timeline */
|
||||
ul.tl { list-style: none; margin: 0; padding: 0; }
|
||||
ul.tl li { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--bg-line); align-items: baseline; font-size: .92rem; line-height: 1.4; }
|
||||
ul.tl li:last-child { border-bottom: none; }
|
||||
.tl-time { color: var(--fade); font-variant-numeric: tabular-nums; font-size: .78rem; min-width: 60px; flex: none; }
|
||||
.tl-body { flex: 1; }
|
||||
.tl-amt { margin-left: 6px; font-variant-numeric: tabular-nums; }
|
||||
li.start .tl-body { color: var(--accent); font-weight: 600; }
|
||||
li.scar .tl-body, li.confidence .tl-body { font-style: italic; }
|
||||
.tl-body a.hand { color: var(--accent); text-decoration: none; white-space: nowrap; }
|
||||
/* quick-capture (no LLM) + inline correction controls */
|
||||
.quick { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; }
|
||||
.quick input { width: 100px; background: var(--bg-line); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 8px 10px; color: var(--text); }
|
||||
.quick input:focus { outline: none; border-color: var(--accent); }
|
||||
.quick button { background: var(--accent); color: #0a0a0a; border: 1px solid var(--accent);
|
||||
border-radius: 8px; padding: 8px 12px; cursor: pointer; font-weight: 600; }
|
||||
button.mini { background: none; border: none; color: var(--fade); cursor: pointer;
|
||||
font-size: .9rem; padding: 0 6px; }
|
||||
button.mini:active { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -207,6 +227,30 @@
|
||||
} catch(e){ alert('Delete failed: '+e.message); }
|
||||
}
|
||||
|
||||
// Quick-capture (no LLM): post a number to a direct endpoint, then refresh.
|
||||
function numVal(id){ const el = document.getElementById(id); return Number(((el && el.value) || '').replace(/[^0-9.]/g,'')); }
|
||||
async function postQuick(url, amount){
|
||||
const r = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ amount }) });
|
||||
const d = await r.json();
|
||||
if(!d.ok){ alert(d.error || 'failed'); return false; }
|
||||
return true;
|
||||
}
|
||||
async function postStack(){ const v = numVal('qStack'); if(v && await postQuick('/session/stack', v)){ document.getElementById('qStack').value=''; refresh(); } }
|
||||
async function postBuyin(){ const v = numVal('qBuyin'); if(v && await postQuick('/session/buyin', v)){ document.getElementById('qBuyin').value=''; refresh(); } }
|
||||
async function postCashout(){
|
||||
if(!curSession) return;
|
||||
const v = numVal('qCashout'); if(!v) return;
|
||||
const r = await fetch('/session/'+curSession.id, { method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ cash_out: v }) });
|
||||
if(!(await r.json()).ok){ alert('failed'); return; }
|
||||
document.getElementById('qCashout').value=''; refresh();
|
||||
}
|
||||
async function renamePlayer(id, current){
|
||||
const name = prompt('Rename player', current || ''); if(!name) return;
|
||||
const r = await fetch('/player/'+id, { method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name }) });
|
||||
if(!(await r.json()).ok){ alert('failed'); return; }
|
||||
refresh();
|
||||
}
|
||||
|
||||
function render(data){
|
||||
const s = data.session;
|
||||
if (!s) {
|
||||
@@ -219,6 +263,7 @@
|
||||
}
|
||||
curSession = s;
|
||||
const stack = data.stack || {};
|
||||
const timeline = data.timeline || [];
|
||||
const hands = data.hands || [];
|
||||
const villains = data.villains || [];
|
||||
const notes = data.notes || [];
|
||||
@@ -274,7 +319,25 @@
|
||||
<span class="stack-meta">bought in ${money(stack.buy_in)}<br>${(stack.log||[]).length} update(s)</span>
|
||||
</div>
|
||||
${sparkline(stack.log || [])}
|
||||
${stack.current == null ? '<p class="empty" style="margin:12px 0 0">No stack logged yet — tell Lyra your stack ("I\'m at 350").</p>' : ''}
|
||||
${stack.current == null ? '<p class="empty" style="margin:12px 0 0">No stack logged yet — log it below or tell Lyra ("I\'m at 350").</p>' : ''}
|
||||
<div class="quick">
|
||||
<input id="qStack" type="number" inputmode="decimal" placeholder="Stack $" onkeydown="if(event.key==='Enter')postStack()">
|
||||
<button onclick="postStack()">Log stack</button>
|
||||
<input id="qBuyin" type="number" inputmode="decimal" placeholder="Buy-in $" onkeydown="if(event.key==='Enter')postBuyin()">
|
||||
<button onclick="postBuyin()">Add buy-in</button>
|
||||
<input id="qCashout" type="number" inputmode="decimal" placeholder="Cash out $" onkeydown="if(event.key==='Enter')postCashout()">
|
||||
<button onclick="postCashout()">Cash out</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="label">📜 Timeline</p>
|
||||
${timeline.length ? `<ul class="tl">${timeline.map(e => `
|
||||
<li class="${esc(e.kind)}">
|
||||
<span class="tl-time">${esc(e.time)}</span>
|
||||
<span class="tl-body">${esc(e.text)}${e.amount != null ? ` <b class="tl-amt">${money(e.amount)}</b>` : ''}${e.result != null ? ` <span class="res ${e.result>=0?'up':'down'}">${signed(e.result)}</span>` : ''}${e.hand_id ? ` <a class="hand" href="/hand/${e.hand_id}">hand ›</a>` : ''}</span>
|
||||
</li>`).join('')}</ul>`
|
||||
: '<p class="empty">Nothing yet tonight — the running log fills in as you play.</p>'}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -311,6 +374,7 @@
|
||||
${villains.length ? `<ul class="rows">${villains.map(v => `
|
||||
<li class="villain">
|
||||
<b>${esc(v.name)}</b> ${v.category ? `<span class="cat">[${esc(v.category)}]</span>` : ''}
|
||||
<button class="mini" title="Rename / fix" onclick="renamePlayer(${v.id}, '${esc(v.name||'').replace(/'/g,"\\'")}')">✎</button>
|
||||
${v.tendencies ? `<div>${esc(v.tendencies)}</div>` : ''}
|
||||
${v.last_note ? `<div class="note-meta">“${esc(v.last_note)}”</div>` : ''}
|
||||
</li>`).join('')}</ul>`
|
||||
|
||||
@@ -56,6 +56,16 @@ body.dark {
|
||||
|
||||
html {
|
||||
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%;
|
||||
}
|
||||
|
||||
html {
|
||||
/* Paints the iOS home-indicator strip below the dvh shell; match the tab bar so the
|
||||
bar looks like it continues to the physical bottom edge. */
|
||||
background: var(--bg-line);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -826,15 +836,17 @@ select:hover {
|
||||
@media screen and (max-width: 768px) {
|
||||
body {
|
||||
padding: 0;
|
||||
background: var(--bg-elev); /* matches the tab bar so any strip below #chat is seamless */
|
||||
background: var(--bg-line); /* matches the tab bar so any strip below #chat is seamless */
|
||||
}
|
||||
|
||||
#chat {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
width: 100%;
|
||||
height: 100dvh; /* the *visible* viewport (excludes the home-indicator zone);
|
||||
overrides the base 95vh. Body bg matches the bar below it. */
|
||||
height: 100vh; /* fallback for old browsers */
|
||||
height: 100dvh; /* the *visible* viewport — keep all content (incl. the tab bar)
|
||||
inside what iOS actually paints, so nothing is clipped into the
|
||||
home-indicator dead zone. The strip below is matched in color. */
|
||||
background: var(--bg-dark);
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
@@ -918,8 +930,11 @@ select:hover {
|
||||
display: flex;
|
||||
flex: none; /* never let it be compressed/clipped by the flex column */
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-elev);
|
||||
padding-bottom: 6px; /* 100dvh already excludes the home-indicator zone */
|
||||
background: var(--bg-line); /* lighter than the page so it reads as a solid bar */
|
||||
/* Shell is 100dvh, so the bar sits at the bottom of the rendered area with the icons
|
||||
fully visible. Minimal padding keeps them low; the home-indicator strip just below
|
||||
the rendered area is painted the same color (html bg) so the bar looks continuous. */
|
||||
padding-bottom: 4px;
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
@@ -1227,3 +1242,31 @@ select:hover {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stack quick-capture (2nd input box on the chat page) — logs without the LLM. */
|
||||
#stackQuick {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
#stackQuick input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-elev);
|
||||
color: inherit;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
#stackQuick button {
|
||||
padding: 8px 14px;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
"""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
|
||||
@@ -0,0 +1,103 @@
|
||||
"""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
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(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)
|
||||
import lyra.web.server as server
|
||||
importlib.reload(server)
|
||||
from fastapi.testclient import TestClient
|
||||
return TestClient(server.app), poker
|
||||
|
||||
|
||||
def test_post_stack_logs_and_returns_state(client):
|
||||
c, poker = client
|
||||
poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
|
||||
r = c.post("/session/stack", json={"amount": 373})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["stack"]["current"] == 373
|
||||
assert body["stack"]["net"] == pytest.approx(-27)
|
||||
|
||||
|
||||
def test_post_stack_without_session_errors(client):
|
||||
c, _ = client
|
||||
r = c.post("/session/stack", json={"amount": 373})
|
||||
assert r.json()["ok"] is False
|
||||
assert "error" in r.json()
|
||||
|
||||
|
||||
def test_post_buyin_increments_total(client):
|
||||
c, poker = client
|
||||
poker.start_session(buy_in=400)
|
||||
r = c.post("/session/buyin", json={"amount": 200})
|
||||
assert r.json()["buy_in_total"] == pytest.approx(600)
|
||||
|
||||
|
||||
def test_post_session_starts_live(client):
|
||||
c, poker = client
|
||||
r = c.post("/session", json={"venue": "Wheeling", "stakes": "1/3", "buy_in": 400})
|
||||
sid = r.json()["id"]
|
||||
assert poker.live_session()["id"] == sid
|
||||
|
||||
|
||||
def test_post_hand_edit_and_delete(client):
|
||||
c, poker = client
|
||||
poker.start_session(buy_in=400)
|
||||
r = c.post("/session/hand", json={"position": "BTN", "hole_cards": "22", "result": 120})
|
||||
assert r.json()["ok"] is True
|
||||
hid = r.json()["id"]
|
||||
r2 = c.patch(f"/hand/{hid}", json={"hole_cards": "2c2d"})
|
||||
assert r2.json()["ok"] is True
|
||||
assert r2.json()["hand"]["hole_cards"] == "2c2d"
|
||||
r3 = c.delete(f"/hand/{hid}")
|
||||
assert r3.json()["ok"] is True
|
||||
assert poker.get_hand(hid) is None
|
||||
|
||||
|
||||
def test_post_read(client):
|
||||
c, poker = client
|
||||
poker.start_session(buy_in=400)
|
||||
r = c.post("/session/read", json={"note": "3-bets light", "name": "James K"})
|
||||
assert r.json()["ok"] is True
|
||||
assert isinstance(r.json()["id"], int)
|
||||
|
||||
|
||||
def test_rename_player_fixes_mislabel(client):
|
||||
c, poker = client
|
||||
pid = poker.upsert_player("Dave the rock", category="reg")
|
||||
r = c.patch(f"/player/{pid}", json={"name": "Dave the mechanic"})
|
||||
assert r.json()["ok"] is True
|
||||
assert r.json()["player"]["name"] == "Dave the mechanic"
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from lyra import tools
|
||||
from lyra.poker_contract import OPERATIONS
|
||||
|
||||
|
||||
def test_llm_tool_required_args_match_contract():
|
||||
for op, decl in OPERATIONS.items():
|
||||
name = decl["llm_tool"]
|
||||
if not name:
|
||||
continue
|
||||
spec = tools.TOOLS[name]["spec"]
|
||||
required = set(spec["function"]["parameters"]["required"])
|
||||
assert required == set(decl["required"]), (
|
||||
f"{op}: tools spec required {required} != contract {set(decl['required'])}"
|
||||
)
|
||||
|
||||
|
||||
def test_rest_routes_registered():
|
||||
import lyra.web.server as server
|
||||
registered = set()
|
||||
for route in server.app.routes:
|
||||
methods = getattr(route, "methods", None)
|
||||
path = getattr(route, "path", None)
|
||||
if not methods or not path:
|
||||
continue
|
||||
for m in methods:
|
||||
registered.add((m, path))
|
||||
for op, decl in OPERATIONS.items():
|
||||
if not decl["rest"]:
|
||||
continue
|
||||
method, path = decl["rest"]
|
||||
assert (method, path) in registered, f"{op}: {method} {path} not registered"
|
||||
Reference in New Issue
Block a user