vignette/apps/api/app/routes/personas.py
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

792 lines
30 KiB
Python

"""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,
summary,
)
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)