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턴 — 좋은/나쁜 상담에 차등 반응 실증
This commit is contained in:
Yun Chan 2026-06-25 23:37:22 +09:00
parent 859ab26314
commit 24b1b7a6e1
84 changed files with 19645 additions and 107 deletions

View file

@ -0,0 +1,402 @@
"""가상내담자 페르소나 시스템프롬프트 빌더 + 시드 페르소나 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",
]