792 lines
30 KiB
Python
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,
|
|
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)
|