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