vignette/apps/api/app/routes/personas.py
2026-06-28 20:12:35 +09:00

915 lines
35 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, HTTPException, Response, status
from ..db import acquire
from ..deps import CurrentPrincipal, Principal, Role, require_role
from ..deps import AIView
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
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 마스킹 파생본",
}
PERSONA_DRAFT_PROMPT_BUNDLE_ID = "persona-draft-rag"
PERSONA_DRAFT_PROMPT_BUNDLE_VERSION = "2026-06-28.1"
PERSONA_DRAFT_SYSTEM_PROMPT = (
"출력은 반드시 structured_schema를 따른다. code는 P숫자 형식을 선호하되 "
"힌트가 없으면 빈 문자열 대신 임시값 P로 둔다. source_provenance에는 "
"RAG source_id와 첨부 근거 기반 초안임을 남긴다. evidence chunk id를 "
"임상 필드 본문에 그대로 노출하지 않는다."
)
PERSONA_DRAFT_USER_PROMPT_PREAMBLE = (
"너는 Vignette 임상 콘텐츠 저작 보조자다. 아래 RAG 근거 청크만 바탕으로 교육용 "
"가상내담자 페르소나 초안을 만든다. 첨부 원문은 KB 문서가 SSOT이며, 근거 밖 내용을 "
"임의로 꾸며 핵심 임상 정보처럼 쓰지 않는다. 실제 개인정보는 이미 마스킹됐으며, "
"원문 표현을 복사하지 말고 "
"범주화·합성화된 임상 훈련용 설정으로 변환한다. CCD/DSM/역린은 런타임 내부 설정이므로 "
"내담자 발화에 직접 노출되지 않는 형태로 작성한다."
)
def _persona_draft_prompt_bundle() -> dict[str, str]:
payload = "\n".join(
[
PERSONA_DRAFT_PROMPT_BUNDLE_ID,
PERSONA_DRAFT_PROMPT_BUNDLE_VERSION,
PERSONA_DRAFT_SYSTEM_PROMPT,
PERSONA_DRAFT_USER_PROMPT_PREAMBLE,
]
)
return {
"id": PERSONA_DRAFT_PROMPT_BUNDLE_ID,
"version": PERSONA_DRAFT_PROMPT_BUNDLE_VERSION,
"hash": hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12],
}
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 _persona_generation_schema() -> dict[str, Any]:
return {
"type": "object",
"additionalProperties": False,
"properties": {
"draft": {
"type": "object",
"additionalProperties": False,
"properties": {
"code": {"type": "string"},
"display_name": {"type": "string"},
"difficulty": {"type": "string", "enum": ["easy", "moderate", "hard"]},
"theory_target": {"type": "array", "items": {"type": "string"}},
"demographics": {"type": "object"},
"presenting": {"type": "object"},
"history": {"type": "object"},
"big5": {"type": "object"},
"resistance": {"type": "object"},
"speech_style": {"type": "object"},
"affect_baseline": {"type": "object"},
"ccd": {"type": "object"},
"dsm5_dimensional": {"type": "object"},
"triggers": {"type": "object"},
"source_provenance": {"type": "string"},
"is_synthetic": {"type": "boolean"},
},
"required": [
"code",
"display_name",
"difficulty",
"theory_target",
"demographics",
"presenting",
"history",
"big5",
"resistance",
"speech_style",
"affect_baseline",
"ccd",
"dsm5_dimensional",
"triggers",
"source_provenance",
"is_synthetic",
],
},
"source_summary": {"type": "string"},
"warnings": {"type": "array", "items": {"type": "string"}},
},
"required": ["draft", "source_summary", "warnings"],
}
def _json_payload_from_generation(text: str) -> dict[str, Any]:
try:
parsed = json.loads(text)
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
try:
parsed = json.loads(text[start : end + 1])
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
return {}
return {}
def _float_dict(value: Any) -> dict[str, float]:
if not isinstance(value, dict):
return {}
result: dict[str, float] = {}
for key, item in value.items():
if isinstance(item, (int, float)):
result[str(key)] = float(item)
return result
def _coerce_generated_draft(
payload: dict[str, Any],
request: PersonaDraftGenerateRequest,
) -> PersonaDraftPayload:
raw = payload.get("draft") if isinstance(payload.get("draft"), dict) else payload
if not isinstance(raw, dict):
raw = {}
theory_target = raw.get("theory_target")
theory_values = (
[str(item).strip().lower() for item in theory_target if str(item).strip()]
if isinstance(theory_target, list)
else [value.strip().lower() for value in request.theory_target if value.strip()]
)
code = str(raw.get("code") or request.code_hint or "").strip().upper()
display_name = str(raw.get("display_name") or request.display_name_hint or "자료 기반 새 페르소나").strip()
difficulty = str(raw.get("difficulty") or request.difficulty)
if difficulty not in {"easy", "moderate", "hard"}:
difficulty = request.difficulty
return PersonaDraftPayload(
code=code or "P",
display_name=display_name,
difficulty=difficulty, # type: ignore[arg-type]
theory_target=theory_values or ["humanistic"],
demographics=_json_object(raw.get("demographics")),
presenting=_json_object(raw.get("presenting")),
history=_json_object(raw.get("history")),
big5=_float_dict(raw.get("big5")) or {"O": 0.5, "C": 0.5, "E": 0.5, "A": 0.5, "N": 0.5},
resistance=_float_dict(raw.get("resistance"))
or {
"base_resistance": 0.5,
"unlock_rate": 0.1,
"decay_floor": 0.05,
"silence_prob": 0.15,
"deflection_prob": 0.25,
},
speech_style=_json_object(raw.get("speech_style")),
affect_baseline=_float_dict(raw.get("affect_baseline"))
or {
"negative_affect": 0.45,
"hopelessness": 0.2,
"anhedonia": 0.2,
"sleep": 0.2,
"anxiety": 0.35,
"suicide_ideation_stage": 1,
},
ccd=_json_object(raw.get("ccd")),
dsm5_dimensional=_json_object(raw.get("dsm5_dimensional")),
triggers=_json_object(raw.get("triggers")),
source_provenance=str(raw.get("source_provenance") or f"masked {request.source_kind}"),
is_synthetic=bool(raw.get("is_synthetic", True)),
submit_for_review=False,
)
def _json_object(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
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("/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 = response.structured or _json_payload_from_generation(response.text)
draft = _coerce_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)