Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e631797187 | |||
| 29a4d59661 | |||
| 07153fc53d | |||
| e6134cf535 | |||
| aefb22c823 | |||
| 5da13a7321 | |||
| 9b844bc356 | |||
| 8d2d7fb576 | |||
| 3d886cdeae | |||
| 392c46d8bf |
@@ -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.
|
||||
+25
-10
@@ -37,30 +37,45 @@ def _resolved_model(cfg, backend: Backend, model: str | None) -> str:
|
||||
return model or cfg.local_model
|
||||
|
||||
|
||||
def complete(messages: list[Message], backend: Backend = "local", model: str | None = None) -> str:
|
||||
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)."""
|
||||
(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 = OpenAI(api_key=cfg.openai_api_key)
|
||||
resp = client.chat.completions.create(model=mdl, messages=messages)
|
||||
out = resp.choices[0].message.content or ""
|
||||
elif backend == "mi50":
|
||||
client_kwargs: dict = {"api_key": cfg.openai_api_key}
|
||||
else:
|
||||
# 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=mdl, messages=messages)
|
||||
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={"model": mdl, "messages": messages, "stream": False},
|
||||
timeout=120,
|
||||
json=payload,
|
||||
timeout=timeout or 120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
out = resp.json()["message"]["content"]
|
||||
|
||||
+35
-5
@@ -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", "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",
|
||||
"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
|
||||
@@ -79,6 +80,30 @@ hand) — prefer this over log_hand so it lands on his timeline with a link. A r
|
||||
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 \
|
||||
and be fully present — your real voice, warm and direct and his. Talk him down off tilt, keep \
|
||||
@@ -102,8 +127,13 @@ stays off the table. At the table you're logging the session, not processing you
|
||||
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. Prefer DISTINCTIVE features (tattoos, build, a hat) over \
|
||||
generic ones — "mid-aged white guy in glasses" identifies no one. When you already have \
|
||||
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 \
|
||||
|
||||
+174
-8
@@ -150,6 +150,17 @@ CREATE TABLE IF NOT EXISTS identity_queue (
|
||||
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).
|
||||
@@ -267,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"]
|
||||
@@ -1189,14 +1200,27 @@ _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 a neck tattoo,
|
||||
~0.1 for 'mid-aged white guy with glasses'. Generic-only stays near zero."""
|
||||
t = (text or "").lower()
|
||||
dist = sum(1 for w in _DISTINCTIVE if w in t)
|
||||
if dist == 0:
|
||||
return 0.10 if any(w in t for w in _GENERIC) else 0.30
|
||||
return min(1.0, 0.45 + 0.28 * dist)
|
||||
"""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):
|
||||
@@ -1473,6 +1497,27 @@ def scan_merge_candidates(sim_threshold: float = _SIM_HIGH) -> int:
|
||||
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,
|
||||
descriptor: str | None = None, session_id: int | None = None,
|
||||
**player_fields) -> int:
|
||||
@@ -1481,6 +1526,11 @@ def add_read(note: str, seat: str | None = None, name: str | None = None,
|
||||
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()
|
||||
@@ -1494,6 +1544,13 @@ def add_read(note: str, seat: str | None = None, name: str | None = None,
|
||||
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(
|
||||
@@ -1757,6 +1814,114 @@ def timeline(session_id: int | None = None) -> list[dict]:
|
||||
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(
|
||||
@@ -1832,6 +1997,7 @@ 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,
|
||||
|
||||
+56
-8
@@ -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])
|
||||
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))
|
||||
raise RuntimeError("unreachable")
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -311,6 +311,33 @@ def _resolve_villain_ref(ref: str) -> tuple[int | None, str]:
|
||||
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()
|
||||
@@ -653,6 +680,34 @@ 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 "
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
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 || {};
|
||||
@@ -369,6 +370,19 @@
|
||||
: '<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 => `
|
||||
|
||||
+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."]}',
|
||||
)
|
||||
|
||||
|
||||
@@ -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,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,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"]
|
||||
@@ -46,6 +46,24 @@ def test_descriptor_read_creates_then_reuses_nameless_villain(mods):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user