Files
qtalker---/backend/app/entities.py
Indiana e5bc253105 feat: add hidden entity traits (Workstream A) + idempotent migration
Entity.traits (alignment/power/volatility/deceptiveness, 0.0-1.0 each) is
rolled once at mint time in entities.py, seeded from the entity's
signature via random.Random(f"traits:{signature}") — a separate rng
namespace from normalize_profile's existing "norm:" rng, and never fed
into mint_prompt, so persona text stays fully decoupled from ground
truth. normalize_profile now includes "traits" in its returned dict;
fallback_profile inherits it for free since it already delegates to
normalize_profile.

Adds the new JSONB column to the Entity model (default {}) and the
idempotent `ALTER TABLE entities ADD COLUMN IF NOT EXISTS traits ...`
migration line to main.py's lifespan, per the live-Postgres migration
convention this spec introduces (no Alembic in this repo).

Tests cover trait value ranges, signature-determinism, and
persona/trait independence (same persona template pairs with a wide
spread of alignment rolls across signatures), plus a regression check
that mint_prompt's signature never grows a traits parameter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 11:15:25 +00:00

180 lines
7.2 KiB
Python

"""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 roll_traits(signature: str) -> dict:
"""Roll the four hidden truth-traits for an entity, seeded from its
signature. These are never derived from — or fed into — the LLM persona
prompt (see `mint_prompt`, which never sees this function's output):
persona text must stay fully decoupled from ground truth, so a
convincing "sweet old lady" persona can pair with any alignment roll.
A separate `random.Random` namespace (`"traits:"` vs. `normalize_profile`'s
`"norm:"`) keeps this roll independent of the cosmetic-defaults rng."""
rng = random.Random(f"traits:{signature}")
return {
"alignment": rng.uniform(0.0, 1.0),
"power": rng.uniform(0.0, 1.0),
"volatility": rng.uniform(0.0, 1.0),
"deceptiveness": rng.uniform(0.0, 1.0),
}
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),
"traits": roll_traits(signature),
}
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,
)