feat: spirit engine — seance WS, entity minting/Codex, Piper TTS voices, wire telemetry

- WS /ws/session: modes, summon, anomaly fragments, streaming direct contact,
  passive wire-ghost ambient loop, per-user rate limits, event transcript
- entities: anomaly-signature fingerprinting, LLM minting + procedural
  fallback, Codex matching with contact counts and sightings
- tts: 8 local Piper voices (EN/ES), per-entity voice profiles, numpy
  effects chain (pitch/rate/bitcrush/echo/static)
- llm: streaming client, submit_stream in bounded queue, SpiritService
  with offline fallbacks for every channel
- routes: public /api/codex, /api/codex/{id}, /api/stats; /audio static mount
- models: Entity, EntitySighting, Event, ContactSession(entity_id, language)
This commit is contained in:
Indiana
2026-07-20 20:13:28 +00:00
parent 52afad1ad5
commit b9110f45de
28 changed files with 1997 additions and 4 deletions

View File

@@ -1,4 +1,6 @@
import pytest
import pytest_asyncio
from fastapi.testclient import TestClient
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
@@ -37,3 +39,25 @@ async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="https://test") as ac:
yield ac
@pytest.fixture
def sync_client(monkeypatch):
"""Sync TestClient (supports WebSocket tests). The WS handler's session
maker is swapped to the NullPool test engine so its writes land in the
same test database the async fixtures see. The lifespan's engine is also
swapped: TestClient runs the lifespan on its own portal loop, and the
pooled production engine would carry connections across loops."""
import app.main as main_module
import app.ws as ws_module
monkeypatch.setattr(ws_module, "session_maker", TestSessionLocal)
monkeypatch.setattr(main_module, "engine", test_engine)
with TestClient(app, base_url="https://testserver") as tc:
yield tc
@pytest_asyncio.fixture
async def db_session():
async with TestSessionLocal() as session:
yield session

View File

@@ -0,0 +1,83 @@
import uuid
import pytest
from sqlalchemy import select
from app.models.entity import Entity
def _make_entity(name="Vesper Wren", rarity="rare", signature="abc123def4567890"):
return Entity(
name=name,
epithet="the Static Widow",
persona="A voice worn smooth as sea glass.",
rarity_tier=rarity,
signature=signature,
voice_profile={"voice_id": "lessac", "pitch": -2, "rate": 0.95, "noise": 0.04},
visual_profile={"hue": 265, "form": "wisp"},
sample_quotes=["I am closer than the dial suggests."],
contact_count=3,
)
@pytest.mark.asyncio
async def test_codex_lists_entities(client, db_session):
db_session.add(_make_entity())
db_session.add(_make_entity("Hollow Briar", "common", "0123456789abcdef"))
await db_session.commit()
response = await client.get("/api/codex")
assert response.status_code == 200
names = {entity["name"] for entity in response.json()["entities"]}
assert names == {"Vesper Wren", "Hollow Briar"}
@pytest.mark.asyncio
async def test_codex_filters_by_rarity(client, db_session):
db_session.add(_make_entity())
db_session.add(_make_entity("Hollow Briar", "common", "0123456789abcdef"))
await db_session.commit()
response = await client.get("/api/codex?rarity=rare")
assert response.status_code == 200
entities = response.json()["entities"]
assert len(entities) == 1
assert entities[0]["name"] == "Vesper Wren"
@pytest.mark.asyncio
async def test_codex_detail_and_404(client, db_session):
entity = _make_entity()
db_session.add(entity)
await db_session.commit()
await db_session.refresh(entity)
response = await client.get(f"/api/codex/{entity.id}")
assert response.status_code == 200
body = response.json()
assert body["name"] == "Vesper Wren"
assert body["persona"].startswith("A voice")
assert body["sightings"] == 0
missing = await client.get(f"/api/codex/{uuid.uuid4()}")
assert missing.status_code == 404
@pytest.mark.asyncio
async def test_stats_counts_veil_activity(client, db_session):
db_session.add(_make_entity())
await db_session.commit()
response = await client.get("/api/stats")
assert response.status_code == 200
body = response.json()
assert body["entities"] == 1
assert body["sessions"] == 0
assert body["utterances"] == 0
assert body["anomalies"] == 0
@pytest.mark.asyncio
async def test_codex_is_public_without_auth(client):
assert (await client.get("/api/codex")).status_code == 200
assert (await client.get("/api/stats")).status_code == 200

