vignette/apps/api/app/persona_generation_contract.py
2026-06-29 08:12:14 +09:00

166 lines
6.7 KiB
Python

"""Persona draft generation contract helpers."""
from __future__ import annotations
import hashlib
from typing import Any
from .contracts.engine_gateway import GenerateResponse, structured_payload_from_response
from .persona_read_model import PersonaDraftGenerateRequest, PersonaDraftPayload
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 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 persona_generation_payload_from_response(response: GenerateResponse) -> dict[str, Any]:
return structured_payload_from_response(response) or {}
def coerce_persona_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 _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 _json_object(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}