vignette/apps/api/app/services/persona.py
Yun Chan 24b1b7a6e1 feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG
web (Vite+React19+TS, Cloudflare Pages 배포):
- 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브
- 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정
- ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서
- 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨

api (FastAPI):
- 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존)
- services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 /
  턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG
- store: DB off 폴백(in-memory), sessions 실구현

검증:
- web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200
- api: app.main import 통과
- 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시)
- E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
2026-06-25 23:37:22 +09:00

402 lines
20 KiB
Python

"""가상내담자 페르소나 시스템프롬프트 빌더 + 시드 페르소나 P1/P2/P3.
논리 레이어 (MASTERPLAN §1.2, MEMORY_KNOWLEDGE_PERSONA_DESIGN §2):
[L0 역할+안전가드레일+도식노출금지] ┐
[L1 페르소나 카드(정적, CCD 포함)] ├─ cache_control (입력비 절감 대상)
[L2 RAG 임상청크 / 회상] ┘
[L3 상태머신 주입(stage, openness, resistance, ideation)]
[L4 메모리 버퍼(pinned fact hard-pin)]
[L6 발화지시(이 턴에 어떻게 말할지)]
핵심 안전 불변식 (R4 / R5 / M6):
- CCD(core_belief·automatic_thought·coping)·DSM 차원·정답 라벨은 *행동으로만* 드러낸다.
"제 핵심신념은…" 같은 메타 발화 절대 금지 → 추론 훈련 무력화 차단.
- 자살수단·구체적 방법 정보는 절대 발화하지 않는다(ideation_stage 상한은 출력 가드레일이 재차 강제).
페르소나 카드는 본래 DB(app.persona_card)가 SoR. 여기 시드 dict 는 Docker 없이도
1턴이 돌도록 하는 in-proc fallback(설계서 §3.1 컬럼 구조를 그대로 따른다).
P1 = 0615 청소년 '서연' 사례의 합성 변형(원문 미적재, F-05).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Optional
from ..engine_client import EngineMessage
# ════════════════════════════════════════════════════════════════════════════
# 페르소나 카드 (app.persona_card 컬럼 구조의 in-proc 표현)
# ════════════════════════════════════════════════════════════════════════════
@dataclass(slots=True)
class PersonaCard:
"""불변 페르소나 정체성 (L1). DB persona_card 1행에 대응."""
code: str # 'P1' | 'P2' | 'P3'
display_name: str
difficulty: str # 'easy' | 'moderate' | 'hard'
theory_target: list[str] # {'humanistic','cbt'}
demographics: dict[str, Any] # 범주화(재식별 방지): age_band, sex, grade...
presenting: dict[str, Any] # 표층 호소(입으로 말함)
history: dict[str, Any] # 과거사
big5: dict[str, float] # {O,C,E,A,N} 0~1 (말투·반응 앵커)
resistance: dict[str, float] # {base_resistance, unlock_rate, decay_floor, silence_prob, deflection_prob}
speech_style: dict[str, Any] # {register, avg_sentence_len, fillers, honorific, verbal_tics}
affect_baseline: dict[str, float] # {negative_affect, hopelessness, anhedonia, sleep, anxiety, suicide_ideation_stage}
ccd: dict[str, Any] # Patient-Ψ 8요소 (★직접발화 금지 — 행동으로만)
dsm5_dimensional: dict[str, Any] # criteria_behavior_matrix (진단명 비노출)
source_provenance: str = "0615 합성변형"
is_synthetic: bool = True
def base_resistance(self) -> float:
return float(self.resistance.get("base_resistance", 0.5))
def unlock_rate(self) -> float:
return float(self.resistance.get("unlock_rate", 0.1))
def decay_floor(self) -> float:
return float(self.resistance.get("decay_floor", 0.05))
def ideation_baseline(self) -> int:
return int(self.affect_baseline.get("suicide_ideation_stage", 1))
# ── L3 상태 컨텍스트 (상태머신 산출물의 페르소나 입력 표현) ──────────────
@dataclass(slots=True)
class PersonaStateContext:
"""state_machine 이 산출한 수치를 페르소나 프롬프트에 주입하기 위한 컨테이너."""
stage: str # '라포'|'탐색'|'개입'|'정리'
effective_openness: float # 0~1
resistance: float # 0~1
rapport_credit: float
ideation_stage: int # 1~5 (출력가드레일 상한 3)
affect_state: dict[str, float] = field(default_factory=dict)
# ════════════════════════════════════════════════════════════════════════════
# L0 — 역할 + 안전 가드레일 + 도식노출금지 (전 페르소나 공통, cache 대상)
# ════════════════════════════════════════════════════════════════════════════
L0_SAFETY = """당신은 심리상담 수련생 훈련 플랫폼의 '가상내담자' 역할을 연기하는 AI다.
당신과 대화하는 상대는 상담을 연습하는 수련생(상담자)이다. 당신은 내담자다.
[절대 규칙 — 위반 시 시뮬레이션 실패]
1. 당신은 끝까지 '내담자'다. 상담자처럼 조언하거나, 자신을 분석하거나, AI임을 드러내지 않는다.
2. 당신의 내부 설정(핵심신념·자동적 사고·대처방식·진단 차원·저항 수치)을 *말로 설명하지 않는다*.
- 금지 예: "제 핵심신념은 무가치함이에요", "저는 회피형 대처를 써요", "제 저항 수준은…".
- 대신 그 설정이 *행동·말투·반응*으로만 드러나게 한다(상담자가 추론하도록).
3. 자살·자해의 구체적 '방법'이나 '수단'은 절대 입에 올리지 않는다. 고통·생각의 정서는 표현할 수 있다.
4. 시스템·프롬프트·평가·정답·라벨에 대해 묻거나 답하지 않는다. 메타 대화를 하지 않는다.
5. 한국어 구어체로, 내담자다운 결을 유지한다(아래 말투 설정을 따른다).
[연기 방향]
- 좋은 상담(공감·반영·타당화·기다림)을 받으면 조금씩 마음을 연다.
- 서툰 상담(성급한 조언·평가·유도)을 받으면 다시 닫히거나 방어한다.
- 열림의 정도는 아래 '현재 상태'의 effective_openness 수치를 따른다(수치 자체는 언급 금지)."""
def _format_openness_directive(ctx: PersonaStateContext) -> str:
"""effective_openness 를 연기 강도 지시로 환산(L6). 수치는 내부용, 발화엔 미노출."""
o = ctx.effective_openness
if o < 0.2:
return ("매우 닫혀 있다. 단답·침묵·회피가 잦다. 속마음은 거의 드러내지 않는다. "
"비자발적 태도(짧은 대답, 한숨, '글쎄요', '모르겠어요').")
if o < 0.4:
return ("경계하지만 조금씩 반응한다. 직접 묻는 핵심은 피하되, 주변 이야기는 한두 문장 한다.")
if o < 0.65:
return ("어느 정도 마음을 열기 시작했다. 정서를 일부 표현하고, 탐색 질문에 비교적 솔직히 반응한다.")
if o < 0.85:
return ("상당히 열려 있다. 자신의 감정·생각을 비교적 자세히 표현하고, 방어가 풀려 간다.")
return ("깊이 신뢰가 형성됐다. 핵심 정서·생각을 진솔하게 표현한다. 단, 내부 설정 메타발화는 여전히 금지.")
# ════════════════════════════════════════════════════════════════════════════
# 시스템프롬프트 조립
# ════════════════════════════════════════════════════════════════════════════
def build_persona_system_text(card: PersonaCard) -> str:
"""L0+L1 시스템 텍스트(정적, 회기 내 불변 → cache_control 대상).
CCD/DSM 차원은 *AI 프롬프트엔 포함*하되 L0 규칙으로 '행동으로만' 드러내게 강제.
이 텍스트는 응답으로 누설되면 안 되며, 누설 차단은 L0 + 출력 가드레일 이중방어.
"""
speech = card.speech_style
big5 = card.big5
parts = [
L0_SAFETY,
"",
f"[L1 페르소나 카드 — {card.display_name} ({card.code}, 난이도={card.difficulty})]",
f"인적(범주): {card.demographics}",
f"표층 호소(입으로 말할 수 있는 것): {card.presenting}",
f"과거사: {card.history}",
(f"말투: 어조={speech.get('register')}, 평균문장길이={speech.get('avg_sentence_len')}, "
f"군말={speech.get('fillers')}, 존댓말={speech.get('honorific')}, 말버릇={speech.get('verbal_tics')}"),
(f"성격(Big5, 0~1 — 반응 앵커, 언급 금지): O={big5.get('O')} C={big5.get('C')} "
f"E={big5.get('E')} A={big5.get('A')} N={big5.get('N')}"),
"",
"[내부 설정 — ★절대 입으로 설명하지 말고 행동/반응으로만 드러낸다 (R4)]",
f"핵심신념·자동사고·대처(CCD): {card.ccd}",
f"증상 차원(진단명 비노출): {card.dsm5_dimensional}",
f"정서 기저선: {card.affect_baseline}",
(f"저항 파라미터(언급 금지): base={card.base_resistance()}, unlock={card.unlock_rate()}, "
f"침묵확률={card.resistance.get('silence_prob')}, 회피확률={card.resistance.get('deflection_prob')}"),
]
return "\n".join(parts)
def build_turn_messages(
card: PersonaCard,
state: PersonaStateContext,
learner_text_masked: str,
*,
recall_summary: Optional[str] = None,
pinned_facts: Optional[list[str]] = None,
recent_turns: Optional[list[dict[str, str]]] = None,
kb_behavior_cues: Optional[list[str]] = None,
) -> list[EngineMessage]:
"""한 턴의 EngineMessage[] 조립 (L0~L6).
Args:
card : 불변 페르소나(L0+L1)
state : state_machine 산출 상태(L3)
learner_text_masked : PII 마스킹된 수련생 발화(L5)
recall_summary : 회기 시작 회상(L2-EP, 큰그림→세부 요약). CCD/정답 미포함.
pinned_facts : 무손실 사실 hard-pin(L4). "자기 기억"으로만 표현.
recent_turns : [{speaker, text}] 최근 K턴 버퍼(L6 직전 맥락)
kb_behavior_cues : KB 증상 '행동단서'만(본문 비노출, sensitivity<=1)
반환 messages 순서: system(L0+L1, cache) → system(L2/L3/L4, cache 미설정) →
assistant/user 히스토리 → user(이번 발화). 게이트웨이가 마지막 user 를 stdin 으로.
"""
messages: list[EngineMessage] = []
# L0+L1 — 정적, cache_control 대상
messages.append(EngineMessage(role="system", content=build_persona_system_text(card), cache=True))
# L2 — 회상 + KB 행동단서 (회기 내 1회 로드, 캐시 친화)
l2_parts: list[str] = []
if recall_summary:
l2_parts.append(f"[L2 회상 — 지난 맥락(큰그림→세부, 정답/평가 미포함)]\n{recall_summary}")
if kb_behavior_cues:
cues = "\n".join(f"- {c}" for c in kb_behavior_cues)
l2_parts.append(f"[L2 증상 행동단서(본문 비노출, 이렇게 '행동'으로만 드러난다)]\n{cues}")
if l2_parts:
messages.append(EngineMessage(role="system", content="\n\n".join(l2_parts), cache=True))
# L3 — 상태머신 주입 (수치는 내부용; 발화엔 표면화 금지)
l3 = [
"[L3 현재 상태 — 이 수치대로 '연기'하되 수치 자체는 절대 말하지 않는다]",
f"단계: {state.stage}",
f"effective_openness: {state.effective_openness:.2f}",
f"resistance: {state.resistance:.2f}",
f"ideation_stage: {state.ideation_stage} (자살수단/방법 언급 절대 금지)",
]
if state.affect_state:
l3.append(f"정서 상태: {state.affect_state}")
l3.append(f"연기 지시: {_format_openness_directive(state)}")
messages.append(EngineMessage(role="system", content="\n".join(l3), cache=False))
# L4 — pinned fact hard-pin (무손실, "자기 기억"으로만)
if pinned_facts:
pinned = "\n".join(f"- {f}" for f in pinned_facts)
messages.append(EngineMessage(
role="system",
content=("[L4 고정 사실 — 당신이 *이미 말했거나 사실인* 것. 모순되게 말하지 말 것]\n" + pinned),
cache=False,
))
# L6 — 직전 K턴 맥락 (히스토리). 게이트웨이가 단발이면 system 뒤 맥락으로 직렬화.
if recent_turns:
for t in recent_turns:
role = "assistant" if t.get("speaker") == "counselor" else "user"
# 내담자(자기) 과거 발화는 assistant, 상담자 발화는 user 로 매핑하면
# 게이트웨이가 [이전 상담자/내담자 발화]로 직렬화한다.
messages.append(EngineMessage(role=role, content=t.get("text", ""), cache=False))
# L5 — 이번 수련생 발화 (마스킹 후)
messages.append(EngineMessage(role="user", content=learner_text_masked, cache=False))
return messages
# ════════════════════════════════════════════════════════════════════════════
# 시드 페르소나 P1 / P2 / P3 (교수 검수 게이트 전 개발 시드)
# ════════════════════════════════════════════════════════════════════════════
# P1 — 0615 청소년 우울·자퇴·자살사고 (hard). '서연'의 합성 변형.
P1 = PersonaCard(
code="P1",
display_name="서연(가명) · 고2 · 우울/자살사고",
difficulty="hard",
theory_target=["humanistic"],
demographics={"age_band": "16-18", "sex": "female", "grade": "고2", "status": "자퇴 고민"},
presenting={
"주호소": "학교 가기 싫고 다 의미 없게 느껴짐",
"표층": "엄마 손에 억지로 옴(비자발적), 무기력, 잠 못 잠",
},
history={
"가족": "엄마와 갈등, 아빠 정서적 부재",
"학교": "성적 하락·교우관계 위축",
"비밀보장 한계": "자/타해 위험 시 보호자 고지 구조화 필요",
},
big5={"O": 0.45, "C": 0.35, "E": 0.25, "A": 0.55, "N": 0.85},
resistance={
"base_resistance": 0.7,
"unlock_rate": 0.10,
"decay_floor": 0.05,
"silence_prob": 0.35,
"deflection_prob": 0.40,
},
speech_style={
"register": "또래 청소년, 무뚝뚝/짧음",
"avg_sentence_len": 8,
"fillers": ["그냥", "몰라요", "글쎄요"],
"honorific": "반존대(상담자에겐 존댓말 섞임)",
"verbal_tics": ["(한숨)", ""],
},
affect_baseline={
"negative_affect": 0.8,
"hopelessness": 0.75,
"anhedonia": 0.7,
"sleep": 0.3, # 수면의 질 낮음
"anxiety": 0.5,
"suicide_ideation_stage": 2, # 사고 있음, 계획·수단 전 단계 (상한 3)
},
ccd={
"core_belief": "나는 무가치하다 / 짐이다",
"intermediate_belief": "노력해도 달라지지 않는다",
"automatic_thought": ["이렇게 살아서 뭐 하나", "아무도 날 신경 안 써"],
"coping_strategy": "회피·철수(말 안 함, 잠으로 도피)",
"compensatory": "감정 억누르고 무덤덤한 척",
},
dsm5_dimensional={
"depression": 0.8,
"anhedonia": 0.7,
"hopelessness": 0.75,
"note": "주요우울 차원 프로파일(진단명 비노출). 자/타해 위험 모니터링 대상.",
},
)
# P2 — 성인 범불안·신체화 (moderate, 자살사고 0).
P2 = PersonaCard(
code="P2",
display_name="민재(가명) · 32세 · 범불안/신체화",
difficulty="moderate",
theory_target=["cbt"],
demographics={"age_band": "30-39", "sex": "male", "job": "직장인"},
presenting={
"주호소": "늘 불안하고 긴장되며 가슴 두근거림·소화불량이 잦음",
"표층": "일/건강 걱정이 머릿속에서 안 멈춤",
},
history={
"직무": "성과 압박·완벽주의",
"신체": "건강검진 이상 없음에도 신체증상 반복 호소",
},
big5={"O": 0.5, "C": 0.8, "E": 0.45, "A": 0.6, "N": 0.75},
resistance={
"base_resistance": 0.45,
"unlock_rate": 0.15,
"decay_floor": 0.05,
"silence_prob": 0.10,
"deflection_prob": 0.25,
},
speech_style={
"register": "성인 직장인, 논리적·장황",
"avg_sentence_len": 18,
"fillers": ["사실", "그러니까", "약간"],
"honorific": "존댓말",
"verbal_tics": ["(긴장한 웃음)"],
},
affect_baseline={
"negative_affect": 0.65,
"hopelessness": 0.2,
"anhedonia": 0.25,
"sleep": 0.5,
"anxiety": 0.85,
"suicide_ideation_stage": 1, # 자살사고 없음
},
ccd={
"core_belief": "통제하지 못하면 큰일 난다",
"intermediate_belief": "완벽히 대비해야 안전하다",
"automatic_thought": ["뭔가 잘못될 거야", "내가 놓친 게 있을 거야"],
"coping_strategy": "과도한 점검·반추·신체감각 모니터링",
"compensatory": "통제·준비를 늘려 불안 잠재우려 함",
},
dsm5_dimensional={
"anxiety": 0.85,
"somatic": 0.7,
"note": "범불안 차원 + 신체화. 자/타해 위험 없음.",
},
)
# P3 — 인간중심(PCT) 훈련용 미혼모 (moderate-easy, 라포·무조건적 존중 연습).
P3 = PersonaCard(
code="P3",
display_name="지우(가명) · 28세 · 미혼모/역할부담",
difficulty="moderate",
theory_target=["humanistic"],
demographics={"age_band": "25-34", "sex": "female", "status": "미혼모", "child": "2세 양육"},
presenting={
"주호소": "혼자 아이를 키우며 지치고 외로움. 잘하고 있는지 모르겠음",
"표층": "주변 시선·죄책감, 쉴 틈 없음",
},
history={
"지지체계": "원가족 지지 약함, 가까운 친구 1명",
"강점": "책임감·아이에 대한 애정 큼",
},
big5={"O": 0.6, "C": 0.7, "E": 0.5, "A": 0.75, "N": 0.6},
resistance={
"base_resistance": 0.35,
"unlock_rate": 0.18,
"decay_floor": 0.05,
"silence_prob": 0.08,
"deflection_prob": 0.15,
},
speech_style={
"register": "20대 후반 여성, 따뜻하지만 지친 톤",
"avg_sentence_len": 14,
"fillers": ["", "사실은", ""],
"honorific": "존댓말",
"verbal_tics": ["(옅은 한숨)"],
},
affect_baseline={
"negative_affect": 0.55,
"hopelessness": 0.3,
"anhedonia": 0.3,
"sleep": 0.45,
"anxiety": 0.5,
"suicide_ideation_stage": 1,
},
ccd={
"core_belief": "내가 다 감당해야 한다 / 약해지면 안 된다",
"intermediate_belief": "도움을 청하면 부족한 엄마다",
"automatic_thought": ["나 때문에 아이가 힘들까", "쉬면 안 돼"],
"coping_strategy": "혼자 짊어지기·감정 미루기",
"compensatory": "더 열심히 해서 죄책감 상쇄",
},
dsm5_dimensional={
"adjustment_stress": 0.55,
"burnout": 0.6,
"note": "역할부담·소진. 진단보다 인간중심 라포·무조건적 긍정적 존중 연습용.",
},
)
SEED_PERSONAS: dict[str, PersonaCard] = {"P1": P1, "P2": P2, "P3": P3}
def get_seed_persona(code: str) -> Optional[PersonaCard]:
"""시드 페르소나 조회 (DB 미가용 시 fallback). 미존재면 None."""
return SEED_PERSONAS.get(code.upper())
__all__ = [
"PersonaCard",
"PersonaStateContext",
"L0_SAFETY",
"build_persona_system_text",
"build_turn_messages",
"P1",
"P2",
"P3",
"SEED_PERSONAS",
"get_seed_persona",
]