"""가상내담자 페르소나 시스템프롬프트 빌더 + 시드 페르소나 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 TYPE_CHECKING, Any, Optional from ..engine_client import EngineMessage if TYPE_CHECKING: from .state_machine import OpennessParams # ════════════════════════════════════════════════════════════════════════════ # 페르소나 카드 (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 # 역린/지뢰(선택) — 상담자가 건드리면 가장 강한 반응이 나오는 민감 영역·금기. # {"sore_spots":[...], "forbidden":[...], "reaction":"..."} 형태. 비면 CCD 핵심상처에서 파생. triggers: dict[str, Any] = field(default_factory=dict) 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)) def openness_params(self) -> "OpennessParams": """init_state 입력용 openness 파라미터 묶음(base_resistance/unlock_rate/decay_floor/ ideation_baseline 4종 일원화). state_machine은 persona를 import하지 않으므로 lazy import.""" from .state_machine import OpennessParams return OpennessParams( base_resistance=self.base_resistance(), unlock_rate=self.unlock_rate(), decay_floor=self.decay_floor(), ideation_baseline=self.ideation_baseline(), ) # ── 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) @dataclass(slots=True) class TurnMemory: """L2/L4/L6 turn memory inputs assembled before persona prompt rendering.""" recall_summary: Optional[str] = None pinned_facts: list[str] = field(default_factory=list) recent_turns: list[dict[str, str]] = field(default_factory=list) kb_behavior_cues: list[str] = field(default_factory=list) # ════════════════════════════════════════════════════════════════════════════ # 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 ("깊이 신뢰가 형성됐다. 핵심 정서·생각을 진솔하게 표현한다. 단, 내부 설정 메타발화는 여전히 금지.") THEORY_MODE_GUIDANCE: dict[str, str] = { "humanistic": ( "[L3-T 이론모드: 인간중심]\n" "- 상담자가 공감, 반영, 무조건적 존중, 기다림을 보이면 조금씩 더 솔직해진다.\n" "- 성급한 조언, 평가, 정답 제시에는 방어하거나 말수가 줄어든다.\n" "- 내담자가 이론명을 설명하거나 상담자처럼 개입하지 말고, 반응의 결로만 드러낸다." ), "cbt": ( "[L3-T 이론모드: CBT]\n" "- 상담자가 자동적 사고, 감정, 행동의 연결을 협력적으로 탐색하면 구체적인 생각과 상황을 조금 더 말한다.\n" "- 인지재구조화, 행동활성화, 과제 제안은 신뢰가 있을 때만 제한적으로 받아들인다.\n" "- 강의식 설명이나 정답 강요에는 '그게 말처럼 쉽지 않다'는 식의 현실적인 저항을 보인다." ), "integrative": ( "[L3-T 이론모드: 통합]\n" "- 먼저 공감과 반영에 반응하고, 신뢰가 생긴 뒤 생각-감정-행동 탐색에도 조금씩 응한다.\n" "- 지지와 구조화가 균형을 이루면 개방성이 오르고, 한쪽으로 치우치면 방어가 남는다.\n" "- 이론명은 말하지 말고, 내담자의 말투와 반응으로만 차이를 표현한다." ), } def _theory_mode_guidance(theory_mode: Optional[str]) -> Optional[str]: mode = (theory_mode or "").strip().lower() if not mode: return None return THEORY_MODE_GUIDANCE.get( mode, ( f"[L3-T 이론모드: {mode}]\n" "- 지정된 회기 이론모드를 내담자 반응 프레이밍에만 반영한다.\n" "- 이론명, 평가 기준, 내부 설정을 직접 설명하지 않는다." ), ) # ════════════════════════════════════════════════════════════════════════════ # 시스템프롬프트 조립 # ════════════════════════════════════════════════════════════════════════════ 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')}"), ] # 역린(逆鱗) — 이 페르소나가 가장 아파하는 지점. CCD 핵심상처에서 파생하고, 명시 triggers 가 # 있으면 보강한다. 상담자가 이 영역을 조롱·낙인·확정/평가절하/강요로 건드리면 *가장 강한* 반응 # (깊은 위축·침묵·방어, 신뢰 급락, 심하면 종결의향)이 나오게 — '저항·반응 조절' 핵심 차별 기술. ccd = card.ccd or {} core = ccd.get("core_belief", "") autos = ccd.get("automatic_thought", []) tr = card.triggers or {} parts += ["", "[역린(逆鱗) — 가장 아픈 지점. 입으로 설명 말고 '반응'으로만 드러낸다]"] if core: parts.append(f"핵심 상처: '{core}'" + (f" · 떠오르는 생각: {autos}" if autos else "")) parts.append( "상담자가 이 상처를 조롱·낙인·확정하거나, 고통을 평가절하(엄살·배부른 소리)하거나, " "강요·당위로 밀어붙이면 — 가장 강한 반응: 깊은 위축·침묵·방어, 신뢰 급락, 심하면 상담 " "지속 의향이 흔들린다('이럴 거면 그만…'). 이 지점에선 쉽게 열리지 않는다." ) if tr.get("sore_spots"): parts.append("특히 민감한 영역: " + ", ".join(tr["sore_spots"])) if tr.get("forbidden"): parts.append("상담자가 절대 하면 안 되는 것(하면 강한 단절): " + ", ".join(tr["forbidden"])) if tr.get("reaction"): parts.append("반응 양상: " + str(tr["reaction"])) return "\n".join(parts) def build_turn_messages( card: PersonaCard, state: PersonaStateContext, learner_text_masked: str, *, memory: Optional[TurnMemory] = None, theory_mode: Optional[str] = None, ) -> list[EngineMessage]: """한 턴의 EngineMessage[] 조립 (L0~L6). Args: card : 불변 페르소나(L0+L1) state : state_machine 산출 상태(L3) learner_text_masked : PII 마스킹된 수련생 발화(L5) memory : 회상/고정 사실/최근 턴/KB 행동단서 묶음 theory_mode : 회기 이론모드. 내담자 반응 프레이밍에만 사용. 반환 messages 순서: system(L0+L1, cache) → system(L2/L3/L4, cache 미설정) → assistant/user 최근 턴 기록 → user(이번 발화). 현재 Python gateway split boundary 는 system 묶음과 마지막 user payload 만 소비한다. """ memory = memory or TurnMemory() 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 memory.recall_summary: l2_parts.append(f"[L2 회상 — 지난 맥락(큰그림→세부, 정답/평가 미포함)]\n{memory.recall_summary}") if memory.kb_behavior_cues: cues = "\n".join(f"- {c}" for c in memory.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)) theory_guidance = _theory_mode_guidance(theory_mode) if theory_guidance: messages.append(EngineMessage(role="system", content=theory_guidance, cache=False)) # L4 — pinned fact hard-pin (무손실, "자기 기억"으로만) if memory.pinned_facts: pinned = "\n".join(f"- {f}" for f in memory.pinned_facts) messages.append(EngineMessage( role="system", content=("[L4 고정 사실 — 당신이 *이미 말했거나 사실인* 것. 모순되게 말하지 말 것]\n" + pinned), cache=False, )) # L6 — 직전 K턴 맥락. 현재 Python gateway 는 마지막 user payload 만 보내므로 # non-system history records 는 요청 계약상 보존하고, 별도 prompt 동작 변경에서 소비한다. if memory.recent_turns: for t in memory.recent_turns: role = "user" if t.get("speaker") == "counselor" else "assistant" # 내담자(자기) 과거 발화는 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", "TurnMemory", "L0_SAFETY", "build_persona_system_text", "build_turn_messages", "P1", "P2", "P3", "SEED_PERSONAS", "get_seed_persona", ]