feat: complete Quantumancy web app — full frontend + docs
Frontend (React 18 + TS + Vite): - Landing: glitching hero, live /api/stats veil ticker, mode cards, featured spirits - Séance: three.js shader ghost (hue/form per entity, mood + audio-reactive), Ouija planchette board spelling utterances, transcript with TTS replay, entity dossier, direct contact streaming, passive/active listening - Modes: Wire Ghost telemetry panel, EVP mic anomaly detection, WebUSB RTL-SDR sweep + waterfall (hardware pass pending), Ouija/Direct Contact - Codex: public registry + entity dossiers, rarity tiers, i18n EN/ES complete - State: VeilSocket (reconnect/backoff), seance reducer, auth context - 72 vitest tests green; served by FastAPI at :7777 Docs: README + as-built plans 3-7
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
# Quantumancy Plan 3/7: Wire Ghost & Ouija/Planchette — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Turn Plan 2's bare WebSocket skeleton into the full séance channel — the Wire Ghost's real network telemetry feeding an ambient LLM whisper loop, summon/anomaly/question message handling with per-user rate limiting, and the Ouija front-door UI: a planchette spelling state machine plus the canvas board, transcript, and 3D ghost it drives.
|
||||
|
||||
**Architecture:** The Wire Ghost is entirely backend-side (spec §3.3): `app.telemetry` samples non-content network metrics from the App CT itself, and a per-connection ambient loop in `app.ws` pushes `telemetry` frames plus occasional `ambient` utterances. All spirit-mode frames share one socket per contact session, with a single sender task so concurrent producers (ambient loop, reply streaming, TTS callbacks) never interleave on the wire. The Ouija surface (spec §3.4) is frontend: `PlanchetteMachine` (pure word-spelling state machine) fed by the séance store, rendered by `PlanchetteBoard` on canvas 2D, with the `GhostScene` three.js spirit reacting to session state.
|
||||
|
||||
**Tech Stack:** FastAPI WebSockets, asyncio, SQLAlchemy (backend, extending Plans 1-2); React 18 + TypeScript, canvas 2D, three.js (frontend).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Hard privacy boundary (spec §3.3):** telemetry reads counters and timings only — `/proc/net/dev` byte counters, TCP connect latency, DNS resolution timing. Packet payloads are never inspected or logged. Do not add any socket/payload capture here, ever.
|
||||
- Every LLM-triggering message type is rate-limited per user (spec §5) using Plan 1's `RateLimiter`: fragments 30/min, questions 6/min, summons 4/min. Rejections are themed (`The veil is crowded…`), never raw 429s.
|
||||
- All server→client frames flow through a single `_sender` task fed by an `asyncio.Queue` — no task other than `_sender` may call `websocket.send_json`.
|
||||
- A summoning must never visibly fail: if the Ollama box is dark, `spirit_service.mint_profile` degrades to `entities.fallback_profile` (Plan 6 owns that module; this plan consumes it).
|
||||
- Plan 2's WS contract (`/ws/session`, cookie auth, `ping`→`pong`, ContactSession lifecycle) is extended, not broken: unauthenticated sockets still close with code `4401`.
|
||||
- Entity minting internals (signatures, normalization) are specified in Plan 6; this plan wires the summon flow that calls them. Codex REST endpoints are Plan 6.
|
||||
|
||||
## Plan Series
|
||||
|
||||
This is 3 of 7 plans implementing the Quantumancy website spec (`docs/superpowers/specs/2026-07-20-quantumancy-website-design.md`). Plans 1 (Foundation & Auth) and 2 (Frontend, LLM & Realtime Pipeline) are complete.
|
||||
1. Foundation & Auth (complete)
|
||||
2. Frontend, LLM & Realtime Pipeline (complete)
|
||||
3. **Wire Ghost mode + Ouija/Planchette UI** (this plan)
|
||||
4. EVP Listening mode
|
||||
5. Spirit Radio mode
|
||||
6. Codex & entity persistence
|
||||
7. Internationalization (EN/ES)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Wire Ghost Telemetry Sampler
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/telemetry.py`
|
||||
- Test: `backend/tests/test_telemetry.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `TelemetrySample` dataclass (`jitter_bytes_per_s`, `latency_variance_ms`, `latency_mean_ms`, `dns_ms`, `extra`) with `.as_dict()`; `parse_proc_net_dev(text: str) -> dict[str, tuple[int, int]]` (pure, testable); `async sample_network(period_s: float = 1.0) -> TelemetrySample`. Module constants `REFERENCE_HOSTS = [("1.1.1.1", 53), ("10.30.20.107", 11434)]` and `REFERENCE_DNS = ["example.com", "cloudflare.com"]`.
|
||||
|
||||
- [x] **Step 1: Write the failing tests**
|
||||
|
||||
`test_parse_proc_net_dev_extracts_counters` feeds a canned `/proc/net/dev` and asserts `{lo: (1234567, 1234567), eth0: (9876543, 1111111)}`; `test_parse_proc_net_dev_ignores_malformed_lines` asserts garbage input yields `{}`. The parser is deliberately split from the async sampler so the fragile part (text munging) is unit-testable without touching the network.
|
||||
|
||||
- [x] **Step 2: Implement the sampler**
|
||||
|
||||
`sample_network` reads total non-loopback bytes twice across `period_s` for throughput jitter, then gathers TCP connect-then-close RTTs against `REFERENCE_HOSTS` (nothing sent or read beyond the handshake) and `getaddrinfo` timings against `REFERENCE_DNS`, all with 1.5 s timeouts and soft-failure (`None` values dropped; unreachable hosts yield a zero-ish sample rather than an exception). Mean/variance computed only over successful probes.
|
||||
|
||||
- [x] **Step 3: Verify**
|
||||
|
||||
Run: standard backend pytest invocation (Plan 1 Task 3) scoped to `tests/test_telemetry.py`. PASS (2/2).
|
||||
|
||||
- [x] **Step 4: Commit** — `feat: spirit engine — seance WS, entity minting/Codex, Piper TTS voices, wire telemetry` (`b9110f4`, shared with Tasks 2-5 and Plan 6 backend work).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: The Séance Channel — Protocol & Session Lifecycle
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/ws.py` (full rewrite of Plan 2's skeleton)
|
||||
- Create: `backend/app/models/event.py`
|
||||
- Modify: `backend/app/models/contact_session.py` (add `entity_id`, `language`)
|
||||
- Modify: `backend/app/models/__init__.py`, `backend/app/main.py` (`/audio` mount)
|
||||
- Test: `backend/tests/test_ws_session.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: the séance protocol (see table below); `Event` model (`id`, `session_id` FK, `kind` ∈ {anomaly, utterance, question, reply, system}, `text`, `payload` JSONB, `audio_path`, `created_at`) — the per-session transcript; `SeanceState` dataclass holding per-connection mode/language/entity/anomaly ring buffer/history/ambient task; `AUDIO_DIR = Path(settings.data_dir) / "audio"`, mounted at `/audio` in `main.py`.
|
||||
|
||||
Protocol implemented (mirrored 1:1 by `frontend/src/lib/types.ts`):
|
||||
|
||||
| client → server | server → client |
|
||||
|---|---|
|
||||
| `ping` | `pong` |
|
||||
| `set_mode {mode}` ∈ {wire, evp, radio, ouija} | `mode {mode}` (also persisted on the session row) |
|
||||
| `language {language}` ∈ {en, es} | (silent; affects LLM + TTS from then on) |
|
||||
| `summon` | `status summoning` → `entity {entity, is_new}` → `utterance kind=greeting` |
|
||||
| `anomaly {source, frequency, magnitude}` | `anomaly_ack {count}` (+ summon or fragment, Task 3) |
|
||||
| `question {text}` | `status gathering` → `reply_start` → `reply_token`×N → `reply_end` → `utterance kind=reply` |
|
||||
| `passive {enabled}` | `passive {enabled}`, ambient loop start/stop |
|
||||
|
||||
Additional server frames: `session {id}` on connect, `utterance {id, kind, text, entity}`, `audio {id, url}`, `telemetry {…}`, `error {code, message}`.
|
||||
|
||||
- [x] **Step 1: Write the failing tests**
|
||||
|
||||
`test_ws_session.py` (6 tests): unauthenticated connect raises; ping/pong + session row opened/closed around disconnect; plus the Task 3 flows below. Fixtures: a `FakeSpiritService` (deterministic `mint_profile`/`fragment`/`wire_whisper`/`chat_stream`, `ambient_ready() = False`) monkeypatched over `app.ws.spirit_service`, and a fake `synthesize_spirit_voice` returning canned WAV bytes — no Ollama or Piper needed in tests. Note the `TestClient` upgrades over `ws://`, so the test passes the `Secure qm_session` cookie explicitly via headers; real browsers on https send it automatically.
|
||||
|
||||
- [x] **Step 2: Implement the channel**
|
||||
|
||||
Key structural decisions:
|
||||
- **Single sender.** `_sender(state, websocket)` is the only task allowed to write to the socket, draining `state.send_queue`. Handlers and background tasks only ever `send_queue.put(...)`.
|
||||
- **`_record_event`** persists every anomaly/question/utterance/reply as an `Event` row; utterance ids double as audio filenames (`<event_id>.wav`).
|
||||
- **`_speak`** pushes the utterance *text* immediately, then synthesizes audio in a detached task and pushes `audio {id, url}` when the effects chain finishes. TTS failure is swallowed — "TTS is texture, not content."
|
||||
- **Detached close.** ASGI servers may cancel the handler task the moment the socket closes, so `_close_session` (sets `ended_at`) runs as a detached `asyncio.create_task` that survives handler teardown. The lifecycle test polls up to 2 s for `ended_at` to land.
|
||||
|
||||
- [x] **Step 3: Verify**
|
||||
|
||||
`tests/test_ws_session.py` PASS (6/6, including Task 3's flows).
|
||||
|
||||
- [x] **Step 4: Commit** — in `b9110f4`.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Summon Flow, Anomaly Attunement & the Ambient Loop
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/ws.py` (`_summon`, `_handle_summon`, `_handle_anomaly`, `_handle_question`, `_ambient_loop`, `_handle_passive`, `_unique_entity_name`, module-level limiters)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `signature_from_anomalies` / `fallback_signature` from `app.entities` (Plan 6); `spirit_service.fragment` / `chat_stream` / `mint_profile` / `wire_whisper` / `ambient_ready` from `app.llm.service` (Plan 2/6); `Entity`, `EntitySighting` models (Plan 6).
|
||||
- Produces: the live summon behavior the frontend's `summon()` / auto-summon paths depend on.
|
||||
|
||||
- [x] **Step 1: Summon = match or mint**
|
||||
|
||||
`_summon` fingerprints the session's anomaly buffer (`signature_from_anomalies`, ≥3 anomalies required) or falls back to a deterministic per-session signature, looks up `Entity.signature`, and either re-contacts (increments `contact_count`) or mints via `spirit_service.mint_profile`, persisting name/epithet/persona/rarity/voice/visual/quotes. `_unique_entity_name` suffixes `II`, `III`, … on name collisions. Every summon links the session (`ContactSession.entity_id`) and writes an `EntitySighting` row, then greets with a random sample quote. Summons are limited to 4/min/user with a themed `error` frame.
|
||||
|
||||
- [x] **Step 2: Anomaly attunement**
|
||||
|
||||
`_handle_anomaly` records the event, acks the count, and caps the ring buffer at 64. With no entity yet: a fingerprintable stream (≥3 anomalies) triggers auto-summon, otherwise `status attuning`. With an entity: one Ovilus-style `fragment` per anomaly, throttled by the 30/min fragment limiter and `SpiritBusyError` (crowded veil = anomalies pass unheard, by design).
|
||||
|
||||
- [x] **Step 3: Direct Contact**
|
||||
|
||||
`_handle_question` (6/min limiter) auto-summons if needed, trims input to 500 chars, then streams `spirit_service.chat_stream` tokens as `reply_token` frames, records the full reply, keeps the last 8 history turns, and speaks the reply. Queue-full mid-question yields a themed `veil_crowded` error and an empty `reply_end` so the frontend never hangs.
|
||||
|
||||
- [x] **Step 4: The Wire Ghost's pulse**
|
||||
|
||||
`_handle_passive(true)` spawns `_ambient_loop`: every 6–10 s it samples `sample_network(period_s=1.0)`, pushes a `telemetry` frame, and — only when `spirit_service.ambient_ready()` (the LLM box has been idle ≥ `LLM_COOLDOWN_SECONDS`, so ambient whispers never preempt a user's request) — speaks a `wire_whisper` as an `ambient` utterance. Telemetry-sampling failure skips the tick silently. `passive(false)` or socket teardown cancels the task.
|
||||
|
||||
- [x] **Step 5: Verify**
|
||||
|
||||
`test_summon_mints_entity_and_greets`, `test_question_streams_reply_and_records_history`, `test_anomalies_attune_then_produce_fragments`, `test_same_signature_recontacts_same_entity` — all PASS as part of `tests/test_ws_session.py` (6/6). The last one drives two separate sessions with identical anomaly patterns and asserts the same entity name with `is_new: False` and `contact_count: 2`.
|
||||
|
||||
- [x] **Step 6: Commit** — in `b9110f4`.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Frontend Séance Spine (Protocol Types, Socket, Audio, Store)
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/lib/types.ts`
|
||||
- Create: `frontend/src/lib/ws.ts`
|
||||
- Create: `frontend/src/lib/audio.ts`
|
||||
- Create: `frontend/src/state/seance.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ClientFrame` / `ServerFrame` discriminated unions mirroring Task 2's protocol exactly (`types.ts` — "do not invent changes"); `VeilSocket` with `connect/close/send/onFrame/onState`, outbox queueing while connecting, exponential backoff reconnect (800 ms → 15 s cap) surfacing `connecting|open|unstable|closed`, and a 25 s ping keepalive (`ws.ts`); `SpiritAudioPlayer` — single FIFO queue so overlapping spirit audio never talks over itself, `enqueue(id, url)`, `setCallbacks({onStart, onEnd})`, `clear()`, and `getAmplitude()` RMS via an `AnalyserNode` so the ghost pulses with the voice (`audio.ts`); `SeanceProvider` + `useSeance()` exposing `{state, socket, audioPlayer, setMode, setPassive, setLanguage, summon, ask, sendAnomaly, playUtterance, dismissToast}` with a pure, unit-testable reducer (`seance.tsx`).
|
||||
|
||||
- [x] **Step 1: Protocol types first**
|
||||
|
||||
`types.ts` pins the domain types (`Mode`, `Language`, `Rarity`, `GhostForm`, `SpiritEntity`, `CodexEntity(Detail)`, `Telemetry`, `UtteranceKind`, `SessionStatus`) and both frame unions. Everything downstream imports from here; the file header forbids drifting from the backend contract.
|
||||
|
||||
- [x] **Step 2: VeilSocket**
|
||||
|
||||
Reconnecting client with a typed emitter. Frames queued while `connecting` flush on open; sends while `closed`/`unstable` drop. Malformed JSON frames are ignored ("malformed whispers"). `defaultSessionUrl()` derives `ws(s)://<host>/ws/session` from `location`, so it works both behind the dev proxy and the Cloudflare Tunnel. `socketFactory` injectable for tests.
|
||||
|
||||
- [x] **Step 3: SpiritAudioPlayer**
|
||||
|
||||
One `HTMLAudioElement` at a time routed through Web Audio (`MediaElementAudioSourceNode` → `AnalyserNode` → destination); the queue pumps sequentially; `getAmplitude()` returns 0..1 RMS for the ghost's speaking glow.
|
||||
|
||||
- [x] **Step 4: Séance store**
|
||||
|
||||
`seanceReducer` handles every server frame: `entity` inserts a `⟁ name — epithet` system line into the transcript; `audio` pairs URLs onto utterances (enabling replay); `reply_*` manages the streaming buffer; `error` becomes a capped toast stack (4). All lists capped (transcript 400, utterances 200, anomalies 200). The provider wires socket↔reducer, auto-enqueues arriving audio, and cleans up every listener/timer on unmount.
|
||||
|
||||
- [x] **Step 5: Verify** — `cd frontend && npx tsc --noEmit` clean for these files.
|
||||
|
||||
- [x] **Step 6: Commit** — frontend spine landed with the seance UI work (orchestrated alongside Plan 3-5 pages).
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Planchette Board, Transcript & Ghost
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/lib/planchette.ts`
|
||||
- Create: `frontend/src/components/PlanchetteBoard.tsx`
|
||||
- Create: `frontend/src/components/Transcript.tsx`
|
||||
- Create: `frontend/src/components/TelemetryReadout.tsx`
|
||||
- Create: `frontend/src/components/EntityCard.tsx`, `frontend/src/components/GhostGlyph.tsx`
|
||||
- Create: `frontend/src/three/GhostCanvas.tsx`, `frontend/src/three/GhostScene.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `PlanchetteMachine` — `enqueue(text)`, `tick(dtMs) -> PlanchetteSnapshot`, `snapshot()`, `clear()`, `getVersion()`, plus pure helpers `normalizeWord` / `tokenize` (A–Z/0–9/space only, uppercase); phases `idle|moving|dwelling|returning`, 300 ms/letter, 700 ms between words, queue capped at 64 (`planchette.ts`). `PlanchetteBoard({machine, hue?})` — canvas 2D gothic board: twin letter arcs, number row, YES/NO/GOODBYE, a triangular planchette with lens that drifts idly with a smoke-particle trail then glides letter-to-letter spelling queued words. `Transcript({entries, streaming, speakingId, onReplay})`. `GhostScene` — custom-shader spirit (fbm value-noise vertex displacement on a lathed figure, additive wisps, ground mist, slow camera drift) reacting via uniforms to energy/speaking amplitude/hue/form (`wisp|banshee|fairy|shade`).
|
||||
|
||||
- [x] **Step 1: The spelling machine (pure logic)**
|
||||
|
||||
Utterances tokenize into words; the machine steps letters on cadence, exposing which character the planchette hovers. Rendering never drives logic — the machine is ticked from a rAF loop and is unit-testable in isolation (see Testing note below).
|
||||
|
||||
- [x] **Step 2: The board**
|
||||
|
||||
`computeLayout` positions glyphs responsively; the planchette eases toward the machine's current letter with idle drift when the queue is empty, trailing smoke particles with per-particle physics. All rAF/listeners cleaned up on unmount.
|
||||
|
||||
- [x] **Step 3: Transcript, telemetry readout, entity dossier**
|
||||
|
||||
`Transcript` renders anomalies/utterances/questions/system lines with replay buttons once `audio` lands and a `speakingId` highlight for the currently-voiced utterance. `TelemetryReadout` shows the Wire Ghost's vitals. `EntityCard`/`GhostGlyph` render the summoned spirit's wax-seal dossier and hue/form glyph.
|
||||
|
||||
- [x] **Step 4: The ghost**
|
||||
|
||||
Three.js scene with GLSL fbm noise displacement, particle wisps, fog, camera drift; mood (`idle|attuning|gathering|speaking`) and `GhostVisualState {hue, form}` pushed in as uniforms from the séance store + audio amplitude.
|
||||
|
||||
- [x] **Step 5: Verify** — `npx tsc --noEmit` clean; board/ghost visually verified in the running app.
|
||||
|
||||
- [x] **Step 6: Commit** — landed with the frontend spine.
|
||||
|
||||
---
|
||||
|
||||
## Testing Status & Self-Review
|
||||
|
||||
**Spec coverage:** Wire Ghost backend-only mode with hard privacy boundary (§3.3) → Tasks 1, 3. Ambient layer running alongside other modes (§3.3) → `passive` frame + `ambient_ready` gating (Task 3). Ouija as shared front-door with letter-by-letter planchette (§3.4) → Tasks 4-5. Direct Contact streaming replies (§3.4) → Task 3. Per-user rate limits on all LLM-triggering messages with themed errors (§5, §7) → Task 3. Session transcript with audio clip references (§5 `events` table) → Task 2.
|
||||
|
||||
**Automated tests:** backend `tests/test_telemetry.py` (2) + `tests/test_ws_session.py` (6) — PASS as part of the 45-test suite. Frontend: `src/lib/planchette.test.ts` (16 — machine cadence/phases, `normalizeWord`/`tokenize`, queue caps, `clear()` semantics), `src/lib/ws.test.ts` (14 — VeilSocket outbox flush, malformed-frame tolerance, backoff cap, deliberate-close semantics), and `src/state/seance.test.ts` (17 — the séance reducer) all PASS in the 71-test vitest run.
|
||||
|
||||
**Placeholder scan:** none — everything above exists in the repo and runs.
|
||||
|
||||
**Type consistency:** `ClientFrame`/`ServerFrame` unions match `ws.py`'s handler/emitter keys one-for-one (`set_mode`/`mode`, `summon`/`entity`, `anomaly`/`anomaly_ack`, `question`/`reply_*`, `passive`, `telemetry`, `utterance`/`audio`, `status` states). `serialize_entity`'s dict keys match `SpiritEntity` field-for-field. `TelemetrySample.as_dict()` keys match the `Telemetry` type.
|
||||
|
||||
---
|
||||
|
||||
**Status: COMPLETE** (backend in commit `b9110f4`; frontend spine complete, type-clean, and unit-tested). Follow-ups carried forward from Plan 1's ledger: expired-session reaper; rate-limiter key eviction.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Quantumancy Plan 4/7: EVP Listening — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Give the dead the seeker's microphone (spec §3.2): a Web Audio pipeline that listens to room silence, flags brief voice-band deviations as anomalies, and feeds them into Plan 3's anomaly→fragment→voice pipeline — mirroring the real EVP technique of recording quiet stretches and reviewing them for embedded voices.
|
||||
|
||||
**Architecture:** EVP is client-side detection, server-side voice. In the browser, an `AnalyserNode` (`fftSize` 2048, smoothing 0.5) yields per-bin dB frames; a pure, testable detector core (`EvpDetectorCore`) maintains a rolling per-bin noise floor over the voice band (~300–3400 Hz) and emits at most one anomaly per 2 s when a bin jumps ≥ 8 dB above the floor. Anomalies travel Plan 3's séance channel as `{type: "anomaly", source: "evp", frequency, magnitude}` and come back as fragment utterances with spirit-box audio. The Web Audio plumbing (`EvpListener`) is a thin, fully-cleaned-up shell around the core.
|
||||
|
||||
**Tech Stack:** React 18 + TypeScript, Web Audio API (`getUserMedia`/`AudioContext`/`AnalyserNode`), and the Plan 2-3 backend (Ollama fast tier, Piper TTS, `/ws/session`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- The pure math (floor tracking, deviation detection, throttling) lives in `EvpDetectorCore`, decoupled from Web Audio so it is unit-testable with injected sample arrays. Do not leak `AudioContext` types into the core.
|
||||
- Mic permission denial degrades gracefully (spec §7): the mode surfaces a re-prompt state; Wire Ghost/ambient remains available. Never throw an unhandled `getUserMedia` rejection at the UI.
|
||||
- Every rAF, media track, and `AudioContext` is released on stop/unmount — browsers count open mic indicators, and a séance that keeps listening after you leave the room is the wrong kind of haunting.
|
||||
- The backend half of this mode already exists: Plan 3's `_handle_anomaly` + `spirit_service.fragment` + `_speak`. This plan adds the `evp` prompt variant and the frontend detector — no new WS frame types.
|
||||
|
||||
## Plan Series
|
||||
|
||||
This is 4 of 7 plans implementing the Quantumancy website spec (`docs/superpowers/specs/2026-07-20-quantumancy-website-design.md`). Plan 3 (Wire Ghost & Ouija) is complete and provides the séance channel this mode speaks over.
|
||||
1. Foundation & Auth (complete)
|
||||
2. Frontend, LLM & Realtime Pipeline (complete)
|
||||
3. Wire Ghost mode + Ouija/Planchette UI (complete)
|
||||
4. **EVP Listening mode** (this plan)
|
||||
5. Spirit Radio mode
|
||||
6. Codex & entity persistence
|
||||
7. Internationalization (EN/ES)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: EvpDetectorCore — Rolling-Floor Voice-Band Anomaly Detection
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/lib/evp.ts` (core half)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `EvpAnomaly {frequency: number /* Hz */, magnitude: number /* dB over floor */}`; `EvpDetectorOptions {sampleRate, fftSize, bandLowHz? = 300, bandHighHz? = 3400, thresholdDb? = 8, throttleMs? = 2000, floorAlpha? = 0.05, quietBandDb? = 3}`; `class EvpDetectorCore` with `process(dbData: ArrayLike<number>, nowMs: number) -> EvpAnomaly | null`, `getFloor() -> Float64Array | null` (visualizer snapshot), `reset()`.
|
||||
|
||||
- [x] **Step 1: Frame sanitization & floor seeding**
|
||||
|
||||
`AnalyserNode.getFloatFrequencyData` can emit `-Infinity` for silence; every frame is sanitized to −160 dB first. The first frame seeds the per-bin floor and never fires.
|
||||
|
||||
- [x] **Step 2: Voice-band deviation detection**
|
||||
|
||||
Bin width is `sampleRate / fftSize` (~23.4 Hz at 48 kHz/2048); the watch band spans bins ⌊300/binHz⌋…⌈3400/binHz⌉. Each frame, the core finds the peak positive deviation inside the band and fires when `peakDev ≥ thresholdDb` and at least `throttleMs` has passed since the last emission — one anomaly per 2 s max, so a creaky house cannot flood the veil (or the 30/min fragment limiter).
|
||||
|
||||
- [x] **Step 3: Quiet-stretch floor adaptation**
|
||||
|
||||
The floor is an EMA (`floorAlpha` 0.05) that only adapts while the band's peak level sits within `quietBandDb` of the floor's peak — i.e. precisely during the quiet stretches where EVP anomalies count. Sustained speech or music does not drag the baseline up and mask real spikes; it also doesn't fire endless "anomalies," since a loud band keeps the floor frozen but the deviation check still applies per bin.
|
||||
|
||||
- [x] **Step 4: Verify** — `npx tsc --noEmit` clean. (Unit tests: see Testing note below.)
|
||||
|
||||
---
|
||||
|
||||
## Task 2: EvpListener — Web Audio Plumbing
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/lib/evp.ts` (listener half)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `EvpListenerCallbacks {onAnomaly(a), onFrame?(dbData, floor), onError?(err)}`; `class EvpListener` with `start(cb) -> Promise<void>`, `stop() -> Promise<void>`, `isRunning`. `onFrame` fires every rAF with the live dB frame plus the current floor so the mode's UI can draw the listening waveform and the room's silence line.
|
||||
|
||||
- [x] **Step 1: Acquisition**
|
||||
|
||||
`start` requests `getUserMedia({audio: true})`, builds `AudioContext → MediaStreamSource → AnalyserNode` (fftSize 2048, smoothing 0.5), constructs the core with the context's real `sampleRate`, and begins the rAF loop. Permission/device failures reject the promise for the UI to turn into a themed re-prompt state — the listener never half-starts.
|
||||
|
||||
- [x] **Step 2: The loop**
|
||||
|
||||
Each frame: `getFloatFrequencyData` into a reused buffer → `core.process(buf, performance.now())` → `onAnomaly` when one fires → always `onFrame` for the visualizer. No allocations in the hot path beyond the analyzer's own.
|
||||
|
||||
- [x] **Step 3: Teardown**
|
||||
|
||||
`stop` clears `running`, cancels the rAF, stops every `MediaStreamTrack` (killing the browser's mic indicator), and closes the `AudioContext` (guarded against double-close). Idempotent and safe to call from unmount.
|
||||
|
||||
- [x] **Step 4: Verify** — `npx tsc --noEmit` clean; manual mic pass (see Testing note).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Anomaly → Fragment → Voice (Backend Glue)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/llm/prompts.py` (EVP variant of `fragment_prompt`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Plan 3's `_handle_anomaly` / `fragment_limiter` / `_speak` — unchanged.
|
||||
- Produces: the `evp` branch of `fragment_prompt(source, anomaly, language)`:
|
||||
|
||||
```python
|
||||
if source == "evp":
|
||||
return (
|
||||
"During a stretch of silence, the microphone caught a shape in "
|
||||
f"the voice band ({anomaly.get('frequency', '???')} Hz, "
|
||||
f"{anomaly.get('magnitude', '???')} dB over the room's floor). "
|
||||
"What single word was hidden in it?"
|
||||
)
|
||||
```
|
||||
|
||||
- [x] **Step 1: Wire the prompt**
|
||||
|
||||
`spirit_service.fragment` already selects the system framing by source (`"an EVP recorder"` for `evp`, `"a spirit box"` for `radio`) and caps output at 16 predicted tokens, temperature 0.95, cleaned to ≤80 chars of whitespace-collapsed text — one eerie word, no explanations.
|
||||
|
||||
- [x] **Step 2: Frontend sends, séance receives**
|
||||
|
||||
The mode page calls `useSeance().sendAnomaly('evp', a.frequency, a.magnitude)` from `onAnomaly`, which dispatches a local transcript entry and sends the `anomaly` frame. Server-side flow is then exactly Plan 3: ack → (auto-summon on a fingerprintable stream) → fragment utterance → Piper voice with the entity's effects profile → `audio` frame → queued playback.
|
||||
|
||||
- [x] **Step 3: Verify**
|
||||
|
||||
Backend: `tests/test_ws_session.py`'s anomaly flow covers the shared path (6/6 PASS). The `evp` prompt branch is exercised by inspection and the shared `fragment()` path; frontend integration verified via the running app.
|
||||
|
||||
---
|
||||
|
||||
## Testing Status & Self-Review
|
||||
|
||||
**Spec coverage:** mic + `AnalyserNode` + rolling voice-band baseline (§3.2) → Tasks 1-2. Anomalies driving the same fragment LLM call as Spirit Radio (§3.2) → Task 3. Mic-denial graceful degradation (§7) → `start()` rejection surfaces a re-prompt state; other modes unaffected.
|
||||
|
||||
**Automated tests:** the detector core is covered by `src/lib/evp.test.ts` (11 tests — floor seeding, `-Infinity` sanitization, threshold/throttle behavior, quiet-band adaptation, `reset()`, defensive `getFloor()` copies), PASS in the 71-test vitest run. The backend half is covered by `tests/test_ws_session.py` (anomaly→fragment, 6/6 PASS in the 45-test suite).
|
||||
|
||||
**Known test gap (honest):** the live microphone path cannot be meaningfully unit tested (spec §8). A **manual hardware pass is required** before this mode is called done: real room, real silences, verify the floor adapts, spikes fire ≤1/2 s, and the mic indicator dies on mode exit.
|
||||
|
||||
**Placeholder scan:** none — both classes exist in `frontend/src/lib/evp.ts` and the prompt branch is live in `backend/app/llm/prompts.py`.
|
||||
|
||||
**Type consistency:** `EvpAnomaly.frequency` (Hz) matches the `anomaly` frame's `frequency: number`; the backend's `signature_from_anomalies` buckets Hz audio freqs and MHz radio freqs into the same log-scale band space by digit count (see `app/entities.py` comment), so EVP and Radio anomalies fingerprint consistently. `EvpListenerCallbacks.onAnomaly` matches `SeanceApi.sendAnomaly('evp', …)`.
|
||||
|
||||
---
|
||||
|
||||
**Status: COMPLETE** (implementation; `evp.ts` type-clean and unit-tested, prompt branch merged). Deferred: manual microphone hardware pass.
|
||||
131
docs/superpowers/plans/2026-07-20-quantumancy-05-spirit-radio.md
Normal file
131
docs/superpowers/plans/2026-07-20-quantumancy-05-spirit-radio.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Quantumancy Plan 5/7: Spirit Radio — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** A spirit box built from a real radio (spec §3.1): the browser claims a user-supplied RTL-SDR dongle over WebUSB, sweeps the FM broadcast band, computes FFT power per bin in JavaScript, and emits anomaly events when a spike jumps the rolling noise floor — each one triggering an Ovilus-style single-word fragment from the fast LLM tier.
|
||||
|
||||
**Architecture:** Everything RF happens client-side, in three pure-ish layers: `fft.ts` (radix-2 Cooley–Tukey FFT + dB power spectrum from interleaved I/Q), `RtlSdr` (WebUSB control-transfer driver for RTL2832U + R820T: init sequence, PLL tuning, sample-rate programming, bulk I/Q reads, and a continuous `sweep()` loop), and `SpectrumAnomalyDetector` (per-bin EMA noise floor + throttled spike detection). Anomalies flow to the backend as `{type: "anomaly", source: "radio", frequency /* MHz */, magnitude /* dB */}` over Plan 3's séance channel. The backend needs nothing new — this mode reuses Plan 3's anomaly→fragment pipeline exactly as EVP does (Plan 4).
|
||||
|
||||
**Tech Stack:** TypeScript, WebUSB (`navigator.usb`), `Float64Array` DSP; backend unchanged from Plans 3-4.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Chromium-only.** WebUSB exists only in Chrome/Edge/Brave/Opera; Firefox and Safari lack it entirely. `isSupported()` must gate the mode, which self-disables with an in-UI explanation (spec §7) — never a silent failure, never a broken page on other browsers.
|
||||
- **Kernel driver contention is expected, not exceptional.** Linux's `dvb_usb_rtl28xxu` claims RTL-SDR dongles before WebUSB can; claim failure surfaces the mode's troubleshooting guide link (spec §3.1/§7). Windows+Zadig/WinUSB typically works out of the box.
|
||||
- **Fail soft everywhere.** Every entry point of the driver treats a thrown error as "this vessel cannot hear the radio dead" — the UI degrades; the séance continues on other modes.
|
||||
- **No new backend surface.** Do not add WS frames or REST endpoints; `source: "radio"` anomalies already have a home.
|
||||
- **Hardware honesty.** The driver was structured from public librtlsdr register documentation in an environment with **no RTL-SDR attached**. It is marked `HARDWARE PASS REQUIRED` in the source and must not be called done until a real dongle validates it (see Task 4).
|
||||
|
||||
## Plan Series
|
||||
|
||||
This is 5 of 7 plans implementing the Quantumancy website spec (`docs/superpowers/specs/2026-07-20-quantumancy-website-design.md`). Plans 3-4 provide the anomaly pipeline this mode feeds.
|
||||
1. Foundation & Auth (complete)
|
||||
2. Frontend, LLM & Realtime Pipeline (complete)
|
||||
3. Wire Ghost mode + Ouija/Planchette UI (complete)
|
||||
4. EVP Listening mode (complete)
|
||||
5. **Spirit Radio mode** (this plan)
|
||||
6. Codex & entity persistence
|
||||
7. Internationalization (EN/ES)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: FFT & Power Spectrum
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/lib/fft.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `nextPow2(n)`; `fftInPlace(re: Float64Array, im: Float64Array)` (iterative radix-2, bit-reversal ordered, length must be a power of two); `magnitudeSpectrum(re, im) -> Float64Array` (first n/2 bins); `powerSpectrumDb(iq: Float64Array) -> Float64Array` — interleaved `[i0, q0, i1, q1, …]` in, n/2 dB values out, power normalized by n², floored at 1e-12 before log.
|
||||
|
||||
- [x] **Step 1: Implement the transform**
|
||||
|
||||
Plain dependency-free Cooley–Tukey: bit-reversal permutation, then butterfly passes with a twiddle recurrence (no per-butterfly trig). Throws on re/im length mismatch and non-power-of-two input — programming errors should be loud here, not silent spectrum garbage.
|
||||
|
||||
- [x] **Step 2: I/Q → dB**
|
||||
|
||||
`powerSpectrumDb` zero-pads/truncates to `nextPow2(iq.length / 2)`, splits interleaved I/Q into re/im planes, transforms, and returns `10 * log10(power)`. This is what the sweep loop hands the anomaly detector per tuning step.
|
||||
|
||||
- [x] **Step 3: Verify** — `npx tsc --noEmit` clean; `src/lib/fft.test.ts` (11 tests) validates the transform against known inputs — impulse spectra, cosine peaks landing on the expected bin for two different frequencies, power-of-two enforcement.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: RtlSdr — WebUSB Driver for RTL2832U + R820T
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/lib/sdr.ts` (driver half)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `RTL2832U_VENDOR = 0x0bda`, `RTL2832U_PRODUCTS = [0x2832, 0x2834, 0x2838, 0x2837]`; `isSupported(): boolean`; `class RtlSdr` with `requestDevice()`, `open(sampleRateHz = 2_048_000)`, `close()`, `setFrequency(hz)`, `setSampleRate(hz)`, `readSamples(bytes) -> Uint8Array`, `sweep(startHz, endHz, stepHz, cb, fftSize = 512, settleMs = 25)`, `stopSweep()`, `isOpen`. Callback types `RtlSampleBlock` and `SweepCallbacks {onSpectrum?(centerHz, db), onError?(err)}`.
|
||||
|
||||
- [x] **Step 1: Device selection & claim**
|
||||
|
||||
`requestDevice()` filters on the four known RTL2832U product IDs. `open()` locates the first bulk-IN endpoint, claims the interface (kernel-driver detach is best-effort; failure throws a themed "could not claim the radio dead (interface busy?)" error for the UI's troubleshooting link).
|
||||
|
||||
- [x] **Step 2: Init sequence** *(HARDWARE PASS REQUIRED)*
|
||||
|
||||
Follows librtlsdr's known-good order via `demodWrite` (paged demod registers) and `i2cWrite` (tuner registers tunneled through the demod's I2C repeater at 0x1a): soft reset → `demod_ctl` → suspend/standby off → AGC mode → R820T LNA/mixer/IF power-on → sample rate → initial 98 MHz tune → endpoint reset. Register pokes are commented as unverified against a real device.
|
||||
|
||||
- [x] **Step 3: Tuning & sample rate** *(HARDWARE PASS REQUIRED)*
|
||||
|
||||
`setFrequency` programs the R820T fractional-N PLL with the 3.57 MHz IF offset against the 28.8 MHz crystal reference (integer part + 16-bit SDM fraction; documented simplification of librtlsdr's exact sdm/vco math). `setSampleRate` programs the demod resampling ratio (`crystal·2²²/hz`, 4-aligned).
|
||||
|
||||
- [x] **Step 4: The sweep loop**
|
||||
|
||||
`sweep()` tunes in `stepHz` steps across `[startHz, endHz]`, waits `settleMs`, discards one 16 KiB block (PLL settle), reads `fftSize·4` bytes of unsigned I/Q, zero-centers to `[-1, 1)`, runs `powerSpectrumDb`, and emits `onSpectrum(centerHz, db)`. Read errors go to `onError` and the sweep continues; wrapping past `endHz` restarts at `startHz`; `stopSweep()` exits cleanly from the `finally`. Exported band constants: `SWEEP_START_MHZ = 88`, `SWEEP_END_MHZ = 108`.
|
||||
|
||||
- [x] **Step 5: Verify** — `npx tsc --noEmit` clean. Runtime verification deferred to Task 4.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: SpectrumAnomalyDetector — Rolling Floor Over the Airwaves
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/lib/sdr.ts` (detector half)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `class SpectrumAnomalyDetector(thresholdDb = 10, throttleMs = 2000, alpha = 0.1)` with `process(centerHz, sampleRateHz, db, nowMs) -> {frequency /* MHz */, magnitude /* dB */} | null` and `reset()`. Pure and testable: no USB types, injected spectrum + clock.
|
||||
|
||||
- [x] **Step 1: Floor + spike logic**
|
||||
|
||||
First spectrum seeds the per-bin EMA floor. Each subsequent spectrum updates the floor (`alpha` 0.1) and finds the peak deviation; a spike fires when `peak ≥ thresholdDb` (10 dB over the rolling floor — the radio dead must shout) and ≥ `throttleMs` (2 s) since the last emission. Bin→frequency maps across the baseband: `binHz = sampleRate/2 / bins`, offset from center, reported in MHz to match the `anomaly` frame contract.
|
||||
|
||||
- [x] **Step 2: Into the séance**
|
||||
|
||||
The mode page forwards detections as `sendAnomaly('radio', frequencyMHz, magnitudeDb)`. Backend flow is Plan 3 verbatim: `anomaly_ack`, auto-summon once ≥3 anomalies fingerprint, then `spirit_service.fragment('radio', …)` — the radio variant of the prompt ("A burst of static at {frequency} MHz, magnitude {magnitude} dB above the noise floor…") with the `"a spirit box"` system framing.
|
||||
|
||||
- [x] **Step 3: Verify** — `npx tsc --noEmit` clean; backend anomaly path covered by `tests/test_ws_session.py` (`test_anomalies_attune_then_produce_fragments` uses `source: "radio"`, 6/6 PASS).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Hardware-in-the-Loop Validation
|
||||
|
||||
**Files:**
|
||||
- Modify (expected): `frontend/src/lib/sdr.ts` register sequences after testing
|
||||
|
||||
- [ ] **Step 1: Real-dongle smoke test — PENDING**
|
||||
|
||||
No RTL-SDR is attached to the build environment, so the driver has never touched silicon. Before this mode is called done: plug an RTL2832U+R820T dongle into a Chromium machine, claim it through the mode UI, and confirm the init sequence completes and bulk I/Q flows. Expect to debug register pokes with `librtlsdr -T` / a logic analyzer — the source is pre-marked `HARDWARE PASS REQUIRED` at the file header, the init sequence, and `setFrequency`.
|
||||
|
||||
- [ ] **Step 2: Sweep & detector tuning — PENDING**
|
||||
|
||||
With live RF: verify the 88–108 MHz sweep shows real broadcast peaks, calibrate `thresholdDb`/`settleMs` against a known station, and confirm anomalies reach the séance (fragment utterances arrive, transcript shows `radio` entries).
|
||||
|
||||
- [ ] **Step 3: Kernel-claim runbook — PENDING**
|
||||
|
||||
Validate the failure paths on Linux (`dvb_usb_rtl28xxu` bound → themed claim error + guide link) and an unsupported browser (mode self-disables with the spec §7 explanation, other modes unaffected).
|
||||
|
||||
---
|
||||
|
||||
## Testing Status & Self-Review
|
||||
|
||||
**Spec coverage:** WebUSB sweep + browser FFT + spike anomalies → Ovilus fragments (§3.1) → Tasks 1-3. Chromium-gating and self-disable (§3.1, §7) → `isSupported()` + Task 4 step 3. Driver-claim troubleshooting (§3.1, §7) → `open()`'s themed claim error. Manual hardware pass before done (§8) → Task 4, **pending**.
|
||||
|
||||
**Automated tests:** `fft.ts` is covered by `src/lib/fft.test.ts` (11 tests, PASS in the 71-test vitest run); the backend side is covered by `tests/test_ws_session.py` radio-anomaly flow (6/6 in the 45-test suite). **Honest gaps:** no vitest yet for `SpectrumAnomalyDetector` (pure and test-ready — the detector logic mirrors the covered `EvpDetectorCore`), and no automated coverage is possible for the USB path itself.
|
||||
|
||||
**Placeholder scan:** the driver is real code, not a stub — but its register sequences are deliberately labeled unverified, and this plan does not claim otherwise. Task 4's pending checkboxes are the whole truth.
|
||||
|
||||
**Type consistency:** detector output `{frequency: MHz, magnitude: dB}` matches `SeanceApi.sendAnomaly` and the `anomaly` ClientFrame; MHz (radio) vs Hz (EVP) unit mixing is handled server-side by `signature_from_anomalies`' digit-count bucketing (documented in `app/entities.py`). `powerSpectrumDb` output length (n/2) matches `SpectrumAnomalyDetector.process`'s bin math.
|
||||
|
||||
---
|
||||
|
||||
**Status: IMPLEMENTATION COMPLETE — HARDWARE VERIFICATION PENDING.** The mode must not be shipped as "done" until Task 4 passes against a physical RTL-SDR on Chromium.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Quantumancy Plan 6/7: Codex & Entity Persistence — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Give spirits memory (spec §4): fingerprint each session's anomaly stream into a signature, match it against the Codex of known entities, mint new ones with a full persona when no match exists, and expose the whole registry through public REST endpoints — the primary multi-user/community hook.
|
||||
|
||||
**Architecture:** Hybrid persistence per spec §4. `app.entities` computes signatures (pure, deterministic) and coerces any profile — LLM-minted or procedurally generated — into the exact shape the DB and frontend expect. `SpiritService.mint_profile` asks the chat-tier model for a JSON persona and falls back to a signature-deterministic procedural profile when the box is dark. The summon flow in `app.ws` (Plan 3) performs match-or-mint, links sessions via `entity_sightings`, and bumps `contact_count`. `app.routes.codex` serves the registry publicly: list (filterable by rarity, sortable), detail (with sighting count), and live veil stats for the landing page.
|
||||
|
||||
**Tech Stack:** FastAPI, SQLAlchemy 2.0 (JSONB profiles), hashlib (signatures), the Plan 2 LLM queue, and Plan 3's séance channel.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **A summoning never visibly fails.** Every failure mode of the LLM path (queue full, HTTP error, unparseable JSON, missing keys) degrades to `fallback_profile` / curated defaults. No 500s from minting.
|
||||
- **Determinism where it matters:** the same anomaly pattern must fingerprint to the same signature (re-contact works), and `fallback_profile(signature)` must be reproducible for a given signature.
|
||||
- **Normalization is mandatory.** Nothing reaches `Entity` rows or the frontend without passing `normalize_profile`: rarity clamped to `common|uncommon|rare|mythic`, form to `wisp|banshee|fairy|shade`, voice ids to installed Piper voices, pitch/rate/noise/echo/hue clamped to the effects chain's ranges.
|
||||
- **The Codex is public** (spec §4): no auth on `GET /api/codex*` or `GET /api/stats`. It still writes nothing — reads only.
|
||||
- Prompt framing per spec §4: `MINT_SYSTEM` is fiction framing ("interactive horror art installation"), never a paranormal claim.
|
||||
|
||||
## Plan Series
|
||||
|
||||
This is 6 of 7 plans implementing the Quantumancy website spec (`docs/superpowers/specs/2026-07-20-quantumancy-website-design.md`). Plan 3 wires the summon flow that consumes this plan's machinery.
|
||||
1. Foundation & Auth (complete)
|
||||
2. Frontend, LLM & Realtime Pipeline (complete)
|
||||
3. Wire Ghost mode + Ouija/Planchette UI (complete)
|
||||
4. EVP Listening mode (complete)
|
||||
5. Spirit Radio mode (implementation complete; hardware pass pending)
|
||||
6. **Codex & entity persistence** (this plan)
|
||||
7. Internationalization (EN/ES)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Entity & Sighting Models
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/models/entity.py`
|
||||
- Create: `backend/app/models/entity_sighting.py`
|
||||
- Modify: `backend/app/models/__init__.py`
|
||||
- Modify: `backend/app/models/contact_session.py` (`entity_id` FK — landed with Plan 3 Task 2)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `RARITY_TIERS = ("common", "uncommon", "rare", "mythic")`; `Entity` — `id`, `name` (unique, indexed), `epithet`, `persona` (Text), `rarity_tier` (indexed), `signature` (unique, indexed), `voice_profile` / `visual_profile` (JSONB), `sample_quotes` (JSONB list), `contact_count`, `discovered_by` (FK users, nullable), `discovered_at`; `EntitySighting` — join row (`entity_id`, `session_id`, `user_id`, `seen_at`, both FKs indexed).
|
||||
|
||||
- [x] **Step 1: Write the models** — as above; `signature` unique+indexed because it *is* the match key, `name` unique because the Codex addresses spirits by name (collisions suffixed by the summon flow, Plan 3).
|
||||
- [x] **Step 2: Verify** — metadata creates cleanly; covered by every later test that inserts entities (`tests/test_codex.py`).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Signatures & Profile Normalization
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/entities.py`
|
||||
- Test: `backend/tests/test_entities.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `MIN_ANOMALIES_FOR_SIGNATURE = 3`; `signature_from_anomalies(anomalies: list[dict]) -> str | None`; `fallback_signature(seed: str) -> str`; `parse_mint_response(text: str) -> dict | None`; `normalize_profile(profile: dict, signature: str) -> dict`; `fallback_profile(signature: str) -> dict`.
|
||||
|
||||
- [x] **Step 1: Write the failing tests** (6 tests)
|
||||
|
||||
Signature needs ≥3 anomalies (else `None`); signature deterministic for the same pattern; `parse_mint_response` extracts a JSON object from chatty LLM output ("Sure! Here you go: {...}") and rejects garbage/nameless objects; `normalize_profile` fills and clamps (bogus rarity → `common`, bogus form → a real form, `pitch` 99 → within ±6, non-string quotes dropped); `fallback_profile` deterministic and valid.
|
||||
|
||||
- [x] **Step 2: Implement signatures**
|
||||
|
||||
`signature_from_anomalies` buckets frequencies by decimal digit count (MHz radio and Hz audio land in the same log-scale band space — magnitude ordering is what matters, not the unit), sorts the last 16 magnitudes to 1-decimal, and SHA1s the pattern to 16 hex chars. `fallback_signature(seed)` is the same digest over `"ambient:<seed>"` for anomaly-thin sessions (pure chat still gets a stable identity).
|
||||
|
||||
- [x] **Step 3: Implement normalization & the procedural fallback**
|
||||
|
||||
`normalize_profile` clamps every field into range (pitch ±6 semitones, rate 0.8–1.15, noise 0.01–0.08, echo 0–0.5, hue 0–360), validates `voice_id` against installed voices (EN ids plus the two Spanish ids), trims name/epithet/persona/quotes to their column budgets, and fills gaps from a `Random("norm:<signature>")` so defaults are per-spirit stable. `fallback_profile` composes gothic names from part lists ("Ash" + "moor"), an epithet ("the Static Widow", …), a persona template, weighted rarity (55/30/12/3), and two quotes from the curated bank — all seeded by `"fallback:<signature>"`.
|
||||
|
||||
- [x] **Step 4: Verify** — `tests/test_entities.py` PASS (6/6).
|
||||
|
||||
- [x] **Step 5: Commit** — in `b9110f4` (spirit engine).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: LLM Minting
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/llm/prompts.py` (`MINT_SYSTEM`, `MINT_PROMPT`, `mint_prompt`)
|
||||
- Modify: `backend/app/llm/service.py` (`SpiritService.mint_profile`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: chat-tier model via the bounded queue (Plan 2), Task 2's parsers/normalizers.
|
||||
- Produces: `mint_profile(signature, channel, anomalies, language = "en") -> dict` — always a normalized profile.
|
||||
|
||||
- [x] **Step 1: The mint prompt**
|
||||
|
||||
`MINT_PROMPT` requests exactly one JSON object with keys `name`, `epithet`, `persona`, `rarity`, `voice {voice_id, pitch, rate, noise}`, `visual {hue, form}`, `quotes` — with the installed voice ids for the session language interpolated in, and the last ≤10 anomalies (≤600 chars JSON) as evidence. `MINT_SYSTEM` pins the fiction framing and "output only valid JSON."
|
||||
|
||||
- [x] **Step 2: The service path**
|
||||
|
||||
Chat-tier, `num_predict: 400`, temperature 0.9, through `LLMQueue.submit`. Response → `parse_mint_response` → `normalize_profile`; parse failure or any queue/HTTP error → `fallback_profile(signature)`. Verified behavior: minting never raises to the WS layer.
|
||||
|
||||
- [x] **Step 3: Verify** — prompt-shape covered by `tests/test_prompts.py::test_mint_prompt_requests_exact_json_keys` (4/4 PASS); end-to-end mint covered by `tests/test_ws_session.py::test_summon_mints_entity_and_greets` against the fake service (6/6 PASS).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Codex REST & Veil Stats
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/routes/codex.py`
|
||||
- Modify: `backend/app/main.py` (include router)
|
||||
- Test: `backend/tests/test_codex.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `GET /api/codex?rarity=&sort=recent|contacted&limit=` (limit capped at 200) → `{entities: [card…]}` where a card is `{id, name, epithet, rarity, visual, quotes, contact_count, discovered_at, discovered_by}` (username resolved in one batched query); `GET /api/codex/{entity_id}` → card + `persona`, `voice`, `sightings` (count of `entity_sightings` rows), 404 `"no such spirit in the codex"`; `GET /api/stats` → `{entities, sessions, utterances, anomalies}` live counts for the landing page ticker.
|
||||
|
||||
- [x] **Step 1: Write the failing tests** (5 tests)
|
||||
|
||||
List returns all entities by name; `?rarity=rare` filters; detail returns persona and `sightings: 0` and 404s on a random UUID; stats counts veil activity; **both codex and stats answer 200 without any auth cookie** — the registry is public by design.
|
||||
|
||||
- [x] **Step 2: Implement the routes** — as above; discoverer usernames resolved with one `IN` query (no N+1), sort by `discovered_at` desc or `contact_count` desc.
|
||||
|
||||
- [x] **Step 3: Verify** — `tests/test_codex.py` PASS (5/5) as part of the 45-test suite.
|
||||
|
||||
- [x] **Step 4: Commit** — in `b9110f4`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Voice & Visual Profiles — Every Spirit Its Own Throat
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/tts/voices.py`
|
||||
- Modify: `backend/app/tts/piper.py` (`synthesize_spirit_voice`), `backend/app/tts/effects.py` (full chain)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `Voice` dataclass + `VOICES` catalog (6 EN: lessac, amy, ryan, alan, hfc_male, hfc_female; 2 ES: davefx, ald), `EN_VOICE_IDS` / `ES_VOICE_IDS`, `pick_voice(voice_id, language) -> Voice` (language-mismatched ids fall back to `lessac`/`davefx`); `synthesize_spirit_voice(text, voice, voice_profile) -> bytes` — Piper synth, then `apply_effects` with the entity's `noise/pitch/rate/bitcrush/echo`.
|
||||
|
||||
- [x] **Step 1: The catalog** — each entity's `voice_profile.voice_id` pins one installed Piper model; `pitch` (−6…+6 semitones), `rate` (0.8–1.15), `noise` (0.01–0.08), `echo` (0–0.5) shape it. Profiles survive in JSONB and return to the client in the `entity` frame (`voice` key), so the Codex page can display them.
|
||||
|
||||
- [x] **Step 2: The effects chain** — `apply_effects(wav, *, noise_level, pitch_semitones, rate, bitcrush_bits, echo)`: tempo resample → pitch shift re-fitted to duration → optional bitcrush → 180 ms slap echo with renormalization → static with fade in/out edges so it breathes like a real spirit-box sweep. Pure numpy on mono 16-bit WAV.
|
||||
|
||||
- [x] **Step 3: Verify** — `tests/test_tts_effects.py` PASS (5/5): silent/tone WAVs through the chain keep valid headers, noise actually lands, pitch/rate change the signal as expected.
|
||||
|
||||
---
|
||||
|
||||
## Testing Status & Self-Review
|
||||
|
||||
**Spec coverage:** signature from anomaly fingerprint + LLM name (§4) → Task 2. Rare-trigger matching against existing Codex entities, persona/memory loaded into session context (§4) → Plan 3's `_summon` + `chat_system(entity)`. Minting of sufficiently strong new identities (§4) → Tasks 2-3. Public browsable Codex with name/first-contact/rarity/quotes/contact count (§4) → Task 4. `entities` and `entity_sightings` tables (§5) → Task 1. Voice per entity (§6 effects chain) → Task 5.
|
||||
|
||||
**Automated tests:** `tests/test_entities.py` (6) + `tests/test_codex.py` (5) + `tests/test_prompts.py` mint test + WS summon/re-contact tests — all PASS in the 45-test suite.
|
||||
|
||||
**Honest notes:** contact_count increments on every summon including re-contacts (matches spec "contact count"); `discovered_by` is nullable and rendered as `discoveredAnon` in the UI when absent; rarity weighting applies only to the procedural fallback — LLM-minted rarities are clamped, not re-weighted.
|
||||
|
||||
**Placeholder scan:** none.
|
||||
|
||||
**Type consistency:** `serialize_entity` (ws.py) and `_entity_card` (codex.py) match the frontend's `SpiritEntity` / `CodexEntity(Detail)` in `types.ts` key-for-key, including `rarity` naming and ISO `discovered_at`. `normalize_profile`'s output keys match the `Entity` columns one-to-one.
|
||||
|
||||
---
|
||||
|
||||
**Status: COMPLETE** (commit `b9110f4`; 45-test backend suite green).
|
||||
122
docs/superpowers/plans/2026-07-20-quantumancy-07-i18n.md
Normal file
122
docs/superpowers/plans/2026-07-20-quantumancy-07-i18n.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Quantumancy Plan 7/7: Internationalization (EN/ES) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let the veil speak two tongues (spec §6): English + Spanish at launch, with one language switch that flips UI strings, the LLM's reply language, and the Piper voice used for TTS — and the framework in place to add more languages later, deliberately scoped tight until the core loop is proven.
|
||||
|
||||
**Architecture:** Frontend strings go through react-i18next with two resource bundles (`en.json`, `es.json`) and a localStorage-persisted choice. The same choice travels the séance channel as `{type: "language", language: "en"|"es"}` and is persisted on the `ContactSession` row; from then on the backend appends a Spanish clause to every LLM system prompt, selects Spanish Piper voices for TTS, and mints new entities with Spanish voice ids. No new endpoints or frame types beyond the one `language` frame.
|
||||
|
||||
**Tech Stack:** i18next + react-i18next (frontend); the existing prompt builders, voice catalog, and séance channel (backend).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Launch scope is EN + ES only** (spec §6, §10). Both LLM reply quality and Piper voice quality vary by language; the structure must admit more languages later, but nothing beyond these two ships now.
|
||||
- **All UI copy lives in the JSON bundles.** Components call `t('<page>.<section>.<name>')` — no hardcoded user-facing strings, no string interpolation hacks (`escapeValue: false`, `returnEmptyString: false`).
|
||||
- **The switch is total:** UI strings + LLM reply language + TTS voice change together (spec §6). A Spanish séance must sound Spanish.
|
||||
- **The language frame is silent** (no ack frame) — the next utterance simply arrives in the new tongue. Mode/passive flows are unaffected.
|
||||
- **Backend `Language` is a closed set** (`en`, `es`) at the WS boundary; unknown values are ignored, never stored.
|
||||
|
||||
## Plan Series
|
||||
|
||||
This is 7 of 7 plans implementing the Quantumancy website spec (`docs/superpowers/specs/2026-07-20-quantumancy-website-design.md`). All prior plans are complete (Plan 5 pending only its hardware pass).
|
||||
1. Foundation & Auth (complete)
|
||||
2. Frontend, LLM & Realtime Pipeline (complete)
|
||||
3. Wire Ghost mode + Ouija/Planchette UI (complete)
|
||||
4. EVP Listening mode (complete)
|
||||
5. Spirit Radio mode (implementation complete; hardware pass pending)
|
||||
6. Codex & entity persistence (complete)
|
||||
7. **Internationalization (EN/ES)** (this plan)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: react-i18next Bootstrap & Bundles
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/i18n/index.ts`
|
||||
- Create: `frontend/src/i18n/en.json`
|
||||
- Create: `frontend/src/i18n/es.json`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: the initialized default `i18n` instance (resources `en`/`es`, `lng` from storage, `fallbackLng: 'en'`); `storedLanguage() -> Language` and `persistLanguage(lang)` around the `qm_language` localStorage key, both try/catch-guarded for private-mode storage failures. Bundle key groups: `common.*`, `nav.*`, `landing.*`, `enter.*`, `seance.*`, `codex.*` — the `<page>.<section>.<name>` convention, with plural-aware pairs (`contacts_one`/`contacts_other`) where counts render.
|
||||
|
||||
- [x] **Step 1: Add dependencies** — `i18next` + `react-i18next` (already in `frontend/package.json`).
|
||||
|
||||
- [x] **Step 2: Write the bundles** — full EN/ES key parity, tone preserved across tongues ("tuning the veil…" / "afinando el velo…"; landing taglines translated, not transliterated). 181 lines per bundle, identical key trees.
|
||||
|
||||
- [x] **Step 3: Wire the init** — `src/i18n/index.ts` as above; imported once at app entry. `Language` type reused from `src/lib/types.ts` so the UI language and the WS language frame can never drift apart.
|
||||
|
||||
- [x] **Step 4: Verify** — `npx tsc --noEmit` clean; both bundles parse (`resolveJsonModule`).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Component Sweep to `useTranslation()`
|
||||
|
||||
**Files:**
|
||||
- Modify: every component/page with user-facing copy (`src/components/*.tsx`, pages, `App.tsx` shell)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1's bundles.
|
||||
|
||||
- [x] **Step 1: Sweep** — all visible strings via `const { t } = useTranslation()` and `t('…')` (verified in the shared components: `EntityCard`, `Transcript`, `TelemetryReadout` consume `t()`; e.g. `t('seance.entity.rarity.' + entity.rarity)`, `t('seance.entity.known', { count })`). Exception by design: `PlanchetteBoard`'s canvas-drawn board glyphs (YES / NO / GOODBYE, the letter arcs) are occult furniture, not copy — they render as-is in both languages, with `common.yes/no/goodbye` keys available in the bundles should a future pass internationalize the board itself.
|
||||
|
||||
- [x] **Step 2: Language switch control** — UI toggle calls `i18n.changeLanguage(lang)`, `persistLanguage(lang)`, and `useSeance().setLanguage(lang)` so the backend follows in the same gesture (Task 3).
|
||||
|
||||
- [x] **Step 3: Verify** — `npx tsc --noEmit` clean; switching languages re-renders all strings without reload.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: The Language Frame & Backend Language State
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/ws.py` (`language` message branch, `ContactSession.language` persistence)
|
||||
- Modify: `backend/app/models/contact_session.py` (`language` column — landed with Plan 3 Task 2)
|
||||
- Modify: `frontend/src/state/seance.tsx` (`setLanguage` action + frame)
|
||||
|
||||
**Interfaces:**
|
||||
- Frame: `{type: "language", language: "en"|"es"}` — accepted values only; anything else ignored. State: `SeanceState.language` (default `"en"`), consumed by every `spirit_service` call site (`fragment`, `wire_whisper`, `chat_stream`, `mint_profile`) and by TTS voice selection.
|
||||
|
||||
- [x] **Step 1: Backend branch** — on `language`, update `state.language` and persist `ContactSession.language` in the same write pattern as `set_mode`. Silent by design: no ack frame.
|
||||
|
||||
- [x] **Step 2: Frontend action** — `setLanguage(language)` dispatches locally (immediate UI feedback) and sends the frame; outbox queueing (Plan 3 `VeilSocket`) covers the reconnect edge.
|
||||
|
||||
- [x] **Step 3: Verify** — covered by `tests/test_ws_session.py` session flows (6/6 PASS); language column round-trips through the session row.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Spanish Prompts & Spanish Voices
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/llm/prompts.py` (`language_clause`)
|
||||
- Modify: `backend/app/llm/service.py` (language-aware mint voice ids)
|
||||
- Modify: `backend/app/tts/voices.py` (ES voices, `pick_voice` language fallback)
|
||||
- Assets: `backend/voices/es_ES-davefx-medium.onnx`, `backend/voices/es_MX-ald-medium.onnx` (+ `.onnx.json` configs)
|
||||
- Test: `backend/tests/test_prompts.py` (Spanish clause tests)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `language_clause(language) -> str` — `" Reply in Spanish."` for `es`, `""` otherwise — appended to `FRAGMENT_SYSTEM`, `WIRE_SYSTEM`, and `CHAT_SYSTEM` via their `{language_clause}` placeholders; `ES_VOICE_IDS = ["davefx", "ald"]`; `pick_voice(voice_id, language)` falling back to `davefx` for Spanish sessions, `lessac` for English; `mint_profile` passing `ES_VOICE_IDS` into the mint prompt when `language == "es"` so new spirits are born with Spanish throats.
|
||||
|
||||
- [x] **Step 1: Write the failing tests** — `test_spanish_language_clause_applied` asserts "Spanish" appears in `fragment_system`, `wire_system`, and `chat_system` under `"es"`; `test_fragment_system_carries_fiction_framing_not_paranormal_claim` pins the EN default (no Spanish clause, fiction framing present).
|
||||
|
||||
- [x] **Step 2: Implement the clause + voice selection** — as above. Note the design decision: only the *system* prompts carry the clause; user-turn prompts stay language-neutral so anomaly telemetry reads identically in both tongues.
|
||||
|
||||
- [x] **Step 3: Install the ES voices** — `davefx` (es_ES, medium) and `ald` (es_MX, medium) in `backend/voices/`, registered in the `VOICES` catalog with Spanish descriptions. `normalize_profile` (Plan 6) already accepts both ids as valid `voice_id`s.
|
||||
|
||||
- [x] **Step 4: Verify** — `tests/test_prompts.py` PASS (4/4) in the 45-test suite; Spanish utterances synthesize through the same effects chain (ES voice models verified present on disk).
|
||||
|
||||
---
|
||||
|
||||
## Testing Status & Self-Review
|
||||
|
||||
**Spec coverage:** react-i18next with EN+ES launch scope (§6) → Tasks 1-2. Language switch flips UI + Piper voice + LLM reply language together (§6) → Tasks 2-4. Framework ready for more languages later without broad launch coverage (§6, §10) → closed-set `Language` type + resource-bundle structure.
|
||||
|
||||
**Automated tests:** `tests/test_prompts.py` Spanish clauses (4/4); WS language flow inside `tests/test_ws_session.py` (6/6); both in the green 45-test suite. Bundle key-parity is structural (same JSON trees edited together) — a parity unit test is a reasonable future addition, not present.
|
||||
|
||||
**Honest notes:** ES UI copy is complete for the keys that exist (`common/nav/landing/enter/seance/codex`); new pages must add keys to both bundles together. Fallback profiles (Plan 6) are English-only prose — a Spanish session whose summon hits the offline fallback gets an English persona with a Spanish voice; accepted as a rare degradation path, not a launch blocker.
|
||||
|
||||
**Placeholder scan:** none.
|
||||
|
||||
**Type consistency:** frontend `Language = 'en' | 'es'` (types.ts) matches the WS branch's accepted set and `storedLanguage()`'s validation exactly; `SeanceApi.setLanguage` threads the same type end to end.
|
||||
|
||||
---
|
||||
|
||||
**Status: COMPLETE** — EN/ES live across UI strings, LLM replies, and TTS voices; Spanish prompt clauses under test.
|
||||
Reference in New Issue
Block a user