vignette/apps/api/app/persona_repository.py
Yun Chan 4511383cd9 전수 E2E 순회·소유자 결정 6건 구현·디자인 감사 반영
- 전수 순회: IA 전 라우트 412개 기능 인벤토리를 체크리스트 백로그로 관리
  (docs/ops/e2e-full-sweep-2026-07-27.md), 신규 full-sweep 스펙 10파일 추가.
  RED→GREEN으로 결함 12건 수정: 관리자 무한 렌더 프리즈(TanStack autoReset
  루프), 복수 코호트 저장 유실, 페르소나 보관 503(SQL 컬럼 모호성), PII 과잉
  마스킹, /admin/ai watchdog 오탐 오버레이, 모바일 겹침 2건, 설정 스크롤
  스파이, 온보딩 전화번호 무검증, 리뷰 조사·난도 라벨, pending 피드백 등.
- 소유자 결정 구현: 설정 아바타 변경, 동의 철회·재동의 전체 흐름, 신규
  학습자 기초 우선 추천, 학생 분석 테이블 가상화(@tanstack/react-virtual),
  저작 모드 죽은 레일 정리, 감정 밸런스 타임라인 차트(deep turn_valence +
  결정론 파생 폴백).
- 디자인 감사(142차): 라이트 팔레트 AA 대비, 다크 토큰 별칭 통일, 미정의
  CSS 변수 정리, 한글 keep-all 전역화, 탭 타깃 24px, LCP preconnect.
- 검증: npm run e2e 병렬 432 수집 GREEN + 직렬 49/49 exit 0, 백엔드 pytest
  421, gateway 29, typecheck/build/design-ssot/dead-code/중복 게이트 통과,
  layout-visual-gate 15/15, session-layout 8/8. 상세는 SSOT 대시보드
  142~144차 노트.
2026-07-27 14:25:24 +09:00

910 lines
28 KiB
Python

"""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",
]