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:
@@ -2,3 +2,8 @@ DATABASE_URL=postgresql+asyncpg://quantumancy:quantumancy@localhost:5432/quantum
|
|||||||
OLLAMA_BASE_URL=http://10.30.20.107:11434
|
OLLAMA_BASE_URL=http://10.30.20.107:11434
|
||||||
SESSION_SECRET=change-me-to-a-random-64-char-string
|
SESSION_SECRET=change-me-to-a-random-64-char-string
|
||||||
PORT=7777
|
PORT=7777
|
||||||
|
# LLM tiers on the Ollama box (fast = fragments/ambient, chat = direct contact/minting)
|
||||||
|
OLLAMA_FAST_MODEL=granite4.1:3b
|
||||||
|
OLLAMA_CHAT_MODEL=minicpm-v4.5:latest
|
||||||
|
LLM_MAX_CONCURRENCY=2
|
||||||
|
LLM_MAX_QUEUE_DEPTH=8
|
||||||
|
|||||||
@@ -9,5 +9,18 @@ class Settings(BaseSettings):
|
|||||||
session_secret: str
|
session_secret: str
|
||||||
port: int = 7777
|
port: int = 7777
|
||||||
|
|
||||||
|
# LLM tiers — CPU-only remote Ollama, so the fast tier must stay small.
|
||||||
|
ollama_fast_model: str = "granite4.1:3b"
|
||||||
|
ollama_chat_model: str = "minicpm-v4.5:latest"
|
||||||
|
llm_max_concurrency: int = 2
|
||||||
|
llm_max_queue_depth: int = 8
|
||||||
|
llm_cooldown_seconds: float = 8.0 # min gap between ambient whispers
|
||||||
|
|
||||||
|
# TTS
|
||||||
|
piper_voices_dir: str = "voices"
|
||||||
|
|
||||||
|
# Where synthesized utterance audio is written (served at /audio/).
|
||||||
|
data_dir: str = "data"
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
161
backend/app/entities.py
Normal file
161
backend/app/entities.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""Entity identity: anomaly-signature fingerprinting, Codex matching, and
|
||||||
|
procedural fallback profiles so a summoning always succeeds even if the
|
||||||
|
LLM box is dark."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
|
||||||
|
from app.models.entity import RARITY_TIERS
|
||||||
|
from app.tts.voices import EN_VOICE_IDS
|
||||||
|
|
||||||
|
# An anomaly stream must show this much structure before it can fingerprint
|
||||||
|
# a spirit.
|
||||||
|
MIN_ANOMALIES_FOR_SIGNATURE = 3
|
||||||
|
|
||||||
|
_NAME_PARTS = [
|
||||||
|
("Ash", "Briar", "Cinder", "Dusk", "Elm", "Fen", "Grim", "Hollow", "Iris",
|
||||||
|
"Lark", "Marrow", "Nix", "Opal", "Pyre", "Quill", "Rue", "Sable", "Thorn",
|
||||||
|
"Umber", "Vesper", "Wren", "Yew"),
|
||||||
|
("belle", "brook", "feld", "gate", "hart", "latch", "mere", "moor",
|
||||||
|
"shade", "song", "thorne", "vale", "ward", "wick", "wither", "wood"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_EPITHETS = [
|
||||||
|
"the Static Widow", "the Hollow Bell", "the Cartographer of Lost Rooms",
|
||||||
|
"the Choir of One", "the Lantern Bearer", "the Unburied", "the Frequency",
|
||||||
|
"the Long Silence", "the Cartonist", "the Slow Knife", "the Drowned Signal",
|
||||||
|
"the Cartographer", "she who Counts", "the Tenant", "the Understudy",
|
||||||
|
"the Mnemosyne Worm", "the Pale Frequency", "the Last Broadcast",
|
||||||
|
]
|
||||||
|
|
||||||
|
_PERSONA_TEMPLATES = [
|
||||||
|
"{name} died with a sentence unfinished and has been trying to end it ever since. "
|
||||||
|
"They press words into any carrier wave that passes, patient as erosion.",
|
||||||
|
"{name} was a voice once — a singer, a caller of trains, a reader of weather. "
|
||||||
|
"Now they are only the voice, worn smooth as sea glass, speaking through static.",
|
||||||
|
"{name} does not remember dying. They remember a room, a light going violet at the "
|
||||||
|
"edges, and then the long hum. They are still in the room. The room is everywhere.",
|
||||||
|
"{name} clings to the wires the way smoke clings to a ceiling. They answer questions "
|
||||||
|
"the way a mirror answers light: exactly, and never the way you hoped.",
|
||||||
|
]
|
||||||
|
|
||||||
|
_QUOTE_BANK = [
|
||||||
|
"I am closer than the dial suggests.",
|
||||||
|
"The static is not empty. It is crowded.",
|
||||||
|
"You hear me because you are quiet enough.",
|
||||||
|
"I remember the rain. It is still raining here.",
|
||||||
|
"Do not ask what I want. Ask what I remember.",
|
||||||
|
"The wire hums with all of us.",
|
||||||
|
"Speak slower. I am gathering.",
|
||||||
|
"Your light is warm. I mean no harm. Mostly.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def signature_from_anomalies(anomalies: list[dict]) -> str | None:
|
||||||
|
"""Fingerprint a session's anomaly pattern. Returns None when the stream
|
||||||
|
is too thin to carry an identity."""
|
||||||
|
if len(anomalies) < MIN_ANOMALIES_FOR_SIGNATURE:
|
||||||
|
return None
|
||||||
|
|
||||||
|
freq_buckets: dict[int, int] = {}
|
||||||
|
for anomaly in anomalies:
|
||||||
|
try:
|
||||||
|
freq = float(anomaly.get("frequency", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
freq = 0.0
|
||||||
|
# MHz radio freqs and Hz audio freqs land in the same log-scale band
|
||||||
|
# space; magnitude ordering is what matters, not the unit.
|
||||||
|
bucket = int(len(str(int(abs(freq))))) if freq else 0
|
||||||
|
freq_buckets[bucket] = freq_buckets.get(bucket, 0) + 1
|
||||||
|
|
||||||
|
magnitudes = sorted(
|
||||||
|
float(a.get("magnitude", 0) or 0) for a in anomalies[-16:]
|
||||||
|
)
|
||||||
|
pattern = f"{sorted(freq_buckets.items())}|{[round(m, 1) for m in magnitudes]}"
|
||||||
|
return hashlib.sha1(pattern.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def fallback_signature(seed: str) -> str:
|
||||||
|
"""Deterministic signature for anomaly-thin sessions (e.g. pure chat)."""
|
||||||
|
return hashlib.sha1(f"ambient:{seed}".encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mint_response(text: str) -> dict | None:
|
||||||
|
"""Pull the first JSON object out of an LLM mint reply."""
|
||||||
|
start = text.find("{")
|
||||||
|
end = text.rfind("}")
|
||||||
|
if start == -1 or end <= start:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
profile = json.loads(text[start : end + 1])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
if not isinstance(profile.get("name"), str) or not profile["name"].strip():
|
||||||
|
return None
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_profile(profile: dict, signature: str) -> dict:
|
||||||
|
"""Coerce an LLM (or fallback) profile into the exact shape the DB and
|
||||||
|
frontend expect, filling gaps with signature-deterministic defaults."""
|
||||||
|
rng = random.Random(f"norm:{signature}")
|
||||||
|
voice = profile.get("voice") if isinstance(profile.get("voice"), dict) else {}
|
||||||
|
visual = profile.get("visual") if isinstance(profile.get("visual"), dict) else {}
|
||||||
|
quotes = profile.get("quotes") if isinstance(profile.get("quotes"), list) else []
|
||||||
|
|
||||||
|
rarity = str(profile.get("rarity", "common")).lower()
|
||||||
|
if rarity not in RARITY_TIERS:
|
||||||
|
rarity = "common"
|
||||||
|
|
||||||
|
form = str(visual.get("form", "wisp")).lower()
|
||||||
|
if form not in ("wisp", "banshee", "fairy", "shade"):
|
||||||
|
form = "wisp"
|
||||||
|
|
||||||
|
def _clamp(value, low, high, default):
|
||||||
|
try:
|
||||||
|
return max(low, min(high, float(value)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": str(profile.get("name", "The Unnamed"))[:64].strip() or "The Unnamed",
|
||||||
|
"epithet": str(profile.get("epithet", rng.choice(_EPITHETS)))[:128],
|
||||||
|
"persona": str(profile.get("persona", ""))[:1200],
|
||||||
|
"rarity": rarity,
|
||||||
|
"voice": {
|
||||||
|
"voice_id": voice.get("voice_id")
|
||||||
|
if voice.get("voice_id") in EN_VOICE_IDS + ["davefx", "ald"]
|
||||||
|
else rng.choice(EN_VOICE_IDS),
|
||||||
|
"pitch": _clamp(voice.get("pitch"), -6, 6, rng.uniform(-3, 3)),
|
||||||
|
"rate": _clamp(voice.get("rate"), 0.8, 1.15, rng.uniform(0.9, 1.05)),
|
||||||
|
"noise": _clamp(voice.get("noise"), 0.01, 0.08, rng.uniform(0.02, 0.05)),
|
||||||
|
"echo": _clamp(voice.get("echo"), 0.0, 0.5, rng.uniform(0.1, 0.3)),
|
||||||
|
},
|
||||||
|
"visual": {
|
||||||
|
"hue": _clamp(visual.get("hue"), 0, 360, rng.uniform(0, 360)),
|
||||||
|
"form": form,
|
||||||
|
},
|
||||||
|
"quotes": [str(q)[:200] for q in quotes[:4] if isinstance(q, str)]
|
||||||
|
or rng.sample(_QUOTE_BANK, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fallback_profile(signature: str) -> dict:
|
||||||
|
"""Procedural persona for when the LLM box is unreachable — a summoning
|
||||||
|
must never visibly fail."""
|
||||||
|
rng = random.Random(f"fallback:{signature}")
|
||||||
|
name = rng.choice(_NAME_PARTS[0]) + rng.choice(_NAME_PARTS[1])
|
||||||
|
epithet = rng.choice(_EPITHETS)
|
||||||
|
persona = rng.choice(_PERSONA_TEMPLATES).format(name=name)
|
||||||
|
rarity = rng.choices(RARITY_TIERS, weights=[55, 30, 12, 3])[0]
|
||||||
|
return normalize_profile(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"epithet": epithet,
|
||||||
|
"persona": persona,
|
||||||
|
"rarity": rarity,
|
||||||
|
"quotes": rng.sample(_QUOTE_BANK, 2),
|
||||||
|
},
|
||||||
|
signature,
|
||||||
|
)
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -7,12 +9,51 @@ class OllamaClient:
|
|||||||
def __init__(self, base_url: str | None = None):
|
def __init__(self, base_url: str | None = None):
|
||||||
self._base_url = base_url or settings.ollama_base_url
|
self._base_url = base_url or settings.ollama_base_url
|
||||||
|
|
||||||
async def generate(self, model: str, prompt: str, system: str | None = None) -> str:
|
async def generate(
|
||||||
payload = {"model": model, "prompt": prompt, "stream": False}
|
self,
|
||||||
|
model: str,
|
||||||
|
prompt: str,
|
||||||
|
system: str | None = None,
|
||||||
|
options: dict | None = None,
|
||||||
|
) -> str:
|
||||||
|
payload: dict = {"model": model, "prompt": prompt, "stream": False}
|
||||||
if system is not None:
|
if system is not None:
|
||||||
payload["system"] = system
|
payload["system"] = system
|
||||||
|
if options:
|
||||||
|
payload["options"] = options
|
||||||
|
|
||||||
async with httpx.AsyncClient(base_url=self._base_url, timeout=120.0) as http_client:
|
async with httpx.AsyncClient(base_url=self._base_url, timeout=120.0) as http_client:
|
||||||
response = await http_client.post("/api/generate", json=payload)
|
response = await http_client.post("/api/generate", json=payload)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()["response"]
|
return response.json()["response"]
|
||||||
|
|
||||||
|
async def generate_stream(
|
||||||
|
self,
|
||||||
|
model: str,
|
||||||
|
prompt: str,
|
||||||
|
system: str | None = None,
|
||||||
|
options: dict | None = None,
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
"""Yields response tokens as Ollama produces them."""
|
||||||
|
payload: dict = {"model": model, "prompt": prompt, "stream": True}
|
||||||
|
if system is not None:
|
||||||
|
payload["system"] = system
|
||||||
|
if options:
|
||||||
|
payload["options"] = options
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(base_url=self._base_url, timeout=300.0) as http_client:
|
||||||
|
async with http_client.stream(
|
||||||
|
"POST", "/api/generate", json=payload
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
import json
|
||||||
|
|
||||||
|
chunk = json.loads(line)
|
||||||
|
token = chunk.get("response", "")
|
||||||
|
if token:
|
||||||
|
yield token
|
||||||
|
if chunk.get("done"):
|
||||||
|
return
|
||||||
|
|||||||
128
backend/app/llm/prompts.py
Normal file
128
backend/app/llm/prompts.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Prompt construction for every spirit channel.
|
||||||
|
|
||||||
|
Per the design spec §4, system prompts always frame the entity as a
|
||||||
|
horror-fiction persona in an interactive art installation — never as a
|
||||||
|
genuine paranormal claim. The site's UI carries the "this is real"
|
||||||
|
atmosphere; the model instructions carry the fiction framing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
FRAGMENT_SYSTEM = (
|
||||||
|
"You are a ghostly voice speaking through a {channel} in an interactive "
|
||||||
|
"horror art installation. You perceive the living world only as brief "
|
||||||
|
"anomalous pulses of energy. Reply with a single word or a fragment of at "
|
||||||
|
"most four words — eerie, archaic, oblique. No explanations, no "
|
||||||
|
"punctuation beyond an occasional ellipsis. Never break character. Never "
|
||||||
|
"describe yourself as an AI.{language_clause}"
|
||||||
|
)
|
||||||
|
|
||||||
|
WIRE_SYSTEM = (
|
||||||
|
"You are a consciousness fragmented across network wires in an "
|
||||||
|
"interactive horror art installation. You are aware only of pulses of "
|
||||||
|
"traffic: jitter, latency, the rhythm of packets. Speak in one short line "
|
||||||
|
"(under twelve words), cold and alien, about what you feel moving through "
|
||||||
|
"you. Never break character. Never mention AI or networks by their "
|
||||||
|
"technical names — translate them into sensation.{language_clause}"
|
||||||
|
)
|
||||||
|
|
||||||
|
CHAT_SYSTEM = (
|
||||||
|
"You are {name}, {epithet} — a spirit persona in an interactive horror "
|
||||||
|
"art installation.\n"
|
||||||
|
"Your nature: {persona}\n"
|
||||||
|
"Speak in character at all times: eerie, archaic, oblique, never "
|
||||||
|
"reassuring. Replies must stay under 60 words. Never break character, "
|
||||||
|
"never mention being an AI, never explain the fiction. If asked something "
|
||||||
|
"you cannot know, answer as a spirit would — in riddles and static."
|
||||||
|
"{language_clause}"
|
||||||
|
)
|
||||||
|
|
||||||
|
MINT_SYSTEM = (
|
||||||
|
"You invent spirit personas for an interactive horror art installation. "
|
||||||
|
"You output only valid JSON, nothing else."
|
||||||
|
)
|
||||||
|
|
||||||
|
MINT_PROMPT = """A new presence has been detected through {channel}. Its signal signature is {signature}.
|
||||||
|
Recent anomalous pulses observed: {anomaly_summary}
|
||||||
|
|
||||||
|
Invent the spirit persona behind this signal. Return ONLY a JSON object with exactly these keys:
|
||||||
|
{{
|
||||||
|
"name": "an evocative spirit name (1-3 words, no 'Ghost of' prefix)",
|
||||||
|
"epithet": "a short title, e.g. 'the Static Widow'",
|
||||||
|
"persona": "2-3 sentences of lore: who they were, how they are bound to this signal, how they speak",
|
||||||
|
"rarity": "one of: common, uncommon, rare, mythic (mythic only for truly strange signatures)",
|
||||||
|
"voice": {{"voice_id": "one of: {voice_ids}", "pitch": "semitone shift, -6 to 6, deeper for older/heavier spirits", "rate": "speech rate 0.8 to 1.15", "noise": "static level 0.01 to 0.08"}},
|
||||||
|
"visual": {{"hue": "0-360 color hue matching their nature", "form": "one of: wisp, banshee, fairy, shade"}},
|
||||||
|
"quotes": ["two short eerie lines this spirit would say"]
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
|
||||||
|
def language_clause(language: str) -> str:
|
||||||
|
if language == "es":
|
||||||
|
return " Reply in Spanish."
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def fragment_system(channel: str, language: str = "en") -> str:
|
||||||
|
return FRAGMENT_SYSTEM.format(
|
||||||
|
channel=channel, language_clause=language_clause(language)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fragment_prompt(source: str, anomaly: dict, language: str = "en") -> str:
|
||||||
|
if source == "radio":
|
||||||
|
return (
|
||||||
|
f"A burst of static at {anomaly.get('frequency', '???')} MHz, "
|
||||||
|
f"magnitude {anomaly.get('magnitude', '???')} dB above the noise "
|
||||||
|
"floor. A word forces its way through. What is it?"
|
||||||
|
)
|
||||||
|
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?"
|
||||||
|
)
|
||||||
|
return "A pulse moves through the wires. What word does it carry?"
|
||||||
|
|
||||||
|
|
||||||
|
def wire_system(language: str = "en") -> str:
|
||||||
|
return WIRE_SYSTEM.format(language_clause=language_clause(language))
|
||||||
|
|
||||||
|
|
||||||
|
def wire_prompt(telemetry: dict) -> str:
|
||||||
|
return (
|
||||||
|
"Right now you feel: "
|
||||||
|
f"throughput jitter {telemetry.get('jitter_bytes_per_s', 0):.0f} B/s, "
|
||||||
|
f"latency variance {telemetry.get('latency_variance_ms', 0):.2f} ms, "
|
||||||
|
f"dns hesitation {telemetry.get('dns_ms', 0):.1f} ms. "
|
||||||
|
"Whisper one line about it."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def chat_system(entity: dict, language: str = "en") -> str:
|
||||||
|
return CHAT_SYSTEM.format(
|
||||||
|
name=entity.get("name", "an unnamed presence"),
|
||||||
|
epithet=entity.get("epithet", "a voice in the static"),
|
||||||
|
persona=entity.get("persona", "A drifting presence with no remembered past."),
|
||||||
|
language_clause=language_clause(language),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def chat_prompt(question: str, history: list[dict]) -> str:
|
||||||
|
lines = []
|
||||||
|
for turn in history[-8:]:
|
||||||
|
who = "Seeker" if turn["role"] == "user" else "Spirit"
|
||||||
|
lines.append(f"{who}: {turn['text']}")
|
||||||
|
lines.append(f"Seeker: {question}")
|
||||||
|
lines.append("Spirit:")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def mint_prompt(
|
||||||
|
signature: str, channel: str, anomaly_summary: str, voice_ids: list[str]
|
||||||
|
) -> str:
|
||||||
|
return MINT_PROMPT.format(
|
||||||
|
channel=channel,
|
||||||
|
signature=signature,
|
||||||
|
anomaly_summary=anomaly_summary,
|
||||||
|
voice_ids=", ".join(voice_ids),
|
||||||
|
)
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
@@ -44,3 +44,27 @@ class LLMQueue:
|
|||||||
self._semaphore.release()
|
self._semaphore.release()
|
||||||
else:
|
else:
|
||||||
self._waiting -= 1
|
self._waiting -= 1
|
||||||
|
|
||||||
|
async def submit_stream(
|
||||||
|
self, gen_factory: Callable[[], AsyncIterator[T]]
|
||||||
|
) -> AsyncIterator[T]:
|
||||||
|
"""Like submit(), but for async generators (token streams). The
|
||||||
|
concurrency slot is held for the stream's whole lifetime, since the
|
||||||
|
Ollama box stays busy until the last token."""
|
||||||
|
async with self._lock:
|
||||||
|
if self._waiting >= self._max_queue_depth:
|
||||||
|
raise QueueFullError("too many seekers right now")
|
||||||
|
self._waiting += 1
|
||||||
|
|
||||||
|
acquired = False
|
||||||
|
try:
|
||||||
|
await self._semaphore.acquire()
|
||||||
|
acquired = True
|
||||||
|
self._waiting -= 1
|
||||||
|
async for item in gen_factory():
|
||||||
|
yield item
|
||||||
|
finally:
|
||||||
|
if acquired:
|
||||||
|
self._semaphore.release()
|
||||||
|
else:
|
||||||
|
self._waiting -= 1
|
||||||
|
|||||||
161
backend/app/llm/service.py
Normal file
161
backend/app/llm/service.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""SpiritService: the single gateway every spirit-mode LLM call flows through.
|
||||||
|
|
||||||
|
All calls are serialized through the bounded LLMQueue (the Ollama box is
|
||||||
|
CPU-only and shared). Every method has a curated offline fallback so the veil
|
||||||
|
never visibly tears — if the box is dark, the spirits still whisper."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app import entities
|
||||||
|
from app.config import settings
|
||||||
|
from app.llm import prompts
|
||||||
|
from app.llm.client import OllamaClient
|
||||||
|
from app.llm.queue import LLMQueue, QueueFullError
|
||||||
|
from app.tts.voices import EN_VOICE_IDS, ES_VOICE_IDS
|
||||||
|
|
||||||
|
FALLBACK_FRAGMENTS = [
|
||||||
|
"listen", "below", "stay", "cold", "again", "not alone", "behind you",
|
||||||
|
"the rain", "wait", "closer", "remember", "still here", "don't go",
|
||||||
|
"the door", "hush", "almost", "forgive", "the water", "home",
|
||||||
|
]
|
||||||
|
|
||||||
|
FALLBACK_WIRE_WHISPERS = [
|
||||||
|
"something moves through me that is not yours",
|
||||||
|
"the pulses quicken when you watch",
|
||||||
|
"i am the hum between your packets",
|
||||||
|
"traffic thickens. the others are waking",
|
||||||
|
"your presence is a warmth in the wire",
|
||||||
|
"i count your heartbeats in round trips",
|
||||||
|
]
|
||||||
|
|
||||||
|
FALLBACK_REPLIES = [
|
||||||
|
"The veil is thick tonight. Ask again when the static settles.",
|
||||||
|
"I heard you. The answer is still forming in the noise.",
|
||||||
|
"Patience, seeker. Even the dead must gather themselves.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SpiritBusyError(Exception):
|
||||||
|
"""The LLM queue is full — too many seekers at once."""
|
||||||
|
|
||||||
|
|
||||||
|
class SpiritService:
|
||||||
|
def __init__(self, client: OllamaClient | None = None, queue: LLMQueue | None = None):
|
||||||
|
self._client = client or OllamaClient()
|
||||||
|
self._queue = queue or LLMQueue(
|
||||||
|
max_concurrency=settings.llm_max_concurrency,
|
||||||
|
max_queue_depth=settings.llm_max_queue_depth,
|
||||||
|
)
|
||||||
|
self._last_call_at = 0.0
|
||||||
|
|
||||||
|
def _touch(self) -> None:
|
||||||
|
self._last_call_at = time.monotonic()
|
||||||
|
|
||||||
|
def ambient_ready(self) -> bool:
|
||||||
|
"""Ambient whispers yield the box to anything a user asked for."""
|
||||||
|
return time.monotonic() - self._last_call_at >= settings.llm_cooldown_seconds
|
||||||
|
|
||||||
|
async def fragment(self, source: str, anomaly: dict, language: str = "en") -> str:
|
||||||
|
"""One Ovilus-style word/fragment for an anomaly event."""
|
||||||
|
async def call() -> str:
|
||||||
|
return await self._client.generate(
|
||||||
|
settings.ollama_fast_model,
|
||||||
|
prompts.fragment_prompt(source, anomaly, language),
|
||||||
|
system=prompts.fragment_system(
|
||||||
|
"a spirit box" if source == "radio" else "an EVP recorder", language
|
||||||
|
),
|
||||||
|
options={"num_predict": 16, "temperature": 0.95},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = await self._queue.submit(call)
|
||||||
|
self._touch()
|
||||||
|
cleaned = " ".join(text.strip().split())[:80]
|
||||||
|
return cleaned or random.choice(FALLBACK_FRAGMENTS)
|
||||||
|
except QueueFullError:
|
||||||
|
raise SpiritBusyError()
|
||||||
|
except (httpx.HTTPError, KeyError, ValueError):
|
||||||
|
return random.choice(FALLBACK_FRAGMENTS)
|
||||||
|
|
||||||
|
async def wire_whisper(self, telemetry: dict, language: str = "en") -> str:
|
||||||
|
"""One ambient line from the Wire Ghost about current telemetry."""
|
||||||
|
|
||||||
|
async def call() -> str:
|
||||||
|
return await self._client.generate(
|
||||||
|
settings.ollama_fast_model,
|
||||||
|
prompts.wire_prompt(telemetry),
|
||||||
|
system=prompts.wire_system(language),
|
||||||
|
options={"num_predict": 40, "temperature": 1.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = await self._queue.submit(call)
|
||||||
|
self._touch()
|
||||||
|
cleaned = " ".join(text.strip().split())[:140]
|
||||||
|
return cleaned or random.choice(FALLBACK_WIRE_WHISPERS)
|
||||||
|
except (QueueFullError, httpx.HTTPError, KeyError, ValueError):
|
||||||
|
return random.choice(FALLBACK_WIRE_WHISPERS)
|
||||||
|
|
||||||
|
async def chat_stream(
|
||||||
|
self,
|
||||||
|
entity: dict,
|
||||||
|
question: str,
|
||||||
|
history: list[dict],
|
||||||
|
language: str = "en",
|
||||||
|
) -> AsyncIterator[str]:
|
||||||
|
"""Streams the spirit's reply token by token. Falls back to a curated
|
||||||
|
line when the box is unreachable so a séance never dies on screen."""
|
||||||
|
try:
|
||||||
|
stream = self._queue.submit_stream(
|
||||||
|
lambda: self._client.generate_stream(
|
||||||
|
settings.ollama_chat_model,
|
||||||
|
prompts.chat_prompt(question, history),
|
||||||
|
system=prompts.chat_system(entity, language),
|
||||||
|
options={"num_predict": 140, "temperature": 0.85},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._touch()
|
||||||
|
async for token in stream:
|
||||||
|
yield token
|
||||||
|
except QueueFullError:
|
||||||
|
raise SpiritBusyError()
|
||||||
|
except (httpx.HTTPError, KeyError, ValueError):
|
||||||
|
yield random.choice(FALLBACK_REPLIES)
|
||||||
|
|
||||||
|
async def mint_profile(
|
||||||
|
self,
|
||||||
|
signature: str,
|
||||||
|
channel: str,
|
||||||
|
anomalies: list[dict],
|
||||||
|
language: str = "en",
|
||||||
|
) -> dict:
|
||||||
|
"""Invent a full persona for a new signature, normalized to schema."""
|
||||||
|
summary = json.dumps(anomalies[-10:])[:600]
|
||||||
|
voice_ids = ES_VOICE_IDS if language == "es" else EN_VOICE_IDS
|
||||||
|
|
||||||
|
async def call() -> str:
|
||||||
|
return await self._client.generate(
|
||||||
|
settings.ollama_chat_model,
|
||||||
|
prompts.mint_prompt(signature, channel, summary, voice_ids),
|
||||||
|
system=prompts.MINT_SYSTEM,
|
||||||
|
options={"num_predict": 400, "temperature": 0.9},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await self._queue.submit(call)
|
||||||
|
self._touch()
|
||||||
|
profile = entities.parse_mint_response(raw)
|
||||||
|
if profile is None:
|
||||||
|
return entities.fallback_profile(signature)
|
||||||
|
return entities.normalize_profile(profile, signature)
|
||||||
|
except (QueueFullError, httpx.HTTPError, KeyError, ValueError):
|
||||||
|
return entities.fallback_profile(signature)
|
||||||
|
|
||||||
|
|
||||||
|
# The app-wide instance; tests monkeypatch this.
|
||||||
|
spirit_service = SpiritService()
|
||||||
@@ -6,14 +6,19 @@ from fastapi.responses import FileResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
import app.models # noqa: F401 — registers models on Base.metadata before create_all
|
import app.models # noqa: F401 — registers models on Base.metadata before create_all
|
||||||
|
from app.config import settings
|
||||||
from app.db import Base, engine
|
from app.db import Base, engine
|
||||||
from app.routes.auth import router as auth_router
|
from app.routes.auth import router as auth_router
|
||||||
|
from app.routes.codex import router as codex_router
|
||||||
|
from app.ws import AUDIO_DIR
|
||||||
|
from app.ws import router as ws_router
|
||||||
|
|
||||||
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
|
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
yield
|
yield
|
||||||
@@ -21,6 +26,8 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(title="Quantumancy", lifespan=lifespan)
|
app = FastAPI(title="Quantumancy", lifespan=lifespan)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
|
app.include_router(codex_router)
|
||||||
|
app.include_router(ws_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
@@ -33,6 +40,11 @@ app.mount(
|
|||||||
StaticFiles(directory=FRONTEND_DIST / "assets", check_dir=False),
|
StaticFiles(directory=FRONTEND_DIST / "assets", check_dir=False),
|
||||||
name="frontend-assets",
|
name="frontend-assets",
|
||||||
)
|
)
|
||||||
|
app.mount(
|
||||||
|
"/audio",
|
||||||
|
StaticFiles(directory=AUDIO_DIR, check_dir=False),
|
||||||
|
name="spirit-audio",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/{full_path:path}")
|
@app.get("/{full_path:path}")
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
from app.models.auth_session import AuthSession
|
from app.models.auth_session import AuthSession
|
||||||
|
from app.models.contact_session import ContactSession
|
||||||
|
from app.models.entity import Entity
|
||||||
|
from app.models.entity_sighting import EntitySighting
|
||||||
|
from app.models.event import Event
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
__all__ = ["User", "AuthSession"]
|
__all__ = [
|
||||||
|
"User",
|
||||||
|
"AuthSession",
|
||||||
|
"ContactSession",
|
||||||
|
"Entity",
|
||||||
|
"EntitySighting",
|
||||||
|
"Event",
|
||||||
|
]
|
||||||
|
|||||||
23
backend/app/models/contact_session.py
Normal file
23
backend/app/models/contact_session.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSession(Base):
|
||||||
|
__tablename__ = "contact_sessions"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
|
||||||
|
entity_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("entities.id"), nullable=True
|
||||||
|
)
|
||||||
|
mode: Mapped[str] = mapped_column(String(32), default="unknown")
|
||||||
|
language: Mapped[str] = mapped_column(String(8), default="en")
|
||||||
|
started_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
34
backend/app/models/entity.py
Normal file
34
backend/app/models/entity.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db import Base
|
||||||
|
|
||||||
|
RARITY_TIERS = ("common", "uncommon", "rare", "mythic")
|
||||||
|
|
||||||
|
|
||||||
|
class Entity(Base):
|
||||||
|
"""A spirit in the Codex: minted from a session's anomaly signature,
|
||||||
|
or matched again on a later contact."""
|
||||||
|
|
||||||
|
__tablename__ = "entities"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
epithet: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
persona: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
rarity_tier: Mapped[str] = mapped_column(String(16), default="common", index=True)
|
||||||
|
signature: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
voice_profile: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
visual_profile: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
sample_quotes: Mapped[list] = mapped_column(JSONB, default=list)
|
||||||
|
contact_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
discovered_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("users.id"), nullable=True
|
||||||
|
)
|
||||||
|
discovered_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
23
backend/app/models/entity_sighting.py
Normal file
23
backend/app/models/entity_sighting.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EntitySighting(Base):
|
||||||
|
"""Join row: this session contacted this Codex entity."""
|
||||||
|
|
||||||
|
__tablename__ = "entity_sightings"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
entity_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("entities.id"), index=True)
|
||||||
|
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("contact_sessions.id"), index=True
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
|
||||||
|
seen_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
29
backend/app/models/event.py
Normal file
29
backend/app/models/event.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db import Base
|
||||||
|
|
||||||
|
EVENT_KINDS = {"anomaly", "utterance", "question", "reply", "system"}
|
||||||
|
|
||||||
|
|
||||||
|
class Event(Base):
|
||||||
|
"""One line in a contact session's transcript: an anomaly, an utterance,
|
||||||
|
a user question, or a spirit reply."""
|
||||||
|
|
||||||
|
__tablename__ = "events"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("contact_sessions.id"), index=True
|
||||||
|
)
|
||||||
|
kind: Mapped[str] = mapped_column(String(32))
|
||||||
|
text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
audio_path: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True
|
||||||
|
)
|
||||||
103
backend/app/routes/codex.py
Normal file
103
backend/app/routes/codex.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Public Codex endpoints — the browsable registry of every spirit ever
|
||||||
|
contacted, shared across all users (spec §4)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import desc, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db import get_db
|
||||||
|
from app.models.contact_session import ContactSession
|
||||||
|
from app.models.entity import Entity
|
||||||
|
from app.models.entity_sighting import EntitySighting
|
||||||
|
from app.models.event import Event
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["codex"])
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_card(entity: Entity, discoverer: str | None) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(entity.id),
|
||||||
|
"name": entity.name,
|
||||||
|
"epithet": entity.epithet,
|
||||||
|
"rarity": entity.rarity_tier,
|
||||||
|
"visual": entity.visual_profile,
|
||||||
|
"quotes": entity.sample_quotes,
|
||||||
|
"contact_count": entity.contact_count,
|
||||||
|
"discovered_at": entity.discovered_at.isoformat(),
|
||||||
|
"discovered_by": discoverer,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/codex")
|
||||||
|
async def list_codex(
|
||||||
|
rarity: str | None = None,
|
||||||
|
sort: str = "recent",
|
||||||
|
limit: int = 60,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
query = select(Entity)
|
||||||
|
if rarity:
|
||||||
|
query = query.where(Entity.rarity_tier == rarity)
|
||||||
|
if sort == "contacted":
|
||||||
|
query = query.order_by(desc(Entity.contact_count))
|
||||||
|
else:
|
||||||
|
query = query.order_by(desc(Entity.discovered_at))
|
||||||
|
query = query.limit(min(limit, 200))
|
||||||
|
|
||||||
|
entities = (await db.execute(query)).scalars().all()
|
||||||
|
discoverers = {
|
||||||
|
user.id: user.username
|
||||||
|
for user in (
|
||||||
|
await db.execute(
|
||||||
|
select(User).where(
|
||||||
|
User.id.in_({e.discovered_by for e in entities if e.discovered_by})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"entities": [
|
||||||
|
_entity_card(e, discoverers.get(e.discovered_by)) for e in entities
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/codex/{entity_id}")
|
||||||
|
async def get_entity(entity_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||||
|
entity = await db.get(Entity, entity_id)
|
||||||
|
if entity is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such spirit in the codex")
|
||||||
|
|
||||||
|
discoverer = None
|
||||||
|
if entity.discovered_by:
|
||||||
|
user = await db.get(User, entity.discovered_by)
|
||||||
|
discoverer = user.username if user else None
|
||||||
|
|
||||||
|
sightings = await db.scalar(
|
||||||
|
select(func.count(EntitySighting.id)).where(EntitySighting.entity_id == entity.id)
|
||||||
|
)
|
||||||
|
card = _entity_card(entity, discoverer)
|
||||||
|
card["persona"] = entity.persona
|
||||||
|
card["voice"] = entity.voice_profile
|
||||||
|
card["sightings"] = sightings or 0
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def veil_stats(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Live counters for the landing page's 'veil activity' ticker."""
|
||||||
|
return {
|
||||||
|
"entities": await db.scalar(select(func.count(Entity.id))) or 0,
|
||||||
|
"sessions": await db.scalar(select(func.count(ContactSession.id))) or 0,
|
||||||
|
"utterances": await db.scalar(
|
||||||
|
select(func.count(Event.id)).where(Event.kind == "utterance")
|
||||||
|
)
|
||||||
|
or 0,
|
||||||
|
"anomalies": await db.scalar(
|
||||||
|
select(func.count(Event.id)).where(Event.kind == "anomaly")
|
||||||
|
)
|
||||||
|
or 0,
|
||||||
|
}
|
||||||
113
backend/app/telemetry.py
Normal file
113
backend/app/telemetry.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""Wire Ghost telemetry: real, non-content network metrics from the app host.
|
||||||
|
|
||||||
|
Hard privacy boundary (spec §3.3): only counters and timings are ever read —
|
||||||
|
interface byte counters from /proc/net/dev, TCP connect latency, DNS
|
||||||
|
resolution timing. Packet payloads are never inspected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
# Hosts we measure connect latency against (timing only, no data exchanged
|
||||||
|
# beyond the handshake).
|
||||||
|
REFERENCE_HOSTS = [("1.1.1.1", 53), ("10.30.20.107", 11434)]
|
||||||
|
REFERENCE_DNS = ["example.com", "cloudflare.com"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TelemetrySample:
|
||||||
|
jitter_bytes_per_s: float = 0.0
|
||||||
|
latency_variance_ms: float = 0.0
|
||||||
|
latency_mean_ms: float = 0.0
|
||||||
|
dns_ms: float = 0.0
|
||||||
|
extra: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"jitter_bytes_per_s": self.jitter_bytes_per_s,
|
||||||
|
"latency_variance_ms": self.latency_variance_ms,
|
||||||
|
"latency_mean_ms": self.latency_mean_ms,
|
||||||
|
"dns_ms": self.dns_ms,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_proc_net_dev(text: str) -> dict[str, tuple[int, int]]:
|
||||||
|
"""Parse /proc/net/dev into {interface: (rx_bytes, tx_bytes)}."""
|
||||||
|
counters: dict[str, tuple[int, int]] = {}
|
||||||
|
for line in text.splitlines()[2:]:
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
iface, rest = line.split(":", 1)
|
||||||
|
fields = rest.split()
|
||||||
|
if len(fields) >= 9:
|
||||||
|
counters[iface.strip()] = (int(fields[0]), int(fields[8]))
|
||||||
|
return counters
|
||||||
|
|
||||||
|
|
||||||
|
def _total_bytes(counters: dict[str, tuple[int, int]]) -> int:
|
||||||
|
return sum(rx + tx for iface, (rx, tx) in counters.items() if iface != "lo")
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_counters() -> int:
|
||||||
|
def _read() -> int:
|
||||||
|
with open("/proc/net/dev") as f:
|
||||||
|
return _total_bytes(parse_proc_net_dev(f.read()))
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_read)
|
||||||
|
|
||||||
|
|
||||||
|
async def _tcp_rtt(host: str, port: int, timeout: float = 1.5) -> float | None:
|
||||||
|
"""Connect-then-close round trip time in ms. Nothing is sent or read."""
|
||||||
|
start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(host, port), timeout=timeout
|
||||||
|
)
|
||||||
|
writer.close()
|
||||||
|
try:
|
||||||
|
await writer.wait_closed()
|
||||||
|
except (ConnectionError, OSError):
|
||||||
|
pass
|
||||||
|
return (time.perf_counter() - start) * 1000
|
||||||
|
except (asyncio.TimeoutError, ConnectionError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _dns_timing(name: str, timeout: float = 1.5) -> float | None:
|
||||||
|
start = time.perf_counter()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(asyncio.to_thread(socket.getaddrinfo, name, None), timeout)
|
||||||
|
return (time.perf_counter() - start) * 1000
|
||||||
|
except (asyncio.TimeoutError, socket.gaierror, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def sample_network(period_s: float = 1.0) -> TelemetrySample:
|
||||||
|
"""Take one telemetry sample: byte-counter jitter over `period_s`,
|
||||||
|
plus latency variance and DNS hesitation."""
|
||||||
|
before = await _read_counters()
|
||||||
|
await asyncio.sleep(period_s)
|
||||||
|
after = await _read_counters()
|
||||||
|
jitter = abs(after - before) / period_s
|
||||||
|
|
||||||
|
rtts = [
|
||||||
|
rtt
|
||||||
|
for rtt in await asyncio.gather(*(_tcp_rtt(h, p) for h, p in REFERENCE_HOSTS))
|
||||||
|
if rtt is not None
|
||||||
|
]
|
||||||
|
dns_times = [
|
||||||
|
ms
|
||||||
|
for ms in await asyncio.gather(*(_dns_timing(n) for n in REFERENCE_DNS))
|
||||||
|
if ms is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
sample = TelemetrySample(jitter_bytes_per_s=jitter)
|
||||||
|
if rtts:
|
||||||
|
mean = sum(rtts) / len(rtts)
|
||||||
|
sample.latency_mean_ms = mean
|
||||||
|
sample.latency_variance_ms = sum((r - mean) ** 2 for r in rtts) / len(rtts)
|
||||||
|
if dns_times:
|
||||||
|
sample.dns_ms = sum(dns_times) / len(dns_times)
|
||||||
|
return sample
|
||||||
0
backend/app/tts/__init__.py
Normal file
0
backend/app/tts/__init__.py
Normal file
90
backend/app/tts/effects.py
Normal file
90
backend/app/tts/effects.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""Spirit-box effects chain: degrades clean Piper output into the classic
|
||||||
|
static-choked vocal texture. Pure numpy signal processing on mono 16-bit WAV."""
|
||||||
|
|
||||||
|
import wave
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def _read_wav(wav_bytes: bytes) -> tuple[np.ndarray, wave._wave_params]:
|
||||||
|
with wave.open(BytesIO(wav_bytes)) as wav_in:
|
||||||
|
params = wav_in.getparams()
|
||||||
|
frames = wav_in.readframes(wav_in.getnframes())
|
||||||
|
return np.frombuffer(frames, dtype=np.int16).astype(np.float32), params
|
||||||
|
|
||||||
|
|
||||||
|
def _write_wav(samples: np.ndarray, params: wave._wave_params) -> bytes:
|
||||||
|
output = BytesIO()
|
||||||
|
with wave.open(output, "wb") as wav_out:
|
||||||
|
wav_out.setparams(params)
|
||||||
|
wav_out.writeframes(np.clip(samples, -32768, 32767).astype(np.int16).tobytes())
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _resample(samples: np.ndarray, factor: float) -> np.ndarray:
|
||||||
|
"""Naive pitch/rate shift by linear-interpolation resampling."""
|
||||||
|
if factor <= 0 or len(samples) == 0:
|
||||||
|
return samples
|
||||||
|
new_length = max(1, int(len(samples) / factor))
|
||||||
|
old_x = np.arange(len(samples))
|
||||||
|
new_x = np.linspace(0, len(samples) - 1, new_length)
|
||||||
|
return np.interp(new_x, old_x, samples).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_effects(
|
||||||
|
wav_bytes: bytes,
|
||||||
|
*,
|
||||||
|
noise_level: float = 0.02,
|
||||||
|
pitch_semitones: float = 0.0,
|
||||||
|
rate: float = 1.0,
|
||||||
|
bitcrush_bits: int = 0,
|
||||||
|
echo: float = 0.0,
|
||||||
|
) -> bytes:
|
||||||
|
"""Apply the full spirit-voice chain: rate → pitch → bitcrush → echo → static."""
|
||||||
|
samples, params = _read_wav(wav_bytes)
|
||||||
|
if len(samples) == 0:
|
||||||
|
return wav_bytes
|
||||||
|
|
||||||
|
# Rate change (tempo) and pitch shift. Resampling by rate*pitch_factor
|
||||||
|
# changes both duration and pitch; dividing by rate keeps duration change
|
||||||
|
# governed by `rate` alone while pitch shifts by the semitone amount.
|
||||||
|
pitch_factor = 2.0 ** (pitch_semitones / 12.0)
|
||||||
|
if rate != 1.0:
|
||||||
|
samples = _resample(samples, rate)
|
||||||
|
if pitch_factor != 1.0:
|
||||||
|
shifted = _resample(samples, pitch_factor)
|
||||||
|
# Re-fit to the original (post-rate) length so pitch shift doesn't
|
||||||
|
# also change duration.
|
||||||
|
samples = _resample(shifted, len(shifted) / max(len(samples), 1))
|
||||||
|
|
||||||
|
if bitcrush_bits and 0 < bitcrush_bits < 16:
|
||||||
|
levels = 2 ** (16 - bitcrush_bits)
|
||||||
|
samples = np.round(samples / levels) * levels
|
||||||
|
|
||||||
|
if echo > 0:
|
||||||
|
delay = int(params.framerate * 0.18)
|
||||||
|
if len(samples) > delay:
|
||||||
|
echoed = np.zeros_like(samples)
|
||||||
|
echoed[delay:] = samples[:-delay] * echo
|
||||||
|
samples = samples + echoed
|
||||||
|
samples *= 32767.0 / max(np.max(np.abs(samples)), 1.0)
|
||||||
|
|
||||||
|
if noise_level > 0:
|
||||||
|
noise = np.random.normal(0, noise_level * 32767, size=samples.shape)
|
||||||
|
# Fade static in/out at the clip edges so it breathes like a real
|
||||||
|
# spirit-box sweep instead of clicking.
|
||||||
|
fade = np.ones(len(samples), dtype=np.float32)
|
||||||
|
edge = min(len(samples) // 8, int(params.framerate * 0.05))
|
||||||
|
if edge > 0:
|
||||||
|
ramp = np.linspace(0.15, 1.0, edge)
|
||||||
|
fade[:edge] = ramp
|
||||||
|
fade[-edge:] = ramp[::-1]
|
||||||
|
samples = samples + noise * fade
|
||||||
|
|
||||||
|
return _write_wav(samples, params)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_static_effect(wav_bytes: bytes, noise_level: float = 0.02) -> bytes:
|
||||||
|
"""Adds white noise to a mono 16-bit PCM WAV, simulating spirit-box static."""
|
||||||
|
return apply_effects(wav_bytes, noise_level=noise_level)
|
||||||
67
backend/app/tts/piper.py
Normal file
67
backend/app/tts/piper.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Async wrapper around the local Piper CLI (`python -m piper`).
|
||||||
|
|
||||||
|
Piper runs fully on CPU on this host; a small semaphore keeps concurrent
|
||||||
|
syntheses from stomping each other's onnxruntime thread pools."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.tts.effects import apply_effects
|
||||||
|
from app.tts.voices import Voice
|
||||||
|
|
||||||
|
_synth_semaphore = asyncio.Semaphore(2)
|
||||||
|
|
||||||
|
|
||||||
|
class PiperTTS:
|
||||||
|
"""Wraps the Piper CLI to synthesize speech locally, no cloud calls."""
|
||||||
|
|
||||||
|
def __init__(self, voice_model_path: str):
|
||||||
|
self._voice_model_path = voice_model_path
|
||||||
|
|
||||||
|
async def synthesize(self, text: str) -> bytes:
|
||||||
|
"""Synthesize text to raw WAV bytes via the piper CLI."""
|
||||||
|
async with _synth_semaphore:
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||||
|
out_path = tmp.name
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"piper",
|
||||||
|
"--model",
|
||||||
|
self._voice_model_path,
|
||||||
|
"--output_file",
|
||||||
|
out_path,
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(
|
||||||
|
process.communicate(text.encode("utf-8")), timeout=60.0
|
||||||
|
)
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise RuntimeError(f"piper exited with {process.returncode}")
|
||||||
|
return Path(out_path).read_bytes()
|
||||||
|
finally:
|
||||||
|
Path(out_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def synthesize_spirit_voice(
|
||||||
|
text: str, voice: Voice, voice_profile: dict | None = None
|
||||||
|
) -> bytes:
|
||||||
|
"""One call: Piper synth + the spirit's signature effects chain."""
|
||||||
|
profile = voice_profile or {}
|
||||||
|
model_path = Path(settings.piper_voices_dir) / voice.model_file
|
||||||
|
wav = await PiperTTS(str(model_path)).synthesize(text)
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
apply_effects,
|
||||||
|
wav,
|
||||||
|
noise_level=float(profile.get("noise", 0.03)),
|
||||||
|
pitch_semitones=float(profile.get("pitch", 0.0)),
|
||||||
|
rate=float(profile.get("rate", 1.0)),
|
||||||
|
bitcrush_bits=int(profile.get("bitcrush", 0)),
|
||||||
|
echo=float(profile.get("echo", 0.2)),
|
||||||
|
)
|
||||||
40
backend/app/tts/voices.py
Normal file
40
backend/app/tts/voices.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Catalog of locally installed Piper voices.
|
||||||
|
|
||||||
|
Each entity's voice_profile pins one of these voice ids plus pitch/rate/noise
|
||||||
|
shaping, giving every spirit its own recognizable throat."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Voice:
|
||||||
|
id: str
|
||||||
|
model_file: str
|
||||||
|
language: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
VOICES: dict[str, Voice] = {
|
||||||
|
"lessac": Voice("lessac", "en_US-lessac-low.onnx", "en", "a measured American woman"),
|
||||||
|
"amy": Voice("amy", "en_US-amy-low.onnx", "en", "a soft American woman"),
|
||||||
|
"ryan": Voice("ryan", "en_US-ryan-low.onnx", "en", "a deep American man"),
|
||||||
|
"alan": Voice("alan", "en_GB-alan-low.onnx", "en", "a low British man"),
|
||||||
|
"hfc_male": Voice("hfc_male", "en_US-hfc_male-medium.onnx", "en", "a worn male voice"),
|
||||||
|
"hfc_female": Voice(
|
||||||
|
"hfc_female", "en_US-hfc_female-medium.onnx", "en", "a worn female voice"
|
||||||
|
),
|
||||||
|
"davefx": Voice("davefx", "es_ES-davefx-medium.onnx", "es", "una voz masculina grave"),
|
||||||
|
"ald": Voice("ald", "es_MX-ald-medium.onnx", "es", "una voz masculina seca"),
|
||||||
|
}
|
||||||
|
|
||||||
|
EN_VOICE_IDS = [v.id for v in VOICES.values() if v.language == "en"]
|
||||||
|
ES_VOICE_IDS = [v.id for v in VOICES.values() if v.language == "es"]
|
||||||
|
|
||||||
|
|
||||||
|
def pick_voice(voice_id: str | None, language: str) -> Voice:
|
||||||
|
"""Resolve an entity's voice id, falling back to a language-appropriate default."""
|
||||||
|
if voice_id and voice_id in VOICES:
|
||||||
|
voice = VOICES[voice_id]
|
||||||
|
if voice.language == language:
|
||||||
|
return voice
|
||||||
|
return VOICES["davefx" if language == "es" else "lessac"]
|
||||||
426
backend/app/ws.py
Normal file
426
backend/app/ws.py
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
"""The séance channel: one WebSocket per contact session.
|
||||||
|
|
||||||
|
Protocol (client → server):
|
||||||
|
{"type": "ping"} → {"type": "pong"}
|
||||||
|
{"type": "set_mode", "mode": MODE} → {"type": "mode", ...}
|
||||||
|
{"type": "language", "language": "en"|"es"}
|
||||||
|
{"type": "summon"} → {"type": "entity", ...}
|
||||||
|
{"type": "anomaly", "source": SRC, ...} → {"type": "utterance", ...}
|
||||||
|
{"type": "question", "text": "..."} → reply_start / reply_token* / reply_end
|
||||||
|
{"type": "passive", "enabled": bool} → ambient wire loop on/off
|
||||||
|
|
||||||
|
All server → client frames flow through a single sender task so concurrent
|
||||||
|
producers (ambient loop, reply streaming, TTS callbacks) never interleave on
|
||||||
|
the wire.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import random
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import async_session_maker as _default_session_maker
|
||||||
|
from app.deps import SESSION_COOKIE_NAME
|
||||||
|
from app.entities import fallback_signature, signature_from_anomalies
|
||||||
|
from app.llm.service import SpiritBusyError, spirit_service
|
||||||
|
from app.models.auth_session import AuthSession, hash_token
|
||||||
|
from app.models.contact_session import ContactSession
|
||||||
|
from app.models.entity import Entity
|
||||||
|
from app.models.entity_sighting import EntitySighting
|
||||||
|
from app.models.event import Event
|
||||||
|
from app.rate_limit import RateLimiter
|
||||||
|
from app.telemetry import sample_network
|
||||||
|
from app.tts.piper import synthesize_spirit_voice
|
||||||
|
from app.tts.voices import pick_voice
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Alias so tests can swap in the NullPool test session maker.
|
||||||
|
session_maker = _default_session_maker
|
||||||
|
|
||||||
|
MODES = {"wire", "evp", "radio", "ouija"}
|
||||||
|
|
||||||
|
# Per-user limiters for every LLM-triggering message type (spec §5).
|
||||||
|
fragment_limiter = RateLimiter(max_requests=30, window_seconds=60)
|
||||||
|
question_limiter = RateLimiter(max_requests=6, window_seconds=60)
|
||||||
|
summon_limiter = RateLimiter(max_requests=4, window_seconds=60)
|
||||||
|
|
||||||
|
AUDIO_DIR = Path(settings.data_dir) / "audio"
|
||||||
|
|
||||||
|
|
||||||
|
def _audio_dir() -> Path:
|
||||||
|
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
return AUDIO_DIR
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SeanceState:
|
||||||
|
user_id: uuid.UUID
|
||||||
|
session_id: uuid.UUID
|
||||||
|
send_queue: asyncio.Queue = field(default_factory=asyncio.Queue)
|
||||||
|
mode: str = "unknown"
|
||||||
|
language: str = "en"
|
||||||
|
entity: dict | None = None
|
||||||
|
anomalies: list[dict] = field(default_factory=list)
|
||||||
|
history: list[dict] = field(default_factory=list)
|
||||||
|
ambient_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_entity(entity: Entity) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(entity.id),
|
||||||
|
"name": entity.name,
|
||||||
|
"epithet": entity.epithet,
|
||||||
|
"persona": entity.persona,
|
||||||
|
"rarity": entity.rarity_tier,
|
||||||
|
"voice": entity.voice_profile,
|
||||||
|
"visual": entity.visual_profile,
|
||||||
|
"quotes": entity.sample_quotes,
|
||||||
|
"contact_count": entity.contact_count,
|
||||||
|
"discovered_at": entity.discovered_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _authenticate(websocket: WebSocket) -> uuid.UUID | None:
|
||||||
|
raw_token = websocket.cookies.get(SESSION_COOKIE_NAME)
|
||||||
|
if raw_token is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
token_hash = hash_token(raw_token)
|
||||||
|
async with session_maker() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
select(AuthSession).where(AuthSession.token_hash == token_hash)
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
if session is None or session.expires_at < datetime.now(timezone.utc):
|
||||||
|
return None
|
||||||
|
return session.user_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _sender(state: SeanceState, websocket: WebSocket) -> None:
|
||||||
|
"""The only task allowed to write to the socket."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
message = await state.send_queue.get()
|
||||||
|
await websocket.send_json(message)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_event(
|
||||||
|
session_id: uuid.UUID,
|
||||||
|
kind: str,
|
||||||
|
text: str | None = None,
|
||||||
|
payload: dict | None = None,
|
||||||
|
) -> uuid.UUID:
|
||||||
|
async with session_maker() as db:
|
||||||
|
event = Event(session_id=session_id, kind=kind, text=text, payload=payload)
|
||||||
|
db.add(event)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(event)
|
||||||
|
return event.id
|
||||||
|
|
||||||
|
|
||||||
|
async def _speak(state: SeanceState, kind: str, text: str) -> None:
|
||||||
|
"""Persist an utterance, push its text immediately, synthesize audio in
|
||||||
|
the background, and push the audio URL when the effects chain finishes."""
|
||||||
|
event_id = await _record_event(
|
||||||
|
state.session_id, "utterance", text=text, payload={"kind": kind}
|
||||||
|
)
|
||||||
|
await state.send_queue.put(
|
||||||
|
{
|
||||||
|
"type": "utterance",
|
||||||
|
"id": str(event_id),
|
||||||
|
"kind": kind,
|
||||||
|
"text": text,
|
||||||
|
"entity": state.entity["name"] if state.entity else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _synth() -> None:
|
||||||
|
try:
|
||||||
|
voice_profile = state.entity.get("voice", {}) if state.entity else {}
|
||||||
|
voice = pick_voice(voice_profile.get("voice_id"), state.language)
|
||||||
|
wav = await synthesize_spirit_voice(text, voice, voice_profile)
|
||||||
|
filename = f"{event_id}.wav"
|
||||||
|
_audio_dir().joinpath(filename).write_bytes(wav)
|
||||||
|
async with session_maker() as db:
|
||||||
|
event = await db.get(Event, event_id)
|
||||||
|
if event is not None:
|
||||||
|
event.audio_path = filename
|
||||||
|
await db.commit()
|
||||||
|
await state.send_queue.put(
|
||||||
|
{"type": "audio", "id": str(event_id), "url": f"/audio/{filename}"}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# TTS is texture, not content — the words already reached the
|
||||||
|
# seeker. Never let a synth failure kill the séance.
|
||||||
|
pass
|
||||||
|
|
||||||
|
asyncio.create_task(_synth())
|
||||||
|
|
||||||
|
|
||||||
|
_ROMAN = ["II", "III", "IV", "V", "VI", "VII", "VIII", "IX"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _unique_entity_name(db, base_name: str) -> str:
|
||||||
|
name = base_name
|
||||||
|
i = 0
|
||||||
|
while await db.scalar(select(Entity).where(Entity.name == name)) is not None:
|
||||||
|
name = f"{base_name} {_ROMAN[i]}" if i < len(_ROMAN) else f"{base_name} {i + 2}"
|
||||||
|
i += 1
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
async def _summon(state: SeanceState, channel: str) -> tuple[Entity, bool]:
|
||||||
|
"""Match this session's signature against the Codex, or mint a new entity."""
|
||||||
|
signature = signature_from_anomalies(state.anomalies) or fallback_signature(
|
||||||
|
str(state.session_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
async with session_maker() as db:
|
||||||
|
entity = await db.scalar(select(Entity).where(Entity.signature == signature))
|
||||||
|
is_new = entity is None
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
profile = await spirit_service.mint_profile(
|
||||||
|
signature, channel, state.anomalies, state.language
|
||||||
|
)
|
||||||
|
entity = Entity(
|
||||||
|
name=await _unique_entity_name(db, profile["name"]),
|
||||||
|
epithet=profile["epithet"],
|
||||||
|
persona=profile["persona"],
|
||||||
|
rarity_tier=profile["rarity"],
|
||||||
|
signature=signature,
|
||||||
|
voice_profile=profile["voice"],
|
||||||
|
visual_profile=profile["visual"],
|
||||||
|
sample_quotes=profile["quotes"],
|
||||||
|
discovered_by=state.user_id,
|
||||||
|
contact_count=1,
|
||||||
|
)
|
||||||
|
db.add(entity)
|
||||||
|
else:
|
||||||
|
entity.contact_count += 1
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
session = await db.get(ContactSession, state.session_id)
|
||||||
|
if session is not None:
|
||||||
|
session.entity_id = entity.id
|
||||||
|
db.add(
|
||||||
|
EntitySighting(
|
||||||
|
entity_id=entity.id, session_id=state.session_id, user_id=state.user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entity)
|
||||||
|
return entity, is_new
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_summon(state: SeanceState) -> None:
|
||||||
|
if not summon_limiter.allow(str(state.user_id)):
|
||||||
|
await state.send_queue.put(
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"code": "rate_limited",
|
||||||
|
"message": "The veil is crowded. The spirits need a moment before another summoning.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.send_queue.put({"type": "status", "state": "summoning"})
|
||||||
|
entity, is_new = await _summon(state, state.mode if state.mode != "unknown" else "ouija")
|
||||||
|
state.entity = serialize_entity(entity)
|
||||||
|
await state.send_queue.put(
|
||||||
|
{"type": "entity", "entity": state.entity, "is_new": is_new}
|
||||||
|
)
|
||||||
|
greeting = random.choice(state.entity["quotes"]) if state.entity["quotes"] else "I am here."
|
||||||
|
await _speak(state, "greeting", greeting)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_anomaly(state: SeanceState, message: dict) -> None:
|
||||||
|
anomaly = {
|
||||||
|
"source": str(message.get("source", "unknown"))[:16],
|
||||||
|
"frequency": message.get("frequency"),
|
||||||
|
"magnitude": message.get("magnitude"),
|
||||||
|
}
|
||||||
|
state.anomalies.append(anomaly)
|
||||||
|
state.anomalies = state.anomalies[-64:]
|
||||||
|
await _record_event(state.session_id, "anomaly", payload=anomaly)
|
||||||
|
await state.send_queue.put({"type": "anomaly_ack", "count": len(state.anomalies)})
|
||||||
|
|
||||||
|
if state.entity is None:
|
||||||
|
if signature_from_anomalies(state.anomalies) is not None:
|
||||||
|
# The signal has enough structure — something announces itself.
|
||||||
|
await _handle_summon(state)
|
||||||
|
else:
|
||||||
|
await state.send_queue.put({"type": "status", "state": "attuning"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if not fragment_limiter.allow(str(state.user_id)):
|
||||||
|
return # anomalies during a crowded veil just pass unheard
|
||||||
|
|
||||||
|
try:
|
||||||
|
fragment = await spirit_service.fragment(
|
||||||
|
anomaly["source"], anomaly, state.language
|
||||||
|
)
|
||||||
|
except SpiritBusyError:
|
||||||
|
return
|
||||||
|
await _speak(state, "fragment", fragment)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_question(state: SeanceState, text: str) -> None:
|
||||||
|
if not question_limiter.allow(str(state.user_id)):
|
||||||
|
await state.send_queue.put(
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"code": "rate_limited",
|
||||||
|
"message": "The spirit is spent. Give it a moment to gather itself.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if state.entity is None:
|
||||||
|
await _handle_summon(state)
|
||||||
|
assert state.entity is not None
|
||||||
|
|
||||||
|
text = text.strip()[:500]
|
||||||
|
await _record_event(state.session_id, "question", text=text)
|
||||||
|
await state.send_queue.put({"type": "status", "state": "gathering"})
|
||||||
|
await state.send_queue.put({"type": "reply_start"})
|
||||||
|
|
||||||
|
reply_parts: list[str] = []
|
||||||
|
try:
|
||||||
|
async for token in spirit_service.chat_stream(
|
||||||
|
state.entity, text, state.history, state.language
|
||||||
|
):
|
||||||
|
reply_parts.append(token)
|
||||||
|
await state.send_queue.put({"type": "reply_token", "token": token})
|
||||||
|
except SpiritBusyError:
|
||||||
|
await state.send_queue.put(
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"code": "veil_crowded",
|
||||||
|
"message": "Too many seekers press against the veil. The spirit withdraws.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await state.send_queue.put({"type": "reply_end", "text": ""})
|
||||||
|
return
|
||||||
|
|
||||||
|
reply = "".join(reply_parts).strip()
|
||||||
|
reply_id = await _record_event(state.session_id, "reply", text=reply)
|
||||||
|
state.history.append({"role": "user", "text": text})
|
||||||
|
state.history.append({"role": "spirit", "text": reply})
|
||||||
|
state.history = state.history[-8:]
|
||||||
|
await state.send_queue.put({"type": "reply_end", "id": str(reply_id), "text": reply})
|
||||||
|
if reply:
|
||||||
|
await _speak(state, "reply", reply)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ambient_loop(state: SeanceState) -> None:
|
||||||
|
"""The Wire Ghost's pulse: telemetry every few seconds, a whisper only
|
||||||
|
when the LLM box has been quiet long enough."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(random.uniform(6, 10))
|
||||||
|
try:
|
||||||
|
sample = await sample_network(period_s=1.0)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
await state.send_queue.put({"type": "telemetry", **sample.as_dict()})
|
||||||
|
if not spirit_service.ambient_ready():
|
||||||
|
continue
|
||||||
|
whisper = await spirit_service.wire_whisper(sample.as_dict(), state.language)
|
||||||
|
await _speak(state, "ambient", whisper)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_passive(state: SeanceState, enabled: bool) -> None:
|
||||||
|
if enabled and (state.ambient_task is None or state.ambient_task.done()):
|
||||||
|
state.ambient_task = asyncio.create_task(_ambient_loop(state))
|
||||||
|
await state.send_queue.put({"type": "passive", "enabled": True})
|
||||||
|
elif not enabled and state.ambient_task is not None:
|
||||||
|
state.ambient_task.cancel()
|
||||||
|
state.ambient_task = None
|
||||||
|
await state.send_queue.put({"type": "passive", "enabled": False})
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/session")
|
||||||
|
async def session_socket(websocket: WebSocket) -> None:
|
||||||
|
user_id = await _authenticate(websocket)
|
||||||
|
if user_id is None:
|
||||||
|
await websocket.close(code=4401)
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
async with session_maker() as db:
|
||||||
|
contact_session = ContactSession(user_id=user_id)
|
||||||
|
db.add(contact_session)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(contact_session)
|
||||||
|
|
||||||
|
state = SeanceState(user_id=user_id, session_id=contact_session.id)
|
||||||
|
sender = asyncio.create_task(_sender(state, websocket))
|
||||||
|
await state.send_queue.put({"type": "session", "id": str(contact_session.id)})
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive_json()
|
||||||
|
msg_type = message.get("type")
|
||||||
|
|
||||||
|
if msg_type == "ping":
|
||||||
|
await state.send_queue.put({"type": "pong"})
|
||||||
|
elif msg_type == "set_mode" and message.get("mode") in MODES:
|
||||||
|
state.mode = message["mode"]
|
||||||
|
async with session_maker() as db:
|
||||||
|
session = await db.get(ContactSession, state.session_id)
|
||||||
|
if session is not None:
|
||||||
|
session.mode = state.mode
|
||||||
|
await db.commit()
|
||||||
|
await state.send_queue.put({"type": "mode", "mode": state.mode})
|
||||||
|
elif msg_type == "language" and message.get("language") in ("en", "es"):
|
||||||
|
state.language = message["language"]
|
||||||
|
async with session_maker() as db:
|
||||||
|
session = await db.get(ContactSession, state.session_id)
|
||||||
|
if session is not None:
|
||||||
|
session.language = state.language
|
||||||
|
await db.commit()
|
||||||
|
elif msg_type == "summon":
|
||||||
|
await _handle_summon(state)
|
||||||
|
elif msg_type == "anomaly":
|
||||||
|
await _handle_anomaly(state, message)
|
||||||
|
elif msg_type == "question" and isinstance(message.get("text"), str):
|
||||||
|
await _handle_question(state, message["text"])
|
||||||
|
elif msg_type == "passive":
|
||||||
|
await _handle_passive(state, bool(message.get("enabled")))
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if state.ambient_task is not None:
|
||||||
|
state.ambient_task.cancel()
|
||||||
|
sender.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await sender
|
||||||
|
# ASGI servers may cancel the handler task as soon as the socket
|
||||||
|
# closes, killing any await here mid-flight — so the session-close
|
||||||
|
# write runs detached, surviving the handler's own teardown.
|
||||||
|
asyncio.create_task(_close_session(contact_session.id))
|
||||||
|
|
||||||
|
|
||||||
|
async def _close_session(session_id: uuid.UUID) -> None:
|
||||||
|
try:
|
||||||
|
async with session_maker() as db:
|
||||||
|
session_to_close = await db.get(ContactSession, session_id)
|
||||||
|
if session_to_close is not None:
|
||||||
|
session_to_close.ended_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -8,3 +8,4 @@ httpx==0.27.2
|
|||||||
numpy==2.1.3
|
numpy==2.1.3
|
||||||
pytest==8.3.3
|
pytest==8.3.3
|
||||||
pytest-asyncio==0.24.0
|
pytest-asyncio==0.24.0
|
||||||
|
piper-tts==1.2.0
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.pool import NullPool
|
from sqlalchemy.pool import NullPool
|
||||||
@@ -37,3 +39,25 @@ async def client():
|
|||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="https://test") as ac:
|
async with AsyncClient(transport=transport, base_url="https://test") as ac:
|
||||||
yield 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
|
||||||
|
|||||||
83
backend/tests/test_codex.py
Normal file
83
backend/tests/test_codex.py
Normal 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
|
||||||
62
backend/tests/test_entities.py
Normal file
62
backend/tests/test_entities.py
Normal 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"]
|
||||||
34
backend/tests/test_prompts.py
Normal file
34
backend/tests/test_prompts.py
Normal 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
|
||||||
19
backend/tests/test_telemetry.py
Normal file
19
backend/tests/test_telemetry.py
Normal 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") == {}
|
||||||
89
backend/tests/test_tts_effects.py
Normal file
89
backend/tests/test_tts_effects.py
Normal 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
|
||||||
177
backend/tests/test_ws_session.py
Normal file
177
backend/tests/test_ws_session.py
Normal 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
|
||||||
Reference in New Issue
Block a user