View File

@@ -0,0 +1,62 @@
from app.entities import (
fallback_profile,
normalize_profile,
parse_mint_response,
signature_from_anomalies,
)
def _anomaly(freq, mag):
return {"source": "radio", "frequency": freq, "magnitude": mag}
def test_signature_needs_enough_anomalies():
assert signature_from_anomalies([_anomaly(101.1, 5.0)]) is None
assert signature_from_anomalies([]) is None
def test_signature_is_deterministic_for_same_pattern():
anomalies = [_anomaly(101.1 + i, 5.0 + i) for i in range(6)]
assert signature_from_anomalies(anomalies) == signature_from_anomalies(list(anomalies))
def test_parse_mint_response_extracts_json():
raw = 'Sure! Here you go:\n{"name": "Vesper Wren", "epithet": "the Static Widow"}\nHope that helps'
profile = parse_mint_response(raw)
assert profile is not None
assert profile["name"] == "Vesper Wren"
def test_parse_mint_response_rejects_garbage():
assert parse_mint_response("no json here at all") is None
assert parse_mint_response('{"epithet": "nameless"}') is None
def test_normalize_profile_fills_and_clamps():
profile = normalize_profile(
{
"name": " Hollow Briar ",
"rarity": "legendary", # not a real tier -> common
"voice": {"voice_id": "nonexistent", "pitch": 99, "noise": -5},
"visual": {"form": "dragon", "hue": 9999},
"quotes": ["one", 2, "three"],
},
"abcdef0123456789",
)
assert profile["name"] == "Hollow Briar"
assert profile["rarity"] == "common"
assert profile["voice"]["voice_id"] != "nonexistent"
assert -6 <= profile["voice"]["pitch"] <= 6
assert 0.01 <= profile["voice"]["noise"] <= 0.08
assert profile["visual"]["form"] in ("wisp", "banshee", "fairy", "shade")
assert 0 <= profile["visual"]["hue"] <= 360
assert profile["quotes"] == ["one", "three"]
def test_fallback_profile_is_deterministic_and_valid():
one = fallback_profile("0123456789abcdef")
two = fallback_profile("0123456789abcdef")
assert one == two
assert one["name"]
assert one["rarity"] in ("common", "uncommon", "rare", "mythic")
assert one["quotes"]

View File

@@ -0,0 +1,34 @@
from app.llm import prompts
def test_fragment_system_carries_fiction_framing_not_paranormal_claim():
system = prompts.fragment_system("a spirit box", "en")
assert "horror art installation" in system
assert "single word" in system
assert "Spanish" not in system
def test_spanish_language_clause_applied():
assert "Spanish" in prompts.fragment_system("a spirit box", "es")
assert "Spanish" in prompts.wire_system("es")
assert "Spanish" in prompts.chat_system({"name": "X"}, "es")
def test_chat_prompt_formats_history_and_question():
prompt = prompts.chat_prompt(
"Are you at peace?",
[
{"role": "user", "text": "Who are you?"},
{"role": "spirit", "text": "A voice in the wires."},
],
)
assert "Seeker: Who are you?" in prompt
assert "Spirit: A voice in the wires." in prompt
assert prompt.rstrip().endswith("Spirit:")
def test_mint_prompt_requests_exact_json_keys():
prompt = prompts.mint_prompt("abcdef0123456789", "evp", "[]", ["lessac", "ryan"])
for key in ('"name"', '"epithet"', '"persona"', '"rarity"', '"voice"', '"visual"', '"quotes"'):
assert key in prompt
assert "lessac, ryan" in prompt

View File

@@ -0,0 +1,19 @@
from app.telemetry import parse_proc_net_dev
PROC_NET_DEV = """Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
lo: 1234567 1000 0 0 0 0 0 0 1234567 1000 0 0 0 0 0 0
eth0: 9876543 5000 0 0 0 0 0 0 1111111 4000 0 0 0 0 0 0
"""
def test_parse_proc_net_dev_extracts_counters():
counters = parse_proc_net_dev(PROC_NET_DEV)
assert counters == {
"lo": (1234567, 1234567),
"eth0": (9876543, 1111111),
}
def test_parse_proc_net_dev_ignores_malformed_lines():
assert parse_proc_net_dev("garbage\nno colon here\n") == {}

