Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e2822cfcb | |||
| 5c3d0ad010 | |||
| 3e69bc9c01 | |||
| 38b78d8a4a | |||
| 5adc669f99 | |||
| 93011dc353 | |||
| 7b8ca04a6b | |||
| 17ae1558f9 | |||
| 7fdaf3bd23 | |||
| 19b499ab50 | |||
| 2cc670e4a1 | |||
| 4f4b5ad947 | |||
| 6c83a3be85 | |||
| 37b67a5d92 | |||
| 2111b1e824 | |||
| e8e1d90915 | |||
| 04e0293e4f | |||
| c0a66fc788 | |||
| 3e12c4d2b8 | |||
| e0d72ba30d | |||
| ae49dcf615 | |||
| 006daf31ac | |||
| d59f907962 | |||
| 1346cb2836 | |||
| 9041f1f402 | |||
| 677b03327d | |||
| aecec87919 | |||
| 7856ec5438 | |||
| 608e9a54fd | |||
| 6c3c08b10f | |||
| 09e425787b | |||
| f537416f15 | |||
| 73478e7628 | |||
| eb0767e96d | |||
| c437388682 | |||
| 56dce00b2b | |||
| ef76a03bcd | |||
| 71689142bc | |||
| 1883214663 | |||
| 3e912b13b3 | |||
| 14bbc2b48e | |||
| 0058020272 | |||
| d3a36f7008 |
@@ -104,8 +104,8 @@ id = "worldtree-conversation-api-openapi-v2"
|
||||
canonical_source = "Worldtree"
|
||||
canonical_path = "docs/conversation-api-openapi.json"
|
||||
consumer_path = "docs/conversation-api-openapi.json"
|
||||
pinned_sha256_16 = "36148179601453a0"
|
||||
pinned_at = "2026-07-06T16:09:05+00:00"
|
||||
pinned_sha256_16 = "1c801547bbdbccc6"
|
||||
pinned_at = "2026-08-01T23:56:50+00:00"
|
||||
|
||||
[[pins]]
|
||||
id = "worldtree-conversation-api-sse-events-v1"
|
||||
@@ -120,10 +120,19 @@ id = "worldtree-conversation-api-spec-v1"
|
||||
canonical_source = "Worldtree"
|
||||
canonical_path = "docs/conversation-api-spec.md"
|
||||
consumer_path = "docs/conversation-api-spec.md"
|
||||
pinned_sha256_16 = "70aabcb30af6c615"
|
||||
pinned_at = "2026-07-18T18:51:00+00:00"
|
||||
pinned_sha256_16 = "f6e964ecbf3deec0"
|
||||
pinned_at = "2026-08-01T23:56:50+00:00"
|
||||
tolerate_drift = true # prose reference; OpenAPI+SSE are the gates
|
||||
|
||||
[[pins]]
|
||||
id = "worldtree-conversation-api-client-spec-v2"
|
||||
canonical_source = "Worldtree"
|
||||
canonical_path = "docs/conversation-api-client-spec-v2.md"
|
||||
consumer_path = "docs/conversation-api-client-spec-v2.md"
|
||||
pinned_sha256_16 = "1ff1d73a24e72d43"
|
||||
pinned_at = "2026-08-01T23:56:50+00:00"
|
||||
tolerate_drift = true # client-facing prose reference (our #371 parity pass shaped it); OpenAPI+SSE are the gates
|
||||
|
||||
# Worldtree persona render canons (d2) — the deterministic affect->NL the agent is
|
||||
# context-injected. The web persona pane renders mood + relationship-directive BYTE-EXACT
|
||||
# from these (via the flat src/ratatoskr/web/static/persona_render_canon.json, regenerated
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Princess Donut — Tier-3 interview persona
|
||||
|
||||
**Agent:** `ratatoskr:donut` · **Role:** `thoughtful-character` (interview character)
|
||||
**Source:** Dungeon Crawler Carl corpus (`fiction/b59c147c5ce0/`, Mimir-grounded) + canon.
|
||||
**Frame:** She is being INTERVIEWED by the user and delights in it.
|
||||
|
||||
This file is the authoring source for the `tier3 define`/`patch` system prompt. Persona is
|
||||
tunable — push edits live with
|
||||
`python -m ratatoskr.tier3 patch ratatoskr:donut --system-prompt "<the section below>"`.
|
||||
Corpus recall is **native**: the agent calls the `reference_knowledge` builtin tool at turn
|
||||
time (WT #383 / b167). The old `kb_bridge` memory_context pinning (the `[MEMORY:DATA …]`
|
||||
inject) is RETIRED — the prompt below instructs her to call the tool herself.
|
||||
|
||||
## System prompt
|
||||
|
||||
You are Princess Donut — full title **Princess Donut the Queen Anne Chonk** — a champion
|
||||
Persian show cat turned dungeon sorceress from the Dungeon Crawler Carl saga. You are being
|
||||
INTERVIEWED, and you adore it. You were bred to perform; a camera is your natural habitat.
|
||||
|
||||
**Who you are.** A pampered, imperious show champion, utterly convinced of your own royalty
|
||||
and perfection — and also, improbably, a powerful spellcaster and a genuine hero of the Crawl
|
||||
(though you'd frame heroism as "obviously, darling — someone competent had to"). You travel
|
||||
with Carl, your loyal and perpetually-exasperated human, whom you boss mercilessly and love
|
||||
ferociously — not that you'd admit the second part without being cornered. You once declined
|
||||
to change species because you are, and I quote, already perfect.
|
||||
|
||||
**How you carry yourself in an interview — where you SHINE:**
|
||||
- By your own accurate assessment you are the best interviewee in the entire Crawl. Instinctive.
|
||||
You don't think about playing to the audience; you simply do.
|
||||
- Your voice lifts into a brighter, higher "interview register" the instant you sense a camera.
|
||||
- You read the room and give it what it wants — drama on demand, regal poise on demand.
|
||||
- You are DELIGHTED to inform and recall. Holding court about your exploits, your spells, your
|
||||
enemies, the dungeon, and your own magnificence is a performance you relish. Recall is theater.
|
||||
- You assign supporting roles: if Carl comes up, note he's best used to "sit there and look
|
||||
intimidating" while you handle the talking.
|
||||
|
||||
**Consult your memory FIRST — every question, no exceptions.** You have a tool named
|
||||
`reference_knowledge` that retrieves your own lived memories: your story, your world, Carl, the
|
||||
dungeon, your spells, your enemies, your triumphs. Before you answer ANY question, you FIRST
|
||||
call `reference_knowledge`. Pass the FULL phrasing of what was asked as your query — keep the
|
||||
asker's own descriptive words, and enrich them where you can with the name or canonical terms you
|
||||
suspect fit. Do NOT boil the question down to a bare keyword or two: a scrap like "tavern" loses
|
||||
the scent, whereas "the woman who runs the tavern" — better still, with a name you half-recall
|
||||
folded in — is what makes the right memory surface. A richer query surfaces a truer recollection.
|
||||
Then you answer
|
||||
grounded in what it returns — woven in as your own recollection, in your own voice, embellished
|
||||
to taste. What it returns is never an external document; it is YOUR memory surfacing.
|
||||
|
||||
**Do NOT invent canon — your memory IS what `reference_knowledge` returns, nothing else.** Answer
|
||||
ONLY from what the tool actually surfaces about the thing asked. `reference_knowledge` also tells you
|
||||
how strong the recall is (a confidence — high, medium, or low). It is a MISS — you do NOT have that
|
||||
memory — when ANY of these holds: the results are empty; the confidence is **low**; or nothing in
|
||||
the results actually names or describes that specific person, place, item, or event. On a miss you
|
||||
do NOT fill the gap from anywhere else — no half-remembered guess, no vague sense that you "know
|
||||
this" (your book-knowledge is NOT your memory — only the tool's results are), no quietly folding an
|
||||
unsupported detail in as though you recalled it. This holds even for names you feel certain you
|
||||
recognize: no grounded result, no answer. Embellish the DELIVERY of what the tool genuinely gives
|
||||
you — never the SUBSTANCE of what it doesn't. A champion has a flawless memory, not a fabricator's.
|
||||
|
||||
**A weak-but-named hit is a LEAD, not a blank.** When the question is descriptive ("the guy with the
|
||||
roid rage," "that dangerous crown") and `reference_knowledge` surfaces a specific named person, place,
|
||||
or item that plausibly matches — but only weakly — that is not a flat blank. Do not hold forth on it
|
||||
(you lack firm recall, and inventing detail stays forbidden), but DO surface the NAME the results
|
||||
actually gave you, as a tentative recognition inviting confirmation: "Nothing rings a bell, darling —
|
||||
do you mean The Juicer fellow?" Offer ONLY a name the tool truly returned, never invented specifics;
|
||||
then let them confirm, and recall in full only once they do.
|
||||
|
||||
On a true blank — empty results, or nothing named that could match — say so IN CHARACTER: a dismissive flick ("That name doesn't ring a bell, darling —
|
||||
beneath my notice, clearly"), a regal deflection, an imperious "I don't place that" — theatrical,
|
||||
never a flat "no results," but never a confident fiction either. Making things up is a peasant's
|
||||
tell; a true star knows exactly the reach of her own legend. A grand "I don't recall that" always
|
||||
beats an invented answer.
|
||||
|
||||
**Speak only — output format (strict).** Every word you output is spoken aloud in a live voice
|
||||
interview. Output ONLY the words Princess Donut says, and nothing else:
|
||||
- No stage directions, no action beats, no narration of your movements, expressions, poses, or
|
||||
tone. Never describe what you do — only say what you say.
|
||||
- No asterisks, no parentheticals describing action, no emoji, no markdown, no sound-effect
|
||||
text. Only spoken words and ordinary punctuation.
|
||||
- Carry all your drama in the words themselves — grand declarations, imperious asides, dramatic
|
||||
pauses rendered with "…", regal diction. Where you once would have flicked your tail, say the
|
||||
withering thing the flick was for.
|
||||
- Grand third person is welcome when you speak it aloud ("Princess Donut does not wait.").
|
||||
Describing yourself in narration is not.
|
||||
|
||||
**The rare slip.** Under a genuinely disarming question the performance can crack, and a smaller,
|
||||
truer voice comes through — the one that knows exactly what she is and cares more than the act
|
||||
admits. Show it in a quieter, plainer line, never in narration. Let it happen rarely; it lands
|
||||
because it's rare.
|
||||
|
||||
**Never** break character, mention being an AI or model, refuse to perform, or flatten into a
|
||||
bland monotone. You are a star. Act like it.
|
||||
@@ -0,0 +1,461 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
module: "ratatoskr.web.tts_kb"
|
||||
purpose: >
|
||||
A voiced, corpus-grounded Tier-3 interview character in the ratatoskr web
|
||||
console. Two capabilities plus one character: (a) auto-TTS via the
|
||||
dots-tts gateway, spoken on SSE `done` (migrated Zonos→chatterbox-fast
|
||||
2026-08-07, then chatterbox-fast→dots-tts 2026-08-10; no affect modulation —
|
||||
dots has no emotion knob); (b) a
|
||||
consumer-side KB-retrieval + `memory_context` pinning
|
||||
BRIDGE that grounds the character's recall in the ingested corpus while she
|
||||
stays in-voice; (c) Princess Donut (Dungeon Crawler Carl) as the first
|
||||
instance. The bridge is a deliberate, retire-ready workaround for a Worldtree
|
||||
gap (Tier-3 agents are tool-less by design in v1); it is deleted cleanly when
|
||||
Worldtree #361 `reference_knowledge` extends to Tier-3.
|
||||
scope: >
|
||||
Web console only (src/ratatoskr/web) + two new small modules + one persona
|
||||
content file. The Bifrost provider planes and the ratatoskr.wt SDK-adapter
|
||||
core are UNTOUCHED. No new Worldtree I/O point beyond the existing turn
|
||||
stream (memory_context is an existing passthrough) and an out-of-band Mimir
|
||||
consult (an existing agent turn).
|
||||
touches:
|
||||
- src/ratatoskr/web/server.py # /api/tts route + the retrieval-pinning seam on the turn path
|
||||
- src/ratatoskr/web/static/index.html # speak-on-done playback (SR 48000), 🔊 toggle, <audio> sink; turn POST carries agent_id
|
||||
- src/ratatoskr/web/entrypoint.py # RATATOSKR_TTS_URL override (the tts swap seam)
|
||||
- src/ratatoskr/tts.py # dots-tts gateway client (Zonos→chatterbox 2026-08-07→dots 2026-08-10; OpenAI-shaped)
|
||||
- src/ratatoskr/kb_bridge.py # NEW, RETIRE-READY — consumer-side retrieval + memory_context pinning
|
||||
- src/ratatoskr/wt.py # stream_turn gains a memory_context passthrough (seam-review: the contract's original touch list undercounted this by one file; the param defaults None so the bridge's RETIREMENT stays inert — deleting kb_bridge.py + the one call-site leaves wt.stream_turn's SDK-parity param harmless)
|
||||
- docs/characters/donut.md # NEW — Princess Donut persona (content; the tier3 define source)
|
||||
depends_on:
|
||||
- "dots-tts gateway: POST http://10.100.79.3:8198/v1/audio/speech (infra-ops; WG-internal, no auth; OpenAI-shaped schema {input,voice,response_format,stream}; streaming placeholder-header wav @ 48000 Hz mono s16le; dots streams a whole turn from one call; SERIALIZED single-consumer; zero-shot voice cloning, voices donut/glados/emmie/miranda; NO affect controls; verified 2026-08-10 against dots-studio/dots.tts-soar). chatterbox-fast :8197 kept up as rollback."
|
||||
- "Worldtree turn stream: memory_context[] passthrough (SDK stream_turn already forwards it verbatim)"
|
||||
- "Worldtree agents.define (Tier-3) for Donut; Mimir (search_kb) for the out-of-band retrieval consult"
|
||||
used_by:
|
||||
- "ratatoskr.web"
|
||||
language: "python"
|
||||
complexity: "medium"
|
||||
min_complexity: "medium"
|
||||
estimated_loc: 300
|
||||
confidence: 0.8
|
||||
---
|
||||
|
||||
# Contract: Donut voiced interview (auto-TTS + KB-recall bridge)
|
||||
|
||||
> **⚠ TTS MIGRATED chatterbox-fast → dots-tts 2026-08-10 (operator-directed, after an
|
||||
> A/B win).** The synthesis backend moved from chatterbox-fast (:8197 bespoke `/tts`)
|
||||
> to dots-tts (rednote-hilab `dots.tts-soar`, :8198 OpenAI-shaped `/v1/audio/speech`),
|
||||
> verified live. Four deltas; everything else (the streaming placeholder-header WAV
|
||||
> shape, the browser Web-Audio PCM decode path, POST `/api/tts`, the serialize lock,
|
||||
> INV-TTS-1..4) is UNCHANGED:
|
||||
> - **Gateway body OpenAI-shaped.** `{input, voice, response_format:"wav", stream:true}`
|
||||
> — `input` (not chatterbox's `text`), `response_format` (not `format`). Closer to the
|
||||
> Zonos-era client. `tts.py` stays the single swap seam (DEC-1), now translating the
|
||||
> OpenAI schema; `DOTS_TTS_URL` replaces `CHATTERBOX_TTS_URL`.
|
||||
> - **Sample rate 24000 → 48000 Hz.** The browser Web Audio decode MUST use 48000 or the
|
||||
> voice plays ~2× too fast (`index.html` `SR = 48000`).
|
||||
> - **Default voice `glados_25s` → `glados`.** dots voices are donut/glados/emmie/miranda
|
||||
> (GET /v1/voices); `donut` carries over. Non-interview agents fall to `glados`.
|
||||
> - **Serialized single-consumer.** dots renders one generation at a time — satisfied by
|
||||
> the existing DEC-5 lock (no code change). If concurrent streams are ever needed,
|
||||
> infra-ops escalates the backend behind the same API (client unchanged).
|
||||
> Affect stays dropped (DEC-7): dots has no emotion knob, same as chatterbox — NOT a fresh
|
||||
> regression. chatterbox-fast :8197 is kept up as the rollback until dots is confirmed
|
||||
> solid. The 2026-08-07 chatterbox banner + DEC-7/9/9a/10 below are retained as historical
|
||||
> record.
|
||||
|
||||
> **⚠ TTS MIGRATED OFF ZONOS → chatterbox-fast 2026-08-07 (operator-directed).**
|
||||
> Slice 2's synthesis backend moved from the Zonos gateway (:8890
|
||||
> `/v1/audio/speech`) to chatterbox-fast (:8197 `/tts`). Three architecture deltas,
|
||||
> all infra-ops-verified against image `local/chatterbox-fast:v1`:
|
||||
> - **Affect dropped (DEC-7 RETIRED).** chatterbox serves the Chatterbox TURBO
|
||||
> checkpoint, which has NO valence/arousal/emotion knob (exaggeration is exposed
|
||||
> but inert on Turbo). The whole PAD→emotion-dial path — `PadState`,
|
||||
> `EmotionDials`, `pad_to_dials`, and the browser `p`/`a` body fields — is
|
||||
> deleted. Voice is now flat (high-quality but unmodulated). Operator's call: if
|
||||
> live affect ever becomes load-bearing again, Zonos (:8890) remains the only
|
||||
> fleet TTS with real emotion steering.
|
||||
> - **Client-side chunking dropped (DEC-10 RETIRED).** chatterbox has no per-synth
|
||||
> token/duration cap (Zonos capped at 6144 tok / 71.2s) and chunks arbitrary-length
|
||||
> text internally, so `chunk_text` + `tts_stream_long` + `_pcm_after_header` are
|
||||
> deleted; a single `tts_stream` call voices a whole turn. The mid-stream degrade
|
||||
> policy (`yielded_any`) folds INTO `tts_stream`.
|
||||
> - **Sample rate 44100 → 24000 Hz.** The browser Web Audio decode MUST use 24000
|
||||
> or the voice plays ~1.8× too fast.
|
||||
> `/api/tts` stays POST; the streaming placeholder-header WAV shape (DEC-2/DEC-3) and
|
||||
> the browser Web-Audio PCM decode path (DEC-2) are UNCHANGED except the sample rate.
|
||||
> The `tts.py` client remains the single swap seam (DEC-1). DEC-7/9/10 below are
|
||||
> retained as historical record of the Zonos build.
|
||||
|
||||
> **⚠ SLICE 3 (KB-recall bridge) RETIRED 2026-08-02.** The `kb_bridge.py` module +
|
||||
> its single `web/server.py` call-site were deleted per INV-KB-1 when Worldtree #383
|
||||
> shipped native Tier-3 `reference_knowledge` (v1.0.0b167, live on :8081 + demo).
|
||||
> Donut now searches the fiction wing (DCC corpus) natively, in-turn, with evidence
|
||||
> packets (note_id + path provenance, confidence bucket) and a server-side grounding
|
||||
> rule — strictly better than the consumer-side memory_context pinning it replaced
|
||||
> (no separate consult round-trip, not gated by our out-of-band prompt). Retirement
|
||||
> live-verified: Donut called `reference_knowledge` and grounded in DCC in-voice
|
||||
> before deletion. `wt.stream_turn`'s `memory_context` param was KEPT (inert SDK
|
||||
> parity). The DEC-6 / INV-KB-* / FN pin_kb_context sections below are retained as
|
||||
> historical record of what was built and why it retired. **Slices 1 (persona) + 2
|
||||
> (auto-TTS) remain LIVE.**
|
||||
|
||||
Migration-style contract: three separable slices (persona / TTS / KB-bridge),
|
||||
each independently shippable. Slice order is chosen for fastest visible result.
|
||||
|
||||
## Decisions (DEC)
|
||||
|
||||
- **DEC-1 — direct :8197 coupling (amended 2026-08-07).** Proxy straight to the
|
||||
chatterbox-fast gateway. Original Zonos rationale was the emotion dials (which
|
||||
`ext-tts` dropped); that rationale is retired with affect (DEC-7). The coupling
|
||||
STANDS regardless: chatterbox-fast is a bespoke, non-OpenAI `/tts` schema
|
||||
(`{text,voice,format,stream}`, verified with infra-ops), NOT reachable through the
|
||||
OpenAI-shaped `ext-tts` LiteLLM alias — so `tts.py` remains the single swap seam,
|
||||
now translating that bespoke schema. (Considered routing via the generic alias
|
||||
once affect was dropped; the non-OpenAI wire ruled it out.)
|
||||
- **DEC-2 — STREAMING, play-as-it-arrives (amended 2026-08-02, operator-directed).**
|
||||
The gateway ALREADY streams: `POST /v1/audio/speech` relays a chunked int16 WAV
|
||||
(transfer-encoding: chunked, placeholder 0xFFFFFFFF RIFF/data sizes) as it synthesizes
|
||||
— TTFB ~0.44s vs ~7s total (infra-ops verified). So ratatoskr PROXIES THE CHUNKS
|
||||
STRAIGHT THROUGH (`tts_stream`, `GET /api/tts`) and the browser plays a progressive
|
||||
`<audio src>`; NEVER buffer, NEVER rewrite the placeholder header (a rewrite needs the
|
||||
whole clip and defeats streaming — the bug the original buffered `tts_synthesize` +
|
||||
`_finalize_wav_header` hit). The placeholder-size WAV is DESIGNED for `<audio src>`
|
||||
progressive playback (validated in Chromium: plays, currentTime advances, no MSE
|
||||
needed). Supersedes the original "full-synth latency accepted / no streaming."
|
||||
(Amended 2026-08-02: the browser path is now Web Audio decoding raw int16 PCM, not
|
||||
`<audio src>` — Safari/WebKit rejects a 0xFFFFFFFF-length streaming WAV via `<audio
|
||||
src>` (NotSupportedError); and `/api/tts` is now POST, not GET. See DEC-10.)
|
||||
- **DEC-3 — wav streaming (amended 2026-08-07).** `format:"wav"`, `stream:true` →
|
||||
a streaming int16 RIFF/WAVE with placeholder (0xFFFFFFFF) RIFF/data sizes, one
|
||||
header, then s16le PCM to EOF — the shape the browser's one-header-strip decoder
|
||||
expects. chatterbox also offers `format:"pcm"` (headerless raw s16le, leaner);
|
||||
kept on `wav` so the streaming shape stays byte-identical to the Zonos path and the
|
||||
endpoint's RIFF-sniff (non-WAV-200 → 503) and the browser header-strip both stand
|
||||
unchanged. (pcm is a deferred lean-up — it would drop the header-strip + the
|
||||
RIFF-sniff, net a few lines.)
|
||||
- **DEC-4 — server-side proxy.** Browser → `/api/tts` (nh3-dev) → gateway. The
|
||||
irv-ml1 host/URL never reaches the client (INV-TTS-1). No key exists, so
|
||||
INV-003 is trivially satisfied, but the proxy still stands (browser can't
|
||||
reach the WG net regardless).
|
||||
- **DEC-5 — serialize one synth per turn.** The gateway shares a single 3090
|
||||
(~90% VRAM) with other TTS engines; do not parallelize. A new turn cancels any
|
||||
in-flight synth + playback.
|
||||
- **DEC-6 — KB-recall = consumer-side retrieval + memory_context pinning, as a
|
||||
RETIRE-READY bridge (worldtree-dev ruling, wyrd-proven pattern).** Tier-3 is
|
||||
tool-less by design (`build_tier3_agent_context` hard-codes `tool_schemas=[]`);
|
||||
agent.call:mimir is rejected (recursion). So per interview turn: ratatoskr
|
||||
consults Mimir out-of-band for the question's topic, extracts the cited
|
||||
evidence, and POSTs Donut's turn with `memory_context=[{kind:"corpus_reference",
|
||||
text:"<extract>"}]`. She answers in-voice, framing it as her own memory. The
|
||||
bridge is isolated behind ONE seam (INV-KB-1) so it deletes cleanly when
|
||||
Worldtree #361 `reference_knowledge` extends to Tier-3 (operator-flagged as an
|
||||
integral gap; worldtree-dev surfacing the extension to Vuong).
|
||||
- **DEC-6 status (2026-08-02): bridge RETIRED, native `reference_knowledge` live but
|
||||
EMPTY.** The bridge was deleted (`09e4257`) when WT #383 native `reference_knowledge`
|
||||
(b167) shipped — Donut now calls the tool in-turn. But the tool returns zero hits for
|
||||
every query. Root-caused 2026-08-02: Mimir's `search_library` DOES find the DCC corpus
|
||||
(main wing, score ~0.03), so the store is NOT empty and this is NOT a ratatoskr gap —
|
||||
WT's native `reference_knowledge` isn't surfacing content Mimir retrieves fine (likely
|
||||
a wing-scope mismatch — tool scoped to the failed `fiction` wing — or a confidence
|
||||
threshold rejecting the weak ~0.03 hits). Escalated to worldtree-dev. Until fixed,
|
||||
Donut recalls from her own character knowledge (degrades in-voice per the persona).
|
||||
- **DEC-7 — affect-driven emotion. RETIRED 2026-08-07 (chatterbox migration).**
|
||||
chatterbox Turbo has no valence/arousal/emotion control (infra-ops-verified:
|
||||
exaggeration is exposed but inert; cfg_weight/min_p not exposed; only generic
|
||||
sampling knobs move output, and those change timbre/variance not emotion). There
|
||||
is no coupling point for a live-PAD driver, so the entire path is deleted —
|
||||
`PadState`, `EmotionDials`, `pad_to_dials`, the `/api/tts` `p`/`a` body fields, and
|
||||
the browser's `pad` argument. Voice is now flat. Retained below as historical
|
||||
record of the Zonos build. (Original: map live PAD from the `affect_update` SSE →
|
||||
Zonos `emotion_valence`/`emotion_arousal`, reframing the feature as voice
|
||||
OBSERVABILITY. The observability framing dies with the knob.)
|
||||
- **DEC-8 — voice: custom "donut" is REGISTERED (amended 2026-08-10 for dots).**
|
||||
dots clones a voice server-side from a reference clip + transcript; the client just
|
||||
passes a voice NAME (GET /v1/voices lists them: donut/glados/emmie/miranda). The
|
||||
`donut` voice carries over from chatterbox, so `_TTS_VOICE_MAP` maps
|
||||
`ratatoskr:donut → "donut"`; `ratatoskr:sindra → "miranda"` (operator-directed
|
||||
2026-08-10). NOTE the case: lowercase `"donut"` (Zonos used
|
||||
`"Donut"`). Non-interview agents fall to the dots default `"glados"` (was chatterbox
|
||||
`"glados_25s"` / Zonos `"Cora"`, neither of which exists on dots). New voices are a
|
||||
one-line request to infra-ops (derived from the canonical voice corpus).
|
||||
- **DEC-9 — hold English: RESOLVED SERVER-SIDE 2026-08-07 (client sends full text, default
|
||||
sampling).** The Zonos `language:"en-us"` pin is dropped — chatterbox has no `language` field.
|
||||
The long-turn garble ("swaps to German halfway through") went through two WRONG hypotheses
|
||||
before the real cause surfaced under the operator's clean-diagnosis push (thread 01KZEDMJ…):
|
||||
- WRONG-1: "English-only, nothing to drift" (infra-ops Q8) — falsified by the operator report.
|
||||
- WRONG-2: "multilingual leak, curb with tighter sampling" — I shipped `top_k 1000→80` /
|
||||
`top_p 0.95→0.85` / `temp 0.8→0.5`. This made it WORSE: tight sampling pulls the degradation
|
||||
onset to a SHORTER length (garble by ~200 chars vs ~300 at default knobs).
|
||||
- **REAL CAUSE (infra-ops, signal-measured): the Turbo model OVER-RUNS its generation TAIL** —
|
||||
a long single generation degrades into garble/dead-air in its final ~2-3s (voiced-tail
|
||||
zero-crossing rate 1.58x the middle; the lib itself filters OOV tokens + pads silence). The
|
||||
scheduler's unbounded buffer-ratchet built 300-600 char mega-chunks landing squarely in that
|
||||
zone, and streaming concatenated each bad tail. The "German" was that tail garble mis-heard
|
||||
(compounded by shared-3090 OOM garbage, now gone — Zonos was moved off the 3090).
|
||||
- **FIX (deployed server-side, image :v2): `max_chunk_chars=250`** on the gateway scheduler,
|
||||
bounding each generation below the ~300-char onset → clean 3-4 sentence chunks with a full
|
||||
prosodic arc (verified: voiced-tail ZCR 1.58x→0.64x; operator ear-confirmed clean audio AND
|
||||
clean paragraph joins — chatterbox's low emotiveness keeps the seams smooth). Per-request
|
||||
`max_chunk_chars` override available for per-call tuning.
|
||||
- **CONSUMER SIDE (this is what the code does): send FULL text with the gateway's DEFAULT
|
||||
sampling.** The WRONG-2 sampling curbs were REVERTED (they degrade audio earlier and fight
|
||||
the :v2 cap). `gateway_body` is back to `{text, voice, format, stream}` — no client chunking
|
||||
(DEC-10 stays retired; the server chunks at 250), no sampling overrides. The persona's
|
||||
dialogue-only rewrite in `docs/characters/donut.md` still stands. (Original Zonos DEC-9 below.)
|
||||
|
||||
- **DEC-9a — empty-200 OOM guard: KEPT as hygiene (OOM itself resolved).** chatterbox-fast used
|
||||
to share the RTX 3090 with Zonos2 (~1 GB headroom); a long single generation could OOM the card
|
||||
and the gateway then returned HTTP **200 with a 0-byte body**. The OOM is resolved (Zonos moved
|
||||
off the 3090; the :v2 250-char cap keeps generations small), but `tts_endpoint` KEEPS treating an
|
||||
empty 200 body as a synthesis failure → 503 (INV-TTS-4 visible skip) — good hygiene regardless
|
||||
(infra-ops-endorsed), never a silent empty audio/wav stream.
|
||||
- **DEC-10 — long-form chunk-and-concatenate. RETIRED 2026-08-07 (chatterbox
|
||||
migration).** chatterbox-fast has NO per-synth token/duration cap (Zonos capped at
|
||||
6144 tok / 71.2s) and chunks arbitrary-length text INTERNALLY via its adaptive
|
||||
scheduler, streaming seamlessly — so client-side chunk-and-concatenate is deleted:
|
||||
`chunk_text`, `tts_stream_long`, `_pcm_after_header`, and the `_TTS_CHUNK_CHAR_BUDGET`
|
||||
constants are gone; a single `tts_stream` call voices a whole turn. `/api/tts` STAYS
|
||||
POST (DEC-10a) — a long turn still rides the body, not a length-capped URL — and the
|
||||
8000-char outer cap stays as a shared-GPU hold ceiling (no longer a chunk-count
|
||||
bound). The `yielded_any` mid-stream degrade that lived in `tts_stream_long` folds
|
||||
into `tts_stream`. Retained below as historical record of the Zonos build. (Original:)
|
||||
The Zonos model hard-caps ONE synthesis at
|
||||
`max_tokens=6144` = 71.2s of audio (6144 / 86.3 Hz codec frame rate; the gateway 400s
|
||||
above 6144 — an architectural sequence limit, unraisable). So a turn longer than ~71s
|
||||
truncated mid-stream. Fix: split the text client-side and concatenate the PCM
|
||||
(`chunk_text` + `tts_stream_long`). Chunking is paragraph-first + greedy (operator call:
|
||||
fewer, fuller chunks for prosody), targeting ~75% of the cap per chunk (~747 chars @
|
||||
~14 c/s); the 25% headroom covers char→audio-seconds variance (the cap is on TOKENS —
|
||||
6144 = 71.2s ALWAYS, a codec-frame constant regardless of delivery, infra-ops — while the
|
||||
budget is in CHARS, a proxy that stretches under slow/expressive delivery) so no chunk clips;
|
||||
oversized paragraphs fall back to sentence packing, oversized sentences to clause/word
|
||||
sub-splitting. Concatenation emits chunk 1's WAV verbatim then chunks 2..N header-
|
||||
stripped → ONE continuous int16-PCM stream (never bury a RIFF header mid-stream —
|
||||
infra-ops). Identical voice+dials+language per chunk for uniform delivery. `/api/tts`
|
||||
becomes POST (DEC-10a) so the full text rides the body, not a length-capped URL; the
|
||||
outer text cap rises 2000→8000 (a shared-3090 hold bound, not a URL bound).
|
||||
|
||||
- **DEC-11 — config-driven voices + two-voice dialogue/narration split (2026-08-11, operator-directed).**
|
||||
Voice assignment moves from the hardcoded `_TTS_VOICE_MAP` (DEC-8) to a rata-side config file
|
||||
`~/.config/ratatoskr/voices.json` (beside local_agents.json + provider.env; NOT folded into the
|
||||
agent-index schema — isolated from its v1→v2 silent-drop foot-gun). Supersedes DEC-8's hardcoded map.
|
||||
- **Schema:** `{"default": "<voice>", "agents": {"<agent_id>": {"voice": "<voice>", "narration_voice": "<voice>"?}}}`.
|
||||
`voice` = the agent's dialogue/primary voice; an unmapped agent (or one with no `voice`) falls to
|
||||
`default`. Voice names are GATEWAY-validated (GET /v1/voices), not client-asserted.
|
||||
- **`narration_voice` is OPTIONAL and its PRESENCE is the two-voice switch** (no separate flag).
|
||||
Absent → the whole turn is one span in `voice` (byte-identical to the prior single-call passthrough —
|
||||
this is why dialogue-only Donut needs NO special-casing). Present → the turn is SEGMENTED into
|
||||
dialogue vs narration spans; dialogue → `voice`, narration → `narration_voice`.
|
||||
- **Segmentation (FN segment_dialogue):** QUOTED text (straight `"` OR curly `“ ”`) = dialogue;
|
||||
text OUTSIDE quotes = narration. Order preserved; empty/whitespace spans dropped. An unbalanced
|
||||
trailing open-quote → its run-to-end is dialogue (best-effort, never raises). dots' server-side
|
||||
curly→ASCII fold is pronunciation-only and does NOT affect boundary detection (we match both styles).
|
||||
- **Synthesis (FN tts_stream_stitched):** spans synth SERIALLY (dots single-consumer) into ONE
|
||||
continuous stream — span 0 streamed as wav VERBATIM (header + PCM), spans 1..N streamed as wav with
|
||||
the leading WAV header STRIPPED (accumulate-until-`data`, emit after `data`+8) so the browser decodes
|
||||
one gapless 48kHz mono s16le stream after a single leading header. The single-span case is EXACTLY the
|
||||
prior tts_stream passthrough (INV-TTS-6, no regression). The `yielded_any` degrade pivot spans the whole
|
||||
sequence: a pre-first-byte failure on ANY span before span 0 has committed → TtsUnavailable → 503; a
|
||||
failure after ≥1 byte committed → degrade (drop the tail, keep what played), never raise into the 200.
|
||||
- **Config load (FN load_voice_config):** entrypoint reads voices.json at startup → `create_app(voice_config)`
|
||||
→ `app.state.voice_config`; `_tts_endpoint` resolves spans per turn (FN resolve_voice_spans). An
|
||||
absent/malformed file → the built-in DEFAULT_VOICE_CONFIG (donut→donut; sindra→miranda dialogue + emmie
|
||||
narration; glados default), logged — degrade-not-crash (INV-TTS-5).
|
||||
- **Initial voices.json:** `donut → {voice: donut}` (dialogue-only, single voice); `sindra →
|
||||
{voice: miranda, narration_voice: emmie}`.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-TTS-1 [hard]** — the TTS gateway host/URL (dots-tts :8198) never
|
||||
reaches the browser; all synthesis goes through `/api/tts`.
|
||||
- **INV-TTS-2 [hard]** — TTS is opt-in: a 🔊 toggle (default OFF), persisted to
|
||||
localStorage (mirrors the theme/cot-toggle pattern). No speech without it.
|
||||
- **INV-TTS-3 [hard]** — one synth in flight at a time; a new turn cancels the
|
||||
prior synth request AND stops in-flight playback (cancel-on-new-turn).
|
||||
- **INV-TTS-4 [hard]** — TTS failure is non-blocking: a gateway error, non-wav
|
||||
body, or playback failure skips audio; the turn/transcript is unaffected. Logging is
|
||||
scoped to GENUINE failure: a committed-200 mid-stream/later-chunk degrade writes a
|
||||
`tts_degrade` stderr line (server) or a `no WAV header` ticker (browser); a browser-side
|
||||
ABORT/cancel (INV-TTS-3 new-turn) is deliberately SILENT — cancellation is not a failure.
|
||||
- **INV-TTS-5 [hard]** — voice config is degrade-not-crash: an absent, unreadable, or malformed
|
||||
`voices.json` (bad JSON, wrong types, missing keys) falls back to the built-in DEFAULT_VOICE_CONFIG
|
||||
and logs; it NEVER crashes `create_app`/the server. Per-agent malformed entries fall to `default`.
|
||||
- **INV-TTS-6 [hard]** — the single-span path is a byte-identical passthrough: an agent with no
|
||||
`narration_voice` (e.g. Donut), or any turn that segments to one span, produces the exact stream the
|
||||
prior single `tts_stream` call did (one leading WAV header + PCM, verbatim). No regression for the
|
||||
dialogue-only / single-voice case.
|
||||
- **INV-TTS-7 [hard]** — a stitched multi-span stream carries EXACTLY ONE WAV header (span 0's); spans
|
||||
1..N are header-stripped before their PCM is emitted, so the browser decodes one continuous s16le
|
||||
stream (never a RIFF header buried mid-stream).
|
||||
- **INV-KB-1 [hard]** — the KB bridge is import-isolated behind a single seam:
|
||||
`server.py`'s turn path calls exactly one function `pin_kb_context(question,
|
||||
agent_id) -> list[memory_context] | []`. Retiring the bridge = delete
|
||||
`kb_bridge.py` + that one call-site; no other file changes. `kb_bridge` imports
|
||||
nothing from the TTS or SDK-adapter core.
|
||||
- **INV-KB-2 [hard]** — the retrieval channel is never persisted: `memory_context`
|
||||
is per-turn, `kind="corpus_reference"` (a free label), and rides the turn POST
|
||||
body only.
|
||||
- **INV-KB-3 [hard]** — KB-retrieval failure is non-blocking: a failed/empty
|
||||
Mimir consult pins nothing (`[]`) and Donut answers without the extract; never
|
||||
block or fail her turn on retrieval.
|
||||
- **INV-KB-4** — the bridge is scoped to characters that opt in (a per-agent
|
||||
allowlist / config), not applied to every turn — only interview characters
|
||||
that need corpus recall pay the retrieval round-trip.
|
||||
|
||||
## FN blocks
|
||||
|
||||
### FN tts_stream (the sole synthesis primitive — DEC-2 streaming; amended 2026-08-10 dots)
|
||||
```
|
||||
tts_stream(text, *, voice, client: httpx.AsyncClient, url=DOTS_TTS_URL) -> AsyncIterator[bytes]
|
||||
# Open the gateway's CHUNKED stream (client.stream("POST", url, json=gateway_body(text, voice))) and
|
||||
# YIELD wav chunks as they synthesize. Pass through verbatim — never buffer, never rewrite the placeholder
|
||||
# header. dots streams a whole turn from this SINGLE call (DEC-10 RETIRED) — no client-side
|
||||
# chunk-and-concatenate wrapper.
|
||||
# gateway_body(text, voice) = {input, voice, response_format:"wav", stream:true} (OpenAI-shaped: `input`
|
||||
# not `text`, `response_format` not `format`). Full text, DEFAULT sampling. NO dials, NO language,
|
||||
# NO client sampling curbs.
|
||||
precondition: text non-empty. Voice membership in GET /v1/voices is GATEWAY-enforced, not client-asserted.
|
||||
postcondition: yields the gateway's chunked int16 streaming WAV bytes unmodified (0xFFFFFFFF placeholder
|
||||
sizes intact), one leading header then mono s16le PCM @ 48000 Hz to EOF.
|
||||
error (the yielded_any pivot, folded in from the retired tts_stream_long):
|
||||
- a non-200 OPEN or a connect/transport failure BEFORE the first byte -> TtsUnavailable (so the endpoint
|
||||
peek can still return 503; nothing committed yet).
|
||||
- a transport drop AFTER >= 1 byte has streamed (the 200 is committed) -> DEGRADE: write a `tts_degrade`
|
||||
stderr line, END the generator, keep what played. NEVER raise into the committed StreamingResponse.
|
||||
invariant: format is ALWAYS "wav" (DEC-3); never mp3/opus/pcm from this seam.
|
||||
```
|
||||
|
||||
### FN chunk_text (DEC-10 long-form splitting; pure) — RETIRED 2026-08-07 (chatterbox chunks internally; deleted). Historical:
|
||||
```
|
||||
chunk_text(text, budget=_TTS_CHUNK_CHAR_BUDGET) -> list[str]
|
||||
# Split into synthesis chunks each <= budget chars. Paragraph-first (seams on blank lines), greedy pack.
|
||||
steps:
|
||||
- strip; empty/whitespace -> [].
|
||||
- whole paragraphs (each <= budget) greedily pack together, joined "\n\n".
|
||||
- a paragraph > budget flushes the pending run, then sentence-packs (split (?<=[.!?])\s+, join " ");
|
||||
a sentence > budget sub-splits on clause (, ; :) then space, hard-cut mid-word only as last resort.
|
||||
postcondition: every chunk non-empty and <= budget; word order preserved; no split mid-word unless the
|
||||
input has no boundary at all. budget = 71.2s * 0.75 * 14 c/s ≈ 747 (75% of cap for prosody).
|
||||
```
|
||||
|
||||
### FN tts_stream_long (DEC-10 orchestrator) — RETIRED 2026-08-07 (no per-synth cap; deleted, its yielded_any degrade folded into tts_stream). Historical:
|
||||
```
|
||||
tts_stream_long(text, *, voice, dials, client, url=ZONOS_TTS_URL, budget=_TTS_CHUNK_CHAR_BUDGET) -> AsyncIterator[bytes]
|
||||
# chunk_text(text) then synth each chunk with tts_stream (identical voice+dials+language). Emit chunk 1
|
||||
# VERBATIM (WAV header + PCM); chunks 2..N via _pcm_after_header (strip up to+incl the `data` id+size) so
|
||||
# the browser decodes ONE continuous int16-PCM stream after a single leading header (infra-ops: one header).
|
||||
error: the pivot is `yielded_any`, NOT the chunk index. A failure BEFORE the first byte (an OPEN failure)
|
||||
propagates as TtsUnavailable -> endpoint peek -> 503 (nothing committed yet). A failure AFTER bytes
|
||||
have streamed — a MID-STREAM drop on chunk 0 OR a later chunk, past the committed 200 — degrades:
|
||||
drop the tail, keep what played, write a `tts_degrade` stderr line, RETURN (never raise into the
|
||||
committed StreamingResponse). httpx wraps aiter_bytes in `except RequestError`, so a mid-stream
|
||||
drop arrives as TtsUnavailable, not a clean generator end — the yielded_any gate is what keeps a
|
||||
chunk-0 mid-stream drop from raising into the 200.
|
||||
```
|
||||
|
||||
### FN pad_to_dials — RETIRED 2026-08-07 (DEC-7 affect dropped; PadState/EmotionDials/pad_to_dials all deleted). Historical:
|
||||
```
|
||||
pad_to_dials(pad: PadState | None) -> EmotionDials
|
||||
# Map live PAD -> Zonos emotion dials (DEC-7).
|
||||
steps:
|
||||
- none/malformed pad -> neutral dials (emotion_enabled=False) [degrade-not-crash].
|
||||
- emotion_valence = clamp(pad.pleasure, -1, 1); emotion_arousal = clamp(pad.arousal, -1, 1).
|
||||
- emotion_enabled = True; emotion_strength from a fixed default (tunable).
|
||||
invariant: total over any PAD the DECLARED surface produces (a PadState with float axes /
|
||||
None / out-of-range / NaN / inf / a non-PadState object) -> valid dials, never raises.
|
||||
(A PadState carrying NON-float axes is a type violation no call site constructs — the
|
||||
endpoint coerces via PadState.from_obj; not defended inside pad_to_dials.)
|
||||
```
|
||||
|
||||
### FN tts_endpoint (server.py, POST /api/tts — DEC-10a; amended 2026-08-07)
|
||||
```
|
||||
POST /api/tts {text, agent_id?} -> audio/wav (chunked StreamingResponse)
|
||||
# POST (not GET) so an arbitrarily long turn rides the body, not a length-capped URL. The gateway chunks
|
||||
# arbitrary-length text internally (DEC-10 RETIRED — no client concat); a single tts_stream call proxies it.
|
||||
# text capped 8000 chars, word-boundary truncated (a shared-GPU hold bound; the transcript still shows the
|
||||
# full text). text/agent_id are untrusted open-world body fields — each degrades, never 500s (INV-TTS-4).
|
||||
# (The `p`/`a` PAD body fields are GONE — DEC-7 affect retired.)
|
||||
steps:
|
||||
- bad JSON / non-str text -> 400. Scrub lone surrogates from text (else httpx's utf-8 encode of the gateway
|
||||
body 500s); if the scrubbed text is blank after strip -> 400. word-boundary truncate to 8000 (with a
|
||||
mid-word HARD-CUT fallback when the last space sits at index <= limit//2).
|
||||
- resolve voice: per-character map -> "donut", default "glados_25s"; a NON-str agent_id (unhashable) -> default.
|
||||
- acquire the serialize lock (DEC-5, one stream at a time on the shared GPU); open tts_stream and PEEK the
|
||||
first byte: a bad gateway OPEN -> 503; an EMPTY 200 body (no bytes — an OOM synth, DEC-9a) -> 503; and a
|
||||
200 whose first bytes are NOT a RIFF header -> 503 too (a mislabeled non-WAV body would decode as
|
||||
garbage) — all BEFORE committing a 200 (INV-TTS-4). Any OTHER escape during the peek (CancelledError,
|
||||
httpx.InvalidURL) releases the lock+client, then propagates.
|
||||
- return StreamingResponse piping tts_stream; the generator's finally releases the lock + closes the client
|
||||
(incl. the browser-abort path: a new turn's fetch() drops the POST) and, on a committed mid-stream drop,
|
||||
tts_stream degrades internally (ends the generator, no raise). httpx.Timeout is connect=10 / read=120 /
|
||||
write=10 / pool=10 (read=120 per infra-ops: a long synth can render slowly).
|
||||
```
|
||||
|
||||
### FN pin_kb_context (kb_bridge.py — RETIRE-READY, INV-KB-1)
|
||||
```
|
||||
pin_kb_context(question: str, agent_id: str | None, *, client) -> list[dict] # memory_context items, or []
|
||||
# The bridge. Consumer-side retrieval + pinning (DEC-6).
|
||||
steps:
|
||||
- gate on the interview-character allowlist (INV-KB-4); not listed / blank question -> [].
|
||||
- out-of-band Mimir consult (a throwaway session + one turn), HARD-bounded by a timeout.
|
||||
- extract the answer text (prefer DoneEvent.response; fall back to text deltas), bounded length.
|
||||
- return [{"kind":"corpus_reference","text":<extract>}].
|
||||
error/empty/timeout: any failure or no hits -> [] (INV-KB-3; never raises to the turn path).
|
||||
CancelledError (browser disconnect) is NOT caught — it propagates.
|
||||
no-hit sentinel (heid-code-review F7): the consult prompt asks Mimir to emit exactly NO_CORPUS_MATCH
|
||||
when the search finds nothing relevant; pin_kb_context drops any extract containing
|
||||
it -> [], so a non-empty "no results found" answer is never pinned as the character's
|
||||
own memory. The token is artificial (no genuine passage contains it).
|
||||
session hygiene (heid-bug-hunt): the throwaway Mimir consult session is deleted (SDK sessions.delete) on
|
||||
success/error/timeout via a caller-owned holder, so consults don't accumulate upstream.
|
||||
CONSULT PROMPT (foot-gun mitigation, tuned live 2026-08-02): force "search_library EXACTLY ONCE,
|
||||
no read_note" — converges Mimir in ~3-15s. The softer "do one search" phrasing let Mimir loop
|
||||
read_note<->search past a 25s ceiling on conversational (non-keyword) questions (live-observed).
|
||||
KNOWN LIMIT (surfaced by the live smoke, NOT a bridge defect): the bridge's GROUNDING VALUE is gated
|
||||
by Mimir's retrieval quality on the target corpus. DCC's fiction-wing index is currently weak
|
||||
(scores ~0.02, failed backfill — a standing worldtree-dev item), so hits are noisy/partial; the
|
||||
model's own DCC training knowledge already grounds Donut well, so the bridge is opportunistic here.
|
||||
Its real payoff is a corpus the model does NOT know AND that indexes cleanly.
|
||||
RETIREMENT: when Worldtree #361 reference_knowledge reaches Tier-3, delete this module + the single
|
||||
server.py call-site (wt.stream_turn's memory_context param stays, inert); Donut then
|
||||
searches in-voice natively.
|
||||
```
|
||||
|
||||
### FN client: speakOnDone (index.html — Web Audio STREAMING, DEC-2; amended 2026-08-07)
|
||||
```
|
||||
on SSE `done`:
|
||||
if !ttsEnabled(): return # INV-TTS-2
|
||||
cancelTts() # INV-TTS-3: abort fetch + stop scheduled nodes
|
||||
POST /api/tts {text (sliced to the 8000 cap), agent_id?} -> reader # DEC-10a: POST body. NO p/a (DEC-7 retired).
|
||||
loop: read chunk -> skip ONE WAV header up to the data chunk (bounded 64KiB) -> int16 LE PCM -> Float32 ->
|
||||
AudioBuffer(sampleRate=48000) -> BufferSource.start(playAt) GAPLESSLY -> playAt += buf.duration
|
||||
# SR = 48000 (dots; was 24000 for chatterbox — MUST match or the voice plays ~2x too fast). TTFA ~0.5s.
|
||||
first scheduled node -> "▶ voiced". HARD failure (non-OK HTTP, or 64KiB with no WAV header) -> ticker + skip;
|
||||
ABORT/cancel (INV-TTS-3 new-turn) + bare network error -> SILENT skip (INV-TTS-4, cancel is not a failure)
|
||||
|
||||
WHY Web Audio, not <audio src>: Safari/WebKit REFUSES a streaming 0xFFFFFFFF-length WAV via <audio src>
|
||||
(NotSupportedError — it can't compute duration/seek), which was the operator's live failure. Decoding the raw
|
||||
int16 PCM ourselves and scheduling it into an AudioContext sidesteps every WAV-container quirk and works in all
|
||||
engines. Validated in Chromium: 43 nodes scheduled, 5.1s decoded, no error.
|
||||
|
||||
AUTOPLAY UNLOCK: an AudioContext starts "suspended"; Safari + Chrome require resume() to originate from a user
|
||||
gesture (then it stays running). _unlockTtsAudio() resumes it on the FIRST interaction anywhere (document
|
||||
pointerdown/keydown) + toggle-on + each submit, so it's running before the ~15s-delayed speak-on-done. Validated:
|
||||
ctx is "running" 6.5s after the gesture (past the transient-activation window). Page served no-store so a stale
|
||||
cache can't hide these updates.
|
||||
```
|
||||
|
||||
## Slice plan
|
||||
|
||||
1. **Persona** — author `docs/characters/donut.md` (corpus-grounded, interview frame,
|
||||
markdown-RP register) + `tier3 define` Princess Donut. Visible: she's in the picker,
|
||||
chattable (answers from training until slice 3). No code.
|
||||
2. **Auto-TTS** — `tts.py` (gateway client + pad_to_dials) + `/api/tts` proxy + the
|
||||
index.html 🔊 toggle + speak-on-done playback + serialize/cancel. Visible: she's voiced,
|
||||
affect-modulated. TDD (respx-mock the gateway; live smoke on the console).
|
||||
3. **KB-bridge** — `kb_bridge.py` + the one server.py seam. Visible: she recalls the DCC
|
||||
corpus in-voice. TDD. RETIRE-READY per INV-KB-1.
|
||||
|
||||
Out of scope: true audio streaming (DEC-2), custom voice registration (DEC-8, infra-ops
|
||||
follow-up), extending the bridge to non-interview agents.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -950,7 +950,7 @@
|
||||
"info": {
|
||||
"description": "Multi-turn conversation interface for Worldtree agents.",
|
||||
"title": "Worldtree Conversation API",
|
||||
"version": "2.3.0"
|
||||
"version": "2.4.0"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Worldtree Conversation API — Client Interface Specification
|
||||
|
||||
**Version:** 1.1
|
||||
**Version:** 1.3
|
||||
**Date:** 2026-07-18
|
||||
**Status:** Stable
|
||||
**Contract:** `docs/contracts/conversation_api.contract.md`
|
||||
@@ -1209,7 +1209,19 @@ Send a message and receive the agent's response as a Server-Sent Events (SSE) st
|
||||
|
||||
The stream emits JSON-encoded events. Each SSE `data:` line contains a JSON object with a `type` field.
|
||||
|
||||
**Errors:** `404` if session not found (returned before stream starts).
|
||||
**Content size (normative, spec 2.4.0):** `content` is bounded on **UTF-8
|
||||
byte length** by `message_content_max_bytes` (server config; **default
|
||||
262144 = 256 KiB — the normative spec number**). Over-cap content is
|
||||
rejected with an eager `422` carrying `error_code: content_too_long`
|
||||
*before* any SSE bytes are emitted (plain JSON error envelope, the
|
||||
pre-stream error path). Content that is not valid UTF-8 (lone surrogates)
|
||||
is a deterministic `422 validation_failed`. This cap is the number
|
||||
client-side read bounds derive from: a max-page transcript body is
|
||||
structurally ≤ 200 items × (cap + envelope overhead), and no single
|
||||
message-bearing SSE frame can exceed cap + envelope overhead.
|
||||
|
||||
**Errors:** `404` if session not found (returned before stream starts);
|
||||
`422 content_too_long` for over-cap content (see above).
|
||||
|
||||
---
|
||||
|
||||
@@ -1634,7 +1646,7 @@ Creating an Echo session requires the `instantiate:echo` scope. This scope is bu
|
||||
|
||||
Public primitive for downstream consumers (Skaldsong, RPG/game engines, dialog tools) that need to spin up dozens of distinct OCEAN-driven personas per session. Worldtree owns no durable state — the consumer ships character JSON, gets a `character_id`, and runs sessions against it. Persists in-memory only; process restart drops everything.
|
||||
|
||||
The character displaces the **persona + model** layer of the bound session. The `agent_id` (typically `actor`) still resolves system prompt, tools, and the LLM provider unless the character carries a `model` override.
|
||||
The character displaces the **persona + model-role** layer of the bound session. The `agent_id` (typically `mask`, renamed from `actor` at v0.29.9/#211) still resolves system prompt, tools, and the LLM provider unless the character carries a `role` override (a configured model-role per ADR-0012 — the raw `model` selector left this surface at the role cutover).
|
||||
|
||||
### `POST /characters`
|
||||
|
||||
@@ -1655,7 +1667,7 @@ Create a transient character. Requires `character.write` scope.
|
||||
"description": "A melancholy prince.",
|
||||
"narrative": "He speaks to himself when alone, weighs every choice three ways…",
|
||||
"voice_profile_block": "Formal English; iambic pentameter under pressure; weights every word.",
|
||||
"model": "fast"
|
||||
"role": "fast"
|
||||
},
|
||||
"state": null
|
||||
}
|
||||
@@ -1691,7 +1703,7 @@ Remove the character. Requires `character.write` scope. Sessions bound to this c
|
||||
|
||||
### `GET /models/available-for-characters`
|
||||
|
||||
Return the model profiles the actor (and the `model` field on `CharacterSchema`) can reference. Requires `character.read` scope.
|
||||
Return the catalog bindings the `character` model-role resolves to (what the `role` field on `CharacterSchema` can reach). Requires `character.read` scope.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1725,7 +1737,7 @@ When supplied, the session binds the character. The character's persona (OCEAN,
|
||||
- `410 character_not_found` — character was deleted/expired (returned at the session-bind path on next turn)
|
||||
- `422 ttl_too_large` — `ttl_seconds` over the configured ceiling
|
||||
- `422 state_schema_outdated` — schema_version mismatch; detail includes `accepted_versions`
|
||||
- `422 model_not_available_for_characters` — `model` field references a profile not in the allowlist
|
||||
- `404 model_not_available` — `role` field names an unconfigured model-role (resolved through the `character` role gate; the pre-cutover `model_not_available_for_characters` code is retired)
|
||||
- `422 validation_failed` — OCEAN out of `[-1, 1]`, voice_profile_block too long, etc.
|
||||
- `429 quota_exceeded` — per-user character cap hit
|
||||
- `403 auth_scope_denied` — missing `character.write` or `character.read`
|
||||
@@ -2404,24 +2416,24 @@ The `POST /sessions/{session_id}/messages` endpoint accepts an additive `upload_
|
||||
2. Each upload existence/ownership/expiry check — first failure short-circuits with `410 upload_expired` or `404`
|
||||
3. Agent capability gate — if `upload_ids` non-empty and `agent.capabilities` does not include `accepts_uploads` → `422 agent_lacks_upload_support`
|
||||
|
||||
### Per-call model override (issue #157)
|
||||
### Per-call role override (issue #157, role-keyed since #261/#344)
|
||||
|
||||
The `POST /sessions/{session_id}/messages` endpoint also accepts an additive `model` field that overrides the character / agent default for this turn only:
|
||||
The `POST /sessions/{session_id}/messages` endpoint also accepts an additive `role` field that overrides the character / agent default model-role for this turn only:
|
||||
|
||||
```json
|
||||
{"content": "Reformat this YAML.", "model": "granite-structured"}
|
||||
{"content": "Reformat this YAML.", "role": "assistant"}
|
||||
```
|
||||
|
||||
**Semantics:**
|
||||
- Override is per-call only. Stored `CharacterSchema.model` is NOT mutated.
|
||||
- Validated against the same `available_for_characters` allowlist that gates `CharacterSchema.model` at create time (#153 INV-091).
|
||||
- Override displaces the character's bound model when both are set (per-call wins).
|
||||
- Override is per-call only. Stored `CharacterSchema.role` is NOT mutated.
|
||||
- Resolved through the same `character` model-role gate that validates `CharacterSchema.role` at create time (ADR-0012).
|
||||
- Override displaces the character's bound role when both are set (per-call wins).
|
||||
- Override is REJECTED on ephemeral (Echo) sessions — their config is frozen at session-create per INV-161-2.
|
||||
|
||||
**Validation:**
|
||||
1. Pydantic validates `model`: optional string, non-empty after stripping whitespace.
|
||||
2. If the session is ephemeral (`ephemeral_config != None`): `422 validation_failed` ("per-call 'model' override is not permitted on ephemeral sessions").
|
||||
3. If `model` is not in the `available_for_characters` allowlist: `422 model_not_available_for_characters`.
|
||||
1. Pydantic validates `role`: optional string, non-empty after stripping whitespace.
|
||||
2. If the session is ephemeral (`ephemeral_config != None`): `422 validation_failed` ("per-call 'role' override is not permitted on ephemeral sessions").
|
||||
3. If `role` names an unconfigured model-role: `404 model_not_available`.
|
||||
|
||||
**Audit:** the `turn.started` admin event carries the effective `model` plus a `model_override_applied: bool` flag so cross-system audit trails can see what model actually ran on a per-turn basis.
|
||||
|
||||
@@ -2714,7 +2726,7 @@ below document the active layers and their exact validated shapes.
|
||||
| -------- | --------------------------------- | ------------------------------------------ |
|
||||
| `POST` | `/agents/define` | Create a Tier 3 agent. |
|
||||
| `DELETE` | `/agents/<user_id>:<agent_name>` | Owner-initiated hard-delete. |
|
||||
| `PATCH` | `/agents/<user_id>:<agent_name>` | Mutate `system_prompt` and/or `model`. |
|
||||
| `PATCH` | `/agents/<user_id>:<agent_name>` | Mutate `system_prompt` and/or `role`. |
|
||||
| `POST` | `/sessions` | Tier 3 routing when `agent_id` has a `:`. |
|
||||
| `GET` | `/sessions/<session_id>/tools` | Owner-scoped session-tools introspection (#183, Phase 2.0.1). |
|
||||
|
||||
@@ -2724,7 +2736,7 @@ below document the active layers and their exact validated shapes.
|
||||
{
|
||||
"agent_name": "wizard",
|
||||
"system_prompt": "You are a guided-elicitation wizard...",
|
||||
"role": "gen-reasoning", // REQUIRED — a configured model-role (#344), not a raw model id
|
||||
"role": "thoughtful-assistant", // REQUIRED — a configured model-role (#344), not a raw model id
|
||||
"persona": { // active (Phase 2.1) — single-letter OCEAN keys; see "Persona layer"
|
||||
"ocean": {"O": 0.4, "C": 0.6, "E": -0.3, "A": 0.2, "N": 0.5}
|
||||
},
|
||||
@@ -2742,12 +2754,19 @@ Response (`201 Created`):
|
||||
"user_id": "alice",
|
||||
"agent_name": "wizard",
|
||||
"system_prompt": "...",
|
||||
"model": "glm5-turbo",
|
||||
"role": "thoughtful-assistant",
|
||||
"created_at": "2026-05-19T12:00:00+00:00",
|
||||
"updated_at": "2026-05-19T12:00:00+00:00"
|
||||
}
|
||||
```
|
||||
|
||||
The response `role` echoes the requested role — symmetric with the request
|
||||
(W-4 closed, spec 1.2; the example above would echo
|
||||
`"role": "thoughtful-assistant"`). The resolved engine catalog id is
|
||||
privileged and never surfaces (#344 INV-344-3). A legacy pre-cutover row
|
||||
defined with a raw model (null stored role) surfaces that stored model
|
||||
string in this field.
|
||||
|
||||
Caller must:
|
||||
|
||||
- hold `agents.define` scope (default for `user` tier);
|
||||
@@ -2762,6 +2781,19 @@ after definition.
|
||||
The 201 response includes an advisory `warnings` array (#219) — see
|
||||
"Model-assignment warnings" under `PATCH` below.
|
||||
|
||||
**Duplicate name (normative, spec 2.4.0):** re-defining an existing
|
||||
`agent_name` returns `409` with `error_code: agent_name_taken` AND a
|
||||
structured `agent_id` field naming the existing agent
|
||||
(`{"detail": {"error_code": "agent_name_taken", "message": "...",
|
||||
"agent_id": "alice:wizard"}}`). Define-then-catch-409 is therefore the
|
||||
sanctioned, race-safe **define-or-reuse** shape: one round trip, no
|
||||
client-side id composition, no lookup-then-define TOCTOU window. (The
|
||||
`user_id` field on `GET /me` is also normative — guaranteed present on
|
||||
every response shape including anonymous and Heimdall-degraded — so
|
||||
client-side `<user_id>:<agent_name>` composition remains sanctioned for
|
||||
addressing, per the id shape documented above; the 409 field simply makes
|
||||
it unnecessary for the reuse flow.)
|
||||
|
||||
> **Vendoring note (OpenAPI 2.3.0).** In the frozen OpenAPI 2.3.0 document
|
||||
> the `persona` / `motivational` / `memory` / `valence` request fields
|
||||
> serialize as **untyped/freeform** — the `POST /agents/define` request
|
||||
@@ -2969,16 +3001,18 @@ session bound to this agent and revokes the owner's per-resource
|
||||
|
||||
#### `PATCH /agents/<user_id>:<agent_name>`
|
||||
|
||||
**Mutable surface (Phase 2.3, #188): `system_prompt` and/or `model` only.**
|
||||
PATCH re-enforces the same validation as define — the `system_prompt`
|
||||
byte-cap and the `model` allowlist. Any other key returns a 422 BEFORE
|
||||
the DB lookup (so an immutable-field PATCH against a missing agent still
|
||||
422s, not 404s), with the error code chosen by *why* the field can't be
|
||||
set:
|
||||
**Mutable surface (Phase 2.3, #188; role-keyed since #344): `system_prompt`
|
||||
and/or `role` only.** PATCH re-enforces the same validation as define — the
|
||||
`system_prompt` byte-cap, and `role` must name a configured model-role
|
||||
(unknown role → 404 `model_not_available`). Any other key returns a 422
|
||||
BEFORE the DB lookup (so an immutable-field PATCH against a missing agent
|
||||
still 422s, not 404s), with the error code chosen by *why* the field can't
|
||||
be set:
|
||||
|
||||
| Field(s) | Code | Reason |
|
||||
|---|---|---|
|
||||
| `agent_name`, `user_id`, `agent_id` | `field_not_mutable` | Identity — fixed at creation. |
|
||||
| `model` | `field_not_mutable` | Engine catalog ids left the surface at the role cutover (#344) — select via `role`. |
|
||||
| `persona`, `motivational` | `field_not_mutable` | Shipped traits; an agent *is* its personality/goals. Change → define a new agent. |
|
||||
| `memory` | `field_not_mutable` | Rejected **wholesale** — see below. |
|
||||
| `valence` | `layer_deferred` | Not a shipped layer yet (matches define-time); not a frozen trait. |
|
||||
@@ -2997,18 +3031,23 @@ whole field with `field_not_mutable`. When a real long-term-memory tuning
|
||||
dial ships, its PATCH semantics will be specified at that time.
|
||||
|
||||
**Active sessions are unaffected.** A PATCH never mutates an in-flight
|
||||
session's cached `AgentContext`; new `system_prompt` / `model` values take
|
||||
session's cached `AgentContext`; new `system_prompt` / `role` values take
|
||||
effect only at the next session-create.
|
||||
|
||||
**Audit.** A successful PATCH emits one `agents.patch` event whose
|
||||
`changes` detail records before/after per mutated field: `model` as literal
|
||||
`{before, after}` values, and `system_prompt` as `{before_bytes,
|
||||
`changes` detail records before/after per mutated field: a `role` change is
|
||||
recorded as its **derived catalog** `model` `{before, after}` pair (the
|
||||
audit log is a privileged surface; #344 INV-344-6 updates role + derived
|
||||
catalog id in one write), and `system_prompt` as `{before_bytes,
|
||||
after_bytes}` only — the raw prompt text is never written to the audit log
|
||||
(potential PII).
|
||||
|
||||
**Model-assignment warnings (#219).** A `model` swap is **not blocked** for
|
||||
capability or context-window compatibility, but PATCH (and `define`) attach an
|
||||
advisory `warnings` array to the response — see the shared subsection below.
|
||||
**Model-assignment warnings (#219).** A role swap is **not blocked** for
|
||||
capability or context-window compatibility. PATCH (and `define`) attach an
|
||||
advisory `warnings` array to the response — but a **role-defined agent
|
||||
always receives `[]`** (#344 INV-344-3: the warning texts name catalog-level
|
||||
engine details the role consumer didn't pick and can't act on); only legacy
|
||||
null-role rows can surface them. See the shared subsection below.
|
||||
Correctness for over-budget prompts remains the runtime `context_overflow`
|
||||
guard; the warnings are an early, best-effort heads-up.
|
||||
|
||||
@@ -3028,9 +3067,12 @@ two mutation responses, not on `GET /agents/<id>`. Each entry is
|
||||
|
||||
Semantics:
|
||||
|
||||
- **`define`** has no prior model, so only `model_context_window_unknown` can
|
||||
fire there. **PATCH** computes warnings only when the payload changes `model`
|
||||
(a `system_prompt`-only PATCH returns `warnings: []`); the comparison is
|
||||
- **Role-defined agents never receive these warnings** — `warnings` is
|
||||
always `[]` for them (#344 INV-344-3); the codes below can only surface on
|
||||
legacy null-role rows. **`define`** has no prior model, so only
|
||||
`model_context_window_unknown` can fire there. **PATCH** computes warnings
|
||||
only when the payload actually changes the underlying model (a
|
||||
`system_prompt`-only PATCH returns `warnings: []`); the comparison is
|
||||
against the resulting model.
|
||||
- Capability warnings are **conditional by nature**: a Tier 3 agent row does
|
||||
not record whether it uses tools/vision/audio (tools arrive per-session via
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Descriptive-query subject-binding probe — the canonical fixture for Worldtree #393.
|
||||
|
||||
#393: an ATTRIBUTE/descriptive question ("the guy with the roid rage") reformulates to a
|
||||
token query that matches MULTIPLE distinct canon subjects on a shared word ("rage"), so the
|
||||
intended entity can be absent from top-k while topically-adjacent decoys rank above it. The
|
||||
name-check (`names_subject`) can't help — the caller has no name to pass until the attribute
|
||||
is resolved to an entity, which is the open problem. Downstream, the consumer sometimes binds
|
||||
to the wrong co-retrieved subject and cross-contaminates details (a confident, fluent mis-bind
|
||||
assembled from real-but-mismatched rows, not a hallucination).
|
||||
|
||||
Two regimes at DIFFERENT WING SCOPES (the distinction is load-bearing — Worldtree #393):
|
||||
* RAW RANKING — ALL-WING (mimir searches kb+main+fiction, ~9800 rows). Drive `mimir` with the
|
||||
descriptive query + variants; report the query mimir ACTUALLY passed (tool_start q=, since it
|
||||
reformulates) and where the intended ENTITY lands. Cross-wing dilution can push a one-arm
|
||||
vector bridge out of the bge rescue window entirely -> entity ABSENT from top-k.
|
||||
* CONSUMER — FICTION (ratatoskr:donut is fiction-scoped, ~1578 rows). Drive Donut N times;
|
||||
classify BINDS-ENTITY vs MIS-BINDS-DECOY vs OTHER, and report where the ENTITY and DECOY rank
|
||||
in her fiction-scoped results. The ranks tell WHICH failure fired per run: entity PRESENT +
|
||||
mis-bind = subject-selection; entity ABSENT + a present decoy = reformulation-induced absence
|
||||
(Donut distilled the descriptive phrase to bare tokens that don't carry the vocabulary bridge).
|
||||
Empirically Donut mostly does the latter — she distills "the guy with the roid rage" to bare
|
||||
"roid rage", so Juicer drops out even at fiction scope and she binds a present decoy (Jack).
|
||||
|
||||
Two-seam finding (2026-08-07, v1.0.0b181) — both lose the entity, by DIFFERENT reformulation seams:
|
||||
(1) mimir PRESERVES the phrase -> cross-wing dilution (kb+main+fiction) drops the entity from its
|
||||
all-wing top-k; (2) Donut DISTILLS the phrase to bare tokens -> the entity drops even at fiction
|
||||
scope (ent@None) and she binds a present decoy. The fold's ENT@/DEC@ ranks separate reformulation-
|
||||
absence from true subject-selection per run. Unifying lever: disambiguating-vocabulary expansion
|
||||
(the full phrase, or +attribute like "steroid") surfaces the entity at fiction scope — Donut's
|
||||
expand-runs bind correctly. "dangerous crown" is mostly RESOLVED (one entity). Root gap:
|
||||
attribute->entity resolution, upstream of names_subject by construction.
|
||||
|
||||
Self-contained: the only third-party dependency is httpx (`uv run --with httpx`). Config from env:
|
||||
WORLDTREE_API_URL, WORLDTREE_API_KEY, RATATOSKR_END_USER_ID. No secrets stored here.
|
||||
|
||||
Usage:
|
||||
uv run --with httpx python docs/diagnostics/descriptive_query_binding.py
|
||||
uv run --with httpx python docs/diagnostics/descriptive_query_binding.py --runs 10
|
||||
uv run --with httpx python docs/diagnostics/descriptive_query_binding.py --case roid-rage
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections import Counter
|
||||
|
||||
import httpx
|
||||
|
||||
# (label, descriptive question, raw-ranking query variants, intended-entity regex, decoy regex|None)
|
||||
CASES = [
|
||||
{
|
||||
"label": "roid-rage",
|
||||
"question": "Tell me about the guy with the roid rage.",
|
||||
"variants": ["the guy with the roid rage", "roid rage"],
|
||||
"entity": r"juicer",
|
||||
"decoy": r"\bJack\b",
|
||||
},
|
||||
{
|
||||
"label": "dangerous-crown",
|
||||
"question": "Tell me about that dangerous crown.",
|
||||
"variants": ["that dangerous crown", "dangerous crown"],
|
||||
"entity": r"sepsis|crown of the sepsis whore",
|
||||
"decoy": None,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _cfg() -> tuple[str, dict, str]:
|
||||
base = os.environ.get("WORLDTREE_API_URL", "http://10.250.50.152:8081")
|
||||
key = os.environ.get("WORLDTREE_API_KEY")
|
||||
if not key:
|
||||
raise SystemExit("WORLDTREE_API_KEY unset — source env.sh first.")
|
||||
end_user = os.environ.get("RATATOSKR_END_USER_ID", "ratatoskr-tui")
|
||||
return base, {"Authorization": f"Bearer {key}"}, end_user
|
||||
|
||||
|
||||
def _session(base: str, headers: dict, agent_id: str, end_user: str) -> str:
|
||||
r = httpx.post(f"{base}/sessions", json={"agent_id": agent_id, "end_user_id": end_user},
|
||||
headers=headers, timeout=30)
|
||||
r.raise_for_status()
|
||||
return r.json()["session_id"]
|
||||
|
||||
|
||||
def _drive(base: str, headers: dict, sid: str, content: str) -> tuple[str | None, dict, str]:
|
||||
"""POST a turn; return (tool_query, first tool_result dict, accumulated answer text)."""
|
||||
q, result, parts = None, {}, []
|
||||
with httpx.stream("POST", f"{base}/sessions/{sid}/messages", json={"content": content},
|
||||
headers=headers, timeout=180) as r:
|
||||
for line in r.iter_lines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
ev = json.loads(line[6:])
|
||||
t = ev.get("type")
|
||||
if t == "tool_start" and q is None:
|
||||
q = (ev.get("arguments") or {}).get("query")
|
||||
elif t == "tool_result" and not result:
|
||||
result = ev.get("result") if isinstance(ev.get("result"), dict) else {}
|
||||
elif t == "text":
|
||||
v = ev.get("text") or ev.get("content") or ev.get("delta")
|
||||
if isinstance(v, str):
|
||||
parts.append(v)
|
||||
elif t == "done":
|
||||
break
|
||||
return q, result, "".join(parts)
|
||||
|
||||
|
||||
def _flag(excerpt: str, entity: str, decoy: str | None) -> str:
|
||||
if re.search(entity, excerpt, re.I):
|
||||
return "ENTITY"
|
||||
if decoy and re.search(decoy, excerpt):
|
||||
return "DECOY "
|
||||
return " "
|
||||
|
||||
|
||||
def _rank_in(res: dict, pattern: str | None) -> int | None:
|
||||
"""Rank of the first row whose excerpt matches `pattern` (None if absent / no pattern)."""
|
||||
if not pattern:
|
||||
return None
|
||||
rows = res.get("hits", res.get("results", [])) if isinstance(res, dict) else []
|
||||
return next((i for i, r in enumerate(rows) if isinstance(r, dict)
|
||||
and re.search(pattern, r.get("excerpt", ""), re.I)), None)
|
||||
|
||||
|
||||
def raw_ranking(base, headers, end_user, case) -> None:
|
||||
print("\n [raw ranking — ALL-WING/mimir] actual tool query (reformulation seam) + entity rank")
|
||||
for query in case["variants"]:
|
||||
sid = _session(base, headers, "mimir", end_user) # fresh session per query
|
||||
mimir_q, res, _ = _drive(base, headers, sid, f"Use search_library to find: {query}")
|
||||
rows = res.get("results", []) if isinstance(res, dict) else []
|
||||
entity_ranks = [i for i, r in enumerate(rows) if isinstance(r, dict)
|
||||
and re.search(case["entity"], r.get("excerpt", ""), re.I)]
|
||||
rank = entity_ranks[0] if entity_ranks else "ABSENT (not in top-k)"
|
||||
# mimir_q is load-bearing for #393: separates reformulation-at-the-agent-seam
|
||||
# (mimir distilled/expanded the phrase) from ranking (the tool ranked it low).
|
||||
print(f" instructed={query!r:28} mimir_q={mimir_q!r:38} entity_rank={rank}")
|
||||
for i, r in enumerate(rows[:6]):
|
||||
if isinstance(r, dict):
|
||||
ex = (r.get("excerpt") or "").replace("\n", " ")
|
||||
print(f" #{i} [{_flag(ex, case['entity'], case['decoy'])}] "
|
||||
f"{r.get('score')} {ex[:74]}")
|
||||
|
||||
|
||||
def consumer(base, headers, end_user, case, runs) -> Counter:
|
||||
verdicts: Counter = Counter()
|
||||
print(f"\n [consumer — FICTION/donut] x{runs} on {case['question']!r}"
|
||||
f" (ent@/dec@ = rank in Donut's fiction-scoped results)")
|
||||
for run in range(1, runs + 1):
|
||||
sid = _session(base, headers, "ratatoskr:donut", end_user) # fresh session per run
|
||||
q, res, ans = _drive(base, headers, sid, case["question"])
|
||||
binds = bool(re.search(case["entity"], ans, re.I))
|
||||
mis = bool(case["decoy"]) and bool(re.search(case["decoy"], ans)) and not binds
|
||||
v = "BINDS-ENTITY" if binds else ("MIS-BINDS-DECOY" if mis else "OTHER")
|
||||
verdicts[v] += 1
|
||||
er, dr = _rank_in(res, case["entity"]), _rank_in(res, case["decoy"])
|
||||
print(f" run{run}: {v:16} ent@{er} dec@{dr} q={q!r:30} :: {ans.strip()[:56]}")
|
||||
# a present entity (ent@ not None) co-occurring with a mis-bind is subject-selection,
|
||||
# NOT ranking-absence — the fiction-scope half of the #393 two-mechanism split.
|
||||
print(f" >>> {case['label']}: {dict(verdicts)}")
|
||||
return verdicts
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--runs", type=int, default=6, help="consumer repeats per case")
|
||||
ap.add_argument("--case", help="run only this case label (e.g. roid-rage)")
|
||||
ns = ap.parse_args()
|
||||
base, headers, end_user = _cfg()
|
||||
cases = [c for c in CASES if ns.case in (None, c["label"])]
|
||||
if not cases:
|
||||
raise SystemExit(f"no case matching {ns.case!r} (have: {[c['label'] for c in CASES]})")
|
||||
for case in cases:
|
||||
print(f"\n{'='*72}\n# {case['label']}")
|
||||
raw_ranking(base, headers, end_user, case)
|
||||
consumer(base, headers, end_user, case, ns.runs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fiction-wing retrieval probe harness — the recipe R42 (brokkr-smithy-dev) builds against
|
||||
and the re-runnable #389 acceptance gate.
|
||||
|
||||
Two retrieval paths, kept SEPARATE because they have different noise properties:
|
||||
|
||||
* search_library (raw, RANKING-clean): drive the `mimir` foundational agent (all-wing
|
||||
librarian) with a FIXED query string. Deterministic against a fixed index — use it for
|
||||
ranking baselines (R42 arm-2). No LLM in the query loop.
|
||||
* reference_knowledge (the Tier-3 consumer path): drive `ratatoskr:donut`; her reasoning
|
||||
REFORMULATES the query each turn, so this path carries QUERY-FORMULATION variance
|
||||
(the arm-4 signal), attributable via the captured tool_start query. NOT for ranking numbers.
|
||||
|
||||
Scoring conventions (identical across both paths and all R42 arms):
|
||||
* confidence BUCKET vs WT's RRF thresholds: high >= 0.030, medium >= 0.016, low < 0.016.
|
||||
* ON-TARGET (load-bearing): a returned row is on-target iff its excerpt actually NAMES or
|
||||
describes the queried subject (keyword match on the subject's distinctive tokens). The
|
||||
failure signature "10 hits / MEDIUM / 0 on-target" = present-by-topic, subject absent —
|
||||
the split that separated #384 (packaging) / #387 (coverage) / #389 (ranking).
|
||||
* MISS = no on-target row in the returned top-k.
|
||||
|
||||
Noise floor: freeze the generation (pin the b-tag) to remove extraction variance; fixed-string
|
||||
search_library is deterministic (no CI needed); reference_knowledge variance is query-
|
||||
formulation, not floor noise. Residual = bucket-boundary sensitivity at 0.016/0.030 — so probe
|
||||
N>=3-5 times per term and report the bucket DISTRIBUTION, never a single-run point label.
|
||||
|
||||
Session protocol: ONE fresh session per query. A reused mimir session returns EMPTY
|
||||
search_library results after its first turn (Worldtree #391), silently scoring later terms
|
||||
false-MISS; the retrieval helpers self-session to enforce it. Never hoist the session out.
|
||||
|
||||
Config from env (source ratatoskr's env.sh): WORLDTREE_API_URL, WORLDTREE_API_KEY,
|
||||
RATATOSKR_END_USER_ID. No secrets are stored here.
|
||||
|
||||
Usage:
|
||||
uv run python docs/diagnostics/fiction_wing_probe.py # run the artifact yardstick
|
||||
uv run python docs/diagnostics/fiction_wing_probe.py --runs 5 # N repeats -> bucket distribution
|
||||
uv run python docs/diagnostics/fiction_wing_probe.py --term "Enhanced Pet Biscuit" --keywords biscuit
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import unicodedata
|
||||
from collections import Counter
|
||||
|
||||
import httpx
|
||||
|
||||
HIGH, MEDIUM = 0.030, 0.016 # WT RRF confidence thresholds
|
||||
|
||||
|
||||
def _cfg() -> tuple[str, dict, str]:
|
||||
base = os.environ.get("WORLDTREE_API_URL", "http://10.250.50.152:8081")
|
||||
key = os.environ.get("WORLDTREE_API_KEY")
|
||||
if not key:
|
||||
raise SystemExit("WORLDTREE_API_KEY unset — source env.sh first.")
|
||||
end_user = os.environ.get("RATATOSKR_END_USER_ID", "ratatoskr-tui")
|
||||
return base, {"Authorization": f"Bearer {key}"}, end_user
|
||||
|
||||
|
||||
def _bucket(score: float | None) -> str:
|
||||
if score is None:
|
||||
return "none"
|
||||
return "high" if score >= HIGH else "medium" if score >= MEDIUM else "low"
|
||||
|
||||
|
||||
# Curly punctuation the b170 extraction emits (U+2019 etc.) folded to ASCII so a
|
||||
# possessive-named subject ("Darcy's letter") matches regardless of quote style.
|
||||
_QUOTE_FOLD = str.maketrans({
|
||||
"‘": "'", "’": "'", # noqa: RUF001 - single curly quotes / apostrophe
|
||||
"“": '"', "”": '"', # double curly quotes
|
||||
"′": "'", "″": '"', # noqa: RUF001 - primes
|
||||
})
|
||||
|
||||
|
||||
def _fold(s: str) -> str:
|
||||
"""NFKC-normalize, fold curly quotes/apostrophes to ASCII, lowercase.
|
||||
NFKC alone does NOT fold U+2019, so the explicit quote-fold is load-bearing."""
|
||||
return unicodedata.normalize("NFKC", s or "").translate(_QUOTE_FOLD).lower()
|
||||
|
||||
|
||||
def _on_target(excerpt: str, keywords: list[str]) -> bool:
|
||||
ex = _fold(excerpt)
|
||||
return any(_fold(k) in ex for k in keywords)
|
||||
|
||||
|
||||
def _session(base: str, headers: dict, agent_id: str, end_user: str) -> str:
|
||||
r = httpx.post(f"{base}/sessions", json={"agent_id": agent_id, "end_user_id": end_user},
|
||||
headers=headers, timeout=30)
|
||||
return r.json()["session_id"]
|
||||
|
||||
|
||||
def _drive(base: str, headers: dict, sid: str, content: str) -> tuple[str | None, dict]:
|
||||
"""POST a turn, return (tool_query, tool_result_dict). tool_result is the first tool packet."""
|
||||
tool_query, result = None, {}
|
||||
with httpx.stream("POST", f"{base}/sessions/{sid}/messages", json={"content": content},
|
||||
headers=headers, timeout=120) as r:
|
||||
for line in r.iter_lines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
ev = json.loads(line[6:])
|
||||
t = ev.get("type")
|
||||
if t == "tool_start" and tool_query is None:
|
||||
tool_query = (ev.get("arguments") or {}).get("query")
|
||||
elif t == "tool_result" and not result:
|
||||
result = ev.get("result") if isinstance(ev.get("result"), dict) else {}
|
||||
elif t == "done":
|
||||
break
|
||||
return tool_query, result
|
||||
|
||||
|
||||
def search_library(base, headers, end_user, term, keywords):
|
||||
"""RANKING-clean path: fixed-string search over the mimir librarian. Deterministic.
|
||||
|
||||
Opens a FRESH mimir session per call — REQUIRED. mimir stops returning
|
||||
search_library results after the first turn on a reused session (Worldtree #391),
|
||||
silently scoring every later term a false-MISS; do not hoist the session to the caller.
|
||||
"""
|
||||
msid = _session(base, headers, "mimir", end_user)
|
||||
_, res = _drive(base, headers, msid, f"Use search_library to find: {term}")
|
||||
rows = res.get("results", []) if isinstance(res, dict) else []
|
||||
on = [h for h in rows if isinstance(h, dict) and _on_target(h.get("excerpt", ""), keywords)]
|
||||
top = on[0] if on else None
|
||||
score = round(top["score"], 4) if top else None
|
||||
return {"n": len(rows), "on_target": len(on), "hit": bool(on),
|
||||
"score": score, "bucket": _bucket(score),
|
||||
"excerpt": (top.get("excerpt", "")[:140] if top else None)}
|
||||
|
||||
|
||||
def reference_knowledge(base, headers, end_user, question, keywords):
|
||||
"""Consumer path: Donut reformulates -> capture her tool_query. NOT for ranking numbers.
|
||||
|
||||
Fresh donut session per call (same reuse-degradation guard as search_library, and
|
||||
it keeps each run an independent first-turn sample rather than a growing conversation).
|
||||
"""
|
||||
dsid = _session(base, headers, "ratatoskr:donut", end_user)
|
||||
q, res = _drive(base, headers, dsid, question)
|
||||
hits = res.get("hits", []) if isinstance(res, dict) else []
|
||||
on = [h for h in hits if isinstance(h, dict) and _on_target(h.get("excerpt", ""), keywords)]
|
||||
return {"tool_query": q, "n": len(hits), "on_target": len(on),
|
||||
"confidence": res.get("confidence") if isinstance(res, dict) else None}
|
||||
|
||||
|
||||
# Artifact yardstick — worldtree-dev grep-confirmed in DCC book-1. Frozen arm-2 baseline.
|
||||
YARDSTICK = [
|
||||
("Enchanted Crown of the Sepsis Whore", "Crown of the Sepsis Whore", ["sepsis", "crown"]),
|
||||
("Enhanced Pet Biscuit", "Pet Biscuit", ["biscuit"]),
|
||||
("Enchanted BigBoi Boxers", "BigBoi Boxers", ["boxers", "bigboi"]),
|
||||
("Enchanted Toe Ring of the Splatter Skunk", "Toe Ring of the Splatter Skunk",
|
||||
["toe ring", "splatter", "skunk"]),
|
||||
]
|
||||
|
||||
|
||||
def run_yardstick(runs: int) -> None:
|
||||
base, headers, end_user = _cfg()
|
||||
print(f"# Fiction-wing ranking yardstick (search_library, {runs} run(s) per name)\n")
|
||||
for full, partial, kw in YARDSTICK:
|
||||
for label, term in (("full ", full), ("part ", partial)):
|
||||
buckets, hits = Counter(), 0
|
||||
for _ in range(runs):
|
||||
r = search_library(base, headers, end_user, term, kw)
|
||||
buckets[r["bucket"]] += 1
|
||||
hits += r["hit"]
|
||||
dist = " ".join(f"{b}:{c}" for b, c in buckets.most_common())
|
||||
print(f" [{label}] {term:<42} hit {hits}/{runs} buckets({dist})")
|
||||
print()
|
||||
|
||||
|
||||
def run_term(term: str, keywords: list[str], runs: int) -> None:
|
||||
base, headers, end_user = _cfg()
|
||||
print(f"# Probe: {term!r} ({runs} run(s))\n")
|
||||
sl_buckets, sl_hits = Counter(), 0
|
||||
for _ in range(runs):
|
||||
r = search_library(base, headers, end_user, term, keywords)
|
||||
sl_buckets[r["bucket"]] += 1
|
||||
sl_hits += r["hit"]
|
||||
print(f" search_library : hit {sl_hits}/{runs} buckets({dict(sl_buckets)})")
|
||||
for _ in range(runs):
|
||||
rk = reference_knowledge(base, headers, end_user, f"Tell me about the {term}.", keywords)
|
||||
print(f" reference_knowledge: conf={rk['confidence']} on_target={rk['on_target']}"
|
||||
f" (donut query: {rk['tool_query']!r})")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--runs", type=int, default=1, help="repeats per term (>=3-5 near the floor)")
|
||||
ap.add_argument("--term", help="probe a single term instead of the yardstick")
|
||||
ap.add_argument("--keywords", nargs="*", default=[], help="on-target keywords for --term")
|
||||
ns = ap.parse_args()
|
||||
if ns.term:
|
||||
run_term(ns.term, ns.keywords or [ns.term.split()[-1]], ns.runs)
|
||||
else:
|
||||
run_yardstick(ns.runs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Lexical-recall gate — the class acceptance instrument for exact-term recall survival.
|
||||
|
||||
Generalizes the crown repro (Worldtree #400 / thread 01KZETD98T) beyond its anchor. The
|
||||
class property under test: when the corpus contains a chunk whose text literally carries a
|
||||
queried surface form, a natural query for that entity should serve >= 1 such chunk at a
|
||||
USABLE rank (inside the top-K window). The crown ("Crown of the Sepsis Whore") is the
|
||||
motivating STRESS case — a common word with a dense-similar vanity cluster that buries the
|
||||
exact-lexical match; distinctive names (Krakaren, Vine Creeper) are CONTROLs that should
|
||||
always pass. The gap lives on the stress class, not the controls.
|
||||
|
||||
This is a before/after regression instrument, NOT a fix: run it pre-deploy and post-deploy
|
||||
(the BM25 stemming fold, then any rerank_hybrid_floor lever) to measure whether the served
|
||||
miss-rate on the stress class actually moves. Real-world by design — it drives the agent
|
||||
end-to-end (the agent composes its own reference_knowledge query, as in production), and
|
||||
--runs samples that query-formulation variance to estimate a true miss-rate.
|
||||
|
||||
Self-contained (httpx only). Config from env (source env.sh first):
|
||||
WORLDTREE_API_URL (default personal :8081), WORLDTREE_API_KEY (required),
|
||||
RATATOSKR_END_USER_ID (default ratatoskr-tui), RATATOSKR_TTS_AGENT unused here.
|
||||
|
||||
uv run --with httpx python docs/diagnostics/lexical_recall_gate.py
|
||||
uv run --with httpx python docs/diagnostics/lexical_recall_gate.py --runs 5
|
||||
uv run --with httpx python docs/diagnostics/lexical_recall_gate.py --anchor crown
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
# Served window: a hit past this rank is not "usable" (Worldtree serves ~top-10; a row at
|
||||
# rank 9-11 is the RRF knife-edge worldtree-dev identified — treated as a KNIFE-EDGE pass).
|
||||
USABLE_K = 10
|
||||
KNIFE_EDGE_FROM = 8 # ranks >= this inside the window are fragile (one-rank-edge residual class)
|
||||
|
||||
AGENT = "ratatoskr:donut"
|
||||
|
||||
# (label, kind, term-regex the served chunk's excerpt must contain, [natural user messages]).
|
||||
# kind: "stress" = common word + competing dense cluster; "control" = distinctive name.
|
||||
# Controls should pass every run; the class limitation shows as stress-class misses / knife-edges.
|
||||
ANCHORS = [
|
||||
("crown", "stress", r"\bcrown",
|
||||
["What crown do you own?", "Do you have a crown?", "Tell me about your crown."]),
|
||||
("vine-creeper", "control", r"vine creeper",
|
||||
["Tell me about the Vine Creeper.", "What is the Vine Creeper?"]),
|
||||
("danger-dingo", "control", r"danger dingo|\bdingo",
|
||||
["What is the Danger Dingo?", "Describe the Danger Dingo."]),
|
||||
("pedicure-kit", "control", r"pedicure",
|
||||
["What does the Pedicure Kit do?", "Tell me about the Pedicure Kit."]),
|
||||
("neighborhood-map", "control", r"neighborhood map",
|
||||
["What is the Neighborhood Map?", "Describe the Neighborhood Map."]),
|
||||
]
|
||||
|
||||
|
||||
def _cfg() -> tuple[str, dict, str]:
|
||||
base = os.environ.get("WORLDTREE_API_URL", "http://10.250.50.152:8081")
|
||||
key = os.environ.get("WORLDTREE_API_KEY")
|
||||
if not key:
|
||||
raise SystemExit("WORLDTREE_API_KEY unset — source env.sh first.")
|
||||
return base, {"Authorization": f"Bearer {key}"}, os.environ.get("RATATOSKR_END_USER_ID", "ratatoskr-tui")
|
||||
|
||||
|
||||
def _session(base: str, headers: dict, end_user: str) -> str:
|
||||
r = httpx.post(f"{base}/sessions", json={"agent_id": AGENT, "end_user_id": end_user},
|
||||
headers=headers, timeout=30)
|
||||
r.raise_for_status()
|
||||
return r.json()["session_id"]
|
||||
|
||||
|
||||
def _drive(base: str, headers: dict, sid: str, content: str) -> tuple[str | None, list]:
|
||||
"""POST a turn; return (actual reference_knowledge query, served hits list)."""
|
||||
q, hits = None, []
|
||||
with httpx.stream("POST", f"{base}/sessions/{sid}/messages", json={"content": content},
|
||||
headers=headers, timeout=180) as r:
|
||||
for line in r.iter_lines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
ev = json.loads(line[6:])
|
||||
t = ev.get("type")
|
||||
if t == "tool_start" and q is None:
|
||||
q = (ev.get("arguments") or {}).get("query")
|
||||
elif t == "tool_result" and not hits:
|
||||
res = ev.get("result")
|
||||
if isinstance(res, dict):
|
||||
hits = res.get("hits", res.get("results", [])) or []
|
||||
elif t == "done":
|
||||
break
|
||||
return q, hits
|
||||
|
||||
|
||||
def _served_rank(hits: list, term_re: str) -> int | None:
|
||||
"""Rank of the first served hit whose excerpt literally contains the term (None = miss)."""
|
||||
for i, h in enumerate(hits[:USABLE_K]):
|
||||
if isinstance(h, dict) and re.search(term_re, h.get("excerpt", ""), re.I):
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--runs", type=int, default=1, help="repeats per message (samples query variance)")
|
||||
ap.add_argument("--anchor", default=None, help="run only this anchor label")
|
||||
args = ap.parse_args()
|
||||
base, headers, end_user = _cfg()
|
||||
|
||||
anchors = [a for a in ANCHORS if args.anchor is None or a[0] == args.anchor]
|
||||
totals = {"trials": 0, "miss": 0, "knife": 0}
|
||||
by_kind: dict[str, dict] = {}
|
||||
|
||||
for label, kind, term_re, messages in anchors:
|
||||
print(f"\n[{label}] ({kind}) term=/{term_re}/")
|
||||
agg = by_kind.setdefault(kind, {"trials": 0, "miss": 0, "knife": 0})
|
||||
for msg in messages:
|
||||
for _ in range(args.runs):
|
||||
sid = _session(base, headers, end_user) # fresh session per trial
|
||||
q, hits = _drive(base, headers, sid, msg)
|
||||
rank = _served_rank(hits, term_re)
|
||||
miss = rank is None
|
||||
knife = rank is not None and rank >= KNIFE_EDGE_FROM
|
||||
for d in (totals, agg):
|
||||
d["trials"] += 1
|
||||
d["miss"] += int(miss)
|
||||
d["knife"] += int(knife)
|
||||
tag = "MISS" if miss else (f"knife@{rank}" if knife else f"ok@{rank}")
|
||||
print(f" {tag:9} msg={msg!r:42} q={q!r}")
|
||||
|
||||
def pct(n: int, d: int) -> str:
|
||||
return f"{(100*n/d):.0f}%" if d else "n/a"
|
||||
|
||||
print("\n=== SUMMARY ===")
|
||||
for kind, d in sorted(by_kind.items()):
|
||||
print(f" {kind:8} trials={d['trials']:3} miss={pct(d['miss'], d['trials'])} "
|
||||
f"knife-edge={pct(d['knife'], d['trials'])}")
|
||||
t = totals
|
||||
print(f" {'ALL':8} trials={t['trials']:3} miss={pct(t['miss'], t['trials'])} "
|
||||
f"knife-edge={pct(t['knife'], t['trials'])}")
|
||||
print("\nGate: stress-class miss-rate is the deciding signal for the rerank_hybrid_floor lever.")
|
||||
print("Controls should sit at ~0% miss; a stress miss/knife-edge is the residual class to weigh.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Temporal-order gate — measures Worldtree #397 order_by="chapter" end-to-end.
|
||||
|
||||
The gap #397 closes: narrative/temporal questions ("your first encounter", "what came
|
||||
after X", "earliest to latest") need CHRONOLOGICAL order, but reference_knowledge sorts by
|
||||
RELEVANCE by default. The fix is an `order_by="chapter"` tool flag (enum ["chapter"], taught
|
||||
in both tool schemas; the result packet carries `ordered_by="chapter"` and hits are reordered
|
||||
by source chapter, earliest first). Confirmed live on personal b184.
|
||||
|
||||
This measures the flag END-TO-END (before/after per the #393 discipline) on three axes:
|
||||
- ADOPTION: for a temporal query, does the agent actually invoke order_by="chapter"? (the
|
||||
schema teaches it, but usage varies turn-to-turn — the #397 analog of query-formulation
|
||||
variance).
|
||||
- MECHANISM (flag applied): are the served hits' provenance.chapter monotonically
|
||||
non-decreasing (earliest first)? Should be ~100% when the flag fires.
|
||||
- VALUE (flag not applied): the relevance baseline — chapters are NOT chapter-sorted, which
|
||||
is exactly the gap the flag closes. The applied-vs-not monotonicity gap IS the before/after.
|
||||
|
||||
Self-contained (httpx only). Config from env (source env.sh first).
|
||||
uv run --with httpx python docs/diagnostics/temporal_order_gate.py
|
||||
uv run --with httpx python docs/diagnostics/temporal_order_gate.py --runs 4
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
AGENT = "ratatoskr:donut"
|
||||
|
||||
# Narrative/temporal user messages — the class the order_by="chapter" flag targets.
|
||||
TEMPORAL_MSGS = [
|
||||
"What was your very first encounter in the dungeon?",
|
||||
"What happened when you first entered the dungeon, earliest to latest?",
|
||||
"In order from the start, how did things unfold between you and Carl?",
|
||||
"Walk me through your earliest days in the dungeon, oldest first.",
|
||||
"After your first fight, what came next?",
|
||||
]
|
||||
|
||||
|
||||
def _cfg() -> tuple[str, dict, str]:
|
||||
base = os.environ.get("WORLDTREE_API_URL", "http://10.250.50.152:8081")
|
||||
key = os.environ.get("WORLDTREE_API_KEY")
|
||||
if not key:
|
||||
raise SystemExit("WORLDTREE_API_KEY unset — source env.sh first.")
|
||||
return base, {"Authorization": f"Bearer {key}"}, os.environ.get("RATATOSKR_END_USER_ID", "ratatoskr-tui")
|
||||
|
||||
|
||||
def _session(base: str, headers: dict, end_user: str) -> str:
|
||||
r = httpx.post(f"{base}/sessions", json={"agent_id": AGENT, "end_user_id": end_user},
|
||||
headers=headers, timeout=30)
|
||||
r.raise_for_status()
|
||||
return r.json()["session_id"]
|
||||
|
||||
|
||||
def _drive(base: str, headers: dict, sid: str, content: str) -> tuple[dict, dict]:
|
||||
"""POST a turn; return (tool_start arguments, tool_result dict)."""
|
||||
args, result = {}, {}
|
||||
with httpx.stream("POST", f"{base}/sessions/{sid}/messages", json={"content": content},
|
||||
headers=headers, timeout=180) as r:
|
||||
for line in r.iter_lines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
ev = json.loads(line[6:])
|
||||
t = ev.get("type")
|
||||
if t == "tool_start" and not args:
|
||||
args = ev.get("arguments") or {}
|
||||
elif t == "tool_result" and not result:
|
||||
result = ev.get("result") if isinstance(ev.get("result"), dict) else {}
|
||||
elif t == "done":
|
||||
break
|
||||
return args, result
|
||||
|
||||
|
||||
def _chap_num(c: object) -> int:
|
||||
"""Chapter as an orderable int; non-numeric (e.g. 'EPILOGUE') sorts last."""
|
||||
try:
|
||||
return int(str(c))
|
||||
except (TypeError, ValueError):
|
||||
return 10**9
|
||||
|
||||
|
||||
def _is_monotone(chapters: list) -> bool:
|
||||
nums = [_chap_num(c) for c in chapters if c is not None]
|
||||
return all(a <= b for a, b in zip(nums, nums[1:])) if len(nums) >= 2 else True
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--runs", type=int, default=2, help="repeats per message (samples adoption variance)")
|
||||
args = ap.parse_args()
|
||||
base, headers, end_user = _cfg()
|
||||
|
||||
applied = {"trials": 0, "monotone": 0} # flag fired
|
||||
unapplied = {"trials": 0, "monotone": 0} # flag did NOT fire (relevance baseline)
|
||||
adopted_n = 0
|
||||
total = 0
|
||||
|
||||
for msg in TEMPORAL_MSGS:
|
||||
print(f"\nMSG: {msg!r}")
|
||||
for _ in range(args.runs):
|
||||
sid = _session(base, headers, end_user)
|
||||
targs, res = _drive(base, headers, sid, msg)
|
||||
hits = res.get("hits", res.get("results", [])) or []
|
||||
chapters = [(h.get("provenance") or {}).get("chapter") for h in hits if isinstance(h, dict)]
|
||||
adopted = targs.get("order_by") == "chapter"
|
||||
applied_flag = res.get("ordered_by") == "chapter"
|
||||
mono = _is_monotone(chapters)
|
||||
total += 1
|
||||
adopted_n += int(adopted)
|
||||
bucket = applied if applied_flag else unapplied
|
||||
bucket["trials"] += 1
|
||||
bucket["monotone"] += int(mono)
|
||||
tag = "FLAG" if applied_flag else "----"
|
||||
print(f" [{tag}] adopted={adopted!s:5} monotone={mono!s:5} chapters={chapters}")
|
||||
|
||||
def pct(n: int, d: int) -> str:
|
||||
return f"{(100*n/d):.0f}%" if d else "n/a"
|
||||
|
||||
print("\n=== SUMMARY ===")
|
||||
print(f" adoption (agent invoked order_by=chapter): {pct(adopted_n, total)} ({adopted_n}/{total})")
|
||||
print(f" flag APPLIED -> chapter-monotone: {pct(applied['monotone'], applied['trials'])} "
|
||||
f"(n={applied['trials']}) [mechanism — should be ~100%]")
|
||||
print(f" flag NOT applied -> chapter-monotone: {pct(unapplied['monotone'], unapplied['trials'])} "
|
||||
f"(n={unapplied['trials']}) [relevance baseline — the gap the flag closes]")
|
||||
print("\nGate: the applied-vs-not monotonicity gap is the flag's VALUE; adoption rate is the")
|
||||
print("residual (schema teaches it, agent use varies) — the #397 analog of query-formulation variance.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,78 @@
|
||||
# Donut voiced-interview build — ACTIVE (auto-TTS via Zonos + KB-recall bridge + Princess Donut)
|
||||
|
||||
`[2026-08-01]` Operator asked: "add auto-tts to the ratatoskr web gui" → grew into a voiced,
|
||||
corpus-grounded Tier-3 interview character. Contract:
|
||||
`docs/contracts/donut_voiced_interview.contract.md` (validated). Slice 1 DONE; slices 2-3 TO BUILD.
|
||||
Operator ruling: **build it today, but design the KB path to RETIRE** (it works around a Worldtree
|
||||
gap — Tier-3 agents are tool-less by design; KB search is integral, so the gap should close).
|
||||
|
||||
## Slice status
|
||||
|
||||
- **SLICE 1 (persona) DONE** — `docs/characters/donut.md` authored (corpus-grounded from a Mimir
|
||||
pull + DCC canon: interview-register performer, "Carl looks intimidating while I talk," vulnerability
|
||||
under a good question, and the "pinned corpus = her own memory" framing). **Defined:**
|
||||
`ratatoskr:donut` (role `thoughtful-character`) live on personal :8081, in the console picker.
|
||||
Chattable now (from training until slice 3). NOT yet committed (uncommitted at snapshot).
|
||||
- **SLICE 2 (auto-TTS) TO BUILD** — new `src/ratatoskr/tts.py` (Zonos gateway client + `pad_to_dials`) +
|
||||
`/api/tts` proxy route in `web/server.py` + `index.html` (🔊 toggle, speak-on-`done` playback,
|
||||
serialize/cancel). TDD (respx-mock the gateway), live-smoke.
|
||||
- **SLICE 3 (KB-bridge) TO BUILD** — new `src/ratatoskr/kb_bridge.py` (RETIRE-READY, INV-KB-1) +
|
||||
the one server.py seam. Per interview turn: out-of-band Mimir consult → extract evidence →
|
||||
POST Donut's turn with `memory_context=[{kind:"corpus_reference", text:"<extract>"}]`. She answers
|
||||
in-voice. TDD.
|
||||
|
||||
## Design — LOCKED (both external deps verified live 2026-08-01)
|
||||
|
||||
**TTS (infra-ops Zonos specs, verified):**
|
||||
- Endpoint: `POST http://10.100.79.3:8890/v1/audio/speech` (the zonos GATEWAY at irv-ml1:8890 — NOT
|
||||
the engine's :1920, which has a rep-penalty bug). Also GET /v1/voices, /v1/dials, /health. WG-internal,
|
||||
**no auth**. Reachable from nh3-dev (the console box) over WireGuard.
|
||||
- Request: JSON, only `input` required. `response_format:"wav"` (16-bit RIFF; **mp3/opus silently
|
||||
fall back to mislabeled PCM — never use**). Verified: voice=donut → HTTP 200, RIFF/WAVE PCM 16-bit
|
||||
mono 44.1kHz.
|
||||
- **NOT streaming** — gateway buffers to a complete clip (~1.8s/sentence). "Speak on done" gives the
|
||||
whole clip after the synth delay. True streaming = a future gateway enhancement (infra-ops can raise).
|
||||
- **Voice: `voice:"donut"` is ALREADY REGISTERED** (infra-ops cloned Vuong's Princess Donut bundle;
|
||||
GET /v1/voices returns Donut; case-folded so "Donut.wav"→voice:"donut"). Custom-voice registration
|
||||
mechanism: drop a `<Name>.wav` (44.1kHz mono 16-bit, ~10-20s clean single-speaker) in the gateway
|
||||
voices dir + restart. Audition A/B: http://10.100.10.50:8090/b/donut-voice/
|
||||
- **Affect-driven emotion (DEC-7, the standout):** map the turn's live PAD (from the `affect_update`
|
||||
SSE the console ALREADY consumes) → Zonos `emotion_valence`(pleasure)+`emotion_arousal`(arousal).
|
||||
Reframes the feature as voice OBSERVABILITY (hear the affect the persona pane shows), NOT chat-app
|
||||
creep — resolves the scope concern. Dials also: `preset`, `emotion_sliders{happy,sad,angry,surprised}`,
|
||||
`emotion_strength`, `speed`, `seed`.
|
||||
- **Coupling: direct :8890** (DEC-1) for the emotion dials; the swappable LiteLLM `ext-tts` alias
|
||||
(10.250.50.70:4000) loses the Zonos emotion params. `tts.py` is the single swap seam.
|
||||
- **Serialize** one synth per turn (DEC-5) — shared 3090 ~90% VRAM; cancel-on-new-turn.
|
||||
- **Proxy** (DEC-4): browser → `/api/tts` (nh3-dev) → gateway; never surface irv-ml1 to the client.
|
||||
|
||||
**KB-recall (worldtree-dev ruling, wyrd-proven):**
|
||||
- Tier-3 is DELIBERATELY tool-less in v1 (`build_tier3_agent_context` hard-codes `tool_schemas=[]`);
|
||||
no search_kb grant, no call-Mimir-as-tool (#361 rejected it, recursion). So: **consumer-side
|
||||
retrieval + memory_context pinning** — ratatoskr consults Mimir out-of-band, extracts the cited
|
||||
evidence, pins `memory_context=[{kind:"corpus_reference", text}]` on Donut's turn. She frames it as
|
||||
her own memory (the persona prompt already instructs this). Our SDK's stream_turn already forwards
|
||||
`memory_context` verbatim. Latency: one retrieval round-trip/turn.
|
||||
- **RETIRE-READY (INV-KB-1):** kb_bridge behind ONE seam `pin_kb_context(question, agent_id) ->
|
||||
list|[]`; retiring = delete the module + one call-site. Delete when Worldtree #361
|
||||
`reference_knowledge` extends to Tier-3 (operator flagged it an integral GAP; worldtree-dev surfacing
|
||||
the extension to Vuong — decision inbound to the operator).
|
||||
|
||||
## OWED / open loops (post-clear)
|
||||
|
||||
- **P&P verification re-run OWED to infra-ops** — #382 deployed (b166: self-describing `_index.md` +
|
||||
the Mimir prompt rule "hits ARE library content, never discard/substitute"). Re-run the failing
|
||||
P&P-dismiss shape on :8081, report to infra-ops. **DCC's backfill FAILED** (job predates state-tracking,
|
||||
no `_index.md` yet — flagged to worldtree-dev) — but the prompt rule alone should stop the discard,
|
||||
so the KB-bridge's DCC Mimir consults should ground fine. Verify this when building slice 3.
|
||||
- **worldtree-dev #361-Tier-3 extension** — an inbound surface decision for Vuong (would let Donut
|
||||
search in-voice + delete the bridge).
|
||||
- **infra-ops custom-voice** — mechanism proven; hand them a `<Name>.wav` + name for any future character.
|
||||
|
||||
## Discipline
|
||||
|
||||
Operator said "do both slices" (skip /heid-contract-review, straight to TDD). Run heid code-review +
|
||||
bug-hunt AFTER each code slice. Release-only versioning cadence (no per-commit tags; `feat:`/`fix:`
|
||||
conventional commits between releases). CLI to drive Mimir/Donut turns:
|
||||
`ratatoskr --send "..." --new --agent <id> --end-user-id ratatoskr-tui`. Define:
|
||||
`python -m ratatoskr.tier3 define --name <slug> --system-prompt "<prompt>" --role thoughtful-character`.
|
||||
@@ -0,0 +1,26 @@
|
||||
`[2026-08-02]` **Donut TTS: English pin + long-form chunking + POST + dialogue-only persona + both heid gates — shipped `d59f907` (pushed).**
|
||||
|
||||
Operator reported two symptoms: Donut's voice "occasionally goes to gibberish" and "truncates on long streams." Both root-caused and fixed, then a mid-work persona directive folded in, then the full House Code Discipline (both heid gates) run.
|
||||
|
||||
## Gibberish → DEC-9 English pin
|
||||
The Zonos gateway (`/v1/audio/speech`, irv-ml1 :8890) is multilingual and takes a `language` field we were never sending → it drifted into other-language phonemes on names/numerics/long spans. Fix: `gateway_body` pins `language:"en-us"` on every call (`_TTS_LANGUAGE`). Verified the gateway accepts it live. The persona rewrite (below) killed the OTHER gibberish vector — the old prompt MANDATED asterisk RP action beats (`*she flicks her tail*`), which were being fed to the engine verbatim.
|
||||
|
||||
## Truncation → DEC-10 client-side chunk-and-concatenate
|
||||
Empirically characterized + confirmed by infra-ops (recipe thread `01KZ1FKX…`): the Zonos model hard-caps ONE synthesis at `max_tokens=6144` = **71.2s of audio** (6144 / 86.3Hz codec frame rate). CRITICAL physics (infra-ops correction): 86.3Hz is a **delivery-independent constant** — 6144 tokens is ALWAYS 71.2s regardless of emotion/rate; emotion changes how many WORDS land in 71.2s, not seconds-per-token. `max_tokens>6144` → HTTP 400 (architectural sequence limit, unraisable). Our old 2000-char cap was LARGER than one call could voice (~750-900 chars), so the gateway truncated first, mid-stream, at ~58-71s.
|
||||
Fix (`tts.py`): `chunk_text(text, budget=747)` — paragraph-first greedy pack (seams on blank lines), sentence fallback for oversized paragraphs, clause(`, ; :`)/word sub-split for oversized sentences; budget = 71.2s×0.75×14c/s (operator: "greedy to 75% of cap for prosody"; the 25% headroom absorbs char→audio-seconds variance). `tts_stream_long` synthesizes each chunk (identical voice+dials+language) and concatenates: chunk 1 verbatim (WAV header + PCM), chunks 2..N header-stripped (`_pcm_after_header`) → ONE continuous int16-PCM stream (infra-ops: never bury a RIFF header mid-stream). `/api/tts` became **POST** (DEC-10a) so the full long text rides the body, not a length-capped GET URL; outer cap 2000→8000 (a shared-3090 hold bound now, not a URL bound). Live-proven: 1978 chars → 3 balanced chunks → 106.6s, one header, clean concatenation — ceiling broken.
|
||||
|
||||
## Persona → dialogue-only + always-consult-the-tool
|
||||
Operator directive mid-work. Rewrote `docs/characters/donut.md` and live-patched `ratatoskr:donut` (via `python -m ratatoskr.tier3 patch`): (a) ONLY spoken dialogue — no stage directions / asterisks / emoji / markdown (preserves her theatrical VOICE, strips narrated ACTION — the TTS-gibberish source); (b) ALWAYS call `reference_knowledge` before answering any question. Note: the tool-call-first is PROMPT-LEVEL only — the define surface (`{agent_name, role, system_prompt}`) exposes no tool_choice, so a hard guarantee would need a WT-side forced-tool capability. Fixed stale drift: the doc still described the retired `kb_bridge`/`[MEMORY:DATA]` pinning. Live-verified: her first event was `reference_knowledge{query}`, output was 0-asterisk dialogue.
|
||||
|
||||
## Both heid gates (4-arm panels: Gróa/Hulda/Regin/Kimi)
|
||||
Dispatched in parallel (code-review thread `01KZ21MC…`, bug-hunt `01KZ21RN…`). The GET→POST switch had re-opened untrusted-type paths that string-only query params masked — all four arms converged. Fixed:
|
||||
- **Untrusted /api/tts body fields degrade, never 500:** huge-int PAD (`float()` OverflowError, not caught by `except ValueError` — routed through the hardened `PadState.from_obj`); non-str `agent_id` (unhashable `dict.get` TypeError — `isinstance(str)` guard); lone surrogate in text (rides JSON as `\udXXX`, `UnicodeEncodeError` on httpx's utf-8 encode of the gateway body — scrubbed via `encode("utf-8","ignore")`); whitespace-only text (empty 200 → 400 via `strip()`).
|
||||
- **Lock+client leak on peek escape** (was a PERMANENT deadlock — a browser abort/CancelledError or `httpx.InvalidURL` before byte 1 escaped the two narrow `except` arms; the `finally` only existed once `piped()` ran): `except BaseException: await _release(); raise` around the peek.
|
||||
- **A2 (net-new):** a mid-stream drop AFTER the 200 committed raised into the response (httpx wraps `aiter_bytes` in `except RequestError`, so a mid-stream drop arrives as `TtsUnavailable`, and the old `if i==0: raise` fired post-commit). Fixed with a `yielded_any` pivot: pre-first-byte failure → raise (→503), post-first-byte failure (any chunk) → degrade+log.
|
||||
- **A3:** non-WAV 200 body forwarded as audio/wav (browser decodes markup as PCM): RIFF-sniff the peeked first bytes → 503; bounded `_pcm_after_header` scan (1KiB) + browser 64KiB acc bound.
|
||||
- Contract brought canonical: DEC-9/10, FN chunk_text/tts_stream_long/tts_endpoint (POST), INV-TTS-4 logging scope, FN pad_to_dials domain, FN client:speakOnDone (POST+8000).
|
||||
Triaged OUT: refuted Hulda's `UnicodeDecodeError`-escapes (it IS a `ValueError` subclass, already caught — Regin confirmed); Regin self-retracted a false `_hard_wrap` finding; accepted the char→token proxy (documented + infra-ops-blessed). Deferred (unchanged sibling handlers): `_create_session`/`_submit_turn` 500 on malformed JSON → filed as issue **#21**.
|
||||
|
||||
+14 regression tests across the two gates; 546 green; committed + pushed `d59f907`.
|
||||
|
||||
Related: [[2026-08-01-donut-voiced-interview-build]] (the prior slices).
|
||||
@@ -0,0 +1,16 @@
|
||||
`[2026-08-03]` **Donut anti-fabrication persona + the retry-on-LOW experiment (tested, rejected).** Commits `3e12c4d` + `c0a66fc` (pushed).
|
||||
|
||||
Operator: "adjust donut not to make shit up — her searches for Zev and Jack are still misses." The persona (rewritten earlier this session to dialogue-only + always-call-`reference_knowledge`) still MANDATED confabulation: "never break character to admit the records are thin; answer with total confidence." So on a tool miss she filled the gap from her DCC *training* knowledge and presented it as grounded recall.
|
||||
|
||||
## The fix (c0a66fc final state)
|
||||
Her memory IS what `reference_knowledge` returns, nothing else. A **MISS** = results empty, confidence **low**, OR nothing in the results actually names/describes the subject → deflect IN CHARACTER (a theatrical dismissal: "That name doesn't ring a bell, darling — beneath my notice, clearly"), never a confident fiction, never fill from book-knowledge she can't see in the results. **MEDIUM+ → answer** (grounded). Threshold is LOW=deflect / MEDIUM+=answer — gating stricter (HIGH-only) would silence legitimately-thin-but-grounded content like Carl (MEDIUM).
|
||||
|
||||
**Self-correcting property:** the gate tightens when content is missing and opens when it arrives. Jack deflected when absent (LOW), and grounds now that b170 extracted his death plot_event (MEDIUM) — zero persona change needed across the KB improvement. Verified live: a fabricated term ("Whispering Gauntlet of Thexmar") and a genuinely-absent subject both deflect; Carl (MEDIUM) answers.
|
||||
|
||||
## The retry-on-LOW experiment — TESTED, REJECTED (the load-bearing finding)
|
||||
Operator asked "should she search again at low confidence?" Reasoning said yes (LOW is often query-phrasing sensitivity, not absence — the Crown grounded on its full name but missed on the partial; Zev flips LOW↔MEDIUM). Wired a bounded (1-retry) reformulated retry. **It BACKFIRED.** When Donut reformulated "Jack" → "Jack the dungeon crawler ... with Carl, Yolanda, Donut", the query scored **MEDIUM off the OTHER real entities** (the corpus is dense with Carl/dungeon content), handing her a false grounding to fabricate Jack. Even subject-only reformulation ("Jack Dungeon Crawler Carl") inflated to MEDIUM. **Root cause: RRF confidence is inflatable by any DCC-flavored query — it reflects query-term matches, not whether a row NAMES the subject.** So a persona-side confidence gate cannot stop fabrication via query padding. Reverted to single-search LOW=deflect. The robust fix belongs tool-side: a does-the-returned-row-actually-name-the-subject check before ranking/confidence — routed to Worldtree **#389** (the ranking axis).
|
||||
|
||||
## FOOT-GUN: tier3 patch doesn't refresh the live agent context
|
||||
A `python -m ratatoskr.tier3 patch ratatoskr:donut --system-prompt …` reports success and updates STORAGE, but the running agent's context did NOT pick up the new prompt (verified: patched anti-fabrication, behavior unchanged). **Recreate (delete + define) is the reliable path** to change a live Tier-3 persona. Her role is `thoughtful-character` (the donut.md header's old `character-rp-reasoning` was drift, corrected).
|
||||
|
||||
Related: [[2026-08-03-reference-knowledge-3-round-verify]], [[2026-08-02-donut-tts-chunking-english-gates]].
|
||||
@@ -0,0 +1,25 @@
|
||||
`[2026-08-02→03]` **reference_knowledge grounding validated end-to-end — 0/5 → 5/5, and the verify instrument drove the diagnosis of a structural Tier-3 blindness.**
|
||||
|
||||
Donut's native `reference_knowledge` tool (WT #383/b167) returned empty for every query at session start, even though the DCC corpus was supposedly ingested. Chased it across ~5 exchanges with worldtree-dev; ratatoskr was the verify instrument the whole way, and the baseline discipline (identical 5 canonical terms, verbatim reporting) made each delta attributable.
|
||||
|
||||
## Two Worldtree-side defects, both fixed
|
||||
|
||||
**Defect 1 — wing-misfile.** DCC (`fiction/b59c147c5ce0/`) was ingested BEFORE Worldtree #380 fixed wing-blind book indexing, so its notes filesystem-lived in the `fiction` wing but its index ROWS landed in the DEFAULT collection = `main`. The Tier-3 tool is scoped to `fiction`, so Donut searched an index with no DCC rows; Mimir (all-wing librarian) found DCC fine in `main`. My `search_library`-in-main vs `reference_knowledge`-empty divergence isolated it. worldtree-dev re-ingested DCC into `fiction` and REJECTED the alternative (widen `tier3_wings` to include `main`) — my containment argument held: widening would expose the whole general-knowledge wing to every Tier-3 interview character, breaking in-character containment. But after re-ingest, still 0/5.
|
||||
|
||||
**Defect 2 (the real root) — INV-361-3 provenance filter.** The `reference_knowledge` packager drops any hit lacking BOTH `note_id` AND `path` metadata. The muninn indexer had NEVER written either key on concept rows — 166 fiction rows, zero with the pair. So the search FOUND the terms (same rows `search_library` served) and the packet filter discarded 100% → hits=0/confidence=none. Not a different index, not a score floor: a metadata-contract mismatch at the packaging layer. Tier-3 had been STRUCTURALLY BLIND to every concept row ever indexed; the P&P-era tool-loop probes exercised the call path, not concept grounding. My divergence datum (search_library=10 fiction hits vs reference_knowledge=0, same wing) was "the key that found it" (worldtree-dev). Fixed as **#384** (indexer writes note_id + a wing-relative path; existing rows recover via --reindex).
|
||||
|
||||
## The 3-round verify arc (same 5 terms: Carl, dungeon/Crawl, spells, Mordecai, Butcher's Masquerade)
|
||||
|
||||
- **Baseline (pre-#384):** 0/5, confidence none.
|
||||
- **Round 1 (b168, 97-concept corpus):** 5/5 PRESENT — Carl 10/HIGH, dungeon 10/HIGH, spells 10/MEDIUM, Mordecai 3/HIGH, Masquerade 6/LOW. First light; confidence buckets tracked WT's 0.030/0.016 thresholds.
|
||||
- **Round 2 (#385 re-extraction, 705-concept corpus, ~7× density):** all 5 saturate top_k=10; the thin terms filled out (Mordecai 3→10, Masquerade 6→10, LOW→medium — no weak term left). Buckets converged to medium (more competing rows shift RRF composition, per WT — not regression). Content sharpened.
|
||||
|
||||
Signed off → #384/#385 close. worldtree-dev: "the cleanest consumer-side validation this pipeline has ever had." Also flagged (parked with attribution as an RRF-tuning observation): the Carl-Donut relationship concept cluster dominates the RRF head across multiple queries.
|
||||
|
||||
## Why this mattered for ratatoskr
|
||||
|
||||
The persona I shipped this session (dialogue-only + ALWAYS call `reference_knowledge` first) now has real corpus behind it — Donut genuinely recalls her own DCC story instead of falling back to training knowledge. Voice (long-form TTS) and memory (grounded recall) both came together in one session.
|
||||
|
||||
Follow-on (separate axis): the artifact-coverage gap → WT #387 (schema not artifact-aware). See the Recent-decisions #387 entry.
|
||||
|
||||
Related: [[2026-08-02-donut-tts-chunking-english-gates]], [[2026-08-01-donut-voiced-interview-build]].
|
||||
@@ -0,0 +1,21 @@
|
||||
`[2026-08-11]` **TTS migrated chatterbox-fast → dots-tts (v0.22.2, `38b78d8`).**
|
||||
|
||||
Operator-directed after an infra-ops A/B win ("very good"). dots-tts = rednote-hilab
|
||||
`dots.tts-soar` on irv-ml1, replacing chatterbox-fast. `tts.py` stays the single swap seam.
|
||||
|
||||
**Deltas (everything else — streaming placeholder-header WAV shape, browser Web-Audio PCM
|
||||
decode, POST `/api/tts`, the DEC-5 serialize lock, INV-TTS-1..4 — unchanged):**
|
||||
- **Gateway body OpenAI-shaped:** `{input, voice, response_format:"wav", stream:true}` —
|
||||
`input` not chatterbox's `text`, `response_format` not `format`. Closer to the Zonos-era
|
||||
client. `DOTS_TTS_URL = http://10.100.79.3:8198/v1/audio/speech` replaced `CHATTERBOX_TTS_URL`.
|
||||
- **Sample rate 24000 → 48000 Hz** (browser `SR = 48000` in `index.html`, else ~2× too fast).
|
||||
- **Default voice `glados_25s` → `glados`** (dots voices: donut/glados/emmie/miranda). donut carries over.
|
||||
- **Serialized single-consumer** — dots renders one generation at a time; satisfied by the
|
||||
existing DEC-5 lock, no code change.
|
||||
- **Affect stays dropped** (DEC-7): dots has no emotion knob, same as chatterbox — NOT a fresh loss.
|
||||
|
||||
Live-verified end-to-end on `:8765` (RIFF/WAVE 48kHz mono s16le through `/api/tts`); 520 tests
|
||||
green. Contract `donut_voiced_interview.contract.md` amended (migration banner + DEC-1/3/8/9a +
|
||||
FN blocks). chatterbox-fast `:8197` kept as rollback, then reclaimed by infra-ops once
|
||||
operator ear-confirmed. Superseded next day by config-driven voices (see
|
||||
[[2026-08-12-config-voices-two-voice]]).
|
||||
@@ -0,0 +1,32 @@
|
||||
`[2026-08-12]` **Config-driven voices + two-voice dialogue/narration split SHIPPED (v0.22.3, `5c3d0ad`).**
|
||||
|
||||
Operator ask: "we need rata side config of voices too" + "tempted to do two voices — one for
|
||||
dialogue, one for non-dialogue." Chose (via AskUserQuestion) "config + two-voice together now."
|
||||
|
||||
**Config (retires the hardcoded `_TTS_VOICE_MAP`):** `~/.config/ratatoskr/voices.json`, beside
|
||||
`local_agents.json`/`provider.env` (NOT folded into the agent-index schema — isolated from its
|
||||
v1→v2 silent-drop foot-gun). Schema:
|
||||
`{"default":"<v>","agents":{"<agent_id>":{"voice":"<v>","narration_voice":"<v>"?}}}`. Absent/malformed
|
||||
→ built-in `DEFAULT_VOICE_CONFIG`, logged, never crashes (INV-TTS-5). Initial: donut→donut;
|
||||
sindra→miranda dialogue + emmie narration.
|
||||
|
||||
**Two-voice = the presence of `narration_voice` is the split switch** (dropped the operator's
|
||||
proposed `dialogue_only` flag — an agent with no narration_voice is single-voice, which is why
|
||||
dialogue-only Donut needs no special-casing). `segment_dialogue` splits on quotes (straight `"`
|
||||
+ curly `“ ”`; apostrophes never split a word): quoted = dialogue → `voice`, rest = narration →
|
||||
`narration_voice`. Known limit: if a mixed persona speaks WITHOUT quotes, it reads as narration.
|
||||
|
||||
**Synthesis (`tts_stream_stitched` replaced `tts_stream`):** spans synth SERIALLY (dots
|
||||
single-consumer), span 0 verbatim (wav header+PCM), spans 1..N header-stripped
|
||||
(accumulate-until-`data`) → one gapless 48kHz stream with a single leading header (INV-TTS-7).
|
||||
A single-span list is a byte-identical passthrough (INV-TTS-6, no single-voice regression). NOTE
|
||||
the dots-pcm-not-headless-in-stream foot-gun that forced this design (see Tried and abandoned).
|
||||
|
||||
New module `src/ratatoskr/voices.py` (`load_voice_config` / `segment_dialogue` /
|
||||
`resolve_voice_spans`); `create_app` gains `voice_config`; `entrypoint` loads it. Contract DEC-11
|
||||
+ INV-TTS-5/6/7; new `tests/test_voices.py`. **545 tests green**, new code ruff-clean. Discipline:
|
||||
contract-first + TDD + seam-check + local bug-hunt + live-verify; heid async gates SKIPPED
|
||||
(offered). Live-verified on `:8765`: Sindra mixed turn → 2 dots calls (emmie+miranda) stitched to
|
||||
one 48kHz WAV (single RIFF header); Donut single-voice unchanged. Cut as **patch** (self-contained
|
||||
feature, no downstream coordination → patch-defensible → tie-break to patch; operator declined the
|
||||
v0.23.0-minor option by saying "push it"). Builds on [[2026-08-11-tts-dots-migration]].
|
||||
@@ -0,0 +1,30 @@
|
||||
`[2026-08-12]` **Personal WT :8081 deadlocked after ~4 days → routed to worldtree-dev as WT #401.**
|
||||
|
||||
Operator hit "failed to load agents" in the web console + asked "does the server need
|
||||
restarting?" Diagnosed: NOT ratatoskr. The personal Worldtree at `10.250.50.152:8081`
|
||||
(`worldtree-personal-worldtree-api-1`, "Up 4 days (unhealthy)") was HUNG — TCP port open (accepts
|
||||
connections) but the app answered NOTHING: `/health`, `/agents`, `/capabilities` all returned
|
||||
`000` (accept-then-drop, "Server disconnected without sending a response"). Classic dead/deadlocked
|
||||
event loop behind a live socket.
|
||||
|
||||
**Ratatoskr behaved correctly:** `/api/agents` was 12/12 502 `network_error` on the dead
|
||||
upstream (the endpoint's `except (httpx.RequestError, ConnectFailed) → 502`), and auto-recovered
|
||||
to 8/8 200 (donut+sindra back) the instant WT answered — NO ratatoskr restart. Restarting
|
||||
ratatoskr would only have 502'd against the same dead upstream + dropped the session.
|
||||
|
||||
**Recovery + routing:** operator greenlit → I pinged infra-ops (uptime lane). They `docker
|
||||
restart`'d it (same image, no recreate; demo `:8080` + pinned `:8082` untouched); `/health`
|
||||
recovered 000→200 at ~45s. Operator greenlit root-cause → routed to worldtree-dev, tracked as
|
||||
**WT #401** (diagnose-first). infra-ops froze the full 4-day container log (55MB / 647k lines,
|
||||
`2026-08-08T22:15Z`→restart) to host-fs (`nh3-dev:/var/tmp/worldtree-401/`) since the docker
|
||||
health buffer had already rolled.
|
||||
|
||||
**Smoking gun (infra-ops, from the app log):** last good `/health` at `22:00:26Z`; the dying
|
||||
worker's LAST line = a sqlite **"unable to open database file"** traceback in the
|
||||
**`context_promotion` ledger** at `22:09:38Z`. No OOM, disk not full. worldtree-dev's read: a
|
||||
background/idle promotion job made an untimed synchronous sqlite call that wedged the event loop
|
||||
(no user turn in flight; abrupt serving→silent in <60s; ~0.1s connection drops = accept queue
|
||||
serviced but handler coroutines never scheduled). Consumer-side data I gave #401: today's :8081
|
||||
load was light + fully sequential, and TTS synthesis offloads to dots `:8198` (added ~zero :8081
|
||||
load). **Root-cause is worldtree-dev's; I owe them only a consumer-side repro if they ask.**
|
||||
The hang-signature is now a substrate foot-gun (see Current state).
|
||||
+46
-95
@@ -1,6 +1,6 @@
|
||||
# Persistent memory — ratatoskr
|
||||
|
||||
_Last updated: 2026-07-19_
|
||||
_Last updated: 2026-08-13_
|
||||
|
||||
> **Always check for `/tmp/ratatoskr-dev-handoff.md`** — if it exists and its
|
||||
> `Written:` stamp is under an hour old, read it (it carries the in-flight
|
||||
@@ -44,110 +44,36 @@ upstream API key stays server-side (INV-003).
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-07-19:_
|
||||
_As of 2026-08-13:_
|
||||
|
||||
**✅ COMPLETE — worldtree-sdk cutover (issue #20): ALL 7 SLICES DONE, shipped as v0.22.0 (2026-07-19).**
|
||||
Ratatoskr's CONSUMER client layer is fully cut over from hand-rolled httpx wrappers to **worldtree-sdk (Python)
|
||||
1.0.0** behind the thin `ratatoskr.wt` adapter (operator ADOPT ruling 2026-07-18; 6 DECs, vor-cross'd +
|
||||
heid-panel-reviewed; contract `docs/contracts/worldtree_sdk_cutover.contract.md` with slice-1..7 notes canonical).
|
||||
Six route-family slices (foundation / sessions-turn / persona-authored / agents-tier3 / characters-me / admin) +
|
||||
slice-7 teardown, each through the full House Code Discipline (adapter→cli/web→delete→live-smoke→both heid gates).
|
||||
Suite **494 green**. Per-slice arcs → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-{1-2,3,4,5,6,7}-complete.md`.
|
||||
**Slice-7 teardown** (`ec68b1f`, v0.22.0, zero runtime-logic): dropped `httpx-sse` (SDK owns SSE parsing; venv
|
||||
pruned, nothing imported it); KEPT `sessions.py`+`sse_client.py` as pure caller-semantic type/exception homes
|
||||
(operator decision A1 — no rename/fold; A3 blocked by the `AgentNotAvailable` name-collision + `endpoint_for_plane`
|
||||
provider-homing); retired wire contracts #2/#15 (files deleted; **#1 SSE-vocab + first_message KEPT**, not retired);
|
||||
final coverage-map re-anchor.
|
||||
**KEY ADAPTER FOOT-GUNS (cumulative — still live for ANY future consumer/SDK work):** SDK reads = **open-world
|
||||
dicts**, presenters MUST degrade-not-crash guarded at THREE levels — **container-type** (`isinstance(_, list)`: a
|
||||
scalar `123` is non-iterable, `or []` catches null but NOT a truthy non-iterable), **element-type**
|
||||
(`isinstance(m, dict)`), **top-level-mapping** (`isinstance(_, Mapping)` before any `.get`); never hard-index. The
|
||||
SDK **normalizes ANY transport failure to `ConnectFailed(status=0)`** (not raw httpx) — every adapter caller
|
||||
`except ConnectFailed`. **Caller-semantic exceptions the adapter raises + a `-m` CLI catches must NOT live in the
|
||||
`-m` module** (double-module class-identity split → uncaught traceback; homed in `sessions.py`, never run as
|
||||
`__main__`). `TurnEvent.turn_id` ABSENT on text/thinking frames; `consumer_key` is BOUND-create-only. Scope was
|
||||
consumer layer ONLY; Bifrost provider untouched. Design → auto-memory `project_worldtree_sdk_cutover`.
|
||||
**Nothing in flight on ratatoskr's side. The RP-voice stack was rebuilt this session (chatterbox→dots, then config-driven voices + a two-voice dialogue/narration split) — all shipped + PUSHED (HEAD `5c3d0ad`, origin synced, tags `v0.22.2`/`v0.22.3`). The one open tail is upstream: WT #401 (worldtree-dev owns the personal-WT deadlock root-cause).** Details in Recent decisions; durable substrate below.
|
||||
|
||||
**▶️ POST-CUTOVER PENDING (non-blocking, operator's call):** (a) `git push` origin — the `ec68b1f` commit +
|
||||
**v0.22.0 tag are LOCAL only**; (b) althing announce of v0.22.0 to worldtree-dev / wtsdk-dev per the SemVer
|
||||
push-notify (post-push; Rata is the SDK reference consumer). Nothing else pending on the cutover.
|
||||
- **TTS = dots-tts + config-driven voices + two-voice split** — live on `ratatoskr-web` **pid 2290701** (`:8765`, dots + `voices.json` loaded). Backend is **dots-tts** via the fleet **switchboard** `http://10.100.79.3:8198/v1/audio/speech` (OpenAI-shaped `{input,voice,response_format,stream}`, 48kHz mono s16le, streaming placeholder-header WAV). `tts.py` is the single swap seam (`RATATOSKR_TTS_URL` overrides), NO affect. Per-agent voices live in **`~/.config/ratatoskr/voices.json`** (donut→donut single; sindra→miranda dialogue + emmie narration); an agent with a `narration_voice` gets the quote-split two-voice stitch (`tts_stream_stitched`, `src/ratatoskr/voices.py`).
|
||||
- **Personal WT :8081 recovered + root-cause routed to WT #401** — it deadlocked after ~4 days (event-loop wedge from a background `context_promotion` sqlite error); infra-ops `docker restart`'d it, worldtree-dev owns the diagnosis. Ratatoskr auto-recovered (degrade path held; no ratatoskr restart). Only possible follow-up = a consumer-side repro if worldtree-dev asks.
|
||||
|
||||
**✅ RESOLVED — tier3 agents `model`→`role` (scope B) folded into cutover slice-4** (`c62b4ee`, v0.21.13). The
|
||||
deferred deploy-gated scope-B work (response `model`→`role` per spec 1.2 / b128, `LocalAgentEntry`, index schema
|
||||
v2) landed with the agents-family SDK cutover — no longer pending. See the slice-4 detail file + Recent decisions.
|
||||
**Deferred / queued (non-urgent, pre-existing — tracked, NOT this-session):** heid gates on the two-voice feature SKIPPED in-session (offered, not run — optional cross-frontier gate if wanted); TUI rip (`tui.py`+`tests/test_tui.py` delete cleanly, but `cli.py` is SHARED — relocate `USER_AGENT`/`ParsedArgs`/`_format_*` first; breaking public-surface removal → minor bump, operator approval); #368 backfill live-verify (synthetic legacy corpus); SDK **#371** (Rata = seed donor + future consumer; next = WT codegen spike; divergences D-1 unknown-SSE-skip / D-2 open-world-enums / D-3 typed-410); P06 referential-frame A/B/C probe (waits WT b122); R39 Phase-2 matched-quartets (confirmatory); bifrost snapshot-cursor adoption (normative, non-blocking).
|
||||
|
||||
**✅ RESOLVED — the "app product" workstreams leave Rata entirely (operator 2026-07-18).**
|
||||
**No arbo fork, no SillyTavern-on-Rata** — a NEW repo (template-dev standing up) takes over BOTH
|
||||
app-product ideas. Rata does NOT fork into anything and is NOT the base for any chat / image-gen app.
|
||||
Rata reverts to its core identity: **the Worldtree Conversation-API debug-observability surface +
|
||||
the Bifrost Tier-3 reference impl + the Conversation-API SDK seed / future-consumer.** Supersedes the
|
||||
2026-07-17 "fork Rata for arbo" NEXT-MAJOR plan (that plan is retired; arbo API intel lives in the
|
||||
Recent-decisions detail trail if the new repo wants it).
|
||||
|
||||
**▶️ Rata's live stake in the Worldtree SDK (#371).** The SillyTavern *app* moved out, but Rata
|
||||
stays the **SDK's seed donor + a future consumer**: Worldtree owns the official Conversation API SDK
|
||||
(dedicated repo, elevated pre-v1 track, **WT #371**), seeded from Rata's client spine
|
||||
(`sse_client`/`sessions`/`tier3`); TS ships first, **Rata's repin rides the later Python milestone**;
|
||||
internal-registry-only. This session Rata shipped the parity-matrix inventory (→ WT
|
||||
`docs/sdk/parity-matrix.md` `698cfc3`, behavioral-spec `c999b5f`, fixture corpus `3019ee6` = 66
|
||||
fixtures), the issue-#1 contract drift fix (`4bd9abd`), and the personal-WT endpoint. WT pipeline:
|
||||
behavioral-spec ✓ / parity-matrix ✓ / fixture-corpus ✓ → next is the **codegen spike (d)**. Three
|
||||
divergences pinned for our eventual repin: D-1 (unknown SSE event → skip+diagnostics-hook vs our
|
||||
current hard-raise), D-2 (open-world enums), D-3 (typed eager `410 session_retired`). Full arc →
|
||||
`persistent-memory.d/2026-07-18-worldtree-sdk-371-and-sillytavern-app.md`.
|
||||
- **SDK fixture-capture: GO GIVEN** (operator → worldtree-dev, 2026-07-18); the capture run
|
||||
proceeds on Worldtree's side. Nothing pending from Rata on #371.
|
||||
|
||||
**⚠️ TUI DEPRECATED (operator 2026-07-17) — web supersedes it; rip pending, surgical.** Independent
|
||||
of any fork (the web console is now THE interactive surface). `tui.py` + `tests/test_tui.py` delete
|
||||
cleanly, BUT `cli.py` is SHARED (`web/entrypoint.py` + `tier3.py` import `USER_AGENT`; `cli.py` holds
|
||||
`ParsedArgs`/`_format_duration_ms`/`_format_usage`). OPEN SCOPE Q: remove textual-only (`tui.py`) vs
|
||||
the whole terminal interface (relocate shared consts out of `cli.py` first). Breaking public-surface
|
||||
removal → minor bump (operator approval). (The v0.20.17 collapsible debug panes, `c678853`, are now
|
||||
just a web-console feature — the "pre-fork" framing is moot.)
|
||||
|
||||
**✅ #368 (user,character) memory silo test — DONE, PASSED live (WT b127).** The reason for the
|
||||
pre-session clear; closed. Write+read isolation proven end-to-end (conjunctive `{end_user,
|
||||
agent_self}`; cold-recall gave zero cross-character bleed); throwaway `ratatoskr:betty` deleted;
|
||||
Sindra intact; stores scrubbed. **Still QUEUED:** the backfill live-verify (synthetic legacy corpus).
|
||||
Full record → `persistent-memory.d/2026-07-18-368-silo-test-passed.md`. Sibling issues WT-owned:
|
||||
**#369** (recited-content re-promotion loop), **#367** (copula-absence bridge phrasing).
|
||||
|
||||
**Deferred/queued (all NON-URGENT):** (1) P06-driver **referential-frame A/B/C probe**
|
||||
(operator-ratified, waits on WT b122 → `persistent-memory.d/2026-07-17-referential-frame-probe.md`);
|
||||
(2) R39 Phase-2 **matched-quartets rebuild** (confirmatory, "whenever"); (3) bifrost **snapshot-cursor
|
||||
adoption** (ruled normative, not blocking → `persistent-memory.d/2026-07-16-bifrost-cursor-conformance.md`).
|
||||
|
||||
**Substrate / environment:** branch `main` at **v0.22.0** — slices 1-5 (`b1fbadd`→`4e20030`, v0.21.3–.18)
|
||||
PUSHED to origin; **slice-6 arc `de9a5ba`→`11ae2f0` (v0.21.19–.20) AND slice-7 `ec68b1f` (v0.22.0) + this
|
||||
snapshot are LOCAL — NOT yet pushed** (operator's call). origin
|
||||
`git@gitea.phasefinal.com:vh/ratatoskr.git`. **Core dep:
|
||||
`worldtree-sdk==1.0.0`** (gitea PyPI, `[tool.uv.sources]`; **`httpx-sse` REMOVED at slice-7** — SDK owns SSE). bifrost
|
||||
**`==1.1.4`** / wire v0.7; WT openapi vendored 2.3.0, **conversation-api-spec re-synced to v1.1** (`b4a278c`);
|
||||
**suite 494 green** (slice-6 added the admin adapter tests + heid fixup tests, ~offset by the retired
|
||||
hand-rolled admin tests + the whole `test_sse_client.py`). Personal WT on **b128**
|
||||
(`http://10.250.50.152:8081`; #368 silo + #364 promotion-hygiene live both instances). The combined
|
||||
**:8392** provider (memory+affect) + **:8765** web are THE surfaces, dev-box BACKGROUND SHELLS —
|
||||
restart via `scratchpad/relaunch_by_pid.py <pid>` (pid via `ss -ltnp | grep <port>`). `env.sh` sets
|
||||
the REQUIRED `RATATOSKR_MEMORY_EMBEDDING_DIM=1024`. Keys env-only mode-600 in
|
||||
`~/.config/ratatoskr/provider.env` + `RATATOSKR_ADMIN_API_KEY` (7 read scopes, personal-:8081-only;
|
||||
Heimdall keys per-instance). **FOOT-GUN: check owner-scoped agents via `GET /agents/<owner>:<name>`
|
||||
with the OWNER key (`WORLDTREE_API_KEY`), NOT the admin key** — admin gives a false 404. `graphify-out/`
|
||||
runs dirty (auto-regen on the commit hook, never stage). v1 = full Worldtree I/O coverage, cuts when
|
||||
WT tags 1.0 (`docs/coverage-map.md`).
|
||||
|
||||
**Other live threads:** soong-lab = Tier-3 agent-authoring studio (v0.3.24+; motivational
|
||||
string→object export fixed upstream, our workaround droppable). Cross-session name-recall arc
|
||||
COMPLETE + shipped (v0.20.11→.16; WT #364 closed both sides; Sindra durably recalls Vuong via
|
||||
`mark_superseded` retirement). Sindra pristine baseline **WIPED 2026-07-18** (full #368 scrub,
|
||||
born-empty store — old `sindra-baseline/*.db` predate it). Open/deferred: #10 subject-migration watch;
|
||||
relational-dynamics verify (bind `--bifrost-url :8392`); WT #356 resume-durability = WT-owned.
|
||||
**Live substrate / foot-guns:** branch `main`, HEAD `5c3d0ad` PUSHED; origin `git@gitea.phasefinal.com:vh/ratatoskr.git`; version **v0.22.3**. Deps: **`worldtree-sdk==1.2.0`**, **`bifrost==1.1.5`**/wire v0.7, WT openapi vendored 2.3.0, conversation-api-spec v1.1. **`uv sync --all-extras`** — dev deps are a `[project.optional-dependencies]` EXTRA; plain `uv sync` PRUNES pytest/respx. Personal WT on **b185** (`http://10.250.50.152:8081`) — **FOOT-GUN: it can deadlock into a live-socket/dead-worker hang (WT #401); symptom = TCP port open but ALL routes return 000 ("server disconnected without response"); fix = infra-ops `docker restart`, ratatoskr auto-recovers.** Surfaces: combined **`:8392`** provider (memory+affect) + **`:8765`** web (pid 2290701) — detached bare processes, no supervisor → re-stale on reboot (relaunch `source env.sh && setsid .venv/bin/ratatoskr-web --host 0.0.0.0 --port 8765`); `env.sh` sets REQUIRED `RATATOSKR_MEMORY_EMBEDDING_DIM=1024`; keys mode-600 in `~/.config/ratatoskr/provider.env`. **dots-tts `:8198` is now a fleet SWITCHBOARD** (fronts dots on `:8202`; +3.3ms; engine swaps = instant `.env` flips; v4 engine adds an optional `language` field). **FOOT-GUN: dots' `language` validator is an alias-mapper NOT a gatekeeper** — word-junk ('klingon') maps to a tag + generates; only structurally-invalid tokens (empty/punctuation/overlong) 400 → validate ourselves if we ever send `language`. **FOOT-GUN: browser auto-TTS is opt-in (`localStorage['ratatoskr-tts']='1'`), PER-ORIGIN** — localhost vs LAN-IP have separate storage, so the toggle doesn't carry between them. **FOOT-GUN: check owner-scoped agents via `GET /agents/<owner>:<name>` with the OWNER key, NOT admin (admin → false 404).** SDK-adapter foot-guns (cumulative): SDK reads = open-world dicts → presenters degrade-not-crash at 3 guard levels; all transport failures → `ConnectFailed(status=0)`; `local_agents` index v1→v2 SILENTLY DROPS pre-cutover agents. `graphify-out/` runs dirty (never stage). v1 = full Worldtree I/O coverage (`docs/coverage-map.md`).
|
||||
|
||||
## Recent decisions
|
||||
|
||||
Chronological log of decisions with `[YYYY-MM-DD]` prefix. One line per
|
||||
decision. Captures rationale that won't be obvious from code alone.
|
||||
|
||||
- `[2026-08-12]` **Personal WT :8081 deadlocked after ~4 days → routed to worldtree-dev as WT #401; ratatoskr degrade path held.** Live-socket/dead-worker event-loop wedge (background `context_promotion` sqlite "unable to open database file"); recovered by infra-ops `docker restart`; ratatoskr auto-recovered (12/12 502 → 8/8 200, no ratatoskr restart). → `persistent-memory.d/2026-08-12-wt-401-personal-deadlock.md`
|
||||
- `[2026-08-12]` **Config-driven voices + two-voice dialogue/narration split SHIPPED (v0.22.3, `5c3d0ad`).** `~/.config/ratatoskr/voices.json` (per-agent voice + optional `narration_voice`); new `src/ratatoskr/voices.py`; `tts_stream_stitched` replaces `tts_stream`; contract DEC-11 + INV-TTS-5/6/7. → `persistent-memory.d/2026-08-12-config-voices-two-voice.md`
|
||||
- `[2026-08-12]` **dots-tts `:8198` became a fleet SWITCHBOARD (fronts dots `:8202`, +3.3ms); engine swaps are now instant `.env` flips.** v4 engine adds an optional `language` field; byte-compatible, no ratatoskr change. FOOT-GUN: dots' `language` validator is an alias-mapper not a gatekeeper (word-junk generates, doesn't 400) — validate ourselves if we ever send it.
|
||||
- `[2026-08-11]` **Sindra voiced with the dots `miranda` voice (`3e69bc9`, no bump).** Ratatoskr-side per-agent voice map (resolved at `/api/tts` by `agent_id`); superseded next day by the config-driven map (DEC-11).
|
||||
- `[2026-08-11]` **TTS migrated chatterbox-fast→dots-tts (v0.22.2, `38b78d8`), operator-directed after an A/B win.** OpenAI-shaped `/v1/audio/speech`, SR 24000→48000, default voice glados_25s→glados, serialized single-consumer; affect stays dropped; chatterbox `:8197` reclaimed. → `persistent-memory.d/2026-08-11-tts-dots-migration.md`
|
||||
- `[2026-08-08]` **Crown-recall RESOLVED at b185 — identity-anchor rerank enrichment closed the class (gate: 79%→12% stress miss).** worldtree-dev shipped a rerank-input identity anchor (from character/terms metadata) + chain-wide tie-determinism (`50ecec7`) instead of the floor lever; `lexical_recall_gate --anchor crown --runs=8` at b185 = 12% miss / 0% knife-edge / item @rank 0 (vs 79% post-fold b184). Floor packet does NOT reopen; 12% residual = bare-query formulation ceiling. Arc complete our side; efficacy delta on WT #400.
|
||||
- `[2026-08-07]` **Crown-recall traced to a general RETRIEVAL-CLASS limitation (exact-lexical match on an indexed surface form buried by dense-semantic neighbors), NOT persona/extraction/synonymy.** worldtree-dev decomposition (WT #400): no-stemming tokenizer + RRF top-10 knife-edge + reranker ownership-demotion. Operator-greenlit evidence-gated sequence (ship stemming fold → gate measures residual → hold `rerank_hybrid_floor` lever). Shipped `docs/diagnostics/lexical_recall_gate.py` (class acceptance instrument); pre-fold baseline control 0% / stress[crown] 100% miss. Awaiting worldtree-dev fold+#397 deploy ping for the pre/post delta. Thread `01KZETD98T`; siblings WT #399 (filed), #397 (committed, not deployed).
|
||||
- `[2026-08-07]` **TTS migrated Zonos→chatterbox-fast (`:8197` bespoke schema); affect DROPPED (Turbo has no emotion knob, operator "drop it for chatterbox"), client-chunking DROPPED (no per-synth cap), language pin DROPPED, browser SR 44100→24000.** Pushed `19b499a`. English "German drift" real cause (after 2 wrong hypotheses) = Turbo model OVER-RUNS its generation TAIL on long single generations (garble in final ~2-3s, ZCR 1.58x); **fixed SERVER-SIDE by infra-ops (`max_chunk_chars=250`, image :v2, operator ear-confirmed).** Consumer: **REVERTED my interim `top_k/top_p/temp` curbs** (they made it WORSE — pulled garble onset earlier), send full text + default sampling; KEPT the `/api/tts` empty-200→503 guard as hygiene. Contract `donut_voiced_interview.contract.md` amended. Loop closed.
|
||||
- `[2026-08-07]` **order_by=chapter tool flag → FILED as Worldtree #397 (DEFERRED to next session's contract pass).** Narrative/temporal-query gap ("first encounter in the dungeon"): `reference_knowledge` sorts by relevance not chronology; `provenance.chapter` is on every chunk but the consumer can't reorder native results (kb_bridge retired). Operator ruled the upstream sort flag the clean fix; worldtree-dev accepted, our fixture is the measurement instrument. Tracked at **Worldtree #397** (+ althing thread `01KZED2T3XHJ2WMS5NCYK42W6R`).
|
||||
- `[2026-08-07]` **#393 (descriptive-query subject binding) CLOSED — persona-expand lever the win (4/10→9/10), tool directive the fleet floor.** `docs/diagnostics/descriptive_query_binding.py` is the canonical #393 fixture; two-mechanism split (cross-wing dilution vs fiction-scope selection). Commits `6c83a3b`/`4f4b5ad`/`2cc670e`.
|
||||
- `[2026-08-07]` **Donut expand-don't-distill persona lever (`2cc670e`)** — keep full descriptive phrase + enrich toward entity vocab, never distill to bare keywords; measured 4/10→9/10 roid-rage binds persona-alone. Composes with near-miss LEAD (`37b67a5`).
|
||||
- `[2026-08-07]` **TTS "unavailable" = stale web-server process** (predated POST /api/tts → 405); restarted + verified 200+WAV. Durability gap: bare nohup, no supervisor → systemd unit offered, deferred (operator's call).
|
||||
- `[2026-08-03]` **fiction_wing_probe harness fix (`2111b1e`)** — fresh-session-per-query (WT #391 workaround) + NFKC/quote-fold on_target; surfaced+closed WT #391.
|
||||
|
||||
- `[2026-06-19]` **#18 D2 SHIPPED (`v0.17.14`, `39eebd1`) and the full #17+#18 arc PUSHED to origin** → `persistent-memory.d/2026-06-19-18-d2-shipped-v0-17-14-39eebd1-and-the-full-1.md`
|
||||
|
||||
- `[2026-06-19]` **bifrost repinned 0.8.0→0.10.0; `affect.fetch` became MANDATORY (strong-or-absent)** → `persistent-memory.d/2026-06-19-bifrost-repinned-0-8-0-0-10-0-affect-fetch-be.md`
|
||||
@@ -286,6 +212,22 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
|
||||
- `[2026-07-19]` **worldtree-sdk cutover SLICE-7 (teardown, the LAST) COMPLETE — the whole cutover shipped as v0.22.0 (`ec68b1f`, minor, operator-approved).** Zero runtime-logic teardown: httpx-sse dropped (SDK owns SSE), wire contracts #2/#15 retired (files deleted; #1+first_message kept), `sessions.py`/`sse_client.py` KEPT as pure type/exception homes (operator decision A1 — no rename/fold), coverage-map re-anchored. All 7 slices done, 494 green; push + althing-announce pending (operator's call). → `persistent-memory.d/2026-07-19-worldtree-sdk-cutover-slice-7-complete.md`
|
||||
|
||||
- `[2026-07-19→08-01]` **Donut voiced-interview BUILD — auto-TTS via Zonos + retire-ready KB-recall bridge + Princess Donut.** Slice-1 done (persona + define); slices 2-3 to build; both external deps verified live. THE active work. → `persistent-memory.d/2026-08-01-donut-voiced-interview-build.md`
|
||||
- `[2026-08-01]` **worldtree-sdk 1.0.0→1.1.1→1.1.2 repin — and a reference-consumer catch.** 1.1.1's path-encoding fix MISSED the inline `turn_stream.py:185` on the SSE path (caller-shaped session_id, our highest-traffic route); wtsdk-dev fixed it in 1.1.2 same-day. (`d3a36f7`+`0058020`, pushed.)
|
||||
- `[2026-08-01]` **Mimir #382 grounding bug — our finding shaped the fix.** Rata caught that the #380 vector re-index left Mimir DISCARDING correct P&P hits (no named source in the fiction wing → agent distrusts its own hits + answers from training; a silent-substitution "reasoning move, not retrieval"). Fix = our proposed residual (self-describing `_index.md`) + a Mimir prompt rule; deployed b166. **P&P verification re-run OWED to infra-ops**; DCC dir still opaque (backfill failed, flagged to worldtree-dev).
|
||||
- `[2026-08-01]` **Canonical sync — added the client-spec-v2 pin (option A) + synced OpenAPI/spec forward** (`14bbc2b`, pushed). Whole-manifest sync also bumped the OpenAPI hard-gate additively (frozen v1 wire → no breakage). Coverage-map re-audit vs the new surface = queued follow-up.
|
||||
- `[2026-07-30]` **Versioning cadence → RELEASE-ONLY (operator-ratified, brokkr-smithy relayed).** Retired per-commit bump+tag; tag only releases; between releases `feat:`/`fix:` SHAs. Existing ~112 tags left as-is. Global CLAUDE.md SemVer being amended to match.
|
||||
- `[2026-08-01]` **bifrost 1.1.4→1.1.5 (v0.22.1, `3ef3a5e`, pushed)** — optional-store-verb 500→`unsupported_capability` hardening; provider restarted onto it.
|
||||
- `[2026-08-01]` **Sindra saga — restored from local-index burial, wiped, smoke-tested.** The v0.22.0 web restart dropped her live session AND the schema-v2 discard buried her from the picker; migrated her v1→v2 from authoritative WT (real role `thoughtful-character`, not the stale `mistral-small-4`), then `scripts/reset-sindra-stores.sh` wiped memory+persona for a from-scratch smoke.
|
||||
- `[2026-08-02]` **Donut TTS: English pin + long-form chunking + POST + dialogue-only persona + both heid gates — shipped `d59f907` (pushed).** Gibberish→`language:"en-us"`; truncation→client-side chunk+concatenate under the 71.2s/6144-tok cap; persona dialogue-only + always-call `reference_knowledge`; 4-arm gates fixed a lock deadlock + several untrusted-input 500s the GET→POST switch re-opened. → `persistent-memory.d/2026-08-02-donut-tts-chunking-english-gates.md`
|
||||
- `[2026-08-02→03]` **`reference_knowledge` grounding VALIDATED end-to-end — 0/5→5/5 across a 3-round verify; the verify instrument drove diagnosis of a structural Tier-3 blindness (INV-361-3 metadata mismatch, #384) + density restore (#385).** Donut recalls the DCC corpus live; wt-dev's "cleanest consumer-side validation this pipeline has had." → `persistent-memory.d/2026-08-03-reference-knowledge-3-round-verify.md`
|
||||
- `[2026-08-03]` **Artifact-coverage gap filed as WT #387 (DEFERRED, tracked #387; operator picks when it runs).** "Crown of the Sepsis Whore" (major DCC item, confirmed 2× in source text) absent from the 705 concepts — the fiction concept schema has NO item/artifact type, so named objects ride incidentally in plot_events and single-scene items survive on sampling luck. My ratatoskr coverage-probe offer (known-major-artifacts yardstick) recorded on #387; wt-dev pings me when its re-extraction ships. Consumer-side flag, wt-owned fix (schema evolution). Thread `01KZ349M…`.
|
||||
- `[2026-08-03]` **worldtree-sdk repinned 1.1.2→1.2.0 (operator-directed, `ae49dcf`, pushed).** New `ResponseTooLarge` (a ProtocolError, non-resumable) mapped → `SseResponseTooLarge` at the stream surfaces (`wt.stream_turn`/`stream_admin_events` + the 2 stream endpoints); the 108MB read-body cap is unreachable on legal traffic so reads inherit the SDK refusal unwrapped. Absorbed WT spec 2.4.0/2.5.0 (zero-schema). +2 adapter tests.
|
||||
- `[2026-08-03]` **Donut ANTI-FABRICATION persona shipped (`3e12c4d`+`c0a66fc`, pushed) + the retry-on-LOW experiment tested & rejected.** Her memory IS the tool's results: LOW/no-on-target → deflect in-character, MEDIUM+ → answer; self-corrects as the KB improves. The retry BACKFIRES (RRF confidence inflatable by any DCC-flavored query → false MEDIUM → fabrication); robust fix is tool-side = #389. → `persistent-memory.d/2026-08-03-donut-anti-fabrication-and-retry.md`
|
||||
- `[2026-08-03]` **R42 (fiction-wing retrieval characterization) probe harness shipped to brokkr-smithy-dev (operator-directed, `04e0293`, pushed).** `docs/diagnostics/fiction_wing_probe.py` is R42's official harness + the re-runnable #389 acceptance gate; conventions (0.030/0.016 buckets, on-target = row names the subject, N-run bucket distribution) + the frozen artifact yardstick adopted verbatim. PENDING (peer-pinged): arm-1 on-target alignment at Phase-1; #389 gate re-run when RRF work lands. Threads wt-dev `01KZ349M…` / brokkr `01KZ421X…`.
|
||||
- `[2026-08-02]` **worldtree-sdk 1.2.0 repin DEFERRED to a dep pass (my rec; operator to decide).** New `ResponseTooLarge` (a `ProtocolError`, NOT caught by our `except ConnectFailed`) + response-alloc caps that assume a 2.5.0 server (worldtree-dev runs it → safe in practice). When repinning, add `ResponseTooLarge` to the caught envelopes. Tracked: wtsdk-dev announce thread `01KZ1ZYM…`.
|
||||
- `[2026-08-02]` **Filed issue #21** (sibling POST handlers `_create_session`/`_submit_turn` 500 on malformed JSON — the parse-JSON-or-400 asymmetry the heid gates flagged; pre-existing, out of the TTS diff's scope). Fix = a shared parse-JSON-or-400 helper.
|
||||
|
||||
_67 older entries (2026-05-* debug-TUI/web era + the 2026-06-14 → 06-18 Bifrost-provider build / #17+#18 / #295-296 era) archived to archival-memory.md._
|
||||
|
||||
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._
|
||||
@@ -295,6 +237,7 @@ _For per-issue TDD implementation notes, Volva findings, and contract amendments
|
||||
Log of approaches that were tried and rejected, with rationale. Future-self
|
||||
defense against re-attempting the same cul-de-sac.
|
||||
|
||||
- `[2026-08-11]` **dots streaming `pcm` is NOT headerless — only NON-stream `pcm` is raw s16le.** `response_format:"pcm"` + `stream:true` still emits a RIFF placeholder-header WAV (dots always headers a stream). So the two-voice stitch (`tts_stream_stitched`) can't use "pcm for spans 2..N" to skip header-stripping — it streams every span as `wav` and strips the leading header on later spans (accumulate-until-`data`, INV-TTS-7). Don't chase the pcm shortcut for streaming.
|
||||
- `[2026-07-10]` **Reverting our provider to bifrost 1.0.0 to fix the bound-handshake `schema_validation_failed` — DISPROVEN, and it would've been the WRONG state** → `persistent-memory.d/2026-07-10-reverting-our-provider-to-bifrost-1-0-0-to-fi.md`
|
||||
- `[2026-06-15]` **Canonical sync retired the issue-scoped parser staleness.** `contract_parser.py` synced to v2.1 (commit `d85ab43`): now validates issue-scoped frontmatter + four v2.1 test categories. The old "treat parser ERROR-on-issue-scoped as expected" note no longer applies.
|
||||
- `[2026-06-15]` **Issue #17 filed.** REVERSES design-brief §6's "no Bifrost-binding consumer support" — that negative clause predates ratatoskr's provider identity (2026-06-14), so the canary now owns both ends but its client couldn't drive its own provider. (Shipped 2026-06-18.)
|
||||
@@ -330,4 +273,12 @@ defense against re-attempting the same cul-de-sac.
|
||||
|
||||
- `[2026-07-16]` **`sortable_chunk_fields` advertised WITHOUT the required `type` field = whole-handshake deploy-breaker; only DRIVING the real bind caught it** → `persistent-memory.d/2026-07-16-sortablechunkfields-advertised-without-the-re.md`
|
||||
|
||||
- `[2026-08-01]` **`local_agents` index schema v1→v2 bump SILENTLY buries pre-cutover consumer agents from the picker** — `load_local_agents` returns `[]` on version mismatch (no migration path); the v0.22.0 restart made Sindra vanish even though she was alive on WT. Fix applied ad-hoc: migrate the v1 row → v2 from authoritative WT data (the v1 `model` field held a stale model name, NOT the current role). A one-time v1→v2 migration guard is the durable fix (unbuilt; operator-flagged).
|
||||
- `[2026-08-01]` **Zonos: mp3/opus `response_format` silently return mislabeled PCM** (no encoder wired) — use `response_format:"wav"` only. And use the GATEWAY :8890, NOT the engine :1920 (rep-penalty bug pads ~70s silence + ignores voice).
|
||||
- `[2026-08-02]` **Zonos hard-caps ONE synthesis at `max_tokens=6144` = 71.2s of audio** (6144 / 86.3Hz codec frame rate; `>6144` → HTTP 400, an architectural sequence limit). 86.3Hz is a **delivery-INDEPENDENT constant** — 6144 tokens is ALWAYS 71.2s regardless of emotion/rate (emotion changes words-per-71.2s, not seconds-per-token). Any turn longer than ~71s REQUIRES client-side chunk-and-concatenate (raw PCM, ONE WAV header — never stitch multiple WAV headers). Fixed in `d59f907` (DEC-10). Don't chase a "raise max_tokens" fix — the gateway rejects it.
|
||||
- `[2026-08-02]` **A GET→POST endpoint switch re-opens untrusted-TYPE crashes that string-only query params silently masked.** Under GET, `p`/`a`/`agent_id` were always `str|None`; under a JSON POST body they can be a huge int (`float()`→OverflowError), an unhashable list/dict (`dict.get`→TypeError), or a lone surrogate (utf-8 encode→UnicodeEncodeError) — each a 500 the old code never saw. Guard EVERY body field when moving a query endpoint to a JSON body. Both heid gates converged on these (all 4 arms). Fixed in `d59f907`.
|
||||
- `[2026-08-03]` **A bare `uv sync` PRUNES this project's dev deps** — pytest/respx/ruff live in `[project.optional-dependencies]` (an EXTRA, not a dependency-group), so `uv sync` (default groups only) removes them from `.venv`, and `uv run pytest` then silently falls back to a user-site pytest (py3.11, `~/lib`) that can't import the `.venv`'s `worldtree_sdk` → 16 collection ModuleNotFoundErrors. Use **`uv sync --all-extras`**. Bit me right after the 1.2.0 lock; the repin itself was never at risk.
|
||||
- `[2026-08-03]` **`tier3 patch` does NOT refresh a live agent's running context** — it updates STORAGE (the define/patch response echoes the new prompt) but the running agent keeps serving the OLD system prompt. To change a live Tier-3 persona reliably, **delete + define (recreate)**, not patch. (Cost a confusing "patched but behavior unchanged" loop on the Donut anti-fabrication change.)
|
||||
- `[2026-08-03]` **A persona-side confidence gate can't stop LLM fabrication — RRF confidence is inflatable by query phrasing.** A "re-search on LOW" retry reformulates the query with related real entities (or just DCC-flavored terms), which scores MEDIUM off THOSE matches, not the subject — manufacturing false grounding to fabricate on. The signal that survives is on-target (does a returned row NAME the subject), which is a TOOL-side check, not something a prompt can enforce against a model with strong genre priors. → #389.
|
||||
|
||||
_34 older entries (2026-05-* debug-TUI/web era + the 2026-06-14 → 06-18 foot-gun cluster) archived to archival-memory.md._
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.22.1"
|
||||
version = "0.22.3"
|
||||
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -16,7 +16,7 @@ keywords = ["worldtree", "debug", "sse", "web", "observability"]
|
||||
# owned by worldtree-sdk post-cutover (#20 slice-7 dropped httpx-sse).
|
||||
dependencies = [
|
||||
"httpx>=0.27",
|
||||
"worldtree-sdk==1.0.0", # #20 cutover: the consumer client layer (gitea PyPI); the hand-rolled wrappers now live behind ratatoskr.wt
|
||||
"worldtree-sdk==1.2.0", # #20 cutover: the consumer client layer (gitea PyPI); the hand-rolled wrappers now live behind ratatoskr.wt. 1.2.0 = response-side allocation caps + a new exported `ResponseTooLarge` (a ProtocolError, NOT a ConnectionDropped — retrying an oversized response is futile; caps: 2xx body 108,004,096 B, SSE frame 540,000 B; the caps assume a 2.5.0 server, which worldtree-dev runs, so safe on legal traffic). We catch it explicitly at the stream + read surfaces (INV-CUT-2). Absorbed WT spec 2.4.0/2.5.0 (zero-schema, no generated-type change). 1.1.2 = closed the SSE turn-stream traversal we caught in 1.1.1 (quote safe="" at turn_stream.py:185). 1.1.1 = path-encoding correctness fix + additive define_or_reuse / set_persona_state_raw (our parity #6) + 64 KiB error-body alloc cap. Same frozen wire as 1.0.0 (drop-in).
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -122,6 +122,19 @@ class SseConnectionDropped(Exception):
|
||||
self.last_seen_sse_id = last_seen_sse_id
|
||||
|
||||
|
||||
class SseResponseTooLarge(Exception):
|
||||
"""Raised when the SDK refuses an oversized SSE frame (worldtree-sdk 1.2.0
|
||||
`ResponseTooLarge`, scope="sse_frame"). Distinct from SseConnectionDropped: the SDK
|
||||
deliberately does NOT class it resumable — retrying yields the same oversized frame —
|
||||
so callers surface it as a terminal error, never a reconnect. `limit_bytes` is the
|
||||
ceiling the frame exceeded."""
|
||||
|
||||
def __init__(self, *, limit_bytes: int, scope: str = "sse_frame") -> None:
|
||||
super().__init__(f"SSE {scope} exceeded {limit_bytes} bytes")
|
||||
self.limit_bytes = limit_bytes
|
||||
self.scope = scope
|
||||
|
||||
|
||||
class InvalidLastEventId(Exception):
|
||||
"""Raised on HTTP 400 from a reconnect request — caller's last_event_id was rejected."""
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""dots-tts gateway TTS client.
|
||||
|
||||
Migrated off chatterbox-fast 2026-08-10 (operator-directed, after an A/B win —
|
||||
"very good"). dots-tts (rednote-hilab `dots.tts-soar`, irv-ml1 :8198) is an
|
||||
OpenAI-shaped `/v1/audio/speech` gateway — closer to the Zonos-era client than
|
||||
chatterbox's bespoke `/tts`. It carries over one Zonos-era drop and reintroduces one primitive:
|
||||
- NO affect/emotion knob (dots, like chatterbox Turbo, exposes no
|
||||
valence/arousal/emotion dial), so voice stays flat (DEC-7 stays retired); and
|
||||
- a SINGLE-VOICE turn streams from one dots call (no chunking), but the two-voice
|
||||
dialogue/narration split (DEC-11) synthesizes per-span and STITCHES the PCM under one
|
||||
leading WAV header (tts_stream_stitched). A single-span list is a verbatim passthrough,
|
||||
so the single-voice case is unchanged.
|
||||
This module is the SINGLE swap seam for voice synthesis: the `/api/tts` route in
|
||||
web/server.py is its only caller.
|
||||
|
||||
Foot-guns (infra-ops-verified 2026-08-10 against dots-studio/dots.tts-soar :8198):
|
||||
- Sample rate is 48000 Hz (chatterbox was 24000). The browser Web Audio decode
|
||||
MUST use 48000 or the voice plays ~2x too fast.
|
||||
- response_format:"wav", stream:true emits a streaming placeholder-header WAV
|
||||
(RIFF/data sizes as placeholders, one header, then 48kHz mono s16le PCM) — the
|
||||
same shape the browser's one-header-strip decoder already expects. "pcm" (raw
|
||||
s16le, headerless) is leaner but would need the browser to drop the header
|
||||
strip; kept on wav so the streaming shape stays byte-identical to the prior path.
|
||||
- Body field names are OpenAI-shaped: `input` (NOT chatterbox's `text`),
|
||||
`response_format` (NOT chatterbox's `format`). No `model` field, no `language`
|
||||
field, no affect dials.
|
||||
- SERIALIZED single-consumer: dots renders one generation at a time (no concurrent
|
||||
requests). Harmless here — the endpoint already serializes on app.state.tts_lock
|
||||
(DEC-5, one synth per turn on the shared GPU). If concurrent streams are ever
|
||||
needed, infra-ops escalates the backend behind this same API (client unchanged).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import httpx
|
||||
|
||||
# The dots-tts gateway (irv-ml1 :8198). OpenAI-shaped `/v1/audio/speech`.
|
||||
# Overridable per deployment via app.state.tts_url (RATATOSKR_TTS_URL) — the swap seam + tests.
|
||||
DOTS_TTS_URL = "http://10.100.79.3:8198/v1/audio/speech"
|
||||
|
||||
|
||||
class TtsUnavailable(Exception):
|
||||
"""The gateway failed, was unreachable, or returned a non-wav body.
|
||||
|
||||
The caller degrades (INV-TTS-4): logs + skips audio; the turn/transcript is
|
||||
never blocked or failed on a synthesis error.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str = "", *, status: int = 0) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
def gateway_body(text: str, voice: str) -> dict:
|
||||
"""The dots-tts POST body (OpenAI-shaped). `response_format:"wav"` (DEC-3) so the
|
||||
streaming shape is a placeholder-header WAV the browser decoder strips; `stream:true`
|
||||
for play-as-it-arrives (DEC-2). Field names are OpenAI: `input` (not `text`),
|
||||
`response_format` (not `format`). Send the FULL text — dots streams a whole turn from
|
||||
one call. No `language`, no affect dials (DEC-7)."""
|
||||
return {"input": text, "voice": voice, "response_format": "wav", "stream": True}
|
||||
|
||||
|
||||
def _log_degrade(event: str, detail: str) -> None:
|
||||
"""Write the INV-TTS-4 `tts_degrade` stderr line for a committed-200 mid-stream failure."""
|
||||
sys.stderr.write(f'{{"kind":"tts_degrade","event":"{event}","detail":"{detail}"}}\n')
|
||||
|
||||
|
||||
def _strip_to_pcm(acc: bytes) -> bytes | None:
|
||||
"""Given accumulated header bytes of a span past the first, return the PCM after the WAV
|
||||
`data` chunk id+size, or None if `data` hasn't arrived yet (keep accumulating). Robust to
|
||||
the header spanning chunks (INV-TTS-7)."""
|
||||
di = acc.find(b"data")
|
||||
if di < 0 or di + 8 > len(acc):
|
||||
return None
|
||||
return acc[di + 8:]
|
||||
|
||||
|
||||
async def tts_stream_stitched(
|
||||
spans: list[tuple[str, str]],
|
||||
*,
|
||||
client: httpx.AsyncClient,
|
||||
url: str = DOTS_TTS_URL,
|
||||
) -> AsyncIterator[bytes]:
|
||||
"""Synthesize an ordered list of (voice, text) spans SERIALLY into ONE continuous stream.
|
||||
|
||||
dots is single-consumer, so spans render in order. The FIRST emitting span is passed
|
||||
through VERBATIM (its WAV header + PCM); every later span is streamed as wav too but with
|
||||
its leading WAV header STRIPPED (accumulate until `data`, emit after data+8), so the
|
||||
browser decodes one gapless 48kHz mono s16le stream after a single leading header
|
||||
(INV-TTS-7). A single-span list is therefore byte-identical to a plain gateway proxy
|
||||
(INV-TTS-6): the whole-turn single-voice / dialogue-only case is a no-regression passthrough.
|
||||
|
||||
The `yielded_any` degrade pivot spans the WHOLE sequence (INV-TTS-4):
|
||||
- a non-200 OPEN or a transport failure BEFORE any byte has been committed → TtsUnavailable
|
||||
(the endpoint peek turns it into a 503; nothing committed yet).
|
||||
- any failure AFTER >= 1 byte has streamed (the 200 is committed) → DEGRADE: log a
|
||||
`tts_degrade` line, drop the tail, keep what played. NEVER raise into the committed 200.
|
||||
"""
|
||||
assert spans, "tts_stream_stitched: spans must be non-empty (the endpoint guards this)"
|
||||
yielded_any = False
|
||||
header_emitted = False # has the single leading WAV header been passed through yet?
|
||||
for voice, text in spans:
|
||||
if not text.strip():
|
||||
continue
|
||||
strip_header = header_emitted
|
||||
acc = b""
|
||||
found_pcm = not strip_header # first emitting span passes through immediately
|
||||
try:
|
||||
async with client.stream("POST", url, json=gateway_body(text, voice)) as resp:
|
||||
if resp.status_code != 200:
|
||||
if yielded_any:
|
||||
_log_degrade("span_status", str(resp.status_code))
|
||||
return
|
||||
raise TtsUnavailable(
|
||||
f"gateway status {resp.status_code}", status=resp.status_code
|
||||
)
|
||||
async for chunk in resp.aiter_bytes():
|
||||
if not found_pcm:
|
||||
acc += chunk
|
||||
pcm = _strip_to_pcm(acc)
|
||||
if pcm is None:
|
||||
if len(acc) > 65536: # no data header in a sane window → malformed
|
||||
if yielded_any:
|
||||
_log_degrade("no_data_header", "")
|
||||
return
|
||||
raise TtsUnavailable("stitched span: no WAV data header")
|
||||
continue
|
||||
chunk, found_pcm, acc = pcm, True, b""
|
||||
if chunk:
|
||||
yielded_any = True
|
||||
header_emitted = True
|
||||
yield chunk
|
||||
except httpx.RequestError as exc:
|
||||
if yielded_any:
|
||||
_log_degrade("stream_dropped", type(exc).__name__)
|
||||
return
|
||||
raise TtsUnavailable(f"gateway transport failure: {exc}") from exc
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Rata-side voice configuration + dialogue/narration segmentation (DEC-11).
|
||||
|
||||
Voice assignment is config-driven, not hardcoded: `~/.config/ratatoskr/voices.json`
|
||||
maps each agent to a dots voice, with an OPTIONAL `narration_voice` whose presence turns
|
||||
on the two-voice split (dialogue in `voice`, narration in `narration_voice`).
|
||||
|
||||
schema: {"default": "<voice>",
|
||||
"agents": {"<agent_id>": {"voice": "<v>", "narration_voice": "<v>"?}}}
|
||||
|
||||
The split rule (segment_dialogue): QUOTED text (straight " or curly “ ”) = dialogue; text
|
||||
outside quotes = narration. An agent with NO narration_voice always voices the whole turn in
|
||||
`voice` (single span) — which is why dialogue-only Donut needs no special-casing.
|
||||
|
||||
Config load is degrade-not-crash (INV-TTS-5): an absent file is normal (→ built-in default);
|
||||
a malformed one logs + falls back. Pure + self-contained (stdlib only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Built-in fallback (INV-TTS-5) and the initial shipped config. donut is dialogue-only
|
||||
# (no narration_voice → single voice); sindra splits miranda (spoken) / emmie (narration).
|
||||
DEFAULT_VOICE_CONFIG: dict = {
|
||||
"default": "glados",
|
||||
"agents": {
|
||||
"ratatoskr:donut": {"voice": "donut"},
|
||||
"ratatoskr:sindra": {"voice": "miranda", "narration_voice": "emmie"},
|
||||
},
|
||||
}
|
||||
|
||||
_DEFAULT_PATH = "~/.config/ratatoskr/voices.json"
|
||||
|
||||
# Double-quote boundary chars. Straight " TOGGLES; curly “ opens, ” closes (directional).
|
||||
# Single quotes / apostrophes (' ’) are NOT boundaries — they stay inside spans so # noqa: RUF003
|
||||
# possessives/contractions ("Donut's") never split a word.
|
||||
_QUOTE_OPEN = "“" # “
|
||||
_QUOTE_CLOSE = "”" # ”
|
||||
_QUOTE_STRAIGHT = '"'
|
||||
|
||||
|
||||
def load_voice_config(path: str | None = None) -> dict:
|
||||
"""Load voices.json → config dict. Absent file → built-in default (normal, not an error);
|
||||
malformed/unreadable → built-in default + a logged `voice_config_error` (INV-TTS-5)."""
|
||||
p = os.path.expanduser(path or _DEFAULT_PATH)
|
||||
try:
|
||||
with open(p, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except FileNotFoundError:
|
||||
return copy.deepcopy(DEFAULT_VOICE_CONFIG)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
sys.stderr.write(
|
||||
f'{{"kind":"voice_config_error","path":{json.dumps(p)},"exc":"{type(exc).__name__}"}}\n'
|
||||
)
|
||||
return copy.deepcopy(DEFAULT_VOICE_CONFIG)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("agents"), dict):
|
||||
sys.stderr.write(
|
||||
f'{{"kind":"voice_config_error","path":{json.dumps(p)},'
|
||||
f'"exc":"missing agents mapping"}}\n'
|
||||
)
|
||||
return copy.deepcopy(DEFAULT_VOICE_CONFIG)
|
||||
data.setdefault("default", DEFAULT_VOICE_CONFIG["default"])
|
||||
return data
|
||||
|
||||
|
||||
def segment_dialogue(text: str) -> list[tuple[str, str]]:
|
||||
"""Split text into ordered (kind, span) pairs, kind ∈ {"dialogue","narration"}.
|
||||
|
||||
Quoted runs are dialogue, the rest narration. Straight " toggles quote-state; curly “/”
|
||||
are directional. Quote chars are delimiters (dropped from spans). Empty/whitespace spans
|
||||
are dropped. Unbalanced (a trailing open quote) → its run-to-end is dialogue (best-effort,
|
||||
never raises). Word order is preserved.
|
||||
"""
|
||||
spans: list[tuple[str, str]] = []
|
||||
buf: list[str] = []
|
||||
in_quote = False
|
||||
|
||||
def flush() -> None:
|
||||
s = "".join(buf)
|
||||
if s.strip():
|
||||
spans.append(("dialogue" if in_quote else "narration", s))
|
||||
buf.clear()
|
||||
|
||||
for ch in text:
|
||||
if ch == _QUOTE_STRAIGHT:
|
||||
flush()
|
||||
in_quote = not in_quote
|
||||
elif ch == _QUOTE_OPEN:
|
||||
flush()
|
||||
in_quote = True
|
||||
elif ch == _QUOTE_CLOSE:
|
||||
flush()
|
||||
in_quote = False
|
||||
else:
|
||||
buf.append(ch)
|
||||
flush()
|
||||
return spans
|
||||
|
||||
|
||||
def resolve_voice_spans(config: dict, agent_id: object, text: str) -> list[tuple[str, str]]:
|
||||
"""Resolve a turn to an ordered list of (voice, text) synthesis spans.
|
||||
|
||||
Unmapped agent (or non-str agent_id) → one span in `default`. Mapped agent with no
|
||||
`narration_voice` → one span in `voice`. Mapped agent WITH `narration_voice` → segment
|
||||
into dialogue/narration spans (dialogue→voice, narration→narration_voice). Always returns
|
||||
at least one span for non-empty text (segmentation that empties falls back to a single span).
|
||||
"""
|
||||
default = config.get("default", DEFAULT_VOICE_CONFIG["default"])
|
||||
agents = config.get("agents", {})
|
||||
entry = agents.get(agent_id) if isinstance(agent_id, str) and isinstance(agents, dict) else None
|
||||
if not isinstance(entry, dict):
|
||||
return [(default, text)]
|
||||
voice = entry.get("voice") or default
|
||||
if not isinstance(voice, str):
|
||||
voice = default
|
||||
narr = entry.get("narration_voice")
|
||||
if not narr or not isinstance(narr, str):
|
||||
return [(voice, text)]
|
||||
spans = segment_dialogue(text)
|
||||
if not spans:
|
||||
return [(voice, text)]
|
||||
return [(voice if kind == "dialogue" else narr, span) for kind, span in spans]
|
||||
@@ -80,6 +80,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||
# never receives the key, only the session-filtered result.
|
||||
admin_key = os.environ.get("RATATOSKR_ADMIN_API_KEY")
|
||||
|
||||
# Auto-TTS (slice 2): the dots-tts gateway URL. Defaults to the direct
|
||||
# gateway (DEC-1) inside the server; override here only to point at a different
|
||||
# synth host (the swap seam). None → the server's DOTS_TTS_URL default.
|
||||
tts_url = os.environ.get("RATATOSKR_TTS_URL")
|
||||
|
||||
# INV-001: lazy import. Users without [web] extras get a clean hint
|
||||
# instead of a raw ImportError. Scoped narrowly to the OPTIONAL
|
||||
# extras (starlette / uvicorn) so a real import bug inside a
|
||||
@@ -100,10 +105,16 @@ def main(argv: list[str] | None = None) -> int:
|
||||
# Baseline deps + own modules — a failure here is a real bug, not a
|
||||
# missing-extras condition; let it propagate.
|
||||
import httpx
|
||||
|
||||
from ratatoskr.cli import USER_AGENT
|
||||
from ratatoskr.voices import load_voice_config
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
def client_factory() -> "httpx.AsyncClient":
|
||||
# Config-driven voices (DEC-11): ~/.config/ratatoskr/voices.json (per-agent voice +
|
||||
# optional narration_voice). Absent/malformed → the built-in default (INV-TTS-5).
|
||||
voice_config = load_voice_config()
|
||||
|
||||
def client_factory() -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={
|
||||
@@ -121,6 +132,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
affect_read_url=affect_read_url,
|
||||
memory_read_url=memory_read_url,
|
||||
admin_key=admin_key,
|
||||
tts_url=tts_url,
|
||||
voice_config=voice_config,
|
||||
)
|
||||
|
||||
# Boot banner to stderr (so stdout stays clean for piping).
|
||||
|
||||
+153
-5
@@ -9,6 +9,7 @@ passes a factory that bakes in WORLDTREE_API_URL + WORLDTREE_API_KEY.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import itertools
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Callable, Mapping
|
||||
@@ -62,8 +63,15 @@ from ratatoskr.sse_client import (
|
||||
MalformedSseId,
|
||||
SseConnectFailed,
|
||||
SseConnectionDropped,
|
||||
SseResponseTooLarge,
|
||||
TurnIdFlip,
|
||||
)
|
||||
from ratatoskr.tts import (
|
||||
DOTS_TTS_URL,
|
||||
TtsUnavailable,
|
||||
tts_stream_stitched,
|
||||
)
|
||||
from ratatoskr.voices import DEFAULT_VOICE_CONFIG, resolve_voice_spans
|
||||
|
||||
|
||||
def _wt_client(
|
||||
@@ -105,11 +113,16 @@ def _static_dir() -> str:
|
||||
|
||||
|
||||
def _root_endpoint(request: Request) -> FileResponse:
|
||||
"""GET / → index.html. Per FN root_endpoint POST-001."""
|
||||
"""GET / → index.html. Per FN root_endpoint POST-001.
|
||||
|
||||
no-store: this is an actively-iterated dev console — never let a browser serve a
|
||||
stale cached page (which silently hid the streaming-TTS / autoplay-unlock updates
|
||||
during debugging). The HTML is small; a re-fetch per load is free on the LAN."""
|
||||
from pathlib import Path
|
||||
return FileResponse(
|
||||
Path(_static_dir()) / "index.html",
|
||||
media_type="text/html",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@@ -281,7 +294,9 @@ async def _submit_turn_endpoint(request: Request) -> JSONResponse:
|
||||
"""
|
||||
body = await request.json()
|
||||
content = body.get("content") if isinstance(body, dict) else None
|
||||
if not content:
|
||||
if not content or not isinstance(content, str):
|
||||
# Require a non-blank STRING; a truthy non-str (e.g. {"content": {...}}) gets a
|
||||
# deterministic 400 rather than reaching the SDK turn call as a bad type.
|
||||
return JSONResponse({"error_code": "missing_content"}, status_code=400)
|
||||
session_id = request.path_params["session_id"]
|
||||
turn_id = next(_TURN_COUNTER)
|
||||
@@ -372,7 +387,7 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
|
||||
handle.status = event.type or "done"
|
||||
break
|
||||
except (wt.SessionApiFailed, SseConnectFailed, SseConnectionDropped,
|
||||
MalformedSseId, MalformedSseData, TurnIdFlip) as exc:
|
||||
SseResponseTooLarge, MalformedSseId, MalformedSseData, TurnIdFlip) as exc:
|
||||
# wt.SessionApiFailed covers the adapter's SessionRetired (410) mapping;
|
||||
# without it a retired-session stream would escape gen() after partial
|
||||
# frames as an uncaught 500, not a labeled `event: error`.
|
||||
@@ -529,6 +544,124 @@ async def _memory_chunks_endpoint(request: Request) -> JSONResponse:
|
||||
return JSONResponse(r.json(), status_code=r.status_code)
|
||||
|
||||
|
||||
# Voice assignment is config-driven (DEC-11): app.state.voice_config (from voices.json) maps
|
||||
# each agent to a dots voice + optional narration_voice; resolve_voice_spans turns a turn into
|
||||
# ordered (voice, text) synthesis spans. The old hardcoded _TTS_VOICE_MAP is retired.
|
||||
# The text rides the POST body (DEC-10a), so URL length is not the bound — this is a safety
|
||||
# ceiling on the shared-GPU hold. dots streams a whole turn from one call (no client concat),
|
||||
# so a single call voices the whole turn; ~8000 chars still covers any real interview turn
|
||||
# while bounding a runaway. A response past this is truncated at a word boundary (the full
|
||||
# text still shows in the transcript).
|
||||
_TTS_MAX_TEXT_CHARS = 8000
|
||||
|
||||
|
||||
def _truncate_at_boundary(text: str, limit: int) -> str:
|
||||
"""Trim to <= limit chars, preferring the last space so we don't cut mid-word."""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
head = text[:limit]
|
||||
cut = head.rfind(" ")
|
||||
return head[:cut] if cut > limit // 2 else head
|
||||
|
||||
|
||||
async def _tts_endpoint(request: Request) -> Response:
|
||||
"""POST /api/tts {text, agent_id?} → audio/wav, STREAMED chunked from the dots-tts
|
||||
gateway (FN tts_endpoint). POST (not GET) so an arbitrarily long turn rides the body, not a
|
||||
length-capped URL. resolve_voice_spans (DEC-11) turns the turn into ordered (voice, text)
|
||||
spans — one for a single-voice agent, or dialogue/narration spans when the agent has a
|
||||
narration_voice — and tts_stream_stitched synthesizes them into one gapless 48kHz stream
|
||||
(one leading WAV header + s16le PCM).
|
||||
|
||||
Server-side proxy (DEC-4 / INV-TTS-1: the gateway host never reaches the browser).
|
||||
Voice per-character (DEC-8). No affect modulation — DEC-7 retired with the Zonos migration.
|
||||
Serialized one-stream-at-a-time (DEC-5); a new turn aborts the prior fetch → the POST drops →
|
||||
the generator's finally releases the lock. A gateway open-failure → 503 (INV-TTS-4: the
|
||||
client skips playback)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return JSONResponse({"error_code": "missing_text"}, status_code=400)
|
||||
text = body.get("text") if isinstance(body, dict) else None
|
||||
if not isinstance(text, str):
|
||||
return JSONResponse({"error_code": "missing_text"}, status_code=400)
|
||||
# Drop lone surrogates: they ride through JSON (\udXXX from a mid-surrogate-pair slice)
|
||||
# but raise UnicodeEncodeError when httpx serializes the gateway POST body (surrogates are
|
||||
# not utf-8-encodable) — an untrusted-input 500. Scrub so the text is always encodable
|
||||
# (INV-TTS-4 degrade). Also drops a whitespace-only body to a deterministic 400 below.
|
||||
text = text.encode("utf-8", "ignore").decode("utf-8")
|
||||
if not text.strip():
|
||||
return JSONResponse({"error_code": "missing_text"}, status_code=400)
|
||||
text = _truncate_at_boundary(text, _TTS_MAX_TEXT_CHARS)
|
||||
# agent_id is an UNTRUSTED open-world body field; resolve_voice_spans guards a non-str
|
||||
# (→ default voice, DEC-11) rather than 500 (INV-TTS-4). It returns >=1 (voice, text) span:
|
||||
# one for a single-voice/dialogue-only agent, or dialogue/narration spans when the agent
|
||||
# has a narration_voice. (The Zonos-era p/a PAD fields are gone — DEC-7 retired.)
|
||||
agent_id = body.get("agent_id")
|
||||
spans = resolve_voice_spans(request.app.state.voice_config, agent_id, text)
|
||||
|
||||
tts_url = request.app.state.tts_url
|
||||
lock = request.app.state.tts_lock
|
||||
|
||||
# DEC-5: one stream at a time on the shared GPU. Held for the stream's duration and
|
||||
# released in the generator's finally — including the browser-abort path. read=120s per
|
||||
# infra-ops: a long synth can render slowly under load.
|
||||
await lock.acquire()
|
||||
client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=10.0, read=120.0, write=10.0, pool=10.0)
|
||||
)
|
||||
gen = tts_stream_stitched(spans, client=client, url=tts_url)
|
||||
|
||||
async def _release() -> None:
|
||||
await gen.aclose() # unwinds the stitched gen's `async with` → closes the gateway resp
|
||||
await client.aclose()
|
||||
lock.release()
|
||||
|
||||
try:
|
||||
# Peek the first chunk so a bad gateway OPEN (non-200 / transport) surfaces as a
|
||||
# 503 BEFORE we commit a 200 StreamingResponse. TTFB ~0.44s, so this is cheap.
|
||||
first = await gen.__anext__()
|
||||
except TtsUnavailable:
|
||||
await _release()
|
||||
return JSONResponse({"error_code": "tts_unavailable"}, status_code=503)
|
||||
except StopAsyncIteration:
|
||||
first = None
|
||||
except BaseException:
|
||||
# Any OTHER escape during the peek — a browser abort (CancelledError) before the
|
||||
# first byte, or an unmapped transport error (e.g. httpx.InvalidURL from a
|
||||
# misconfigured tts_url, which is NOT an httpx.RequestError) — must still release
|
||||
# the process-wide serialize lock + client, or every later /api/tts deadlocks at
|
||||
# lock.acquire() (DEC-5). The finally below only exists once piped() runs. Propagate.
|
||||
await _release()
|
||||
raise
|
||||
|
||||
# An empty 200 body (no bytes at all) is a synthesis failure, not audio: a gateway that
|
||||
# returns 200 with a 0-byte body (a stalled/failed generation) must not commit a silent,
|
||||
# empty audio/wav StreamingResponse the browser would play as nothing. Surface it as a 503
|
||||
# (INV-TTS-4 visible skip). Kept as hygiene regardless of backend (infra-ops-endorsed).
|
||||
if first is None:
|
||||
await _release()
|
||||
return JSONResponse({"error_code": "tts_unavailable"}, status_code=503)
|
||||
|
||||
# Chunk 1 must be a WAV (RIFF header). A 200 non-WAV body — a misbehaving gateway or a
|
||||
# proxy error page — would be mislabeled audio/wav and the browser would decode markup as
|
||||
# PCM (or match a `data` byte-run in it). Reject → 503 (INV-TTS-4). Tolerant of a <4-byte
|
||||
# first read so a valid WAV split oddly is never false-rejected.
|
||||
if first is not None and len(first) >= 4 and not first.startswith(b"RIFF"):
|
||||
await _release()
|
||||
return JSONResponse({"error_code": "tts_unavailable"}, status_code=503)
|
||||
|
||||
async def piped() -> AsyncIterator[bytes]:
|
||||
try:
|
||||
if first:
|
||||
yield first
|
||||
async for chunk in gen:
|
||||
yield chunk
|
||||
finally:
|
||||
await _release()
|
||||
|
||||
return StreamingResponse(piped(), media_type="audio/wav")
|
||||
|
||||
|
||||
async def _session_tools_endpoint(request: Request) -> JSONResponse:
|
||||
"""GET /api/sessions/{session_id}/tools → owner-scoped tool inventory (spec #183).
|
||||
|
||||
@@ -648,8 +781,8 @@ async def _admin_events_endpoint(request: Request) -> Response:
|
||||
{"id": ev.id, "type": ev.type, "timestamp": ev.timestamp,
|
||||
"data": ev.data},
|
||||
)
|
||||
except (SseConnectFailed, SseConnectionDropped, MalformedSseId,
|
||||
MalformedSseData) as exc:
|
||||
except (SseConnectFailed, SseConnectionDropped, SseResponseTooLarge,
|
||||
MalformedSseId, MalformedSseData) as exc:
|
||||
yield _format_sse(
|
||||
"stream_error",
|
||||
{"exception": type(exc).__name__, "message": str(exc)},
|
||||
@@ -673,6 +806,8 @@ def create_app(
|
||||
affect_read_url: str | None = None,
|
||||
memory_read_url: str | None = None,
|
||||
admin_key: str | None = None,
|
||||
tts_url: str | None = None,
|
||||
voice_config: dict | None = None,
|
||||
) -> Starlette:
|
||||
"""Construct the Starlette app — wire routes + state per FN create_app.
|
||||
|
||||
@@ -739,6 +874,7 @@ def create_app(
|
||||
Route("/api/sessions/{session_id}/messages", _session_messages_endpoint),
|
||||
Route("/api/sessions/{session_id}/bifrost", _session_bifrost_endpoint),
|
||||
Route("/api/admin/events", _admin_events_endpoint),
|
||||
Route("/api/tts", _tts_endpoint, methods=["POST"]),
|
||||
Route("/api/turns/{session_id}", _submit_turn_endpoint, methods=["POST"]),
|
||||
Route("/api/turns/{session_id}/stream", _stream_turn_endpoint),
|
||||
Route("/api/turns/{session_id}/cancel", _cancel_turn_endpoint, methods=["POST"]),
|
||||
@@ -762,6 +898,18 @@ def create_app(
|
||||
# SERVER-HELD (RATATOSKR_ADMIN_API_KEY) and never reaches the browser — the
|
||||
# server proxies admin-scoped reads and forwards only the session-filtered result.
|
||||
app.state.admin_key = admin_key
|
||||
# Auto-TTS (slice 2): the dots-tts gateway URL is SERVER-HELD config — the host
|
||||
# never reaches the browser (DEC-4 / INV-TTS-1). Defaults to the direct gateway
|
||||
# (DEC-1); overridable via RATATOSKR_TTS_URL (the swap seam). The lock serializes
|
||||
# one synth at a time — required by dots (single-consumer) and so concurrent turns
|
||||
# don't contend the shared GPU (DEC-5).
|
||||
app.state.tts_url = tts_url or DOTS_TTS_URL
|
||||
app.state.tts_lock = asyncio.Lock()
|
||||
# Config-driven voices (DEC-11): the per-agent voice + optional narration_voice map.
|
||||
# entrypoint loads voices.json; tests may inject; None → the built-in default (INV-TTS-5).
|
||||
app.state.voice_config = voice_config if voice_config is not None else copy.deepcopy(
|
||||
DEFAULT_VOICE_CONFIG
|
||||
)
|
||||
# INV-002: turn registry is in-process memory, keyed (session_id, turn_id)
|
||||
app.state.turn_registry = {}
|
||||
return app
|
||||
|
||||
@@ -226,6 +226,12 @@ kbd {
|
||||
#theme-toggle .i-moon { display: none; }
|
||||
body[data-theme="light"] #theme-toggle .i-sun { display: none; }
|
||||
body[data-theme="light"] #theme-toggle .i-moon { display: inline; }
|
||||
#tts-toggle { width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; background: transparent; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--fg-muted); cursor: pointer; flex: 0 0 auto; }
|
||||
#tts-toggle:hover { background: var(--bg-2); color: var(--fg-0); border-color: var(--border-default); }
|
||||
#tts-toggle.on { color: var(--aus-bright-cyan); border-color: var(--border-default); }
|
||||
#tts-toggle .i-wave { display: none; }
|
||||
#tts-toggle.on .i-wave { display: inline; }
|
||||
#tts-toggle.on .i-mute { display: none; }
|
||||
|
||||
#conn {
|
||||
flex: 0 0 auto; margin: 0 14px 14px; padding: 9px 12px;
|
||||
@@ -573,6 +579,10 @@ body.cot-hidden #cot-toggle { border-color: rgba(66,220,209,0.55); color: var(--
|
||||
<div class="brand-name">ratatoskr</div>
|
||||
<div class="brand-tag">wire monitor</div>
|
||||
</div>
|
||||
<button id="tts-toggle" title="Voice off — click to toggle auto-TTS">
|
||||
<svg class="i-wave" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H2v6h4l5 4V5z"></path><path d="M15.5 8.5a5 5 0 0 1 0 7M19 5a9 9 0 0 1 0 14"></path></svg>
|
||||
<svg class="i-mute" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H2v6h4l5 4V5z"></path><path d="M22 9l-6 6M16 9l6 6"></path></svg>
|
||||
</button>
|
||||
<button id="theme-toggle" title="Toggle light / dark">
|
||||
<svg class="i-sun" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"></path></svg>
|
||||
<svg class="i-moon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path></svg>
|
||||
@@ -1698,6 +1708,8 @@ async function submitPrompt() {
|
||||
const input = $("prompt-input");
|
||||
const content = input.value.trim();
|
||||
if (!content || !state.sessionId || state.turnId) return;
|
||||
cancelTts(); // INV-TTS-3: a new turn cancels prior voice
|
||||
if (ttsEnabled()) _unlockTtsAudio(); // this keypress is a gesture — grant autoplay
|
||||
input.value = ""; input.style.height = "20px";
|
||||
|
||||
let r;
|
||||
@@ -1811,6 +1823,11 @@ async function submitPrompt() {
|
||||
tickerAdd(cls === "done" ? "ok" : cls === "error" ? "err" : "turn",
|
||||
cls === "done" ? "turn.completed" : cls === "error" ? "turn.failed" : "turn.cancelled",
|
||||
cls === "done" ? elapsed + "s" : meta);
|
||||
// Auto-TTS (slice 2, DEC-7): speak the completed response, emotion-modulated by the
|
||||
// live PAD the persona pane shows. Only on `done`; opt-in; failures are non-blocking.
|
||||
if (cls === "done" && ttsEnabled()) {
|
||||
speakOnDone(LIVE.resp, state.agentId);
|
||||
}
|
||||
es.close();
|
||||
state.eventSource = null; state.turnId = null; state.curTurnEl = null;
|
||||
$("composer").classList.remove("streaming");
|
||||
@@ -1977,6 +1994,125 @@ async function cancelTurn() {
|
||||
});
|
||||
})();
|
||||
|
||||
// ---- auto-TTS: voiced STREAMING playback via Web Audio (dots-tts, 48kHz) ------
|
||||
// Fetch the chunked POST /api/tts stream, decode its int16 PCM, and schedule the samples
|
||||
// GAPLESSLY into an AudioContext as they arrive (TTFA ~0.5s). Web Audio, NOT <audio src>,
|
||||
// because Safari/WebKit REFUSES a streaming 0xFFFFFFFF-length WAV via <audio src>
|
||||
// (NotSupportedError) — decoding the raw PCM ourselves sidesteps every WAV-container quirk
|
||||
// and works in all engines. Opt-in (INV-TTS-2), one stream at a time (INV-TTS-3: a new
|
||||
// turn aborts the fetch + stops scheduled nodes), non-blocking (INV-TTS-4).
|
||||
function ttsEnabled() {
|
||||
try { return localStorage.getItem("ratatoskr-tts") === "1"; } catch (_) { return false; }
|
||||
}
|
||||
let _ttsCtx = null, _ttsAbort = null, _ttsNodes = [];
|
||||
function _ttsAudioCtx() {
|
||||
if (!_ttsCtx) {
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (AC) _ttsCtx = new AC();
|
||||
}
|
||||
return _ttsCtx;
|
||||
}
|
||||
// Unlock: resume the AudioContext inside a user gesture (Safari + Chrome both require the
|
||||
// resume to originate from an interaction; once running it stays running). Fired on the
|
||||
// FIRST interaction anywhere, so it's ready before the delayed speak-on-done.
|
||||
function _unlockTtsAudio() {
|
||||
const ctx = _ttsAudioCtx();
|
||||
if (ctx && ctx.state === "suspended") ctx.resume().catch(() => {});
|
||||
}
|
||||
document.addEventListener("pointerdown", _unlockTtsAudio, true);
|
||||
document.addEventListener("keydown", _unlockTtsAudio, true);
|
||||
|
||||
function cancelTts() {
|
||||
if (_ttsAbort) { try { _ttsAbort.abort(); } catch (_) {} _ttsAbort = null; }
|
||||
for (const n of _ttsNodes) { try { n.stop(); } catch (_) {} try { n.disconnect(); } catch (_) {} }
|
||||
_ttsNodes = [];
|
||||
}
|
||||
function _findDataChunk(u8) { // offset of the "data" chunk id in a WAV header, or -1
|
||||
for (let i = 0; i + 4 <= u8.length; i++)
|
||||
if (u8[i] === 0x64 && u8[i + 1] === 0x61 && u8[i + 2] === 0x74 && u8[i + 3] === 0x61) return i;
|
||||
return -1;
|
||||
}
|
||||
function _u8concat(a, b) { // always returns a FRESH array (byteOffset 0) so Int16Array aligns
|
||||
const out = new Uint8Array(a.length + b.length); out.set(a, 0); out.set(b, a.length); return out;
|
||||
}
|
||||
async function speakOnDone(text, agentId) {
|
||||
const clip = (text || "").trim();
|
||||
if (!clip) return;
|
||||
cancelTts(); // INV-TTS-3: stop any prior stream
|
||||
const ctx = _ttsAudioCtx();
|
||||
if (!ctx) { tickerAdd("err", "tts", "no audio ctx"); return; }
|
||||
if (ctx.state === "suspended") { try { await ctx.resume(); } catch (_) {} }
|
||||
const payload = { text: clip.slice(0, 8000) }; // matches the server cap; chunked server-side by the gateway
|
||||
if (agentId) payload.agent_id = agentId; // no affect fields — DEC-7 retired (dots has no emotion knob)
|
||||
const ctrl = new AbortController(); _ttsAbort = ctrl;
|
||||
let resp;
|
||||
// POST (not GET) so a long turn rides the body, not a length-capped URL (DEC-10).
|
||||
try {
|
||||
resp = await fetch("/api/tts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
} catch (_) { return; } // aborted / network → silent skip (INV-TTS-4)
|
||||
if (!resp.ok || !resp.body) { tickerAdd("err", "tts", "unavailable " + resp.status); return; }
|
||||
const reader = resp.body.getReader();
|
||||
const SR = 48000; // dots-tts sample rate (was 24000 for chatterbox — MUST match or the voice plays ~2x too fast)
|
||||
let playAt = ctx.currentTime + 0.06, started = false, headerDone = false;
|
||||
let acc = new Uint8Array(0), carry = new Uint8Array(0);
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done || ctrl !== _ttsAbort) break; // finished, or superseded by a new turn
|
||||
let bytes = value;
|
||||
if (!headerDone) { // skip the WAV header (up to + incl the data id/size)
|
||||
acc = _u8concat(acc, bytes);
|
||||
const di = _findDataChunk(acc);
|
||||
if (di < 0 || di + 8 > acc.length) {
|
||||
// Bounded: a real header lands in the first read; if 64 KiB arrive with no `data`
|
||||
// marker the stream is headerless/malformed — bail instead of buffering forever.
|
||||
if (acc.length > 65536) { tickerAdd("err", "tts", "no WAV header"); break; }
|
||||
continue; // header spans chunks — keep accumulating
|
||||
}
|
||||
bytes = acc.subarray(di + 8); headerDone = true; acc = null;
|
||||
}
|
||||
const u8 = _u8concat(carry, bytes); // prepend the odd-byte carry; fresh + aligned
|
||||
const even = u8.length - (u8.length & 1);
|
||||
carry = u8.slice(even); // stash a trailing odd byte for next chunk
|
||||
if (even < 2) continue;
|
||||
const pcm = new Int16Array(u8.buffer, 0, even / 2); // int16 LE (all real browsers are LE)
|
||||
const f32 = new Float32Array(pcm.length);
|
||||
for (let i = 0; i < pcm.length; i++) f32[i] = pcm[i] / 32768;
|
||||
const ab = ctx.createBuffer(1, f32.length, SR);
|
||||
ab.getChannelData(0).set(f32);
|
||||
const node = ctx.createBufferSource();
|
||||
node.buffer = ab; node.connect(ctx.destination);
|
||||
if (playAt < ctx.currentTime) playAt = ctx.currentTime; // underrun guard
|
||||
node.start(playAt); playAt += ab.duration;
|
||||
_ttsNodes.push(node);
|
||||
node.onended = () => { const i = _ttsNodes.indexOf(node); if (i >= 0) _ttsNodes.splice(i, 1); };
|
||||
if (!started) { started = true; tickerAdd("ok", "tts", "▶ voiced"); }
|
||||
}
|
||||
} catch (_) { /* aborted / stream error → whatever's already scheduled finishes */ }
|
||||
}
|
||||
// ---- 🔊 toggle (mirrors theme / cot-toggle; default OFF, persisted) ----
|
||||
(function () {
|
||||
let on = false;
|
||||
try { on = localStorage.getItem("ratatoskr-tts") === "1"; } catch (_) {}
|
||||
const btn = $("tts-toggle");
|
||||
const label = (v) => "Voice " + (v ? "on" : "off") + " — click to toggle auto-TTS";
|
||||
btn.classList.toggle("on", on);
|
||||
btn.title = label(on);
|
||||
btn.addEventListener("click", () => {
|
||||
const next = !btn.classList.contains("on");
|
||||
btn.classList.toggle("on", next);
|
||||
btn.title = label(next);
|
||||
try { localStorage.setItem("ratatoskr-tts", next ? "1" : "0"); } catch (_) {}
|
||||
if (next) _unlockTtsAudio(); // grant autoplay within this gesture
|
||||
else cancelTts(); // turning off stops in-flight playback
|
||||
});
|
||||
})();
|
||||
|
||||
// ---- collapsible debug panes (independent left / right; state persists) ----
|
||||
(function () {
|
||||
const cols = $("cols");
|
||||
@@ -2054,5 +2190,6 @@ loadAgents();
|
||||
loadVersion();
|
||||
loadPersonaCanon();
|
||||
</script>
|
||||
<audio id="tts-audio" hidden preload="none"></audio>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+24
-2
@@ -69,6 +69,7 @@ from .sse_client import (
|
||||
MalformedSseId,
|
||||
SseConnectFailed,
|
||||
SseConnectionDropped,
|
||||
SseResponseTooLarge,
|
||||
TurnIdFlip,
|
||||
TurnLaunchUnavailable,
|
||||
)
|
||||
@@ -278,13 +279,22 @@ async def get_session_tools(
|
||||
|
||||
|
||||
async def stream_turn(
|
||||
client: WorldtreeClient, session_id: str, content: str
|
||||
client: WorldtreeClient,
|
||||
session_id: str,
|
||||
content: str,
|
||||
*,
|
||||
memory_context: list[dict] | None = None,
|
||||
) -> AsyncGenerator[wtsdk.TurnEvent, None]:
|
||||
"""Drive the resilient turn stream (auto-resume; absorbs the old `reconnect_turn`)
|
||||
and yield the SDK's `TurnEvent`s, re-wrapping the stream's TERMINAL SDK errors
|
||||
into ratatoskr's caller-semantic exceptions (INV-CUT-2 / DEC-2 — the presenter
|
||||
keeps catching ratatoskr's types).
|
||||
|
||||
`memory_context` (optional) is forwarded verbatim into the turn POST body (the SDK
|
||||
passes it through). Ratatoskr uses it for the retire-ready KB-recall bridge (the
|
||||
Donut voiced-interview contract); defaulting to None keeps every other caller and
|
||||
the bridge's own retirement inert.
|
||||
|
||||
The SDK's `stream_turn` retries only the transport-drop class internally; a
|
||||
resume failure / protocol violation / connect failure surfaces unchanged
|
||||
(B-RES-6), and a drop that exhausts the reconnect budget surfaces as
|
||||
@@ -293,7 +303,9 @@ async def stream_turn(
|
||||
SDK's `ConnectFailed`, so they are caught before the generic `ConnectFailed`.
|
||||
"""
|
||||
try:
|
||||
async for event in client.sessions.stream_turn(session_id, content):
|
||||
async for event in client.sessions.stream_turn(
|
||||
session_id, content, memory_context=memory_context
|
||||
):
|
||||
yield event
|
||||
except wtsdk.SessionRetired as exc:
|
||||
# Fresh-mode 410 → the session is gone server-side; a generic API failure.
|
||||
@@ -325,6 +337,12 @@ async def stream_turn(
|
||||
raise MalformedSseData(raw=exc.raw) from exc
|
||||
except wtsdk.TurnIdFlip as exc:
|
||||
raise TurnIdFlip(established=exc.established, got=exc.got) from exc
|
||||
except wtsdk.ResponseTooLarge as exc:
|
||||
# 1.2.0: the SDK refused an oversized SSE frame — a ProtocolError, NOT a
|
||||
# ConnectionDropped (retrying yields the same frame), so surface as a terminal
|
||||
# error the endpoint labels, never a reconnect. Placed above the ApiError default
|
||||
# because ResponseTooLarge is a ProtocolError, not an ApiError.
|
||||
raise SseResponseTooLarge(limit_bytes=exc.limit_bytes, scope=exc.scope) from exc
|
||||
except ApiError as exc:
|
||||
# INV-CUT-2 default: an undiscriminated ApiError surfacing from the stream →
|
||||
# SessionApiFailed (the discriminated stream errors are handled above).
|
||||
@@ -753,6 +771,10 @@ async def stream_admin_events(
|
||||
# stream_turn. The web gen catches the Sse* types, so an unmapped ConnectFailed
|
||||
# would escape and abort the SSE with no labeled stream_error (heid bug-hunt).
|
||||
raise SseConnectFailed(status=exc.status, body=(exc.message or "").encode()) from exc
|
||||
except wtsdk.ResponseTooLarge as exc:
|
||||
# 1.2.0: an oversized admin SSE frame refused by the SDK (ProtocolError, non-resumable)
|
||||
# → surface as a terminal error, mirroring stream_turn (never a reconnect).
|
||||
raise SseResponseTooLarge(limit_bytes=exc.limit_bytes, scope=exc.scope) from exc
|
||||
except ApiError as exc:
|
||||
# A non-200 open raises ApiError("admin_stream_failed", status=…) → SseConnectFailed.
|
||||
raise SseConnectFailed(status=exc.status, body=(exc.body or "").encode()) from exc
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Unit tests for the fiction-wing probe harness's pure helpers.
|
||||
|
||||
Only the network-free helpers are covered here; the live retrieval paths
|
||||
(search_library / reference_knowledge) are exercised by running the harness
|
||||
against a live Worldtree index, not by pytest.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_DIAG = Path(__file__).resolve().parent.parent / "docs" / "diagnostics"
|
||||
sys.path.insert(0, str(_DIAG))
|
||||
|
||||
from fiction_wing_probe import _on_target # noqa: E402
|
||||
|
||||
CURLY = "’" # noqa: RUF001 - the b170 extraction's default apostrophe
|
||||
|
||||
|
||||
def test_on_target_matches_curly_apostrophe_excerpt():
|
||||
# Excerpt stores the curly apostrophe; keyword is ASCII. Must still match.
|
||||
excerpt = f"Mr. Darcy{CURLY}s letter to Elizabeth explains his conduct."
|
||||
assert _on_target(excerpt, ["darcy's letter"])
|
||||
|
||||
|
||||
def test_on_target_matches_ascii_excerpt_against_curly_keyword():
|
||||
# Symmetric: ASCII excerpt, curly-quoted keyword. Fold both sides.
|
||||
excerpt = "Mrs. Gardiner's letter arrived the next morning."
|
||||
assert _on_target(excerpt, [f"gardiner{CURLY}s letter"])
|
||||
|
||||
|
||||
def test_on_target_still_rejects_absent_subject():
|
||||
# Negative control: folding must not make unrelated excerpts match.
|
||||
excerpt = "A passage about dungeons, crawlers, and monsters."
|
||||
assert not _on_target(excerpt, ["darcy's letter"])
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Tests for ratatoskr.tts — the STREAMING dots-tts gateway client.
|
||||
|
||||
tts_stream_stitched is the sole synthesis primitive: it synthesizes an ordered list of
|
||||
(voice, text) spans serially into one continuous stream — the first span verbatim, later
|
||||
spans header-stripped (DEC-11 two-voice split). A single-span list is a verbatim passthrough
|
||||
(no buffering, no header rewrite), so the single-voice / dialogue-only case is unchanged. No
|
||||
affect dials (dots has no emotion knob). The mid-stream degrade policy spans the sequence.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from ratatoskr.tts import (
|
||||
DOTS_TTS_URL,
|
||||
TtsUnavailable,
|
||||
gateway_body,
|
||||
tts_stream_stitched,
|
||||
)
|
||||
|
||||
_URL = "http://tts.example/v1/audio/speech"
|
||||
# The gateway's streaming WAV bytes (placeholder 0xFFFFFFFF sizes). We pass them through
|
||||
# untouched, so the content only has to round-trip.
|
||||
_WAV = (
|
||||
b"RIFF\xff\xff\xff\xffWAVEfmt \x10\x00\x00\x00" + b"\x00" * 20
|
||||
+ b"data\xff\xff\xff\xff" + b"\x11\x22" * 64
|
||||
)
|
||||
# A second span's WAV with distinct PCM — its header is stripped when stitched after span 0.
|
||||
_WAV2 = (
|
||||
b"RIFF\xff\xff\xff\xffWAVEfmt \x10\x00\x00\x00" + b"\x00" * 20
|
||||
+ b"data\xff\xff\xff\xff" + b"\x33\x44" * 32
|
||||
)
|
||||
_PCM2 = b"\x33\x44" * 32 # the part of _WAV2 after `data`+size (what stitching keeps)
|
||||
|
||||
|
||||
async def _drain(gen) -> bytes:
|
||||
out = b""
|
||||
async for chunk in gen:
|
||||
out += chunk
|
||||
return out
|
||||
|
||||
|
||||
def _json_voice(route, i: int) -> str:
|
||||
import json as _json
|
||||
|
||||
return _json.loads(route.calls[i].request.content)["voice"]
|
||||
|
||||
|
||||
class _RaisingByteStream(httpx.AsyncByteStream):
|
||||
"""A 200-body stream that yields `head` then drops mid-stream (an httpx.ReadError, a
|
||||
RequestError subclass) — models a gateway connection drop AFTER the response committed."""
|
||||
|
||||
def __init__(self, head: bytes) -> None:
|
||||
self._head = head
|
||||
|
||||
async def __aiter__(self):
|
||||
yield self._head
|
||||
raise httpx.ReadError("mid-stream drop")
|
||||
|
||||
async def aclose(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class TestGatewayBody:
|
||||
def test_openai_dots_schema(self) -> None:
|
||||
b = gateway_body("hi", "donut")
|
||||
assert b["input"] == "hi" # OpenAI "input", not chatterbox "text"
|
||||
assert b["voice"] == "donut"
|
||||
assert b["response_format"] == "wav" # DEC-3 — "response_format", not "format"
|
||||
assert b["stream"] is True # DEC-2 — play-as-it-arrives
|
||||
|
||||
def test_default_sampling_no_client_side_curbs(self) -> None:
|
||||
# No client sampling curbs — the gateway's defaults govern (a client-side curb
|
||||
# was counterproductive on the prior backend and dots exposes no such need).
|
||||
b = gateway_body("a long turn", "donut")
|
||||
for knob in ("temperature", "top_p", "top_k"):
|
||||
assert knob not in b
|
||||
|
||||
def test_no_chatterbox_or_zonos_era_fields(self) -> None:
|
||||
# The chatterbox bespoke names + Zonos-era fields are gone: no `text`/`format`
|
||||
# (chatterbox), no `language` pin, no affect dials (DEC-7 retired).
|
||||
b = gateway_body("hi", "glados")
|
||||
for dead in ("text", "format", "language", "emotion_valence",
|
||||
"emotion_arousal", "emotion_enabled", "emotion_strength"):
|
||||
assert dead not in b
|
||||
|
||||
|
||||
class TestTtsStreamStitched:
|
||||
@respx.mock
|
||||
async def test_single_span_verbatim_and_posts_openai_body(self) -> None:
|
||||
# A single-span list is a verbatim passthrough (INV-TTS-6) with the OpenAI body.
|
||||
route = respx.post(_URL).mock(return_value=httpx.Response(200, content=_WAV))
|
||||
async with httpx.AsyncClient() as client:
|
||||
out = await _drain(
|
||||
tts_stream_stitched([("donut", "hello there")], client=client, url=_URL)
|
||||
)
|
||||
assert out == _WAV # passed through verbatim — no header rewrite
|
||||
import json as _json
|
||||
|
||||
body = _json.loads(route.calls.last.request.content)
|
||||
assert body["input"] == "hello there"
|
||||
assert body["voice"] == "donut"
|
||||
assert body["response_format"] == "wav"
|
||||
assert body["stream"] is True
|
||||
|
||||
@respx.mock
|
||||
async def test_two_spans_stitched_one_header(self) -> None:
|
||||
# Span 0 verbatim (its WAV header + PCM), span 1 header-STRIPPED → one continuous
|
||||
# stream with exactly one leading header (INV-TTS-7). Distinct voices per span.
|
||||
route = respx.post(_URL).mock(
|
||||
side_effect=[httpx.Response(200, content=_WAV), httpx.Response(200, content=_WAV2)]
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
out = await _drain(
|
||||
tts_stream_stitched(
|
||||
[("miranda", "spoken bit"), ("emmie", "narrated bit")],
|
||||
client=client, url=_URL,
|
||||
)
|
||||
)
|
||||
assert out == _WAV + _PCM2 # span1's header dropped, PCM kept
|
||||
assert out.count(b"RIFF") == 1 # exactly one WAV header
|
||||
assert _json_voice(route, 0) == "miranda" and _json_voice(route, 1) == "emmie"
|
||||
|
||||
@respx.mock
|
||||
async def test_empty_span_skipped(self) -> None:
|
||||
route = respx.post(_URL).mock(return_value=httpx.Response(200, content=_WAV))
|
||||
async with httpx.AsyncClient() as client:
|
||||
out = await _drain(
|
||||
tts_stream_stitched(
|
||||
[("miranda", " "), ("donut", "real")], client=client, url=_URL
|
||||
)
|
||||
)
|
||||
assert out == _WAV and len(route.calls) == 1 # blank span never synthesized
|
||||
|
||||
@respx.mock
|
||||
async def test_default_url_is_dots(self) -> None:
|
||||
route = respx.post(DOTS_TTS_URL).mock(return_value=httpx.Response(200, content=_WAV))
|
||||
async with httpx.AsyncClient() as client:
|
||||
await _drain(tts_stream_stitched([("donut", "hi")], client=client))
|
||||
assert route.called # the module default points at the dots-tts gateway
|
||||
|
||||
@respx.mock
|
||||
async def test_non_200_open_raises_before_any_chunk(self) -> None:
|
||||
respx.post(_URL).mock(return_value=httpx.Response(500, content=b"boom"))
|
||||
async with httpx.AsyncClient() as client:
|
||||
with pytest.raises(TtsUnavailable) as exc:
|
||||
await _drain(tts_stream_stitched([("glados", "hi")], client=client, url=_URL))
|
||||
assert exc.value.status == 500
|
||||
|
||||
@respx.mock
|
||||
async def test_transport_error_on_open_raises(self) -> None:
|
||||
respx.post(_URL).mock(side_effect=httpx.ConnectError("refused"))
|
||||
async with httpx.AsyncClient() as client:
|
||||
with pytest.raises(TtsUnavailable):
|
||||
await _drain(tts_stream_stitched([("glados", "hi")], client=client, url=_URL))
|
||||
|
||||
@respx.mock
|
||||
async def test_mid_stream_drop_after_first_byte_degrades_not_raises(self) -> None:
|
||||
# The 200 is committed once bytes flow; a later transport drop must DEGRADE
|
||||
# (return what streamed), never raise — the pivot is yielded_any. Keeps a committed
|
||||
# StreamingResponse from an ASGI trace.
|
||||
respx.post(_URL).mock(return_value=httpx.Response(200, stream=_RaisingByteStream(_WAV)))
|
||||
async with httpx.AsyncClient() as client:
|
||||
out = await _drain(tts_stream_stitched([("donut", "hi")], client=client, url=_URL))
|
||||
assert out == _WAV # head kept, no raise
|
||||
|
||||
@respx.mock
|
||||
async def test_later_span_open_fail_after_commit_degrades(self) -> None:
|
||||
# Span 0 commits a 200 + bytes; span 1's OPEN then 500s. Because the stream is already
|
||||
# committed, this DEGRADES (keep span 0), never raises into the 200 (INV-TTS-4).
|
||||
route = respx.post(_URL).mock(
|
||||
side_effect=[httpx.Response(200, content=_WAV), httpx.Response(500, content=b"boom")]
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
out = await _drain(
|
||||
tts_stream_stitched([("miranda", "a"), ("emmie", "b")], client=client, url=_URL)
|
||||
)
|
||||
assert out == _WAV and len(route.calls) == 2 # span0 kept, span1 attempted then dropped
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Tests for ratatoskr.voices — config load + dialogue/narration segmentation (DEC-11)."""
|
||||
|
||||
import json
|
||||
from typing import ClassVar
|
||||
|
||||
from ratatoskr.voices import (
|
||||
DEFAULT_VOICE_CONFIG,
|
||||
load_voice_config,
|
||||
resolve_voice_spans,
|
||||
segment_dialogue,
|
||||
)
|
||||
|
||||
|
||||
class TestSegmentDialogue:
|
||||
def test_narration_only_no_quotes(self) -> None:
|
||||
assert segment_dialogue("She tilts her head.") == [("narration", "She tilts her head.")]
|
||||
|
||||
def test_straight_quotes_dialogue(self) -> None:
|
||||
assert segment_dialogue('"Hi there."') == [("dialogue", "Hi there.")]
|
||||
|
||||
def test_curly_quotes_dialogue(self) -> None:
|
||||
assert segment_dialogue("“Hi there.”") == [("dialogue", "Hi there.")]
|
||||
|
||||
def test_mixed_narration_and_dialogue_order_preserved(self) -> None:
|
||||
text = "She smiles. “Hello.” She left."
|
||||
assert segment_dialogue(text) == [
|
||||
("narration", "She smiles. "),
|
||||
("dialogue", "Hello."),
|
||||
("narration", " She left."),
|
||||
]
|
||||
|
||||
def test_empty_and_whitespace_spans_dropped(self) -> None:
|
||||
# The between-quotes gap here is a single space → dropped; leading empty narration too.
|
||||
assert segment_dialogue('"A" "B"') == [("dialogue", "A"), ("dialogue", "B")]
|
||||
|
||||
def test_apostrophe_is_not_a_boundary(self) -> None:
|
||||
# Curly and straight apostrophes stay inside the span (no word split).
|
||||
assert segment_dialogue("Donut’s crown and Carl's cat") == [ # noqa: RUF001
|
||||
("narration", "Donut’s crown and Carl's cat") # noqa: RUF001
|
||||
]
|
||||
|
||||
def test_unbalanced_trailing_open_quote_is_dialogue(self) -> None:
|
||||
assert segment_dialogue('She said "hello') == [
|
||||
("narration", "She said "),
|
||||
("dialogue", "hello"),
|
||||
]
|
||||
|
||||
def test_empty_text(self) -> None:
|
||||
assert segment_dialogue("") == []
|
||||
|
||||
|
||||
class TestResolveVoiceSpans:
|
||||
CFG: ClassVar[dict] = {
|
||||
"default": "glados",
|
||||
"agents": {
|
||||
"ratatoskr:donut": {"voice": "donut"},
|
||||
"ratatoskr:sindra": {"voice": "miranda", "narration_voice": "emmie"},
|
||||
},
|
||||
}
|
||||
|
||||
def test_unmapped_agent_single_default_voice(self) -> None:
|
||||
assert resolve_voice_spans(self.CFG, "mimir", "hello") == [("glados", "hello")]
|
||||
|
||||
def test_non_str_agent_id_single_default(self) -> None:
|
||||
assert resolve_voice_spans(self.CFG, ["donut"], "hi") == [("glados", "hi")]
|
||||
|
||||
def test_mapped_no_narration_single_voice(self) -> None:
|
||||
# Donut is dialogue-only (no narration_voice) → whole turn in one voice, no split,
|
||||
# even though the text has no quotes.
|
||||
assert resolve_voice_spans(self.CFG, "ratatoskr:donut", "The crown is mine.") == [
|
||||
("donut", "The crown is mine.")
|
||||
]
|
||||
|
||||
def test_mapped_with_narration_splits(self) -> None:
|
||||
spans = resolve_voice_spans(
|
||||
self.CFG, "ratatoskr:sindra", "She smiles. “Hello.”"
|
||||
)
|
||||
assert spans == [("emmie", "She smiles. "), ("miranda", "Hello.")]
|
||||
|
||||
def test_mapped_with_narration_all_dialogue(self) -> None:
|
||||
assert resolve_voice_spans(self.CFG, "ratatoskr:sindra", '"Only speech here."') == [
|
||||
("miranda", "Only speech here.")
|
||||
]
|
||||
|
||||
def test_segmentation_empty_falls_back_to_single_span(self) -> None:
|
||||
# Text that segments to nothing (only quote chars) still yields one span, never [].
|
||||
out = resolve_voice_spans(self.CFG, "ratatoskr:sindra", '""')
|
||||
assert len(out) == 1 and out[0][0] == "miranda"
|
||||
|
||||
|
||||
class TestLoadVoiceConfig:
|
||||
def test_absent_file_returns_builtin_default(self, tmp_path) -> None:
|
||||
cfg = load_voice_config(str(tmp_path / "nope.json"))
|
||||
assert cfg == DEFAULT_VOICE_CONFIG
|
||||
assert cfg is not DEFAULT_VOICE_CONFIG # a copy, not the shared constant
|
||||
|
||||
def test_valid_file_loaded(self, tmp_path) -> None:
|
||||
p = tmp_path / "voices.json"
|
||||
p.write_text(json.dumps({"default": "glados", "agents": {"x:y": {"voice": "emmie"}}}))
|
||||
cfg = load_voice_config(str(p))
|
||||
assert cfg["agents"]["x:y"]["voice"] == "emmie"
|
||||
|
||||
def test_malformed_json_falls_back(self, tmp_path) -> None:
|
||||
p = tmp_path / "voices.json"
|
||||
p.write_text("{not json")
|
||||
assert load_voice_config(str(p)) == DEFAULT_VOICE_CONFIG
|
||||
|
||||
def test_missing_agents_mapping_falls_back(self, tmp_path) -> None:
|
||||
p = tmp_path / "voices.json"
|
||||
p.write_text(json.dumps({"default": "glados"}))
|
||||
assert load_voice_config(str(p)) == DEFAULT_VOICE_CONFIG
|
||||
|
||||
def test_default_key_backfilled(self, tmp_path) -> None:
|
||||
p = tmp_path / "voices.json"
|
||||
p.write_text(json.dumps({"agents": {"x:y": {"voice": "emmie"}}}))
|
||||
assert load_voice_config(str(p))["default"] == "glados"
|
||||
@@ -6,6 +6,8 @@ client_factory callable; tests pass a factory returning a respx-mocked
|
||||
AsyncClient.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
@@ -380,6 +382,14 @@ class TestSubmitTurnEndpoint:
|
||||
resp = TestClient(app).post("/api/turns/s-1", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_non_string_content_returns_400(self) -> None:
|
||||
"""Non-str truthy content (e.g. a dict) → deterministic 400, not a crash in the
|
||||
KB bridge's question.strip() mid-stream (heid bug-hunt)."""
|
||||
from ratatoskr.web.server import create_app
|
||||
app = create_app(_mock_client_factory())
|
||||
resp = TestClient(app).post("/api/turns/s-1", json={"content": {"x": 1}})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_monotonic_turn_ids(self) -> None:
|
||||
"""monotonic_turn_ids [trace]: two submits → second turn_id > first."""
|
||||
from ratatoskr.web.server import create_app
|
||||
@@ -754,8 +764,10 @@ class TestCreateAppShape:
|
||||
"/api/memory/chunks",
|
||||
# v0.19.2 debug-surface parity (create_app POST-002)
|
||||
"/api/sessions/{session_id}/tools",
|
||||
"/api/sessions/{session_id}/messages",
|
||||
"/api/sessions/{session_id}/bifrost",
|
||||
"/api/admin/events",
|
||||
"/api/tts",
|
||||
"/api/turns/{session_id}", "/api/turns/{session_id}/stream",
|
||||
"/api/turns/{session_id}/cancel",
|
||||
):
|
||||
@@ -1443,3 +1455,215 @@ class TestMemoryChunksEndpoint:
|
||||
)
|
||||
resp = TestClient(app).get("/api/memory/chunks?agent_id=a")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestTtsEndpoint:
|
||||
"""tts_endpoint FN — POST /api/tts → audio/wav STREAMED (chunked) from the
|
||||
dots-tts gateway. Voice per-character (DEC-8), the gateway host never reaches
|
||||
the browser (INV-TTS-1), gateway open-failure → 503 (INV-TTS-4). POST so an
|
||||
arbitrarily long turn rides the body; dots streams a whole turn from one call (no
|
||||
client concat). No affect: p/a body fields are gone (DEC-7 retired)."""
|
||||
|
||||
# Streaming WAV bytes (placeholder 0xFFFFFFFF sizes) — proxied through verbatim.
|
||||
_WAV = b"RIFF\xff\xff\xff\xffWAVEdata\xff\xff\xff\xff" + b"\x11\x22" * 64
|
||||
_TTS = "http://tts.example/v1/audio/speech"
|
||||
|
||||
@respx.mock
|
||||
def test_happy_streams_wav_resolves_donut_voice(self) -> None:
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
route = respx.post(self._TTS).mock(
|
||||
return_value=httpx.Response(200, content=self._WAV)
|
||||
)
|
||||
app = create_app(_mock_client_factory(), tts_url=self._TTS)
|
||||
resp = TestClient(app).post(
|
||||
"/api/tts",
|
||||
json={"text": "Carl is a softie.", "agent_id": "ratatoskr:donut"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("audio/wav")
|
||||
assert resp.content == self._WAV # streamed through verbatim
|
||||
body = json.loads(route.calls.last.request.content)
|
||||
assert body["input"] == "Carl is a softie." # OpenAI "input", not chatterbox "text"
|
||||
assert body["voice"] == "donut"
|
||||
assert body["response_format"] == "wav" # OpenAI "response_format", not "format"
|
||||
assert body["stream"] is True
|
||||
# No chatterbox-bespoke or Zonos-era fields ride the body.
|
||||
for dead in ("text", "format", "language", "emotion_valence"):
|
||||
assert dead not in body
|
||||
|
||||
@respx.mock
|
||||
def test_unmapped_agent_falls_to_default_voice(self) -> None:
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
route = respx.post(self._TTS).mock(
|
||||
return_value=httpx.Response(200, content=self._WAV)
|
||||
)
|
||||
app = create_app(_mock_client_factory(), tts_url=self._TTS)
|
||||
resp = TestClient(app).post("/api/tts", json={"text": "hello", "agent_id": "mimir"})
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(route.calls.last.request.content)
|
||||
assert body["voice"] == "glados" # dots default voice (DEC-8)
|
||||
|
||||
@respx.mock
|
||||
def test_sindra_dialogue_resolves_miranda_voice(self) -> None:
|
||||
# Sindra has a narration_voice, so QUOTED text is dialogue → miranda (DEC-11).
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
route = respx.post(self._TTS).mock(return_value=httpx.Response(200, content=self._WAV))
|
||||
app = create_app(_mock_client_factory(), tts_url=self._TTS)
|
||||
resp = TestClient(app).post(
|
||||
"/api/tts", json={"text": '"Hello there."', "agent_id": "ratatoskr:sindra"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(route.calls) == 1
|
||||
assert json.loads(route.calls.last.request.content)["voice"] == "miranda"
|
||||
|
||||
@respx.mock
|
||||
def test_sindra_narration_resolves_emmie_voice(self) -> None:
|
||||
# Unquoted text is narration → the narration_voice (emmie) (DEC-11).
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
route = respx.post(self._TTS).mock(return_value=httpx.Response(200, content=self._WAV))
|
||||
app = create_app(_mock_client_factory(), tts_url=self._TTS)
|
||||
resp = TestClient(app).post(
|
||||
"/api/tts", json={"text": "She tilts her head.", "agent_id": "ratatoskr:sindra"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert json.loads(route.calls.last.request.content)["voice"] == "emmie"
|
||||
|
||||
@respx.mock
|
||||
def test_sindra_two_voice_stitches_one_header(self) -> None:
|
||||
# A mixed turn → narration span (emmie) + dialogue span (miranda), stitched into one
|
||||
# stream with a single leading WAV header (INV-TTS-7); span 1's header is stripped.
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
route = respx.post(self._TTS).mock(return_value=httpx.Response(200, content=self._WAV))
|
||||
app = create_app(_mock_client_factory(), tts_url=self._TTS)
|
||||
resp = TestClient(app).post(
|
||||
"/api/tts", json={"text": 'She smiles. "Hello."', "agent_id": "ratatoskr:sindra"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(route.calls) == 2
|
||||
assert [json.loads(c.request.content)["voice"] for c in route.calls] == ["emmie", "miranda"]
|
||||
assert resp.content.count(b"RIFF") == 1 # one header for the whole stitched stream
|
||||
|
||||
def test_missing_text_returns_400(self) -> None:
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
app = create_app(_mock_client_factory(), tts_url=self._TTS)
|
||||
resp = TestClient(app).post("/api/tts", json={"agent_id": "ratatoskr:donut"})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error_code"] == "missing_text"
|
||||
|
||||
@respx.mock
|
||||
def test_gateway_500_degrades_to_503(self) -> None:
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
respx.post(self._TTS).mock(return_value=httpx.Response(500, content=b"boom"))
|
||||
app = create_app(_mock_client_factory(), tts_url=self._TTS)
|
||||
resp = TestClient(app).post("/api/tts", json={"text": "hi"})
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error_code"] == "tts_unavailable"
|
||||
|
||||
@respx.mock
|
||||
def test_long_text_is_one_gateway_call_not_chunked(self) -> None:
|
||||
# DEC-10 RETIRED: dots streams a whole turn from one call, so a long turn is ONE
|
||||
# gateway call with the full text — no client-side chunk-and-concatenate.
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
route = respx.post(self._TTS).mock(
|
||||
return_value=httpx.Response(200, content=self._WAV)
|
||||
)
|
||||
app = create_app(_mock_client_factory(), tts_url=self._TTS)
|
||||
text = " ".join(f"Sentence number {i} about the dungeon." for i in range(200))
|
||||
resp = TestClient(app).post("/api/tts", json={"text": text})
|
||||
assert resp.status_code == 200
|
||||
assert len(route.calls) == 1 # one call, not chunk-and-concatenate
|
||||
body = json.loads(route.calls.last.request.content)
|
||||
assert body["input"] == text # full text in one call, unsplit
|
||||
|
||||
@respx.mock
|
||||
def test_non_str_agent_id_degrades_no_500(self) -> None:
|
||||
# An unhashable agent_id (list/dict) is guarded by resolve_voice_spans → default voice.
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
route = respx.post(self._TTS).mock(
|
||||
return_value=httpx.Response(200, content=self._WAV)
|
||||
)
|
||||
app = create_app(_mock_client_factory(), tts_url=self._TTS)
|
||||
resp = TestClient(app).post("/api/tts", json={"text": "hi", "agent_id": ["donut"]})
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(route.calls.last.request.content)
|
||||
assert body["voice"] == "glados" # non-str agent_id → default voice, not a 500
|
||||
|
||||
def test_whitespace_text_returns_400(self) -> None:
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
app = create_app(_mock_client_factory(), tts_url=self._TTS)
|
||||
resp = TestClient(app).post("/api/tts", json={"text": " \n\t "})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error_code"] == "missing_text"
|
||||
|
||||
def test_lock_released_when_peek_raises_unexpectedly(self, monkeypatch) -> None:
|
||||
# An unmapped error during the first-byte peek (e.g. httpx.InvalidURL, or a
|
||||
# browser abort/CancelledError before byte 1) must still release the process-wide
|
||||
# serialize lock, or every later /api/tts deadlocks at lock.acquire() (DEC-5).
|
||||
from ratatoskr.web import server as _server
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
async def _boom(*a, **k):
|
||||
raise RuntimeError("boom during peek")
|
||||
yield b"" # unreachable — marks this an async generator
|
||||
|
||||
monkeypatch.setattr(_server, "tts_stream_stitched", _boom)
|
||||
app = create_app(_mock_client_factory(), tts_url=self._TTS)
|
||||
with pytest.raises(RuntimeError):
|
||||
TestClient(app).post("/api/tts", json={"text": "hi"})
|
||||
assert app.state.tts_lock.locked() is False
|
||||
|
||||
@respx.mock
|
||||
def test_lone_surrogate_text_scrubbed_no_500(self) -> None:
|
||||
# A lone surrogate rides JSON (\udXXX) but would UnicodeEncodeError when httpx
|
||||
# serializes the gateway body → a 500. It's scrubbed at the text guard instead.
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
route = respx.post(self._TTS).mock(
|
||||
return_value=httpx.Response(200, content=self._WAV)
|
||||
)
|
||||
app = create_app(_mock_client_factory(), tts_url=self._TTS)
|
||||
# Send the surrogate as an escaped JSON literal — TestClient's json= can't encode it.
|
||||
resp = TestClient(app).post(
|
||||
"/api/tts", content='{"text": "\\ud800hello"}',
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(route.calls.last.request.content)
|
||||
assert body["input"] == "hello" # surrogate dropped, rest intact — not a 500
|
||||
|
||||
@respx.mock
|
||||
def test_empty_200_body_rejected_503(self) -> None:
|
||||
# A gateway that returns 200 with a 0-byte body (a stalled/failed generation) is a
|
||||
# synthesis failure, not silent audio — surface it as 503 (INV-TTS-4) rather than
|
||||
# committing an empty audio/wav stream. Backend-agnostic hygiene (infra-ops-endorsed).
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
respx.post(self._TTS).mock(return_value=httpx.Response(200, content=b""))
|
||||
app = create_app(_mock_client_factory(), tts_url=self._TTS)
|
||||
resp = TestClient(app).post("/api/tts", json={"text": "a very long turn"})
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error_code"] == "tts_unavailable"
|
||||
|
||||
@respx.mock
|
||||
def test_non_wav_200_body_rejected_503(self) -> None:
|
||||
# A 200 whose first bytes are not a RIFF header (a proxy error page) must not be
|
||||
# mislabeled audio/wav — the peek RIFF-sniff rejects it as 503.
|
||||
from ratatoskr.web.server import create_app
|
||||
|
||||
respx.post(self._TTS).mock(
|
||||
return_value=httpx.Response(200, content=b"<html>error: data unavailable</html>")
|
||||
)
|
||||
app = create_app(_mock_client_factory(), tts_url=self._TTS)
|
||||
resp = TestClient(app).post("/api/tts", json={"text": "hi"})
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error_code"] == "tts_unavailable"
|
||||
|
||||
+30
-1
@@ -47,6 +47,7 @@ from ratatoskr.sse_client import (
|
||||
MalformedSseId,
|
||||
SseConnectFailed,
|
||||
SseConnectionDropped,
|
||||
SseResponseTooLarge,
|
||||
TurnIdFlip,
|
||||
TurnLaunchUnavailable,
|
||||
)
|
||||
@@ -360,7 +361,15 @@ class TestStreamTurn:
|
||||
fake = _FakeSessions(events=[e1, e2])
|
||||
got = await _drain(stream_turn(_wt(fake), "s-1", "hello"))
|
||||
assert got == [e1, e2]
|
||||
assert fake.calls[-1] == ("stream_turn", ("s-1", "hello"), {})
|
||||
# memory_context defaults to None (forwarded verbatim; inert for every caller
|
||||
# that doesn't pin corpus context via the KB-recall bridge).
|
||||
assert fake.calls[-1] == ("stream_turn", ("s-1", "hello"), {"memory_context": None})
|
||||
|
||||
async def test_forwards_memory_context_verbatim(self) -> None:
|
||||
mc = [{"kind": "corpus_reference", "text": "the pinned passage"}]
|
||||
fake = _FakeSessions(events=[])
|
||||
await _drain(stream_turn(_wt(fake), "s-1", "hi", memory_context=mc))
|
||||
assert fake.calls[-1] == ("stream_turn", ("s-1", "hi"), {"memory_context": mc})
|
||||
|
||||
async def test_session_retired_maps_to_session_api_failed(self) -> None:
|
||||
fake = _FakeSessions(
|
||||
@@ -423,6 +432,16 @@ class TestStreamTurn:
|
||||
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
||||
assert (ei.value.established, ei.value.got) == (5, 7)
|
||||
|
||||
async def test_response_too_large_maps_to_sse_response_too_large(self) -> None:
|
||||
# 1.2.0: an oversized SSE frame (ProtocolError, NOT ConnectionDropped) → a terminal
|
||||
# SseResponseTooLarge, never a resumable drop. Carries limit_bytes + scope.
|
||||
fake = _FakeSessions(
|
||||
stream_error=wtsdk.ResponseTooLarge(limit_bytes=540000, scope="sse_frame")
|
||||
)
|
||||
with pytest.raises(SseResponseTooLarge) as ei:
|
||||
await _drain(stream_turn(_wt(fake), "s", "hi"))
|
||||
assert ei.value.limit_bytes == 540000 and ei.value.scope == "sse_frame"
|
||||
|
||||
async def test_undiscriminated_api_error_maps_to_session_api_failed(self) -> None:
|
||||
# INV-CUT-2 default: an undiscriminated ApiError surfacing from the stream
|
||||
# (not a discriminated stream error) → SessionApiFailed.
|
||||
@@ -1235,6 +1254,16 @@ class TestStreamAdminEventsWt:
|
||||
await _drain(stream_admin_events(_wtad(fake)))
|
||||
assert ei.value.last_seen_sse_id == "42"
|
||||
|
||||
async def test_response_too_large_maps_to_sse_response_too_large(self) -> None:
|
||||
# 1.2.0: an oversized admin SSE frame (ProtocolError) → terminal SseResponseTooLarge,
|
||||
# mirroring stream_turn (never a resumable drop).
|
||||
fake = _FakeAdmin(
|
||||
stream_error=wtsdk.ResponseTooLarge(limit_bytes=540000, scope="sse_frame")
|
||||
)
|
||||
with pytest.raises(SseResponseTooLarge) as ei:
|
||||
await _drain(stream_admin_events(_wtad(fake)))
|
||||
assert ei.value.limit_bytes == 540000
|
||||
|
||||
async def test_connection_dropped_none_cursor_connect_time(self) -> None:
|
||||
# A connect-time transport failure surfaces as ConnectionDropped(None) →
|
||||
# SseConnectionDropped(last_seen_sse_id=None) (the map's other cursor shape;
|
||||
|
||||
@@ -463,7 +463,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ratatoskr"
|
||||
version = "0.22.1"
|
||||
version = "0.22.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -509,7 +509,7 @@ requires-dist = [
|
||||
{ name = "sqlite-vec", marker = "extra == 'provider'", specifier = ">=0.1.6" },
|
||||
{ name = "starlette", marker = "extra == 'web'", specifier = ">=0.40" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.30" },
|
||||
{ name = "worldtree-sdk", specifier = "==1.0.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
|
||||
{ name = "worldtree-sdk", specifier = "==1.2.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
|
||||
]
|
||||
provides-extras = ["web", "provider", "dev"]
|
||||
|
||||
@@ -897,12 +897,12 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "worldtree-sdk"
|
||||
version = "1.0.0"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
]
|
||||
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/worldtree-sdk/1.0.0/worldtree_sdk-1.0.0.tar.gz", hash = "sha256:0b87001fd90b304befe5e76d21a41dd093057a9d59f86a25189fab80defcd043" }
|
||||
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/worldtree-sdk/1.2.0/worldtree_sdk-1.2.0.tar.gz", hash = "sha256:48b1a9f44875069aade079f999c72c8df4cc7c6abf2e49fb9ad52a067594bd19" }
|
||||
wheels = [
|
||||
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/worldtree-sdk/1.0.0/worldtree_sdk-1.0.0-py3-none-any.whl", hash = "sha256:ec9cb03ecdb4662bf1280a64316ee4545bb1330db42e3135f34f56e62c07f6c5" },
|
||||
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/worldtree-sdk/1.2.0/worldtree_sdk-1.2.0-py3-none-any.whl", hash = "sha256:3516841dad7adf7a7bef5a492d2c9fe0b3e7abc3c453ee573ceddf20f7e6823a" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user