"""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, Literal, cast from .config import settings from .db import acquire, get_pool from .services.persona import PersonaCard, SEED_PERSONAS, get_seed_persona SEED_VERSION = 1 PersonaStatus = Literal["draft", "review", "approved", "archived"] _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 """ _REVIEW_COLUMNS = """ persona_id, code, version, status, display_name, difficulty, theory_target, source_provenance, is_synthetic, created_at, approved_at """ _PERSONA_STATUSES = {"draft", "review", "approved", "archived"} _REVIEW_QUEUE_STATUSES = ("draft", "review") PersonaReviewAction = Literal["approve", "reject"] @dataclass(frozen=True, slots=True) class CatalogPersona: card: PersonaCard persona_id: str | None version: int | None source: str degraded: bool = False @dataclass(frozen=True, slots=True) class PersonaReviewItem: persona_id: str code: str version: int status: PersonaStatus display_name: str difficulty: str theory_target: list[str] source_provenance: str is_synthetic: bool created_at: str | None approved_at: str | None 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 _optional_text(value: Any) -> str | None: if value is None: return None isoformat = getattr(value, "isoformat", None) if callable(isoformat): return str(isoformat()) return str(value) def _normalize_statuses(statuses: Iterable[str]) -> list[str]: normalized: list[str] = [] for status in statuses: value = str(status).strip().lower() if value in _PERSONA_STATUSES and value not in normalized: normalized.append(value) return normalized 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 persona_review_item_from_row(row: Any) -> PersonaReviewItem: return PersonaReviewItem( persona_id=str(row["persona_id"]), code=str(row["code"]).upper(), version=int(row["version"]), status=cast(PersonaStatus, str(row["status"]).lower()), display_name=str(row["display_name"]), difficulty=str(row["difficulty"]), theory_target=_string_list(row["theory_target"]), source_provenance=str(row["source_provenance"] or ""), is_synthetic=bool(row["is_synthetic"]), created_at=_optional_text(row["created_at"]), approved_at=_optional_text(row["approved_at"]), ) 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_persona_review_queue( *, role: str, statuses: Iterable[str] = _REVIEW_QUEUE_STATUSES, ) -> list[PersonaReviewItem]: if role not in {"teacher", "admin"}: raise ValueError("persona review queue requires teacher or admin role") status_values = _normalize_statuses(statuses) if not status_values: return [] get_pool() async with acquire(role=role) as conn: rows = await conn.fetch( f""" SELECT {_REVIEW_COLUMNS} FROM app.persona_card WHERE status = ANY($1::text[]) ORDER BY CASE status WHEN 'review' THEN 0 WHEN 'draft' THEN 1 ELSE 2 END, code, version DESC """, status_values, ) return [persona_review_item_from_row(row) for row in rows] async def update_persona_review_status( *, persona_id: str, action: PersonaReviewAction, reviewer_id: str, role: str, ) -> PersonaReviewItem | None: if role not in {"teacher", "admin"}: raise ValueError("persona review update requires teacher or admin role") if action not in {"approve", "reject"}: raise ValueError("unsupported persona review action") next_status = "approved" if action == "approve" else "draft" approved_by = reviewer_id if action == "approve" else None approved_at_expr = "now()" if action == "approve" else "NULL" get_pool() async with acquire(role=role, user_id=reviewer_id) as conn: row = await conn.fetchrow( f""" UPDATE app.persona_card SET status = $2, approved_by = $3::uuid, approved_at = {approved_at_expr} WHERE persona_id = $1::uuid AND status IN ('draft', 'review') RETURNING {_REVIEW_COLUMNS} """, persona_id, next_status, approved_by, ) if row is None: return None await conn.execute( """ INSERT INTO audit.audit_log ( actor_uid, action, target_kind, target_id, detail ) VALUES ($1::uuid, $2, $3, $4, $5::jsonb) """, reviewer_id, f"persona_{action}", "persona_card", persona_id, { "next_status": next_status, "code": str(row["code"]).upper(), "version": int(row["version"]), }, ) return persona_review_item_from_row(row) 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", "PersonaReviewItem", "PersonaReviewAction", "PersonaStatus", "SEED_VERSION", "card_from_row", "catalog_persona_from_row", "get_approved_persona", "get_catalog_persona", "list_approved_personas", "list_catalog_personas", "list_persona_review_queue", "materialize_seed_personas", "persona_review_item_from_row", "seed_fallback_persona", "seed_fallback_personas", "seed_persona_id", "update_persona_review_status", ]