vignette/apps/api/app/persona_repository.py
2026-07-15 21:31:30 +09:00

904 lines
27 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
"""
_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}
)
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",
]