Stabilize runtime auth and E2E coverage
This commit is contained in:
parent
6a3e3b541c
commit
188e899394
133 changed files with 55987 additions and 6775 deletions
243
apps/api/app/persona_repository.py
Normal file
243
apps/api/app/persona_repository.py
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
"""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
|
||||
|
||||
from .config import settings
|
||||
from .db import acquire, get_pool
|
||||
from .services.persona import PersonaCard, SEED_PERSONAS, get_seed_persona
|
||||
|
||||
|
||||
SEED_VERSION = 1
|
||||
|
||||
_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
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CatalogPersona:
|
||||
card: PersonaCard
|
||||
persona_id: str | None
|
||||
version: int | None
|
||||
source: str
|
||||
degraded: bool = False
|
||||
|
||||
|
||||
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 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 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_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",
|
||||
"SEED_VERSION",
|
||||
"card_from_row",
|
||||
"catalog_persona_from_row",
|
||||
"get_approved_persona",
|
||||
"get_catalog_persona",
|
||||
"list_approved_personas",
|
||||
"list_catalog_personas",
|
||||
"materialize_seed_personas",
|
||||
"seed_fallback_persona",
|
||||
"seed_fallback_personas",
|
||||
"seed_persona_id",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue