"""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 .paths import repo_path from .services.persona import PersonaCard, SEED_PERSONAS, get_seed_persona from .services.voice import resolve_voice SEED_VERSION = 1 REPO_PERSONA_DIR = repo_path("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, triggers """ _REVIEW_COLUMNS = """ persona_id, code, version, status, display_name, difficulty, theory_target, source_provenance, is_synthetic, created_at, approved_at """ # UPDATE ... FROM target 처럼 다른 테이블이 스코프에 있는 RETURNING에서는 # 무한정 `code`가 target.code와 모호해진다(AmbiguousColumnError → 보관 503). _REVIEW_COLUMNS_CARD_QUALIFIED = ", ".join( f"card.{column.strip()}" for column in _REVIEW_COLUMNS.split(",") ) _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 PersonaVoiceMap: provider: str voice_id: str base_params: dict[str, Any] @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)), triggers=_json_dict(payload.get("triggers")), ) 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"]), triggers=_json_dict(row["triggers"]), ) 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 persona_voice_map_from_row(row: Any) -> PersonaVoiceMap: return PersonaVoiceMap( provider=str(row["provider"]), voice_id=str(row["voice_id"]), base_params=_json_dict(row["base_params"]), ) 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: """Materialize built-in seed personas as editable DB catalog rows. Existing rows belong to the authoring database. Startup seeding must not overwrite faculty edits or resurrect archived personas. """ get_pool() count = 0 async with acquire(role="admin") as conn: for card in built_in_personas(): persona_id = seed_persona_id(card.code) 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, triggers, 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, $18::jsonb, now() ) ON CONFLICT (code, version) DO NOTHING """, persona_id, 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, card.triggers, ) voice = resolve_voice(persona_code=card.code) await conn.execute( """ INSERT INTO app.persona_voice_map ( persona_id, version, voice_id, provider, base_params, prosody_map ) VALUES ($1::uuid, $2, $3, 'openai', $4::jsonb, '{}'::jsonb) ON CONFLICT (persona_id, version) DO NOTHING """, persona_id, SEED_VERSION, voice.openai_voice, { "preset": voice.preset, "openai_voice": voice.openai_voice, "rate": voice.rate, }, ) 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 get_persona_voice_map( *, persona_id: str | None, version: int | None, ) -> PersonaVoiceMap | None: if not persona_id or version is None: return None get_pool() async with acquire(ai_context=True) as conn: row = await conn.fetchrow( """ SELECT provider, voice_id, base_params FROM app.persona_voice_map WHERE persona_id = $1::uuid AND version = $2 """, persona_id, version, ) return persona_voice_map_from_row(row) if row is not None else None async def get_session_voice_map(session_id: str) -> PersonaVoiceMap | None: get_pool() async with acquire(ai_context=True) as conn: row = await conn.fetchrow( """ SELECT pvm.provider, pvm.voice_id, pvm.base_params FROM app.sessions AS s JOIN app.persona_voice_map AS pvm ON pvm.persona_id = s.persona_id AND pvm.version = s.persona_version WHERE s.id = $1::uuid """, session_id, ) return persona_voice_map_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 _insert_persona_card( conn: Any, *, persona_id: str, card: PersonaCard, version: int, next_status: str, author_id: str, returning_columns: str, ) -> Any: """Insert one immutable persona version from the canonical PersonaCard shape.""" return 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, triggers, 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::jsonb, $20::uuid ) RETURNING {returning_columns} """, persona_id, card.code, version, 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, card.triggers, author_id, ) async def _record_persona_create_audit( conn: Any, *, author_id: str, action: str, persona_id: str, next_status: str, card: PersonaCard, version: int, ) -> 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, action, "persona_card", persona_id, {"next_status": next_status, "code": card.code, "version": version}, ) 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 _insert_persona_card( conn, persona_id=persona_id, card=card, version=int(version or 1), next_status=next_status, author_id=author_id, returning_columns=_REVIEW_COLUMNS, ) await _record_persona_create_audit( conn, author_id=author_id, action="persona_draft_create", persona_id=persona_id, next_status=next_status, card=card, version=int(row["version"]), ) return persona_review_item_from_row(row) async def create_persona_revision_from_existing( *, persona_id: str, author_id: str, role: str, submit_for_review: bool = False, ) -> PersonaDraftRecord | None: """Clone an approved persona into a new editable draft version.""" if role not in {"teacher", "admin"}: raise ValueError("persona revision creation 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: source = await conn.fetchrow( f""" SELECT {_CARD_COLUMNS}, created_at, approved_at FROM app.persona_card WHERE persona_id = $1::uuid AND status = 'approved' ORDER BY version DESC LIMIT 1 """, persona_id, ) if source is None: return None card = card_from_row(source) existing = await conn.fetchrow( f""" SELECT {_CARD_COLUMNS}, created_at, approved_at FROM app.persona_card WHERE upper(code) = upper($1) AND status IN ('draft', 'review') ORDER BY version DESC LIMIT 1 """, card.code, ) if existing is not None: return persona_draft_record_from_row(existing) version = await conn.fetchval( """ SELECT COALESCE(MAX(version), 0) + 1 FROM app.persona_card WHERE upper(code) = upper($1) """, card.code, ) row = await _insert_persona_card( conn, persona_id=persona_id, card=card, version=int(version or 1), next_status=next_status, author_id=author_id, returning_columns=f"{_CARD_COLUMNS}, created_at, approved_at", ) await _record_persona_create_audit( conn, author_id=author_id, action="persona_revision_create", persona_id=persona_id, next_status=next_status, card=card, version=int(row["version"]), ) return persona_draft_record_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, triggers = $18::jsonb, 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, card.triggers, ) 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 archive_persona_family( *, persona_id: str, archiver_id: str, role: str, ) -> PersonaReviewItem | None: """Archive every non-archived version for the persona code. This is the delete operation exposed to faculty. It preserves historical session foreign keys while removing the persona from the start catalog. """ if role not in {"teacher", "admin"}: raise ValueError("persona archive requires teacher or admin role") get_pool() async with acquire(role=role, user_id=archiver_id) as conn: row = await conn.fetchrow( f""" WITH target AS ( SELECT code FROM app.persona_card WHERE persona_id = $1::uuid AND status <> 'archived' LIMIT 1 ), archived AS ( UPDATE app.persona_card AS card SET status = 'archived', approved_by = NULL, approved_at = NULL FROM target WHERE upper(card.code) = upper(target.code) AND card.status <> 'archived' RETURNING {_REVIEW_COLUMNS_CARD_QUALIFIED} ) SELECT {_REVIEW_COLUMNS} FROM archived WHERE persona_id = $1::uuid ORDER BY version DESC LIMIT 1 """, persona_id, ) 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) """, archiver_id, "persona_archive", "persona_card", persona_id, { "next_status": "archived", "code": str(row["code"]).upper(), "version": int(row["version"]), "scope": "code_family", }, ) 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", "archive_persona_family", "built_in_personas", "card_from_row", "catalog_persona_from_row", "create_persona_draft", "create_persona_revision_from_existing", "get_approved_persona", "get_catalog_persona", "get_persona_draft_record", "get_persona_voice_map", "get_session_voice_map", "list_approved_personas", "list_catalog_personas", "list_persona_review_queue", "load_file_personas", "materialize_seed_personas", "persona_draft_record_from_row", "persona_voice_map_from_row", "persona_review_item_from_row", "PersonaVoiceMap", "seed_fallback_persona", "seed_fallback_personas", "seed_persona_id", "update_persona_draft", "update_persona_review_status", ]