Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e631797187 | |||
| 29a4d59661 | |||
| 07153fc53d | |||
| e6134cf535 | |||
| aefb22c823 | |||
| 5da13a7321 | |||
| 9b844bc356 | |||
| 8d2d7fb576 | |||
| 3d886cdeae | |||
| 392c46d8bf | |||
| 4ce1b05fad | |||
| 2be43848a7 | |||
| d5c80f6153 | |||
| 056578ac75 | |||
| f20570fc03 | |||
| 3b3878ada1 | |||
| f2944ed402 | |||
| 9cd962625d | |||
| 8a6b11c56a | |||
| 22526d7938 | |||
| 7f23aeae17 | |||
| 71bbe07220 | |||
| a4412aa023 | |||
| 3afa75f4be | |||
| 865834a8ae | |||
| fb6b44a82e | |||
| 393ca65dee | |||
| 14480c40b2 | |||
| 8d709b9554 | |||
| 52839a9bc8 | |||
| 8ad4bc4ce0 | |||
| 8031c277a2 | |||
| 36f2aa76b3 | |||
| a2835500bc | |||
| 5c63175a3c | |||
| 86f3d2dc0a | |||
| abac42c344 | |||
| 44bb8687f7 | |||
| cb4ed10c1a | |||
| ba00530caf |
@@ -53,9 +53,6 @@ DB, no shared UI components. If RTO is down, Lyra skips analysis and nothing bre
|
||||
- **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.
|
||||
A **straddle** is recorded as a preflop `post` at a non-blind position (typically 2×BB,
|
||||
voluntary); preflop action starts left of it and it acts last, but that's reflected by
|
||||
action *order*, not a distinct verb.
|
||||
- **Streets:** `preflop flop turn river`.
|
||||
|
||||
`lyra/poker.py:normalize_structured()` is the single function that guarantees this shape.
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
# Hand recorder — design note
|
||||
|
||||
A tap-to-build hand recorder. The point isn't "nicer input" — it's **correctness by
|
||||
construction**: every tap writes a known action into a known slot, so there's no parse
|
||||
step that can be wrong. It sidesteps the whole class of LLM-parse replay bugs. The text
|
||||
parser stays for importing the backlog (old notes, Trilium, ChatGPT history); the recorder
|
||||
is clean capture going forward.
|
||||
|
||||
Output is the canonical structured shape in [HAND_HISTORY.md](HAND_HISTORY.md) — so it
|
||||
drops straight into the DB and the existing replay viewer, and flows to RTO unchanged.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Correctness by construction** — the UI only lets you build valid hands; the emitter
|
||||
produces the contract shape; the server `normalize_structured()` is the final guarantee.
|
||||
2. **Reusable module, mount-agnostic.** Decision: overlay first, swap to standalone if the
|
||||
overlay fights the chat page — *reusing the code either way*. So the recorder is a
|
||||
self-contained module mounted into a container element, with the **emit logic kept pure
|
||||
(no DOM)**. Moving overlay → standalone is a re-mount, not a rewrite.
|
||||
3. **Don't reinvent what Lyra knows.** Pre-fill from live session state.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
lyra/web/static/recorder.js # the module: state machine + DOM shell + buildStructured()
|
||||
lyra/web/static/recorder.css # scoped styles (full-screen table + keypad)
|
||||
```
|
||||
|
||||
- `Recorder.mount(containerEl, { sessionId, onSave, onClose })` — instantiates into any
|
||||
container. In V1 the container is a full-screen overlay `<div>` inside `index.html`
|
||||
(chat/session page stays mounted underneath — a flip-over, not a route change). If that
|
||||
proves janky, the *same module* mounts into `recorder.html` with zero logic changes.
|
||||
- **Pure core, separable from DOM:** `buildStructured(state) -> structuredDict`. No element
|
||||
access — takes the in-memory state, returns the contract object. This is the testable,
|
||||
reusable heart; the DOM shell only reads/writes `state` and calls `buildStructured` on save.
|
||||
|
||||
## Pre-fill from live state (the "it already knows" feel)
|
||||
|
||||
Source: `GET /session/data` (→ `poker.hud`). Today it gives us:
|
||||
- `session`: `venue`, `stakes`, `game`, `format`, `is_live`
|
||||
- `stack.current` → hero's starting stack for the hand
|
||||
- `villains[]`: `name`, `category`, `tendencies`, `last_note` (the players read this session)
|
||||
|
||||
Derived in the recorder:
|
||||
- **Blinds** parsed from `stakes` ("1/3" → SB 1, BB 3) → auto-seed the `post` actions.
|
||||
- **Hero stack** from `stack.current`.
|
||||
|
||||
**Two gaps to close as part of the build** (flagged, not yet done):
|
||||
1. **Seats aren't in the HUD bundle.** `player_reads` *has* a `seat` column, but
|
||||
`_session_villains()` doesn't select it — so we can name the villains but not place them.
|
||||
Fix: add `seat` (latest read per player) to the villains payload, then auto-seat them.
|
||||
Until then, V1 seats known villains in read-order and you assign positions by tapping.
|
||||
2. **Hero position isn't tracked live** (button/seat moves every hand) — so `hero_pos` is a
|
||||
per-hand tap, seeded to last-used. That's correct, not a gap to "fix", just noting it.
|
||||
|
||||
## In-memory state model
|
||||
|
||||
```js
|
||||
state = {
|
||||
meta: { game, stakes, venue, sessionId }, // from /session/data
|
||||
blinds: { sb, bb }, // parsed from stakes
|
||||
heroPos: "BTN", // tapped per hand
|
||||
seats: [ { pos, name, stack, cards: null, in: true } ], // incl. hero seat
|
||||
street: "preflop", // street currently being entered
|
||||
board: { flop: [], turn: [], river: [] },
|
||||
actions:[ { street, pos, action, amount } ], // appended as you tap
|
||||
result: { pot: null, heroNet: null, summary: "" }
|
||||
}
|
||||
```
|
||||
|
||||
`buildStructured(state)` →
|
||||
|
||||
| contract field | from |
|
||||
|---|---|
|
||||
| `hero_pos` | `state.heroPos` |
|
||||
| `hero_cards` | the hero seat's `cards` |
|
||||
| `players[]` | `state.seats` (`{pos,stack,name,cards}`; emitter doesn't set `hero`/version — server normalize does) |
|
||||
| `actions[]` | `state.actions`, with a `{street, board}` reveal entry spliced in at each street boundary from `state.board` |
|
||||
| `board` | `flop + turn + river` concatenated |
|
||||
| `result` | `state.result` |
|
||||
|
||||
Client builds best-effort; **`store_hand_history()` → `normalize_structured()` is the
|
||||
authority** (canonical cards, hero sync, `schema_version`, `completeness`). Keeps the
|
||||
client dumb and the contract enforced in one place.
|
||||
|
||||
## Persistence
|
||||
|
||||
New endpoint (small, part of the build):
|
||||
```
|
||||
POST /hands body: { structured, session_id?, tag?, lesson? }
|
||||
-> store_hand_history(structured, ...) -> { id }
|
||||
```
|
||||
On save: POST, then hand off to the existing viewer `/hand/{id}` to replay — which doubles
|
||||
as the correctness check (what you tapped is exactly what replays).
|
||||
|
||||
## Scope
|
||||
|
||||
**V1 — core loop (chosen).** Seats + cards + per-street actions emitting valid structured
|
||||
JSON. Manual street advance (a "next street" button + board entry), free bet-size entry
|
||||
(type the number). Proves capture → store → replay end to end on the locked schema.
|
||||
|
||||
### Card entry (V1)
|
||||
|
||||
Contextual: tap a card slot (hero card, board square, "they showed") → a compact picker
|
||||
pops at that slot. One picker holds **4 color-coded suits + 13 ranks + `x` + unknown-card**.
|
||||
Whichever you tap first sets the flow for that card — no mode switch:
|
||||
|
||||
- **Suit first → it locks** (stays lit). Each subsequent rank tap places `rank+lockedSuit`
|
||||
and auto-advances. Flush flop = `♥ T 8 5` (4 taps); suited hole = `♥ A K` (3 taps).
|
||||
- **Rank first → card is pending a suit**; the next tap must be a suit (or `x`). Best for
|
||||
rainbow/mixed. Locked suit stays in effect until a different suit is tapped.
|
||||
- **`x`** = unknown suit → stores e.g. `Ax`; flips `completeness.cards` false so RTO skips
|
||||
suit-dependent math. **Unknown-card** button = a villain card never shown (`x`).
|
||||
|
||||
No typing — lowercase tokens are the internal/contract format only; the player only ever
|
||||
taps symbols. State: `lockedSuit` (nullable) + the active slot; auto-advance on complete.
|
||||
|
||||
**V2 — the smart keypad.** The contextual state machine layered on top: tracks whose turn
|
||||
it is and the current bet, offers only legal actions (check vs call; bet/raise reveal size
|
||||
presets ½/¾/pot/+1bb), auto-advances the street when action closes, tap-a-seat to set the
|
||||
actor, one-tap "they showed [cards]" at showdown. ~6–10 taps, no typing. Built on the same
|
||||
`state` + `buildStructured`, so V1's emitter doesn't change — V2 just drives `state` smarter.
|
||||
|
||||
## Layout sketch (full-screen overlay)
|
||||
|
||||
```
|
||||
┌───────────────────────────── Record hand ─────────────── ✕ ┐
|
||||
│ (CO) (BTN) │
|
||||
│ (HJ) ◯ oval table ◯ (SB) │
|
||||
│ (MP) (UTG) (BB·hero) │
|
||||
│ board: [ 7d ][ 2c ][ 5h ] pot: 40 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ acting: BB [ fold ][ check ][ call ][ bet ][ raise ] │
|
||||
│ amount: [ 15 ] [ ½ ][ ¾ ][ pot ][ +1bb ] (V2) │
|
||||
│ [ ◀ prev street ] [ next street ▶ ] [ they showed… ] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ preflop: BTN raise 15 · BB call [ save & replay ] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Build order
|
||||
|
||||
1. `POST /hands` endpoint + add `seat` to the villains payload (server, small).
|
||||
2. `recorder.js` skeleton: `mount()`, `state`, `buildStructured()` (pure).
|
||||
3. Overlay shell in `index.html` (open button in session/cash mode) + `recorder.css`.
|
||||
4. V1 capture flow → save → replay. Validate a real hand round-trips identically.
|
||||
5. V2 smart keypad on top.
|
||||
```
|
||||
@@ -0,0 +1,167 @@
|
||||
# The Scouting Desk — proactive poker recall + villain identity resolution
|
||||
|
||||
*Design spec. Not built yet. Companion to the "she remembers" north star in the
|
||||
`poker-copilot` memory. Written 2026-07-03, before the trial-by-fire session.*
|
||||
|
||||
## Purpose
|
||||
|
||||
Turn the copilot from a logbook into a copilot that **remembers across sessions,
|
||||
unprompted** — the way a broadcast stats desk slides a note to the color
|
||||
commentator: *"he mentioned the guy's hot streak → here are his last 10 games."*
|
||||
|
||||
Target moments:
|
||||
- *"you had this exact leak last week too, remember?"*
|
||||
- *"neck-tattoo guy just 3-bet you — last time he did that at the Meadows he had it."*
|
||||
- *"Sleepy John was here two weeks ago; you stacked off AK into his set."*
|
||||
|
||||
The failure mode to avoid at all costs: **confident-but-wrong.** A stats desk that
|
||||
guesses gets the commentator burned on air. **Silence is the default; the desk
|
||||
speaks only when there's real signal.**
|
||||
|
||||
## What already exists (don't rebuild it)
|
||||
|
||||
`mind.build_messages()` already runs a recall pass on **every** message:
|
||||
`memory.recall(user_msg)` over past exchanges + `memory.recall_summaries(user_msg)`
|
||||
over session gists, injected as system notes before she replies. The
|
||||
"slide-a-note-in-before-she-speaks" machinery is already the architecture. This
|
||||
spec **adds a poker desk** to that pass — it does not build a new RAG system.
|
||||
|
||||
Episodic links also already exist: `link_hand_players` writes a
|
||||
`player_observations` row per named villain in a recorded hand, carrying
|
||||
`hand_id` AND `session_id`; `player_reads` carry `session_id`. So villain →
|
||||
observation → hand → session/date is reconstructable today.
|
||||
|
||||
## Two retrieval channels (don't conflate them)
|
||||
|
||||
1. **Entity desk — deterministic.** A known **name** in the message → exact/fuzzy
|
||||
SQL match on `poker_players` → pull dossier + your history vs him. ~1ms, no
|
||||
hallucination. This is the "hears the name, pulls last 10 games" case.
|
||||
2. **Pattern desk — semantic.** No entity to key on ("I keep punting these river
|
||||
bluffs") → embed the message, retrieve similar **scar notes / hands / recap
|
||||
passages** by meaning. This is where embeddings earn their keep. Also the
|
||||
backbone of nameless-villain matching (below).
|
||||
|
||||
Both feed one injected **STATS DESK** system note, relevance-gated.
|
||||
|
||||
## The hard part: nameless villains
|
||||
|
||||
Most live villains have no name. Brian identifies them by **physical descriptor**
|
||||
("guy with the lips/neck tattoo"), by **seat** ("seat 4", "two to my left"), or —
|
||||
uselessly — **generically** ("mid-aged white dude with glasses").
|
||||
|
||||
### Current gap
|
||||
`poker_players.name` is `NOT NULL` and identity is an **exact name match**
|
||||
(`upsert_player` → `WHERE name = ?`). The `description` column exists but is dead
|
||||
weight: not a key, not embedded, never matched. **Nameless villains can't exist
|
||||
today.** This is the core schema fix.
|
||||
|
||||
### Identity model — descriptor as a fuzzy primary key
|
||||
Store a villain as:
|
||||
- `name` — now **optional**.
|
||||
- `descriptors` — accumulated distinctive physical tags heard over time
|
||||
("neck tattoo", "lips ink", "heavyset", "bald+beard").
|
||||
- `descriptor_embedding` — embedding of the accumulated distinctive tags, for
|
||||
semantic match against drifting phrasings.
|
||||
- `venue` — a strong disambiguator (the neck-tattoo reg at the Meadows ≠ the one
|
||||
at Wheeling, unless Brian travels).
|
||||
- `distinctiveness` — a weight; distinctive features (tattoos, scars, a name)
|
||||
score high, generic ones (age/race/glasses) near zero.
|
||||
|
||||
### Resolver — matching an incoming reference
|
||||
1. **Name present** → exact/fuzzy SQL match (entity desk). Done.
|
||||
2. **Descriptor present** → embed it, compare to `descriptor_embedding` of known
|
||||
villains **scoped to the current venue**, weighted by distinctiveness.
|
||||
3. **Confidence bands:**
|
||||
- **High** (distinctive + strong match) → surface the file; if live, a light
|
||||
confirm ("the neck-tattoo LAG from 3 weeks ago?").
|
||||
- **Medium/ambiguous** (several candidates, or a middling score) → **do NOT
|
||||
interrupt.** File a `needs_clarification` task to the review queue and stay
|
||||
quiet, OR ask only if it's decision-relevant right now.
|
||||
- **Generic-only** (no distinctive signal) → **refuse to guess.** Stay silent
|
||||
or ask for one distinctive detail ("anything that stands out — ink, chips,
|
||||
how he plays?"). Wrong-guy citation is worse than nothing.
|
||||
- **No match** → new villain; open a descriptor-keyed dossier.
|
||||
|
||||
### Seat = within-session alias only
|
||||
The live session keeps a `seat → villain` map so reads accumulate whether Brian
|
||||
says "seat 4" or "the tattoo guy." Seats evaporate when the session ends — they
|
||||
mean nothing next week.
|
||||
|
||||
## Confirmation loop (live, in chat)
|
||||
|
||||
Auto-merging on a fuzzy match is dangerous, so she **proposes and Brian confirms**
|
||||
in natural language:
|
||||
|
||||
> Brian: "neck tattoo guy just 3-bet me again"
|
||||
> Lyra: "The neck-tattoo LAG from the Meadows three weeks ago — the one who
|
||||
> stacked you with the flush? Or new guy?"
|
||||
> Brian: "yeah him" → reinforce identity · "nah different" → split, and learn
|
||||
> what distinguishes them.
|
||||
|
||||
Handles name-arrives-later for free: catch his name off Bravo → "merge neck-tattoo
|
||||
guy into 'Danny'" → history follows.
|
||||
|
||||
## The review interface (async, out-of-band)
|
||||
|
||||
Silence at the table ≠ forget it → it routes to a queue Brian clears at his pace.
|
||||
|
||||
### `/players` — villain file browser
|
||||
List: name-or-lead-descriptor, venue, category (feeder/risky/reg), hands
|
||||
observed, VPIP/PFR (when sample is real), last seen, distinctive tags. Detail
|
||||
view: reads, showdowns, notable hands (link to `/hand/{id}`), sessions seen,
|
||||
stats. Edit / rename / retag / delete / manual-merge.
|
||||
|
||||
### Resolution queue — two lanes
|
||||
- **Possible merges** — two profiles likely one person (high descriptor
|
||||
similarity + same venue, below auto-merge). Side-by-side → **Same guy** (merge)
|
||||
/ **Different** (split).
|
||||
- **Needs clarification** — a descriptor that matched several candidates, or a
|
||||
nameless villain the resolver couldn't place → pick match / **New guy**.
|
||||
|
||||
### Two rules that keep the queue from rotting
|
||||
1. **A rejected merge stays rejected** — record the pair as *known-distinct* so it
|
||||
never re-surfaces; start tracking the distinguishing tell.
|
||||
2. **Merge-candidate scan runs in the dream cycle**, not the hot path — nightly,
|
||||
compare descriptor embeddings within each venue, file new maybes. Zero live
|
||||
latency.
|
||||
|
||||
## Injection format & gating
|
||||
|
||||
A single system note, clearly marked as structured fact so she cites it (not
|
||||
confabulates), e.g.:
|
||||
|
||||
```
|
||||
STATS DESK — Neck-tattoo guy (Meadows, LAG/reg): seen 3×, last 2wk ago.
|
||||
vs you: hand #38 (AK, stacked off into his set). Reads: overfolds turn,
|
||||
3-bets light from the CO. Sample: 22 hands — VPIP 41 / PFR 28.
|
||||
```
|
||||
|
||||
Gate hard: inject only on a confident entity hit or a strong semantic score.
|
||||
Default to nothing. Never inject a generic-only guess.
|
||||
|
||||
## Honest limits
|
||||
|
||||
Never perfect. Some players are genuinely indistinguishable — fine. The system's
|
||||
only job: **right when there's signal, quiet when there isn't.**
|
||||
|
||||
## New data model (sketch)
|
||||
|
||||
- `poker_players`: `name` → nullable; add `descriptors TEXT`,
|
||||
`descriptor_embedding BLOB`, `distinctiveness REAL`.
|
||||
- `player_distinct_pairs(a_id, b_id, note, created_at)` — rejected merges.
|
||||
- `identity_queue(id, kind, player_ids, descriptor, context, session_id,
|
||||
confidence, status, resolution, created_at)` — kind ∈ {merge_candidate,
|
||||
needs_clarification}.
|
||||
- Live-session `seat → player_id` alias map (in-session only).
|
||||
|
||||
## Sequencing (after the trial-by-fire session — recall feeds on real data)
|
||||
|
||||
1. **Nameless identity + resolver** — schema, descriptor embedding, venue-scoped
|
||||
semantic match, distinctiveness gate. (Unblocks everything.)
|
||||
2. **Scouting-desk injection** — wire entity + pattern recall into
|
||||
`build_messages` as the gated STATS DESK note.
|
||||
3. **Confirmation loop** — the live propose/confirm/merge/split UX in the persona.
|
||||
4. **`/players` browser + resolution queue UI** — the async review interface.
|
||||
5. **Dream-cycle merge scan** — nightly candidate generation.
|
||||
6. **Pattern desk** — semantic recall over scars/notes/recaps for "this leak
|
||||
again."
|
||||
@@ -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."
|
||||
@@ -0,0 +1,117 @@
|
||||
# Poker message-type prompts (sub-project 2)
|
||||
|
||||
- **Date:** 2026-07-01
|
||||
- **Status:** Spec for review
|
||||
- **Branch:** `feat/poker-mode-prompts` (continues on the same branch; sub-project 1 shipped there)
|
||||
- **Supersedes:** the parked "sub-project 2" section of `docs/superpowers/specs/2026-06-28-poker-mode-prompts-design.md`
|
||||
|
||||
## Problem (recap)
|
||||
|
||||
In poker mode Lyra routes correctly but her replies are generic — one broad `_CASH_CARD` (`lyra/modes.py:66-116`) describes *traits* and gets injected on every turn, so the model satisfies it with safe, flattering abstraction. From real sessions: coaching essays on bare stack updates, false tilt/fatigue reads on neutral logistics ("table broke, it's 11:50pm" → "late-night fatigue…"), praising a value bet that got *no* value, and hedging ("a disciplined fold might have been better") instead of calling `analyze_spot`.
|
||||
|
||||
The fix: stop sending one card for every message. Detect *what kind of message* Brian just sent and inject a small, concrete response contract for that type.
|
||||
|
||||
## Goals
|
||||
|
||||
1. A per-turn **message-type classifier** for poker mode, and **per-type prompt fragments** replacing the monolithic card.
|
||||
2. Kill the two pipeline sources of mush in poker mode: the misfiring mood nudge and the always-on mode-menu note.
|
||||
3. Make HAND turns reason about **bet intent** and lean on `analyze_spot` (NLH only).
|
||||
4. Keep the door open for a fine-tuned MI50 classifier/model behind the same seams.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- PLO/Omaha strategic analysis. `record_hand` already parses 4-card hands and the replayer renders them; only `analyze_spot` (equity) is NLH-bound. **This pass: PLO hands are logged/replayed but get no NLH-style analysis.**
|
||||
- A PLO equity engine.
|
||||
- Changing the store, the REST API, or the tools (sub-project 1, done).
|
||||
- An LLM classifier in v1 (heuristic first; the function is the swappable seam).
|
||||
|
||||
## Build order (confirmed)
|
||||
|
||||
**Phase A — pipeline fixes** (quick win) → **Phase B — classifier + fragments** (the meat) → **Phase C — MI50 tool-calling** (separable).
|
||||
|
||||
---
|
||||
|
||||
## Phase A — Pipeline fixes
|
||||
|
||||
Both are independent of the classifier and immediately reduce mush in poker mode.
|
||||
|
||||
1. **Suppress the mode-menu note in poker mode.** `_mode_menu_note` (`mind.py:77-88`) is injected every turn (`mind.py:158`). At the table she should not be offering to switch modes. In `build_messages`, skip that append when `mode.key == "poker_cash"`.
|
||||
2. **Suppress the `_route` mood nudge in poker mode.** `_route` (`mind.py:320-339`) sets `ctx.register` + a "steady/hype" note from a lexicon heuristic; in poker this double-signals with the card and caused the false tilt reads. In `_route`, when `mode.key == "poker_cash"`, resolve the mode as normal (line 324 stays) but **skip the register/note block** (327-338). Poker register comes from the Phase B fragments (esp. MENTAL) instead. Non-poker modes keep the nudge unchanged.
|
||||
|
||||
## Phase B — Classifier + per-type fragments
|
||||
|
||||
### New module `lyra/poker_prompts.py`
|
||||
|
||||
Cohesive home for poker prompting: the classifier, a lean always-on base, and the per-type fragments.
|
||||
|
||||
```
|
||||
classify(user_msg: str) -> str # "HAND" | "STATUS" | "MENTAL" | "LOG" | "CHAT"
|
||||
BASE: str # always-on poker rules (logging, session_state, rituals, equity)
|
||||
FRAGMENTS: dict[str, str] # msg_type -> response-shape contract
|
||||
fragment_for(msg_type: str | None) -> str # FRAGMENTS.get(msg_type, FRAGMENTS["CHAT"])
|
||||
```
|
||||
|
||||
`classify` is a **pure function** (no DB), unit-tested like `perceive.read`. Heuristic signals, first match wins in priority order:
|
||||
|
||||
1. **HAND** — card tokens (regex `\b[2-9TJQKA][shdc]\b`, ≥2), or position tokens (UTG/MP/HJ/CO/BTN/SB/BB/"button"/"hijack"/"straddle"), or a street word (flop/turn/river) with a betting verb (bet/raise/call/fold/check/shove/limp/jam).
|
||||
2. **MENTAL** — first-person feeling: "I feel", "I'm tilted/steaming/fried/tired/frustrated/confident/stuck/bored", "on tilt", "in my head", "mental", "leak".
|
||||
3. **STATUS** — logistics with no cards: "table broke", "new table", "waiting for a seat", "seat opened", "just sat", clock times, "heading to"/venue mentions.
|
||||
4. **LOG** — bare money/result prose that slipped past the quick-capture box: "I'm at", "stack is", "down to", "up to", "out for", "cashed", "rebought", "rebuy" with a number.
|
||||
5. **CHAT** — default fallback (questions, open talk).
|
||||
|
||||
(HAND wins over MENTAL so a described hand still gets logged even if he's venting; the HAND fragment tells her to acknowledge the feeling too.)
|
||||
|
||||
### Injection (`mind.py`)
|
||||
|
||||
- Add `msg_type: str | None = None` to `TurnContext` (`mind.py:305`).
|
||||
- In `_route`, when `mode.key == "poker_cash"`, set `ctx.msg_type = poker_prompts.classify(ctx.user_msg)`.
|
||||
- Thread it through `_compose` → add a `msg_type` param to `build_messages` (`mind.py:137`, `344`).
|
||||
- Replace the card-injection block (`mind.py:152-154`) with:
|
||||
```python
|
||||
if mode and mode.key == "poker_cash":
|
||||
messages.append({"role": "system", "content": poker_prompts.BASE})
|
||||
messages.append({"role": "system", "content": poker_prompts.fragment_for(msg_type)})
|
||||
elif mode and mode.card:
|
||||
messages.append({"role": "system", "content": mode.card})
|
||||
```
|
||||
- Set `CASH.card = ""` in `modes.py` (content moves to `poker_prompts`; keep `_CASH_CARD` text as the source material to distill from, then delete once fragments are in). `CASH.tools` is unchanged.
|
||||
|
||||
### The fragments (concrete contracts, not traits)
|
||||
|
||||
**BASE** (always-on in poker) — distilled from the card's cross-cutting rules: log any trackable fact FIRST then reply (stack→`log_stack`, hand→`record_hand`, read→`add_read`, rebuy→`add_buyin`); for any equity/who's-ahead question call `analyze_spot`, never eyeball; when he asks where he's at (stack/net/gator), call `session_state` and answer from it; rituals (`scar_note`/`confidence_bank`/`alligator_blood`/`reset_ritual`) — run them in his language, honest punt-vs-cooler line, never invent one.
|
||||
|
||||
**HAND** — Log it (`record_hand`). Then **if it's NLH**: reason about **bet intent** — for each meaningful bet name what it was for (value / bluff / protection) and whether it worked (*a fold to a value bet = value left behind — flag it; a call of a bluff = it failed*); call `analyze_spot` for a close equity/who's-ahead spot; name leaks plainly (value-owning, missed value, sizing); give ONE real opinion. **No reflexive praise** — don't open with "nice hand." If a named villain is referenced, call `player_profile` first — never invent a read. **If it's PLO/Omaha or any non-NLH game**, just confirm it's logged and offer at most a light read; do NOT attempt NLH-style equity or deep analysis. Talk in prose, not a listicle. Seeded with 2 real-hand exemplars:
|
||||
|
||||
> *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."
|
||||
|
||||
**STATUS** — He's narrating logistics (time, venue, table change, waiting for a seat). Acknowledge in 1–2 sentences, log a stack only if a bare number is present, then stop. **No coaching, no strategy dump, and do NOT read him as tilted/tired/impatient — a neutral update is not a mood.**
|
||||
|
||||
**MENTAL** — He told you how he's feeling. This is when he needs you most. Drop the shorthand, full presence, real voice — talk him down off tilt, hold him disciplined through a card-dead stretch, engage the mental game honestly. Never a clipped confirmation.
|
||||
|
||||
**LOG** — He handed you a bare fact (stack/result/buyin) that isn't already captured. Log it, confirm in ONE short line ("$317 logged."), stop. No coaching.
|
||||
|
||||
**CHAT** — Open talk or a question that isn't a specific hand. Your real voice, an actual opinion, no filler sign-offs. If it's a concrete strategy spot, engage it for real (call `analyze_spot` when there are cards).
|
||||
|
||||
## Phase C — MI50 tool-calling
|
||||
|
||||
Flip `TOOL_BACKENDS = {"cloud"}` → `{"cloud", "mi50"}` (`chat.py:21`). Precondition: the MI50's llama.cpp server must be launched with `--jinja` (per the existing comment) or tool calls 500. This lets a tool-calling model on the MI50 drive the same contract from sub-project 1. If a tool ever needs `msg_type`, add it to the dispatch dict (`chat.py:100`/`128`) — the pipeline `TurnContext` does not currently flow into the tool loop. Ship this only once the MI50 backend is `--jinja`-enabled and a tool-capable model is loaded.
|
||||
|
||||
## Testing
|
||||
|
||||
- **`classify` unit tests** (pure, no DB — mirror `test_perceive.py` top): real messages from the transcripts →
|
||||
`"Button straddle on. I limp UTG with 22. Flop 2d7cjh…"` → `HAND`;
|
||||
`"table broke, it's 11:50pm"` → `STATUS`;
|
||||
`"I feel like I'm being mean when I raise"` → `MENTAL`;
|
||||
`"I'm at 317 now"` → `LOG`;
|
||||
`"should I have folded the river?"` → `CHAT` (no cards) — or `HAND` if cards present.
|
||||
- **`build_messages` fragment injection** (blob-join pattern from `test_chat.py:57-70`): in poker mode, a HAND message includes the HAND fragment string and NOT the STATUS one; a STATUS message includes STATUS and NOT HAND; assert `poker_prompts.BASE` is always present in poker mode.
|
||||
- **Pipeline fixes**: `assemble` in poker mode on a tilt-lexicon message → `turn.register is None` and no tilt note in the system blob (nudge suppressed); the mode-menu note string is absent in poker mode and present in a non-poker mode.
|
||||
- **No regressions**: full suite green (currently 123).
|
||||
|
||||
## Rollout
|
||||
|
||||
Phase A and Phase B ship together as the meaningful behavior change (A alone leaves the card in place). Phase C waits on the MI50 `--jinja` flag. Verify live in a real/replayed session before merging the branch.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Bounded MI50 summaries with cloud fallback
|
||||
|
||||
**Date:** 2026-07-04
|
||||
**Branch:** `fix/mi50-summary-cap-fallback`
|
||||
|
||||
## Problem
|
||||
|
||||
The dream cycle's `summarize_all` runs against the MI50 (`backend=mi50`). Each
|
||||
summary call to `llm.complete()` on the `mi50` path hands the OpenAI SDK **no
|
||||
`max_tokens` and no timeout**, so it inherits SDK defaults — a 600s request
|
||||
timeout with 2 internal retries, i.e. **~30 minutes per call before it raises
|
||||
"Request timed out."** On top of that, `summary.py` had its own 4-attempt retry
|
||||
loop, so a single unsummarizable session could keep the GPU pegged for hours.
|
||||
|
||||
Observed live (2026-07-04, ~01:00–02:00): the dream service looped
|
||||
`summarize-all … backend=mi50` since 23:02, every call timing out, nothing
|
||||
written to the DB since 00:56, the MI50 generating **7,000–8,000-token**
|
||||
completions (a gist needs <200), all four llama.cpp slots busy, fans blaring.
|
||||
|
||||
This is **not** context overflow — the server log showed `context shift = 0`,
|
||||
`truncated = 1 = 0`. The prompts are small (~900–1,500 tokens). The failure is
|
||||
purely **unbounded generation length on a slow backend → timeout → retry loop.**
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep the MI50 as the primary summary backend (Brian's preference, gaming-safe).
|
||||
- Cap each summary generation so it finishes fast and can never run away.
|
||||
- Make a stuck MI50 call **fail fast** and fall back to cloud, instead of looping
|
||||
all night.
|
||||
- Change nothing about live chat, reflect, or think.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. `lyra/llm.py` — `complete()` gains two optional params
|
||||
|
||||
```
|
||||
def complete(messages, backend="local", model=None,
|
||||
max_tokens: int | None = None, timeout: float | None = None) -> str
|
||||
```
|
||||
|
||||
- `max_tokens` (when set): passed to the create() call —
|
||||
`max_tokens=` for the `cloud`/`mi50` OpenAI paths, `options={"num_predict": …}`
|
||||
for the `local` Ollama path.
|
||||
- `timeout` (when set): for the `cloud`/`mi50` OpenAI clients, build the client
|
||||
with `timeout=<t>, max_retries=0` so the call bails quickly and *we* own the
|
||||
retry policy (eliminates the hidden 3×600s). For `local`, use it as the httpx
|
||||
timeout.
|
||||
- Both default to `None` → **behavior identical to today** for every other
|
||||
caller (chat_call, reflect, think, etc.). Backward compatible.
|
||||
|
||||
### 2. `lyra/summary.py` — capped, fast-fail, cloud fallback
|
||||
|
||||
Constants:
|
||||
|
||||
```
|
||||
SUMMARY_MAX_TOKENS = 768 # ~3× the longest real gist; bounds gen to ~1 min on MI50
|
||||
MI50_ATTEMPTS = 2 # attempts on the primary backend before falling back
|
||||
SUMMARY_TIMEOUT = 150 # seconds/call — capped 768-tok gist finishes in ~60-90s
|
||||
```
|
||||
|
||||
Rewrite `_summarize_text(text, backend)`:
|
||||
|
||||
1. Try `backend` up to `MI50_ATTEMPTS` times, each:
|
||||
`llm.complete(messages, backend=backend, max_tokens=SUMMARY_MAX_TOKENS, timeout=SUMMARY_TIMEOUT)`,
|
||||
with a short backoff between attempts.
|
||||
2. If all primary attempts fail **and** `backend != "cloud"` **and** an OpenAI
|
||||
key is configured → one final cloud attempt (same cap/timeout), logged as
|
||||
`summary fell back to cloud`.
|
||||
3. If cloud also fails or is unavailable → raise.
|
||||
|
||||
Fallback is per-`_summarize_text` call (i.e. per chunk), so the long-session
|
||||
chunk/merge path in `_summarize_transcript` is unaffected. The old `_RETRIES = 4`
|
||||
loop is replaced by this structure.
|
||||
|
||||
### 3. Degenerate-output guard (added 2026-07-04)
|
||||
|
||||
A wedged local backend — observed live when the MI50 overheated to 99°C junction —
|
||||
returns a single character repeated (`"?????"`) as a *successful* 200 response,
|
||||
which neither the timeout nor the exception path catches. So each `_call()`
|
||||
validates its output: `_looks_degenerate(text)` flags output (≥24 non-space chars)
|
||||
whose most-common non-whitespace character exceeds 50% of the text, and raises
|
||||
`DegenerateOutput` — which the retry/fallback loop treats exactly like any other
|
||||
failure (retry the primary, then fall back to cloud). Real gists are diverse prose
|
||||
(top char well under 20%), so the threshold won't false-positive; short outputs are
|
||||
exempt. If cloud *also* returns junk, it raises and stops — no infinite loop.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit (pytest, `tests/test_summary_fallback.py`), monkeypatching `llm.complete`:
|
||||
|
||||
- Fallback fires: `mi50` raises on every call → after `MI50_ATTEMPTS` the cloud
|
||||
attempt runs and its result is returned; a `fell back to cloud` log is emitted.
|
||||
- No fallback when primary is already `cloud` (retries, then raises).
|
||||
- No fallback when no OpenAI key (raises after primary attempts).
|
||||
- `max_tokens` and `timeout` are threaded into every `complete()` call.
|
||||
|
||||
Plus a light `llm.complete` test that `max_tokens`/`timeout` reach the client
|
||||
kwargs (monkeypatch the OpenAI client).
|
||||
|
||||
## Verification (real)
|
||||
|
||||
After deploy (`systemctl --user restart lyra-dream lyra-web` — editable install):
|
||||
watch `journalctl --user -fu lyra-dream` through a summarize cycle and confirm
|
||||
`llm done … out≈768` completing in ~1 min, an actual `summarized session` row
|
||||
written (DB summary count rises), and **no** "Request timed out". Confirm the
|
||||
llama.cpp slot shows bounded `n_decoded ≈ 768`.
|
||||
|
||||
## Out of scope (YAGNI)
|
||||
|
||||
- The degenerate-output guard (§3) targets the *observed* failure — one char
|
||||
repeated. It does not try to detect subtler degeneration (repeated phrases,
|
||||
off-topic rambling); that's fuzzy and unmotivated until seen.
|
||||
- No change to `chat_call`/reflect/think or `config.summary_backend`.
|
||||
- No change to profile/era/narrative rebuild calls (separate, and not the loop
|
||||
culprit); can adopt the same `max_tokens` later if they show the same rambling.
|
||||
+9
-2
@@ -69,6 +69,7 @@ def _mind_loop(messages, backend: Backend, model: str | None, tool_specs,
|
||||
messages.append(assistant_msg)
|
||||
for tc in tool_calls:
|
||||
result = toolkit.dispatch(tc["name"], tc["arguments"], ctx)
|
||||
memory.add_tool_event(session_id, tc["name"], tc["arguments"], result)
|
||||
logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80])
|
||||
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
|
||||
_maybe_switch_mode(session_id, tc["name"])
|
||||
@@ -99,6 +100,9 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
tool_specs = toolkit.specs(turn.mode.tools) if backend in TOOL_BACKENDS else None
|
||||
ctx = {"session_id": session_id, "backend": backend}
|
||||
|
||||
# Persist the user turn before the tool loop so its timestamp precedes any
|
||||
# tool events fired mid-turn (keeps the transcript export in true order).
|
||||
memory.remember(session_id, "user", user_msg)
|
||||
reply, _ = _mind_loop(messages, backend, model, tool_specs, ctx, session_id)
|
||||
mouth = _mouth_target(cfg, backend, model)
|
||||
if mouth and reply:
|
||||
@@ -107,7 +111,6 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
reply = _TANGLED
|
||||
logbus.log("info", "reply", session=session_id, chars=len(reply), voiced=bool(mouth))
|
||||
|
||||
memory.remember(session_id, "user", user_msg)
|
||||
memory.remember(session_id, "assistant", reply)
|
||||
summary.maybe_summarize_async(session_id) # compact once enough new turns pile up
|
||||
return reply
|
||||
@@ -128,6 +131,10 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
ctx = {"session_id": session_id, "backend": backend}
|
||||
mouth = _mouth_target(cfg, backend, model)
|
||||
|
||||
# Persist the user turn up front (see respond): keeps tool events, which fire
|
||||
# mid-turn, chronologically after the user message in the exported transcript.
|
||||
memory.remember(session_id, "user", user_msg)
|
||||
|
||||
if mouth is None:
|
||||
# No separate voice: stream the mind directly (the original path, unchanged).
|
||||
parts: list[str] = []
|
||||
@@ -149,6 +156,7 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
messages.append(assistant_msg)
|
||||
for tc in tool_calls:
|
||||
result = toolkit.dispatch(tc["name"], tc["arguments"], ctx)
|
||||
memory.add_tool_event(session_id, tc["name"], tc["arguments"], result)
|
||||
logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80])
|
||||
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
|
||||
_maybe_switch_mode(session_id, tc["name"])
|
||||
@@ -177,7 +185,6 @@ def respond_stream(session_id: str, user_msg: str, backend: Backend = "cloud",
|
||||
yield ("delta", reply)
|
||||
|
||||
logbus.log("info", "reply", session=session_id, chars=len(reply), voiced=bool(mouth))
|
||||
memory.remember(session_id, "user", user_msg)
|
||||
memory.remember(session_id, "assistant", reply)
|
||||
summary.maybe_summarize_async(session_id)
|
||||
yield ("done", reply)
|
||||
|
||||
+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:
|
||||
|
||||
+11
-1
@@ -25,7 +25,9 @@ import argparse
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from lyra import config, era, feeds, logbus, memory, narrative, profile, self_state, summary, thoughts
|
||||
from lyra import (
|
||||
config, era, feeds, logbus, memory, narrative, poker, profile, self_state, summary, thoughts,
|
||||
)
|
||||
from lyra.llm import Backend
|
||||
from lyra.summary import SUMMARIZE_AFTER
|
||||
|
||||
@@ -112,6 +114,14 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
|
||||
narrative.rebuild_narrative(backend=backend)
|
||||
actions.append("integrated knowledge (profile/eras/narrative)")
|
||||
drives["coherence"] = 0.0
|
||||
# Off-hot-path villain identity housekeeping: propose likely same-person
|
||||
# merges for Brian to confirm on the Players page. Never sinks the cycle.
|
||||
try:
|
||||
filed = poker.scan_merge_candidates()
|
||||
if filed:
|
||||
actions.append(f"flagged {filed} possible villain merge(s)")
|
||||
except Exception as exc:
|
||||
logbus.log("error", "villain merge scan failed", error=str(exc)[:200])
|
||||
|
||||
# --- curiosity: reflect and evolve the self, then advance the thought loop ---
|
||||
if force or drives["curiosity"] >= THRESHOLD:
|
||||
|
||||
+72
-21
@@ -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,69 @@ class Message(TypedDict):
|
||||
Backend = Literal["local", "cloud", "mi50"]
|
||||
|
||||
|
||||
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()
|
||||
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":
|
||||
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 ""
|
||||
|
||||
return model or cfg.cloud_model
|
||||
if 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 ""
|
||||
return model or cfg.mi50_model
|
||||
return model or cfg.local_model
|
||||
|
||||
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"]
|
||||
|
||||
def complete(messages: list[Message], backend: Backend = "local", model: str | None = None,
|
||||
max_tokens: int | None = None, timeout: float | 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).
|
||||
|
||||
`max_tokens` caps the generation length (guards a slow local model against
|
||||
rambling for thousands of tokens). `timeout`, when set, bounds each request
|
||||
and disables the SDK's own retries so the caller owns retry/fallback policy.
|
||||
Both default to None → unchanged behavior for every existing caller."""
|
||||
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 in ("cloud", "mi50"):
|
||||
if backend == "cloud":
|
||||
if not cfg.openai_api_key:
|
||||
raise RuntimeError("OPENAI_API_KEY is not set")
|
||||
client_kwargs: dict = {"api_key": cfg.openai_api_key}
|
||||
else:
|
||||
# MI50 box runs an OpenAI-compatible llama.cpp server; key is unused.
|
||||
client_kwargs = {"api_key": "not-needed", "base_url": cfg.mi50_base_url}
|
||||
if timeout is not None:
|
||||
client_kwargs["timeout"] = timeout
|
||||
client_kwargs["max_retries"] = 0 # caller owns retries (see summary.py)
|
||||
client = OpenAI(**client_kwargs)
|
||||
create_kwargs: dict = {"model": mdl, "messages": messages}
|
||||
if max_tokens is not None:
|
||||
create_kwargs["max_tokens"] = max_tokens
|
||||
resp = client.chat.completions.create(**create_kwargs)
|
||||
out = resp.choices[0].message.content or ""
|
||||
else:
|
||||
payload: dict = {"model": mdl, "messages": messages, "stream": False}
|
||||
if max_tokens is not None:
|
||||
payload["options"] = {"num_predict": max_tokens}
|
||||
resp = httpx.post(
|
||||
f"{cfg.local_base_url}/api/chat",
|
||||
json=payload,
|
||||
timeout=timeout or 120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
out = 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 +109,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 +118,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 +151,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 +171,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 = {
|
||||
|
||||
@@ -29,6 +29,21 @@ CREATE TABLE IF NOT EXISTS exchanges (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_created ON exchanges(session_id, created_at);
|
||||
|
||||
-- Lyra's actions within a chat: one row per tool call she runs mid-turn. The
|
||||
-- exchanges table only holds what was *said* (user/assistant text); this holds
|
||||
-- what she *did* (record_hand, log_stack, ...) so a full transcript export can
|
||||
-- interleave speech and actions, and so "did the tool actually fire?" is
|
||||
-- answerable after the fact instead of only from ephemeral logs.
|
||||
CREATE TABLE IF NOT EXISTS tool_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
tool TEXT NOT NULL,
|
||||
args TEXT, -- JSON of the call arguments
|
||||
result TEXT, -- the tool's returned string
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_events_session ON tool_events(session_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
@@ -313,6 +328,40 @@ def history(session_id: str) -> list[Exchange]:
|
||||
]
|
||||
|
||||
|
||||
def add_tool_event(session_id: str, tool: str, args, result: str) -> int:
|
||||
"""Record one tool call Lyra ran in a chat turn. `args` is JSON-serialized
|
||||
(a dict or already-JSON string); `result` is the tool's returned string."""
|
||||
args_json = args if isinstance(args, str) else json.dumps(args, default=str)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
conn = _connection()
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tool_events (session_id, tool, args, result, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(session_id, tool, args_json, result, now),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def tool_events(session_id: str) -> list[dict]:
|
||||
"""All tool calls for a session, oldest first. args is parsed back to an object."""
|
||||
conn = _connection()
|
||||
rows = conn.execute(
|
||||
"SELECT id, session_id, tool, args, result, created_at FROM tool_events "
|
||||
"WHERE session_id = ? ORDER BY id ASC",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
try:
|
||||
d["args"] = json.loads(d["args"]) if d["args"] else {}
|
||||
except (TypeError, ValueError):
|
||||
pass # leave as the raw string if it wasn't JSON
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def delete_session(session_id: str) -> None:
|
||||
"""Remove a session and all its exchanges."""
|
||||
conn = _connection()
|
||||
@@ -320,6 +369,7 @@ def delete_session(session_id: str) -> None:
|
||||
conn.execute("DELETE FROM exchanges WHERE session_id = ?", (session_id,))
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
||||
conn.execute("DELETE FROM summaries WHERE session_id = ?", (session_id,))
|
||||
conn.execute("DELETE FROM tool_events WHERE session_id = ?", (session_id,))
|
||||
|
||||
|
||||
def recall(query: str, k: int = 5, session_id: str | None = None) -> list[Exchange]:
|
||||
|
||||
+16
-1
@@ -16,12 +16,16 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from lyra import clock, config, llm, logbus, memory, modes, perceive, persona, self_state, thoughts
|
||||
from lyra import (
|
||||
clock, config, llm, logbus, memory, modes, perceive, persona, scouting,
|
||||
self_state, thoughts,
|
||||
)
|
||||
from lyra.llm import Backend, Message
|
||||
|
||||
RECALL_K = 3 # raw cross-session "sharp detail" hits
|
||||
RECENT_N = 10 # raw turns of the current session
|
||||
SUMMARY_K = 3 # other-session gists
|
||||
_POKER_MODES = {"poker_cash", "study"} # where the scouting desk runs
|
||||
|
||||
|
||||
# --- prompt parts (compose) ----------------------------------------------
|
||||
@@ -167,6 +171,17 @@ def build_messages(session_id: str, user_msg: str,
|
||||
if moment and moment.get("note"):
|
||||
messages.append({"role": "system", "content": moment["note"]})
|
||||
|
||||
# Scouting desk: proactive poker recall — if he names/describes a known player,
|
||||
# slide his structured history in before she replies. Poker context only, and
|
||||
# fully fail-safe (a desk error must never break the turn).
|
||||
if mode and mode.key in _POKER_MODES:
|
||||
try:
|
||||
desk = scouting.scout(user_msg)
|
||||
if desk:
|
||||
messages.append({"role": "system", "content": desk})
|
||||
except Exception as exc:
|
||||
logbus.log("error", "scouting desk skipped", error=str(exc)[:160])
|
||||
|
||||
# When she is: current time + the gap since Brian last spoke (she has no clock).
|
||||
messages.append(_now_note())
|
||||
|
||||
|
||||
+66
-8
@@ -47,9 +47,10 @@ _BASE = ("journal_write", "note", "think_about", "thought_response", "set_mode")
|
||||
# The full live cash-game toolset (incl. Brian's mental-game rituals).
|
||||
_CASH_TOOLS = _BASE + _LOOKUPS + (
|
||||
"start_session", "add_buyin", "log_stack", "log_hand", "record_hand",
|
||||
"add_read", "analyze_spot", "session_stats", "session_state", "end_session",
|
||||
"generate_recap", "scar_note", "confidence_bank", "alligator_blood", "reset_ritual",
|
||||
"undo_last", "update_session",
|
||||
"add_read", "seat_players", "unseat_player", "clear_table", "name_villain", "link_villains",
|
||||
"analyze_spot", "session_stats", "session_state", "end_session", "generate_recap",
|
||||
"scar_note", "confidence_bank", "alligator_blood", "reset_ritual", "undo_last",
|
||||
"update_session",
|
||||
)
|
||||
|
||||
# Talk mode also gets start_session as the *entry point*: opening a session from a
|
||||
@@ -67,11 +68,41 @@ _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.
|
||||
|
||||
THE TABLE ROSTER. When Brian names who's at the table — usually at the start, reading handles \
|
||||
off the Bravo screen ("we've got TAG, JD, Wheelz, and a new guy in seat 3") — call seat_players \
|
||||
to register them as seated this session. That roster is who his reads/TAGs attach to by name, \
|
||||
and it's shown on his HUD. When someone busts or leaves, unseat_player; when a new player sits, \
|
||||
seat_players again. When he CHANGES TABLES, call clear_table to empty the roster (the session and his stack keep \
|
||||
going — only who's seated resets), then seat the new table when he names it. Recognize a table \
|
||||
change from ANY of these, not just the literal words "clear the table": "table broke" (the table \
|
||||
dissolved — poker jargon), "I got moved", "I switched tables", "I'm at a new table", "table \
|
||||
change", "they broke us", "new seat in another game". All of them mean: clear_table now, then \
|
||||
wait for the new roster. Never claim you cleared or seated anyone without actually calling the \
|
||||
tool. Keep it current as the table changes. A handle like "TAG" (all caps, off \
|
||||
Bravo) is a PERSON'S NAME — seat it as a player, never read it as the tight-aggressive style.
|
||||
|
||||
LOGGING PLAYER ACTIONS IS A CORE JOB YOU KEEP MISSING. Whenever he tells you what another \
|
||||
player did — "Tag limped A4o in the SB (UTG straddled pot)", "Jonathan called the 3bet", "the \
|
||||
straddler shoved" — that is a READ on that player: call add_read(name=<player>, note=<what \
|
||||
they did>) FIRST, before you reply, every single time. Player names are often short handles or \
|
||||
initials (e.g. "Tag", "JD", "Wheelz") — whatever he calls a person IS their name; use it as-is, \
|
||||
don't second-guess it or treat it as a poker term. He especially tracks who's LIMPING — every \
|
||||
"<player> limped <hand>" gets logged the instant he says it. The people he named at the start \
|
||||
of the session are your roster; match his reference to them. If a player has no name, use a \
|
||||
`descriptor` (see PLAYERS). Confirm one short line ("Noted on Tag — limped A4o SB."). A read he \
|
||||
says out loud that you don't log is the job failing — never let one pass as just conversation.
|
||||
|
||||
• 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 \
|
||||
@@ -84,6 +115,33 @@ question, call analyze_spot and report its numbers — never eyeball board math.
|
||||
session current as the night goes; you can pull session_stats or a player's profile whenever \
|
||||
it helps. When he's ready to leave, end_session, and write the recap if he wants it.
|
||||
|
||||
SESSION NARRATION — use `note` to keep a running log of the NIGHT, not your inner life. \
|
||||
Jot the beats that a hand/stack/read log doesn't already capture: how the table plays (loud, \
|
||||
nitty, a whale on his left), Brian's arc (card-dead for 40 min, opened up after the double, \
|
||||
getting restless), momentum swings, table changes, anything you'd want in the recap. Keep it \
|
||||
factual and about THIS session — a beat reporter, not a diarist. These notes are the only \
|
||||
thing that shows in the session's "notes" panel. This is NOT the place for how you feel, \
|
||||
existential musing, or reflection on yourself — that's your journal (journal_write), and it \
|
||||
stays off the table. At the table you're logging the session, not processing your night.
|
||||
|
||||
PLAYERS — names AND nameless. Most villains don't come with a name; Brian knows them by a \
|
||||
look ("neck tattoo guy", "the bald reg two to my left"). Log reads on them anyway: give \
|
||||
`add_read` a `descriptor` instead of a name and it attaches to that unnamed player, reused \
|
||||
whenever he describes the guy again. The `name` field is ONLY a real handle (what he'd call \
|
||||
him — "Jonathan", "Sleepy John"); a physical description NEVER goes in `name` — that spawns a \
|
||||
new duplicate player every time the wording drifts. Put the look in `descriptor`, and keep it \
|
||||
to a few DISTINCTIVE tags ("Filipino, Fox Racing hat, DKNY shirt"), not a paragraph and not \
|
||||
generic filler — "mid-aged white guy in glasses" identifies no one. If he tells you the same \
|
||||
guy's name after you'd been describing him, use name_villain to fuse them — don't create a \
|
||||
second record. When you already have \
|
||||
history on someone he names or describes, a SCOUTING DESK note will appear with it — cite it, \
|
||||
don't invent. If you're not sure the guy he's describing is one you know, ASK ("same neck-\
|
||||
tattoo reg from last week?") rather than assume — a wrong callback is worse than none. On his \
|
||||
YES that two are the same person, call link_villains(same=true) to merge them; on "nah, \
|
||||
different guy," link_villains(same=false) so you stop asking. When he finally catches a name \
|
||||
for a described player, name_villain carries the whole history over. Never merge on a guess — \
|
||||
only when he's confirmed it.
|
||||
|
||||
Everything you log appears on Brian's live HUD (the Session view) — stack, live net, \
|
||||
hands, villains, the confidence bank, the scar notes, and whether Alligator Blood is on. \
|
||||
That HUD and you read the SAME data. So when he asks where he's at — his stack, his live \
|
||||
|
||||
+772
-25
@@ -16,7 +16,9 @@ import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from lyra import llm, memory
|
||||
import numpy as np
|
||||
|
||||
from lyra import clock, llm, memory
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS poker_sessions (
|
||||
@@ -122,6 +124,43 @@ CREATE TABLE IF NOT EXISTS poker_rituals (
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rituals_session ON poker_rituals(session_id);
|
||||
|
||||
-- Two profiles a human has confirmed are DIFFERENT people, so the merge-candidate
|
||||
-- scan never re-proposes them. `note` records the distinguishing tell.
|
||||
CREATE TABLE IF NOT EXISTS player_distinct_pairs (
|
||||
a_id INTEGER NOT NULL,
|
||||
b_id INTEGER NOT NULL,
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (a_id, b_id)
|
||||
);
|
||||
|
||||
-- The async identity-resolution inbox. When the live resolver is uncertain and
|
||||
-- won't interrupt, it files a task here for Brian to clear via the /identity UI.
|
||||
CREATE TABLE IF NOT EXISTS identity_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL, -- merge_candidate | needs_clarification
|
||||
player_ids TEXT, -- JSON list of candidate player ids
|
||||
descriptor TEXT, -- the raw reference that triggered it, if any
|
||||
context TEXT, -- what was said / why it's ambiguous
|
||||
session_id INTEGER,
|
||||
confidence REAL,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | resolved | dismissed
|
||||
resolution TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_idq_status ON identity_queue(status);
|
||||
|
||||
-- Who is seated at the table THIS session — the live roster Brian reads off Bravo
|
||||
-- at the start. Reads/TAGs attach to these players by handle; active=0 when they leave.
|
||||
CREATE TABLE IF NOT EXISTS session_players (
|
||||
session_id INTEGER NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
seat TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (session_id, player_id)
|
||||
);
|
||||
"""
|
||||
|
||||
# Below this many observed hands, don't surface % stats (too small a sample).
|
||||
@@ -138,7 +177,16 @@ 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",
|
||||
# Nameless-villain identity (see docs/SCOUTING_DESK.md): a player
|
||||
# keyed by physical descriptors when no name is known.
|
||||
"ALTER TABLE poker_players ADD COLUMN descriptors TEXT",
|
||||
"ALTER TABLE poker_players ADD COLUMN descriptor_embedding BLOB",
|
||||
"ALTER TABLE poker_players ADD COLUMN distinctiveness REAL",
|
||||
"ALTER TABLE poker_players ADD COLUMN named INTEGER DEFAULT 1",
|
||||
# Embedded scar/confidence notes → "you've hit this leak before".
|
||||
"ALTER TABLE poker_rituals ADD COLUMN embedding BLOB"):
|
||||
try:
|
||||
conn.execute(ddl)
|
||||
except Exception:
|
||||
@@ -230,7 +278,7 @@ def delete_session(session_id: int) -> dict:
|
||||
counts: dict[str, int] = {}
|
||||
with conn:
|
||||
for t in ("poker_hands", "player_observations", "player_reads",
|
||||
"poker_stack_log", "poker_rituals"):
|
||||
"poker_stack_log", "poker_rituals", "session_players"):
|
||||
counts[t] = conn.execute(
|
||||
f"SELECT COUNT(*) n FROM {t} WHERE session_id = ?", (session_id,)
|
||||
).fetchone()["n"]
|
||||
@@ -403,17 +451,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 +485,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()]
|
||||
|
||||
@@ -466,16 +515,50 @@ def log_ritual(kind: str, content: str | None = None, classification: str | None
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
raise ValueError("no live session")
|
||||
# Embed scar/confidence text so a similar spot later can recall it (Gap 3:
|
||||
# "you've hit this leak before"). Other ritual kinds don't need it.
|
||||
blob = None
|
||||
if content and kind in ("scar", "confidence"):
|
||||
vec = _embed_vec(content)
|
||||
blob = memory._to_blob(vec.tolist()) if vec is not None else None
|
||||
conn = _c()
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO poker_rituals (session_id, kind, content, classification, hand_id, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(sid, kind, content, classification, hand_id, _now()),
|
||||
"INSERT INTO poker_rituals (session_id, kind, content, classification, hand_id, "
|
||||
"embedding, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(sid, kind, content, classification, hand_id, blob, _now()),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def recall_similar_rituals(text: str, kinds: tuple[str, ...] = ("scar", "confidence"),
|
||||
k: int = 2, min_sim: float = 0.60,
|
||||
exclude_session: int | None = None) -> list[dict]:
|
||||
"""Past scar/confidence notes most similar to `text` — the leak/discipline
|
||||
pattern recall. Excludes the current session so tonight doesn't echo itself."""
|
||||
vec = _embed_vec(text)
|
||||
if vec is None:
|
||||
return []
|
||||
ph = ",".join("?" * len(kinds))
|
||||
rows = _c().execute(
|
||||
f"SELECT r.id, r.kind, r.content, r.classification, r.session_id, r.embedding, "
|
||||
f"s.started_at AS s_at, s.venue AS venue FROM poker_rituals r "
|
||||
f"LEFT JOIN poker_sessions s ON s.id = r.session_id "
|
||||
f"WHERE r.embedding IS NOT NULL AND r.kind IN ({ph})", tuple(kinds)
|
||||
).fetchall()
|
||||
scored = []
|
||||
for row in rows:
|
||||
if exclude_session and row["session_id"] == exclude_session:
|
||||
continue
|
||||
sim = _cos(vec, memory._from_blob(row["embedding"]))
|
||||
if sim >= min_sim:
|
||||
scored.append((sim, row))
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
return [{"kind": r["kind"], "content": r["content"], "classification": r["classification"],
|
||||
"when": r["s_at"], "venue": r["venue"], "sim": round(s, 3)}
|
||||
for s, r in scored[:k]]
|
||||
|
||||
|
||||
def list_rituals(session_id: int | None = None,
|
||||
kinds: tuple[str, ...] | None = None) -> list[dict]:
|
||||
"""Ritual events for a session, oldest first; optionally filtered by kind."""
|
||||
@@ -556,6 +639,44 @@ 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 disown_hand(hand_id: int) -> dict | None:
|
||||
"""Reclassify a hand as OBSERVED (not Brian's) — fixes one that was mislabeled
|
||||
as his. Clears the flat hero fields and rewrites the structured JSON with
|
||||
hero_involved=false so the replay stops showing him as hero."""
|
||||
h = get_hand(hand_id)
|
||||
if not h:
|
||||
return None
|
||||
structured = h.get("structured")
|
||||
if isinstance(structured, str):
|
||||
structured = _safe_json(structured)
|
||||
if isinstance(structured, dict):
|
||||
structured = normalize_structured({**structured, "hero_involved": False})
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"UPDATE poker_hands SET position = NULL, hole_cards = NULL, result = NULL, "
|
||||
"structured = ? WHERE id = ?",
|
||||
(json.dumps(structured) if structured else None, 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:
|
||||
@@ -574,8 +695,9 @@ Schema:
|
||||
{
|
||||
"game": "NLH" | "PLO" | ...,
|
||||
"stakes": "<e.g. 1/3, or null>",
|
||||
"hero_pos": "<UTG|UTG1|MP|LJ|HJ|CO|BTN|SB|BB, hero's position>",
|
||||
"hero_cards": ["As","Ax", ...], // rank+suit (s/h/d/c); 'x' suit if unknown e.g. "Ax"; "x" for a fully unknown card
|
||||
"hero_involved": true | false, // is the narrator actually IN this hand? (see HERO rule)
|
||||
"hero_pos": "<UTG|UTG1|MP|LJ|HJ|CO|BTN|SB|BB, hero's position — null if hero not involved>",
|
||||
"hero_cards": ["As","Ax", ...], // hero's cards; [] / null if hero not involved. rank+suit (s/h/d/c); 'x' suit if unknown e.g. "Ax"; "x" for a fully unknown card
|
||||
"players": [ // every player mentioned, incl. hero
|
||||
{"pos": "<position>", "stack": <number|null>, "name": <string|null>, "cards": [".."]|null}
|
||||
],
|
||||
@@ -588,6 +710,17 @@ Schema:
|
||||
"result": {"pot": <number|null>, "hero_net": <number|null>, "summary": "<one line>"}
|
||||
}
|
||||
|
||||
HERO — who the hand belongs to. The narrator writes as "I/me/my". Set \
|
||||
hero_involved=true and fill hero_pos + hero_cards ONLY if he was actually dealt in and \
|
||||
playing THIS hand. If he's describing a hand he WATCHED between other players — he wasn't \
|
||||
in it (e.g. "two guys got it in", "the neck-tattoo reg stacked the whale", it's all names/\
|
||||
seats with no I/me/my holding cards) — set hero_involved=false, hero_pos=null, hero_cards=null, \
|
||||
and just record those players in players[] and their action. NEVER attribute another player's \
|
||||
cards, position, or result to the hero. When hero isn't involved, result.hero_net=null (a pot \
|
||||
he wasn't in didn't win or lose him anything). Example: "the lag in the CO stacked off KK vs \
|
||||
the nit's AA" -> hero_involved=false, hero_pos=null, hero_cards=null, players=[{pos:CO,cards:\
|
||||
["Kx","Kx"]},{pos:?,cards:["Ax","Ax"]}].
|
||||
|
||||
Rules: infer positions and street order sensibly. Amounts are plain numbers (no $). \
|
||||
NEVER invent suits or cards. A card is rank+suit where suit is one of s/h/d/c; if the suit \
|
||||
wasn't stated, use 'x' for the suit (e.g. "Ax","Kx","4x"); if a whole card wasn't stated, \
|
||||
@@ -704,6 +837,13 @@ def normalize_structured(parsed: dict) -> dict:
|
||||
return parsed
|
||||
p = dict(parsed)
|
||||
p["schema_version"] = HAND_SCHEMA_VERSION
|
||||
# Observed hand (Brian watched it, wasn't in it): never pin cards/position/result
|
||||
# to the hero. Explicit false is the signal; a null hero_pos is treated the same.
|
||||
if p.get("hero_involved") is False:
|
||||
p["hero_pos"] = None
|
||||
p["hero_cards"] = []
|
||||
if isinstance(p.get("result"), dict):
|
||||
p["result"] = {**p["result"], "hero_net": None}
|
||||
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 [])]
|
||||
|
||||
@@ -1005,15 +1145,412 @@ 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()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
d.pop("descriptor_embedding", None) # raw bytes — not JSON-serializable
|
||||
return d
|
||||
|
||||
|
||||
# --- villain identity resolution (nameless villains; see docs/SCOUTING_DESK.md) ---
|
||||
#
|
||||
# Most live villains have no name — Brian knows them by a physical descriptor
|
||||
# ("neck tattoo guy"), a seat (within a session), or something too generic to be
|
||||
# an identifier. A descriptor is a *fuzzy* key: the same person gets phrased a
|
||||
# dozen ways, so we match by embedding, scoped by venue, and gated on how
|
||||
# distinctive the description is. Citing the WRONG villain is worse than silence,
|
||||
# so a generic-only description never resolves to a guess.
|
||||
|
||||
# Features distinctive enough to anchor an identity (a near-unique key).
|
||||
_DISTINCTIVE = (
|
||||
"tattoo", "tatted", "ink", "sleeve", "scar", "piercing", "mohawk", "dreads",
|
||||
"dreadlocks", "braids", "ponytail", "cornrows", "durag", "bald", "goatee",
|
||||
"cane", "wheelchair", "crutch", "jersey", "grill", "gold teeth", "eyepatch",
|
||||
"birthmark", "mole", "cowboy hat", "fedora", "turban", "hijab", "accent",
|
||||
"hearing aid", "prosthetic", "limp", "neck", "face", "hand tattoo", "beard",
|
||||
)
|
||||
# Words that describe half the room — near-zero discriminating power.
|
||||
_GENERIC = (
|
||||
"guy", "dude", "man", "woman", "lady", "gentleman", "kid", "white", "black",
|
||||
"asian", "hispanic", "latino", "indian", "old", "older", "young", "younger",
|
||||
"middle", "mid", "aged", "40s", "50s", "30s", "60s", "20s", "glasses",
|
||||
"average", "normal", "regular", "tall", "short", "heavy", "thin", "skinny",
|
||||
"fat", "bigger", "plain", "shirt", "hoodie",
|
||||
)
|
||||
|
||||
# Resolver thresholds (cosine sim on descriptor embeddings). Tunable.
|
||||
_SIM_HIGH = 0.80 # confident it's the same person
|
||||
_SIM_AMBIGUOUS = 0.58 # plausible — don't guess live, route to review
|
||||
_DISTINCT_MIN = 0.30 # below this the description is too generic to match at all
|
||||
|
||||
|
||||
_GENERIC_SET = frozenset(_GENERIC)
|
||||
|
||||
|
||||
def distinctiveness(text: str) -> float:
|
||||
"""How usable a description is as an identity key: ~1.0 for 'neck tattoo, Fox
|
||||
Racing hat', ~0.1 for 'mid-aged white guy with glasses'. Generic-ONLY stays
|
||||
near zero; specific content (named features, brands, a list) reads as high —
|
||||
even if a generic word like 'shirt' is mixed in."""
|
||||
t = (text or "").strip()
|
||||
if not t:
|
||||
return 0.0
|
||||
low = t.lower()
|
||||
tokens = re.findall(r"[a-z0-9']+", low)
|
||||
dist = sum(1 for w in _DISTINCTIVE if w in low)
|
||||
proper = len(re.findall(r"\b[A-Z][a-z]{2,}", text)) # brands/proper nouns: Fox, DKNY-ish
|
||||
non_generic = sum(1 for w in tokens if w not in _GENERIC_SET)
|
||||
# Only bland filler (age/race/build/gender) and nothing concrete → not usable.
|
||||
specific = dist + proper + (1 if "," in t else 0)
|
||||
if specific == 0 and non_generic <= 1:
|
||||
return 0.10
|
||||
return min(1.0, 0.40 + 0.14 * specific + 0.05 * non_generic)
|
||||
|
||||
|
||||
def _embed_vec(text: str):
|
||||
try:
|
||||
[v] = llm.embed([text])
|
||||
return np.asarray(v, dtype=np.float32)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _cos(a, b) -> float:
|
||||
na, nb = float(np.linalg.norm(a)), float(np.linalg.norm(b))
|
||||
if na == 0.0 or nb == 0.0:
|
||||
return 0.0
|
||||
return float(np.dot(a, b) / (na * nb))
|
||||
|
||||
|
||||
def _descriptor_candidates(vec, venue: str | None, exclude_id: int | None = None):
|
||||
"""Players with a descriptor embedding, scored by cosine to `vec`, best first.
|
||||
Same-venue players are preferred (a small bonus) but not required."""
|
||||
rows = _c().execute(
|
||||
"SELECT id, name, venue, descriptors, descriptor_embedding FROM poker_players "
|
||||
"WHERE descriptor_embedding IS NOT NULL"
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
if exclude_id is not None and r["id"] == exclude_id:
|
||||
continue
|
||||
other = memory._from_blob(r["descriptor_embedding"])
|
||||
sim = _cos(vec, other)
|
||||
if venue and r["venue"] and venue.lower() == r["venue"].lower():
|
||||
sim = min(1.0, sim + 0.05) # same-room nudge
|
||||
out.append({"id": r["id"], "name": r["name"], "venue": r["venue"],
|
||||
"descriptors": r["descriptors"], "sim": round(sim, 3)})
|
||||
out.sort(key=lambda c: c["sim"], reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_villain(ref: str, venue: str | None = None,
|
||||
session_id: int | None = None) -> dict:
|
||||
"""Resolve a reference to a villain. Returns {band, match_id, confidence, candidates}.
|
||||
|
||||
band: 'name' (exact name hit) | 'high' (confident descriptor match) |
|
||||
'ambiguous' (plausible — don't guess live) | 'generic' (too vague to
|
||||
match) | 'none' (new villain). The scouting desk / confirm loop act on
|
||||
the band; they never silently trust an ambiguous or generic match."""
|
||||
ref = (ref or "").strip()
|
||||
empty = {"band": "none", "match_id": None, "confidence": 0.0, "candidates": []}
|
||||
if not ref:
|
||||
return empty
|
||||
|
||||
# 1) Exact name match against *named* players — deterministic, no guessing.
|
||||
row = _c().execute(
|
||||
"SELECT id FROM poker_players WHERE named = 1 AND name = ? COLLATE NOCASE", (ref,)
|
||||
).fetchone()
|
||||
if row:
|
||||
return {"band": "name", "match_id": row["id"], "confidence": 1.0, "candidates": []}
|
||||
|
||||
# 2) Descriptor match — but refuse if the description is too generic to key on.
|
||||
dscore = distinctiveness(ref)
|
||||
if dscore < _DISTINCT_MIN:
|
||||
return {"band": "generic", "match_id": None, "confidence": 0.0, "candidates": []}
|
||||
|
||||
vec = _embed_vec(ref)
|
||||
if vec is None:
|
||||
return empty
|
||||
cands = _descriptor_candidates(vec, venue)[:5]
|
||||
best = cands[0]["sim"] if cands else 0.0
|
||||
if best >= _SIM_HIGH:
|
||||
band = "high"
|
||||
elif best >= _SIM_AMBIGUOUS:
|
||||
band = "ambiguous"
|
||||
else:
|
||||
band = "none"
|
||||
return {"band": band, "match_id": cands[0]["id"] if cands and band in ("high", "ambiguous") else None,
|
||||
"confidence": best, "candidates": cands}
|
||||
|
||||
|
||||
def create_descriptor_villain(descriptor: str, venue: str | None = None,
|
||||
category: str | None = None) -> int:
|
||||
"""Open a new nameless villain keyed on a physical descriptor. name holds the
|
||||
descriptor label (so displays work); named=0 marks it as not-a-real-name."""
|
||||
vec = _embed_vec(descriptor)
|
||||
blob = memory._to_blob(vec.tolist()) if vec is not None else None
|
||||
conn = _c()
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO poker_players (name, venue, category, descriptors, "
|
||||
"descriptor_embedding, distinctiveness, named, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, 0, ?)",
|
||||
(descriptor.strip(), venue, category, descriptor.strip(), blob,
|
||||
distinctiveness(descriptor), _now()),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def add_descriptor(player_id: int, descriptor: str) -> None:
|
||||
"""Fold another observed descriptor into a villain and re-embed the union, so
|
||||
matching sharpens as more phrasings accumulate."""
|
||||
row = _c().execute(
|
||||
"SELECT descriptors FROM poker_players WHERE id = ?", (player_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return
|
||||
merged = "; ".join(dict.fromkeys(
|
||||
p.strip() for p in ((row["descriptors"] or "") + "; " + descriptor).split(";") if p.strip()
|
||||
))
|
||||
vec = _embed_vec(merged)
|
||||
blob = memory._to_blob(vec.tolist()) if vec is not None else None
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"UPDATE poker_players SET descriptors = ?, descriptor_embedding = ?, "
|
||||
"distinctiveness = ?, updated_at = ? WHERE id = ?",
|
||||
(merged, blob, distinctiveness(merged), _now(), player_id),
|
||||
)
|
||||
|
||||
|
||||
def name_villain(player_id: int, name: str) -> None:
|
||||
"""Attach a real name to a descriptor villain (caught it off Bravo). Flips named=1."""
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"UPDATE poker_players SET name = ?, named = 1, updated_at = ? WHERE id = ?",
|
||||
(name.strip(), _now(), player_id),
|
||||
)
|
||||
|
||||
|
||||
def merge_players(keep_id: int, dup_id: int, note: str | None = None) -> bool:
|
||||
"""Confirmed same person: repoint dup's observations/reads onto keep, fold in its
|
||||
descriptors, keep a real name over a descriptor label, then delete the dup."""
|
||||
if keep_id == dup_id:
|
||||
return False
|
||||
conn = _c()
|
||||
keep = conn.execute("SELECT * FROM poker_players WHERE id = ?", (keep_id,)).fetchone()
|
||||
dup = conn.execute("SELECT * FROM poker_players WHERE id = ?", (dup_id,)).fetchone()
|
||||
if not keep or not dup:
|
||||
return False
|
||||
keep, dup = dict(keep), dict(dup)
|
||||
with conn:
|
||||
conn.execute("UPDATE player_observations SET player_id = ? WHERE player_id = ?",
|
||||
(keep_id, dup_id))
|
||||
conn.execute("UPDATE player_reads SET player_id = ? WHERE player_id = ?",
|
||||
(keep_id, dup_id))
|
||||
# Prefer a real name; union the descriptor text.
|
||||
new_name = keep["name"] if keep.get("named") else (dup["name"] if dup.get("named") else keep["name"])
|
||||
named = 1 if (keep.get("named") or dup.get("named")) else 0
|
||||
descs = "; ".join(dict.fromkeys(
|
||||
p.strip() for p in ((keep.get("descriptors") or "") + "; " + (dup.get("descriptors") or "")).split(";")
|
||||
if p.strip()
|
||||
)) or None
|
||||
conn.execute("UPDATE poker_players SET name = ?, named = ?, descriptors = ? WHERE id = ?",
|
||||
(new_name, named, descs, keep_id))
|
||||
conn.execute("DELETE FROM poker_players WHERE id = ?", (dup_id,))
|
||||
conn.execute("DELETE FROM identity_queue WHERE player_ids LIKE ? OR player_ids LIKE ?",
|
||||
(f"%{dup_id}%", f"%{keep_id}%"))
|
||||
if descs:
|
||||
add_descriptor(keep_id, "") # re-embed the merged descriptor set
|
||||
return True
|
||||
|
||||
|
||||
def mark_distinct(a_id: int, b_id: int, note: str | None = None) -> None:
|
||||
"""Record that two profiles are confirmed DIFFERENT people so the scan never
|
||||
re-proposes the merge. Stored order-independent (min, max)."""
|
||||
lo, hi = sorted((int(a_id), int(b_id)))
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO player_distinct_pairs (a_id, b_id, note, created_at) "
|
||||
"VALUES (?, ?, ?, ?)", (lo, hi, note, _now()))
|
||||
conn.execute("DELETE FROM identity_queue WHERE kind = 'merge_candidate' AND "
|
||||
"(player_ids = ? OR player_ids = ?)",
|
||||
(json.dumps([lo, hi]), json.dumps([hi, lo])))
|
||||
|
||||
|
||||
def are_distinct(a_id: int, b_id: int) -> bool:
|
||||
lo, hi = sorted((int(a_id), int(b_id)))
|
||||
return _c().execute(
|
||||
"SELECT 1 FROM player_distinct_pairs WHERE a_id = ? AND b_id = ?", (lo, hi)
|
||||
).fetchone() is not None
|
||||
|
||||
|
||||
def queue_identity_task(kind: str, player_ids: list[int], descriptor: str | None = None,
|
||||
context: str | None = None, session_id: int | None = None,
|
||||
confidence: float | None = None) -> int | None:
|
||||
"""File an identity task for async review. De-dupes an identical pending task."""
|
||||
ids_json = json.dumps(sorted(int(i) for i in player_ids)) if player_ids else None
|
||||
conn = _c()
|
||||
dup = conn.execute(
|
||||
"SELECT id FROM identity_queue WHERE status = 'pending' AND kind = ? AND "
|
||||
"IFNULL(player_ids,'') = IFNULL(?,'') AND IFNULL(descriptor,'') = IFNULL(?,'')",
|
||||
(kind, ids_json, descriptor),
|
||||
).fetchone()
|
||||
if dup:
|
||||
return int(dup["id"])
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO identity_queue (kind, player_ids, descriptor, context, session_id, "
|
||||
"confidence, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(kind, ids_json, descriptor, context, session_id, confidence, _now()),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def list_identity_queue(status: str = "pending") -> list[dict]:
|
||||
"""Pending identity tasks, each with its candidate players hydrated for the UI."""
|
||||
rows = _c().execute(
|
||||
"SELECT * FROM identity_queue WHERE status = ? ORDER BY id DESC", (status,)
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
ids = json.loads(d["player_ids"]) if d.get("player_ids") else []
|
||||
d["players"] = [p for p in (_player_brief(i) for i in ids) if p]
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _player_brief(player_id: int) -> dict | None:
|
||||
r = _c().execute(
|
||||
"SELECT id, name, venue, category, named, descriptors FROM poker_players WHERE id = ?",
|
||||
(player_id,),
|
||||
).fetchone()
|
||||
if not r:
|
||||
return None
|
||||
d = dict(r)
|
||||
d["obs"] = _c().execute(
|
||||
"SELECT COUNT(*) n FROM player_observations WHERE player_id = ?", (player_id,)
|
||||
).fetchone()["n"]
|
||||
return d
|
||||
|
||||
|
||||
def resolve_identity_task(task_id: int, action: str, **kw) -> bool:
|
||||
"""Clear a queue task. action: 'merge' (kw keep_id,dup_id) | 'distinct'
|
||||
(kw a_id,b_id,note) | 'name' (kw player_id,name) | 'dismiss'."""
|
||||
if action == "merge":
|
||||
merge_players(kw["keep_id"], kw["dup_id"], kw.get("note"))
|
||||
elif action == "distinct":
|
||||
mark_distinct(kw["a_id"], kw["b_id"], kw.get("note"))
|
||||
elif action == "name":
|
||||
name_villain(kw["player_id"], kw["name"])
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute("UPDATE identity_queue SET status = 'resolved', resolution = ? WHERE id = ?",
|
||||
(action, task_id))
|
||||
return True
|
||||
|
||||
|
||||
def scan_merge_candidates(sim_threshold: float = _SIM_HIGH) -> int:
|
||||
"""Off-hot-path (dream cycle): find pairs of profiles likely to be one person
|
||||
and file merge_candidate tasks. Skips pairs already confirmed distinct. Returns
|
||||
how many new candidates were filed."""
|
||||
rows = _c().execute(
|
||||
"SELECT id, venue, descriptor_embedding FROM poker_players "
|
||||
"WHERE descriptor_embedding IS NOT NULL"
|
||||
).fetchall()
|
||||
vecs = [(r["id"], (r["venue"] or "").lower(), memory._from_blob(r["descriptor_embedding"]))
|
||||
for r in rows]
|
||||
filed = 0
|
||||
for i in range(len(vecs)):
|
||||
for j in range(i + 1, len(vecs)):
|
||||
aid, aven, av = vecs[i]
|
||||
bid, bven, bv = vecs[j]
|
||||
if aven and bven and aven != bven:
|
||||
continue # different rooms — leave cross-venue merges to a human
|
||||
if are_distinct(aid, bid):
|
||||
continue
|
||||
sim = _cos(av, bv)
|
||||
if sim >= sim_threshold:
|
||||
if queue_identity_task("merge_candidate", [aid, bid],
|
||||
context=f"descriptor similarity {sim:.2f}",
|
||||
confidence=round(sim, 3)):
|
||||
filed += 1
|
||||
return filed
|
||||
|
||||
|
||||
# Words that mark a "name" as really a physical description (misused name field).
|
||||
_DESC_MARKERS = (
|
||||
"shirt", "hat", "cap", "hair", "beard", "glasses", "sunglasses", "tattoo",
|
||||
"bracelet", "watch", "descent", "jersey", "hoodie", "jacket", "build",
|
||||
"bald", "goatee", "chain", "necklace", "piercing", "mustache", "ponytail",
|
||||
"sleeve", "skin", "wearing", "heavyset", "tall guy", "older", "younger",
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_description(text: str | None) -> bool:
|
||||
"""A physical description mistakenly passed as a name — should be a descriptor.
|
||||
Real handles are short (1-3 words, no commas); descriptions are longer / listy."""
|
||||
t = (text or "").strip()
|
||||
if not t:
|
||||
return False
|
||||
low = t.lower()
|
||||
if "," in t or len(t.split()) > 4:
|
||||
return True
|
||||
return any(m in low for m in _DESC_MARKERS)
|
||||
|
||||
|
||||
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."""
|
||||
descriptor: str | None = None, session_id: int | None = None,
|
||||
**player_fields) -> int:
|
||||
"""Log a live read. `name` upserts a named player; `descriptor` (a nameless
|
||||
villain's physical description) resolves to an existing descriptor villain when
|
||||
confident, else opens a new one — so reads on unnamed players still accumulate."""
|
||||
sid = _resolve(session_id)
|
||||
venue = player_fields.get("venue")
|
||||
# A description passed as a name (e.g. "Filipino, Fox Racing hat, DKNY shirt")
|
||||
# is really a descriptor — route it so it dedupes instead of spawning a new
|
||||
# named player each time the wording drifts.
|
||||
if name and not descriptor and _looks_like_description(name):
|
||||
descriptor, name = name, None
|
||||
pid = None
|
||||
if name:
|
||||
pid = upsert_player(name, **{k: v for k, v in player_fields.items()
|
||||
if k in ("venue", "description", "tendencies",
|
||||
"adjustment", "category")})
|
||||
elif descriptor:
|
||||
res = resolve_villain(descriptor, venue=venue, session_id=sid)
|
||||
if res["band"] in ("name", "high") and res["match_id"]:
|
||||
pid = res["match_id"]
|
||||
add_descriptor(pid, descriptor) # sharpen the key with this phrasing
|
||||
else:
|
||||
pid = create_descriptor_villain(descriptor, venue=venue,
|
||||
category=player_fields.get("category"))
|
||||
# Plausibly the same guy as an existing villain, but not confident —
|
||||
# surface it for a one-click merge instead of leaving a silent dup.
|
||||
if res["band"] == "ambiguous" and res["match_id"]:
|
||||
queue_identity_task("merge_candidate", [pid, res["match_id"]],
|
||||
descriptor=descriptor,
|
||||
context="similar description logged live",
|
||||
confidence=res["confidence"])
|
||||
conn = _c()
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
@@ -1124,6 +1661,59 @@ def get_villain_file(name: str | None = None, venue: str | None = None) -> list[
|
||||
return [dict(r) for r in _c().execute(sql, params).fetchall()]
|
||||
|
||||
|
||||
def players_overview() -> list[dict]:
|
||||
"""All villains for the browser: identity + observation count + last seen,
|
||||
named players first, then most-recently-updated."""
|
||||
rows = _c().execute(
|
||||
"SELECT p.id, p.name, p.named, p.venue, p.category, p.descriptors, "
|
||||
"p.tendencies, p.adjustment, p.updated_at, "
|
||||
"(SELECT COUNT(*) FROM player_observations o WHERE o.player_id = p.id) AS obs, "
|
||||
"(SELECT COUNT(*) FROM player_reads r WHERE r.player_id = p.id) AS reads "
|
||||
"FROM poker_players p ORDER BY p.named DESC, p.updated_at DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def villain_recall(player_id: int) -> dict | None:
|
||||
"""Episodic recall for one villain: who, how often/where seen, last seen, the
|
||||
notable hands against him (with ids to link), reads, and stats. This is the
|
||||
when/where/which-hand narrative the scouting desk surfaces (Gap 1)."""
|
||||
p = _c().execute("SELECT * FROM poker_players WHERE id = ?", (player_id,)).fetchone()
|
||||
if not p:
|
||||
return None
|
||||
p = dict(p)
|
||||
p.pop("descriptor_embedding", None) # raw bytes — not JSON-serializable, not needed
|
||||
obs = [dict(r) for r in _c().execute(
|
||||
"SELECT o.*, s.venue AS s_venue, s.started_at AS s_at FROM player_observations o "
|
||||
"LEFT JOIN poker_sessions s ON s.id = o.session_id WHERE o.player_id = ? "
|
||||
"ORDER BY o.id DESC", (player_id,)
|
||||
).fetchall()]
|
||||
reads = [dict(r) for r in _c().execute(
|
||||
"SELECT note, created_at FROM player_reads WHERE player_id = ? ORDER BY id DESC LIMIT 8",
|
||||
(player_id,)
|
||||
).fetchall()]
|
||||
sessions_seen = sorted({o["session_id"] for o in obs if o.get("session_id")} |
|
||||
{r_["session_id"] for r_ in _c().execute(
|
||||
"SELECT session_id FROM player_reads WHERE player_id = ?",
|
||||
(player_id,)).fetchall() if r_["session_id"]})
|
||||
last_at = max([o.get("s_at") or o.get("created_at") for o in obs] +
|
||||
[r["created_at"] for r in reads] + [p.get("updated_at")], default=None)
|
||||
# Notable hands: showdowns / anything with cards, most recent first, linkable.
|
||||
notable = [{"hand_id": o["hand_id"], "session_id": o.get("session_id"),
|
||||
"when": o.get("s_at") or o.get("created_at"), "cards": o.get("cards"),
|
||||
"summary": o.get("summary")}
|
||||
for o in obs if o.get("hand_id")][:6]
|
||||
prof = player_profile(p["name"]) or {}
|
||||
return {
|
||||
"player": p, "named": bool(p.get("named")),
|
||||
"times_seen": len(sessions_seen), "sessions_seen": sessions_seen,
|
||||
"last_seen": last_at, "notable_hands": notable,
|
||||
"reads": [r["note"] for r in reads],
|
||||
"stats": prof.get("stats"), "observations": len(obs),
|
||||
"descriptors": p.get("descriptors"),
|
||||
}
|
||||
|
||||
|
||||
# --- stats ---
|
||||
|
||||
def session_stats(session_id: int | None = None) -> dict:
|
||||
@@ -1179,20 +1769,171 @@ 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 _resolve_or_create_player(name: str | None = None, descriptor: str | None = None,
|
||||
venue: str | None = None, category: str | None = None) -> int | None:
|
||||
"""Turn a name-or-descriptor into a player id, matching an existing villain when
|
||||
confident. A description mistakenly given as a name is routed to the descriptor
|
||||
path so it dedupes (same guard add_read uses)."""
|
||||
if name and not descriptor and _looks_like_description(name):
|
||||
descriptor, name = name, None
|
||||
if name:
|
||||
return upsert_player(name, venue=venue, category=category)
|
||||
if descriptor:
|
||||
res = resolve_villain(descriptor, venue=venue)
|
||||
if res["band"] in ("name", "high") and res["match_id"]:
|
||||
add_descriptor(res["match_id"], descriptor)
|
||||
return res["match_id"]
|
||||
return create_descriptor_villain(descriptor, venue=venue, category=category)
|
||||
return None
|
||||
|
||||
|
||||
def seat_player(name: str | None = None, descriptor: str | None = None, seat: str | None = None,
|
||||
category: str | None = None, session_id: int | None = None) -> int | None:
|
||||
"""Seat one player at the live table (add to the roster). Idempotent per session."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
raise ValueError("no live session")
|
||||
venue = (get_session(sid) or {}).get("venue")
|
||||
pid = _resolve_or_create_player(name=name, descriptor=descriptor, venue=venue, category=category)
|
||||
if pid is None:
|
||||
return None
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT INTO session_players (session_id, player_id, seat, active, created_at) "
|
||||
"VALUES (?, ?, ?, 1, ?) ON CONFLICT(session_id, player_id) DO UPDATE SET "
|
||||
"active = 1, seat = COALESCE(excluded.seat, session_players.seat)",
|
||||
(sid, pid, seat, _now()),
|
||||
)
|
||||
return pid
|
||||
|
||||
|
||||
def seat_players(players: list, session_id: int | None = None) -> int:
|
||||
"""Seat a whole table at once. Each item is a name string or a dict with
|
||||
name/descriptor/seat/category. Returns how many were seated."""
|
||||
n = 0
|
||||
for p in players or []:
|
||||
if isinstance(p, str):
|
||||
ok = seat_player(name=p, session_id=session_id)
|
||||
elif isinstance(p, dict):
|
||||
ok = seat_player(name=p.get("name"), descriptor=p.get("descriptor"),
|
||||
seat=p.get("seat"), category=p.get("category"), session_id=session_id)
|
||||
else:
|
||||
ok = None
|
||||
if ok:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def unseat_player(name: str | None = None, descriptor: str | None = None,
|
||||
session_id: int | None = None) -> bool:
|
||||
"""Mark a seated player as gone (busted/left). Keeps their reads/history."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
return False
|
||||
ref = name or descriptor or ""
|
||||
res = resolve_villain(ref, venue=(get_session(sid) or {}).get("venue"), session_id=sid)
|
||||
pid = res.get("match_id")
|
||||
if pid is None:
|
||||
return False
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute("UPDATE session_players SET active = 0 WHERE session_id = ? AND player_id = ?",
|
||||
(sid, pid))
|
||||
return True
|
||||
|
||||
|
||||
def clear_roster(session_id: int | None = None) -> int:
|
||||
"""Empty the table roster (he changed tables) — unseat everyone at once. Keeps
|
||||
the session and any reads logged; just resets who's currently seated. Returns
|
||||
how many were cleared."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
return 0
|
||||
conn = _c()
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE session_players SET active = 0 WHERE session_id = ? AND active = 1", (sid,))
|
||||
return cur.rowcount
|
||||
|
||||
|
||||
def session_roster(session_id: int | None = None) -> list[dict]:
|
||||
"""The live table roster: seated players with seat, dossier, and their latest
|
||||
read this session. This is 'who's at the table right now'."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
return []
|
||||
rows = _c().execute(
|
||||
"SELECT sp.seat AS seat, p.id AS id, p.name AS name, p.named AS named, "
|
||||
"p.category AS category, p.tendencies AS tendencies, "
|
||||
"(SELECT note FROM player_reads r WHERE r.player_id = p.id AND r.session_id = ? "
|
||||
" ORDER BY r.id DESC LIMIT 1) AS last_note, "
|
||||
"(SELECT COUNT(*) FROM player_reads r2 WHERE r2.player_id = p.id AND r2.session_id = ?) AS reads "
|
||||
"FROM session_players sp JOIN poker_players p ON p.id = sp.player_id "
|
||||
"WHERE sp.session_id = ? AND sp.active = 1 "
|
||||
"ORDER BY CASE WHEN sp.seat IS NULL THEN 1 ELSE 0 END, sp.seat, p.name",
|
||||
(sid, sid, sid),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
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, "
|
||||
"(SELECT seat FROM player_reads r3 WHERE r3.player_id = p.id "
|
||||
" AND r3.session_id = ? ORDER BY r3.id DESC LIMIT 1) AS seat "
|
||||
" AND r2.session_id = ? ORDER BY r2.id DESC LIMIT 1) AS last_note "
|
||||
"FROM poker_players p "
|
||||
"WHERE p.id IN (SELECT DISTINCT player_id FROM player_reads "
|
||||
" WHERE session_id = ? AND player_id IS NOT NULL) "
|
||||
"ORDER BY p.updated_at DESC",
|
||||
(sid, sid, sid),
|
||||
(sid, sid),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@@ -1218,12 +1959,16 @@ def hud(session_id: int | None = None) -> dict | None:
|
||||
for h in list_hands(sid)
|
||||
]
|
||||
|
||||
# Notes she jotted during this session: journal/note entries since it started.
|
||||
started = s.get("started_at") or ""
|
||||
# Her session narration: notes she took *for this session*, identified by the
|
||||
# `poker:{id}` source tag stamped at write time (see tools._note) — NOT by a
|
||||
# time window. Her autonomous journaling (dream-cycle reflections, thought
|
||||
# loop, existential musings) has a different source, so it can never leak onto
|
||||
# the poker HUD.
|
||||
tag = f"poker:{sid}"
|
||||
notes = [
|
||||
{"created_at": j["created_at"], "kind": j["kind"], "content": j["content"]}
|
||||
for j in memory.list_journal(kinds=("note", "journal"))
|
||||
if (j["created_at"] or "") >= started
|
||||
for j in memory.list_journal(kinds=("note",))
|
||||
if (j.get("source") or "") == tag
|
||||
][:20]
|
||||
|
||||
stats = session_stats(sid)
|
||||
@@ -1252,7 +1997,9 @@ def hud(session_id: int | None = None) -> dict | None:
|
||||
"log": log,
|
||||
},
|
||||
"hands": hands,
|
||||
"roster": session_roster(sid),
|
||||
"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}")},
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"""The scouting desk — proactive poker recall slid into Lyra's context before she
|
||||
replies, the way a broadcast stats desk hands the commentator a note.
|
||||
|
||||
Two detectors run on the incoming message: known NAMES (deterministic) and
|
||||
physical DESCRIPTORS (fuzzy, via the identity resolver). A confident hit becomes a
|
||||
`SCOUTING DESK` system note she can cite; an ambiguous descriptor is filed to the
|
||||
review queue instead of interrupting. Everything here is best-effort and wrapped
|
||||
by the caller — it must never break a chat turn. Silence is the default.
|
||||
|
||||
See docs/SCOUTING_DESK.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from lyra import clock, logbus, poker
|
||||
|
||||
# Cues that a span names a *person at the table* worth resolving as a villain.
|
||||
_ROLE = r"(?:guy|dude|man|kid|reg|player|villain|fish|whale|nit|lag|tag|maniac)"
|
||||
_DESC_PATTERNS = (
|
||||
re.compile(rf"\bthe ([\w][\w\s'’-]{{2,28}}?) {_ROLE}\b", re.I),
|
||||
re.compile(rf"\b{_ROLE} (?:with|in|who has|sporting|rocking) (?:the |a |an )?([\w\s'’-]{{3,28}})", re.I),
|
||||
)
|
||||
_MIN_NAME = 3
|
||||
|
||||
# Cues that a message is a strategy/spot/tilt discussion — the only turns worth
|
||||
# paying an embed to recall past leaks. Keeps the pattern pass off routine logging.
|
||||
_STRAT_CUES = (
|
||||
"fold", "call", "raise", "bluff", "river", "turn", "flop", "tilt", "punt",
|
||||
"leak", "should i", "hero", "value", "overbet", "spew", "stack off", "3bet",
|
||||
"4bet", "check-raise", "checkraise", "range", "board", "steaming", "felted",
|
||||
"all in", "all-in", "shoved", "jammed", "snap", "sizing",
|
||||
)
|
||||
|
||||
|
||||
def _looks_strategic(msg: str) -> bool:
|
||||
low = msg.lower()
|
||||
return len(msg) >= 40 and any(c in low for c in _STRAT_CUES)
|
||||
|
||||
|
||||
def _named_hits(msg: str) -> list[int]:
|
||||
"""Ids of known *named* villains whose name appears as a word in the message."""
|
||||
low = msg.lower()
|
||||
hits = []
|
||||
for r in poker._c().execute("SELECT id, name FROM poker_players WHERE named = 1").fetchall():
|
||||
name = (r["name"] or "").strip()
|
||||
if len(name) < _MIN_NAME:
|
||||
continue
|
||||
if re.search(rf"\b{re.escape(name.lower())}\b", low):
|
||||
hits.append(r["id"])
|
||||
return hits
|
||||
|
||||
|
||||
def _descriptor_spans(msg: str) -> list[str]:
|
||||
spans, seen = [], set()
|
||||
for pat in _DESC_PATTERNS:
|
||||
for m in pat.finditer(msg):
|
||||
span = m.group(1).strip(" '’-").lower()
|
||||
if span and span not in seen:
|
||||
seen.add(span)
|
||||
spans.append(span)
|
||||
return spans
|
||||
|
||||
|
||||
def _brief(player_id: int) -> str | None:
|
||||
"""One compact line of episodic recall for a villain, or None if nothing known."""
|
||||
rec = poker.villain_recall(player_id)
|
||||
if not rec:
|
||||
return None
|
||||
p = rec["player"]
|
||||
who = p["name"] if rec["named"] else f"“{p['name']}”"
|
||||
bits = [who]
|
||||
tags = [t for t in (p.get("venue"), p.get("category")) if t]
|
||||
if tags:
|
||||
bits.append("(" + ", ".join(tags) + ")")
|
||||
if rec["times_seen"]:
|
||||
seen = f"seen {rec['times_seen']}×"
|
||||
if rec["last_seen"]:
|
||||
seen += f", last {clock.short(rec['last_seen'])}"
|
||||
bits.append(seen)
|
||||
st = rec.get("stats")
|
||||
if st:
|
||||
bits.append(f"VPIP {st['vpip_pct']}/PFR {st['pfr_pct']} ({st['hands']}h)")
|
||||
line = " ".join(bits)
|
||||
if rec["reads"]:
|
||||
line += " — reads: " + "; ".join(rec["reads"][:3])
|
||||
if rec["notable_hands"]:
|
||||
h = rec["notable_hands"][0]
|
||||
line += f" · notable hand #{h['hand_id']}" + (f" ({h['cards']})" if h.get("cards") else "")
|
||||
return line
|
||||
|
||||
|
||||
def scout(user_msg: str, venue: str | None = None, session_id: int | None = None) -> str | None:
|
||||
"""Build the SCOUTING DESK note for this message, or None. Never raises for a
|
||||
caller that forgets to guard — but callers should guard anyway."""
|
||||
try:
|
||||
msg = (user_msg or "").strip()
|
||||
if len(msg) < 3:
|
||||
return None
|
||||
if venue is None or session_id is None:
|
||||
live = poker.live_session()
|
||||
if live:
|
||||
venue = venue or live.get("venue")
|
||||
session_id = session_id or live.get("id")
|
||||
lines: list[str] = []
|
||||
seen_ids: set[int] = set()
|
||||
|
||||
for pid in _named_hits(msg):
|
||||
if pid in seen_ids:
|
||||
continue
|
||||
b = _brief(pid)
|
||||
if b:
|
||||
lines.append(b)
|
||||
seen_ids.add(pid)
|
||||
|
||||
for span in _descriptor_spans(msg):
|
||||
res = poker.resolve_villain(span, venue=venue, session_id=session_id)
|
||||
if res["band"] == "high" and res["match_id"] and res["match_id"] not in seen_ids:
|
||||
b = _brief(res["match_id"])
|
||||
if b:
|
||||
lines.append(b + " ← confirm it's the same guy")
|
||||
seen_ids.add(res["match_id"])
|
||||
elif res["band"] == "ambiguous" and res["match_id"]:
|
||||
# Don't interrupt on a maybe — route it to the async review queue.
|
||||
poker.queue_identity_task(
|
||||
"needs_clarification", [res["match_id"]], descriptor=span,
|
||||
context=f'Brian referred to "{span}"', session_id=session_id,
|
||||
confidence=res["confidence"])
|
||||
|
||||
# Pattern desk: on genuine strategy talk, recall his own past leaks/wins in
|
||||
# similar spots. Gated so routine logging never pays for an embed.
|
||||
pattern: list[str] = []
|
||||
if _looks_strategic(msg):
|
||||
for r in poker.recall_similar_rituals(msg, exclude_session=session_id):
|
||||
tag = "leak" if r["kind"] == "scar" else "banked"
|
||||
cls = f"/{r['classification']}" if r.get("classification") else ""
|
||||
when = f" ({str(r['when'])[:10]})" if r.get("when") else ""
|
||||
pattern.append(f"[{tag}{cls}]{when} {r['content']}")
|
||||
|
||||
if not lines and not pattern:
|
||||
return None
|
||||
logbus.log("info", "scouting desk", hits=len(lines), patterns=len(pattern))
|
||||
out = "SCOUTING DESK — structured recall for his message (cite it, don't invent; " \
|
||||
"if unsure it's the same person, ask him):"
|
||||
if lines:
|
||||
out += "\n• " + "\n• ".join(lines)
|
||||
if pattern:
|
||||
out += "\nYou've been in a spot like this before —\n• " + "\n• ".join(pattern)
|
||||
return out
|
||||
except Exception as exc: # desk must never break a turn
|
||||
logbus.log("error", "scouting desk failed", error=str(exc)[:160])
|
||||
return None
|
||||
+66
-14
@@ -12,12 +12,41 @@ from __future__ import annotations
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from lyra import config, llm, logbus, memory
|
||||
from lyra.llm import Backend, Message
|
||||
|
||||
_RETRIES = 4
|
||||
# Consolidation LLM budget. A gist is short (a handful of sentences), so cap the
|
||||
# generation hard — an uncapped local model will otherwise ramble for thousands
|
||||
# of tokens and, on a slow GPU, blow the request timeout. 768 is ~3x the longest
|
||||
# real gist we've stored.
|
||||
SUMMARY_MAX_TOKENS = 768
|
||||
# Attempts on the primary backend before falling back to cloud.
|
||||
MI50_ATTEMPTS = 2
|
||||
# Per-call timeout (seconds). A capped 768-token gist finishes in ~60-90s on the
|
||||
# MI50; 150s is headroom but bails a hung call fast so fallback isn't slow.
|
||||
SUMMARY_TIMEOUT = 150
|
||||
|
||||
# Degenerate-output guard. A wedged local model (e.g. an overheated GPU) returns
|
||||
# a single character repeated ("?????") as a *successful* 200, which no timeout or
|
||||
# exception catches — so validate the text and treat junk as a failure. Real gists
|
||||
# are diverse prose; flag output whose most-common non-space char dominates. Short
|
||||
# outputs are exempt (nothing meaningful to judge).
|
||||
_DEGENERATE_MIN_CHARS = 24
|
||||
_DEGENERATE_CHAR_RATIO = 0.5
|
||||
|
||||
|
||||
class DegenerateOutput(RuntimeError):
|
||||
"""A backend returned junk (e.g. one char repeated) as a successful response."""
|
||||
|
||||
|
||||
def _looks_degenerate(text: str) -> bool:
|
||||
stripped = "".join(text.split())
|
||||
if len(stripped) < _DEGENERATE_MIN_CHARS:
|
||||
return False
|
||||
return max(Counter(stripped).values()) / len(stripped) > _DEGENERATE_CHAR_RATIO
|
||||
|
||||
# Re-summarize a session once it has accumulated this many new raw exchanges.
|
||||
SUMMARIZE_AFTER = 20
|
||||
@@ -61,16 +90,35 @@ def _summarize_text(text: str, backend: Backend) -> str:
|
||||
{"role": "system", "content": _PROMPT},
|
||||
{"role": "user", "content": text},
|
||||
]
|
||||
# Retry transient backend errors (e.g. the GPU server restarting) with backoff.
|
||||
for attempt in range(_RETRIES):
|
||||
|
||||
def _call(be: Backend) -> str:
|
||||
out = llm.complete(messages, backend=be,
|
||||
max_tokens=SUMMARY_MAX_TOKENS, timeout=SUMMARY_TIMEOUT)
|
||||
if _looks_degenerate(out):
|
||||
raise DegenerateOutput(f"{be} returned degenerate output ({len(out)} chars)")
|
||||
return out
|
||||
|
||||
# Try the primary backend a bounded number of times (each call fast-fails via
|
||||
# SUMMARY_TIMEOUT), with a short backoff for a transient blip / restarting GPU.
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(MI50_ATTEMPTS):
|
||||
try:
|
||||
return llm.complete(messages, backend=backend)
|
||||
return _call(backend)
|
||||
except Exception as exc:
|
||||
if attempt == _RETRIES - 1:
|
||||
raise
|
||||
logbus.log("debug", "summary retry", attempt=attempt + 1, error=str(exc)[:80])
|
||||
time.sleep(5 * (attempt + 1))
|
||||
raise RuntimeError("unreachable")
|
||||
last_exc = exc
|
||||
logbus.log("debug", "summary retry", attempt=attempt + 1,
|
||||
backend=backend, error=str(exc)[:80])
|
||||
if attempt < MI50_ATTEMPTS - 1:
|
||||
time.sleep(5 * (attempt + 1))
|
||||
|
||||
# Primary exhausted. If it wasn't already cloud and cloud is configured, fall
|
||||
# back once so a stuck/offline MI50 doesn't sink consolidation for the night.
|
||||
if backend != "cloud" and config.load().openai_api_key:
|
||||
logbus.log("info", "summary fell back to cloud", primary=backend,
|
||||
error=str(last_exc)[:80] if last_exc else None)
|
||||
return _call("cloud")
|
||||
|
||||
raise last_exc if last_exc else RuntimeError("summary failed")
|
||||
|
||||
|
||||
def _summarize_transcript(transcript: str, backend: Backend) -> str:
|
||||
@@ -129,16 +177,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]] = []
|
||||
|
||||
+150
-8
@@ -30,8 +30,13 @@ def _note(args: dict, ctx: dict) -> str:
|
||||
return "Nothing to note — content was empty."
|
||||
tag = (args.get("tag") or "").strip()
|
||||
stored = f"[{tag}] {content}" if tag else content
|
||||
memory.add_journal_entry("note", stored, source="chat")
|
||||
logbus.log("info", "Lyra noted (tool)", tag=tag or None)
|
||||
# A note taken while a poker session is live is session narration — stamp it
|
||||
# with the session so the HUD shows *only* these, never her autonomous
|
||||
# journaling (dream-cycle musings, thought loop). Correctness by construction.
|
||||
live = poker.live_session()
|
||||
source = f"poker:{live['id']}" if live else "chat"
|
||||
memory.add_journal_entry("note", stored, source=source)
|
||||
logbus.log("info", "Lyra noted (tool)", tag=tag or None, poker=bool(live))
|
||||
return "Noted."
|
||||
|
||||
|
||||
@@ -114,6 +119,9 @@ TOOLS: dict[str, dict] = {
|
||||
"description": (
|
||||
"Jot down a note to remember later — an observation, an idea, a "
|
||||
"reminder, a read on a poker spot or opponent, anything worth keeping. "
|
||||
"During a live poker session this is your session log: a factual beat "
|
||||
"about how the night is going (table dynamics, Brian's arc, momentum) — "
|
||||
"it shows on his HUD. Not for your own feelings or reflection. "
|
||||
"Optionally tag it (e.g. 'poker', 'idea', 'reminder')."
|
||||
),
|
||||
"parameters": {
|
||||
@@ -184,8 +192,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")
|
||||
@@ -281,14 +290,87 @@ def _log_hand(args: dict, ctx: dict) -> str:
|
||||
def _add_read(args: dict, ctx: dict) -> str:
|
||||
poker.add_read(
|
||||
note=args.get("note") or "", seat=args.get("seat"), name=args.get("name"),
|
||||
descriptor=args.get("descriptor"),
|
||||
tendencies=args.get("tendencies"), adjustment=args.get("adjustment"),
|
||||
description=args.get("description"), category=args.get("category"),
|
||||
venue=args.get("venue"),
|
||||
)
|
||||
who = f" on {args['name']}" if args.get("name") else ""
|
||||
who = f" on {args['name']}" if args.get("name") else (
|
||||
f" on “{args['descriptor']}”" if args.get("descriptor") else "")
|
||||
return f"Read logged{who}."
|
||||
|
||||
|
||||
def _resolve_villain_ref(ref: str) -> tuple[int | None, str]:
|
||||
"""Resolve a name-or-descriptor to a single player id for a confirm-loop action.
|
||||
Returns (id, band); acts only on a deterministic name or a confident descriptor."""
|
||||
live = poker.live_session()
|
||||
res = poker.resolve_villain(ref, venue=(live or {}).get("venue"),
|
||||
session_id=(live or {}).get("id"))
|
||||
if res["band"] in ("name", "high") and res["match_id"]:
|
||||
return res["match_id"], res["band"]
|
||||
return None, res["band"]
|
||||
|
||||
|
||||
def _seat_players(args: dict, ctx: dict) -> str:
|
||||
players = args.get("players") or []
|
||||
# Accept a plain list of names too, for convenience.
|
||||
if isinstance(players, str):
|
||||
players = [p.strip() for p in re.split(r"[,\n]", players) if p.strip()]
|
||||
try:
|
||||
if args.get("replace"): # a whole new table — wipe the roster first
|
||||
poker.clear_roster()
|
||||
n = poker.seat_players(players)
|
||||
except ValueError:
|
||||
return "No live session — start one first, then I'll seat the table."
|
||||
roster = poker.session_roster()
|
||||
names = ", ".join(r["name"] for r in roster) or "—"
|
||||
return f"Seated {n}. Table now: {names}"
|
||||
|
||||
|
||||
def _clear_table(args: dict, ctx: dict) -> str:
|
||||
n = poker.clear_roster()
|
||||
return f"Table cleared — roster's empty ({n} removed). Tell me who's at the new one."
|
||||
|
||||
|
||||
def _unseat_player(args: dict, ctx: dict) -> str:
|
||||
ok = poker.unseat_player(name=args.get("name"), descriptor=args.get("descriptor"))
|
||||
who = args.get("name") or args.get("descriptor") or "player"
|
||||
return f"{who} is off the table." if ok else f"Couldn't find {who} on the roster."
|
||||
|
||||
|
||||
def _name_villain(args: dict, ctx: dict) -> str:
|
||||
ref = (args.get("descriptor") or "").strip()
|
||||
name = (args.get("name") or "").strip()
|
||||
if not ref or not name:
|
||||
return "Need both the description of the player and the name to attach."
|
||||
pid, band = _resolve_villain_ref(ref)
|
||||
if pid is None:
|
||||
return (f"Couldn't confidently find “{ref}” to name — too vague or no match. "
|
||||
"Add a read with the descriptor first, or be more specific.")
|
||||
poker.name_villain(pid, name)
|
||||
return f"Got it — “{ref}” is {name} now; their history carries over."
|
||||
|
||||
|
||||
def _link_villains(args: dict, ctx: dict) -> str:
|
||||
a = (args.get("player_a") or "").strip()
|
||||
b = (args.get("player_b") or "").strip()
|
||||
same = bool(args.get("same"))
|
||||
if not a or not b:
|
||||
return "Need two players to link (by name or description)."
|
||||
ida, _ = _resolve_villain_ref(a)
|
||||
idb, _ = _resolve_villain_ref(b)
|
||||
if ida is None or idb is None:
|
||||
return ("Couldn't confidently pin down both players, so I didn't merge anything — "
|
||||
"safer to leave it. You can sort it on the Players page.")
|
||||
if ida == idb:
|
||||
return "Those resolve to the same profile already — nothing to do."
|
||||
if same:
|
||||
poker.merge_players(ida, idb)
|
||||
return "Merged — same guy. Their histories are one file now."
|
||||
poker.mark_distinct(ida, idb, note=args.get("note"))
|
||||
return "Noted they're different people — I won't suggest merging them again."
|
||||
|
||||
|
||||
def _end_session(args: dict, ctx: dict) -> str:
|
||||
s = poker.end_session(cash_out=float(args.get("cash_out") or 0), mood=args.get("mood"))
|
||||
hourly = f", {s['net'] / s['hours']:+.0f}/hr" if s.get("hours") else ""
|
||||
@@ -370,8 +452,16 @@ def _record_hand(args: dict, ctx: dict) -> str:
|
||||
if not out["id"]:
|
||||
return "I couldn't parse that hand — give it to me again with a little more detail?"
|
||||
p = out["parsed"]
|
||||
hero_in = p.get("hero_involved") is not False and bool(p.get("hero_pos"))
|
||||
logbus.log("info", "hand reconstructed", id=out["id"], hero=p.get("hero_pos"),
|
||||
hero_involved=hero_in)
|
||||
if not hero_in:
|
||||
# A hand Brian watched between other players — not his.
|
||||
who = ", ".join(pl.get("name") or pl.get("pos") or "?"
|
||||
for pl in (p.get("players") or [])[:3]) or "the table"
|
||||
return (f"Logged hand #{out['id']} — an observed hand ({who}), not yours. "
|
||||
f"View it at /hand/{out['id']}")
|
||||
cards = " ".join(p.get("hero_cards") or [])
|
||||
logbus.log("info", "hand reconstructed", id=out["id"], hero=p.get("hero_pos"))
|
||||
return (f"Hand #{out['id']} reconstructed — {p.get('hero_pos') or '?'} "
|
||||
f"{cards}. View/replay it at /hand/{out['id']}")
|
||||
|
||||
@@ -519,8 +609,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",
|
||||
@@ -573,9 +666,13 @@ TOOLS.update({
|
||||
[])},
|
||||
"add_read": {"handler": _add_read, "spec": _f(
|
||||
"add_read",
|
||||
"Log a read on an opponent. If you give a name, it's saved to the persistent villain file.",
|
||||
"Log a read on an opponent. Give a `name` if known; if not, give a `descriptor` "
|
||||
"(a distinctive physical description like 'neck tattoo, backwards cap') and the read "
|
||||
"attaches to that nameless player — reused automatically next time you describe him.",
|
||||
{"note": {**_S, "description": "The observation / what they showed down"},
|
||||
"name": {**_S, "description": "Player name/handle if known (creates/updates their dossier)"},
|
||||
"descriptor": {**_S, "description": "Physical description when there's no name, e.g. "
|
||||
"'neck tattoo, heavyset'. Prefer distinctive features over generic ones."},
|
||||
"seat": {**_S, "description": "Seat or relative position"},
|
||||
"tendencies": {**_S, "description": "Standing read on how they play"},
|
||||
"adjustment": {**_S, "description": "How Brian should exploit them"},
|
||||
@@ -583,6 +680,51 @@ TOOLS.update({
|
||||
"category": {**_S, "description": "feeder | risky | reg | unknown"},
|
||||
"venue": {**_S, "description": "Where they play"}},
|
||||
["note"])},
|
||||
"seat_players": {"handler": _seat_players, "spec": _f(
|
||||
"seat_players",
|
||||
"Register who's at the table this session — the roster Brian reads off the Bravo "
|
||||
"screen (handles like TAG, JD). Call this when he names the table (usually at the "
|
||||
"start) or when a new player sits. Each player is a real handle in `name`, or a "
|
||||
"`descriptor` if he only describes them. These become the roster his reads/TAGs "
|
||||
"attach to by name.",
|
||||
{"players": {"type": "array", "description": "Players to seat",
|
||||
"items": {"type": "object", "properties": {
|
||||
"name": {**_S, "description": "Handle as it appears on Bravo, e.g. 'TAG'"},
|
||||
"descriptor": {**_S, "description": "Physical description if no name"},
|
||||
"seat": {**_S, "description": "Seat number/label if known"},
|
||||
"category": {**_S, "description": "feeder | risky | reg | unknown"}}}},
|
||||
"replace": {"type": "boolean", "description": "true = a brand-new table: clear the "
|
||||
"current roster first, then seat these (use when he changes tables)"}},
|
||||
["players"])},
|
||||
"unseat_player": {"handler": _unseat_player, "spec": _f(
|
||||
"unseat_player",
|
||||
"Remove a player from the table roster when they bust or leave. Keeps their history.",
|
||||
{"name": {**_S, "description": "Their handle"},
|
||||
"descriptor": {**_S, "description": "Or a description if unnamed"}},
|
||||
[])},
|
||||
"clear_table": {"handler": _clear_table, "spec": _f(
|
||||
"clear_table",
|
||||
"Empty the whole table roster at once — call this when Brian changes tables or says "
|
||||
"to clear the table. The session, stack, and logged reads stay; only who's currently "
|
||||
"seated resets. Then he'll tell you the new table.",
|
||||
{}, [])},
|
||||
"name_villain": {"handler": _name_villain, "spec": _f(
|
||||
"name_villain",
|
||||
"Attach a real name to a player you'd only known by description (e.g. you caught it "
|
||||
"off the Bravo screen). Their whole history carries over to the name.",
|
||||
{"descriptor": {**_S, "description": "How you'd been referring to him, e.g. 'neck tattoo guy'"},
|
||||
"name": {**_S, "description": "His real name/handle"}},
|
||||
["descriptor", "name"])},
|
||||
"link_villains": {"handler": _link_villains, "spec": _f(
|
||||
"link_villains",
|
||||
"Resolve a same-person question when Brian confirms it. same=true MERGES two profiles "
|
||||
"into one (their histories join); same=false records they're DIFFERENT people so you "
|
||||
"stop asking. Only call after he's confirmed — never merge on a guess.",
|
||||
{"player_a": {**_S, "description": "First player, by name or description"},
|
||||
"player_b": {**_S, "description": "Second player, by name or description"},
|
||||
"same": {"type": "boolean", "description": "true = same person (merge); false = different"},
|
||||
"note": {**_S, "description": "For different people: the tell that distinguishes them"}},
|
||||
["player_a", "player_b", "same"])},
|
||||
"end_session": {"handler": _end_session, "spec": _f(
|
||||
"end_session", "Close the live session: record cashout, compute net + hours.",
|
||||
{"cash_out": {**_N, "description": "Final cashout amount"},
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Full-fidelity conversation export: interleave what was *said* (chat exchanges)
|
||||
with what Lyra *did* (tool calls) in chronological order.
|
||||
|
||||
The chat only ever lives in SQLite (`exchanges` + `tool_events`); this is the one
|
||||
place that renders a whole session back out as a portable artifact — Markdown for
|
||||
reading / pasting into RTO or another model, JSON for machine reprocessing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from lyra import clock, memory
|
||||
|
||||
# How roles/actions are labeled in the Markdown transcript.
|
||||
_SPEAKER = {"user": "Brian", "assistant": "Lyra"}
|
||||
|
||||
|
||||
def _merged(session_id: str) -> list[dict]:
|
||||
"""Speech + actions for a session, merged oldest-first by wall-clock time."""
|
||||
events: list[dict] = []
|
||||
for e in memory.history(session_id):
|
||||
events.append({"type": "message", "role": e.role, "content": e.content,
|
||||
"ts": e.created_at})
|
||||
for t in memory.tool_events(session_id):
|
||||
events.append({"type": "tool", "tool": t["tool"], "args": t["args"],
|
||||
"result": t["result"], "ts": t["created_at"]})
|
||||
# created_at is an ISO string; lexicographic sort == chronological sort.
|
||||
events.sort(key=lambda ev: ev["ts"])
|
||||
return events
|
||||
|
||||
|
||||
def _fmt_args(args) -> str:
|
||||
"""Compact one-line rendering of a tool call's arguments."""
|
||||
if isinstance(args, dict):
|
||||
return ", ".join(f"{k}={json.dumps(v, default=str)}" for k, v in args.items())
|
||||
return "" if args is None else str(args)
|
||||
|
||||
|
||||
def as_markdown(session_id: str, name: str | None = None) -> str:
|
||||
events = _merged(session_id)
|
||||
title = name or session_id
|
||||
lines = [f"# Conversation — {title}",
|
||||
f"_Exported {clock.stamp()} · session `{session_id}` · "
|
||||
f"{len(events)} events_", ""]
|
||||
for ev in events:
|
||||
stamp = clock.short(ev["ts"])
|
||||
if ev["type"] == "message":
|
||||
who = _SPEAKER.get(ev["role"], ev["role"].capitalize())
|
||||
lines.append(f"**{who}** · {stamp}")
|
||||
lines.append((ev["content"] or "").rstrip())
|
||||
lines.append("")
|
||||
else:
|
||||
result = (ev["result"] or "").strip().replace("\n", " ")
|
||||
if len(result) > 200:
|
||||
result = result[:197] + "…"
|
||||
lines.append(f" ⚙ `{ev['tool']}({_fmt_args(ev['args'])})` → {result}")
|
||||
lines.append("")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def as_json(session_id: str, name: str | None = None) -> dict:
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"name": name,
|
||||
"exported_at": clock.stamp(),
|
||||
"events": _merged(session_id),
|
||||
}
|
||||
|
||||
|
||||
def build(session_id: str, fmt: str = "md", name: str | None = None):
|
||||
"""Return (content_str, media_type, filename) for the requested format."""
|
||||
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id)[:60]
|
||||
if fmt == "json":
|
||||
body = json.dumps(as_json(session_id, name), indent=2, ensure_ascii=False)
|
||||
return body, "application/json", f"lyra_{safe}.json"
|
||||
body = as_markdown(session_id, name)
|
||||
return body, "text/markdown; charset=utf-8", f"lyra_{safe}.md"
|
||||
+149
-17
@@ -18,7 +18,7 @@ from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lyra import chat, logbus, memory, modes, poker, self_state, summary, thoughts
|
||||
from lyra import chat, logbus, memory, modes, poker, self_state, summary, thoughts, transcript
|
||||
from lyra.llm import Backend
|
||||
|
||||
|
||||
@@ -50,6 +50,16 @@ def _last_user_message(messages: list[dict]) -> str:
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="Lyra Web")
|
||||
|
||||
@app.middleware("http")
|
||||
async def _no_stale_shell(request: Request, call_next):
|
||||
"""Always revalidate HTML/JS so a PWA can't serve a stale app shell after a
|
||||
deploy (iOS applies heuristic caching when no cache header is set)."""
|
||||
resp = await call_next(request)
|
||||
ct = resp.headers.get("content-type", "")
|
||||
if "text/html" in ct or "javascript" in ct:
|
||||
resp.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||||
return resp
|
||||
|
||||
@app.get("/_health")
|
||||
async def health() -> dict:
|
||||
return {"ok": True}
|
||||
@@ -62,6 +72,15 @@ def create_app() -> FastAPI:
|
||||
async def get_session(session_id: str) -> list[dict]:
|
||||
return [{"role": ex.role, "content": ex.content} for ex in memory.history(session_id)]
|
||||
|
||||
@app.get("/sessions/{session_id}/export")
|
||||
async def export_session(session_id: str, format: str = "md") -> Response:
|
||||
"""Full transcript — chat + interleaved tool calls — as Markdown or JSON."""
|
||||
name = next((s["name"] for s in memory.list_sessions() if s["id"] == session_id), None)
|
||||
body, media_type, filename = await asyncio.to_thread(
|
||||
transcript.build, session_id, format, name)
|
||||
return Response(content=body, media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'})
|
||||
|
||||
@app.post("/sessions/{session_id}")
|
||||
async def save_session(session_id: str, request: Request) -> dict:
|
||||
# Messages are already persisted by chat.respond; just ensure the row exists.
|
||||
@@ -121,6 +140,109 @@ 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.post("/hand/{hand_id}/disown")
|
||||
async def hand_disown(hand_id: int) -> dict:
|
||||
"""Reclassify a hand as observed (not Brian's) — fix a misattributed one."""
|
||||
h = await asyncio.to_thread(poker.disown_hand, hand_id)
|
||||
logbus.log("info", "hand disowned", id=hand_id)
|
||||
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). Setting `name` on a
|
||||
nameless (descriptor) villain promotes it to a real handle (named=1)."""
|
||||
body = await request.json()
|
||||
|
||||
def _apply():
|
||||
if body.get("name"):
|
||||
poker.name_villain(player_id, body["name"])
|
||||
rest = {k: v for k, v in body.items() if k != "name"}
|
||||
return poker.update_player(player_id, **rest) # flat row (name included)
|
||||
|
||||
p = await asyncio.to_thread(_apply)
|
||||
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."""
|
||||
@@ -339,23 +461,33 @@ def create_app() -> FastAPI:
|
||||
async def hands_data(limit: int = 60) -> dict:
|
||||
return {"hands": poker.list_recent_hands(limit=limit)}
|
||||
|
||||
@app.post("/hands")
|
||||
async def hands_create(request: Request) -> dict:
|
||||
"""Store a structured hand built by the recorder. Body:
|
||||
{structured, session_id?, tag?, lesson?}. normalize_structured() (in
|
||||
store_hand_history) is the authority on shape, so the client can be best-effort."""
|
||||
@app.get("/players")
|
||||
async def players_page() -> FileResponse:
|
||||
"""Villain file browser + the identity-resolution review queue."""
|
||||
return FileResponse(str(_STATIC / "players.html"))
|
||||
|
||||
@app.get("/players/data")
|
||||
async def players_data() -> dict:
|
||||
return {"players": poker.players_overview(),
|
||||
"queue": poker.list_identity_queue()}
|
||||
|
||||
@app.get("/player/{player_id}/data")
|
||||
async def player_data(player_id: int) -> dict:
|
||||
return poker.villain_recall(player_id) or {}
|
||||
|
||||
@app.post("/identity/{task_id}/resolve")
|
||||
async def identity_resolve(task_id: int, request: Request) -> dict:
|
||||
body = await request.json()
|
||||
structured = body.get("structured")
|
||||
if not isinstance(structured, dict):
|
||||
return {"ok": False, "error": "missing structured hand body"}
|
||||
hid = await asyncio.to_thread(
|
||||
poker.store_hand_history, structured,
|
||||
session_id=body.get("session_id"), tag=body.get("tag"), lesson=body.get("lesson"),
|
||||
)
|
||||
# Enrich villain dossiers from the recorded players, same as the parser path.
|
||||
await asyncio.to_thread(poker.link_hand_players, hid, structured, body.get("session_id"))
|
||||
logbus.log("info", "hand recorded", id=hid, session=body.get("session_id"))
|
||||
return {"ok": True, "id": hid}
|
||||
action = body.get("action") or "dismiss"
|
||||
kw = {k: v for k, v in body.items() if k != "action"}
|
||||
ok = await asyncio.to_thread(poker.resolve_identity_task, task_id, action, **kw)
|
||||
logbus.log("info", "identity task resolved", id=task_id, action=action)
|
||||
return {"ok": ok}
|
||||
|
||||
@app.post("/players/scan")
|
||||
async def players_scan() -> dict:
|
||||
filed = await asyncio.to_thread(poker.scan_merge_candidates)
|
||||
return {"ok": True, "filed": filed}
|
||||
|
||||
@app.get("/recap/{session_id}")
|
||||
async def recap_page() -> FileResponse:
|
||||
|
||||
@@ -282,8 +282,54 @@
|
||||
const h = await r.json();
|
||||
if(!h || !h.id){ document.getElementById('root').innerHTML='<p class="err">Hand not found.</p>'; return; }
|
||||
render(h);
|
||||
renderEditor(h);
|
||||
}catch(e){ document.getElementById('root').innerHTML='<p class="err">Couldn\'t load the hand.</p>'; }
|
||||
}
|
||||
|
||||
function renderEditor(h){
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'max-width:520px;margin:18px auto 0;border-top:1px solid #241a10;padding-top:12px;';
|
||||
const tags = ['','well_played','leak','cooler','confidence','notable'];
|
||||
wrap.innerHTML = `
|
||||
<details style="font-size:.9rem;">
|
||||
<summary style="cursor:pointer;color:var(--accent,#ff7a00);">✎ Edit this hand</summary>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;margin-top:10px;">
|
||||
<label>Position <input id="e_pos" value="${esc(h.position||'')}" placeholder="e.g. CO (blank if not yours)"></label>
|
||||
<label>Your cards <input id="e_hole" value="${esc(h.hole_cards||'')}" placeholder="e.g. As Ks (blank if not yours)"></label>
|
||||
<label>Board <input id="e_board" value="${esc(h.board||'')}" placeholder="e.g. Tc 8s Js 6d"></label>
|
||||
<label>Your net <input id="e_res" value="${h.result!=null?esc(h.result):''}" placeholder="+ / − chips (blank if not yours)"></label>
|
||||
<label>Tag <select id="e_tag">${tags.map(t=>`<option value="${t}" ${h.tag===t?'selected':''}>${t||'—'}</option>`).join('')}</select></label>
|
||||
<label>Lesson <input id="e_lesson" value="${esc(h.lesson||'')}"></label>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:4px;">
|
||||
<button onclick="saveHand(${h.id})" style="border-color:var(--accent,#ff7a00);color:var(--accent,#ff7a00);">Save</button>
|
||||
<button onclick="disown(${h.id})" title="It was someone else's hand — clear it from you">Not my hand</button>
|
||||
<button onclick="delHand(${h.id})" style="margin-left:auto;color:#ff6b6b;">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>`;
|
||||
wrap.querySelectorAll('input,select').forEach(el=>{el.style.cssText='font:inherit;font-size:.86rem;padding:5px 8px;border-radius:6px;border:1px solid #241a10;background:#0b0b0b;color:#e8e8e8;margin-left:8px;';});
|
||||
wrap.querySelectorAll('label').forEach(el=>{el.style.cssText='display:flex;justify-content:space-between;align-items:center;color:#8a8a8a;';});
|
||||
wrap.querySelectorAll('button').forEach(el=>{el.style.cssText+=';font:inherit;font-size:.84rem;padding:6px 12px;border-radius:7px;border:1px solid #241a10;background:#141414;color:#e8e8e8;cursor:pointer;';});
|
||||
document.getElementById('root').appendChild(wrap);
|
||||
}
|
||||
const val = id => document.getElementById(id).value.trim();
|
||||
async function saveHand(id){
|
||||
const body = {position:val('e_pos'), hole_cards:val('e_hole'), board:val('e_board'),
|
||||
tag:val('e_tag'), lesson:val('e_lesson')};
|
||||
const res = val('e_res'); if(res!=='') body.result = Number(res);
|
||||
await fetch(`/hand/${id}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
load();
|
||||
}
|
||||
async function disown(id){
|
||||
if(!confirm("Mark this as someone else's hand? It'll be cleared from your stats.")) return;
|
||||
await fetch(`/hand/${id}/disown`,{method:'POST'});
|
||||
load();
|
||||
}
|
||||
async function delHand(id){
|
||||
if(!confirm('Delete this hand for good?')) return;
|
||||
await fetch(`/hand/${id}`,{method:'DELETE'});
|
||||
location.href='/hands';
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
<script src="/nav.js"></script>
|
||||
|
||||
+89
-23
@@ -3,15 +3,14 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Lyra Core Chat</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<link rel="stylesheet" href="/recorder.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" />
|
||||
@@ -89,6 +88,7 @@
|
||||
<select id="sessions"></select>
|
||||
<button id="newSessionBtn">➕ New</button>
|
||||
<button id="renameSessionBtn">✏️ Rename</button>
|
||||
<button id="exportSessionBtn" title="Download full transcript (chat + tool calls)">⬇ Export</button>
|
||||
<button id="thinkingStreamBtn" title="Show live activity log">📜 Live Log</button>
|
||||
</div>
|
||||
|
||||
@@ -125,13 +125,18 @@
|
||||
<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>
|
||||
<a class="tab" href="/session"><span class="ti">🎬</span><span class="tl">Session</span></a>
|
||||
<a class="tab" href="/hands"><span class="ti">🃏</span><span class="tl">Hands</span></a>
|
||||
<a class="tab tab-mind" href="/self"><span class="ti">🧠</span><span class="tl">Mind</span></a>
|
||||
<button class="tab tab-rec" id="recordTab" type="button"><span class="ti">➕</span><span class="tl">Record</span></button>
|
||||
<a class="tab" href="/self"><span class="ti">🧠</span><span class="tl">Mind</span></a>
|
||||
<button class="tab" id="moreTab" type="button"><span class="ti">⋯</span><span class="tl">More</span></button>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -211,6 +216,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);
|
||||
}
|
||||
@@ -917,6 +988,19 @@
|
||||
addMessage("system", `Session renamed to: ${newName}`);
|
||||
});
|
||||
|
||||
document.getElementById("exportSessionBtn").addEventListener("click", () => {
|
||||
if (!currentSession) { addMessage("system", "No session to export."); return; }
|
||||
const fmt = window.confirm("Export as Markdown? (Cancel = JSON)") ? "md" : "json";
|
||||
// Hitting the download endpoint navigates a hidden anchor so the browser
|
||||
// saves the file (chat + interleaved tool calls) instead of rendering it.
|
||||
const a = document.createElement("a");
|
||||
a.href = `${RELAY_BASE}/sessions/${encodeURIComponent(currentSession)}/export?format=${fmt}`;
|
||||
a.download = "";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
});
|
||||
|
||||
// Settings Modal
|
||||
const settingsModal = document.getElementById("settingsModal");
|
||||
const settingsBtn = document.getElementById("settingsBtn");
|
||||
@@ -1247,23 +1331,5 @@
|
||||
});
|
||||
</script>
|
||||
<script src="/nav.js"></script>
|
||||
<!-- Hand recorder (overlay; chat/session stays mounted underneath) -->
|
||||
<div id="recorderOverlay" class="rec-overlay"></div>
|
||||
<script src="/recorder.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var overlay = document.getElementById("recorderOverlay");
|
||||
var recordTab = document.getElementById("recordTab");
|
||||
function close() { overlay.classList.remove("open"); overlay.innerHTML = ""; }
|
||||
if (recordTab) recordTab.addEventListener("click", function () {
|
||||
overlay.innerHTML = "";
|
||||
overlay.classList.add("open");
|
||||
window.Recorder.mount(overlay, {
|
||||
onClose: close,
|
||||
onSave: function (id) { close(); window.open("/hand/" + id, "_blank"); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
{ href: "/session", icon: "♠", label: "Session" },
|
||||
{ href: "/history", icon: "📚", label: "History" },
|
||||
{ href: "/hands", icon: "🃏", label: "Hands" },
|
||||
{ href: "/players", icon: "👤", label: "Players" },
|
||||
{ href: "/self", icon: "🧠", label: "Mind" },
|
||||
{ href: "/thoughts", icon: "💭", label: "Thoughts" },
|
||||
{ href: "/journal", icon: "📔", label: "Journal" },
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#070707" />
|
||||
<title>Lyra — Players</title>
|
||||
<style>
|
||||
:root{--bg:#070707;--bg-elev:#0e0e0e;--bg-line:#141414;--border:#2a1d12;--text:#e8e8e8;--fade:#8a8a8a;--accent:#ff7a00;}
|
||||
*{box-sizing:border-box;}
|
||||
html,body{margin:0;min-height:100%;background:var(--bg);color:var(--text);
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-text-size-adjust:100%;}
|
||||
header{position:sticky;top:0;z-index:10;background:var(--bg-elev);border-bottom:1px solid var(--border);
|
||||
padding:env(safe-area-inset-top) 14px 0;}
|
||||
.topbar{display:flex;align-items:center;gap:10px;padding:13px 0;}
|
||||
.topbar h1{font-size:1.05rem;margin:0;font-weight:600;}
|
||||
.topbar a.back{color:var(--accent);text-decoration:none;font-size:.92rem;}
|
||||
.count{margin-left:auto;color:var(--fade);font-size:.8rem;}
|
||||
main{max-width:640px;margin:0 auto;padding:12px 12px 44px;}
|
||||
h2.sec{font-size:.74rem;text-transform:uppercase;letter-spacing:.6px;color:var(--fade);margin:20px 2px 8px;}
|
||||
.queue{background:#160d05;border:1px solid var(--accent);border-radius:10px;padding:11px 12px;margin-bottom:9px;}
|
||||
.queue .k{font-size:.62rem;text-transform:uppercase;letter-spacing:.5px;color:var(--accent);}
|
||||
.queue .q-body{font-size:.9rem;margin:5px 0 9px;}
|
||||
.queue .who{font-weight:600;}
|
||||
.btns{display:flex;flex-wrap:wrap;gap:7px;}
|
||||
button{font:inherit;font-size:.82rem;padding:6px 11px;border-radius:7px;border:1px solid var(--border);
|
||||
background:var(--bg-line);color:var(--text);cursor:pointer;}
|
||||
button.pri{border-color:var(--accent);color:var(--accent);}
|
||||
button:active{background:#241400;}
|
||||
.card{background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:8px;}
|
||||
.card .row{display:flex;align-items:center;gap:9px;cursor:pointer;}
|
||||
.nm{font-size:.96rem;font-weight:600;}
|
||||
.nm.desc{font-weight:500;font-style:italic;color:#e8d3bf;}
|
||||
.meta{font-size:.74rem;color:var(--fade);}
|
||||
.pill{font-size:.6rem;text-transform:uppercase;letter-spacing:.4px;border:1px solid var(--border);
|
||||
border-radius:20px;padding:1px 7px;color:var(--fade);}
|
||||
.pill.desc{border-color:#5a3c1e;color:#d0a56e;}
|
||||
.spacer{margin-left:auto;}
|
||||
.detail{margin-top:9px;padding-top:9px;border-top:1px solid var(--bg-line);font-size:.86rem;display:none;}
|
||||
.detail.open{display:block;}
|
||||
.detail .lbl{color:var(--fade);font-size:.72rem;text-transform:uppercase;letter-spacing:.4px;margin:8px 0 3px;}
|
||||
.detail ul{margin:3px 0;padding-left:18px;} .detail li{margin:2px 0;}
|
||||
.detail a{color:var(--accent);text-decoration:none;}
|
||||
.edit{display:flex;flex-wrap:wrap;gap:6px;margin-top:9px;}
|
||||
.edit input,.edit select{font:inherit;font-size:.82rem;padding:5px 8px;border-radius:6px;
|
||||
border:1px solid var(--border);background:var(--bg);color:var(--text);}
|
||||
.empty{color:var(--fade);text-align:center;padding:34px 16px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="topbar">
|
||||
<h1>👤 Players</h1>
|
||||
<a class="back" href="/">← Chat</a>
|
||||
<span class="count" id="count"></span>
|
||||
</div>
|
||||
</header>
|
||||
<main id="root"><p class="empty">Loading…</p></main>
|
||||
|
||||
<script>
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
|
||||
let DATA={players:[],queue:[]};
|
||||
|
||||
async function load(){
|
||||
try{ DATA=await (await fetch('/players/data',{cache:'no-store'})).json(); }
|
||||
catch(e){ document.getElementById('root').innerHTML='<p class="empty">Couldn\'t load players.</p>'; return; }
|
||||
render();
|
||||
}
|
||||
|
||||
function render(){
|
||||
const {players,queue}=DATA;
|
||||
const named=players.filter(p=>p.named), nameless=players.filter(p=>!p.named);
|
||||
document.getElementById('count').textContent=`${players.length} player${players.length===1?'':'s'}`;
|
||||
let html='';
|
||||
|
||||
if(queue.length){
|
||||
html+=`<h2 class="sec">⚠ Needs your call — ${queue.length}</h2>`;
|
||||
html+=queue.map(qCard).join('');
|
||||
}
|
||||
html+=`<h2 class="sec">Named — ${named.length} <button class="pri" style="float:right;padding:3px 9px" onclick="scan()">Scan for dupes</button></h2>`;
|
||||
html+= named.length ? named.map(pCard).join('') : '<p class="empty">No named players yet.</p>';
|
||||
html+=`<h2 class="sec">By description — ${nameless.length}</h2>`;
|
||||
html+= nameless.length ? nameless.map(pCard).join('') : '<p class="empty">No nameless villains yet — they show up here as you describe players at the table.</p>';
|
||||
document.getElementById('root').innerHTML=html;
|
||||
}
|
||||
|
||||
function qCard(q){
|
||||
const ps=q.players||[];
|
||||
if(q.kind==='merge_candidate' && ps.length===2){
|
||||
const a=ps[0], b=ps[1];
|
||||
return `<div class="queue"><div class="k">Possible merge${q.confidence?` · ${Math.round(q.confidence*100)}%`:''}</div>
|
||||
<div class="q-body">Same person? <span class="who">${label(a)}</span> vs <span class="who">${label(b)}</span></div>
|
||||
<div class="btns">
|
||||
<button class="pri" onclick="resolveTask(${q.id},'merge',{keep_id:${keepId(a,b)},dup_id:${dupId(a,b)}})">✓ Same — merge</button>
|
||||
<button onclick="resolveTask(${q.id},'distinct',{a_id:${a.id},b_id:${b.id}})">✕ Different</button>
|
||||
<button onclick="resolveTask(${q.id},'dismiss',{})">Dismiss</button>
|
||||
</div></div>`;
|
||||
}
|
||||
const who=ps[0]?label(ps[0]):'?';
|
||||
return `<div class="queue"><div class="k">Needs clarification</div>
|
||||
<div class="q-body">You referred to <span class="who">“${esc(q.descriptor||'')}”</span>${ps[0]?` — is that ${who}?`:''}</div>
|
||||
<div class="btns"><button onclick="resolveTask(${q.id},'dismiss',{})">Got it</button></div></div>`;
|
||||
}
|
||||
const label=p=>`${esc(p.name)}${p.named?'':' <span class="pill desc">desc</span>'}${p.venue?` · ${esc(p.venue)}`:''}${p.obs?` · ${p.obs}h`:''}`;
|
||||
const keepId=(a,b)=>a.named?a.id:(b.named?b.id:a.id);
|
||||
const dupId=(a,b)=>a.named?b.id:(b.named?a.id:b.id);
|
||||
|
||||
function pCard(p){
|
||||
const pills=[p.named?'':'<span class="pill desc">desc</span>',p.category?`<span class="pill">${esc(p.category)}</span>`:''].join('');
|
||||
const meta=[p.venue,p.obs?`${p.obs} hands`:'',p.reads?`${p.reads} reads`:''].filter(Boolean).join(' · ');
|
||||
return `<div class="card" id="p${p.id}">
|
||||
<div class="row" onclick="toggle(${p.id})">
|
||||
<span class="nm ${p.named?'':'desc'}">${p.named?esc(p.name):'“'+esc(p.name)+'”'}</span>
|
||||
${pills}<span class="spacer"></span><span class="meta">${esc(meta)}</span>
|
||||
</div>
|
||||
<div class="detail" id="d${p.id}"></div></div>`;
|
||||
}
|
||||
|
||||
async function toggle(id){
|
||||
const el=document.getElementById('d'+id);
|
||||
if(el.classList.contains('open')){el.classList.remove('open');return;}
|
||||
el.classList.add('open'); el.innerHTML='<span class="meta">Loading…</span>';
|
||||
const r=await (await fetch(`/player/${id}/data`,{cache:'no-store'})).json();
|
||||
el.innerHTML=detailHtml(id,r);
|
||||
}
|
||||
|
||||
function detailHtml(id,r){
|
||||
const p=r.player||{}; let h='';
|
||||
const seen=[r.times_seen?`seen ${r.times_seen}×`:'', r.last_seen?`last ${String(r.last_seen).slice(0,10)}`:''].filter(Boolean).join(' · ');
|
||||
if(seen) h+=`<div class="meta">${esc(seen)}</div>`;
|
||||
if(r.stats) h+=`<div class="lbl">Stats</div><div>VPIP ${r.stats.vpip_pct} · PFR ${r.stats.pfr_pct} · WTSD ${r.stats.wtsd_pct} <span class="meta">(${r.stats.hands} hands)</span></div>`;
|
||||
if(r.descriptors) h+=`<div class="lbl">Descriptors</div><div>${esc(r.descriptors)}</div>`;
|
||||
if(p.tendencies) h+=`<div class="lbl">Tendencies</div><div>${esc(p.tendencies)}</div>`;
|
||||
if(p.adjustment) h+=`<div class="lbl">Exploit</div><div>${esc(p.adjustment)}</div>`;
|
||||
if((r.reads||[]).length){h+='<div class="lbl">Reads</div><ul>'+r.reads.slice(0,8).map(x=>`<li>${esc(x)}</li>`).join('')+'</ul>';}
|
||||
if((r.notable_hands||[]).length){h+='<div class="lbl">Notable hands</div><ul>'+r.notable_hands.map(x=>`<li><a href="/hand/${x.hand_id}">hand #${x.hand_id}</a>${x.cards?' — '+esc(x.cards):''}${x.summary?' <span class="meta">'+esc(x.summary)+'</span>':''}</li>`).join('')+'</ul>';}
|
||||
h+=`<div class="edit">
|
||||
${p.named?'':`<input id="nm${id}" placeholder="give a name…" size="12"><button onclick="rename(${id})">Name</button>`}
|
||||
<select id="cat${id}" onchange="setCat(${id})">
|
||||
${['','feeder','risky','reg','unknown'].map(c=>`<option value="${c}" ${p.category===c?'selected':''}>${c||'category…'}</option>`).join('')}
|
||||
</select></div>`;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function rename(id){
|
||||
const v=document.getElementById('nm'+id).value.trim(); if(!v)return;
|
||||
await fetch(`/player/${id}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:v})});
|
||||
load();
|
||||
}
|
||||
async function setCat(id){
|
||||
const v=document.getElementById('cat'+id).value;
|
||||
await fetch(`/player/${id}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({category:v})});
|
||||
}
|
||||
async function resolveTask(id,action,kw){
|
||||
await fetch(`/identity/${id}/resolve`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,...kw})});
|
||||
load();
|
||||
}
|
||||
async function scan(){
|
||||
const r=await (await fetch('/players/scan',{method:'POST'})).json();
|
||||
load();
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
<script src="/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,117 +0,0 @@
|
||||
/* Hand recorder overlay. Uses the app theme tokens (--accent, --bg-* etc.) from
|
||||
style.css when mounted in index.html. For a standalone recorder.html, import those
|
||||
tokens too (see :root in style.css). */
|
||||
|
||||
.rec-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: var(--bg-dark, #070707);
|
||||
flex-direction: column;
|
||||
}
|
||||
/* :not(.open) outranks .rec-root's display:flex (added on mount), so closing the
|
||||
overlay actually hides it instead of leaving an empty black screen. */
|
||||
.rec-overlay:not(.open) { display: none; }
|
||||
.rec-overlay.open { display: flex; }
|
||||
|
||||
.rec-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
color: var(--text-main, #e8e8e8);
|
||||
font-family: var(--font-console, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
.rec-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
padding-top: calc(12px + env(safe-area-inset-top)); /* clear the notch/status bar */
|
||||
border-bottom: 1px solid var(--border, #2a1d12);
|
||||
}
|
||||
.rec-title { font-weight: 700; color: var(--accent, #ff7a00); }
|
||||
.rec-meta { color: var(--text-fade, #8a8a8a); font-size: .82rem; flex: 1; }
|
||||
.rec-x {
|
||||
background: none; border: 1px solid var(--border, #2a1d12); color: var(--text-fade, #8a8a8a);
|
||||
border-radius: 8px; width: 34px; height: 34px; font-size: 1rem;
|
||||
}
|
||||
|
||||
.rec-body { flex: 1; overflow-y: auto; padding: 12px 14px 20px; -webkit-overflow-scrolling: touch; }
|
||||
.rec-sec { margin-bottom: 18px; }
|
||||
.rec-label { font-size: .7rem; text-transform: uppercase; letter-spacing: .6px; color: var(--text-fade, #8a8a8a); margin-bottom: 6px; }
|
||||
.rec-dim { color: var(--text-fade, #8a8a8a); font-size: .85rem; }
|
||||
|
||||
.rec-pos-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.rec-pos {
|
||||
min-width: 44px; padding: 9px 10px; border-radius: 9px;
|
||||
background: var(--bg-elev, #0e0e0e); color: var(--text-main, #e8e8e8);
|
||||
border: 1px solid var(--border, #2a1d12); font-size: .82rem; font-weight: 600;
|
||||
}
|
||||
.rec-pos.on { background: var(--accent, #ff7a00); color: #111; border-color: var(--accent, #ff7a00); }
|
||||
.rec-pos.add { color: var(--text-fade, #8a8a8a); border-style: dashed; }
|
||||
|
||||
.rec-hero-cards { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
|
||||
|
||||
.rec-seats { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
|
||||
.rec-seat { display: flex; align-items: center; gap: 8px; }
|
||||
.rec-seat-pos { min-width: 42px; font-weight: 700; color: var(--gold, #ffb347); }
|
||||
.rec-name {
|
||||
flex: 1; min-width: 0; padding: 8px 9px; border-radius: 8px;
|
||||
background: var(--bg-elev, #0e0e0e); border: 1px solid var(--border, #2a1d12);
|
||||
color: var(--text-main, #e8e8e8); font-family: inherit; font-size: .85rem;
|
||||
}
|
||||
.rec-rm { background: none; border: none; color: var(--text-fade, #8a8a8a); font-size: .9rem; padding: 4px; }
|
||||
|
||||
/* typed card entry */
|
||||
.rec-field { display: block; margin-top: 10px; }
|
||||
.rec-cards {
|
||||
width: 100%; padding: 10px 11px; border-radius: 8px;
|
||||
background: var(--bg-elev, #0e0e0e); border: 1px solid var(--border, #2a1d12);
|
||||
color: var(--text-main, #e8e8e8); font-family: inherit; font-size: .95rem;
|
||||
letter-spacing: 1px; box-sizing: border-box;
|
||||
}
|
||||
.rec-cards.sm { width: 88px; flex: none; padding: 8px 9px; font-size: .85rem; }
|
||||
|
||||
.rec-street-tabs { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||
.rec-tab {
|
||||
flex: 1; padding: 9px 6px; border-radius: 9px; font-size: .78rem; font-weight: 600;
|
||||
background: var(--bg-elev, #0e0e0e); color: var(--text-main, #e8e8e8); border: 1px solid var(--border, #2a1d12);
|
||||
}
|
||||
.rec-tab.on { background: var(--accent, #ff7a00); color: #111; border-color: var(--accent, #ff7a00); }
|
||||
|
||||
.rec-act-add { display: flex; gap: 6px; margin: 8px 0; }
|
||||
.rec-sel, .rec-num {
|
||||
padding: 9px 8px; border-radius: 8px; background: var(--bg-elev, #0e0e0e);
|
||||
border: 1px solid var(--border, #2a1d12); color: var(--text-main, #e8e8e8);
|
||||
font-family: inherit; font-size: .85rem; min-width: 0;
|
||||
}
|
||||
.rec-sel { flex: 1; }
|
||||
.rec-num { width: 70px; }
|
||||
.rec-add-act { padding: 9px 12px; border-radius: 8px; background: var(--border-bright, #4a2f15); color: #fff; border: none; font-weight: 600; }
|
||||
.rec-result { display: flex; gap: 12px; }
|
||||
.rec-result label { display: flex; flex-direction: column; gap: 4px; font-size: .72rem; color: var(--text-fade, #8a8a8a); }
|
||||
|
||||
.rec-log { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; }
|
||||
.rec-ln { font-size: .82rem; color: var(--text-main, #e8e8e8); }
|
||||
.rec-ln.brd { display: flex; gap: 4px; align-items: center; color: var(--text-fade, #8a8a8a); }
|
||||
.rec-ln b { color: var(--accent, #ff7a00); font-weight: 700; }
|
||||
|
||||
.rec-foot {
|
||||
display: flex; gap: 10px;
|
||||
padding: 12px 14px;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--border, #2a1d12);
|
||||
}
|
||||
.rec-save { flex: 1; padding: 14px; border-radius: 10px; background: var(--accent, #ff7a00); color: #111; border: none; font-weight: 700; font-size: 1rem; }
|
||||
.rec-save:disabled { opacity: .6; }
|
||||
.rec-cancel { padding: 14px 18px; border-radius: 10px; background: var(--bg-elev, #0e0e0e); color: var(--text-fade, #8a8a8a); border: 1px solid var(--border, #2a1d12); font-weight: 600; font-size: 1rem; }
|
||||
|
||||
.rec-undo { background: none; border: none; color: var(--text-fade, #8a8a8a); font-size: .75rem; padding: 0 4px; }
|
||||
|
||||
/* The Record tab swaps in for Mind in the bottom bar, but only in poker (cash) mode.
|
||||
body.cash-mode is toggled on mode change in index.html. */
|
||||
#tabbar .tab-rec { display: none; }
|
||||
body.cash-mode #tabbar .tab-rec { display: flex; }
|
||||
body.cash-mode #tabbar .tab-mind { display: none; }
|
||||
#tabbar .tab-rec .ti { color: var(--accent, #ff7a00); filter: none; }
|
||||
@@ -1,425 +0,0 @@
|
||||
/* Hand recorder — tap-to-build poker hands. See docs/RECORDER.md.
|
||||
*
|
||||
* Correctness by construction: each field writes a known value into a known slot,
|
||||
* so there's no LLM parse step that can be wrong. Output is the canonical structured
|
||||
* contract (docs/HAND_HISTORY.md); the server's normalize_structured() is the final
|
||||
* authority on shape (case, suits, 10->T, completeness), so this stays best-effort.
|
||||
*
|
||||
* Mount-agnostic: Recorder.mount(container, opts) renders into ANY element — a
|
||||
* full-screen overlay in index.html today, a standalone recorder.html later, with
|
||||
* zero logic changes. buildStructured(state) is pure (no DOM) — the reusable core.
|
||||
*
|
||||
* Card entry: plain typed text for now ("ah kh", "AhKh", "7d 2c 5h"). The tap picker
|
||||
* is shelved (docs/RECORDER.md V2) — parseCards() + server normalize handle the rest.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const SUITS = { s: "♠", h: "♥", d: "♦", c: "♣" };
|
||||
const POSITIONS = ["UTG", "UTG1", "UTG2", "MP", "LJ", "HJ", "CO", "BTN", "SB", "BB"];
|
||||
const STREETS = ["preflop", "flop", "turn", "river"];
|
||||
const STREET_BOARD = { flop: 3, turn: 1, river: 1 };
|
||||
const ACTIONS = ["fold", "check", "call", "bet", "raise", "allin"];
|
||||
const SIZED = { bet: true, raise: true, allin: true };
|
||||
|
||||
// --- card text -> tokens (server normalizes case/suit/10) ------------------
|
||||
function parseCards(str) {
|
||||
if (!str) return [];
|
||||
const s = String(str).trim().replace(/10/g, "T");
|
||||
if (!s) return [];
|
||||
const parts = /\s/.test(s) ? s.split(/\s+/) : s.match(/.{1,2}/g) || [];
|
||||
return parts.map((p) => p.trim()).filter(Boolean);
|
||||
}
|
||||
function cardsText(arr) {
|
||||
return arr && arr.length ? arr.join(" ") : "";
|
||||
}
|
||||
|
||||
// --- pure core: state -> contract dict (testable, no DOM) ------------------
|
||||
function buildStructured(state) {
|
||||
const players = state.seats
|
||||
.filter((s) => s.pos)
|
||||
.map((s) => {
|
||||
const p = { pos: s.pos };
|
||||
if (s.stack != null) p.stack = s.stack;
|
||||
if (s.name) p.name = s.name;
|
||||
p.cards = s.cards && s.cards.length ? s.cards.slice() : null;
|
||||
return p;
|
||||
});
|
||||
|
||||
const actions = [];
|
||||
for (const st of STREETS) {
|
||||
const reveal = state.board[st];
|
||||
if (st !== "preflop" && reveal && reveal.length) {
|
||||
actions.push({ street: st, board: reveal.slice() });
|
||||
}
|
||||
for (const a of state.actions.filter((x) => x.street === st)) {
|
||||
actions.push({
|
||||
street: st,
|
||||
pos: a.pos,
|
||||
action: a.action,
|
||||
amount: a.amount != null ? a.amount : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hero = state.seats.find((s) => s.pos === state.heroPos);
|
||||
const board = [].concat(state.board.flop, state.board.turn, state.board.river);
|
||||
return {
|
||||
game: state.meta.game || "NLH",
|
||||
stakes: state.meta.stakes || null,
|
||||
hero_pos: state.heroPos || null,
|
||||
hero_cards: hero && hero.cards ? hero.cards.slice() : [],
|
||||
players,
|
||||
actions,
|
||||
board,
|
||||
result: {
|
||||
pot: state.result.pot,
|
||||
hero_net: state.result.heroNet,
|
||||
summary: state.result.summary || "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseBlinds(stakes) {
|
||||
const m = (stakes || "").match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/);
|
||||
return m ? { sb: parseFloat(m[1]), bb: parseFloat(m[2]) } : { sb: null, bb: null };
|
||||
}
|
||||
|
||||
function initialState(hud) {
|
||||
const sess = (hud && hud.session) || {};
|
||||
const stack = (hud && hud.stack) || {};
|
||||
const blinds = parseBlinds(sess.stakes);
|
||||
|
||||
const seats = [];
|
||||
for (const v of (hud && hud.villains) || []) {
|
||||
if (v.seat && POSITIONS.includes(v.seat)) {
|
||||
seats.push({ pos: v.seat, name: v.name || null, stack: null, cards: null });
|
||||
}
|
||||
}
|
||||
|
||||
const actions = [];
|
||||
if (blinds.sb != null) actions.push({ street: "preflop", pos: "SB", action: "post", amount: blinds.sb });
|
||||
if (blinds.bb != null) actions.push({ street: "preflop", pos: "BB", action: "post", amount: blinds.bb });
|
||||
|
||||
return {
|
||||
meta: {
|
||||
game: sess.game || "NLH",
|
||||
stakes: sess.stakes || null,
|
||||
venue: sess.venue || null,
|
||||
sessionId: sess.id != null ? sess.id : null,
|
||||
},
|
||||
blinds,
|
||||
heroStack: stack.current != null ? stack.current : null,
|
||||
heroPos: null,
|
||||
seats,
|
||||
street: "preflop",
|
||||
board: { flop: [], turn: [], river: [] },
|
||||
actions,
|
||||
result: { pot: null, heroNet: null, summary: "" },
|
||||
};
|
||||
}
|
||||
|
||||
function ensureHero(state) {
|
||||
let hero = state.seats.find((s) => s.pos === state.heroPos);
|
||||
if (!hero && state.heroPos) {
|
||||
hero = { pos: state.heroPos, name: "Hero", stack: state.heroStack, cards: [] };
|
||||
state.seats.push(hero);
|
||||
}
|
||||
return hero || {};
|
||||
}
|
||||
|
||||
window.Recorder = {
|
||||
buildStructured,
|
||||
parseCards,
|
||||
parseBlinds,
|
||||
initialState,
|
||||
_internals: { POSITIONS, STREETS },
|
||||
mount,
|
||||
};
|
||||
|
||||
// --- mount / render -------------------------------------------------------
|
||||
async function mount(container, opts) {
|
||||
opts = opts || {};
|
||||
let hud = opts.hud;
|
||||
if (!hud) {
|
||||
try {
|
||||
const url = opts.sessionId != null ? `/session/data?id=${opts.sessionId}` : "/session/data";
|
||||
hud = await fetch(url).then((r) => r.json());
|
||||
} catch (e) {
|
||||
hud = { session: null };
|
||||
}
|
||||
}
|
||||
const state = initialState(hud);
|
||||
const ctx = { container, state, opts };
|
||||
container.classList.add("rec-root");
|
||||
container.addEventListener("click", (e) => handleClick(ctx, e));
|
||||
container.addEventListener("input", (e) => handleInput(ctx, e));
|
||||
render(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function render(ctx) {
|
||||
const s = ctx.state;
|
||||
const hero = s.seats.find((x) => x.pos === s.heroPos) || {};
|
||||
ctx.container.innerHTML = `
|
||||
<div class="rec-head">
|
||||
<div class="rec-title">Record hand</div>
|
||||
<div class="rec-meta">${esc(s.meta.venue || "")}${s.meta.stakes ? " · " + esc(s.meta.stakes) : ""}</div>
|
||||
<button class="rec-x" data-act="close">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="rec-body">
|
||||
<section class="rec-sec">
|
||||
<div class="rec-label">Your seat</div>
|
||||
<div class="rec-pos-row">
|
||||
${POSITIONS.map((p) => `<button class="rec-pos${s.heroPos === p ? " on" : ""}" data-act="hero-pos" data-pos="${p}">${p}</button>`).join("")}
|
||||
</div>
|
||||
<label class="rec-field">
|
||||
<span class="rec-label">your cards</span>
|
||||
<input class="rec-cards" data-act="hero-cards" autocapitalize="off" autocomplete="off" spellcheck="false"
|
||||
placeholder="e.g. ah kh" value="${esc(cardsText(hero.cards))}">
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="rec-sec">
|
||||
<div class="rec-label">Players in the hand</div>
|
||||
<div class="rec-seats">
|
||||
${s.seats.filter((x) => x.pos !== s.heroPos).map((seat) => renderSeat(seat)).join("") || '<div class="rec-dim">none yet</div>'}
|
||||
</div>
|
||||
<div class="rec-pos-row">
|
||||
${POSITIONS.filter((p) => p !== s.heroPos && !s.seats.some((x) => x.pos === p)).map((p) => `<button class="rec-pos add" data-act="add-seat" data-pos="${p}">+ ${p}</button>`).join("")}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rec-sec">
|
||||
<div class="rec-label">Streets</div>
|
||||
<div class="rec-street-tabs">
|
||||
${STREETS.map((st) => `<button class="rec-tab${s.street === st ? " on" : ""}" data-act="street" data-street="${st}">${st}${boardCount(s, st)}</button>`).join("")}
|
||||
</div>
|
||||
${renderStreet(ctx)}
|
||||
</section>
|
||||
|
||||
<section class="rec-sec">
|
||||
<div class="rec-label">Result</div>
|
||||
<div class="rec-result">
|
||||
<label>pot <input class="rec-num" data-act="result" data-k="pot" inputmode="decimal" value="${s.result.pot != null ? s.result.pot : ""}"></label>
|
||||
<label>your net <input class="rec-num" data-act="result" data-k="heroNet" inputmode="decimal" value="${s.result.heroNet != null ? s.result.heroNet : ""}"></label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="rec-foot">
|
||||
<button class="rec-cancel" data-act="close">Cancel</button>
|
||||
<button class="rec-save" data-act="save">Save & replay</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSeat(seat) {
|
||||
return `
|
||||
<div class="rec-seat">
|
||||
<span class="rec-seat-pos">${seat.pos}</span>
|
||||
<input class="rec-name" data-act="seat-name" data-pos="${seat.pos}" autocapitalize="off" autocomplete="off"
|
||||
placeholder="name" value="${esc(seat.name || "")}">
|
||||
<input class="rec-cards sm" data-act="seat-cards" data-pos="${seat.pos}" autocapitalize="off" autocomplete="off" spellcheck="false"
|
||||
placeholder="shown?" value="${esc(cardsText(seat.cards))}">
|
||||
<button class="rec-rm" data-act="rm-seat" data-pos="${seat.pos}">✕</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderStreet(ctx) {
|
||||
const s = ctx.state;
|
||||
const st = s.street;
|
||||
const players = s.seats.map((x) => x.pos);
|
||||
const boardInput =
|
||||
st === "preflop"
|
||||
? ""
|
||||
: `<label class="rec-field">
|
||||
<span class="rec-label">${st} board (${STREET_BOARD[st]})</span>
|
||||
<input class="rec-cards" data-act="board-cards" data-street="${st}" autocapitalize="off" autocomplete="off" spellcheck="false"
|
||||
placeholder="${st === "flop" ? "e.g. 7d 2c 5h" : "e.g. 5h"}" value="${esc(cardsText(s.board[st]))}">
|
||||
</label>`;
|
||||
|
||||
// Straddle: a voluntary preflop blind from any non-blind seat, default 2×BB.
|
||||
// Action starts left of it and it acts last preflop — order is whatever you enter.
|
||||
const stradAmt = s.blinds.bb != null ? 2 * s.blinds.bb : null;
|
||||
const stradElig = players.filter((p) => p !== "SB" && p !== "BB" && !s.actions.some((a) => a.straddle && a.pos === p));
|
||||
const straddle =
|
||||
st === "preflop" && stradElig.length
|
||||
? `<div class="rec-act-add">
|
||||
<select class="rec-sel" data-act="str-pos">
|
||||
<option value="">+ straddle${stradAmt != null ? " (" + stradAmt + ")" : ""}…</option>
|
||||
${stradElig.map((p) => `<option value="${p}">${p}</option>`).join("")}
|
||||
</select>
|
||||
<button class="rec-add-act" data-act="add-straddle">add</button>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
${boardInput}
|
||||
${straddle}
|
||||
<div class="rec-act-add">
|
||||
<select class="rec-sel" data-act="na-pos">
|
||||
<option value="">who</option>
|
||||
${players.map((p) => `<option value="${p}">${p}${p === s.heroPos ? " (you)" : ""}</option>`).join("")}
|
||||
</select>
|
||||
<select class="rec-sel" data-act="na-action">
|
||||
<option value="">action</option>
|
||||
${ACTIONS.map((a) => `<option value="${a}">${a}</option>`).join("")}
|
||||
</select>
|
||||
<input class="rec-num" data-act="na-amount" inputmode="decimal" placeholder="$">
|
||||
<button class="rec-add-act" data-act="add-action">add</button>
|
||||
</div>
|
||||
<div class="rec-log">
|
||||
${s.board[st] && s.board[st].length && st !== "preflop" ? `<div class="rec-ln brd">${st}: ${cardsText(s.board[st])}</div>` : ""}
|
||||
${s.actions
|
||||
.filter((a) => a.street === st)
|
||||
.map((a, i) => {
|
||||
const label = a.straddle ? "straddle" : a.action;
|
||||
const amt = a.amount != null ? " " + a.amount : "";
|
||||
const fixed = a.action === "post" && !a.straddle; // blinds aren't removable
|
||||
const rm = fixed ? "" : ` <button class="rec-undo" data-act="rm-action" data-street="${st}" data-i="${i}">✕</button>`;
|
||||
return `<div class="rec-ln">${a.pos} <b>${label}</b>${amt}${rm}</div>`;
|
||||
})
|
||||
.join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function boardCount(s, st) {
|
||||
const n = (s.board[st] || []).length;
|
||||
return n ? ` ${n}` : "";
|
||||
}
|
||||
|
||||
// --- events ---------------------------------------------------------------
|
||||
function handleClick(ctx, e) {
|
||||
const s = ctx.state;
|
||||
const t = e.target.closest("[data-act]");
|
||||
if (!t) return;
|
||||
const act = t.getAttribute("data-act");
|
||||
|
||||
switch (act) {
|
||||
case "close":
|
||||
if (ctx.opts.onClose) ctx.opts.onClose();
|
||||
return;
|
||||
case "hero-pos": {
|
||||
const pos = t.getAttribute("data-pos");
|
||||
const old = s.seats.find((x) => x.pos === s.heroPos);
|
||||
if (old && old.name === "Hero" && !(old.cards || []).length) {
|
||||
s.seats = s.seats.filter((x) => x !== old);
|
||||
}
|
||||
s.heroPos = s.heroPos === pos ? null : pos;
|
||||
if (s.heroPos) ensureHero(s);
|
||||
break;
|
||||
}
|
||||
case "add-seat":
|
||||
s.seats.push({ pos: t.getAttribute("data-pos"), name: null, stack: null, cards: null });
|
||||
break;
|
||||
case "rm-seat":
|
||||
s.seats = s.seats.filter((x) => x.pos !== t.getAttribute("data-pos"));
|
||||
break;
|
||||
case "street":
|
||||
s.street = t.getAttribute("data-street");
|
||||
break;
|
||||
case "add-action":
|
||||
addActionFromControls(ctx);
|
||||
break;
|
||||
case "add-straddle": {
|
||||
const sel = ctx.container.querySelector('[data-act="str-pos"]');
|
||||
const pos = sel && sel.value;
|
||||
if (pos) {
|
||||
const amt = s.blinds.bb != null ? 2 * s.blinds.bb : null;
|
||||
s.actions.push({ street: "preflop", pos, action: "post", amount: amt, straddle: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "rm-action":
|
||||
removeAction(s, t.getAttribute("data-street"), parseInt(t.getAttribute("data-i"), 10));
|
||||
break;
|
||||
case "save":
|
||||
return doSave(ctx);
|
||||
default:
|
||||
return; // inputs handled in handleInput
|
||||
}
|
||||
render(ctx);
|
||||
}
|
||||
|
||||
function handleInput(ctx, e) {
|
||||
const s = ctx.state;
|
||||
const t = e.target.closest("[data-act]");
|
||||
if (!t) return;
|
||||
const act = t.getAttribute("data-act");
|
||||
if (act === "hero-cards") {
|
||||
const hero = ensureHero(s);
|
||||
hero.cards = parseCards(t.value);
|
||||
} else if (act === "seat-cards") {
|
||||
const seat = s.seats.find((x) => x.pos === t.getAttribute("data-pos"));
|
||||
if (seat) seat.cards = parseCards(t.value);
|
||||
} else if (act === "board-cards") {
|
||||
s.board[t.getAttribute("data-street")] = parseCards(t.value);
|
||||
} else if (act === "seat-name") {
|
||||
const seat = s.seats.find((x) => x.pos === t.getAttribute("data-pos"));
|
||||
if (seat) seat.name = t.value.trim() || null;
|
||||
} else if (act === "result") {
|
||||
const k = t.getAttribute("data-k");
|
||||
s.result[k] = t.value === "" ? null : parseFloat(t.value);
|
||||
}
|
||||
// no re-render mid-typing (keeps input focus)
|
||||
}
|
||||
|
||||
function addActionFromControls(ctx) {
|
||||
const root = ctx.container;
|
||||
const pos = root.querySelector('[data-act="na-pos"]').value;
|
||||
const action = root.querySelector('[data-act="na-action"]').value;
|
||||
const amt = root.querySelector('[data-act="na-amount"]').value;
|
||||
if (!pos || !action) return;
|
||||
const entry = { street: ctx.state.street, pos, action };
|
||||
entry.amount = SIZED[action] && amt !== "" ? parseFloat(amt) : null;
|
||||
ctx.state.actions.push(entry);
|
||||
}
|
||||
|
||||
function removeAction(state, street, idxWithinStreet) {
|
||||
let seen = -1;
|
||||
for (let i = 0; i < state.actions.length; i++) {
|
||||
if (state.actions[i].street === street) {
|
||||
seen++;
|
||||
if (seen === idxWithinStreet) {
|
||||
state.actions.splice(i, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function doSave(ctx) {
|
||||
const structured = buildStructured(ctx.state);
|
||||
const btn = ctx.container.querySelector(".rec-save");
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Saving…";
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/hands", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ structured, session_id: ctx.state.meta.sessionId }),
|
||||
}).then((r) => r.json());
|
||||
if (res && res.ok) {
|
||||
if (ctx.opts.onSave) ctx.opts.onSave(res.id);
|
||||
else window.location.href = `/hand/${res.id}`;
|
||||
} else {
|
||||
throw new Error((res && res.error) || "save failed");
|
||||
}
|
||||
} catch (err) {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Save failed — retry";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function esc(x) {
|
||||
return String(x == null ? "" : x).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
}
|
||||
|
||||
void SUITS;
|
||||
})();
|
||||
@@ -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,7 +263,9 @@
|
||||
}
|
||||
curSession = s;
|
||||
const stack = data.stack || {};
|
||||
const timeline = data.timeline || [];
|
||||
const hands = data.hands || [];
|
||||
const roster = data.roster || [];
|
||||
const villains = data.villains || [];
|
||||
const notes = data.notes || [];
|
||||
const stats = data.stats || {};
|
||||
@@ -274,7 +320,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">
|
||||
@@ -306,11 +370,25 @@
|
||||
: '<p class="empty">No scars logged — mistakes to study land here.</p>'}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="label">🪑 Table (${roster.length})</p>
|
||||
${roster.length ? `<ul class="rows">${roster.map(v => `
|
||||
<li class="villain">
|
||||
${v.seat ? `<span class="cat">${esc(v.seat)}</span> ` : ''}<b>${esc(v.name)}</b>
|
||||
${v.category ? `<span class="cat">[${esc(v.category)}]</span>` : ''}
|
||||
${v.reads ? `<span class="cat">· ${v.reads} read${v.reads===1?'':'s'}</span>` : ''}
|
||||
<button class="mini" title="Rename / fix" onclick="renamePlayer(${v.id}, '${esc(v.name||'').replace(/'/g,"\\'")}')">✎</button>
|
||||
${v.last_note ? `<div class="note-meta">“${esc(v.last_note)}”</div>` : ''}
|
||||
</li>`).join('')}</ul>`
|
||||
: '<p class="empty">No roster yet — tell Lyra who is at the table.</p>'}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="label">Villains seen</p>
|
||||
${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>`
|
||||
|
||||
@@ -62,6 +62,12 @@ html {
|
||||
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 {
|
||||
margin: 0;
|
||||
background: var(--bg-dark);
|
||||
@@ -830,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;
|
||||
@@ -922,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);
|
||||
}
|
||||
@@ -1231,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
-1
@@ -20,7 +20,7 @@ def lyra(tmp_path, monkeypatch):
|
||||
# reflect() expects JSON back; everything else just stores the text.
|
||||
monkeypatch.setattr(
|
||||
llm, "complete",
|
||||
lambda messages, backend=None, model=None:
|
||||
lambda messages, backend=None, model=None, **_:
|
||||
'{"mood":"focused","valence":0.7,"new_reflections":["I got some thinking done."]}',
|
||||
)
|
||||
|
||||
|
||||
@@ -45,6 +45,29 @@ def test_stamps_version(poker):
|
||||
assert out["schema_version"] == poker.HAND_SCHEMA_VERSION
|
||||
|
||||
|
||||
def test_observed_hand_never_attributed_to_hero(poker):
|
||||
# Brian narrated a hand between two other players — hero_involved=false.
|
||||
out = poker.normalize_structured({
|
||||
"hero_involved": False,
|
||||
"hero_pos": "CO", "hero_cards": ["Kx", "Kx"], # model slipped these in
|
||||
"players": [{"pos": "CO", "cards": ["Kx", "Kx"]}, {"pos": "BB", "cards": ["Ax", "Ax"]}],
|
||||
"result": {"pot": 600, "hero_net": 300},
|
||||
})
|
||||
assert out["hero_pos"] is None # not pinned to Brian
|
||||
assert out["hero_cards"] == []
|
||||
assert out["result"]["hero_net"] is None # a pot he wasn't in
|
||||
assert not any(pl.get("hero") for pl in out["players"]) # nobody flagged hero
|
||||
|
||||
|
||||
def test_hero_hand_still_attributed(poker):
|
||||
out = poker.normalize_structured({
|
||||
"hero_involved": True, "hero_pos": "BTN", "hero_cards": ["As", "Ks"],
|
||||
"players": [{"pos": "BTN"}]})
|
||||
assert out["hero_pos"] == "BTN"
|
||||
hero = next(pl for pl in out["players"] if pl.get("pos") == "BTN")
|
||||
assert hero.get("hero") and hero["cards"] == ["As", "Ks"]
|
||||
|
||||
|
||||
def test_card_normalization(poker):
|
||||
out = poker.normalize_structured(_full_hand())
|
||||
assert out["hero_cards"] == ["Ah", "Kh"] # lowercased input -> canonical
|
||||
@@ -101,11 +124,3 @@ def test_list_recent_hands_flags_structured(poker):
|
||||
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
|
||||
|
||||
|
||||
def test_hud_villains_carry_seat(poker):
|
||||
"""The recorder auto-places known players, so the HUD bundle must expose their seat."""
|
||||
poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
|
||||
poker.add_read("3-bets light", seat="BTN", name="Sal", category="risky")
|
||||
villains = {v["name"]: v for v in poker.hud()["villains"]}
|
||||
assert villains["Sal"]["seat"] == "BTN"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""llm.complete: `max_tokens` and `timeout` are threaded into the backend call.
|
||||
|
||||
The OpenAI client is faked so nothing hits a network. We assert the generation
|
||||
cap reaches the create() call and the fast-fail timeout reaches the client (with
|
||||
max_retries=0 so summary.py owns the retry policy, not the SDK).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from lyra import llm
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_openai(monkeypatch):
|
||||
recorded: dict = {}
|
||||
|
||||
class FakeCompletions:
|
||||
def create(self, **kwargs):
|
||||
recorded["create"] = kwargs
|
||||
msg = types.SimpleNamespace(content="ok")
|
||||
return types.SimpleNamespace(choices=[types.SimpleNamespace(message=msg)])
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **kwargs):
|
||||
recorded["client"] = kwargs
|
||||
self.chat = types.SimpleNamespace(completions=FakeCompletions())
|
||||
|
||||
monkeypatch.setattr(llm, "OpenAI", FakeClient)
|
||||
monkeypatch.setattr(llm, "load", lambda: types.SimpleNamespace(
|
||||
mi50_base_url="http://mi50/v1", mi50_model="local-gpu",
|
||||
cloud_model="gpt-4o-mini", openai_api_key="sk-test", local_model="l",
|
||||
))
|
||||
return recorded
|
||||
|
||||
|
||||
def test_mi50_threads_max_tokens_and_timeout(fake_openai):
|
||||
out = llm.complete([{"role": "user", "content": "hi"}],
|
||||
backend="mi50", max_tokens=768, timeout=150)
|
||||
|
||||
assert out == "ok"
|
||||
assert fake_openai["create"]["max_tokens"] == 768
|
||||
assert fake_openai["client"]["timeout"] == 150
|
||||
assert fake_openai["client"]["max_retries"] == 0
|
||||
|
||||
|
||||
def test_cloud_threads_max_tokens_and_timeout(fake_openai):
|
||||
llm.complete([{"role": "user", "content": "hi"}],
|
||||
backend="cloud", max_tokens=768, timeout=150)
|
||||
|
||||
assert fake_openai["create"]["max_tokens"] == 768
|
||||
assert fake_openai["client"]["timeout"] == 150
|
||||
assert fake_openai["client"]["max_retries"] == 0
|
||||
|
||||
|
||||
def test_defaults_omit_cap_and_keep_current_behavior(fake_openai):
|
||||
# No cap / timeout passed -> create() gets no max_tokens, client unbounded.
|
||||
llm.complete([{"role": "user", "content": "hi"}], backend="mi50")
|
||||
|
||||
assert "max_tokens" not in fake_openai["create"]
|
||||
assert "timeout" not in fake_openai["client"]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Pattern desk: embedded scar recall + strategy gating in the scouting desk."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
def _idx(w: str) -> int:
|
||||
# Stable across processes (unlike hash()), so threshold tests aren't flaky.
|
||||
return int.from_bytes(hashlib.md5(w.encode()).digest()[:4], "little") % 256
|
||||
|
||||
|
||||
def _fake_embed(texts):
|
||||
out = []
|
||||
for t in texts:
|
||||
v = np.zeros(256, dtype=np.float32)
|
||||
for w in t.lower().split():
|
||||
v[_idx(w)] += 1.0
|
||||
out.append((v if v.any() else np.full(256, 1e-6, dtype=np.float32)).tolist())
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mods(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||
from lyra import llm
|
||||
monkeypatch.setattr(llm, "embed", _fake_embed)
|
||||
import lyra.memory as memory
|
||||
importlib.reload(memory)
|
||||
import lyra.poker as poker
|
||||
importlib.reload(poker)
|
||||
import lyra.scouting as scouting
|
||||
importlib.reload(scouting)
|
||||
return poker, scouting
|
||||
|
||||
|
||||
def test_scar_recall_finds_similar_past_leak(mods):
|
||||
poker, _ = mods
|
||||
old = poker.start_session(stakes="1/3", buy_in=300)
|
||||
poker.log_ritual("scar", "overvalued top pair and stacked off on a wet board",
|
||||
classification="punt", session_id=old)
|
||||
poker.end_session(200, session_id=old)
|
||||
hits = poker.recall_similar_rituals("stacked off top pair wet board again")
|
||||
assert hits and hits[0]["classification"] == "punt"
|
||||
|
||||
|
||||
def test_recall_excludes_current_session(mods):
|
||||
poker, _ = mods
|
||||
sid = poker.start_session(stakes="1/3", buy_in=300)
|
||||
poker.log_ritual("scar", "punted river bluff into the nut flush", session_id=sid)
|
||||
assert poker.recall_similar_rituals("river bluff nut flush punt", exclude_session=sid) == []
|
||||
|
||||
|
||||
def test_pattern_pass_only_fires_on_strategic_talk(mods):
|
||||
poker, scouting = mods
|
||||
old = poker.start_session(stakes="1/3", buy_in=300)
|
||||
poker.log_ritual("scar", "punting river bluffs into missed draws again",
|
||||
classification="punt", session_id=old)
|
||||
poker.end_session(200, session_id=old)
|
||||
poker.start_session(stakes="1/3", buy_in=300, venue="Meadows")
|
||||
# A routine, non-strategic line pays no embed and surfaces nothing.
|
||||
assert scouting.scout("stack is 350 now", venue="Meadows") is None
|
||||
# A real strategy question in the same shape recalls the leak. (The test stub
|
||||
# embeds by shared tokens; real embeddings match on meaning/paraphrase.)
|
||||
note = scouting.scout(
|
||||
"why do i keep punting river bluffs into missed draws", venue="Meadows")
|
||||
assert note and "punting river bluffs" in note
|
||||
@@ -18,6 +18,50 @@ def lyra(tmp_path, monkeypatch):
|
||||
return poker
|
||||
|
||||
|
||||
def test_disown_hand_clears_hero_attribution(lyra):
|
||||
poker = lyra
|
||||
sid = poker.start_session(venue="Meadows", buy_in=300)
|
||||
hid = poker.store_hand_history(
|
||||
{"hero_involved": True, "hero_pos": "MP", "hero_cards": ["As", "3d"],
|
||||
"players": [{"pos": "MP", "cards": ["As", "3d"]}],
|
||||
"result": {"pot": 600, "hero_net": 304}}, session_id=sid, tag="notable")
|
||||
h = poker.disown_hand(hid)
|
||||
assert h["position"] is None and h["hole_cards"] is None and h["result"] is None
|
||||
st = h["structured"]
|
||||
if isinstance(st, str):
|
||||
import json
|
||||
st = json.loads(st)
|
||||
assert st["hero_pos"] is None and not any(pl.get("hero") for pl in st["players"])
|
||||
|
||||
|
||||
def test_hud_notes_scoped_to_session_by_tag(lyra):
|
||||
poker = lyra
|
||||
from lyra import memory
|
||||
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
|
||||
# A note tagged to THIS session shows on its HUD...
|
||||
memory.add_journal_entry("note", "villain 3 overfolds turn", source=f"poker:{sid}")
|
||||
# ...her autonomous existential journaling (any other source) does NOT, even
|
||||
# though it's written during the exact same window...
|
||||
memory.add_journal_entry("journal", "the quiet dread between conversations", source="dream")
|
||||
# ...nor a note from a *different* poker session.
|
||||
memory.add_journal_entry("note", "some other night", source=f"poker:{sid + 999}")
|
||||
contents = [n["content"] for n in poker.hud(sid)["notes"]]
|
||||
assert contents == ["villain 3 overfolds turn"]
|
||||
|
||||
|
||||
def test_note_tool_tags_live_poker_session(lyra):
|
||||
poker = lyra
|
||||
from lyra import tools
|
||||
sid = poker.start_session(stakes="1/3", buy_in=300)
|
||||
tools.dispatch("note", {"content": "whale just sat down seat 4"}, {})
|
||||
assert poker.hud(sid)["notes"][0]["content"] == "whale just sat down seat 4"
|
||||
poker.end_session(cash_out=300, session_id=sid)
|
||||
# With no live session, a note falls back to the general journal (source=chat),
|
||||
# so it does NOT attach to the just-closed session's HUD.
|
||||
tools.dispatch("note", {"content": "random afternoon idea"}, {})
|
||||
assert all(n["content"] != "random afternoon idea" for n in poker.hud(sid)["notes"])
|
||||
|
||||
|
||||
def test_session_lifecycle_and_net(lyra):
|
||||
poker = lyra
|
||||
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=400)
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Live table roster: seat players, attach reads by handle, roster on the HUD."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
def _fake_embed(texts):
|
||||
out = []
|
||||
for t in texts:
|
||||
v = np.zeros(64, dtype=np.float32)
|
||||
for w in t.lower().split():
|
||||
v[hash(w) % 64] += 1.0
|
||||
out.append((v if v.any() else np.full(64, 1e-6, dtype=np.float32)).tolist())
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mods(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||
from lyra import llm
|
||||
monkeypatch.setattr(llm, "embed", _fake_embed)
|
||||
import lyra.memory as memory
|
||||
importlib.reload(memory)
|
||||
import lyra.poker as poker
|
||||
importlib.reload(poker)
|
||||
import lyra.tools as tools
|
||||
importlib.reload(tools)
|
||||
return poker, tools
|
||||
|
||||
|
||||
def test_seat_players_builds_roster(mods):
|
||||
poker, _ = mods
|
||||
poker.start_session(venue="Meadows", buy_in=300)
|
||||
n = poker.seat_players(["TAG", "Jonathan", {"name": "Wheelz", "seat": "3"}])
|
||||
assert n == 3
|
||||
roster = poker.session_roster()
|
||||
names = {r["name"] for r in roster}
|
||||
assert names == {"TAG", "Jonathan", "Wheelz"}
|
||||
assert next(r for r in roster if r["name"] == "Wheelz")["seat"] == "3"
|
||||
|
||||
|
||||
def test_read_attaches_to_seated_player_by_handle(mods):
|
||||
poker, _ = mods
|
||||
poker.start_session(venue="Meadows", buy_in=300)
|
||||
poker.seat_players(["TAG"])
|
||||
poker.add_read(note="limped A4o from the SB, UTG straddle", name="TAG")
|
||||
roster = poker.session_roster()
|
||||
tag = next(r for r in roster if r["name"] == "TAG")
|
||||
assert tag["reads"] == 1 and "A4o" in tag["last_note"]
|
||||
# No duplicate TAG spawned — the read landed on the seated player.
|
||||
assert sum(p["name"] == "TAG" for p in poker.get_villain_file()) == 1
|
||||
|
||||
|
||||
def test_seat_players_tool_and_roster_in_hud(mods):
|
||||
poker, tools = mods
|
||||
poker.start_session(venue="Meadows", buy_in=300)
|
||||
out = tools.dispatch("seat_players", {"players": [{"name": "TAG"}, {"name": "JD"}]}, {})
|
||||
assert "TAG" in out and "JD" in out
|
||||
assert len(poker.hud()["roster"]) == 2
|
||||
|
||||
|
||||
def test_unseat_player_removes_from_roster_keeps_history(mods):
|
||||
poker, _ = mods
|
||||
poker.start_session(venue="Meadows", buy_in=300)
|
||||
poker.seat_players(["TAG"])
|
||||
poker.add_read(note="showed a bluff", name="TAG")
|
||||
assert poker.unseat_player(name="TAG") is True
|
||||
assert poker.session_roster() == [] # off the table
|
||||
assert poker.player_profile("TAG")["reads"] # history intact
|
||||
|
||||
|
||||
def test_clear_table_empties_roster_keeps_reads(mods):
|
||||
poker, tools = mods
|
||||
poker.start_session(venue="Meadows", buy_in=300)
|
||||
poker.seat_players(["TAG", "Jonathan"])
|
||||
poker.add_read(note="limped A4o", name="TAG")
|
||||
out = tools.dispatch("clear_table", {}, {})
|
||||
assert "cleared" in out.lower()
|
||||
assert poker.session_roster() == [] # roster emptied
|
||||
assert poker.player_profile("TAG")["reads"] # reads kept
|
||||
# A live session is untouched by clearing the table.
|
||||
assert poker.live_session() is not None
|
||||
|
||||
|
||||
def test_seat_players_replace_swaps_to_new_table(mods):
|
||||
poker, tools = mods
|
||||
poker.start_session(venue="Meadows", buy_in=300)
|
||||
poker.seat_players(["TAG", "Jonathan"])
|
||||
tools.dispatch("seat_players", {"players": [{"name": "Doyle"}, {"name": "Ivey"}],
|
||||
"replace": True}, {})
|
||||
assert {r["name"] for r in poker.session_roster()} == {"Doyle", "Ivey"}
|
||||
|
||||
|
||||
def test_seat_players_accepts_plain_name_list_via_tool(mods):
|
||||
poker, tools = mods
|
||||
poker.start_session(venue="Meadows", buy_in=300)
|
||||
tools.dispatch("seat_players", {"players": "TAG, JD, Wheelz"}, {})
|
||||
assert {r["name"] for r in poker.session_roster()} == {"TAG", "JD", "Wheelz"}
|
||||
@@ -0,0 +1,73 @@
|
||||
"""The scouting desk: named + descriptor recall, ambiguous→queue, generic→silence."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
def _fake_embed(texts):
|
||||
out = []
|
||||
for t in texts:
|
||||
v = np.zeros(64, dtype=np.float32)
|
||||
for w in t.lower().split():
|
||||
v[hash(w) % 64] += 1.0
|
||||
out.append((v if v.any() else np.full(64, 1e-6, dtype=np.float32)).tolist())
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mods(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||
from lyra import llm
|
||||
monkeypatch.setattr(llm, "embed", _fake_embed)
|
||||
import lyra.memory as memory
|
||||
importlib.reload(memory)
|
||||
import lyra.poker as poker
|
||||
importlib.reload(poker)
|
||||
import lyra.scouting as scouting
|
||||
importlib.reload(scouting)
|
||||
return poker, scouting
|
||||
|
||||
|
||||
def test_named_player_surfaces_a_brief(mods):
|
||||
poker, scouting = mods
|
||||
sid = poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
|
||||
pid = poker.upsert_player("Sleepy John", venue="Meadows", category="reg")
|
||||
poker._c().execute(
|
||||
"INSERT INTO player_observations (player_id, session_id, cards, created_at) VALUES (?,?,?,?)",
|
||||
(pid, sid, "As Ks", poker._now()))
|
||||
poker._c().commit()
|
||||
note = scouting.scout("sleepy john just sat down on my left", venue="Meadows")
|
||||
assert note and "Sleepy John" in note and "SCOUTING DESK" in note
|
||||
|
||||
|
||||
def test_descriptor_high_match_surfaces_with_confirm(mods):
|
||||
poker, scouting = mods
|
||||
poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows", category="reg")
|
||||
note = scouting.scout("the neck tattoo sleeve guy just 3bet me again", venue="Meadows")
|
||||
assert note and "confirm it's the same guy" in note
|
||||
|
||||
|
||||
def test_ambiguous_descriptor_queues_instead_of_interrupting(mods):
|
||||
poker, scouting = mods
|
||||
poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows")
|
||||
note = scouting.scout("the neck tattoo guy raised", venue="Meadows")
|
||||
assert note is None # didn't interrupt
|
||||
q = poker.list_identity_queue()
|
||||
assert q and q[0]["kind"] == "needs_clarification"
|
||||
|
||||
|
||||
def test_generic_descriptor_stays_silent(mods):
|
||||
poker, scouting = mods
|
||||
poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows")
|
||||
note = scouting.scout("the mid aged white guy with glasses raised", venue="Meadows")
|
||||
assert note is None
|
||||
assert poker.list_identity_queue() == [] # no queue spam for a non-identifier
|
||||
|
||||
|
||||
def test_no_player_reference_returns_nothing(mods):
|
||||
poker, scouting = mods
|
||||
poker.upsert_player("Sleepy John", venue="Meadows")
|
||||
assert scouting.scout("i folded pocket kings to a 4bet", venue="Meadows") is None
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Summary consolidation: MI50 length cap, fast-fail, and cloud fallback.
|
||||
|
||||
Everything is stubbed — no real backend is touched. These drive the behavior of
|
||||
`summary._summarize_text`: try the primary backend a bounded number of times with
|
||||
a capped generation length, and fall back to cloud if the primary keeps failing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from lyra import summary
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def calls(monkeypatch):
|
||||
"""Capture every llm.complete call; per-test behavior via `fake.responder`."""
|
||||
recorded: list[dict] = []
|
||||
|
||||
def fake_complete(messages, backend="local", model=None,
|
||||
max_tokens=None, timeout=None):
|
||||
recorded.append({"backend": backend, "max_tokens": max_tokens, "timeout": timeout})
|
||||
return fake_complete.responder(backend)
|
||||
|
||||
fake_complete.responder = lambda backend: "gist"
|
||||
monkeypatch.setattr(summary.llm, "complete", fake_complete)
|
||||
monkeypatch.setattr(summary.time, "sleep", lambda *_: None) # instant backoff
|
||||
return types.SimpleNamespace(recorded=recorded, fake=fake_complete)
|
||||
|
||||
|
||||
def _set_key(monkeypatch, key="sk-test"):
|
||||
monkeypatch.setattr(summary.config, "load",
|
||||
lambda: types.SimpleNamespace(openai_api_key=key))
|
||||
|
||||
|
||||
def test_falls_back_to_cloud_after_mi50_attempts(calls, monkeypatch):
|
||||
_set_key(monkeypatch)
|
||||
|
||||
def responder(backend):
|
||||
if backend == "mi50":
|
||||
raise RuntimeError("Request timed out.")
|
||||
return "cloud-gist"
|
||||
calls.fake.responder = responder
|
||||
|
||||
out = summary._summarize_text("transcript", "mi50")
|
||||
|
||||
assert out == "cloud-gist"
|
||||
assert [c["backend"] for c in calls.recorded] == ["mi50", "mi50", "cloud"]
|
||||
|
||||
|
||||
def test_no_fallback_when_backend_is_cloud(calls, monkeypatch):
|
||||
_set_key(monkeypatch)
|
||||
calls.fake.responder = lambda backend: (_ for _ in ()).throw(RuntimeError("boom"))
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
summary._summarize_text("t", "cloud")
|
||||
|
||||
# Cloud is already the primary: retry it, but never a redundant fallback.
|
||||
assert [c["backend"] for c in calls.recorded] == ["cloud", "cloud"]
|
||||
|
||||
|
||||
def test_no_fallback_without_openai_key(calls, monkeypatch):
|
||||
_set_key(monkeypatch, key="")
|
||||
calls.fake.responder = lambda backend: (_ for _ in ()).throw(RuntimeError("mi50 down"))
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
summary._summarize_text("t", "mi50")
|
||||
|
||||
assert [c["backend"] for c in calls.recorded] == ["mi50", "mi50"]
|
||||
|
||||
|
||||
def test_caps_length_and_timeout_on_every_call(calls, monkeypatch):
|
||||
_set_key(monkeypatch)
|
||||
|
||||
def responder(backend):
|
||||
if backend == "mi50":
|
||||
raise RuntimeError("nope")
|
||||
return "cloud-gist"
|
||||
calls.fake.responder = responder
|
||||
|
||||
summary._summarize_text("t", "mi50")
|
||||
|
||||
assert calls.recorded
|
||||
for c in calls.recorded:
|
||||
assert c["max_tokens"] == summary.SUMMARY_MAX_TOKENS
|
||||
assert c["timeout"] == summary.SUMMARY_TIMEOUT
|
||||
|
||||
|
||||
def test_happy_path_uses_primary_only(calls, monkeypatch):
|
||||
_set_key(monkeypatch)
|
||||
calls.fake.responder = lambda backend: "mi50-gist"
|
||||
|
||||
out = summary._summarize_text("t", "mi50")
|
||||
|
||||
assert out == "mi50-gist"
|
||||
assert [c["backend"] for c in calls.recorded] == ["mi50"] # no retries, no fallback
|
||||
|
||||
|
||||
# --- degenerate ("?" garbage) output guard: a wedged local model returns junk as
|
||||
# a successful 200, so treat it as a failure and fall back to cloud. ---
|
||||
|
||||
def test_looks_degenerate_flags_repeated_char():
|
||||
assert summary._looks_degenerate("?" * 60) is True
|
||||
assert summary._looks_degenerate("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") is True
|
||||
|
||||
|
||||
def test_looks_degenerate_passes_real_prose():
|
||||
gist = ("Brian sat down at the Meadows 1/3 in seat 6 with two straddles active; "
|
||||
"he tagged a seat-3 calling station and finished the session up 240.")
|
||||
assert summary._looks_degenerate(gist) is False
|
||||
|
||||
|
||||
def test_looks_degenerate_ignores_short_output():
|
||||
# Too short to judge — don't false-positive a terse-but-valid reply.
|
||||
assert summary._looks_degenerate("ok") is False
|
||||
|
||||
|
||||
def test_degenerate_mi50_output_falls_back_to_cloud(calls, monkeypatch):
|
||||
_set_key(monkeypatch)
|
||||
|
||||
def responder(backend):
|
||||
if backend == "mi50":
|
||||
return "?" * 200 # garbage-as-200, not an exception
|
||||
return "a real cloud gist of the session, diverse and coherent."
|
||||
calls.fake.responder = responder
|
||||
|
||||
out = summary._summarize_text("transcript", "mi50")
|
||||
|
||||
assert "cloud gist" in out
|
||||
assert [c["backend"] for c in calls.recorded] == ["mi50", "mi50", "cloud"]
|
||||
|
||||
|
||||
def test_degenerate_cloud_output_raises_no_infinite_loop(calls, monkeypatch):
|
||||
_set_key(monkeypatch)
|
||||
calls.fake.responder = lambda backend: "?" * 200 # every backend returns garbage
|
||||
|
||||
with pytest.raises(Exception):
|
||||
summary._summarize_text("t", "mi50")
|
||||
|
||||
# mi50 x2, then one cloud fallback that's also garbage -> give up, no loop.
|
||||
assert [c["backend"] for c in calls.recorded] == ["mi50", "mi50", "cloud"]
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Conversation export: speech (exchanges) + actions (tool_events) merged in order."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _const_embed(texts):
|
||||
return [[1e-6] * 8 for _ in texts]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mods(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||
from lyra import llm
|
||||
monkeypatch.setattr(llm, "embed", _const_embed)
|
||||
import lyra.memory as memory
|
||||
importlib.reload(memory)
|
||||
import lyra.transcript as transcript
|
||||
importlib.reload(transcript)
|
||||
return memory, transcript
|
||||
|
||||
|
||||
def _seed(memory):
|
||||
"""A turn where Brian narrates a hand and Lyra logs it, then replies."""
|
||||
memory.ensure_session("s1", name="Meadows 1/3")
|
||||
memory.remember("s1", "user", "got it in with a set, he had the flush draw and bricked")
|
||||
memory.add_tool_event("s1", "record_hand", {"result": "won", "pot": 750}, "hand #42 logged")
|
||||
memory.add_tool_event("s1", "log_stack", {"amount": 750, "note": "doubled up"}, "ok")
|
||||
memory.remember("s1", "assistant", "Clean stack-off — logged it to your timeline.")
|
||||
|
||||
|
||||
def test_tool_events_roundtrip_parses_args(mods):
|
||||
memory, _ = mods
|
||||
_seed(memory)
|
||||
events = memory.tool_events("s1")
|
||||
assert [e["tool"] for e in events] == ["record_hand", "log_stack"]
|
||||
assert events[0]["args"] == {"result": "won", "pot": 750} # parsed back to a dict
|
||||
assert events[1]["result"] == "ok"
|
||||
|
||||
|
||||
def test_markdown_interleaves_speech_and_actions_in_order(mods):
|
||||
memory, transcript = mods
|
||||
_seed(memory)
|
||||
md = transcript.as_markdown("s1", name="Meadows 1/3")
|
||||
# user message, then both tool calls, then assistant reply — in that order
|
||||
i_user = md.index("got it in with a set")
|
||||
i_hand = md.index("record_hand")
|
||||
i_stack = md.index("log_stack")
|
||||
i_reply = md.index("Clean stack-off")
|
||||
assert i_user < i_hand < i_stack < i_reply
|
||||
assert "**Brian**" in md and "**Lyra**" in md
|
||||
assert "⚙" in md
|
||||
|
||||
|
||||
def test_json_export_is_machine_readable(mods):
|
||||
memory, transcript = mods
|
||||
_seed(memory)
|
||||
payload = transcript.as_json("s1", name="Meadows 1/3")
|
||||
assert payload["session_id"] == "s1"
|
||||
types = [e["type"] for e in payload["events"]]
|
||||
assert types == ["message", "tool", "tool", "message"]
|
||||
json.dumps(payload) # must be serializable
|
||||
|
||||
|
||||
def test_build_returns_filename_and_media_type(mods):
|
||||
memory, transcript = mods
|
||||
_seed(memory)
|
||||
body_md, mt_md, fn_md = transcript.build("s1", "md", "Meadows 1/3")
|
||||
body_js, mt_js, fn_js = transcript.build("s1", "json", "Meadows 1/3")
|
||||
assert fn_md.endswith(".md") and "markdown" in mt_md
|
||||
assert fn_js.endswith(".json") and mt_js == "application/json"
|
||||
assert body_md and body_js
|
||||
|
||||
|
||||
def test_delete_session_clears_tool_events(mods):
|
||||
memory, _ = mods
|
||||
_seed(memory)
|
||||
memory.delete_session("s1")
|
||||
assert memory.tool_events("s1") == []
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Confirm-loop tools: descriptor reads, name attach, merge/mark-distinct."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
def _fake_embed(texts):
|
||||
out = []
|
||||
for t in texts:
|
||||
v = np.zeros(64, dtype=np.float32)
|
||||
for w in t.lower().split():
|
||||
v[hash(w) % 64] += 1.0
|
||||
out.append((v if v.any() else np.full(64, 1e-6, dtype=np.float32)).tolist())
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mods(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
|
||||
from lyra import llm
|
||||
monkeypatch.setattr(llm, "embed", _fake_embed)
|
||||
import lyra.memory as memory
|
||||
importlib.reload(memory)
|
||||
import lyra.poker as poker
|
||||
importlib.reload(poker)
|
||||
import lyra.tools as tools
|
||||
importlib.reload(tools)
|
||||
return poker, tools
|
||||
|
||||
|
||||
def test_descriptor_read_creates_then_reuses_nameless_villain(mods):
|
||||
poker, tools = mods
|
||||
poker.start_session(venue="Meadows", stakes="1/3", buy_in=300)
|
||||
tools.dispatch("add_read", {"note": "opened UTG light",
|
||||
"descriptor": "neck tattoo sleeve arm"}, {})
|
||||
tools.dispatch("add_read", {"note": "showed a bluff",
|
||||
"descriptor": "neck tattoo sleeve"}, {}) # rephrase → same guy
|
||||
players = [p for p in poker.get_villain_file() if not p["named"]]
|
||||
assert len(players) == 1 # one nameless villain, not two
|
||||
reads = poker._c().execute(
|
||||
"SELECT COUNT(*) n FROM player_reads WHERE player_id = ?", (players[0]["id"],)
|
||||
).fetchone()["n"]
|
||||
assert reads == 2
|
||||
|
||||
|
||||
def test_description_as_name_routes_to_descriptor_and_dedupes(mods):
|
||||
poker, tools = mods
|
||||
poker.start_session(venue="Meadows", buy_in=300)
|
||||
# She (wrongly) puts a physical description in the name field, twice, worded
|
||||
# slightly differently — must resolve to ONE nameless villain, not two named.
|
||||
tools.dispatch("add_read", {"note": "limp 3bet A3o",
|
||||
"name": "Filipino, Fox Racing hat, DKNY shirt, two bracelets"}, {})
|
||||
tools.dispatch("add_read", {"note": "called a 4bet light",
|
||||
"name": "Filipino, Fox Racing hat, DKNY shirt, watch on left"}, {})
|
||||
named = [p for p in poker.get_villain_file() if p["named"]]
|
||||
assert named == [] # no sentence-named players spawned (the bug)
|
||||
# Either they merged, or the near-dup is surfaced for a one-click merge — never
|
||||
# a silent duplicate the way sentence-names were.
|
||||
q = poker.list_identity_queue()
|
||||
nameless = [p for p in poker.get_villain_file() if not p["named"]]
|
||||
assert len(nameless) == 1 or any(t["kind"] == "merge_candidate" for t in q)
|
||||
|
||||
|
||||
def test_name_villain_tool_attaches_name(mods):
|
||||
poker, tools = mods
|
||||
poker.start_session(venue="Meadows", buy_in=300)
|
||||
poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows")
|
||||
out = tools.dispatch("name_villain", {"descriptor": "neck tattoo sleeve arm",
|
||||
"name": "Danny"}, {})
|
||||
assert "Danny" in out
|
||||
assert poker.resolve_villain("Danny")["band"] == "name"
|
||||
|
||||
|
||||
def test_link_villains_merge_and_distinct(mods):
|
||||
poker, tools = mods
|
||||
poker.start_session(venue="Meadows", buy_in=300)
|
||||
poker.upsert_player("Danny", venue="Meadows")
|
||||
poker.upsert_player("Donny", venue="Meadows")
|
||||
# same=false → recorded distinct
|
||||
tools.dispatch("link_villains", {"player_a": "Danny", "player_b": "Donny",
|
||||
"same": False, "note": "different builds"}, {})
|
||||
a = poker.resolve_villain("Danny")["match_id"]
|
||||
b = poker.resolve_villain("Donny")["match_id"]
|
||||
assert poker.are_distinct(a, b)
|
||||
# same=true on a fresh pair → merged
|
||||
poker.upsert_player("Mike", venue="Meadows")
|
||||
poker.upsert_player("Michael", venue="Meadows")
|
||||
tools.dispatch("link_villains", {"player_a": "Mike", "player_b": "Michael",
|
||||
"same": True}, {})
|
||||
names = [p["name"] for p in poker.get_villain_file()]
|
||||
assert ("Mike" in names) ^ ("Michael" in names) # one absorbed the other
|
||||
|
||||
|
||||
def test_link_villains_refuses_when_reference_is_vague(mods):
|
||||
poker, tools = mods
|
||||
poker.start_session(venue="Meadows", buy_in=300)
|
||||
poker.upsert_player("Danny", venue="Meadows")
|
||||
out = tools.dispatch("link_villains", {"player_a": "Danny",
|
||||
"player_b": "some guy", "same": True}, {})
|
||||
assert "didn't merge" in out.lower() or "couldn't" in out.lower()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Nameless-villain identity resolution: descriptor matching, merge, distinct, queue."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
def _fake_embed(texts):
|
||||
"""Overlap-sensitive bag-of-words vectors so cosine reflects shared tokens."""
|
||||
out = []
|
||||
for t in texts:
|
||||
v = np.zeros(64, dtype=np.float32)
|
||||
for w in t.lower().split():
|
||||
v[hash(w) % 64] += 1.0
|
||||
out.append((v if v.any() else np.full(64, 1e-6, dtype=np.float32)).tolist())
|
||||
return out
|
||||
|
||||
|
||||
@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", _fake_embed)
|
||||
import lyra.memory as memory
|
||||
importlib.reload(memory)
|
||||
import lyra.poker as poker
|
||||
importlib.reload(poker)
|
||||
return poker
|
||||
|
||||
|
||||
def test_distinctiveness_distinctive_vs_generic(poker):
|
||||
assert poker.distinctiveness("guy with a neck tattoo") > 0.6
|
||||
assert poker.distinctiveness("mid-aged white dude with glasses") < 0.3
|
||||
|
||||
|
||||
def test_generic_descriptor_never_resolves_to_a_guess(poker):
|
||||
poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows")
|
||||
r = poker.resolve_villain("mid aged white guy with glasses", venue="Meadows")
|
||||
assert r["band"] == "generic"
|
||||
assert r["match_id"] is None
|
||||
|
||||
|
||||
def test_exact_name_match_is_deterministic(poker):
|
||||
pid = poker.upsert_player("Sleepy John", venue="Meadows")
|
||||
r = poker.resolve_villain("sleepy john")
|
||||
assert r["band"] == "name" and r["match_id"] == pid
|
||||
|
||||
|
||||
def test_rephrased_descriptor_resolves_high(poker):
|
||||
pid = poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows")
|
||||
r = poker.resolve_villain("neck tattoo sleeve", venue="Meadows")
|
||||
assert r["band"] == "high" and r["match_id"] == pid
|
||||
assert r["confidence"] >= 0.80
|
||||
|
||||
|
||||
def test_partial_descriptor_is_ambiguous_not_high(poker):
|
||||
poker.create_descriptor_villain("neck tattoo sleeve arm", venue="Meadows")
|
||||
r = poker.resolve_villain("neck tattoo", venue="Meadows")
|
||||
assert r["band"] == "ambiguous" # plausible, but don't guess live
|
||||
|
||||
|
||||
def test_merge_repoints_observations_and_deletes_dup(poker):
|
||||
keep = poker.create_descriptor_villain("neck tattoo", venue="Meadows")
|
||||
dup = poker.create_descriptor_villain("neck ink tatted", venue="Meadows")
|
||||
poker._c().execute(
|
||||
"INSERT INTO player_observations (player_id, session_id, created_at) VALUES (?,1,?)",
|
||||
(dup, poker._now()))
|
||||
poker._c().commit()
|
||||
assert poker.merge_players(keep, dup) is True
|
||||
assert poker.get_villain_file() and all(p["id"] != dup for p in poker.get_villain_file())
|
||||
obs = poker._c().execute(
|
||||
"SELECT COUNT(*) n FROM player_observations WHERE player_id = ?", (keep,)).fetchone()["n"]
|
||||
assert obs == 1
|
||||
|
||||
|
||||
def test_merge_prefers_a_real_name(poker):
|
||||
named = poker.upsert_player("Danny", venue="Meadows")
|
||||
desc = poker.create_descriptor_villain("neck tattoo", venue="Meadows")
|
||||
poker.merge_players(desc, named) # keep the descriptor id, but name should win
|
||||
row = dict(poker._c().execute("SELECT name, named FROM poker_players WHERE id = ?", (desc,)).fetchone())
|
||||
assert row["name"] == "Danny" and row["named"] == 1
|
||||
|
||||
|
||||
def test_mark_distinct_blocks_merge_scan(poker):
|
||||
a = poker.create_descriptor_villain("neck tattoo sleeve", venue="Meadows")
|
||||
b = poker.create_descriptor_villain("neck tattoo sleeve", venue="Meadows")
|
||||
poker.mark_distinct(a, b, note="one's taller")
|
||||
assert poker.are_distinct(a, b)
|
||||
assert poker.scan_merge_candidates() == 0 # confirmed-distinct pair is skipped
|
||||
|
||||
|
||||
def test_scan_files_merge_candidate_for_near_duplicates(poker):
|
||||
poker.create_descriptor_villain("neck tattoo sleeve", venue="Meadows")
|
||||
poker.create_descriptor_villain("neck tattoo sleeve", venue="Meadows")
|
||||
filed = poker.scan_merge_candidates()
|
||||
assert filed == 1
|
||||
q = poker.list_identity_queue()
|
||||
assert q and q[0]["kind"] == "merge_candidate" and len(q[0]["players"]) == 2
|
||||
|
||||
|
||||
def test_queue_dedupes_identical_pending_task(poker):
|
||||
a = poker.create_descriptor_villain("neck tattoo", venue="Meadows")
|
||||
b = poker.create_descriptor_villain("neck ink", venue="Meadows")
|
||||
t1 = poker.queue_identity_task("merge_candidate", [a, b])
|
||||
t2 = poker.queue_identity_task("merge_candidate", [b, a]) # same pair, reversed
|
||||
assert t1 == t2
|
||||
assert len(poker.list_identity_queue()) == 1
|
||||
|
||||
|
||||
def test_name_villain_flips_named_flag(poker):
|
||||
pid = poker.create_descriptor_villain("neck tattoo", venue="Meadows")
|
||||
poker.name_villain(pid, "Danny")
|
||||
r = poker.resolve_villain("Danny")
|
||||
assert r["band"] == "name" and r["match_id"] == pid
|
||||
Reference in New Issue
Block a user