14 Commits

Author SHA1 Message Date
serversdown 267b6ad7ba Merge pull request 'Big poker mode changes and hotfixes.' (#5) from fix/mi50-summary-cap-fallback into dev
Reviewed-on: #5
2026-07-04 15:09:32 -04:00
serversdown c212099738 feat: host-side MI50 runaway watchdog (guard A, staged for install)
Independent Proxmox-host backstop to the in-app dream budget: a systemd timer
runs every ~2 min and stops lyra-brain if the MI50 is busy >=1hr continuously OR
junction >=97C for ~6 min, then pings Brian via ntfy. Trips on duration only
after a full hour so a legit ~40-min manual workload runs untouched. GPU temp/use
read from host rocm-smi; stop via 'pct exec 202 -- docker stop'.

Parsing + duration/temp decision logic dry-run-verified locally against real
rocm-smi output format (4 scenarios). NOT yet installed/live-verified — card is
off and Brian's away; install + trip-test per deploy/mi50-watchdog/README.md when
it's back.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
2026-07-04 19:05:32 +00:00
serversdown 3573ac8d79 feat: dream-cycle time budget + default per-call timeout (guard C)
Belt-and-suspenders so no dream pass can run unchecked for hours:
- llm.complete() now always bounds the OpenAI/mi50 request: default 300s +
  max_retries=0 instead of the SDK's 600s x2 (~30 min). One change bounds every
  consolidation/introspection call (profile/era/narrative/reflect/think), not
  just summaries. Live chat (chat_call*) is a separate path, unaffected.
- dream_cycle() enforces a 20-min wall-clock budget, checked between stages;
  once past it, remaining stages are skipped, it logs 'stopped early (over
  budget)', and notify.push() pings Brian. Paired with the host watchdog (A) as
  an independent fallback.

Tests: default timeout/max_retries threaded into complete(); an over-budget pass
skips later stages + pings. 178 pass, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
2026-07-04 19:05:32 +00:00
serversdown af778ef327 docs: spec for MI50 runaway guards (dream budget + host watchdog)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
2026-07-04 19:05:32 +00:00
serversdown e631797187 feat: guard summaries against degenerate (garbage) backend output
Observed live: an overheated MI50 returns a single char repeated ("?????") as a
successful 200, which neither the timeout nor the exception fallback catches — so
a degraded GPU would silently save capped garbage gists. Validate each summary
call's output: flag text (>=24 non-space chars) whose most-common non-whitespace
char exceeds 50%, raise DegenerateOutput, and let the existing retry->cloud
fallback handle it. Real prose (top char <20%) won't false-positive; short output
is exempt; cloud garbage raises rather than looping.

Tests: _looks_degenerate flags repeated-char / passes real prose / ignores short;
degenerate MI50 output falls back to cloud; cloud garbage raises. 177 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
2026-07-04 07:26:17 +00:00
serversdown 29a4d59661 fix: cap MI50 summary length + fast-fail cloud fallback
The dream cycle's summarize_all ran uncapped against the MI50: no max_tokens
and no timeout, so the OpenAI SDK's 600s x2-retry default meant ~30 min per
call. Combined with summary.py's own retry loop, one unsummarizable session
pegged the GPU for hours (observed 2026-07-04: stuck since 23:02, nothing saved
since 00:56, 7-8k-token runaway generations, all 4 llama.cpp slots busy). Not
context overflow (0 shifts/truncations) - purely unbounded length on a slow
backend timing out and retrying.

- llm.complete(): add optional max_tokens (caps generation; num_predict for
  Ollama) and timeout (bounds the request and sets max_retries=0 so the caller
  owns retry policy). Both default None -> unchanged for every existing caller.
