diff --git a/backend/app/entities.py b/backend/app/entities.py index 3b1722a..3548f11 100644 --- a/backend/app/entities.py +++ b/backend/app/entities.py @@ -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), } diff --git a/backend/app/main.py b/backend/app/main.py index 5f9a429..887f8e1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models/entity.py b/backend/app/models/entity.py index d5d6042..5a18f61 100644 --- a/backend/app/models/entity.py +++ b/backend/app/models/entity.py @@ -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( diff --git a/backend/tests/test_entities.py b/backend/tests/test_entities.py index 212e539..78bfcb2 100644 --- a/backend/tests/test_entities.py +++ b/backend/tests/test_entities.py @@ -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"}