"""DB-backed approved persona catalog. The runtime still uses services.persona.PersonaCard as the in-process card shape. This module is the boundary that materializes seed cards into app.persona_card and converts approved DB rows back into PersonaCard values. """ from __future__ import annotations import json import uuid from dataclasses import dataclass from typing import Any, Iterable from .config import settings from .db import acquire, get_pool from .services.persona import PersonaCard, SEED_PERSONAS, get_seed_persona SEED_VERSION = 1 _CARD_COLUMNS = """ persona_id, code, version, status, display_name, difficulty, theory_target, demographics, presenting, history, big5, resistance, speech_style, affect_baseline, ccd, dsm5_dimensional, source_provenance, is_synthetic """ @dataclass(frozen=True, slots=True) class CatalogPersona: card: PersonaCard persona_id: str | None version: int | None source: str degraded: bool = False def seed_persona_id(code: str) -> str: return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:persona:{code.upper()}")) def _json_dict(value: Any) -> dict[str, Any]: if value is None: return {} if isinstance(value, str): parsed = json.loads(value) return dict(parsed) if isinstance(parsed, dict) else {} return dict(value) def _string_list(value: Iterable[Any] | None) -> list[str]: if value is None: return [] return [str(item) for item in value] def card_from_row(row: Any) -> PersonaCard: return PersonaCard( code=str(row["code"]).upper(), display_name=str(row["display_name"]), difficulty=str(row["difficulty"]), theory_target=_string_list(row["theory_target"]), demographics=_json_dict(row["demographics"]), presenting=_json_dict(row["presenting"]), history=_json_dict(row["history"]), big5=_json_dict(row["big5"]), resistance=_json_dict(row["resistance"]), speech_style=_json_dict(row["speech_style"]), affect_baseline=_json_dict(row["affect_baseline"]), ccd=_json_dict(row["ccd"]), dsm5_dimensional=_json_dict(row["dsm5_dimensional"]), source_provenance=str(row["source_provenance"] or ""), is_synthetic=bool(row["is_synthetic"]), ) def catalog_persona_from_row(row: Any) -> CatalogPersona: return CatalogPersona( card=card_from_row(row), persona_id=str(row["persona_id"]), version=int(row["version"]), source="database", degraded=False, ) def seed_fallback_persona(code: str) -> CatalogPersona | None: card = get_seed_persona(code) if card is None: return None return CatalogPersona( card=card, persona_id=seed_persona_id(card.code), version=SEED_VERSION, source="seed_fallback", degraded=True, ) def seed_fallback_personas() -> list[CatalogPersona]: return [ CatalogPersona( card=card, persona_id=seed_persona_id(card.code), version=SEED_VERSION, source="seed_fallback", degraded=True, ) for card in SEED_PERSONAS.values() ] async def materialize_seed_personas() -> int: """Upsert built-in seed personas as approved DB catalog rows.""" get_pool() count = 0 async with acquire(role="admin") as conn: for card in SEED_PERSONAS.values(): await conn.execute( """ INSERT INTO app.persona_card ( persona_id, code, version, status, display_name, difficulty, theory_target, demographics, presenting, history, big5, resistance, speech_style, affect_baseline, ccd, dsm5_dimensional, source_provenance, is_synthetic, approved_at ) VALUES ( $1::uuid, $2, $3, 'approved', $4, $5, $6::text[], $7::jsonb, $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb, $12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb, $16, $17, now() ) ON CONFLICT (code, version) DO UPDATE SET status = 'approved', display_name = EXCLUDED.display_name, difficulty = EXCLUDED.difficulty, theory_target = EXCLUDED.theory_target, demographics = EXCLUDED.demographics, presenting = EXCLUDED.presenting, history = EXCLUDED.history, big5 = EXCLUDED.big5, resistance = EXCLUDED.resistance, speech_style = EXCLUDED.speech_style, affect_baseline = EXCLUDED.affect_baseline, ccd = EXCLUDED.ccd, dsm5_dimensional = EXCLUDED.dsm5_dimensional, source_provenance = EXCLUDED.source_provenance, is_synthetic = EXCLUDED.is_synthetic, approved_at = COALESCE(persona_card.approved_at, now()) """, seed_persona_id(card.code), card.code, SEED_VERSION, card.display_name, card.difficulty, card.theory_target, card.demographics, card.presenting, card.history, card.big5, card.resistance, card.speech_style, card.affect_baseline, card.ccd, card.dsm5_dimensional, card.source_provenance, card.is_synthetic, ) count += 1 return count async def list_approved_personas() -> list[CatalogPersona]: get_pool() async with acquire(ai_context=True) as conn: rows = await conn.fetch( f""" SELECT {_CARD_COLUMNS} FROM ( SELECT DISTINCT ON (code) {_CARD_COLUMNS} FROM app.persona_card WHERE status = 'approved' ORDER BY code, version DESC ) approved ORDER BY code """ ) return [catalog_persona_from_row(row) for row in rows] async def get_approved_persona(code: str) -> CatalogPersona | None: normalized = code.strip().upper() if not normalized: return None get_pool() async with acquire(ai_context=True) as conn: row = await conn.fetchrow( f""" SELECT {_CARD_COLUMNS} FROM app.persona_card WHERE status = 'approved' AND upper(code) = $1 ORDER BY version DESC LIMIT 1 """, normalized, ) return catalog_persona_from_row(row) if row is not None else None async def list_catalog_personas() -> list[CatalogPersona]: try: return await list_approved_personas() except Exception: if settings.allow_seed_persona_fallback: return seed_fallback_personas() raise async def get_catalog_persona(code: str) -> CatalogPersona | None: try: return await get_approved_persona(code) except Exception: if settings.allow_seed_persona_fallback: return seed_fallback_persona(code) raise __all__ = [ "CatalogPersona", "SEED_VERSION", "card_from_row", "catalog_persona_from_row", "get_approved_persona", "get_catalog_persona", "list_approved_personas", "list_catalog_personas", "materialize_seed_personas", "seed_fallback_persona", "seed_fallback_personas", "seed_persona_id", ]