"""Persona catalog routes.""" from __future__ import annotations from typing import Annotated, Any, Literal from fastapi import APIRouter, Depends, HTTPException, Response, status from pydantic import BaseModel, Field from ..deps import CurrentPrincipal, Principal, Role, require_role from ..persona_repository import ( CatalogPersona, PersonaDraftRecord, PersonaReviewAction, PersonaReviewItem, create_persona_draft, get_persona_draft_record, list_catalog_personas, list_persona_review_queue, update_persona_draft, update_persona_review_status, ) from ..services.persona import PersonaCard router = APIRouter(prefix="/personas", tags=["personas"]) TeacherOrAdmin = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))] JSON_OBJECT_FIELD = {"additionalProperties": True} class PersonaSummary(BaseModel): code: str display_name: str difficulty: str theory_target: list[str] demographics: dict[str, Any] presenting_summary: str voice_preset: str | None = None source: str = "database" degraded: bool = False class PersonaReviewSummary(BaseModel): persona_id: str code: str version: int status: Literal["draft", "review", "approved", "archived"] display_name: str difficulty: str theory_target: list[str] source_provenance: str is_synthetic: bool created_at: str | None = None approved_at: str | None = None class PersonaReviewDecisionRequest(BaseModel): action: PersonaReviewAction class PersonaDraftPayload(BaseModel): code: str = Field(min_length=1, max_length=24) display_name: str = Field(min_length=1, max_length=80) difficulty: Literal["easy", "moderate", "hard"] theory_target: list[str] = Field(default_factory=list) demographics: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD) presenting: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD) history: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD) big5: dict[str, float] = Field(default_factory=dict) resistance: dict[str, float] = Field(default_factory=dict) speech_style: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD) affect_baseline: dict[str, float] = Field(default_factory=dict) ccd: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD) dsm5_dimensional: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD) source_provenance: str = Field(default="", max_length=240) is_synthetic: bool = True submit_for_review: bool = False class PersonaDraftDetail(PersonaReviewSummary): demographics: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD) presenting: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD) history: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD) big5: dict[str, float] resistance: dict[str, float] speech_style: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD) affect_baseline: dict[str, float] ccd: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD) dsm5_dimensional: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD) def _first_text_value(data: dict[str, Any]) -> str: for value in data.values(): if isinstance(value, str) and value.strip(): return value.strip() return "" def _summary(entry: CatalogPersona) -> PersonaSummary: card = entry.card return PersonaSummary( code=card.code, display_name=card.display_name, difficulty=card.difficulty, theory_target=card.theory_target, demographics=card.demographics, presenting_summary=_first_text_value(card.presenting), source=entry.source, degraded=entry.degraded, ) def _review_summary(entry: PersonaReviewItem) -> PersonaReviewSummary: return PersonaReviewSummary( persona_id=entry.persona_id, code=entry.code, version=entry.version, status=entry.status, display_name=entry.display_name, difficulty=entry.difficulty, theory_target=entry.theory_target, source_provenance=entry.source_provenance, is_synthetic=entry.is_synthetic, created_at=entry.created_at, approved_at=entry.approved_at, ) def _draft_detail(entry: PersonaDraftRecord) -> PersonaDraftDetail: card = entry.card return PersonaDraftDetail( **_review_summary(entry.review).model_dump(), demographics=card.demographics, presenting=card.presenting, history=card.history, big5=card.big5, resistance=card.resistance, speech_style=card.speech_style, affect_baseline=card.affect_baseline, ccd=card.ccd, dsm5_dimensional=card.dsm5_dimensional, ) def _card_from_draft_payload(request: PersonaDraftPayload) -> PersonaCard: code = request.code.strip().upper() display_name = request.display_name.strip() if not code: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="persona code is required") if not display_name: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="display_name is required") theory_target = [value.strip().lower() for value in request.theory_target if value.strip()] return PersonaCard( code=code, display_name=display_name, difficulty=request.difficulty, theory_target=theory_target, demographics=request.demographics, presenting=request.presenting, history=request.history, big5=request.big5, resistance=request.resistance, speech_style=request.speech_style, affect_baseline=request.affect_baseline, ccd=request.ccd, dsm5_dimensional=request.dsm5_dimensional, source_provenance=request.source_provenance.strip(), is_synthetic=request.is_synthetic, ) def _ensure_teacher_or_admin(principal: Principal) -> None: if principal.role not in {Role.TEACHER, Role.ADMIN}: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only teachers and admins can review personas") @router.get("", response_model=list[PersonaSummary]) async def list_personas(response: Response, _principal: CurrentPrincipal) -> list[PersonaSummary]: """Return latest approved personas from app.persona_card.""" try: personas = await list_catalog_personas() except Exception as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail="persona catalog database unavailable", ) from exc if any(entry.degraded for entry in personas): response.headers["X-Vignette-Degraded"] = "true" response.headers["X-Vignette-Catalog-Source"] = "seed_fallback" else: response.headers["X-Vignette-Catalog-Source"] = "database" return [_summary(entry) for entry in personas] @router.get("/review", response_model=list[PersonaReviewSummary]) async def list_persona_reviews(principal: TeacherOrAdmin) -> list[PersonaReviewSummary]: """Return draft/review personas awaiting faculty approval.""" _ensure_teacher_or_admin(principal) try: queue = await list_persona_review_queue(role=principal.role.value) except Exception as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail="persona review queue database unavailable", ) from exc return [_review_summary(entry) for entry in queue] @router.post("/drafts", response_model=PersonaReviewSummary, status_code=status.HTTP_201_CREATED) async def create_persona_draft_route( request: PersonaDraftPayload, principal: TeacherOrAdmin, ) -> PersonaReviewSummary: """Create a draft persona card version for faculty review.""" _ensure_teacher_or_admin(principal) try: created = await create_persona_draft( card=_card_from_draft_payload(request), author_id=principal.user_id, role=principal.role.value, submit_for_review=request.submit_for_review, ) except ValueError as exc: raise HTTPException(status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc except Exception as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail="persona draft database unavailable", ) from exc return _review_summary(created) @router.get("/drafts/{persona_id}", response_model=PersonaDraftDetail) async def get_persona_draft_route( persona_id: str, principal: TeacherOrAdmin, ) -> PersonaDraftDetail: """Return a draft/review persona card for editing.""" _ensure_teacher_or_admin(principal) try: record = await get_persona_draft_record(persona_id=persona_id, role=principal.role.value) except ValueError as exc: raise HTTPException(status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc except Exception as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail="persona draft database unavailable", ) from exc if record is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="persona draft not found") return _draft_detail(record) @router.put("/drafts/{persona_id}", response_model=PersonaReviewSummary) async def update_persona_draft_route( persona_id: str, request: PersonaDraftPayload, principal: TeacherOrAdmin, ) -> PersonaReviewSummary: """Update a draft/review persona card and optionally submit it for review.""" _ensure_teacher_or_admin(principal) try: updated = await update_persona_draft( persona_id=persona_id, card=_card_from_draft_payload(request), author_id=principal.user_id, role=principal.role.value, submit_for_review=request.submit_for_review, ) except ValueError as exc: raise HTTPException(status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc except Exception as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail="persona draft update database unavailable", ) from exc if updated is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="persona draft not found or locked") return _review_summary(updated) @router.post("/review/{persona_id}", response_model=PersonaReviewSummary) async def decide_persona_review( persona_id: str, request: PersonaReviewDecisionRequest, principal: TeacherOrAdmin, ) -> PersonaReviewSummary: """Approve a persona for learners or return it to draft for changes.""" _ensure_teacher_or_admin(principal) try: updated = await update_persona_review_status( persona_id=persona_id, action=request.action, reviewer_id=principal.user_id, role=principal.role.value, ) except ValueError as exc: raise HTTPException(status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc except Exception as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail="persona review update database unavailable", ) from exc if updated is None: raise HTTPException( status.HTTP_404_NOT_FOUND, detail="persona review item not found or not pending review", ) return _review_summary(updated)