- summary.py: cap gists at 768 tokens, 150s/call fast-fail, 2 MI50 attempts
  then one cloud fallback (when primary isn't already cloud and a key exists).

Known limitation (scoped out per decision): the fallback triggers on
timeouts/exceptions, not on a degraded backend returning garbage as a 200.

Tests: fallback fires after 2 MI50 failures; no fallback when primary is cloud
or no key; cap+timeout threaded into every complete() call; llm bounds tests.
172 pass, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
2026-07-04 07:19:31 +00:00
serversdown 07153fc53d fix: recognize natural table-change phrasings for clear_table
"table broke", "I got moved", "switched tables", "new table" etc. all mean clear
the roster — spell them out in the Cash card (esp. "table broke" jargon) so it's
reliable, not dependent on her inferring it from "changes tables".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:05:43 +00:00
serversdown e6134cf535 docs: spec for bounded MI50 summaries + cloud fallback
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015yrEb5qpPGv2FjyxrB7LLk
2026-07-04 07:01:42 +00:00
serversdown aefb22c823 feat: clear_table — empty the roster on a table change
"Clear the table" had no tool behind it, so she claimed she did it and nothing
changed. Add clear_table (empties the roster, keeps the session/stack/reads) and
a `replace` flag on seat_players for a one-shot table swap. Cash card: on a table
change / "clear the table", call clear_table then seat the new table — never claim
it without calling the tool.

2 tests. Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:01:11 +00:00
serversdown 5da13a7321 fix: session HUD syntax error + no-cache the app shell
- The roster card's empty-state string had a broken apostrophe escape
  ("who\\'s") that terminated the string early — a syntax error that killed the
  whole session.html script, so the HUD only rendered from a stale cached shell.
  Reworded to drop the apostrophe.
- Add a middleware that sets Cache-Control: no-cache on HTML/JS so a PWA can't
  keep serving a stale shell after a deploy (iOS heuristically caches when no
  cache header is present — the reason a hard refresh + reopen didn't update).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:54:18 +00:00
serversdown 9b844bc356 feat: live table roster — seat_players / unseat_player + HUD card
The missing backbone for read tracking: a place for "who's at the table" to live.
When Brian reads the table off Bravo (handles like TAG), Lyra registers them as
seated this session; reads/TAGs then attach to those players by handle instead of
spawning duplicates or getting missed.

- session_players table; seat_player/seat_players/unseat_player/session_roster;
  _resolve_or_create_player (shared name/descriptor resolution, dedupe guard).
- tools seat_players (accepts objects or a plain name list) + unseat_player.
- HUD gains `roster`; Session page shows a 🪑 Table card (seat, handle, category,
  read count, last read).
- Cash card: capture the roster when he names the table; a Bravo handle like TAG
  is a PERSON, seated as a player — never the tight-aggressive style.

5 tests. Full suite 162 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:41:20 +00:00
serversdown 8d2d7fb576 fix: correct the read-logging guidance — "Tag" is a player name, not a command
Prior commit misread "TAG" as an imperative ("tag this on his file"); it's
actually a player's handle (his initials). Rewrite the Cash-card rule around the
real gap: any "<player> did X" (limped/called/raised/shoved) is a read →
add_read log-first, every time. Player names are often short handles/initials
(Tag, JD, Wheelz) — use whatever he calls a person as-is, never as a poker term.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:26:57 +00:00
serversdown 3d886cdeae fix: make "TAG <player> <action>" a hard add_read trigger
Brian tracks who's limping by messaging "TAG <player> limped A4o in the SB". She
was treating these as chat, not logging them — and "TAG" is ambiguous (reads as
the tight-aggressive player type). Cash card now makes TAG an explicit order to
add_read on that player, log-first, covering limps/calls/raises/sizings/showdowns;
a bare "X limped" counts too. Names given at session start are the roster.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:25:20 +00:00
serversdown 392c46d8bf fix: stop spawning duplicate villains from descriptions in the name field
Root cause of "4 entries for the same person": physical descriptions were being
passed as `name`, creating a new *named* player each time the wording drifted
(exact-name match can't dedupe near-identical sentences, and the merge scan only
looks at descriptor embeddings).

- add_read: a `name` that looks like a description (comma-listed / long / has
  appearance words) is rerouted to the descriptor path so it dedupes.
- descriptor reads that are ambiguously close to an existing villain now file a
  merge_candidate to the review queue instead of leaving a silent duplicate.
- distinctiveness() reworked: recognizes specific content (proper nouns/brands,
  feature lists) as distinctive even when a generic word like "shirt" is present —
  the old list-only heuristic scored "Filipino, Fox Racing hat, DKNY shirt" as
  generic and gated it out.
- Cash card: name = real handle ONLY; the look goes in descriptor as a few
  distinctive tags, and use name_villain to fuse a name onto a described player.

Full suite 157 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:16:50 +00:00
19 changed files with 1123 additions and 41 deletions
+54
View File
@@ -0,0 +1,54 @@
# MI50 runaway watchdog (fallback layer "A")
Independent host-side backstop to Lyra's in-app dream-cycle budget (layer "C",
`lyra/dream.py`). Stops the llama.cpp backend if the MI50 is busy too long or too
hot, and pings Brian. See
`docs/superpowers/specs/2026-07-04-mi50-runaway-guards-design.md`.
## What it does
Runs on the **Proxmox host** (`10.0.0.4`) via a systemd timer, every ~2 min:
- **Duration:** if the GPU is busy (`rocm-smi` use% > 0) for **3600s continuously**,
it stops the container. Any idle read resets the streak, so a legitimate ~40-min
manual workload never trips it.
- **Temperature:** if junction ≥ **97°C** for **3 consecutive checks (~6 min)**, it
stops the container — independent of duration.
- On either trip: `pct exec 202 -- docker stop lyra-brain`, clear state, `logger` a
line, and POST to your ntfy topic.
All thresholds are `Environment=` overrides in the `.service`.
## Install (on the Proxmox host, as root)
```sh
# copy the three files up (from the repo, on lyra-cortex):
scp -i ~/.ssh/id_lyra_proxmox deploy/mi50-watchdog/mi50-watchdog.sh \
root@10.0.0.4:/usr/local/sbin/mi50-watchdog.sh
scp -i ~/.ssh/id_lyra_proxmox deploy/mi50-watchdog/mi50-watchdog.{service,timer} \
root@10.0.0.4:/etc/systemd/system/
# on the host:
chmod +x /usr/local/sbin/mi50-watchdog.sh
# set your ntfy topic (same one Lyra uses) in the service:
sed -i 's/CHANGE_ME/YOUR_NTFY_TOPIC/' /etc/systemd/system/mi50-watchdog.service
systemctl daemon-reload
systemctl enable --now mi50-watchdog.timer
```
## Verify (when the card is back and healthy)
```sh
# dry run once, watch what it decides:
NTFY_URL= /usr/local/sbin/mi50-watchdog.sh; echo "exit $?"
journalctl -t mi50-watchdog -n 20 --no-pager
# force a trip test with tiny thresholds (won't touch a healthy idle card unless busy):
MAX_BUSY_SEC=60 TEMP_KILL_C=40 TEMP_KILL_STREAK=1 /usr/local/sbin/mi50-watchdog.sh
# confirm it stopped lyra-brain + sent the ntfy, then restart the container.
systemctl list-timers mi50-watchdog.timer # confirm it's scheduled
```
**Not yet installed / live-verified** — staged here on 2026-07-04 while the card is
off and Brian is away. Install + trip-test when the MI50 is back.
@@ -0,0 +1,16 @@
[Unit]
Description=MI50 runaway watchdog (stop the llama.cpp backend if the GPU is busy too long or too hot)
After=network-online.target
[Service]
Type=oneshot
# Fill in your ntfy topic so it can ping Brian when it trips (leave URL empty to log only).
Environment=NTFY_URL=https://ntfy.sh
Environment=NTFY_TOPIC=CHANGE_ME
# Optional overrides (defaults shown):
# Environment=MAX_BUSY_SEC=3600
# Environment=TEMP_KILL_C=97
# Environment=TEMP_KILL_STREAK=3
# Environment=CTID=202
# Environment=CONTAINER=lyra-brain
ExecStart=/usr/local/sbin/mi50-watchdog.sh
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# MI50 runaway watchdog — fallback layer "A".
#
# Runs on the Proxmox HOST (10.0.0.4) via a systemd timer (every ~2 min). It is the
# independent backstop to Lyra's own in-app dream-cycle budget ("C", in lyra/dream.py):
# if the MI50 is busy too LONG or runs too HOT, it stops the llama.cpp backend and
# pings Brian — regardless of what caused it. Trips on duration only after a full hour
# of *continuous* busy, so a legitimate ~40-min manual workload runs untouched.
#
# The GPU lives on the host; the llama.cpp container ("lyra-brain") runs inside LXC
# CT202. So temp/use come from host rocm-smi, and the stop goes via `pct exec`.
#
# See docs/superpowers/specs/2026-07-04-mi50-runaway-guards-design.md
set -uo pipefail
# --- tunables (override in the .service via Environment=) ---
CTID="${CTID:-202}" # LXC holding the docker container
CONTAINER="${CONTAINER:-lyra-brain}"
MAX_BUSY_SEC="${MAX_BUSY_SEC:-3600}" # 1 hr continuous busy -> stop
TEMP_KILL_C="${TEMP_KILL_C:-97}" # junction >= this ...
TEMP_KILL_STREAK="${TEMP_KILL_STREAK:-3}" # ... for this many consecutive checks (~6 min)
NTFY_URL="${NTFY_URL:-}" # e.g. https://ntfy.sh (empty => log only)
NTFY_TOPIC="${NTFY_TOPIC:-}"
BUSY_STATE="${BUSY_STATE:-/run/mi50-watchdog.busy_since}"
HOT_STATE="${HOT_STATE:-/run/mi50-watchdog.hot_streak}"
now="$(date +%s)"
alert() { # $1 title, $2 message
logger -t mi50-watchdog "$2"
if [[ -n "$NTFY_URL" && -n "$NTFY_TOPIC" ]]; then
curl -s -m 8 -H "Title: $1" -H "Priority: urgent" -H "Tags: warning" \
-d "$2" "$NTFY_URL/$NTFY_TOPIC" >/dev/null 2>&1 || true
fi
}
stop_backend() { # $1 reason
pct exec "$CTID" -- docker stop "$CONTAINER" >/dev/null 2>&1 || true
rm -f "$BUSY_STATE" "$HOT_STATE"
alert "MI50 watchdog stopped the card" "$1"
}
# Nothing to guard if the backend isn't even running.
running="$(pct exec "$CTID" -- docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || echo false)"
if [[ "$running" != "true" ]]; then
rm -f "$BUSY_STATE" "$HOT_STATE"
exit 0
fi
use="$(rocm-smi --showuse 2>/dev/null | awk -F: '/GPU use \(%\)/ {gsub(/[^0-9]/, "", $NF); print $NF; exit}')"
junction="$(rocm-smi --showtemp 2>/dev/null | awk -F: '/junction/ {gsub(/[^0-9.]/, "", $NF); print $NF; exit}')"
# --- duration rule: accumulate continuous busy time in a state file ---
busy=0
[[ "${use:-}" =~ ^[0-9]+$ ]] && (( use > 0 )) && busy=1
if (( busy )); then
[[ -f "$BUSY_STATE" ]] || echo "$now" > "$BUSY_STATE"
since="$(cat "$BUSY_STATE" 2>/dev/null || echo "$now")"
elapsed=$(( now - since ))
if (( elapsed >= MAX_BUSY_SEC )); then
stop_backend "MI50 busy ${elapsed}s continuously (>= ${MAX_BUSY_SEC}s) — stopped ${CONTAINER}."
exit 0
fi
else
rm -f "$BUSY_STATE" # idle breaks the streak
fi
# --- temperature rule: independent of duration ---
if [[ "${junction:-}" =~ ^[0-9.]+$ ]]; then
jint="${junction%.*}"
if (( jint >= TEMP_KILL_C )); then
streak=$(( $(cat "$HOT_STATE" 2>/dev/null || echo 0) + 1 ))
echo "$streak" > "$HOT_STATE"
if (( streak >= TEMP_KILL_STREAK )); then
stop_backend "MI50 junction ${jint}C >= ${TEMP_KILL_C}C for ${streak} checks — stopped ${CONTAINER}."
exit 0
fi
else
rm -f "$HOT_STATE" # cooled off, reset the streak
fi
fi
exit 0
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Run the MI50 runaway watchdog every 2 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=2min
AccuracySec=15s
[Install]
WantedBy=timers.target
@@ -0,0 +1,79 @@
# MI50 runaway guards: dream-cycle budget + host watchdog
**Date:** 2026-07-04
**Branch:** `fix/mi50-summary-cap-fallback`
**Follows:** the summary cap/fallback fix (same branch). This adds general
"never run unchecked again" protection on top of the specific summary fix.
## Problem
The summary fix stops the *known* runaway (uncapped summaries). But the operator
wants a guarantee that *no* cause — known or future — can peg the MI50 for hours
unattended. Two independent layers, per operator decision:
- **C (in-app, primary):** Lyra's own dream cycle bounds itself.
- **A (host, fallback):** a watchdog on the always-on Proxmox host kills the
backend if the GPU runs too long or too hot, regardless of cause. Trips only
after **1 hr** of continuous busy so legitimate manual workloads (~40 min) run
untouched.
## Design
### C — dream-cycle time budget (`lyra/`)
1. **Per-call ceiling.** `llm.complete()` currently sets a timeout only when one
is passed; otherwise it inherits the OpenAI SDK default (600s × 2 retries ≈
30 min). Change the default: when no `timeout` is given, the cloud/mi50 paths
use **300s + `max_retries=0`**. This bounds *every* consolidation/introspection
call (`profile`, `era`, `narrative`, `reflect`, `think`) — not just summaries —
with one change. Live chat uses `chat_call*`, a different path, unaffected.
2. **Cycle deadline.** `dream_cycle()` sets `deadline = now + DREAM_CYCLE_BUDGET`
(**20 min**) before its heavy stages and checks it between them (continuity →
coherence → curiosity). Once past the deadline, remaining stages are skipped,
the cycle logs `dream cycle over budget — stopped early`, appends a
`stopped early (over budget)` action, and `notify.push()` pings Brian. A hung
single call can't blow past ~300s (step 1), so the between-stage checks keep a
pass bounded to roughly the budget.
### A — host watchdog (`deploy/mi50-watchdog/`)
A bash script + systemd timer installed on the Proxmox host (`10.0.0.4`), which
has `rocm-smi` + `docker` and is always on. Runs every 2 min:
- **Duration rule:** track continuous busy time in a state file (`GPU use % > 0`).
If busy ≥ **3600s** straight → `docker stop lyra-brain`. Idle clears the timer,
so a 40-min job never trips it.
- **Temp rule (independent):** if junction ≥ **97°C** for **3 consecutive checks
(~6 min)** → stop. A normal-temp long workload won't trip this; only a genuinely
overheating one.
- On either trip: stop the container, clear state, `logger` a line, and POST to
the ntfy topic so Brian is told. Thresholds are unit-file env vars (tunable).
Files: `mi50-watchdog.sh`, `mi50-watchdog.service`, `mi50-watchdog.timer`,
`README.md` (install: copy to host, set ntfy env, `systemctl enable --now`).
## Testing
- **C step 1:** `llm.complete()` with no timeout builds the client with
`timeout=300, max_retries=0` and still no `max_tokens` (update existing
`test_llm_bounds` default test).
- **C step 2:** a dream pass that goes over budget skips later stages, records the
`stopped early` action, and calls `notify.push` (stub the clock/operations in
`test_dream`).
- **A:** decision logic dry-run locally against sample `rocm-smi` output (busy /
idle / hot). Cannot be live-verified now (card is off, operator away) — install
+ real trip test deferred to when the card is back.
## Verification
C is repo code and ships live the moment `lyra-dream` restarts. A is staged in the
repo for host install; verify on the host when the card returns (force a long/hot
condition or lower thresholds temporarily and confirm it stops the container +
pings).
## Out of scope (YAGNI)
- No power cap (option B) — deferred; C+A cover the "unchecked" concern and the
electricity cost of one event is trivial (~$0.10).
- No change to live chat, `chat_call*`, or `config.summary_backend`.
@@ -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:0002: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,0008,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 (~9001,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.
+30 -4
View File
@@ -26,7 +26,8 @@ import time
from datetime import datetime, timezone from datetime import datetime, timezone
from lyra import ( from lyra import (
config, era, feeds, logbus, memory, narrative, poker, profile, self_state, summary, thoughts, config, era, feeds, logbus, memory, narrative, notify, poker, profile, self_state,
summary, thoughts,
) )
from lyra.llm import Backend from lyra.llm import Backend
from lyra.summary import SUMMARIZE_AFTER from lyra.summary import SUMMARIZE_AFTER
@@ -34,6 +35,17 @@ from lyra.summary import SUMMARIZE_AFTER
# A drive at/above this has built up enough to act on. # A drive at/above this has built up enough to act on.
THRESHOLD = 0.6 THRESHOLD = 0.6
# Wall-clock ceiling for a single pass. Every consolidation/introspection call is
# individually bounded (llm.complete's default timeout), but this caps the whole
# pass: once exceeded, remaining stages are skipped and Brian is pinged — so a slow
# or wedged MI50 can never grind for hours unattended. The host watchdog (A) is the
# independent fallback if this ever fails to fire.
DREAM_CYCLE_BUDGET_SEC = 20 * 60
def _over_budget(deadline: float) -> bool:
return time.monotonic() > deadline
# How much backlog saturates each pressure (the drive reaches ~1.0 at this level). # How much backlog saturates each pressure (the drive reaches ~1.0 at this level).
CONTINUITY_FULL = 4 # ripe (summary-needing) sessions CONTINUITY_FULL = 4 # ripe (summary-needing) sessions
COHERENCE_FULL = 10 # gists not yet folded into the profile COHERENCE_FULL = 10 # gists not yet folded into the profile
@@ -96,9 +108,12 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
logbus.log("error", "daily digest failed", error=str(exc)[:160]) logbus.log("error", "daily digest failed", error=str(exc)[:160])
actions: list[str] = [] actions: list[str] = []
# Cap the whole pass: skip any stage we reach after the deadline (checked
# between stages; each call is already individually bounded).
deadline = time.monotonic() + DREAM_CYCLE_BUDGET_SEC
# --- continuity: compact raw sessions into gists --- # --- continuity: compact raw sessions into gists ---
if force or drives["continuity"] >= THRESHOLD: if (force or drives["continuity"] >= THRESHOLD) and not _over_budget(deadline):
report = summary.summarize_all(backend=backend) report = summary.summarize_all(backend=backend)
actions.append(f"consolidated {report['summarized']} sessions") actions.append(f"consolidated {report['summarized']} sessions")
drives["continuity"] = 0.0 drives["continuity"] = 0.0
@@ -108,7 +123,7 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
drives["coherence"] = _clamp(profile_lag / COHERENCE_FULL) drives["coherence"] = _clamp(profile_lag / COHERENCE_FULL)
# --- coherence: fold gists up into profile / eras / narrative --- # --- coherence: fold gists up into profile / eras / narrative ---
if force or drives["coherence"] >= THRESHOLD: if (force or drives["coherence"] >= THRESHOLD) and not _over_budget(deadline):
profile.rebuild_profile(backend=backend) profile.rebuild_profile(backend=backend)
era.rebuild_eras(backend=backend) era.rebuild_eras(backend=backend)
narrative.rebuild_narrative(backend=backend) narrative.rebuild_narrative(backend=backend)
@@ -124,7 +139,7 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
logbus.log("error", "villain merge scan failed", error=str(exc)[:200]) logbus.log("error", "villain merge scan failed", error=str(exc)[:200])
# --- curiosity: reflect and evolve the self, then advance the thought loop --- # --- curiosity: reflect and evolve the self, then advance the thought loop ---
if force or drives["curiosity"] >= THRESHOLD: if (force or drives["curiosity"] >= THRESHOLD) and not _over_budget(deadline):
# reflect()/think() self-resolve to the *introspection* backend (her voice), # reflect()/think() self-resolve to the *introspection* backend (her voice),
# which can differ from the consolidation backend above — don't pass `backend`. # which can differ from the consolidation backend above — don't pass `backend`.
self_state.reflect(source="dream") # writes state + journal itself self_state.reflect(source="dream") # writes state + journal itself
@@ -139,6 +154,17 @@ def dream_cycle(backend: Backend | None = None, force: bool = False) -> dict:
logbus.log("error", "thought loop failed", error=str(exc)[:200]) logbus.log("error", "thought loop failed", error=str(exc)[:200])
drives["curiosity"] = CURIOSITY_FLOOR drives["curiosity"] = CURIOSITY_FLOOR
if _over_budget(deadline):
logbus.log("error", "dream cycle over budget — stopped early",
budget_min=DREAM_CYCLE_BUDGET_SEC // 60, done=actions)
actions.append("stopped early (over budget)")
notify.push(
"Lyra — dream cycle over budget",
f"A dream pass ran past {DREAM_CYCLE_BUDGET_SEC // 60} min and stopped early. "
"The MI50 backend may be slow or wedged — worth a look.",
tags="warning",
)
if not actions: if not actions:
actions.append("rested (nothing past threshold)") actions.append("rested (nothing past threshold)")
+32 -10
View File
@@ -19,6 +19,11 @@ class Message(TypedDict):
Backend = Literal["local", "cloud", "mi50"] Backend = Literal["local", "cloud", "mi50"]
# Hard ceiling on any single completion so a slow/stuck backend can't hang a call
# for the SDK's 600s x2-retry default (~30 min). Callers pass an explicit timeout
# to override (e.g. summary.py's tighter fast-fail).
_DEFAULT_TIMEOUT = 300.0
def _approx_tok(messages: list) -> int: def _approx_tok(messages: list) -> int:
"""Rough prompt size (chars/4) — enough to see what's loading a backend.""" """Rough prompt size (chars/4) — enough to see what's loading a backend."""
@@ -37,30 +42,47 @@ def _resolved_model(cfg, backend: Backend, model: str | None) -> str:
return model or cfg.local_model 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 """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() cfg = load()
mdl = _resolved_model(cfg, backend, model) mdl = _resolved_model(cfg, backend, model)
logbus.log("info", "llm call", kind="complete", backend=backend, model=mdl, tok=_approx_tok(messages)) logbus.log("info", "llm call", kind="complete", backend=backend, model=mdl, tok=_approx_tok(messages))
t0 = time.monotonic() t0 = time.monotonic()
if backend in ("cloud", "mi50"):
if backend == "cloud": if backend == "cloud":
if not cfg.openai_api_key: if not cfg.openai_api_key:
raise RuntimeError("OPENAI_API_KEY is not set") raise RuntimeError("OPENAI_API_KEY is not set")
client = OpenAI(api_key=cfg.openai_api_key) client_kwargs: dict = {"api_key": cfg.openai_api_key}
resp = client.chat.completions.create(model=mdl, messages=messages) else:
out = resp.choices[0].message.content or ""
elif backend == "mi50":
# MI50 box runs an OpenAI-compatible llama.cpp server; key is unused. # MI50 box runs an OpenAI-compatible llama.cpp server; key is unused.
client = OpenAI(api_key="not-needed", base_url=cfg.mi50_base_url) client_kwargs = {"api_key": "not-needed", "base_url": cfg.mi50_base_url}
resp = client.chat.completions.create(model=mdl, messages=messages) # Always bound the request: default 300s (vs the SDK's 600s x2 retries ≈
# 30 min that let a stuck MI50 call hang for half an hour), and disable the
# SDK's own retries so the caller owns retry/fallback policy.
client_kwargs["timeout"] = timeout if timeout is not None else _DEFAULT_TIMEOUT
client_kwargs["max_retries"] = 0
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 "" out = resp.choices[0].message.content or ""
else: else:
payload: dict = {"model": mdl, "messages": messages, "stream": False}
if max_tokens is not None:
payload["options"] = {"num_predict": max_tokens}
resp = httpx.post( resp = httpx.post(
f"{cfg.local_base_url}/api/chat", f"{cfg.local_base_url}/api/chat",
json={"model": mdl, "messages": messages, "stream": False}, json=payload,
timeout=120, timeout=timeout or 120,
) )
resp.raise_for_status() resp.raise_for_status()
out = resp.json()["message"]["content"] out = resp.json()["message"]["content"]
+35 -5
View File
@@ -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). # The full live cash-game toolset (incl. Brian's mental-game rituals).
_CASH_TOOLS = _BASE + _LOOKUPS + ( _CASH_TOOLS = _BASE + _LOOKUPS + (
"start_session", "add_buyin", "log_stack", "log_hand", "record_hand", "start_session", "add_buyin", "log_stack", "log_hand", "record_hand",
"add_read", "name_villain", "link_villains", "analyze_spot", "session_stats", "add_read", "seat_players", "unseat_player", "clear_table", "name_villain", "link_villains",
"session_state", "end_session", "generate_recap", "scar_note", "confidence_bank", "analyze_spot", "session_stats", "session_state", "end_session", "generate_recap",
"alligator_blood", "reset_ritual", "undo_last", "update_session", "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 # 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 \ 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. 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, \ • 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 \ 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 \ 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 \ 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 \ 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 \ `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 \ whenever he describes the guy again. The `name` field is ONLY a real handle (what he'd call \
generic ones — "mid-aged white guy in glasses" identifies no one. When you already have \ 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, \ 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-\ 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 \ tattoo reg from last week?") rather than assume — a wrong callback is worse than none. On his \
+174 -8
View File
@@ -150,6 +150,17 @@ CREATE TABLE IF NOT EXISTS identity_queue (
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_idq_status ON identity_queue(status); 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). # 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] = {} counts: dict[str, int] = {}
with conn: with conn:
for t in ("poker_hands", "player_observations", "player_reads", 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( counts[t] = conn.execute(
f"SELECT COUNT(*) n FROM {t} WHERE session_id = ?", (session_id,) f"SELECT COUNT(*) n FROM {t} WHERE session_id = ?", (session_id,)
).fetchone()["n"] ).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 _DISTINCT_MIN = 0.30 # below this the description is too generic to match at all
_GENERIC_SET = frozenset(_GENERIC)
def distinctiveness(text: str) -> float: def distinctiveness(text: str) -> float:
"""How usable a description is as an identity key: ~1.0 for a neck tattoo, """How usable a description is as an identity key: ~1.0 for 'neck tattoo, Fox
~0.1 for 'mid-aged white guy with glasses'. Generic-only stays near zero.""" Racing hat', ~0.1 for 'mid-aged white guy with glasses'. Generic-ONLY stays
t = (text or "").lower() near zero; specific content (named features, brands, a list) reads as high —
dist = sum(1 for w in _DISTINCTIVE if w in t) even if a generic word like 'shirt' is mixed in."""
if dist == 0: t = (text or "").strip()
return 0.10 if any(w in t for w in _GENERIC) else 0.30 if not t:
return min(1.0, 0.45 + 0.28 * dist) 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): def _embed_vec(text: str):
@@ -1473,6 +1497,27 @@ def scan_merge_candidates(sim_threshold: float = _SIM_HIGH) -> int:
return filed 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, def add_read(note: str, seat: str | None = None, name: str | None = None,
descriptor: str | None = None, session_id: int | None = None, descriptor: str | None = None, session_id: int | None = None,
**player_fields) -> int: **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.""" confident, else opens a new one — so reads on unnamed players still accumulate."""
sid = _resolve(session_id) sid = _resolve(session_id)
venue = player_fields.get("venue") 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 pid = None
if name: if name:
pid = upsert_player(name, **{k: v for k, v in player_fields.items() 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: else:
pid = create_descriptor_villain(descriptor, venue=venue, pid = create_descriptor_villain(descriptor, venue=venue,
category=player_fields.get("category")) 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() conn = _c()
with conn: with conn:
cur = conn.execute( cur = conn.execute(
@@ -1757,6 +1814,114 @@ def timeline(session_id: int | None = None) -> list[dict]:
return events 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]: def _session_villains(sid: int) -> list[dict]:
"""Players read this session, with their standing dossier fields.""" """Players read this session, with their standing dossier fields."""
rows = _c().execute( rows = _c().execute(
@@ -1832,6 +1997,7 @@ def hud(session_id: int | None = None) -> dict | None:
"log": log, "log": log,
}, },
"hands": hands, "hands": hands,
"roster": session_roster(sid),
"villains": _session_villains(sid), "villains": _session_villains(sid),
"timeline": timeline(sid), "timeline": timeline(sid),
"notes": notes, "notes": notes,
+56 -8
View File
@@ -12,12 +12,41 @@ from __future__ import annotations
import sys import sys
import threading import threading
import time import time
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from lyra import config, llm, logbus, memory from lyra import config, llm, logbus, memory
from lyra.llm import Backend, Message 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. # Re-summarize a session once it has accumulated this many new raw exchanges.
SUMMARIZE_AFTER = 20 SUMMARIZE_AFTER = 20
@@ -61,16 +90,35 @@ def _summarize_text(text: str, backend: Backend) -> str:
{"role": "system", "content": _PROMPT}, {"role": "system", "content": _PROMPT},
{"role": "user", "content": text}, {"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: try:
return llm.complete(messages, backend=backend) return _call(backend)
except Exception as exc: except Exception as exc:
if attempt == _RETRIES - 1: last_exc = exc
raise logbus.log("debug", "summary retry", attempt=attempt + 1,
logbus.log("debug", "summary retry", attempt=attempt + 1, error=str(exc)[:80]) backend=backend, error=str(exc)[:80])
if attempt < MI50_ATTEMPTS - 1:
time.sleep(5 * (attempt + 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: def _summarize_transcript(transcript: str, backend: Backend) -> str:
+55
View File
@@ -311,6 +311,33 @@ def _resolve_villain_ref(ref: str) -> tuple[int | None, str]:
return None, 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: def _name_villain(args: dict, ctx: dict) -> str:
ref = (args.get("descriptor") or "").strip() ref = (args.get("descriptor") or "").strip()
name = (args.get("name") or "").strip() name = (args.get("name") or "").strip()
@@ -653,6 +680,34 @@ TOOLS.update({
"category": {**_S, "description": "feeder | risky | reg | unknown"}, "category": {**_S, "description": "feeder | risky | reg | unknown"},
"venue": {**_S, "description": "Where they play"}}, "venue": {**_S, "description": "Where they play"}},
["note"])}, ["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": {"handler": _name_villain, "spec": _f(
"name_villain", "name_villain",
"Attach a real name to a player you'd only known by description (e.g. you caught it " "Attach a real name to a player you'd only known by description (e.g. you caught it "
+10
View File
@@ -50,6 +50,16 @@ def _last_user_message(messages: list[dict]) -> str:
def create_app() -> FastAPI: def create_app() -> FastAPI:
app = FastAPI(title="Lyra Web") 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") @app.get("/_health")
async def health() -> dict: async def health() -> dict:
return {"ok": True} return {"ok": True}
+14
View File
@@ -265,6 +265,7 @@
const stack = data.stack || {}; const stack = data.stack || {};
const timeline = data.timeline || []; const timeline = data.timeline || [];
const hands = data.hands || []; const hands = data.hands || [];
const roster = data.roster || [];
const villains = data.villains || []; const villains = data.villains || [];
const notes = data.notes || []; const notes = data.notes || [];
const stats = data.stats || {}; const stats = data.stats || {};
@@ -369,6 +370,19 @@
: '<p class="empty">No scars logged — mistakes to study land here.</p>'} : '<p class="empty">No scars logged — mistakes to study land here.</p>'}
</div> </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"> <div class="card">
<p class="label">Villains seen</p> <p class="label">Villains seen</p>
${villains.length ? `<ul class="rows">${villains.map(v => ` ${villains.length ? `<ul class="rows">${villains.map(v => `
+29 -1
View File
@@ -20,7 +20,7 @@ def lyra(tmp_path, monkeypatch):
# reflect() expects JSON back; everything else just stores the text. # reflect() expects JSON back; everything else just stores the text.
monkeypatch.setattr( monkeypatch.setattr(
llm, "complete", 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."]}', '{"mood":"focused","valence":0.7,"new_reflections":["I got some thinking done."]}',
) )
@@ -77,3 +77,31 @@ def test_dream_cycle_consolidates_and_persists(lyra):
state2 = dream.dream_cycle(force=False) state2 = dream.dream_cycle(force=False)
assert state2["dream"]["cycle_count"] == 2 assert state2["dream"]["cycle_count"] == 2
assert state2["drives"]["continuity"] == 0.0 assert state2["drives"]["continuity"] == 0.0
def test_dream_cycle_stops_when_over_budget(lyra, monkeypatch):
memory = lyra
from lyra import dream, notify
for k in range(7):
_seed(memory, f"s{k}", 4)
# Go over budget right after the first heavy stage: first check passes
# (summarize runs), every check after trips.
checks = {"n": 0}
def fake_over(deadline):
checks["n"] += 1
return checks["n"] > 1
monkeypatch.setattr(dream, "_over_budget", fake_over)
pings: list = []
monkeypatch.setattr(notify, "push",
lambda title, message, **k: pings.append((title, message)) or True)
state = dream.dream_cycle(force=True)
acts = state["dream"]["last_actions"]
assert any("stopped early" in a for a in acts) # bailed
assert not any("reflected" in a for a in acts) # later stage skipped
assert pings, "expected an over-budget ntfy push"
+66
View File
@@ -0,0 +1,66 @@
"""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_default_bounds_calls_even_without_explicit_timeout(fake_openai):
# No cap / timeout passed -> still bounded: 300s default + no SDK retries, so
# no call can silently inherit the SDK's 600s x2 (~30 min). No length cap
# unless asked, though.
llm.complete([{"role": "user", "content": "hi"}], backend="mi50")
assert "max_tokens" not in fake_openai["create"]
assert fake_openai["client"]["timeout"] == 300
assert fake_openai["client"]["max_retries"] == 0
+101
View File
@@ -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"}
+142
View File
@@ -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"]
+18
View File
@@ -46,6 +46,24 @@ def test_descriptor_read_creates_then_reuses_nameless_villain(mods):
assert reads == 2 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): def test_name_villain_tool_attaches_name(mods):
poker, tools = mods poker, tools = mods
poker.start_session(venue="Meadows", buy_in=300) poker.start_session(venue="Meadows", buy_in=300)