음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
|
|
@ -2,33 +2,58 @@
|
|||
|
||||
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 pydantic import BaseModel, Field
|
||||
|
||||
from ..db import acquire
|
||||
from ..deps import CurrentPrincipal, Principal, Role, require_role
|
||||
from ..deps import AIView
|
||||
from ..persona_repository import (
|
||||
CatalogPersona,
|
||||
PersonaDraftRecord,
|
||||
PersonaReviewAction,
|
||||
PersonaReviewItem,
|
||||
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 ..engine_client import EngineError, EngineMessage, GenerateRequest, engine_client
|
||||
from ..services import rag
|
||||
from ..services.guardrail import mask_pii
|
||||
from ..services.persona import PersonaCard
|
||||
|
||||
router = APIRouter(prefix="/personas", tags=["personas"])
|
||||
TeacherOrAdmin = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))]
|
||||
JSON_OBJECT_FIELD = {"additionalProperties": True}
|
||||
PersonaSourceKind = Literal["client_record", "textbook_guide", "mixed_notes"]
|
||||
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 마스킹 파생본",
|
||||
}
|
||||
|
||||
|
||||
class PersonaSummary(BaseModel):
|
||||
persona_id: str | None = None
|
||||
code: str
|
||||
version: int | None = None
|
||||
status: Literal["approved"] = "approved"
|
||||
display_name: str
|
||||
difficulty: str
|
||||
theory_target: list[str]
|
||||
|
|
@ -57,6 +82,10 @@ class PersonaReviewDecisionRequest(BaseModel):
|
|||
action: PersonaReviewAction
|
||||
|
||||
|
||||
class PersonaRevisionRequest(BaseModel):
|
||||
submit_for_review: bool = False
|
||||
|
||||
|
||||
class PersonaDraftPayload(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=24)
|
||||
display_name: str = Field(min_length=1, max_length=80)
|
||||
|
|
@ -71,11 +100,66 @@ class PersonaDraftPayload(BaseModel):
|
|||
affect_baseline: dict[str, float] = Field(default_factory=dict)
|
||||
ccd: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
|
||||
dsm5_dimensional: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
|
||||
triggers: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
|
||||
source_provenance: str = Field(default="", max_length=240)
|
||||
is_synthetic: bool = True
|
||||
submit_for_review: bool = False
|
||||
|
||||
|
||||
class PersonaSourceDocumentRequest(BaseModel):
|
||||
filename: str = Field(min_length=1, max_length=240)
|
||||
source_kind: PersonaSourceKind = "mixed_notes"
|
||||
text: str = Field(min_length=20, max_length=120000)
|
||||
title: str | None = Field(default=None, max_length=160)
|
||||
source_note: str = Field(default="", max_length=800)
|
||||
|
||||
|
||||
class PersonaSourceDocumentResponse(BaseModel):
|
||||
source_id: str
|
||||
doc_id: int | None
|
||||
doc_uri: str
|
||||
title: str
|
||||
source_kind: PersonaSourceKind
|
||||
kb_kind: str
|
||||
license_class: Literal["A", "B", "C", "D"] = "B"
|
||||
external_llm_ok: bool = True
|
||||
content_hash: str
|
||||
chunk_count: int
|
||||
chunks_indexed: int
|
||||
embedded: bool
|
||||
degraded: bool = False
|
||||
pii_entities_masked: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PersonaGenerationEvidence(BaseModel):
|
||||
chunk_id: int
|
||||
source_id: str
|
||||
score: float
|
||||
kb_kind: str
|
||||
heading_path: str | None = None
|
||||
excerpt: str
|
||||
|
||||
|
||||
class PersonaDraftGenerateRequest(BaseModel):
|
||||
source_text: str | None = Field(default=None, min_length=20, max_length=30000)
|
||||
source_ids: list[str] = Field(default_factory=list, max_length=12)
|
||||
source_kind: PersonaSourceKind = "mixed_notes"
|
||||
code_hint: str | None = Field(default=None, max_length=24)
|
||||
display_name_hint: str | None = Field(default=None, max_length=80)
|
||||
difficulty: Literal["easy", "moderate", "hard"] = "moderate"
|
||||
theory_target: list[str] = Field(default_factory=lambda: ["humanistic"])
|
||||
generation_goal: str = Field(default="", max_length=800)
|
||||
|
||||
|
||||
class PersonaDraftGenerateResponse(BaseModel):
|
||||
draft: PersonaDraftPayload
|
||||
source_summary: str = ""
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
pii_entities_masked: list[str] = Field(default_factory=list)
|
||||
source_references: list[PersonaSourceDocumentResponse] = Field(default_factory=list)
|
||||
evidence_chunks: list[PersonaGenerationEvidence] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PersonaDraftDetail(PersonaReviewSummary):
|
||||
demographics: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
presenting: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
|
|
@ -86,6 +170,7 @@ class PersonaDraftDetail(PersonaReviewSummary):
|
|||
affect_baseline: dict[str, float]
|
||||
ccd: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
dsm5_dimensional: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
triggers: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
|
||||
|
||||
def _first_text_value(data: dict[str, Any]) -> str:
|
||||
|
|
@ -98,7 +183,10 @@ def _first_text_value(data: dict[str, Any]) -> str:
|
|||
def _summary(entry: CatalogPersona) -> PersonaSummary:
|
||||
card = entry.card
|
||||
return PersonaSummary(
|
||||
persona_id=entry.persona_id,
|
||||
code=card.code,
|
||||
version=entry.version,
|
||||
status="approved",
|
||||
display_name=card.display_name,
|
||||
difficulty=card.difficulty,
|
||||
theory_target=card.theory_target,
|
||||
|
|
@ -138,6 +226,7 @@ def _draft_detail(entry: PersonaDraftRecord) -> PersonaDraftDetail:
|
|||
affect_baseline=card.affect_baseline,
|
||||
ccd=card.ccd,
|
||||
dsm5_dimensional=card.dsm5_dimensional,
|
||||
triggers=card.triggers,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -163,11 +252,430 @@ def _card_from_draft_payload(request: PersonaDraftPayload) -> PersonaCard:
|
|||
affect_baseline=request.affect_baseline,
|
||||
ccd=request.ccd,
|
||||
dsm5_dimensional=request.dsm5_dimensional,
|
||||
triggers=request.triggers,
|
||||
source_provenance=request.source_provenance.strip(),
|
||||
is_synthetic=request.is_synthetic,
|
||||
)
|
||||
|
||||
|
||||
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 _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)}"
|
||||
)
|
||||
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_not_stored": True,
|
||||
},
|
||||
"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 마스킹 파생본"),
|
||||
)
|
||||
result = await rag.index_document(conn, index_req)
|
||||
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")
|
||||
|
|
@ -231,6 +739,196 @@ async def create_persona_draft_route(
|
|||
return _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 _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 _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 = (
|
||||
"너는 Vignette 임상 콘텐츠 저작 보조자다. 아래 RAG 근거 청크만 바탕으로 교육용 "
|
||||
"가상내담자 페르소나 초안을 만든다. 첨부 원문은 KB 문서가 SSOT이며, 근거 밖 내용을 "
|
||||
"임의로 꾸며 핵심 임상 정보처럼 쓰지 않는다. 실제 개인정보는 이미 마스킹됐으며, "
|
||||
"원문 표현을 복사하지 말고 "
|
||||
"범주화·합성화된 임상 훈련용 설정으로 변환한다. CCD/DSM/역린은 런타임 내부 설정이므로 "
|
||||
"내담자 발화에 직접 노출되지 않는 형태로 작성한다.\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=(
|
||||
"출력은 반드시 structured_schema를 따른다. code는 P숫자 형식을 선호하되 "
|
||||
"힌트가 없으면 빈 문자열 대신 임시값 P로 둔다. source_provenance에는 "
|
||||
"RAG source_id와 첨부 근거 기반 초안임을 남긴다. evidence chunk id를 "
|
||||
"임상 필드 본문에 그대로 노출하지 않는다."
|
||||
),
|
||||
),
|
||||
EngineMessage(role="user", content=prompt),
|
||||
],
|
||||
max_tokens=2200,
|
||||
temperature=0.2,
|
||||
structured_schema=_persona_generation_schema(),
|
||||
metadata={
|
||||
"feature": "persona_draft_generation",
|
||||
"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"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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue