"""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 pathlib import Path 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 REPO_PERSONA_DIR = Path(__file__).resolve().parents[3] / "data" / "personas" 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 @dataclass(frozen=True, slots=True) class PersonaDraftRecord: review: PersonaReviewItem card: PersonaCard def seed_persona_id(code: str) -> str: return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:persona:{code.upper()}")) def _card_from_payload(payload: dict[str, Any]) -> PersonaCard: return PersonaCard( code=str(payload["code"]).strip().upper(), display_name=str(payload["display_name"]), difficulty=str(payload["difficulty"]), theory_target=_string_list(payload.get("theory_target")), demographics=_json_dict(payload.get("demographics")), presenting=_json_dict(payload.get("presenting")), history=_json_dict(payload.get("history")), big5=_json_dict(payload.get("big5")), resistance=_json_dict(payload.get("resistance")), speech_style=_json_dict(payload.get("speech_style")), affect_baseline=_json_dict(payload.get("affect_baseline")), ccd=_json_dict(payload.get("ccd")), dsm5_dimensional=_json_dict(payload.get("dsm5_dimensional")), source_provenance=str(payload.get("source_provenance") or ""), is_synthetic=bool(payload.get("is_synthetic", True)), ) def load_file_personas(persona_dir: Path = REPO_PERSONA_DIR) -> list[PersonaCard]: """Load repository-managed persona cards that are not hard-coded in persona.py.""" if not persona_dir.exists(): return [] cards: list[PersonaCard] = [] for path in sorted(persona_dir.glob("P*.json")): payload = json.loads(path.read_text(encoding="utf-8")) if isinstance(payload, dict): cards.append(_card_from_payload(payload)) return cards def built_in_personas() -> list[PersonaCard]: """Return deterministic built-in catalog cards from code seeds plus repo JSON cards.""" cards: dict[str, PersonaCard] = {card.code.upper(): card for card in SEED_PERSONAS.values()} for card in load_file_personas(): cards.setdefault(card.code.upper(), card) return [cards[code] for code in sorted(cards)] 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 persona_draft_record_from_row(row: Any) -> PersonaDraftRecord: return PersonaDraftRecord( review=persona_review_item_from_row(row), card=card_from_row(row), ) def seed_fallback_persona(code: str) -> CatalogPersona | None: normalized = code.strip().upper() card = get_seed_persona(normalized) if card is None: card = next((entry for entry in load_file_personas() if entry.code.upper() == normalized), None) 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 built_in_personas() ] 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 built_in_personas(): 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 get_persona_draft_record( *, persona_id: str, role: str, ) -> PersonaDraftRecord | None: if role not in {"teacher", "admin"}: raise ValueError("persona draft read requires teacher or admin role") get_pool() async with acquire(role=role) as conn: row = await conn.fetchrow( f""" SELECT {_CARD_COLUMNS}, created_at, approved_at FROM app.persona_card WHERE persona_id = $1::uuid AND status IN ('draft', 'review') LIMIT 1 """, persona_id, ) return persona_draft_record_from_row(row) if row is not None else None async def create_persona_draft( *, card: PersonaCard, author_id: str, role: str, submit_for_review: bool = False, ) -> PersonaReviewItem: if role not in {"teacher", "admin"}: raise ValueError("persona draft creation requires teacher or admin role") next_status = "review" if submit_for_review else "draft" persona_id = str(uuid.uuid4()) get_pool() async with acquire(role=role, user_id=author_id) as conn: version = await conn.fetchval( """ SELECT COALESCE(MAX(version), 0) + 1 FROM app.persona_card WHERE upper(code) = upper($1) """, card.code, ) row = await conn.fetchrow( f""" 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, created_by ) VALUES ( $1::uuid, $2, $3, $4, $5, $6, $7::text[], $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb, $12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb, $16::jsonb, $17, $18, $19::uuid ) RETURNING {_REVIEW_COLUMNS} """, persona_id, card.code, int(version or 1), next_status, 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, author_id, ) await conn.execute( """ INSERT INTO audit.audit_log ( actor_uid, action, target_kind, target_id, detail ) VALUES ($1::uuid, $2, $3, $4, $5::jsonb) """, author_id, "persona_draft_create", "persona_card", persona_id, { "next_status": next_status, "code": card.code, "version": int(row["version"]), }, ) return persona_review_item_from_row(row) async def update_persona_draft( *, persona_id: str, card: PersonaCard, author_id: str, role: str, submit_for_review: bool = False, ) -> PersonaReviewItem | None: if role not in {"teacher", "admin"}: raise ValueError("persona draft update requires teacher or admin role") next_status = "review" if submit_for_review else "draft" get_pool() async with acquire(role=role, user_id=author_id) as conn: row = await conn.fetchrow( f""" UPDATE app.persona_card SET code = $2, status = $3, display_name = $4, difficulty = $5, theory_target = $6::text[], demographics = $7::jsonb, presenting = $8::jsonb, history = $9::jsonb, big5 = $10::jsonb, resistance = $11::jsonb, speech_style = $12::jsonb, affect_baseline = $13::jsonb, ccd = $14::jsonb, dsm5_dimensional = $15::jsonb, source_provenance = $16, is_synthetic = $17, approved_by = NULL, approved_at = NULL WHERE persona_id = $1::uuid AND status IN ('draft', 'review') RETURNING {_REVIEW_COLUMNS} """, persona_id, card.code, next_status, 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, ) 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) """, author_id, "persona_draft_update", "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 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", "PersonaDraftRecord", "PersonaReviewItem", "PersonaReviewAction", "PersonaStatus", "SEED_VERSION", "built_in_personas", "card_from_row", "catalog_persona_from_row", "create_persona_draft", "get_approved_persona", "get_catalog_persona", "get_persona_draft_record", "list_approved_personas", "list_catalog_personas", "list_persona_review_queue", "load_file_personas", "materialize_seed_personas", "persona_draft_record_from_row", "persona_review_item_from_row", "seed_fallback_persona", "seed_fallback_personas", "seed_persona_id", "update_persona_draft", "update_persona_review_status", ]