vignette/apps/api/app/persona_repository.py
2026-06-27 16:08:41 +09:00

594 lines
18 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 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
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 _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:
card = get_seed_persona(code)
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 SEED_PERSONAS.values()
]
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 SEED_PERSONAS.values():
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",
"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",
"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",
]