Merge Workstream A: entity hidden traits + migration

This commit is contained in:
Indiana
2026-07-24 03:56:48 +00:00
4 changed files with 117 additions and 0 deletions

View File

@@ -96,6 +96,23 @@ def parse_mint_response(text: str) -> dict | 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."""
@@ -138,6 +155,7 @@ def normalize_profile(profile: dict, signature: str) -> dict:
},
"quotes": [str(q)[:200] for q in quotes[:4] if isinstance(q, str)]
or rng.sample(_QUOTE_BANK, 2),
"traits": roll_traits(signature),
}

View File

@@ -6,6 +6,7 @@ from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
import app.models # noqa: F401 — registers models on Base.metadata before create_all
from app.config import settings
@@ -45,6 +46,12 @@ async def lifespan(app: FastAPI):
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# No Alembic in this repo — `create_all` never alters existing
# tables, so columns added to live models need a manual, idempotent
# migration here. Safe to run on every startup.
await conn.execute(text(
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS traits JSONB NOT NULL DEFAULT '{}'::jsonb"
))
cleanup_task = asyncio.create_task(_session_cleanup_loop())
try:
yield

View File

@@ -24,6 +24,7 @@ class Entity(Base):
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)
traits: 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(

View File

@@ -1,9 +1,15 @@
import inspect
from app.entities import (
fallback_profile,
normalize_profile,
parse_mint_response,
roll_traits,
signature_from_anomalies,
)
from app.llm import prompts as llm_prompts
TRAIT_KEYS = ("alignment", "power", "volatility", "deceptiveness")
def _anomaly(freq, mag):
@@ -60,3 +66,88 @@ def test_fallback_profile_is_deterministic_and_valid():
assert one["name"]
assert one["rarity"] in ("common", "uncommon", "rare", "mythic")
assert one["quotes"]
# --- hidden traits (Workstream A) -------------------------------------
def test_roll_traits_values_are_in_range():
traits = roll_traits("some-signature-1")
assert set(traits.keys()) == set(TRAIT_KEYS)
for key in TRAIT_KEYS:
assert isinstance(traits[key], float)
assert 0.0 <= traits[key] <= 1.0
def test_roll_traits_is_deterministic_for_same_signature():
one = roll_traits("repeatable-signature")
two = roll_traits("repeatable-signature")
assert one == two
def test_roll_traits_varies_by_signature():
values = {tuple(roll_traits(f"sig-{i}").values()) for i in range(20)}
# 20 distinct signatures should not collapse onto a single roll.
assert len(values) > 1
def test_normalize_profile_includes_traits_in_range():
profile = normalize_profile({"name": "Hollow Briar"}, "traits-norm-signature")
traits = profile["traits"]
assert set(traits.keys()) == set(TRAIT_KEYS)
for key in TRAIT_KEYS:
assert 0.0 <= traits[key] <= 1.0
def test_normalize_profile_traits_match_roll_traits_for_signature():
signature = "cross-check-signature"
profile = normalize_profile({"name": "X"}, signature)
assert profile["traits"] == roll_traits(signature)
def test_normalize_profile_traits_are_signature_deterministic():
one = normalize_profile({"name": "A"}, "det-signature")
two = normalize_profile({"name": "B", "rarity": "rare"}, "det-signature")
# Same signature -> same traits every call, regardless of the rest of
# the (LLM-supplied or fallback) profile passed in.
assert one["traits"] == two["traits"]
def test_fallback_profile_includes_traits_in_range():
profile = fallback_profile("fallback-traits-signature")
traits = profile["traits"]
assert set(traits.keys()) == set(TRAIT_KEYS)
for key in TRAIT_KEYS:
assert 0.0 <= traits[key] <= 1.0
def test_fallback_profile_traits_are_signature_deterministic():
one = fallback_profile("fallback-det-signature")
two = fallback_profile("fallback-det-signature")
assert one["traits"] == two["traits"]
def test_persona_is_independent_of_rolled_traits():
"""Persona text must stay fully decoupled from hidden truth: pairing the
exact same persona string with many different signatures should surface
a spread of alignment rolls, not a single value tied to the persona."""
fixed_persona = "A sweet old lady who just wants to chat about her garden."
alignments = set()
for i in range(40):
profile = normalize_profile(
{"name": "Nana", "persona": fixed_persona}, f"persona-independence-{i}"
)
assert profile["persona"] == fixed_persona
alignments.add(round(profile["traits"]["alignment"], 3))
# A meaningful spread (not clustered on one or two values) shows
# alignment isn't derived from — or gated by — the persona text.
assert len(alignments) > 20
def test_mint_prompt_never_receives_traits():
"""`mint_prompt` builds the LLM system/user prompt; traits must never be
one of its inputs, so hidden ground truth can't leak into persona
generation."""
params = set(inspect.signature(llm_prompts.mint_prompt).parameters)
assert "traits" not in params
assert params == {"signature", "channel", "anomaly_summary", "voice_ids"}