vignette/apps/api/app/persona_repository.py
2026-06-27 19:04:46 +09:00

641 lines
20 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 .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",
]