런타임 계약과 학습자 흐름 보강
This commit is contained in:
parent
f456b8997a
commit
206018b088
56 changed files with 4306 additions and 1008 deletions
|
|
@ -13,6 +13,14 @@ 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_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,
|
||||
|
|
@ -57,38 +65,6 @@ PERSONA_SOURCE_CITATION: dict[str, str] = {
|
|||
"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):
|
||||
|
|
@ -454,142 +430,6 @@ def _format_generation_evidence(evidence: list[PersonaGenerationEvidence]) -> st
|
|||
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")
|
||||
|
|
@ -775,7 +615,7 @@ async def generate_persona_draft_route(
|
|||
query=evidence_query or "페르소나 저작 근거",
|
||||
)
|
||||
evidence_text = _format_generation_evidence(evidence)
|
||||
prompt_bundle = _persona_draft_prompt_bundle()
|
||||
prompt_bundle = persona_draft_prompt_bundle()
|
||||
prompt = (
|
||||
f"{PERSONA_DRAFT_USER_PROMPT_PREAMBLE}\n\n"
|
||||
f"자료 종류: {request.source_kind}\n"
|
||||
|
|
@ -799,7 +639,7 @@ async def generate_persona_draft_route(
|
|||
],
|
||||
max_tokens=2200,
|
||||
temperature=0.2,
|
||||
structured_schema=_persona_generation_schema(),
|
||||
structured_schema=persona_generation_schema(),
|
||||
metadata={
|
||||
"feature": "persona_draft_generation",
|
||||
"prompt_bundle": prompt_bundle,
|
||||
|
|
@ -814,8 +654,8 @@ async def generate_persona_draft_route(
|
|||
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)
|
||||
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)}; "
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue