"""Persona catalog routes.""" from __future__ import annotations import json import hashlib import re import uuid from typing import Annotated, Any, Literal from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status from ..db import acquire from ..deps import CurrentPrincipal, Principal, Role, require_role from ..deps import AIView from ..persona_generation_contract import ( PERSONA_DRAFT_SYSTEM_PROMPT, PERSONA_DRAFT_USER_PROMPT_PREAMBLE, coerce_persona_generated_draft, persona_draft_prompt_bundle, persona_generation_payload_from_response, persona_generation_schema, ) from ..persona_repository import ( archive_persona_family, create_persona_draft, create_persona_revision_from_existing, get_persona_draft_record, list_catalog_personas, list_persona_review_queue, update_persona_draft, update_persona_review_status, ) from ..persona_read_model import ( PersonaDraftDetail, PersonaDraftGenerateRequest, PersonaDraftGenerateResponse, PersonaDraftPayload, PersonaGenerationEvidence, PersonaReviewDecisionRequest, PersonaReviewSummary, PersonaRevisionRequest, PersonaSourceDocumentRequest, PersonaSourceDocumentResponse, PersonaSourceKind, PersonaSummary, persona_card_from_draft_payload, persona_draft_detail, persona_review_summary, persona_summary, ) from ..engine_client import EngineError, EngineMessage, GenerateRequest, engine_client from ..services import rag from ..services.guardrail import mask_pii from ..services.tabular_ingest import TabularIngestError, extract_tabular_text router = APIRouter(prefix="/personas", tags=["personas"]) TeacherOrAdmin = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))] PERSONA_SOURCE_KB_KIND: dict[str, str] = { "client_record": "diagnostic", "textbook_guide": "theory", "mixed_notes": "ko_context", } PERSONA_SOURCE_CITATION: dict[str, str] = { "client_record": "교수자 첨부 PII 마스킹 파생본 — 실사례 원문은 저장하지 않음", "textbook_guide": "교수자 첨부 교재/가이드 환언·발췌 근거 — 저작권 검수 필요", "mixed_notes": "교수자 첨부 혼합 메모 PII 마스킹 파생본", } def _card_from_draft_payload(request: PersonaDraftPayload): try: return persona_card_from_draft_payload(request) except ValueError as exc: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc def _safe_doc_segment(value: str) -> str: safe = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()) return safe.strip("-")[:120] or "source" def _chunk_source_text(text: str, *, max_chars: int = 1800) -> list[str]: paragraphs = [item.strip() for item in re.split(r"\n\s*\n", text) if item.strip()] chunks: list[str] = [] current = "" for paragraph in paragraphs or [text.strip()]: pending = paragraph while len(pending) > max_chars: chunks.append(pending[:max_chars].strip()) pending = pending[max_chars:].strip() if not pending: continue if current and len(current) + len(pending) + 2 > max_chars: chunks.append(current.strip()) current = pending else: current = f"{current}\n\n{pending}".strip() if current else pending if current: chunks.append(current.strip()) return chunks def _source_content_hash(chunks: list[dict[str, Any]]) -> str: h = hashlib.sha256() for chunk in chunks: stable = { "chunk_text": chunk.get("chunk_text") or "", "context_prefix": chunk.get("context_prefix") or "", "kb_kind": chunk.get("kb_kind") or "", "visible_to": chunk.get("visible_to") or [], "sensitivity": chunk.get("sensitivity"), "meta": chunk.get("meta") or {}, } h.update(json.dumps(stable, ensure_ascii=False, sort_keys=True).encode("utf-8")) return h.hexdigest() def _raw_source_content_hash(request: PersonaSourceDocumentRequest) -> str: h = hashlib.sha256() h.update(request.text.encode("utf-8")) h.update(request.filename.encode("utf-8")) h.update(request.source_kind.encode("utf-8")) return h.hexdigest() def _raw_source_artifact_id(source_id: str) -> str: return f"{source_id}_raw" async def _record_persona_raw_source_artifact( conn: Any, *, request: PersonaSourceDocumentRequest, principal: Principal, source_id: str, doc_uri: str, content_hash: str, masked_entities: list[str], license_class: str, ) -> None: summary = { "source_kind": request.source_kind, "filename": request.filename, "title": _source_title(request), "pii_entities_masked": masked_entities, "derived_source_id": source_id, "raw_text_not_indexed": True, "storage": "hash_only", } await conn.execute( """ INSERT INTO kb.raw_source_artifact (raw_source_id, derived_source_id, owner_user_id, doc_uri, content_hash, storage_uri, sealed_blob, license_class, redaction_summary, external_llm_ok) VALUES ($1, $2, $3::uuid, $4, $5, NULL, NULL, $6, $7::jsonb, FALSE) ON CONFLICT (raw_source_id) DO UPDATE SET derived_source_id = EXCLUDED.derived_source_id, owner_user_id = EXCLUDED.owner_user_id, doc_uri = EXCLUDED.doc_uri, content_hash = EXCLUDED.content_hash, license_class = EXCLUDED.license_class, redaction_summary = EXCLUDED.redaction_summary, external_llm_ok = FALSE """, _raw_source_artifact_id(source_id), source_id, principal.user_id, f"{doc_uri}.raw", content_hash, license_class, json.dumps(summary, ensure_ascii=False), ) def _source_title(request: PersonaSourceDocumentRequest) -> str: return (request.title or request.filename).strip() async def _register_persona_source_document( request: PersonaSourceDocumentRequest, principal: Principal, ) -> PersonaSourceDocumentResponse: """Register a persona-authoring attachment as masked evaluator-only KB chunks.""" masked = mask_pii(request.text) title = _source_title(request) source_id = f"persona_authoring_{uuid.uuid4().hex[:16]}" doc_uri = ( f"persona-authoring/{principal.user_id}/" f"{source_id}/{_safe_doc_segment(request.filename)}" ) raw_source_id = _raw_source_artifact_id(source_id) raw_content_hash = _raw_source_content_hash(request) kb_kind = PERSONA_SOURCE_KB_KIND.get(request.source_kind, "ko_context") license_class: Literal["A", "B", "C", "D"] = "B" external_llm_ok = True chunks = [ { "seq": index, "chunk_text": chunk, "heading_path": title, "context_prefix": ( "페르소나 저작 첨부 자료. " f"자료종류={request.source_kind}; 파일={request.filename}; " "PII 마스킹본이며 evaluator 전용 근거로만 사용한다." ), "kb_kind": kb_kind, "visible_to": ["evaluator"], "sensitivity": 2, "meta": { "persona_authoring": True, "source_kind": request.source_kind, "filename": request.filename, "title": title, "source_note": request.source_note, "pii_entities_masked": masked.entities, "license_class": license_class, "external_llm_ok": external_llm_ok, "raw_source_id": raw_source_id, "raw_source_content_hash": raw_content_hash, "raw_source_not_indexed": True, "raw_source_storage": "hash_only", }, "token_count": max(1, len(chunk) // 4), } for index, chunk in enumerate(_chunk_source_text(masked.text_masked)) ] if not chunks: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="source document is empty") content_hash = _source_content_hash(chunks) index_req = rag.IndexRequest( source_id=source_id, doc_uri=doc_uri, version=1, content_hash=content_hash, chunks=chunks, ) try: async with acquire(role=principal.role.value, user_id=principal.user_id) as conn: await conn.execute( """ INSERT INTO kb.source (source_id, title, kb_kind, license_class, origin_path, citation, external_llm_ok) VALUES ($1, $2, $3, 'B', $4, $5, TRUE) ON CONFLICT (source_id) DO UPDATE SET title = EXCLUDED.title, origin_path = EXCLUDED.origin_path, citation = EXCLUDED.citation """, source_id, title, kb_kind, request.filename, PERSONA_SOURCE_CITATION.get(request.source_kind, "교수자 첨부 PII 마스킹 파생본"), ) await _record_persona_raw_source_artifact( conn, request=request, principal=principal, source_id=source_id, doc_uri=doc_uri, content_hash=raw_content_hash, masked_entities=masked.entities, license_class=license_class, ) result = await rag.index_document(conn, index_req) except rag.IndexPolicyViolation as exc: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc except rag.NotConfigured as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"persona source RAG index unavailable: {exc}", ) from exc except RuntimeError as exc: raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {exc}") from exc return PersonaSourceDocumentResponse( source_id=source_id, doc_id=result.doc_id, doc_uri=doc_uri, title=title, source_kind=request.source_kind, kb_kind=kb_kind, license_class=license_class, external_llm_ok=external_llm_ok, content_hash=content_hash, chunk_count=len(chunks), chunks_indexed=result.chunks_indexed, embedded=result.embedded, degraded=result.degraded, pii_entities_masked=masked.entities, ) async def _retrieve_persona_generation_evidence( *, source_ids: list[str], query: str, ) -> list[PersonaGenerationEvidence]: if not source_ids: raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, detail="persona generation requires at least one RAG source", ) try: async with acquire(ai_view=AIView.EVALUATOR.value) as conn: result = await rag.search_kb( conn, query=query, role=rag.AIRole.EVALUATOR, k=8, filters={"source_id": source_ids, "sensitivity_max": 2}, rerank=True, ) try: await rag.log_retrieval(conn, result=result, ai_role="evaluator") except Exception: pass except rag.NotConfigured as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"persona source RAG search unavailable: {exc}", ) from exc except RuntimeError as exc: raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {exc}") from exc evidence = [ PersonaGenerationEvidence( chunk_id=chunk.chunk_id, source_id=chunk.source_id or "", score=round(chunk.score, 6), kb_kind=chunk.kb_kind, heading_path=chunk.heading_path, excerpt=(chunk.body or chunk.behavior_cue or "")[:1200], ) for chunk in result.chunks if (chunk.body or chunk.behavior_cue) ] if not evidence: raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, detail="RAG evidence was not found for the selected persona sources", ) return evidence async def _load_persona_source_references( source_ids: list[str], ) -> list[PersonaSourceDocumentResponse]: if not source_ids: return [] try: async with acquire(role="admin") as conn: rows = await conn.fetch( """ WITH latest_doc AS ( SELECT DISTINCT ON (source_id) source_id, doc_id, doc_uri, content_hash FROM kb.document WHERE is_active AND source_id = ANY($1::text[]) ORDER BY source_id, version DESC ), first_chunk AS ( SELECT DISTINCT ON (source_id) source_id, COALESCE(meta->>'source_kind', 'mixed_notes') AS source_kind FROM kb.chunk WHERE source_id = ANY($1::text[]) ORDER BY source_id, seq ), chunk_counts AS ( SELECT source_id, COUNT(*)::int AS chunk_count FROM kb.chunk WHERE source_id = ANY($1::text[]) GROUP BY source_id ) SELECT s.source_id, s.title, s.kb_kind, s.license_class, s.external_llm_ok, d.doc_id, d.doc_uri, d.content_hash, COALESCE(fc.source_kind, 'mixed_notes') AS source_kind, COALESCE(cc.chunk_count, 0) AS chunk_count FROM kb.source s LEFT JOIN latest_doc d ON d.source_id = s.source_id LEFT JOIN first_chunk fc ON fc.source_id = s.source_id LEFT JOIN chunk_counts cc ON cc.source_id = s.source_id WHERE s.source_id = ANY($1::text[]) """, source_ids, ) except RuntimeError as exc: raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {exc}") from exc found = {str(row["source_id"]) for row in rows} missing = [source_id for source_id in source_ids if source_id not in found] if missing: raise HTTPException( status.HTTP_404_NOT_FOUND, detail=f"persona source not found: {', '.join(missing)}", ) references: list[PersonaSourceDocumentResponse] = [] for row in rows: source_kind = str(row["source_kind"] or "mixed_notes") if source_kind not in {"client_record", "textbook_guide", "mixed_notes"}: source_kind = "mixed_notes" references.append( PersonaSourceDocumentResponse( source_id=str(row["source_id"]), doc_id=int(row["doc_id"]) if row["doc_id"] is not None else None, doc_uri=str(row["doc_uri"] or ""), title=str(row["title"] or row["source_id"]), source_kind=source_kind, # type: ignore[arg-type] kb_kind=str(row["kb_kind"] or "ko_context"), license_class=str(row["license_class"] or "B"), # type: ignore[arg-type] external_llm_ok=bool(row["external_llm_ok"]), content_hash=str(row["content_hash"] or ""), chunk_count=int(row["chunk_count"] or 0), chunks_indexed=0, embedded=True, degraded=False, pii_entities_masked=[], ) ) return references def _format_generation_evidence(evidence: list[PersonaGenerationEvidence]) -> str: lines: list[str] = [] for index, item in enumerate(evidence, start=1): heading = item.heading_path or item.source_id lines.append( f"[근거 {index}] source_id={item.source_id}; chunk_id={item.chunk_id}; " f"score={item.score}; heading={heading}\n{item.excerpt}" ) return "\n\n".join(lines) 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 [persona_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 [persona_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 persona_review_summary(created) @router.post("/{persona_id}/revisions", response_model=PersonaDraftDetail, status_code=status.HTTP_201_CREATED) async def create_persona_revision_route( persona_id: str, request: PersonaRevisionRequest, principal: TeacherOrAdmin, ) -> PersonaDraftDetail: """Clone an approved/system persona into an editable draft version.""" _ensure_teacher_or_admin(principal) try: record = await create_persona_revision_from_existing( persona_id=persona_id, 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 revision database unavailable", ) from exc if record is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="approved persona not found") return persona_draft_detail(record) @router.delete("/{persona_id}", response_model=PersonaReviewSummary) async def archive_persona_route( persona_id: str, principal: TeacherOrAdmin, ) -> PersonaReviewSummary: """Archive a persona code family instead of hard-deleting historical cards.""" _ensure_teacher_or_admin(principal) try: archived = await archive_persona_family( persona_id=persona_id, archiver_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 archive database unavailable", ) from exc if archived is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="persona not found") return persona_review_summary(archived) @router.post("/sources", response_model=PersonaSourceDocumentResponse, status_code=status.HTTP_201_CREATED) async def create_persona_source_route( request: PersonaSourceDocumentRequest, principal: TeacherOrAdmin, ) -> PersonaSourceDocumentResponse: """Attach a source document to the persona-authoring KB before draft generation.""" _ensure_teacher_or_admin(principal) return await _register_persona_source_document(request, principal) @router.post( "/sources/upload", response_model=PersonaSourceDocumentResponse, status_code=status.HTTP_201_CREATED, ) async def upload_persona_source_route( principal: TeacherOrAdmin, file: UploadFile = File(...), source_kind: PersonaSourceKind = Form("mixed_notes"), title: str | None = Form(None), source_note: str = Form(""), ) -> PersonaSourceDocumentResponse: """자유 양식 엑셀/CSV 업로드 → 텍스트 변환 → 기존 source 등록 경로 재사용 (P4). 업로드 원본 바이트는 이 핸들러 메모리에서만 파싱하고 저장하지 않는다(원본 파기). 파생 텍스트만 기존 마스킹·hash-only 증거·sanitized chunk 경로로 등록된다. """ _ensure_teacher_or_admin(principal) data = await file.read() try: text = extract_tabular_text(filename=file.filename or "", data=data) except TabularIngestError as exc: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc finally: # 원본 바이트 참조를 즉시 놓는다 — 파생 텍스트 외에는 남기지 않는다. del data request = PersonaSourceDocumentRequest( filename=(file.filename or "uploaded-table.xlsx")[:240], source_kind=source_kind, text=text, title=(title or None), source_note=source_note[:800], ) return await _register_persona_source_document(request, principal) @router.post("/drafts/generate", response_model=PersonaDraftGenerateResponse) async def generate_persona_draft_route( request: PersonaDraftGenerateRequest, principal: TeacherOrAdmin, ) -> PersonaDraftGenerateResponse: """Generate an editable persona draft from evaluator-only RAG evidence.""" _ensure_teacher_or_admin(principal) source_references: list[PersonaSourceDocumentResponse] = [] source_ids = [item.strip() for item in request.source_ids if item.strip()] pii_entities: list[str] = [] if request.source_text: inline_source = await _register_persona_source_document( PersonaSourceDocumentRequest( filename="inline-persona-source.txt", source_kind=request.source_kind, text=request.source_text, title="붙여넣은 페르소나 저작 자료", source_note=request.generation_goal, ), principal, ) source_references.append(inline_source) source_ids.append(inline_source.source_id) pii_entities.extend(inline_source.pii_entities_masked) if not source_ids: raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, detail="source_ids or source_text is required for RAG-based persona generation", ) known_refs = {item.source_id: item for item in source_references} unknown_source_ids = [source_id for source_id in source_ids if source_id not in known_refs] if unknown_source_ids: for item in await _load_persona_source_references(unknown_source_ids): known_refs[item.source_id] = item source_references = [known_refs[source_id] for source_id in source_ids if source_id in known_refs] blocked_sources = [item.source_id for item in source_references if not item.external_llm_ok] if blocked_sources: raise HTTPException( status.HTTP_409_CONFLICT, detail=( "selected persona sources are not allowed for external LLM generation: " + ", ".join(blocked_sources) ), ) code_hint = (request.code_hint or "").strip().upper() display_hint = (request.display_name_hint or "").strip() evidence_query = "\n".join( [ request.generation_goal or "교육용 가상내담자 페르소나 초안 생성", request.source_kind, request.difficulty, " ".join(request.theory_target), display_hint, ] ).strip() evidence = await _retrieve_persona_generation_evidence( source_ids=source_ids, query=evidence_query or "페르소나 저작 근거", ) evidence_text = _format_generation_evidence(evidence) prompt_bundle = persona_draft_prompt_bundle() prompt = ( f"{PERSONA_DRAFT_USER_PROMPT_PREAMBLE}\n\n" f"자료 종류: {request.source_kind}\n" f"RAG source_ids: {source_ids}\n" f"코드 힌트: {code_hint or '미정'}\n" f"표시명 힌트: {display_hint or '미정'}\n" f"난이도: {request.difficulty}\n" f"대상 이론: {request.theory_target}\n" f"저작 목표: {request.generation_goal or '첫 편집 가능한 초안 생성'}\n\n" "[RAG 근거 청크]\n" f"{evidence_text}" ) req = GenerateRequest( ai_role="evaluator", messages=[ EngineMessage( role="system", content=PERSONA_DRAFT_SYSTEM_PROMPT, ), EngineMessage(role="user", content=prompt), ], max_tokens=2200, temperature=0.2, structured_schema=persona_generation_schema(), metadata={ "feature": "persona_draft_generation", "prompt_bundle": prompt_bundle, "source_kind": request.source_kind, "source_ids": source_ids, }, ) try: response = await engine_client.generate(req) except EngineError as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"persona draft generator unavailable: {exc}", ) from exc payload = persona_generation_payload_from_response(response) draft = coerce_persona_generated_draft(payload, request) provenance = ( f"prompt={prompt_bundle['id']}@{prompt_bundle['version']}#{prompt_bundle['hash']}; " f"RAG sources={','.join(source_ids)}; " f"chunks={','.join(str(item.chunk_id) for item in evidence)}" ) if draft.source_provenance and draft.source_provenance not in provenance: provenance = f"{provenance}; {draft.source_provenance}" draft.source_provenance = provenance[:240] summary = str(payload.get("source_summary") or "") warnings = payload.get("warnings") if isinstance(payload.get("warnings"), list) else [] return PersonaDraftGenerateResponse( draft=draft, source_summary=summary, warnings=[str(item) for item in warnings], pii_entities_masked=pii_entities, source_references=source_references, evidence_chunks=evidence, ) @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 persona_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 persona_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 persona_review_summary(updated)