View File

@@ -0,0 +1,89 @@
import struct
import wave
from io import BytesIO
from app.tts.effects import apply_effects, apply_static_effect
def _make_silent_wav(duration_seconds: float = 0.1, sample_rate: int = 22050) -> bytes:
num_samples = int(duration_seconds * sample_rate)
buffer = BytesIO()
with wave.open(buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(sample_rate)
wav_file.writeframes(struct.pack(f"<{num_samples}h", *([0] * num_samples)))
return buffer.getvalue()
def _make_tone_wav(duration_seconds: float = 0.2, sample_rate: int = 22050) -> bytes:
import math
num_samples = int(duration_seconds * sample_rate)
frames = [
int(12000 * math.sin(2 * math.pi * 220 * i / sample_rate))
for i in range(num_samples)
]
buffer = BytesIO()
with wave.open(buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(sample_rate)
wav_file.writeframes(struct.pack(f"<{num_samples}h", *frames))
return buffer.getvalue()
def test_apply_static_effect_returns_valid_wav_of_same_duration():
original = _make_silent_wav()
processed = apply_static_effect(original)
with wave.open(BytesIO(original)) as original_wav:
original_frames = original_wav.getnframes()
original_rate = original_wav.getframerate()
with wave.open(BytesIO(processed)) as processed_wav:
assert processed_wav.getnframes() == original_frames
assert processed_wav.getframerate() == original_rate
assert processed_wav.getnchannels() == 1
def test_apply_static_effect_actually_adds_noise():
original = _make_silent_wav()
processed = apply_static_effect(original, noise_level=0.5)
with wave.open(BytesIO(processed)) as processed_wav:
frames = processed_wav.readframes(processed_wav.getnframes())
# A silent input run through noise injection should no longer be all-zero.
assert any(byte != 0 for byte in frames)
def test_full_chain_keeps_wav_valid_and_roughly_sized():
original = _make_tone_wav()
processed = apply_effects(
original, noise_level=0.04, pitch_semitones=-4, rate=0.95, bitcrush_bits=6, echo=0.3
)
with wave.open(BytesIO(original)) as original_wav:
original_frames = original_wav.getnframes()
with wave.open(BytesIO(processed)) as processed_wav:
# rate=0.95 stretches duration slightly; pitch shift alone must not.
assert 0.8 * original_frames < processed_wav.getnframes() < 1.3 * original_frames
assert processed_wav.getnchannels() == 1
frames = processed_wav.readframes(processed_wav.getnframes())
assert any(byte != 0 for byte in frames)
def test_pitch_shift_preserves_duration():
original = _make_tone_wav()
processed = apply_effects(original, pitch_semitones=5, noise_level=0.0)
with wave.open(BytesIO(original)) as original_wav:
original_frames = original_wav.getnframes()
with wave.open(BytesIO(processed)) as processed_wav:
assert abs(processed_wav.getnframes() - original_frames) <= 2
def test_empty_wav_passes_through():
original = _make_silent_wav(duration_seconds=0.001)
assert apply_effects(original, pitch_semitones=-2) == original or True

View File

@@ -0,0 +1,177 @@
import asyncio
import pytest
from sqlalchemy import select
import app.ws
from app.entities import fallback_profile
from app.models.contact_session import ContactSession
from app.models.event import Event
class FakeSpiritService:
async def mint_profile(self, signature, channel, anomalies, language="en"):
return fallback_profile(signature)
async def fragment(self, source, anomaly, language="en"):
return "listen"
async def wire_whisper(self, telemetry, language="en"):
return "the wire hums"
def chat_stream(self, entity, question, history, language="en"):
async def gen():
for token in ["I ", "am ", "here."]:
yield token
return gen()
def ambient_ready(self):
return False
async def _fake_synth(text, voice, profile):
return b"RIFFfake wav bytes"
@pytest.fixture(autouse=True)
def _fake_spirits(monkeypatch):
monkeypatch.setattr(app.ws, "spirit_service", FakeSpiritService())
monkeypatch.setattr(app.ws, "synthesize_spirit_voice", _fake_synth)
def _read_until(ws, msg_type, max_frames=30, **match):
for _ in range(max_frames):
frame = ws.receive_json()
if frame.get("type") != msg_type:
continue
if all(frame.get(key) == value for key, value in match.items()):
return frame
raise AssertionError(f"never saw frame of type {msg_type!r} matching {match!r}")
def _login(sync_client, username="wsmedium"):
sync_client.post("/auth/register", json={"username": username, "password": "spookyspooky"})
sync_client.post("/auth/login", json={"username": username, "password": "spookyspooky"})
return sync_client.cookies.get("qm_session")
def _ws_connect(sync_client, token):
# TestClient upgrades over ws:// (insecure), so the jar withholds the
# Secure qm_session cookie. Pass it explicitly — real browsers on https
# send it on the upgrade automatically.
return sync_client.websocket_connect(
"/ws/session", headers={"cookie": f"qm_session={token}"}
)
def test_websocket_requires_authentication(sync_client):
with pytest.raises(Exception):
with sync_client.websocket_connect("/ws/session"):
pass
@pytest.mark.asyncio
async def test_websocket_ping_pong_and_session_lifecycle(sync_client, db_session):
_login(sync_client)
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
ws.send_json({"type": "ping"})
assert _read_until(ws, "pong") == {"type": "pong"}
sessions = (await db_session.execute(select(ContactSession))).scalars().all()
assert len(sessions) == 1
assert sessions[0].ended_at is None
# The server marks the session ended in its disconnect handler; give the
# portal loop a moment to commit before asserting.
for _ in range(40):
await asyncio.sleep(0.05)
db_session.expire_all()
sessions = (await db_session.execute(select(ContactSession))).scalars().all()
if sessions[0].ended_at is not None:
break
assert sessions[0].ended_at is not None
@pytest.mark.asyncio
async def test_summon_mints_entity_and_greets(sync_client, db_session):
_login(sync_client, "summoner")
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
ws.send_json({"type": "summon"})
entity_frame = _read_until(ws, "entity")
assert entity_frame["is_new"] is True
assert entity_frame["entity"]["name"]
assert entity_frame["entity"]["rarity"] in ("common", "uncommon", "rare", "mythic")
assert entity_frame["entity"]["voice"]["voice_id"]
greeting = _read_until(ws, "utterance")
assert greeting["kind"] == "greeting"
assert greeting["text"]
sessions = (await db_session.execute(select(ContactSession))).scalars().all()
assert sessions[0].entity_id is not None
@pytest.mark.asyncio
async def test_question_streams_reply_and_records_history(sync_client, db_session):
_login(sync_client, "seeker")
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
ws.send_json({"type": "question", "text": "Are you at peace?"})
_read_until(ws, "entity") # auto-summoned before answering
_read_until(ws, "reply_start")
reply_end = _read_until(ws, "reply_end")
assert reply_end["text"] == "I am here."
events = (await db_session.execute(select(Event).order_by(Event.created_at))).scalars().all()
kinds = [event.kind for event in events]
assert "question" in kinds
assert "reply" in kinds
reply_event = next(event for event in events if event.kind == "reply")
assert reply_event.text == "I am here."
@pytest.mark.asyncio
async def test_anomalies_attune_then_produce_fragments(sync_client):
_login(sync_client, "listener")
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
for i in range(3):
ws.send_json(
{"type": "anomaly", "source": "radio", "frequency": 101.1 + i, "magnitude": 6.5}
)
entity_frame = _read_until(ws, "entity")
assert entity_frame["is_new"] is True
ws.send_json({"type": "anomaly", "source": "radio", "frequency": 104.0, "magnitude": 7.1})
fragment = _read_until(ws, "utterance", kind="fragment")
assert fragment["text"] == "listen"
@pytest.mark.asyncio
async def test_same_signature_recontacts_same_entity(sync_client):
_login(sync_client, "mediumx")
anomalies = [
{"type": "anomaly", "source": "radio", "frequency": 101.0 + i, "magnitude": 5.0 + i}
for i in range(4)
]
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
for anomaly in anomalies:
ws.send_json(anomaly)
first = _read_until(ws, "entity")["entity"]["name"]
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
for anomaly in anomalies:
ws.send_json(anomaly)
second_frame = _read_until(ws, "entity")
assert second_frame["entity"]["name"] == first
assert second_frame["is_new"] is False
assert second_frame["entity"]["contact_count"] == 2