Spaces:
Running
Running
| # -*- coding: utf-8 -*- | |
| """ | |
| 표현 코칭 — 최소 흐름 프로토타입 (v0.1) | |
| 시나리오: 서운함 전하기 (B·여성 고정) | |
| 흐름: 적기 → 측정 분기(막막/또렷) → 비계(선택지) → 3단계(자기 문장 다듬기) → 완성 | |
| 측정: KoSimCSE(검증된 엔진) — 신호 강도(max|coord|) 게이트로 막막/또렷 분기 | |
| 응답: Gemini (좌표 요약을 시스템 프롬프트에 + 최근 몇 턴만 — 비용 절제) | |
| 목적: '적기→측정분기→3단계'가 작동하는지, '거울+산파'로 느껴지는지 검증. | |
| 뺀 것(의도적): 맥락 3선택, 데이터 저장, 나침반, 4시나리오 — 검증 후 추가. | |
| 실행: Colab/로컬에서 GEMINI_API_KEY 환경변수 또는 Colab Secrets 설정 후 python app_coach.py | |
| (HF Space면 Space Secret에 GEMINI_API_KEY) | |
| """ | |
| import os | |
| import re | |
| import json | |
| import time | |
| from contextlib import nullcontext | |
| import numpy as np | |
| from numpy.linalg import norm | |
| import gradio as gr | |
| # ── [향후: 긍정 트랙 설계 메모] ────────────────────────────────── | |
| # 현재는 '부정 감정 표현' 트랙만 (서운/속상/화남 → 다듬어 전하기). 완성=EVA 상승(격한 감정 정리). | |
| # 긍정 트랙(호감/고마움/기쁨)은 별도 설계 필요. 핵심 원칙(사용자 지침): | |
| # - 세기가 너무 세지지 않게 (긍정 감정이 과하게 분출되지 않도록 조절) | |
| # - 변화율(rate-of-change)을 감지하며 (절대값보다 EVA 추세·증감을 봄) | |
| # - 차근차근 표현하게 (단계적으로, 부담 덜며 구체화) | |
| # → 완성 기준도 'EVA 상승'이 아닌 '표현 구체화/신호 안정'으로 새로 정의해야. 데이터 0건이므로 만든 뒤 검증 필수. | |
| VERSION = "coach-0.9.10.7-drama-voice" # 0.9.10.6 + [본편 목소리] 3턴 연속 말꼬리 에코("…알았으면 했다니") 수리: ①본편 3편에 partner_state(장면별 상대 상태 — 늦은귀가=굳음·화해완결금지 / 읽씹=고백 받은 쪽·기색으로 반응·확답만 금지 / 회식=머쓱·소화 중)와 편별 style_ex 부여 — 공용 계약의 '굳음' 하드코딩 제거 ②에코 강등: 되받기는 1회, 직전에 되받았으면 내용으로 ③고백 수신 경로: 사소한 단어 말고 그 마음에 반응 ④연속턴 신호: 상대가 n번 말했음을 프롬프트에 명시 ⑤검문에 미완결 어미 가드(~라니./~말은. + 잔여≤6자 → 재생성). 라이트(0.9.10.6)는 불변. // 이전: 0.9.10.5 + [라이트 목소리] 어색 대화 수리(장르 불일치·앵무새·drama few-shot 오염·조건무시 지문): ①라이트 3편에 genre/partner_goal/style_ex 부여 — 생성 계약을 장면별 이원화(라이트=생활 말투·질문엔 내용으로 답변 의무·메뉴 이름 등 구체어 허용·침묵체 금지 / 본편=기존 절제 계약 유지+질문 답변 의무 추가) ②few-shot을 라이트 전용 말투 예시로 교체 ③앵무새 검문(사용자 말 3-gram 제거 후 알맹이≤2자 → 재생성, 과차단 방지 위해 보수적) ④마무리 지문 이원화 — 3마디 완주=close_n(조건중립 재작성), 조기종료·생성실패=close_flat 신설. // 이전: 0.9.10.4 + [깔때기 다리] 이야기 카드에 '다른 이야기'·'이제 내 마음 정리하기(감정 선택 직행)' 추가(세션 유지, story_to_story/story_to_main 이벤트 로깅 — 전환율 측정 가능). 라이트 카드의 '내 마음 정리'도 모드 선택을 건너뛰고 감정 선택 직행(마찰 1단계 제거). 카드 브리지 문구를 버튼 유도형으로 교체. 근거: 실사용 데이터에서 이야기/라이트 세션의 본류 전환 0건. // 이전: 0.9.10.3 + [라이트 교환 복원] 0.9.10.1에서 분기하며 누락된 라이트 교환(탭→초대 지문→상대와 최대 3마디, 절제 계약·✦·비난 침묵 분기·카드로 출구) 재이식 + [위험도 수동 관찰] 이야기/라이트 발화(story_speak·story_reply·lite_speak·lite_reply)를 measure_full로 전환, 위험도를 risks에 기록만(게이트 없음 — '대본 흐름 불필요' 판단 유지, log_turn.risk로 자동 유입되어 확장 검토용 재검증 재료 수집). story_preview(미리보기)는 measure 유지. // 이전: 0.9.10.2 + [인코더 교체 준비] ENC_POOLING(cls/mean)·ENC_PREFIX 설정으로 임의 트랜스포머 인코더 지원(e5류 mean 풀링+접두어 규약 포함, 접두어 토큰은 궤적에서 제외). 현행 KoSimCSE(cls, 무접두어) 동작은 완전 동일. [게이트 호환 안전장치] RISK_GATE에 trained_for 명시 — ENCODER가 학습 인코더와 다르면 위험 게이트 자동 비활성(risk=None)+기동 경고: 옛 계수로 새 인코더를 판정하는 오류를 구조적으로 차단. 교체 절차(주석): ①인코더_교체검증.ipynb 통과 ②GATE/CLEAR 재보정 ③위험게이트 재학습·재내보내기 ④상수 교체. // 이전: 0.9.10.1 + [위험 게이트] 학습된 오답 위험 모델(로지스틱 16계수, 10-seed CV AUC 0.807±0.018, KOTE 2000)로 '이 측정을 믿어도 되는가'를 메인 코치 흐름의 매 발화 판정. 위험 높으면 신호가 강해도 되묻기(비계) 경로 + describe_measure에 [측정 주의] 주입(LLM이 감정 단정 안 함). 같은 forward pass 부산물(토큰 EVA 궤적)만 사용 — 추가 인코딩 0, 외부 사전(KNU) 불필요. coach_turn에 risk 필드 로깅(실사용 재검증 재료). 이야기/라이트 경로는 기존 measure 유지(대본 흐름이라 게이트 불필요 — 재검증 후 확장 검토). 측정값 자체는 불변(무환각 유지). // 이전: 0.9.10 + [첫 턴 절단 수리] 검문이 여러 줄 출력의 첫 줄만 취해 "… 연락." 류 조각을 만들던 결함 수정: 전 줄 합침→과길이는 첫 문장 소프트 트림→알맹이 4자 미만 조각 폐기→1회 재생성 후 대본 폴백. 프롬프트에 "줄바꿈 없이 완결 문장" 명시. 짧은 입력 경고는 채팅 오염 대신 gr.Warning. // 이전: 0.9.9 + [이야기 교환] 상대가 내 문장에 응답(Gemini) 최대 3턴 — 온도 분기(비난→침묵 대본)는 코드가, 표면만 생성(절제 계약: 화해 완결·적대 금지·60자·에코·되물음 1회). 마무리 지문은 항상 대본(결말 유보). 생성 실패/STORY_EXCHANGE=0 → 기존 0.9.9 대본 흐름으로 강등. 매 답장 실측·로깅(kind=story_reply, story_event=exchange_gen/branch_blame). 대본 응답엔 ✦ 없음(구분 가능). // 이전: 0.9.8 + [이야기 모드] 대본 재생(한 줄씩)→개입(마음 찾기→세기 사다리→내 문장, 실측 프리즘 라이브 나침반)→절제된 반응→'완료' 클릭→마음 카드(+기존 후기 재사용). 이야기 대사·측정은 코치 프롬프트에 주입 안 함(측정·완성 엔진 불변). [로깅 수정] coach_turn에 mode/scenario_rel/story_* 추가(보류 과제 해소) · choose_hint가 힌트를 실측정해 chosen_strength 공백 해소 · coach_selection에 mode/scenario_rel 추가 · story_event 레코드 신설. // 이전: 0.9.7 + 시나리오 '부부'를 남편/아내로 분기(옵션 A). 아내는 다시 남편/시댁으로 분기해 각 10개(총 20개) 강도 사다리 신규 작성. 3개 사다리(남편→아내 / 아내→남편 / 아내→시댁) 각 약함(1)→강함(10). 부모/시댁 사다리는 신규 작성분(실사용 검토 대상). 측정·완성 엔진 불변. // 0.9.5 진입 모드+시나리오, 0.9.6 후기·자녀/부모, 0.9.7 남편 사다리: [감정 기반=기존 그대로] / [시나리오 기반: 관계(연인·부부·사춘기자녀)→감정→상황 예시를 '씨앗'으로만 제시]. 시나리오는 측정·완성·코치 프롬프트에 절대 주입 안 함(0.8.9 재발 방지). 사춘기 자녀 트랙=소프트 게이트(비번 qwer, 클라 노출·실보안 아님). 공허함 진입에 care 안내. // 0.9.3 + 나침반 자동 스케일: 화면 크기(lim)를 데이터 최대값에 맞춰 반올림 자동 조절(_compass_auto_lim, pad 1.12·step 0.25·floor 1.0·cap 2.5). 강한 감정(예 '답답함' EVA-0.86→정규화-1.17)도 경계 넘침 없이 표시, 약한 감정은 화면 채움. 측정값/로그 원본 유지. 세션 간 절대 비교는 약해지나 세션 내 다이나믹 우선(마음결은 한 대화 흐름을 봄). | |
| DEBUG = False # True면 측정값/상태를 나침반 캡션 아래 표시(임계값 조정용). 평소 False(사용자 화면에 수치 노출 안 함). | |
| ENCODER = "BM-K/KoSimCSE-roberta-multitask" | |
| USE_SENTENCE_TRANSFORMERS = ENCODER.startswith("BAAI") | |
| # ── 인코더 규약 (교체 준비) ───────────────────────────────────────── | |
| # 인코더를 바꿀 때 함께 바꿀 상수. 반드시 인코더_교체검증.ipynb 통과 후에만 교체할 것. | |
| # KoSimCSE(현행): ENC_POOLING="cls", ENC_PREFIX="" | |
| # multilingual-e5 계열: ENC_POOLING="mean", ENC_PREFIX="query: " (공식 규약) | |
| # MiniLM 등 sentence-transformers 계열: ENC_POOLING="mean", ENC_PREFIX="" | |
| # 교체 절차: ① 검증 노트북 통과 → ② GATE/CLEAR_THRESHOLD 재보정(신호강도 분포 변화) | |
| # → ③ 위험 게이트 재학습·재내보내기(RISK_GATE 교체) → ④ 여기 상수 교체. | |
| ENC_POOLING = "cls" # "cls" | "mean" | |
| ENC_PREFIX = "" # 모든 입력(축 문장 포함)에 붙는 접두어. 궤적에서는 접두어 토큰 제외. | |
| # HF Dataset 저장 설정 — Space Secret에 HF_TOKEN(쓰기), HF_DATASET_REPO("아이디/coach-data") 설정 시 영구 저장 | |
| HF_DATASET_REPO = os.environ.get("HF_DATASET_REPO", "") | |
| DATA_DIR = os.environ.get("COACH_DATA_DIR", "/tmp/coach_data") | |
| # 신호 강도 게이트 — 검증값(의미분명 1.25 vs 단편 0.37). 이 값으로 막막/또렷 분기. | |
| GATE = 0.35 | |
| # 또렷 판정: max|coord|가 이 이상이면 '이미 표현됨' → 비계 건너뛰고 3단계 | |
| CLEAR_THRESHOLD = 0.7 | |
| # ============================== 위험 게이트 (학습된 오답 위험 모델) ============================== | |
| # '이 측정을 믿어도 되는가'를 판정하는 로지스틱 모델. selective prediction 계보(Chow 1957~). | |
| # 특징: 프리즘 내부 부산물만(EVA·신호강도·토큰 궤적의 파형/주파수) — 외부 사전(KNU) 불필요. | |
| # 학습된 규칙(계수 해석): 위험 = |EVA|·|뒤쪽EVA| 작음(애매) + 반전 파형 + 앞뒤 불일치 + 비단조. | |
| # 검증: 10-seed CV AUC 0.807±0.018, 위험 상위 10% 보류 시 잔여 정확도 Δ+0.034 (KOTE, 클래스당 1000). | |
| # 정직한 경계: KOTE 도메인 검증 — 마음결 실사용 데이터로 재검증 필요(coach_turn의 risk 필드가 그 재료). | |
| # 반어는 되묻기로 보낼 뿐 못 맞힘. 기존 irony_suspect(휴리스틱)와 상호보완(둘 다 기록됨). | |
| RISK_GATE = { | |
| "trained_for": "BM-K/KoSimCSE-roberta-multitask", # 이 계수가 학습된 인코더 — 다르면 게이트 자동 비활성 | |
| "features": ["eva", "strength", "backw_eva", "low", "mid", "high", "dom_freq", | |
| "reversal", "monotonic", "n_extrema", "valley_pos", "length", | |
| "abs_eva", "abs_backw", "disagree", "sign_conflict"], | |
| "scaler_mean": [-0.004361, 0.498294, 0.005867, 0.628363, 0.225631, 0.143006, 1.6005, | |
| 0.047582, 0.026758, 9.4395, 0.502326, 28.896, | |
| 0.262152, 0.240983, 0.049571, 0.0525], | |
| "scaler_std": [0.319475, 0.186585, 0.293073, 0.126468, 0.078393, 0.067335, 1.208677, | |
| 0.999586, 0.497112, 3.542505, 0.3286, 16.836721, | |
| 0.182646, 0.166894, 0.042757, 0.223033], | |
| "coef": [-0.084199, -0.019469, -0.025516, 0.473949, 0.296618, 0.368274, -0.066653, | |
| 0.256526, -0.346253, -0.093888, -0.042957, -0.050844, | |
| -0.591558, -1.05506, 0.214219, 0.130036], | |
| "intercept": -0.707856, | |
| "risk_threshold_10pct": 0.770826, # 보수적 게이트(위험 상위 ~10%만 되묻기) — 실사용 재검증 전 기본값 | |
| "risk_threshold_20pct": 0.682869, | |
| } | |
| GEMINI_PRICES = {"gemini-2.5-flash": (0.30, 2.50), "gemini-2.5-flash-lite": (0.10, 0.40)} | |
| # ============================== 축 정의 (검증된 CONSTRUCTS) ============================== | |
| CONSTRUCTS = { | |
| "VAL": {"label": ("자율 지향", "순응 지향"), "pos": [ | |
| "나는 내 삶을 어떻게 살지 스스로 결정하는 것을 좋아한다.", | |
| "새롭고 독창적인 생각을 떠올리는 것이 나에게 중요하다.", | |
| "나는 무엇이든 내 방식대로 해결하는 편이다.", | |
| "내 목표를 스스로 자유롭게 선택하는 것이 중요하다.", | |
| "남에게 묻기보다 내 판단을 믿고 따르는 편이다.", | |
| "나는 호기심을 따라 새로운 것을 탐험하는 것을 가치 있게 여긴다.", | |
| "누가 알려주기보다 세상을 스스로 이해하고 싶다.", | |
| "나는 독립적으로 일을 해내는 것에 자부심을 느낀다.", | |
| "남을 따라 하기보다 내 방식을 새로 만드는 편이 좋다.", | |
| "내 인생의 방향을 스스로 정하는 것이 무척 중요하다.", | |
| "나는 창의적으로, 내 식대로 시도하는 것을 좋아한다.", | |
| "나에게 무엇이 옳은지 스스로 판단할 수 있다고 믿는다.", | |
| ], "neg": [ | |
| "나는 사람은 규칙을 잘 지켜야 한다고 생각한다.", | |
| "시키는 일을 잘 따르는 것이 나에게 중요하다.", | |
| "나는 예의 바르게 행동하고 잘못된 일을 하지 않으려 애쓴다.", | |
| "물려받은 전통과 관습을 지키는 것이 나에게 중요하다.", | |
| "나는 권위와 윗사람을 존중해야 한다고 믿는다.", | |
| "전통을 존중하는 것을 중요하게 여긴다.", | |
| "나는 튀기보다 집단에 어울리는 편을 택한다.", | |
| "정해진 방식을 함부로 의심하지 않는 편이 낫다고 생각한다.", | |
| "나에게 기대되는 바를 지키는 것이 옳게 느껴진다.", | |
| "나는 순종적이고 믿음직한 사람이 되는 것을 가치 있게 여긴다.", | |
| "사회 규범에는 이유가 있으니 지켜야 한다고 믿는다.", | |
| "나에게는 새로움보다 안정과 질서를 지키는 것이 더 중요하다.", | |
| # 선택-동사 순응 보강 — 진단 결과 '능동 선택형 순응'(남들 따라 고르기)이 | |
| # 자율로 오측정되던 문제 완화. 독립 데이터 검증 AUC 0.90→0.95. | |
| "남들이 많이 사는 물건을 골라서 산다.", | |
| "유행하는 쪽을 보고 그대로 선택한다.", | |
| "다수가 정한 방향에 내 결정을 맞춘다.", | |
| "주변에서 고르는 것을 보고 똑같이 고른다.", | |
| "나도 남들 하는 대로 무난한 쪽을 택한다.", | |
| "사람들이 좋다고 하는 것을 골라 산다.", | |
| "내 취향보다 대세를 따라 고르는 편이다.", | |
| "남들 눈을 의식해서 선택을 정한다.", | |
| ]}, | |
| "REI": {"label": ("분석적", "직관적"), "pos": [ | |
| "나는 깊이 생각해야 하는 문제를 즐긴다.", | |
| "행동하기 전에 상황을 논리적으로 분석하는 것을 좋아한다.", | |
| "나는 단계를 밟아 차근차근 따져보는 편이다.", | |
| "복잡한 문제를 깊이 고민하는 것이 만족스럽다.", | |
| "나는 결론을 내릴 때 논리와 근거에 의존한다.", | |
| "문제를 부분으로 나누어 분석하는 것을 좋아한다.", | |
| "어려운 지적 과제를 풀어내는 것을 즐긴다.", | |
| "결정할 때 감정보다 이성을 더 믿는다.", | |
| "나는 결정 전에 장단점을 신중히 따져본다.", | |
| "추상적이고 분석적인 사고가 즐겁다.", | |
| "분명한 근거로 뒷받침된 결론을 선호한다.", | |
| "무언가의 논리를 풀어내면 만족스럽다.", | |
| "느낌이 좋았지만 가격을 비교하고 구매했다.", | |
| "느낌보다 데이터를 우선해 전공을 정했다.", | |
| "감으로 판단하지 않고 사실을 확인했다.", | |
| "직감이 들어도 먼저 수치를 확인한다.", | |
| "막연한 느낌을 숫자로 바꿔 확인했다.", | |
| "직감이 나빴지만 안전 데이터를 검증했다.", | |
| "감보다 근거를 보고 이직을 결정했다.", | |
| "느낌에 기대지 않고 논문을 분석했다.", | |
| "느낌이 와도 계약 조건을 따져본다.", | |
| "직감을 가설로 세워 실험을 설계했다.", | |
| ], "neg": [ | |
| "나는 보통 직감에 따라 행동한다.", | |
| "나는 종종 무엇이 옳은지 그냥 느낌으로 안다.", | |
| "분석보다 첫인상에 더 의존하는 편이다.", | |
| "나는 많은 결정을 느낌에 따라 내린다.", | |
| "설명할 수 없어도 내 직관을 믿는다.", | |
| "내 예감은 대체로 들어맞는다.", | |
| "나는 그 순간 옳게 느껴지는 대로 하는 편이다.", | |
| "나는 상황을 본능으로 읽는다.", | |
| "나는 결정을 느낌으로 더듬어 가는 편이 좋다.", | |
| "나는 감정과 인상에 따라 선택하곤 한다.", | |
| "따져보지 않아도 옳다는 걸 아는 경우가 많다.", | |
| "나는 신중한 분석보다 직관에 더 의존한다.", | |
| ]}, | |
| "EVA": {"label": ("긍정", "부정"), "pos": [ | |
| "지금 나는 즐겁고 기분이 좋다.", "따뜻한 만족감이 느껴진다.", | |
| "오늘 나는 희망차고 밝다.", "나는 흐뭇하고 만족스럽다.", | |
| "나는 고맙고 뿌듯하다.", "좋고 기분 좋은 느낌이 함께한다.", | |
| "나는 행복하고 마음이 가볍다.", "나는 흡족하고 긍정적이다.", | |
| "지금 내 안에 기쁨이 있다.", "나는 즐겁고 편안하다.", | |
| "나는 감사하고 마음이 따뜻하다.", "밝은 안녕감이 느껴진다.", | |
| ], "neg": [ | |
| "지금 나는 시무룩하고 우울하다.", "무거운 슬픔이 느껴진다.", | |
| "오늘 나는 낙담하고 서럽다.", "나는 언짢고 불만스럽다.", | |
| "나는 비참하고 의기소침하다.", "나쁘고 불쾌한 느낌이 함께한다.", | |
| "나는 침울하고 풀이 죽었다.", "나는 속상하고 부정적이다.", | |
| "지금 내 안에 슬픔이 있다.", "나는 슬프고 마음이 불편하다.", | |
| "나는 씁쓸하고 마음이 차갑다.", "어두운 괴로움이 느껴진다.", | |
| ]}, | |
| "EAR": {"label": ("고각성", "저각성"), "pos": [ | |
| "나는 활력이 넘치고 또렷이 깨어 있다.", "나는 들뜨고 잔뜩 흥분돼 있다.", | |
| "내 몸이 활성화되고 긴장돼 있다.", "나는 격렬하고 잔뜩 달아올라 있다.", | |
| "나는 안절부절못하며 에너지가 넘친다.", "심장이 빠르게 뛰는 것 같다.", | |
| "나는 긴장되고 크게 각성돼 있다.", "나는 안달이 나고 자극받아 있다.", | |
| "나는 한껏 충전돼 들떠 있다.", "나는 곤두서고 신경이 팽팽하다.", | |
| "몸에 각성이 솟구치는 느낌이다.", "나는 또렷하고 활기차게 깨어 있다.", | |
| ], "neg": [ | |
| "나는 차분하고 고요하다.", "나는 졸리고 느긋하다.", | |
| "나는 조용하고 가라앉아 있다.", "나는 나른하고 멍하다.", | |
| "내 에너지가 낮고 느리게 느껴진다.", "나는 누그러지고 서두르지 않는다.", | |
| "나는 평온하고 쉬고 있다.", "나는 축 늘어지고 무겁다.", | |
| "나는 잔잔하고 조용하다.", "나는 졸음이 오고 잦아든다.", | |
| "깊은 고요함이 느껴진다.", "나는 느긋하고 반쯤 잠든 듯하다.", | |
| ]}, | |
| # ── 외로움/공허 측정용 신규 축 (노트북 검증: 외로움 vs 중립 CON d=-3.70, 공허 vs 중립 FUL d=-4.76, 길이통제 통과) ── | |
| # ※ 측정·기록만. signal_strength/완성판정에는 미반영(검증 전엔 동작 불변). 실사용 재검증 후 활용 예정. | |
| "CON": {"label": ("연결됨", "고립됨"), "pos": [ | |
| "나는 사람들과 깊이 이어져 있다고 느낀다.", | |
| "내 곁에는 마음을 나눌 사람들이 있다.", | |
| "나는 누군가와 함께 있고 연결되어 있다.", | |
| "내가 속한 사람들 사이에서 소속감을 느낀다.", | |
| "나를 아끼고 곁에 있어 주는 사람들이 있다.", | |
| "나는 다른 사람들과 가깝게 맺어져 있다.", | |
| ], "neg": [ | |
| "나는 혼자 동떨어져 있다고 느낀다.", | |
| "내 곁에는 마음을 나눌 사람이 아무도 없다.", | |
| "나는 외롭고 누구와도 이어져 있지 않다.", | |
| "어디에도 속하지 못하고 겉도는 느낌이다.", | |
| "아무도 내 곁에 없는 것 같다.", | |
| "나는 사람들에게서 멀리 떨어져 단절되어 있다.", | |
| ]}, | |
| "FUL": {"label": ("충만함", "공허함"), "pos": [ | |
| "내 삶은 의미로 가득 차 있다.", | |
| "나는 마음이 충만하고 채워져 있다.", | |
| "하루하루가 의미 있고 보람차다.", | |
| "내 안이 따뜻하게 채워진 느낌이다.", | |
| "나는 살아가는 이유와 의미를 느낀다.", | |
| "내 마음은 풍요롭고 가득하다.", | |
| ], "neg": [ | |
| "내 마음은 텅 비어 있다.", | |
| "나는 공허하고 아무것도 느껴지지 않는다.", | |
| "하루하루가 의미 없이 비어 있다.", | |
| "내 안이 허전하게 비어 있는 느낌이다.", | |
| "나는 살아가는 의미를 찾지 못하겠다.", | |
| "내 마음은 메마르고 텅 비었다.", | |
| ]}, | |
| } | |
| # ============================== 측정 엔진 (검증된 코드 그대로) ============================== | |
| class MeasurementEngine: | |
| def __init__(self, encoder_name=ENCODER): | |
| self.encoder_name = encoder_name | |
| self._load_encoder() | |
| # 접두어 토큰 수(특수토큰 제외) — 토큰 궤적에서 건너뛸 길이 | |
| if (not USE_SENTENCE_TRANSFORMERS) and ENC_PREFIX: | |
| self._prefix_len = len(self._tok(ENC_PREFIX, add_special_tokens=False)["input_ids"]) | |
| else: | |
| self._prefix_len = 0 | |
| self.axes, self.refs = {}, {} | |
| for c, d in CONSTRUCTS.items(): | |
| ep, en = self._embed(d["pos"]), self._embed(d["neg"]) | |
| v = ep.mean(0) - en.mean(0); v = v / (norm(v) + 1e-8) | |
| self.axes[c] = v | |
| ref = np.vstack([ep, en]) @ v | |
| self.refs[c] = (ref.mean(), ref.std() + 1e-8) | |
| def _load_encoder(self): | |
| if USE_SENTENCE_TRANSFORMERS: | |
| from sentence_transformers import SentenceTransformer | |
| self._st = SentenceTransformer(self.encoder_name) | |
| else: | |
| from transformers import AutoModel, AutoTokenizer | |
| import torch | |
| self._torch = torch | |
| self._tok = AutoTokenizer.from_pretrained(self.encoder_name) | |
| self._mdl = AutoModel.from_pretrained(self.encoder_name).eval() | |
| def _embed(self, sents): | |
| if isinstance(sents, str): sents = [sents] | |
| if USE_SENTENCE_TRANSFORMERS: | |
| return np.asarray(self._st.encode(sents, normalize_embeddings=True), dtype=np.float64) | |
| out = [] | |
| max_len = 64 + self._prefix_len # 접두어만큼 여유(무접두어면 기존과 동일한 64) | |
| for i in range(0, len(sents), 16): | |
| b = [ENC_PREFIX + s for s in sents[i:i + 16]] if ENC_PREFIX else sents[i:i + 16] | |
| inp = self._tok(b, padding=True, truncation=True, max_length=max_len, return_tensors="pt") | |
| with self._torch.no_grad(): | |
| hs = self._mdl(**inp).last_hidden_state | |
| if ENC_POOLING == "mean": # e5·MiniLM 등: attention mask 가중 평균(공식 규약) | |
| m = inp["attention_mask"].unsqueeze(-1).float() | |
| e = (hs * m).sum(1) / m.sum(1).clamp(min=1e-9) | |
| else: # KoSimCSE 등: CLS — 기존 동작과 완전 동일 | |
| e = hs[:, 0] | |
| e = e / (e.norm(dim=1, keepdim=True) + 1e-8) | |
| out.append(e.cpu().double().numpy()) | |
| return np.vstack(out) | |
| def measure(self, text): | |
| emb = self._embed(text)[0] | |
| return {c: float((emb @ v - self.refs[c][0]) / self.refs[c][1]) for c, v in self.axes.items()} | |
| def measure_full(self, text): | |
| """한 번의 forward pass로 (전체 축 좌표, EVA 토큰 궤적)을 함께 반환. | |
| 위험 게이트용 — 어차피 계산하던 hidden states의 부산물이라 추가 인코딩 비용 0. | |
| CLS 좌표는 measure()와 동일 정규화(행별 norm → 사영 → z). | |
| sentence_transformers 경로는 토큰 접근 불가 → (coords, None) 반환(게이트 비활성).""" | |
| if USE_SENTENCE_TRANSFORMERS: | |
| return self.measure(text), None | |
| txt = ENC_PREFIX + str(text) if ENC_PREFIX else str(text) | |
| inp = self._tok([txt], padding=True, truncation=True, max_length=64 + self._prefix_len, return_tensors="pt") | |
| with self._torch.no_grad(): | |
| hs_t = self._mdl(**inp).last_hidden_state[0] | |
| if ENC_POOLING == "mean": | |
| m = inp["attention_mask"][0].unsqueeze(-1).float() | |
| sent = (hs_t * m).sum(0) / m.sum(0).clamp(min=1e-9) | |
| else: | |
| sent = hs_t[0] # CLS — 기존 동작과 완전 동일 | |
| sent = (sent / (sent.norm() + 1e-8)).cpu().double().numpy() | |
| coords = {c: float((sent @ v - self.refs[c][0]) / self.refs[c][1]) for c, v in self.axes.items()} | |
| hs = hs_t / (hs_t.norm(dim=1, keepdim=True) + 1e-8) | |
| hs = hs.cpu().double().numpy() | |
| ids = inp["input_ids"][0].tolist() | |
| toks = self._tok.convert_ids_to_tokens(ids) | |
| special = set(self._tok.all_special_tokens) | |
| v = self.axes["EVA"]; mu, sd = self.refs["EVA"] | |
| proj = (hs @ v - mu) / sd | |
| keep = [p for t, p in zip(toks, proj) if t not in special] | |
| if self._prefix_len: | |
| keep = keep[self._prefix_len:] # 접두어 토큰은 궤적에서 제외(파형 오염 방지) | |
| return coords, np.asarray(keep, dtype=np.float64) | |
| def signal_strength(m): | |
| """신호 강도 = 전체 축 최대 |coord|. 막막/또렷 분기의 기준.""" | |
| return max(abs(m[c]) for c in ("VAL", "REI", "EVA", "EAR")) | |
| # ── 위험도 계산 (순수 numpy — scipy 불필요, 학습 노트북과 동일 공식·수치검증 오차 0) ── | |
| RISK_N_RS = 24 # 파형 리샘플 점수(학습과 동일) | |
| def _rank(x): | |
| order = np.argsort(x, kind="stable") | |
| r = np.empty(len(x), dtype=np.float64) | |
| r[order] = np.arange(len(x), dtype=np.float64) | |
| return r | |
| def risk_probability(m, token_proj): | |
| """프리즘 오답 위험도(0~1). '이 좌표를 믿어도 되는가'의 학습된 판정. | |
| token_proj가 없으면 None(게이트 비활성 — 기존 동작과 동일).""" | |
| if (not RISK_GATE_ACTIVE) or token_proj is None or len(token_proj) == 0: | |
| return None | |
| eva = m["EVA"]; strength = signal_strength(m) | |
| proj = np.asarray(token_proj, dtype=np.float64) | |
| n = len(proj) | |
| w = (np.arange(1, n + 1, dtype=np.float64)) ** 2.0 | |
| backw = float(np.average(proj, weights=w)) # 뒤쪽 가중 EVA(한국어 후행 진심) | |
| if n >= 3: | |
| r = np.interp(np.linspace(0, 1, RISK_N_RS), np.linspace(0, 1, n), proj) | |
| s = r.std(); wv = (r - r.mean()) / (s if s > 1e-9 else 1.0) | |
| spec = np.abs(np.fft.rfft(wv))[1:] | |
| if spec.sum() > 1e-9: | |
| sp = spec / spec.sum(); nb = len(sp) | |
| low = float(sp[:max(1, nb // 3)].sum()) | |
| mid = float(sp[max(1, nb // 3):max(2, 2 * nb // 3)].sum()) | |
| high = float(sp[max(2, 2 * nb // 3):].sum()) | |
| dom = int(np.argmax(spec)) + 1 | |
| else: | |
| low = mid = high = 0.0; dom = 0 | |
| half = RISK_N_RS // 2 | |
| rev = float(wv[half:].mean() - wv[:half].mean()) | |
| mono = float(np.corrcoef(np.arange(RISK_N_RS, dtype=np.float64), _rank(wv))[0, 1]) | |
| inner = wv[1:-1] | |
| nex = int(np.sum((inner > wv[:-2]) & (inner > wv[2:]))) + int(np.sum((inner < wv[:-2]) & (inner < wv[2:]))) | |
| valley_pos = float(np.argmin(wv)) / (RISK_N_RS - 1) | |
| else: | |
| low = mid = high = 0.0; dom = 0; rev = 0.0; mono = 0.0; nex = 0; valley_pos = 0.5 | |
| feats = {"eva": eva, "strength": strength, "backw_eva": backw, | |
| "low": low, "mid": mid, "high": high, "dom_freq": dom, | |
| "reversal": rev, "monotonic": mono, "n_extrema": nex, "valley_pos": valley_pos, | |
| "length": n, "abs_eva": abs(eva), "abs_backw": abs(backw), | |
| "disagree": abs(eva - backw), "sign_conflict": float(eva * backw < 0)} | |
| g = RISK_GATE | |
| x = np.array([feats[f] for f in g["features"]], dtype=np.float64) | |
| z = (x - np.array(g["scaler_mean"])) / np.array(g["scaler_std"]) | |
| logit = float(z @ np.array(g["coef"]) + g["intercept"]) | |
| return 1.0 / (1.0 + np.exp(-logit)) | |
| # ── VAL/REI 방향 3단계 분류 (실험적, A+B용) ────────────────────────────── | |
| # 검증 결과: VAL/REI는 '방향'(자율/순응, 분석/직관)은 안정적으로 갈리나 | |
| # '강도'(매우/약간)는 KoSimCSE가 못 잡음 → 4/8단계 불가, 2~3단계만 유효. | |
| # 따라서 방향 기반 3단계(양극 + 중립)로만 분류. 강도 표현 금지. | |
| # 주의: 방향 분류는 동의어 변동에 안정적(범주정확도 VAL 100%/REI 92%)이나, | |
| # 그 분류가 '실제 성향'과 맞는지는 외부검증 안 됨 → 참고용/실험적. | |
| # EVA(KNU 0.75 검증)와 달리 강하게 의존하지 말 것. | |
| VALREI_NEUTRAL_BAND = 0.3 # |값| < 0.3 이면 '뚜렷하지 않음'(중립). 검증에서 쓴 값. | |
| def classify3(value, pos_label, neg_label): | |
| """연속 측정값을 방향 3단계로. 강도는 표현하지 않음(방향만).""" | |
| if value > VALREI_NEUTRAL_BAND: | |
| return pos_label | |
| elif value < -VALREI_NEUTRAL_BAND: | |
| return neg_label | |
| else: | |
| return None # 뚜렷하지 않음 → 성향 언급 안 함 | |
| def tendency_of(m): | |
| """측정 m에서 VAL/REI 방향 성향을 추출. (val_dir, rei_dir) 각각 라벨 또는 None.""" | |
| val_dir = classify3(m["VAL"], "자율", "순응") | |
| rei_dir = classify3(m["REI"], "분석", "직관") | |
| return val_dir, rei_dir | |
| # ── 반어 의심 플래그 ────────────────────────────────────────────────────── | |
| # 측정이 약한 '순수 반어'("내가 행복하겠어?" = 불행이나 EVA 양수로 측정됨)를 | |
| # 대화 흐름의 급반전으로 탐지. 자동 처리 안 함 — 플래그만 기록(데이터 수집용). | |
| # 절대 원칙: EVA 측정값 불변. 완성 판정 등 기존 로직에 안 씀. 진짜 긍정 전환 보호. | |
| IRONY_PATTERNS = [ | |
| r"겠어\s*\?", r"겠니\s*\?", r"겠나\s*\?", r"겠어요\s*\?", # 수사의문 | |
| r"리가\s*없", r"리\s*없", r"ㄹ\s*리가", r"을\s*리가", # ~리가 없 | |
| r"뭐\s*(좋|행복|즐겁|기쁘)", # 뭐 좋아 | |
| r"(좋|행복|즐거울|기쁠)\s*게\s*(뭐|있)", # 좋을 게 뭐 | |
| ] | |
| def has_irony_cue(text): | |
| """조건2: 반어 형태 단서가 있는가.""" | |
| for p in IRONY_PATTERNS: | |
| if re.search(p, text): | |
| return True | |
| return False | |
| def is_eva_reversal(cur_eva, eva_hist, n=3, neg_thresh=-0.2, jump_thresh=0.5): | |
| """조건1: 직전 부정 흐름에서 현재가 크게 상승해 양수(급반전). | |
| 이력이 적은 대화 초반에도 탐지되도록, 있는 만큼(최소 1턴)으로 직전 평균을 본다. | |
| (기록만 — 측정값/완성판정 안 건드림. 반어는 초반에 자주 나오므로 초반 탐지가 중요.)""" | |
| if len(eva_hist) < 1: | |
| return False, None | |
| win = min(len(eva_hist), n) # 있는 만큼(최대 n) | |
| recent = eva_hist[-win:] | |
| recent_mean = sum(recent) / len(recent) | |
| jump = cur_eva - recent_mean | |
| cond = (recent_mean < neg_thresh) and (jump > jump_thresh) and (cur_eva > 0) | |
| return cond, {"recent_mean": round(recent_mean, 2), "jump": round(jump, 2), "win": win} | |
| def irony_suspect(cur_text, cur_eva, eva_hist): | |
| """반어 의심 종합 판정. 자동 처리 안 함 — 플래그만 반환. | |
| level: 강(반어 의심) / 급반전(진짜 전환 가능, 안 건드림) / 형태만 / 없음""" | |
| c1, c1d = is_eva_reversal(cur_eva, eva_hist) | |
| c2 = has_irony_cue(cur_text) | |
| if c1 and c2: | |
| level = "강" | |
| elif c1: | |
| level = "급반전" | |
| elif c2: | |
| level = "형태만" | |
| else: | |
| level = "없음" | |
| return {"level": level, "cond1_reversal": c1, "cond2_cue": c2, "detail": c1d} | |
| print("측정 엔진 로딩 중...") | |
| ENGINE = MeasurementEngine() | |
| print("측정 엔진 준비 완료:", {k: round(v, 2) for k, v in ENGINE.measure("오늘 정말 행복해").items()}) | |
| # ── 게이트-인코더 호환 검사 ── 계수는 특정 인코더의 궤적으로 학습됨. 다르면 판정이 무의미하므로 자동 비활성. | |
| RISK_GATE_ACTIVE = (ENCODER == RISK_GATE.get("trained_for")) | |
| if not RISK_GATE_ACTIVE: | |
| print(f"⚠ 위험 게이트 비활성: RISK_GATE는 {RISK_GATE.get('trained_for')} 학습분 — 현재 인코더({ENCODER})와 불일치. " | |
| f"교체검증 노트북으로 재학습 후 RISK_GATE를 교체하세요. (게이트 없이도 앱은 기존 GATE로 정상 동작)") | |
| # ============================== 데이터 저장 (측정값·변수만, 발화 원문 제외) ============================== | |
| def _now(): | |
| return time.strftime("%Y-%m-%dT%H:%M:%S") | |
| class CoachDataStore: | |
| """대화별 측정값·변수·원문을 저장. HF Dataset 영구화(설정 시). | |
| 원문 수집은 화면 하단 고지로 알림(별도 동의 체크 없음 — 가상 시나리오 + 고지 기반). | |
| 주의: 일반 공개 시에는 명시적 동의 절차로 강화 필요.""" | |
| def __init__(self, data_dir=DATA_DIR, repo=HF_DATASET_REPO, version=VERSION, encoder=ENCODER): | |
| self.dir = data_dir | |
| os.makedirs(data_dir, exist_ok=True) | |
| self.path = os.path.join(data_dir, "coach_turns.jsonl") | |
| self.version, self.encoder = version, encoder | |
| self.saved = 0 | |
| self.scheduler = None | |
| if repo and os.environ.get("HF_TOKEN"): | |
| try: | |
| from huggingface_hub import CommitScheduler, hf_hub_download | |
| from huggingface_hub.utils import EntryNotFoundError, RepositoryNotFoundError | |
| # ★ 재시작 복구: CommitScheduler 시작 전에 HF에서 기존 파일을 먼저 받아 로컬에 복원. | |
| # (이 단계가 없으면 빈 로컬 폴더가 HF로 동기화돼 기존 데이터를 덮어씀 — 데이터 손실 원인.) | |
| safe_to_sync = True | |
| if not os.path.exists(self.path) or os.path.getsize(self.path) == 0: | |
| try: | |
| cached = hf_hub_download(repo_id=repo, repo_type="dataset", | |
| filename="data/coach_turns.jsonl", | |
| token=os.environ.get("HF_TOKEN")) | |
| import shutil | |
| shutil.copyfile(cached, self.path) | |
| n = sum(1 for _ in open(self.path, encoding="utf-8")) | |
| print(f"HF Dataset 복구: 기존 {n}건 불러옴 → {self.path}") | |
| except (EntryNotFoundError, RepositoryNotFoundError): | |
| # HF에 파일/레포가 아직 없음 = 최초 실행. 빈 채로 시작해도 안전(덮어쓸 데이터 없음). | |
| print("HF Dataset 복구: 기존 파일 없음(최초 실행) — 빈 채로 시작") | |
| except Exception as de: | |
| # 그 외 실패(네트워크 등): HF에 데이터가 있을 수 있음 → 빈 폴더로 덮어쓰기 방지 위해 동기화 끔. | |
| safe_to_sync = False | |
| print(f"⚠️ HF Dataset 복구 실패(네트워크 등): {de}\n" | |
| f" → 기존 데이터 덮어쓰기 방지를 위해 이번 세션 자동 동기화를 끕니다. " | |
| f"로컬에는 계속 저장되며, Space 재시작으로 복구를 재시도하세요.") | |
| if safe_to_sync: | |
| self.scheduler = CommitScheduler(repo_id=repo, repo_type="dataset", | |
| folder_path=data_dir, path_in_repo="data", | |
| every=5, private=True) | |
| print("HF Dataset 동기화 ON:", repo) | |
| else: | |
| print("HF Dataset 동기화 OFF(안전모드) — 로컬 저장만 유지") | |
| except Exception as e: | |
| print("HF Dataset 동기화 OFF:", e) | |
| else: | |
| print("HF Dataset 미설정 — 로컬 저장만(재시작 시 초기화). HF_TOKEN·HF_DATASET_REPO 설정 시 영구화.") | |
| def write(self, rec): | |
| lock = self.scheduler.lock if self.scheduler else nullcontext() | |
| with lock: | |
| with open(self.path, "a", encoding="utf-8") as f: | |
| f.write(json.dumps(rec, ensure_ascii=False) + "\n") | |
| f.flush() | |
| try: | |
| os.fsync(f.fileno()) # 디스크에 즉시 반영(동기화 타이밍 문제 방지) | |
| except Exception: | |
| pass | |
| self.saved += 1 | |
| print(f"[SAVE-DEBUG] write 완료: type={rec.get('type')}, " | |
| f"누적 {self.saved}건, 파일={self.path}") | |
| def log_turn(self, session, m, strength, state, utter_len, kind="user", text=None): | |
| """발화 한 건의 측정값·변수 기록. text 제공 시 원문도 함께 저장(고지 후 수집).""" | |
| rec = { | |
| "type": "coach_turn", "ts": int(time.time()), "datetime": _now(), | |
| "session": session, "app_version": self.version, "encoder": self.encoder, "kind": kind, | |
| "EVA": round(float(m["EVA"]), 4), "EAR": round(float(m["EAR"]), 4), | |
| "VAL": round(float(m["VAL"]), 4), "REI": round(float(m["REI"]), 4), | |
| "CON": round(float(m.get("CON", 0.0)), 4), "FUL": round(float(m.get("FUL", 0.0)), 4), # 신규 축(기록만 — 외로움/공허 실사용 검증용) | |
| "strength": round(float(strength), 4), | |
| "phase": state["phase"], "turn": state["turns"], | |
| "refine_turns": state["refine_turns"], "stall_count": state["stall_count"], | |
| "max_strength": round(float(state["max_strength"]), 4), | |
| "n_candidates": len(state["candidates"]), | |
| "irony_flag": state.get("irony_flag", "없음"), # 반어 의심 레벨(기록만) | |
| "risk": (state.get("risks") or [-1.0])[-1], # 위험 게이트 오답 위험도(0~1, -1=비활성) — 실사용 재검증 재료 | |
| "emotions": state.get("emotions"), # 사용자가 고른 감정 주제들(EMOTIONS 키 리스트) — 감정별 분석용 | |
| # ▼ 0.9.9: 모드·시나리오 컨텍스트(보류 과제 해소 — 시나리오/모드별 분석 가능해짐) | |
| "mode": state.get("mode"), "scenario_rel": state.get("scenario_rel"), | |
| "story_id": state.get("story_id"), # 이야기 모드: 어느 이야기인가 | |
| "story_emo": state.get("story_emo_label"), # 이야기 모드: 고른 마음(라벨) | |
| "story_rung": state.get("story_rung"), # 이야기 모드: 고른 세기(0=순하게,1=솔직하게,2=직설) | |
| "utter_len": int(utter_len), | |
| "text": text, # 발화 원문(고지 후 수집). None이면 미저장. | |
| } | |
| self.write(rec) | |
| def log_selection(self, session, candidates, chosen_idx, emotions=None, mode=None, scenario_rel=None): | |
| """길2 완성 선택 기록 — 최종 후보 리스트와 사용자 선택. 원문 포함(고지 후 수집). | |
| '사용자가 어떤 측정 특성/표현을 선택하는가'를 분석하기 위한 데이터.""" | |
| print(f"[SAVE-DEBUG] log_selection 호출됨: session={session}, " | |
| f"후보수={len(candidates)}, chosen_idx={chosen_idx}") | |
| cand_rows = [] | |
| for i, c in enumerate(candidates): | |
| cand_rows.append({ | |
| "idx": i, | |
| "EVA": c["EVA"], "EAR": c["EAR"], "VAL": c["VAL"], "REI": c["REI"], | |
| "strength": c["strength"], "turn": c.get("turn"), | |
| "len": len(c["text"]), | |
| "text": c["text"], # 후보 표현 원문(고지 후 수집) | |
| "chosen": (i == chosen_idx), | |
| }) | |
| chosen = candidates[chosen_idx] if 0 <= chosen_idx < len(candidates) else None | |
| rec = { | |
| "type": "coach_selection", "ts": int(time.time()), "datetime": _now(), | |
| "session": session, "app_version": self.version, "encoder": self.encoder, | |
| "n_candidates": len(candidates), | |
| "chosen_idx": chosen_idx, | |
| # 선택된 표현의 측정값(값만) | |
| "chosen_EVA": chosen["EVA"] if chosen else None, | |
| "chosen_EAR": chosen["EAR"] if chosen else None, | |
| "chosen_VAL": chosen["VAL"] if chosen else None, | |
| "chosen_REI": chosen["REI"] if chosen else None, | |
| "chosen_strength": chosen["strength"] if chosen else None, | |
| "chosen_turn": chosen.get("turn") if chosen else None, | |
| "chosen_len": len(chosen["text"]) if chosen else None, | |
| "chosen_text": chosen["text"] if chosen else None, # 선택된 표현 원문(고지 후 수집) | |
| # 후보 리스트 전체(값만 — 선택 안 된 것과 비교용) | |
| "candidates": cand_rows, | |
| # 선택된 게 신호강도 최고였나?(측정과 사람 선택의 일치 여부 — 핵심 분석 지표) | |
| "chose_max_strength": (chosen_idx == max(range(len(candidates)), | |
| key=lambda i: candidates[i]["strength"])) if candidates else None, | |
| "emotions": emotions, # 사용자가 고른 감정 주제들(EMOTIONS 키 리스트) — 감정별 선택 분석용 | |
| "mode": mode, "scenario_rel": scenario_rel, # 0.9.9: 선택 레코드에도 모드 컨텍스트 | |
| } | |
| self.write(rec) | |
| def log_story_event(self, session, event, state, extra=None): | |
| """이야기 모드 이벤트(start/retry/done 등) — 흐름 분석·이탈 지점 파악용.""" | |
| rec = { | |
| "type": "story_event", "ts": int(time.time()), "datetime": _now(), | |
| "session": session, "app_version": self.version, | |
| "event": event, | |
| "story_id": state.get("story_id"), | |
| "story_emo": state.get("story_emo_label"), | |
| "story_rung": state.get("story_rung"), | |
| } | |
| if extra: | |
| rec.update(extra) | |
| self.write(rec) | |
| def log_feedback(self, session, score, comment, state): | |
| """완성(표현 선택) 후 후기. score=1~5 도움 정도(None=건너뜀), comment=주관식(선택). | |
| 효과 신호 — 성장 대조용으로 첫 신호강도/선택 강도/모드/감정을 함께 저장.""" | |
| chosen = state.get("chosen_meta") or {} | |
| rec = { | |
| "type": "coach_feedback", "ts": int(time.time()), "datetime": _now(), | |
| "session": session, "app_version": self.version, | |
| "helpfulness": score, # 1~5 또는 None(건너뜀) | |
| "comment": ((comment or "").strip() or None), | |
| "mode": state.get("mode"), "scenario_rel": state.get("scenario_rel"), | |
| "story_id": state.get("story_id"), # 0.9.9: 이야기 모드면 어느 이야기의 후기인지 | |
| "emotions": state.get("emotions"), | |
| "refine_turns": state.get("refine_turns"), | |
| "first_strength": (round(float(state["first_strength"]), 4) | |
| if state.get("first_strength") is not None else None), | |
| "chosen_strength": (round(float(chosen["strength"]), 4) | |
| if isinstance(chosen, dict) and chosen.get("strength") is not None else None), | |
| } | |
| self.write(rec) | |
| STORE = CoachDataStore() | |
| import uuid as _uuid | |
| # ============================== 측정 → 자연어 요약 (좌표를 LLM에 압축 전달) ============================== | |
| def describe_measure(m, risk=None): | |
| """측정 좌표를 LLM이 읽을 짧은 자연어로. EVA는 강도 신뢰, 나머지는 방향 위주. | |
| risk가 높으면 '측정 주의'를 덧붙여 코치(LLM)가 감정을 단정하지 않게 한다.""" | |
| eva, ear = m["EVA"], m["EAR"] | |
| strength = signal_strength(m) | |
| # EVA(감정가) — 강도까지 신뢰 | |
| if eva > 0.6: emo = "분명히 긍정적" | |
| elif eva > 0.15: emo = "약간 긍정적" | |
| elif eva < -0.6: emo = "분명히 부정적(가라앉음)" | |
| elif eva < -0.15: emo = "약간 부정적" | |
| else: emo = "중립적이거나 섞임" | |
| # 신호 강도 — 막막/또렷 | |
| if strength < GATE: clarity = "감정이 흐릿하거나 억압된 듯(신호 약함)" | |
| elif strength < CLEAR_THRESHOLD: clarity = "감정이 어느 정도 드러남" | |
| else: clarity = "감정이 또렷하게 표현됨" | |
| desc = f"감정가: {emo} / 표현 또렷함: {clarity} (신호강도 {strength:.2f})" | |
| if risk is not None and risk >= RISK_GATE["risk_threshold_10pct"]: | |
| desc += (" [측정 주의: 이 발화는 좌표 신뢰도가 낮습니다(감정이 상쇄·반전됐을 수 있음). " | |
| "감정 방향을 단정하지 말고, 부드럽게 조금 더 물어봐 주세요.]") | |
| # VAL/REI 방향 성향 (실험적, 참고용) — 뚜렷할 때만, 강도 없이 방향만 | |
| val_dir, rei_dir = tendency_of(m) | |
| hints = [] | |
| if val_dir == "자율": | |
| hints.append("스스로 정하려는 결(자율) 쪽") | |
| elif val_dir == "순응": | |
| hints.append("주위에 맞추려는 결(순응) 쪽") | |
| if rei_dir == "분석": | |
| hints.append("따져 생각하는 결(분석) 쪽") | |
| elif rei_dir == "직관": | |
| hints.append("느낌으로 받아들이는 결(직관) 쪽") | |
| if hints: | |
| desc += f" / 말하는 결(참고만, 약하게): {', '.join(hints)}" | |
| return desc | |
| # ============================== 코칭 프롬프트 (단계별) ============================== | |
| SITUATION = ("사용자는 '서운함·속상함·화남 같은 부정적인 마음을 누군가에게 전하고 싶은' 상황입니다. " | |
| "상대는 연인일 수도, 가족·친구·동료일 수도 있습니다. 어떤 일이 있었고 그 일로 마음이 상했는데, " | |
| "'이런 마음을 표현해도 되나, 어떻게 말해야 하나' 망설입니다. 고마움이나 미안함 같은 다른 감정이 " | |
| "함께 섞여 있을 수도 있습니다. 구체적인 상황은 사용자가 직접 이야기합니다 — 미리 단정하지 마세요. " | |
| "사용자의 성별·나이·관계는 정해져 있지 않습니다.") | |
| # ── 감정 주제 8가지 (터치 선택) ────────────────────────────────────────── | |
| # 실사용 피드백 반영: 감정 직접형(외로움/공허/화남/서운 — 여성 피드백 좋음) + | |
| # 관계·인정형(인정/관계회복/미안/복잡 — 인정·관계 프레임). 성별 라벨 없이 사용자가 끌리는 걸 선택. | |
| # 각 감정: label(버튼), note(코치용 상황 설명), tone(코치 접근 톤 힌트). | |
| # 약한 감정(외로움/공허/관계회복)은 '세기 약하니 차근차근, 변화율로'(사용자 지침) 톤 반영. | |
| EMOTIONS = { | |
| "lonely": { | |
| "label": "🌙 외로움 · 혼자인 것 같아 / 쓸쓸해 / 허전해", | |
| "note": "사용자는 외로움을 느끼고 있습니다. 누군가에게 그 마음을 전하고 싶거나, 혼자라는 느낌을 털어놓고 싶어 합니다.", | |
| "tone": "외로움은 격하게 터지기보다 가만히 가라앉는 감정입니다. 다그치지 말고, 그 쓸쓸함을 천천히 말로 옮기도록 차분히 곁을 지켜주세요.", | |
| }, | |
| "empty": { | |
| "label": "🫥 공허함 · 마음이 텅 빈 / 의욕이 없어 / 다 부질없어", | |
| "note": "사용자는 공허함·허전함을 느끼고 있습니다. 무엇이 비어 있는지, 그 마음을 어떻게 표현할지 함께 찾고 싶어 합니다.", | |
| "tone": "공허함은 또렷한 사건보다 막연한 느낌일 수 있습니다. 억지로 이유를 캐묻지 말고, 어렴풋한 마음을 조금씩 말로 만들어가게 도와주세요.", | |
| }, | |
| "angry": { | |
| "label": "🔥 화남 · 욱했어 / 따지고 싶어 / 못 참겠어", | |
| "note": "사용자는 화가 난 상태입니다. 그 화를 누군가에게 어떻게 전할지, 어떻게 표현해야 할지 막막해합니다.", | |
| "tone": "화는 강하게 올라오는 감정입니다. 화를 누르라고 하지 말고, 그 화 밑에 있는 진짜 바람(무엇을 원했는지)이 드러나도록 비춰주세요.", | |
| }, | |
| "hurt": { | |
| "label": "💧 서운함 · 서운해 / 몰라줘 / 기대했는데", | |
| "note": "사용자는 누군가에게 서운하고 속상한 마음입니다. 그 마음을 그 사람에게 전하고 싶어 합니다.", | |
| "tone": "서운함은 기대가 어긋난 자리에서 옵니다. '이런 마음을 가져도 되나' 망설임을 풀어주고, 자기 감정을 그대로 인정하게 도와주세요.", | |
| }, | |
| "recognized": { | |
| "label": "🏅 인정받고 싶음 · 알아줬으면 / 노력했는데 / 속상해", | |
| "note": "사용자는 자신의 노력·마음을 누군가가 알아주길 바랍니다. 인정받고 싶은 마음을 어떻게 전할지 고민합니다.", | |
| "tone": "인정 욕구는 약점이 아니라 자연스러운 바람입니다. '알아달라'고 말하는 게 부끄럽지 않도록, 그 바람을 떳떳이 표현하게 도와주세요. 감정을 캐묻기보다 '무엇을 알아줬으면 하는지'에 초점을 맞추세요.", | |
| }, | |
| "reconnect": { | |
| "label": "🤝 관계 회복 · 멀어졌어 / 다가가고 싶어 / 예전처럼", | |
| "note": "사용자는 멀어졌거나 소원해진 사람과 다시 가까워지고 싶어 합니다. 그 마음을 어떻게 전할지 찾고 있습니다.", | |
| "tone": "관계를 잇고 싶은 마음은 조심스럽습니다. 서두르지 말고, '다시 다가가고 싶다'는 마음을 부담 없이 한 걸음씩 말로 옮기게 도와주세요. 상대를 분석하기보다 자기 바람에 집중하게 하세요.", | |
| }, | |
| "sorry": { | |
| "label": "🙏 미안함 · 내가 잘못했어 / 사과하고 싶어 / 후회돼", | |
| "note": "사용자는 누군가에게 미안한 마음이 있습니다. 그 사과를 어떻게 진심으로 전할지 고민합니다.", | |
| "tone": "사과는 변명이 섞이면 흐려집니다. 자기 행동을 인정하는 마음이 또렷한 말이 되도록, 핑계보다 진심에 머물게 도와주세요.", | |
| }, | |
| } | |
| # 여러 감정을 함께 고른 경우(복합) 코치 톤 — 단일 감정 'mixed' 대신 다중선택으로 대체됨 | |
| COMBINED_TONE = ("섞인 감정은 하나로 누르지 말고, 여러 마음이 함께 있어도 괜찮다고 알려주세요. " | |
| "그중 지금 가장 전하고 싶은 마음이 무엇인지 천천히 가려내게 도와주세요.") | |
| EMOTION_ORDER = ["lonely", "empty", "angry", "hurt", "recognized", "reconnect", "sorry"] | |
| COACH_RULES = ("당신은 '표현 코칭'을 돕는 따뜻한 대화 상대입니다. 핵심 원칙(반드시 지킬 것):\n" | |
| "1) 절대 '이렇게 말해'라고 정답 문장을 주지 마세요. 당신은 거울과 산파일 뿐, 사용자가 스스로 자기 말을 찾게 돕습니다.\n" | |
| "2) 사용자가 직접 쓴 표현을 존중하고, 더 정확한지 '본인이' 판단하게 하세요.\n" | |
| "3) 감정을 지적·진단하지 말고, 부드럽게 비춰주고 물어보세요.\n" | |
| "4) 한국어로 2~3문장, 따뜻하고 담백하게. 과한 느낌표·이모지 자제.\n" | |
| "5) 표현이 서툰 것을 탓하지 말고, 표현하려는 시도 자체를 인정하세요.\n" | |
| "6) 사용자에게 '상대방이 어떻게 반응할지/생각할지 예상해보라'고 묻지 마세요. 이 앱은 '자기 표현'을 돕는 곳이지 상대를 분석하는 곳이 아닙니다. " | |
| "상대 입장은 필요할 때 '당신(코치)이 긍정적으로 비춰주는' 것이지, 사용자에게 예측을 시키지 않습니다.") | |
| # ============================== 나침반(감정 평면 + 궤적) ============================== | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| # 각 축 표시 스케일(95분위 |값|, 241발화 근거). 값을 이걸로 나눠 ±1 기준 정규화. | |
| # → EVA·EAR이 화면 가장자리까지 크게 움직여 다이나믹하게 보인다. 배포 데이터로 재보정 가능. | |
| COMPASS_SCALE = {"EVA": 0.73, "EAR": 0.63} | |
| def _compass_auto_lim(coords, sx, sy, pad=1.12, step=0.25, floor=1.0, cap=2.5): | |
| """정규화 좌표의 최대 절대값에 여백(pad)을 주고 step 단위로 올림해 화면 크기 결정. | |
| → 강한 감정은 넘침 없이 다 담고, 약한 감정도 화면을 적절히 채운다. | |
| floor: 최소 화면(약한 감정이 너무 확대되지 않게). cap: 최대(이상치 방어).""" | |
| import math | |
| if not coords: | |
| return floor | |
| mx = 0.0 | |
| for c in coords: | |
| mx = max(mx, abs(c[0]/sx), abs(c[1]/sy)) | |
| raw = mx * pad | |
| rounded = math.ceil(raw/step)*step if raw > 0 else floor | |
| return max(floor, min(cap, rounded)) | |
| def render_compass(coords, best_idx=None): | |
| """EVA(x: 부정↔긍정) × EAR(y: 차분↔격앙) 평면에 발화 궤적을 그린다. | |
| 각 축을 자기 분포로 정규화(EVA/0.73, EAR/0.63)하고, 화면 크기(lim)는 | |
| 데이터 최대값에 맞춰 자동 조절(_compass_auto_lim)해 넘침을 막고 화면을 채운다. | |
| coords: [(eva, ear, strength), ...] 원본 측정값. best_idx: best 표현 순번(별표). | |
| 한글 폰트가 없는 환경(HF Space) 대비, 차트 내 텍스트는 영어/기호만 사용.""" | |
| fig, ax = plt.subplots(figsize=(4.2, 4.2), dpi=100) | |
| sx, sy = COMPASS_SCALE["EVA"], COMPASS_SCALE["EAR"] | |
| lim = _compass_auto_lim(coords, sx, sy) # 자동 스케일: 데이터 최대값에 맞춰 화면 크기 결정(넘침 방지) | |
| # 사분면 배경/안내(영어로 — 폰트 안전) | |
| ax.axhline(0, color="#bbb", lw=0.8, zorder=1) | |
| ax.axvline(0, color="#bbb", lw=0.8, zorder=1) | |
| ax.fill_between([-lim, 0], 0, lim, color="#ffe5e5", alpha=0.5, zorder=0) # 좌상: 부정+격앙(분노) | |
| ax.fill_between([0, lim], 0, lim, color="#fff4e0", alpha=0.5, zorder=0) # 우상: 긍정+격앙(흥분) | |
| ax.fill_between([-lim, 0], -lim, 0, color="#e8eef7", alpha=0.5, zorder=0) # 좌하: 부정+차분(가라앉음) | |
| ax.fill_between([0, lim], -lim, 0, color="#e6f5ea", alpha=0.5, zorder=0) # 우하: 긍정+차분(평온) | |
| ax.text(-lim*0.96, lim*0.88, "ANGRY", fontsize=8, color="#c0392b", alpha=0.7, weight="bold") | |
| ax.text(lim*0.45, lim*0.88, "EXCITED", fontsize=8, color="#e67e22", alpha=0.7, weight="bold") | |
| ax.text(-lim*0.96, -lim*0.93, "DOWN", fontsize=8, color="#5a7", alpha=0.7, weight="bold") | |
| ax.text(lim*0.42, -lim*0.93, "CALM", fontsize=8, color="#27ae60", alpha=0.7, weight="bold") | |
| if coords: | |
| # 정규화: 각 축을 자기 스케일로 나눠 ±1 기준. clip은 lim으로. | |
| xs = [max(-lim, min(lim, c[0] / sx)) for c in coords] | |
| ys = [max(-lim, min(lim, c[1] / sy)) for c in coords] | |
| n = len(coords) | |
| # ── B-2: 추세선(EMA 평활) — 측정 원본은 점으로 정직하게, 흐름은 부드러운 선으로 ── | |
| # 긍정 흐름 중 "미안/짜증" 한 발화로 튀어도, 추세선은 이전 긍정을 반영해 완만하게 보상. | |
| if n >= 2: | |
| alpha_ema = 0.45 # 평활 강도(작을수록 더 부드러움 = 과거 더 반영) | |
| ex, ey = xs[0], ys[0] | |
| tx, ty = [ex], [ey] | |
| for i in range(1, n): | |
| ex = alpha_ema * xs[i] + (1 - alpha_ema) * ex | |
| ey = alpha_ema * ys[i] + (1 - alpha_ema) * ey | |
| tx.append(ex); ty.append(ey) | |
| ax.plot(tx, ty, "-", color="#3498db", lw=2.4, alpha=0.55, zorder=2, | |
| solid_capstyle="round", label="흐름(추세)") | |
| # 원본 점은 옅은 점선으로 가볍게 연결(순서 참고용) | |
| ax.plot(xs, ys, ":", color="#aaa", lw=0.9, alpha=0.5, zorder=2) | |
| # 원본 점들(측정 그대로 — 정확한 위치) | |
| for i, (x, y) in enumerate(zip(xs, ys)): | |
| is_last = (i == n - 1) | |
| is_best = (best_idx is not None and i == best_idx) | |
| if is_best: | |
| ax.scatter([x], [y], s=260, marker="*", color="#f1c40f", | |
| edgecolors="#b8860b", linewidths=1.2, zorder=5) | |
| alpha = 0.35 + 0.65 * (i + 1) / n | |
| color = "#2c3e50" if is_last else "#5d6d7e" | |
| sz = 130 if is_last else 70 | |
| ax.scatter([x], [y], s=sz, color=color, alpha=alpha, zorder=4, | |
| edgecolors="white", linewidths=1.0) | |
| ax.annotate(str(i + 1), (x, y), fontsize=7, color="white", | |
| ha="center", va="center", zorder=6, weight="bold") | |
| # 추세선 끝(현재 마음의 '흐름상' 위치) 표시 | |
| if n >= 2: | |
| ax.scatter([tx[-1]], [ty[-1]], s=90, marker="D", color="#3498db", | |
| alpha=0.7, zorder=5, edgecolors="white", linewidths=1.0) | |
| ax.set_xlim(-lim, lim); ax.set_ylim(-lim, lim) | |
| ax.set_xlabel("← negative valence positive →", fontsize=8) | |
| ax.set_ylabel("← calm arousal excited →", fontsize=8) | |
| ax.set_xticks([]); ax.set_yticks([]) | |
| ax.set_title("Emotion Compass", fontsize=10, weight="bold") | |
| # 범례(영어 — 폰트 안전): 점=각 발화, 파란선=흐름(추세), 별=가장 또렷한 표현 | |
| from matplotlib.lines import Line2D | |
| legend_items = [ | |
| Line2D([0], [0], marker="o", color="w", markerfacecolor="#2c3e50", markersize=7, label="each msg"), | |
| Line2D([0], [0], color="#3498db", lw=2.4, alpha=0.7, label="trend (smoothed)"), | |
| ] | |
| if best_idx is not None: | |
| legend_items.append(Line2D([0], [0], marker="*", color="w", markerfacecolor="#f1c40f", | |
| markeredgecolor="#b8860b", markersize=12, label="clearest")) | |
| ax.legend(handles=legend_items, loc="upper right", fontsize=6.5, framealpha=0.85) | |
| for spine in ax.spines.values(): | |
| spine.set_edgecolor("#ddd") | |
| fig.tight_layout() | |
| return fig | |
| def compass_caption(coords, best_idx=None): | |
| """나침반 아래에 붙일 한글 설명(차트 밖이라 폰트 안전).""" | |
| if not coords: | |
| return "아직 좌표가 없어요. 마음을 적으면 나침반에 표시됩니다." | |
| eva, ear, _ = coords[-1] | |
| # 현재 위치 설명 | |
| val_txt = "긍정적" if eva > 0.3 else ("부정적" if eva < -0.3 else "중립") | |
| ar_txt = "격앙됨" if ear > 0.3 else ("차분함" if ear < -0.3 else "보통") | |
| cur = f"지금: 감정가 {val_txt}, 각성 {ar_txt}" | |
| # 이동 설명(처음 대비) | |
| move = "" | |
| if len(coords) >= 2: | |
| d_eva = eva - coords[0][0]; d_ear = ear - coords[0][1] | |
| parts = [] | |
| if d_eva > 0.4: parts.append("덜 부정적으로") | |
| elif d_eva < -0.4: parts.append("더 부정적으로") | |
| if d_ear < -0.4: parts.append("더 차분하게") | |
| elif d_ear > 0.4: parts.append("더 격해지게") | |
| if parts: | |
| move = " · 처음보다 " + ", ".join(parts) + " 이동했어요" | |
| star = "" | |
| if best_idx is not None: | |
| star = f" · ⭐는 가장 또렷한 표현({best_idx+1}번)" | |
| return cur + move + star | |
| def debug_line(m, strength, state): | |
| """임계값 조정용 실측 표시(DEBUG=True일 때만). 측정값과 상태를 한 줄로.""" | |
| if not DEBUG: | |
| return "" | |
| n_cand = len(state.get("candidates", [])) | |
| irony = state.get("irony_flag", "없음") | |
| irony_txt = f" | 반어신호={irony}" if irony in ("강", "급반전") else "" | |
| return (f"<sub>🔧 EVA={m['EVA']:.2f} EAR={m['EAR']:.2f} VAL={m['VAL']:.2f} REI={m['REI']:.2f} " | |
| f"신호={strength:.2f} | phase={state['phase']} refine턴={state['refine_turns']} " | |
| f"max신호={state['max_strength']:.2f} stall={state['stall_count']} 후보={n_cand}개{irony_txt}</sub>") | |
| def build_coach_prompt(phase, measure_desc, situation_note="", best=None, hard5=False, tendency=None, emotions=None): | |
| # 사용자가 고른 감정 주제(들)가 있으면 그 맥락·톤을 상황에 반영(감정별 코칭 분기) | |
| valid = [e for e in (emotions or []) if e in EMOTIONS] | |
| if len(valid) == 1: | |
| em = EMOTIONS[valid[0]] | |
| situation_block = f"{em['note']}\n[이 감정을 대할 때] {em['tone']}" | |
| elif len(valid) >= 2: | |
| # 복합: 고른 감정들의 note를 모으고, 복합 전용 톤을 붙임 | |
| names = " · ".join(EMOTIONS[e]["label"].split("·")[0].strip() for e in valid) | |
| notes = " 그리고 ".join(EMOTIONS[e]["note"] for e in valid) | |
| situation_block = (f"사용자는 여러 감정({names})이 섞여 있다고 느낍니다. {notes}\n" | |
| f"[이 감정을 대할 때] {COMBINED_TONE}") | |
| else: | |
| situation_block = SITUATION | |
| base = f"{COACH_RULES}\n\n[상황]\n{situation_block}\n" | |
| if best: | |
| base += f"\n[사용자가 지금까지 도달한 가장 또렷한 표현]\n\"{best}\"\n" | |
| if measure_desc: | |
| base += f"\n[방금 사용자 발화의 측정 결과 — 은근히 참고하되 숫자나 진단은 직접 언급 금지]\n{measure_desc}\n" | |
| # 성향 기반 코칭 방향 (실험적, 보조) — 방향만, 강도 없음. 강압 아닌 미세 조정. | |
| if tendency: | |
| val_dir, rei_dir = tendency | |
| guide = [] | |
| if val_dir == "순응": | |
| guide.append("이 사람은 주위에 맞추려는 결이 보입니다. 자기 마음을 눌러 표현을 망설일 수 있으니, '당신이 느끼는 것 자체가 소중하다'는 쪽으로 자기 표현을 살며시 북돋아 주세요.") | |
| elif val_dir == "자율": | |
| guide.append("이 사람은 스스로 정하려는 결이 보입니다. 이미 자기 마음을 꺼내고 있으니, 방향을 바꾸려 들지 말고 그 표현을 존중하며 비춰만 주세요.") | |
| if rei_dir == "분석": | |
| guide.append("이 사람은 따져 생각하는 결이 보입니다. 감정을 논리로 정리해 버리지 않도록, 느끼는 것 자체에 잠시 머물게 도와주세요.") | |
| elif rei_dir == "직관": | |
| guide.append("이 사람은 느낌으로 받아들이는 결이 보입니다. 막연한 느낌을 한 걸음 더 구체적인 말로 옮겨보도록 부드럽게 도와주세요.") | |
| if guide: | |
| base += ("\n[말하는 결에 맞춘 보조 지침 — 약하게 반영, 측정 진단은 절대 입 밖에 내지 말 것]\n" | |
| + " ".join(guide) + "\n") | |
| if phase == "scaffold": | |
| base += ("\n[지금 단계: 비계]\n사용자의 감정이 아직 흐릿합니다(억압·막막). " | |
| "감정을 '대신 규정'하지 말고, 방향을 좁히도록 도와주세요. 단 선택지(①②③)는 시스템이 따로 제시하므로, " | |
| "당신은 사용자가 자기 마음을 살짝 더 들여다보도록 공감하며 한 걸음만 이끄세요.") | |
| elif phase == "graduate": | |
| base += ("\n[지금 단계: 졸업 전환]\n사용자의 감정이 또렷해졌습니다. " | |
| "'고마움과 서운함은 함께 있어도 괜찮다'는 점을 비춰 자기검열을 풀어주고, " | |
| "이제 그 마음을 '직접 한 문장으로 써보도록' 부드럽게 권하세요.") | |
| elif phase == "refine": | |
| base += ("\n[지금 단계: 자기 문장 다듬기]\n사용자가 자기 표현을 썼습니다. " | |
| "그 문장에 어떤 마음이 담겼는지 측정 결과로 '비춰주고'(고쳐주지 말 것), " | |
| "처음보다 또렷해졌다면 그 변화를 짚어주세요.\n" | |
| "[중요 — 반복 금지] 매번 똑같은 질문('그 사람이 어떻게 반응할까요/이해하길 바라나요')을 되풀이하지 마세요. " | |
| "특히 '상대가 어떻게 반응할지 예상해보라'는 식의 질문은 하지 마세요 — 그건 상대를 분석하게 만들어 자기검열을 부추깁니다. " | |
| "대신 사용자 '자신의 표현'에 집중하게 하세요. 표현이 이미 또렷하면 더 캐묻지 말고, " | |
| "사용자가 한두 번 더 다듬으면 그것으로 충분합니다. 간결하게 1~2문장으로.") | |
| elif phase == "confirm": | |
| if hard5: | |
| base += ("\n[지금 단계: 완성 확인 — 충분히 다듬음]\n사용자가 여러 번 표현을 다듬었습니다. 위 '가장 또렷한 표현'을 그대로 인용해 따뜻하게 인정한 뒤, " | |
| "'마음을 충분히 잘 정리하셨어요. 이대로 마무리할까요, 아니면 조금 더 다듬어볼까요?'라고 부드럽게 물으세요. " | |
| "절대 하지 말 것: 상대 반응 예상 질문, 같은 질문 반복. 2문장 이내.") | |
| else: | |
| base += ("\n[지금 단계: 완성 확인 — 딱 한 번만]\n사용자의 표현이 충분히 또렷해졌습니다. 위 '가장 또렷한 표현'을 그대로 인용해 따뜻하게 인정한 뒤, " | |
| "'이 표현이면 마음이 잘 담긴 것 같아요. 이대로 정리할까요?'라고 딱 한 번만 부드럽게 확인하세요. " | |
| "절대 하지 말 것: 상대 반응 예상 질문, '더 담아볼까요/덧붙일까요' 같은 추가 다듬기 유도, 같은 질문 반복. 2문장 이내.") | |
| elif phase == "complete": | |
| base += ("\n[지금 단계: 완성·마무리 — 질문 없이 끝맺기]\n사용자가 표현을 확정했습니다. 위 '가장 또렷한 표현'을 인용하며 다음을 담아 따뜻하게 맺으세요(2~3문장):\n" | |
| "1) 그 표현을 긍정적으로 재해석 — 서운함/요구가 아니라 '너와 더 가까이 있고 싶은 소중한 마음'임을 비춰주세요.\n" | |
| "2) 그 말이 상대에게 '비난'이 아니라 '사랑의 표현'으로 가닿을 수 있음을 당신(코치)이 직접 안심시켜 자기검열을 풀어주세요.\n" | |
| "3) 준비됐을 때 솔직하게 전해보도록 격려하고 끝맺으세요.\n" | |
| "★절대 금지: 어떤 질문도 하지 마세요(특히 '상대가 어떻게 반응할까요' 류). 이건 마지막 응답이니 질문 없이 따뜻하게 마침표를 찍으세요.") | |
| return base | |
| # ============================== Gemini 호출 ============================== | |
| def _msg_text(c): | |
| if isinstance(c, str): return c | |
| if isinstance(c, list): | |
| return " ".join(_msg_text(x) for x in c) | |
| if isinstance(c, dict): | |
| return c.get("text", "") or c.get("content", "") or "" | |
| return str(c) if c is not None else "" | |
| def make_hint(candidates, emotions=None): | |
| """완성 시점 힌트 1개 생성 — 사용자 발화 후보 중 '마음을 가장 잘 담은 것'을 골라 | |
| 상대에게 전할 수 있게 보수적으로 다듬는다(재구성만, 내용 추가·조언·해석 금지). | |
| 측정이 표현 질을 못 보므로(KoSimCSE 한계) 이 판단·다듬기는 LLM이 맡음. | |
| 실패 시 None 반환(힌트 없이 진행).""" | |
| texts = [c["text"] for c in (candidates or []) if c.get("text")] | |
| if not texts: | |
| return None | |
| joined = "\n".join(f"- {t}" for t in texts) | |
| sysp = ( | |
| "너는 사용자가 마음 속 이야기를 상대에게 전할 수 있게 돕는 도우미다.\n" | |
| "아래는 사용자가 대화 중 한 말들이다. 이 중에서 사용자의 진짜 마음이 가장 잘 담긴 말을 하나 고르고, " | |
| "그 말을 상대에게 그대로 전할 수 있는 자연스러운 한 문장으로 다듬어라.\n\n" | |
| "[반드시 지킬 것]\n" | |
| "1. 사용자가 하지 않은 말(새로운 내용·이유·상황)을 절대 추가하지 마라.\n" | |
| "2. 조언·해결책·평가·훈수를 넣지 마라. 사용자의 마음을 대신 전하는 것일 뿐이다.\n" | |
| "3. 사용자가 '나는 어떻게 해야 하나' 같이 스스로에게 던진 질문이나 도우미에게 한 질문은 고르지 마라. " | |
| "상대에게 전할 '마음'을 골라라.\n" | |
| "4. 1인칭으로(내가 상대에게 말하듯), 한 문장으로, 담담하게.\n" | |
| "5. 설명 없이 다듬은 문장만 출력해라. 따옴표도 붙이지 마라." | |
| ) | |
| user_msg = f"사용자가 한 말들:\n{joined}\n\n이 중 마음이 가장 잘 담긴 것을 골라 상대에게 전할 한 문장으로 다듬어줘." | |
| try: | |
| out = gemini(sysp, [], user_msg) | |
| except Exception: | |
| return None | |
| if not out or out.startswith("["): # 키 미설정 등 | |
| return None | |
| hint = out.strip().strip('"').strip("'").split("\n")[0].strip() | |
| # 안전장치: 너무 길거나(다듬기 실패) 비면 버림 | |
| if not hint or len(hint) > 120: | |
| return None | |
| # 후보 원문과 완전히 같으면 힌트로서 의미 없음 → 그래도 표시(다듬을 게 없던 것일 수) | |
| return hint | |
| def gemini(sys_prompt, history, user_msg): | |
| key = os.environ.get("GEMINI_API_KEY", "").strip() | |
| if not key: | |
| try: | |
| from google.colab import userdata | |
| key = (userdata.get("GEMINI_API_KEY") or "").strip() | |
| except Exception: | |
| pass | |
| if not key: | |
| return "[GEMINI_API_KEY 미설정 — 환경변수/Colab Secrets/Space Secret에 추가하세요]" | |
| import requests | |
| models = ["gemini-2.5-flash", "gemini-2.5-flash-lite", "gemini-flash-latest"] | |
| # 최근 3턴(6메시지)만 — 비용 절제(입력 누적 방지). 좌표는 시스템 프롬프트에 이미 압축. | |
| hist = (history or [])[-6:] | |
| while hist and hist[0].get("role") == "assistant": | |
| hist = hist[1:] | |
| contents = [] | |
| for m in hist: | |
| role = "model" if m.get("role") == "assistant" else "user" | |
| c = _msg_text(m.get("content")).strip() | |
| if c: contents.append({"role": role, "parts": [{"text": c}]}) | |
| contents.append({"role": "user", "parts": [{"text": user_msg}]}) | |
| payload = {"system_instruction": {"parts": [{"text": sys_prompt}]}, | |
| "contents": contents, | |
| "generationConfig": {"thinkingConfig": {"thinkingBudget": 0}, | |
| "maxOutputTokens": 400, "temperature": 0.85}} | |
| for mdl in models: | |
| url = f"https://generativelanguage.googleapis.com/v1beta/models/{mdl}:generateContent?key={key}" | |
| try: | |
| data = requests.post(url, json=payload, timeout=30).json() | |
| except Exception as e: | |
| continue | |
| if "error" in data: | |
| continue | |
| cands = data.get("candidates") | |
| if not cands: continue | |
| parts = cands[0].get("content", {}).get("parts", []) | |
| text = "".join(p.get("text", "") for p in parts).strip() | |
| if text: return text | |
| return "[응답 생성 실패 — 잠시 후 다시 시도해주세요]" | |
| # ============================== 대화 상태 + 흐름 로직 ============================== | |
| def new_state(): | |
| return {"phase": "start", # start → scaffold → refine → confirm → complete | |
| "first_strength": None, # 처음 적은 글의 신호강도(성장 비교용) | |
| "first_text": None, | |
| "candidates": [], # 길2: refine 표현 후보 [{text, EVA, EAR, VAL, REI, strength, turn}]. UI는 text, 저장은 값. | |
| "chosen_text": None, # 사용자가 최종 선택한 표현(텍스트 — UI용) | |
| "chosen_meta": None, # 선택된 후보의 측정값(저장용 — 값만) | |
| "refine_turns": 0, # refine 단계에 머문 턴 수 | |
| "stall_count": 0, # 새 표현 없이 맴돈 횟수(무한루프 방지) | |
| "coords": [], # 나침반 궤적: [(eva, ear, strength), ...] 발화별 좌표 | |
| "eva_hist": [], # refine 단계 EVA 이력(조건3: 이전 3턴 평균 비교용) | |
| "eva_all": [], # 모든 발화 EVA 이력(반어 급반전 판정용 — start/confirm 포함, 직전 흐름 정확히) | |
| "irony_flag": "없음", # 반어 의심 플래그(기록만 — 측정값 안 건드림) | |
| "risks": [], # 발화별 오답 위험도(0~1, 게이트 비활성 시 -1) — 실사용 재검증 재료 | |
| "max_strength": 0.0, # 세션 최고 신호강도(완성 조건 판정용 — best '선정'과 무관) | |
| "done_shown": False, # 완성 마무리 1회 표시 여부 | |
| "show_confirm": False, # 완성 선택 UI 표시 여부(@gr.render가 읽음) | |
| "hint": None, # 완성 시점 LLM이 다듬은 힌트(전달용 예시 1개) | |
| "hard5_flag": False, # 완성 멘트 hard5 여부(@gr.render 헤더용) | |
| "emotions": [], # 사용자가 고른 감정 주제들(EMOTIONS 키 리스트) — 다중선택, 코치 프롬프트 분기용 | |
| "emo_pick": [], # 시작 화면에서 아직 '시작하기' 전 임시 선택(토글) 상태 | |
| "topic_chosen": False, # 감정 주제 선택 완료 여부(시작 화면 버튼 표시 제어) | |
| "mode": None, # 진입 모드: None(선택 전) / "감정"(기존) / "시나리오" | |
| "scenario_rel": None, # 시나리오 모드 관계: "연인"/"부부"/"자녀"/"부모" | |
| "teen_unlocked": False, # 사춘기 자녀 섹션 잠금 해제(소프트 게이트 — 실보안 아님) | |
| "scenario_emo": None, # 시나리오 모드 감정(EMOTIONS 키) | |
| "show_feedback": False, # 완성 후 후기 UI 표시 여부 | |
| "feedback_done": False, # 후기 제출/건너뜀 완료 여부(1회만) | |
| "session": None, # 세션 id(저장용, 첫 발화 때 생성) | |
| # ── 0.9.9 이야기 모드 ── | |
| "story_id": None, # 선택된 이야기 id (None=이야기 모드 아님) | |
| "story_step": None, # select→play→emo→rung→write→after→done→card | |
| "story_li": 0, # 대본에서 다음에 보여줄 줄 인덱스 | |
| "story_after_i": 0, # 발화 후 반응 줄 인덱스 | |
| "story_emo": None, # 고른 마음 인덱스 | |
| "story_emo_label": None, # 고른 마음 라벨(저장용) | |
| "story_rung": None, # 고른 세기(0=순하게 1=솔직하게 2=직설) | |
| "story_text": None, # 사용자가 완성한 문장 | |
| "story_convo": [], # 0.9.10 교환 기록 [{"who":"me"/"them","text":...}] | |
| "story_user_turns": 0, # 0.9.10 사용자 발화 수(개입 포함, 상한 _EXCHANGE_MAX) | |
| "story_branch": None, # 0.9.10 온도 분기: "clear"/"blame" | |
| # ── 0.9.9.2 라이트 입구(30초) ── | |
| "lite_step": None, # pick→card→(write)→card / None=라이트 아님 | |
| "lite_choice": None, # 고른 마음 인덱스 | |
| "lite_rated": False, # 평가(1~5) 완료 여부 | |
| "lite_text": None, # (선택) 내 문장으로 남긴 텍스트 | |
| "lite_seen": False, # 입구를 통과/건너뜀 → 본 앱 모드 선택 표시 | |
| "turns": 0} | |
| # ── 완성 판정 파라미터 ── | |
| CLEAR_FOR_DONE = 0.6 # 완성 후보가 되려면 '세션 최고 표현'이 이 이상 또렷해야 | |
| CONVERGE_DELTA = 0.10 # (참고용) | |
| REPEAT_SIM = 0.80 # (참고용) | |
| MIN_REFINE_TURNS = 1 # refine 최소 턴 | |
| STALL_LIMIT = 3 # 새 표현 없이 이만큼 맴돌면 강제 완성 제안 | |
| # ── 완성 4조건 ── (모호한 임계값 추측 대신 명확한 규칙) | |
| COND_MIN_REFINE = 3 # 조건1: refine 최소 턴 | |
| EXTREME_NEG_EVA = -0.55 # 조건2: EVA가 이보다 위면 '극단 부정 해소'됨(이 아래는 아직 격한 분노) | |
| HARD_DONE_REFINE = 5 # 조건4: refine 5턴째엔 무조건 완료 선택지 | |
| def completion_conditions(eva, state): | |
| """완성 제안 4조건 판정. | |
| 조건1: refine 3턴 이상 | |
| 조건2: 극단 부정 해소 (현재 EVA > EXTREME_NEG_EVA) | |
| 조건3: 현재 EVA ≥ 직전 3턴 평균 (감정이 나아지는 추세) | |
| 조건4: refine 5턴째면 무조건 완료 선택지(1·2·3 무시) | |
| 반환: (제안할까, 강제5턴인가, 사유)""" | |
| rt = state["refine_turns"] | |
| # 조건4 — 5턴째 강제 | |
| if rt >= HARD_DONE_REFINE: | |
| return True, True, "5턴_강제선택" | |
| # 조건1 | |
| if rt < COND_MIN_REFINE: | |
| return False, False, "" | |
| # 표현 후보가 하나라도 있어야(다듬은 결과) | |
| if not state["candidates"]: | |
| return False, False, "" | |
| # 조건2 — 극단 부정 해소 | |
| if eva <= EXTREME_NEG_EVA: | |
| return False, False, "조건2미충족(아직 격한 부정)" | |
| # 조건3 — 직전 3턴 평균 이상 | |
| hist = state["eva_hist"] | |
| if len(hist) >= 3: | |
| prev3 = hist[-3:] | |
| if eva < sum(prev3) / 3: | |
| return False, False, "조건3미충족(감정 추세 하락)" | |
| # 1·2·3 모두 충족 | |
| return True, False, "조건123충족" | |
| # ── 길2: 표현 후보 수집 ── (측정으로 best를 '선정'하지 않음 — 명백히 표현이 아닌 것만 제외하고 모두 수집) | |
| # 측정은 어순/구조를 못 보므로(shuffle 검증) best 자동 선정 불가. 대신 사용자가 완성 시 직접 선택. | |
| # 순수 메타 발화(코칭 소감)만 제외 — '좋았/고마워'가 표현 내용에 들어간 경우는 후보로 살림. | |
| PURE_META = ["고마워", "고맙습니다", "감사", "도움이 됐", "도움이 되었", "도움 됐", "알겠어", "알겠습니다", | |
| "이제 알", "정리됐어", "정리되었", "기분이 나아", "덕분"] | |
| def is_pure_meta(t): | |
| """발화 전체가 코칭에 대한 소감인지(표현이 아니라). 짧고 메타 표현으로만 구성.""" | |
| s = t.strip() | |
| if len(s) <= 12 and any(s.startswith(w) or s == w or w in s for w in PURE_META): | |
| return True | |
| return False | |
| def is_coach_question(t): | |
| """코치에게 던진 질문(조언 요청)인지 — '전할 표현'이 아니라 '어떻게 해야 하냐'는 물음. | |
| 예: '나는 어떻게 해야해?', '와이프한테 어떻게 해야 마음을 열 수 있어?', '이럴 땐 어떡해?' | |
| 이런 건 상대에게 전할 말이 아니므로 완성 후보에서 제외.""" | |
| s = t.strip() | |
| # 물음표로 끝나고 + 조언/방법을 구하는 표현이 있으면 코치 질문으로 간주 | |
| asks_how = any(k in s for k in ["어떻게 해", "어떡해", "어떻게 하", "어떻해", "어찌해", "어째", | |
| "방법이 뭐", "방법 좀", "어떻게 풀", "어떻게 대처", | |
| "뭐라고 해야", "뭐라 해야", "어떤 말을 해야", "조언", "어떻게 말해"]) | |
| asks_self = any(k in s for k in ["나는 어떻게", "내가 어떻게", "난 어떻게", "어떻게 해야 할", "어떻게 해야 돼", "어떻게 해야해"]) | |
| if (s.endswith("?") or s.endswith("?")) and (asks_how or asks_self): | |
| return True | |
| if asks_self: # '나는 어떻게 해야해' 류는 물음표 없어도 | |
| return True | |
| if any(k in s for k in ["조언", "어떻게 해야", "어떡하면", "어떻게 하면 좋"]): # 명시적 조언 요청은 물음표 없어도 | |
| return True | |
| return False | |
| def is_expression_candidate(user_msg, said_done, is_choice): | |
| """'전할 표현 후보'로 모을지 — 짧은 답/순수메타/선택지/확정어/코치질문만 제외, 나머지는 모두 후보. | |
| (측정값으로 거르지 않음: 신호강도가 표현 완성도를 반영하지 못함이 확인됨. | |
| '좋았/고마워'가 표현 내용에 든 경우는 살림 — 순수 메타 발화만 제외)""" | |
| t = user_msg.strip() | |
| if said_done or is_choice: | |
| return False | |
| if len(t) < 6: # 너무 짧은 단어 조각/맞장구 제외 | |
| return False | |
| if is_pure_meta(t): # 순수 코칭 소감만 제외 | |
| return False | |
| if is_coach_question(t): # 코치에게 한 질문(조언 요청)은 '전할 표현'이 아니므로 제외 | |
| return False | |
| # 단순 맞장구·되묻기(표현 아님) 제외 | |
| fillers = ["응 맞아", "그러게", "그치", "맞아 그래서", "뭐라는", "그건 왜", "그게 다야", "응 그래"] | |
| if any(t == f or t.startswith(f + " ") for f in fillers): | |
| return False | |
| return True | |
| def add_candidate(state, user_msg, m, strength): | |
| """후보 추가(중복 제외). 텍스트 + 측정값을 함께 저장. 최신이 뒤로.""" | |
| t = user_msg.strip() | |
| for c in state["candidates"]: | |
| if c["text"] == t: | |
| return | |
| state["candidates"].append({ | |
| "text": t, | |
| "EVA": round(float(m["EVA"]), 4), "EAR": round(float(m["EAR"]), 4), | |
| "VAL": round(float(m["VAL"]), 4), "REI": round(float(m["REI"]), 4), | |
| "strength": round(float(strength), 4), | |
| "turn": state["turns"], | |
| }) | |
| # 너무 많아지면 최근 6개만(완성 시 선택 부담 줄임) | |
| if len(state["candidates"]) > 6: | |
| state["candidates"] = state["candidates"][-6:] | |
| # 사용자의 명시적 '확정/충분' 의사 (더 다듬을 게 없다는 명확한 신호만 — 맞장구성 '맞아/응'은 제외) | |
| DONE_WORDS = ["없는", "없어", "없을", "없다", "충분", "이대로", "이거면", "됐어", "됐고", "이게 다", | |
| "그게 다", "다인거 같", "다인 것 같", "끝", "이 정도면", "이거로", "이걸로", "그만"] | |
| # 더 다듬겠다는 의사 | |
| MORE_WORDS = ["더", "바꾸", "다시", "고치", "조금", "다른", "추가", "보태"] | |
| # 메타 대화(코칭 자체에 대한 응답: 고마움·소감) — '전할 표현'이 아니므로 best 추적에서 제외 | |
| META_WORDS = ["고마", "감사", "도움", "알게", "알겠", "좋았", "좋네", "괜찮았", "덕분", "이제 알", "정리됐", "정리되"] | |
| def _cos(a, b): | |
| if a is None or b is None: return 0.0 | |
| return float(a @ b / ((norm(a) * norm(b)) + 1e-8)) | |
| SCAFFOLD_CHOICES = ["① 고마운데 뭔가 아쉬운", "② 서운한데 말하기 미안한", "③ 나도 내 마음을 잘 모르겠는"] | |
| # 상대 반응 예측을 유도하는 문장 — 생성 응답에서 이런 문장이 나오면 제거(프롬프트가 안 먹혀서 코드로 강제) | |
| import re as _re | |
| def strip_prediction(text): | |
| """상대 반응 예측 유도 문장을 제거. 프롬프트로 막아도 LLM이 어겨서 코드로 강제 차단. | |
| 문장 단위로 쪼개, '상대(연인 등) + 반응/받아들임/생각' 류를 묻는 의문문이면 그 문장을 뺀다.""" | |
| # 문장 분리(물음표·마침표·줄바꿈 기준, 구분자 유지) | |
| parts = _re.split(r'(?<=[.!??\n])', text) | |
| OTHER = ("연인", "상대", "그 사람", "남자친구", "여자친구", "그분", "그가", "그녀", | |
| "가족", "친구", "동료", "엄마", "아빠", "부모", "남편", "아내", "선배", "후배", "그들") | |
| REACT = ("반응", "받아들", "어떻게 들", "어떻게 생각", "어떻게 느", "어떤 마음", "어떤 생각") | |
| kept = [] | |
| for s in parts: | |
| is_q = ("?" in s) or ("?" in s) | |
| about_other = any(o in s for o in OTHER) | |
| about_react = any(r in s for r in REACT) | |
| # 상대의 반응을 묻는 의문문이면 제거 | |
| if is_q and about_other and about_react: | |
| continue | |
| # '상상해/예상해/떠올려'+반응 류도 제거(상대 명시 없어도) | |
| if is_q and any(g in s for g in ("상상해", "예상해", "떠올려")) and any(r in s for r in ("반응", "받아들")): | |
| continue | |
| kept.append(s) | |
| out = "".join(kept) | |
| out = _re.sub(r"\n{3,}", "\n\n", out).strip() | |
| if out: | |
| return out | |
| # 전부 제거됨(= 응답이 상대반응 질문뿐이었음) → 자기표현으로 돌리는 중립 멘트로 대체 | |
| return "지금 표현에 당신의 마음이 잘 담긴 것 같아요. 혹시 더 보태고 싶은 말이 있으면 편하게 적어주세요." | |
| COMPLETE_CLARITY_THRESHOLD = 0.7 # best_strength 이 미만이면 A(또렷함 낮음), 이상이면 B(또렷함 높음). 실사용으로 조정. | |
| def make_completion_message(chosen_text, grew=False): | |
| """완성 마무리를 코드가 직접 생성(LLM 안 씀 → 상대 반응 질문 구조적 차단). | |
| 길2: 사용자가 직접 고른 표현(chosen_text)을 인용해 따뜻하게 마무리.""" | |
| bt = (chosen_text or "").strip() | |
| quote = f'"{bt}"\n\n' if bt else "" | |
| grew_line = "" | |
| if grew: | |
| grew_line = "\n\n처음의 막연한 마음에서 여기까지, 표현이 한 걸음 또렷해졌어요." | |
| body = ("이 표현에 당신의 마음이 잘 담겼어요. " | |
| "막막했던 마음을 이렇게 한 문장으로 정리해낸 것 자체가 의미 있는 일이에요.\n\n" | |
| "준비됐을 때, 편하게 전해보세요. 잘 해내실 거예요. 🙂") | |
| return f"{quote}{body}{grew_line}" | |
| # ── 길2: 후보 선택 버튼 ── | |
| MAX_CAND = 6 # 후보 버튼 최대 개수 | |
| def make_candidate_updates(state): # [미사용] 0.8.2부터 @gr.render로 대체 | |
| """후보 버튼들 + '더 다듬기' 버튼의 gr.update 리스트 생성. | |
| 반환: [버튼1..버튼MAX_CAND, btn_more] (총 MAX_CAND+1개)""" | |
| cands = state.get("candidates", [])[-MAX_CAND:] | |
| updates = [] | |
| for i in range(MAX_CAND): | |
| if i < len(cands): | |
| txt = cands[i]["text"] | |
| label = txt if len(txt) <= 40 else txt[:38] + "…" | |
| updates.append(gr.update(value=label, visible=True)) | |
| else: | |
| updates.append(gr.update(visible=False)) | |
| # 마지막: '더 다듬어볼래요' 버튼(완성 선택 시 항상 표시) | |
| updates.append(gr.update(visible=True)) | |
| return updates | |
| OPENING = ("안녕하세요. 여기서는 '마음에 있는데, 어떻게 말해야 할지 막막한 이야기'를 같이 정리해볼 수 있어요.\n\n" | |
| "지금 내 상황과 가장 가까운 걸 아래에서 하나 골라주세요. 고르고 나면, 무슨 일이 있었는지 편하게 적어보시면 돼요.\n" | |
| "(딱 맞는 게 없어도 괜찮아요 — 가장 가까운 걸 고르거나 맨 아래 '여러 감정이 섞여서…'를 골라주세요.)") | |
| def respond(user_msg, chat, state, is_choice=False): | |
| if not user_msg or not user_msg.strip(): | |
| return (chat, state, "", gr.update(visible=False), gr.update(), gr.update()) | |
| # 0.9.9: 이야기/라이트 진행 중엔 자유 입력창 대신 버튼으로 진행(대본 흐름 보호) | |
| if (state.get("story_id") and state.get("story_step") not in (None, "card")) \ | |
| or state.get("lite_step") in ("pick", "write", "exchange"): | |
| chat = chat + [{"role": "assistant", | |
| "content": "*이야기 모드에서는 아래 버튼으로 진행해요 — 당신이 말할 차례가 오면 입력칸이 열립니다.*"}] | |
| return (chat, state, "", gr.update(visible=False), gr.update(), gr.update()) | |
| state["turns"] += 1 | |
| if state["session"] is None: | |
| state["session"] = _uuid.uuid4().hex[:12] | |
| m, _tokproj = ENGINE.measure_full(user_msg) | |
| strength = signal_strength(m) | |
| # 위험 게이트: '이 측정을 믿어도 되는가' — 위험 높으면 신호가 강해도 되묻기 경로(아래 분기) | |
| risk = risk_probability(m, _tokproj) | |
| risky = (risk is not None and risk >= RISK_GATE["risk_threshold_10pct"]) | |
| # 반어 의심 신호 — 측정 직후, eva_hist에 이번 값 넣기 전(직전 흐름 기준). 기록만, 측정값 불변. | |
| irony_sig = irony_suspect(user_msg, m["EVA"], state["eva_all"]) | |
| state["irony_flag"] = irony_sig["level"] | |
| mdesc = describe_measure(m, risk) | |
| tnd = tendency_of(m) # VAL/REI 방향 성향 (실험적, 보조 코칭용) | |
| show_choices = False | |
| show_confirm = False # 완성 제안 시 "더 해볼래요/충분해요" 버튼 표시 여부 | |
| said_done_now = any(w in user_msg for w in DONE_WORDS) | |
| is_meta = any(w in user_msg for w in META_WORDS) | |
| # 비계 선택지 문구는 사용자 표현이 아니므로 best에서 제외(버튼 클릭 또는 문구 일치) | |
| is_choice = is_choice or (user_msg.strip() in SCAFFOLD_CHOICES) | |
| phase_before = state["phase"] # 이번 발화가 도착했을 때의 단계(전환 판단용) | |
| # 나침반 좌표 누적(EVA=x, EAR=y) | |
| state["coords"].append((m["EVA"], m["EAR"], strength)) | |
| state.setdefault("risks", []).append(round(risk, 4) if risk is not None else -1.0) # 위험도 기록(재검증 재료) | |
| state["eva_all"].append(m["EVA"]) # 모든 발화 EVA 누적(반어 판정용 — 다음 발화가 이번 흐름을 봄) | |
| # 흐름 분기 | |
| if state["phase"] == "start": | |
| # 처음 적기 → 신호강도로 막막/또렷 분기 | |
| state["first_strength"] = strength | |
| state["first_text"] = user_msg | |
| state["max_strength"] = max(state["max_strength"], strength) | |
| # 신호가 강해도 위험하면 = '확신에 찬 오측정' 후보 → 단정 대신 비계(되묻기)로 | |
| if strength >= CLEAR_THRESHOLD and not risky: | |
| # 또렷 → 바로 다듬기로 | |
| state["phase"] = "refine" | |
| sysp = build_coach_prompt("graduate", mdesc, tendency=tnd, emotions=state.get("emotions")) | |
| # 첫 발화가 또렷하면 후보로 수집(표현일 수 있음) | |
| if is_expression_candidate(user_msg, said_done_now, is_choice): | |
| add_candidate(state, user_msg, m, strength) | |
| else: | |
| # 막막 → 비계 | |
| state["phase"] = "scaffold" | |
| sysp = build_coach_prompt("scaffold", mdesc, tendency=tnd, emotions=state.get("emotions")) | |
| show_choices = True | |
| elif state["phase"] == "scaffold": | |
| # 비계 중 — 신호 또렷해지면 졸업, 아니면 계속 비계 | |
| state["max_strength"] = max(state["max_strength"], strength) | |
| # 측정 기반 졸업은 위험 게이트 통과 필요. 명시적 감정어는 측정과 무관하게 신뢰 → 그대로 졸업. | |
| if (strength >= CLEAR_THRESHOLD and not risky) or any(k in user_msg for k in [ | |
| "서운", "속상", "화나", "화가", "짜증", "섭섭", "아쉬", "답답", "억울", "미안", "속상해", "슬프"]): | |
| state["phase"] = "refine" | |
| sysp = build_coach_prompt("graduate", mdesc, tendency=tnd, emotions=state.get("emotions")) | |
| # 졸업을 일으킨 발화를 후보로 수집 | |
| if is_expression_candidate(user_msg, said_done_now, is_choice): | |
| add_candidate(state, user_msg, m, strength) | |
| else: | |
| sysp = build_coach_prompt("scaffold", mdesc, tendency=tnd, emotions=state.get("emotions")) | |
| show_choices = True | |
| elif state["phase"] == "refine": | |
| state["refine_turns"] += 1 | |
| state["eva_hist"].append(m["EVA"]) # 조건3용 EVA 이력 | |
| state["max_strength"] = max(state["max_strength"], strength) | |
| # 길2: 표현 후보 수집(측정으로 거르지 않음 — 명백히 표현 아닌 것만 제외) | |
| if is_expression_candidate(user_msg, said_done_now, is_choice): | |
| add_candidate(state, user_msg, m, strength) | |
| state["stall_count"] = 0 | |
| else: | |
| state["stall_count"] += 1 | |
| # 완성 4조건 판정 (명확한 규칙) | |
| suggest, hard5, why = completion_conditions(m["EVA"], state) | |
| # 사용자가 직접 "충분/없어" 확정하면 바로 완성 선택으로(조건과 별개) | |
| said_more = any(w in user_msg for w in MORE_WORDS) | |
| explicit_done = said_done_now and not said_more and bool(state["candidates"]) | |
| if explicit_done or suggest: | |
| # 완성 시점 — 사용자가 표현을 직접 선택(길2) | |
| state["phase"] = "confirm" | |
| sysp = "PICK_EXPRESSION" # 후보 선택 UI 표시(코드 처리) | |
| show_confirm = True | |
| state["_hard5"] = hard5 and not explicit_done | |
| else: | |
| grew = "" | |
| if state["first_strength"] is not None and state["max_strength"] > state["first_strength"] + 0.2: | |
| grew = f"(처음보다 또렷해짐 — 성장 비춰주기)" | |
| sysp = build_coach_prompt("refine", mdesc + " " + grew, tendency=tnd, emotions=state.get("emotions")) | |
| elif state["phase"] == "confirm": | |
| # 완성 제안 단계 — 보통은 버튼으로 처리되지만, 텍스트로 답한 경우 대비 | |
| affirm = any(w in user_msg for w in DONE_WORDS) or any(k in user_msg for k in ["응", "네", "그래", "좋아", "맞아", "알았", "그치", "그러"]) | |
| more = any(w in user_msg for w in MORE_WORDS) or ("아니" in user_msg and "아니야" not in user_msg[:4]) | |
| # 새 표현을 입력했으면 후보에 추가(긍정/더 아닌 실제 표현) | |
| if is_expression_candidate(user_msg, said_done_now, is_choice) and not affirm: | |
| add_candidate(state, user_msg, m, strength) | |
| if more and not affirm: | |
| state["phase"] = "refine" | |
| state["stall_count"] = 0 | |
| sysp = build_coach_prompt("refine", mdesc, tendency=tnd, emotions=state.get("emotions")) | |
| else: | |
| # 다시 후보 선택 UI 표시 | |
| sysp = "PICK_EXPRESSION" | |
| show_confirm = True | |
| elif state["phase"] == "complete": | |
| # 이미 마무리함 → 반복하지 않고 짧게 응답(종료 상태 유지) | |
| sysp = "DONE_ALREADY" | |
| else: | |
| sysp = build_coach_prompt("refine", mdesc, tendency=tnd, emotions=state.get("emotions")) | |
| # 응답 생성: | |
| # - sysp == "PICK_EXPRESSION": 완성 시점 → 사용자가 표현 직접 선택(길2). 후보 안내 멘트. | |
| # - sysp == "DONE_ALREADY": 이미 마무리함 → 반복 없이 짧은 종료 멘트 | |
| # - 그 외: LLM 생성 후 '상대 반응 예측' 문장 후처리 제거 | |
| if sysp == "PICK_EXPRESSION": | |
| hard5 = state.get("_hard5", False) | |
| if hard5: | |
| reply = ("여러 표현을 다듬어보셨어요. 충분히 마음이 정리된 것 같아요.\n" | |
| "아래에서 가장 마음에 드는 표현을 골라주세요. 더 다듬고 싶으면 '더 해볼래요'를 눌러주세요.") | |
| else: | |
| reply = ("지금까지 마음을 잘 표현해주셨어요.\n" | |
| "아래에서 그 사람에게 전하고 싶은 표현을 골라주세요. 더 다듬고 싶으면 '더 해볼래요'를 눌러주세요.") | |
| elif sysp == "DONE_ALREADY": | |
| reply = "이미 마음을 잘 정리하셨어요. 준비됐을 때 전해보세요. 새로 이야기하고 싶으면 페이지를 새로고침해 주세요. 🙂" | |
| else: | |
| reply = gemini(sysp, chat, user_msg) | |
| reply = strip_prediction(reply) | |
| chat = chat + [{"role": "user", "content": user_msg}, | |
| {"role": "assistant", "content": reply}] | |
| # 측정값·변수 저장(원문 제외, 길이만) — 갱신된 phase/refine_turns 반영 | |
| try: | |
| STORE.log_turn(state["session"], m, strength, state, utter_len=len(user_msg.strip()), kind="user", text=user_msg.strip()) | |
| except Exception as e: | |
| if DEBUG: | |
| print("저장 실패:", e) | |
| # 나침반 갱신(길2: best 별표 없음 — 측정이 best를 모름) | |
| fig = render_compass(state["coords"], None) | |
| cap = compass_caption(state["coords"], None) | |
| dbg = debug_line(m, strength, state) | |
| if dbg: | |
| cap = cap + "\n\n" + dbg | |
| # 완성 선택 UI는 @gr.render가 state["show_confirm"]/candidates를 보고 그림 | |
| state["show_confirm"] = show_confirm | |
| # 완성 시점이면 힌트 1개 생성(사용자 발화를 전달용으로 보수적으로 다듬은 예시). | |
| # 후보가 발화 원문 그대로라 그 자체로는 '전할 표현'이 되기 어려움 → LLM이 다듬어 예시 제공. | |
| if show_confirm: | |
| cands_for_hint = state.get("candidates", [])[-MAX_CAND:] | |
| state["hint"] = make_hint(cands_for_hint, state.get("emotions")) | |
| else: | |
| state["hint"] = None | |
| state["hard5_flag"] = state.get("_hard5", False) | |
| return (chat, state, "", gr.update(visible=show_choices), fig, cap) | |
| def pick_choice(choice, chat, state): | |
| # 비계 선택지 — 측정은 하되(분기용) best 추적에서는 제외(is_choice=True) | |
| return respond(choice, chat, state, is_choice=True) | |
| def choose_more(chat, state): | |
| """완성 시점에 '더 해볼래요' → refine 복귀(고도화 기회).""" | |
| state["phase"] = "refine" | |
| state["stall_count"] = 0 | |
| state["show_confirm"] = False # 완성 선택 UI 닫기 | |
| reply = ("좋아요. 그럼 조금 더 다듬어볼까요? 지금 표현에서 더 보태거나 바꾸고 싶은 부분, " | |
| "또는 아직 못 한 말이 있다면 편하게 적어주세요.") | |
| chat = chat + [{"role": "assistant", "content": reply}] | |
| fig = render_compass(state["coords"], None) | |
| cap = compass_caption(state["coords"], None) | |
| return (chat, state, "", gr.update(visible=False), fig, cap) | |
| def choose_candidate(idx, chat, state): | |
| """후보 선택 → 그 표현으로 마무리(길2의 핵심). 선택 데이터 저장.""" | |
| cands = state.get("candidates", [])[-MAX_CAND:] | |
| if idx < 0 or idx >= len(cands): | |
| return (chat, state, "", gr.update(visible=False), gr.update(), gr.update()) | |
| chosen = cands[idx] | |
| state["chosen_text"] = chosen["text"] | |
| state["chosen_meta"] = chosen | |
| state["phase"] = "complete" | |
| state["done_shown"] = True | |
| state["show_confirm"] = False # 완성 선택 UI 닫기 | |
| state["show_feedback"] = True # 완성 직후 후기 요청 표시 | |
| # 선택 결과 저장(측정값 + 원문) | |
| try: | |
| STORE.log_selection(state["session"], cands, idx, emotions=state.get("emotions"), | |
| mode=state.get("mode"), scenario_rel=state.get("scenario_rel")) | |
| except Exception as e: | |
| if DEBUG: | |
| print("선택 저장 실패:", e) | |
| grew = (state["first_strength"] is not None and state["max_strength"] > state["first_strength"] + 0.25) | |
| reply = make_completion_message(chosen["text"], grew=grew) | |
| chat = chat + [{"role": "assistant", "content": reply}] | |
| fig = render_compass(state["coords"], None) | |
| cap = compass_caption(state["coords"], None) | |
| return (chat, state, "", gr.update(visible=False), fig, cap) | |
| def choose_hint(hint_text, chat, state): | |
| """LLM이 다듬은 힌트를 사용자가 선택 → 그 표현으로 마무리. | |
| 힌트 선택 여부도 기록(데이터 분석: 사람이 원문 vs 다듬은 힌트 중 무엇을 고르나).""" | |
| # 0.9.9 수정: 힌트도 실측정 — chosen_meta·기록에 진짜 값이 들어가 chosen_strength 공백 해소 | |
| try: | |
| _hm = ENGINE.measure(hint_text) | |
| _hs = signal_strength(_hm) | |
| except Exception: | |
| _hm, _hs = {"EVA": 0.0, "EAR": 0.0, "VAL": 0.0, "REI": 0.0}, 0.0 | |
| hint_meta = {"text": hint_text, | |
| "EVA": round(float(_hm["EVA"]), 4), "EAR": round(float(_hm["EAR"]), 4), | |
| "VAL": round(float(_hm["VAL"]), 4), "REI": round(float(_hm["REI"]), 4), | |
| "strength": round(float(_hs), 4), "turn": state.get("turns"), "is_hint": True} | |
| state["chosen_text"] = hint_text | |
| state["chosen_meta"] = hint_meta | |
| state["phase"] = "complete" | |
| state["done_shown"] = True | |
| state["show_confirm"] = False | |
| state["show_feedback"] = True # 완성 직후 후기 요청 표시 | |
| # 힌트 선택 기록 — log_selection에 힌트를 후보로 추가해 'chosen=힌트'로 저장 | |
| try: | |
| cands = state.get("candidates", [])[-MAX_CAND:] | |
| cands_with_hint = cands + [hint_meta] | |
| STORE.log_selection(state["session"], cands_with_hint, len(cands_with_hint) - 1, | |
| emotions=state.get("emotions"), | |
| mode=state.get("mode"), scenario_rel=state.get("scenario_rel")) | |
| except Exception as e: | |
| if DEBUG: | |
| print("힌트 선택 저장 실패:", e) | |
| reply = make_completion_message(hint_text, grew=False) | |
| chat = chat + [{"role": "assistant", "content": reply}] | |
| fig = render_compass(state["coords"], None) | |
| cap = compass_caption(state["coords"], None) | |
| return (chat, state, "", gr.update(visible=False), fig, cap) | |
| def toggle_emotion(emotion_key, state): | |
| """시작 화면에서 감정 주제 토글(선택/해제). 임시 선택 emo_pick만 갱신 → @gr.render 재실행으로 강조 반영. | |
| 아직 '시작하기' 전이므로 채팅은 시작하지 않음. | |
| ※ Gradio가 state 변경을 확실히 감지하도록 새 dict를 반환(같은 객체 반환 시 재렌더 누락 위험).""" | |
| if emotion_key not in EMOTIONS: | |
| return state | |
| pick = list(state.get("emo_pick", [])) | |
| if emotion_key in pick: | |
| pick.remove(emotion_key) # 다시 누르면 해제 | |
| else: | |
| if len(pick) < 3: # 너무 많이 고르면 초점 흐려짐 → 최대 3개 | |
| pick.append(emotion_key) | |
| new_state = dict(state) # 새 객체로 복사 → 변경 감지 보장 | |
| new_state["emo_pick"] = pick | |
| return new_state | |
| def start_with_emotions(chat, state): | |
| """'시작하기' — 선택한 감정들을 확정하고 코칭 시작. 0개여도 시작 가능(바로 상황 적기).""" | |
| state["emotions"] = list(state.get("emo_pick", [])) | |
| state["topic_chosen"] = True | |
| n = len(state["emotions"]) | |
| if n == 0: | |
| reply = ("편하게 시작해볼게요. 어떤 일이 있었는지, 그때 마음이 어땠는지 적어보세요. " | |
| "짧아도 괜찮아요. (적는 것만으로도 마음이 조금 정리될 거예요.)") | |
| else: | |
| reply = ("그런 마음이셨군요. 여기서 함께 천천히 들여다볼게요.\n\n" | |
| "어떤 일이 있었는지, 그때 마음이 어땠는지 편하게 적어보세요. 짧아도 괜찮아요. " | |
| "(적는 것만으로도 마음이 조금 정리될 거예요.)") | |
| chat = chat + [{"role": "assistant", "content": reply}] | |
| return (chat, state, "", gr.update(visible=False), gr.update(), gr.update()) | |
| # ============================== 시나리오 (진입 예시 전용) ============================== | |
| # 원칙(0.8.9 재발 방지): 시나리오는 '진입 씨앗'까지만. 측정·완성·코치 프롬프트엔 절대 주입하지 않음. | |
| # 구조: SCENARIOS[관계][감정키] = [{"title","sit"}]. sit=사용자에게 보여줄 예시 상황(1인칭). | |
| SCENARIOS = { | |
| "연인": { | |
| "hurt": [ | |
| {"title": "자꾸 미뤄지는 데이트", "sit": "바쁘다며 약속을 자꾸 미루는데, 이해는 가지만 서운해. 부담 줄까 봐 말도 못 하고 있어."}, | |
| {"title": "내 고민을 가볍게 넘길 때", "sit": "힘들다고 했는데 '그 정도는 누구나 겪어'라고 해서 서운했어."}, | |
| {"title": "미래 얘기에 소극적일 때", "sit": "결혼 얘기를 꺼냈는데 '아직은…'이라고 해서 좀 실망했어."}, | |
| ], | |
| "angry": [ | |
| {"title": "세게 화를 내서 나도 상했을 때", "sit": "상대가 너무 세게 화를 내서 나도 기분이 상했어. 어떻게 말해야 할지 모르겠어."}, | |
| ], | |
| "reconnect": [ | |
| {"title": "말다툼 뒤 어색해졌을 때", "sit": "말다툼에서 너무 방어적으로 말한 게 찜찜하고, 다시 편해지고 싶어."}, | |
| ], | |
| "sorry": [ | |
| {"title": "깜짝 파티가 부담이 됐을 때", "sit": "생일에 깜짝 파티를 열어줬는데 오히려 서운해해서 당황스러워. 내가 뭘 놓친 걸까."}, | |
| {"title": "'그냥 참아'라고 해버렸을 때", "sit": "힘들다는 상대에게 '그냥 좀 참아'라고 했다가 상처를 준 것 같아."}, | |
| ], | |
| }, | |
| "자녀": { # 사춘기 자녀 섹션 — 자녀(중학생) 관점 | |
| "hurt": [ | |
| {"title": "성적 얘기만 하는 아빠", "sit": "시험을 못 봤는데 아빠가 점수 얘기만 해서, 나라는 사람은 안 보이는 것 같아 서운해."}, | |
| {"title": "바빠서 못 오는 행사", "sit": "아빠가 바빠서 학교 행사에 못 온대. '안 와도 돼'라고 했지만 사실은 서운해."}, | |
| {"title": "또 미뤄진 약속", "sit": "아빠가 약속을 또 미뤘어. 기대했다가 실망하는 게 반복돼."}, | |
| ], | |
| "empty": [ | |
| {"title": "뭘 해도 재미없는 요즘", "sit": "요즘 뭘 해도 재미가 없고 마음이 좀 텅 빈 것 같아."}, | |
| {"title": "내 마음은 아무도 안 물어봐", "sit": "다들 뭘 하라고만 하고, 내가 뭘 느끼는지는 아무도 안 물어봐서 좀 공허해."}, | |
| ], | |
| "angry": [ | |
| {"title": "폰 뺏기고 못 믿는 느낌", "sit": "규칙을 어겨서 폰을 뺏겼는데, '너는 못 믿어' 같은 느낌이 들어서 답답하고 화가 나."}, | |
| {"title": "억울하게 혼났을 때", "sit": "내가 안 그랬는데 억울하게 혼나서 화가 나."}, | |
| ], | |
| "recognized": [ | |
| {"title": "좋아하는 걸 쓸데없다고 할 때", "sit": "내가 좋아하는 걸 '쓸데없다'고 해서 속상해. 이것도 나인데."}, | |
| {"title": "결과가 안 좋을 때도 봐줬으면", "sit": "결과가 안 좋을 때도 나를 좀 봐줬으면 좋겠어."}, | |
| ], | |
| "reconnect": [ | |
| {"title": "서먹해진 아빠와", "sit": "아빠랑 서먹해졌는데, 다시 예전처럼 가까워지고 싶어."}, | |
| {"title": "먼저 화해하고 싶은데 어색해", "sit": "먼저 화해의 손을 내밀고 싶은데 어색해서 못 하고 있어."}, | |
| ], | |
| "sorry": [ | |
| {"title": "짜증 낸 게 미안한데 말 못 함", "sit": "아빠한테 짜증을 냈는데, 화해하고 싶은데 어떻게 말할지 몰라서 못 하고 있어."}, | |
| {"title": "걱정 끼친 게 미안할 때", "sit": "내가 걱정을 끼친 게 아빠한테 미안해."}, | |
| ], | |
| }, | |
| "부모": { # 사춘기 자녀 섹션 — 부모 관점 (신규 작성 — 자녀 시나리오처럼 실사용 검토 필요) | |
| "hurt": [ | |
| {"title": "닫힌 방문 앞에서", "sit": "요즘 아이가 뭐든 '몰라도 돼'라며 방문을 닫아버려서, 다가가고 싶은데 서운하고 서글퍼."}, | |
| {"title": "형식적인 대답만 돌아올 때", "sit": "물어봐도 '응' '아니'만 하고 대화를 피해서, 마음이 멀어진 것 같아 속상해."}, | |
| ], | |
| "angry": [ | |
| {"title": "또 어긴 귀가 약속", "sit": "약속한 귀가 시간을 또 어겼어. 화가 나지만 소리치지 않고 마음을 전하고 싶어."}, | |
| {"title": "툭 쏘는 말투에 욱했을 때", "sit": "'알아서 할게'라며 쏘아붙이는 말투에 욱했는데, 감정적으로 쏟지 않고 얘기하고 싶어."}, | |
| ], | |
| "sorry": [ | |
| {"title": "욱해서 한 심한 말", "sit": "성적 얘기에 욱해서 심한 말을 해버렸어. 아이한테 상처 준 것 같아 미안해."}, | |
| {"title": "다른 집 아이와 비교한 말", "sit": "다른 집 아이랑 비교하는 말을 해버렸는데, 후회되고 미안해."}, | |
| ], | |
| "reconnect": [ | |
| {"title": "언젠가부터 끊긴 대화", "sit": "언제부턴가 아이랑 대화가 끊겼는데, 어색해도 다시 가까워지고 싶어."}, | |
| ], | |
| }, | |
| } | |
| # ── 부부·남편: 약한 표현(1) → 강한 표현(10) 강도 오름차순 사다리 (표시 순서 = 이 순서) ── | |
| # 남성은 감정을 실용적 얘기·화·"됐어"로 덮는 자기검열이 강함 → 앞쪽은 표현하기 쉬운 결, 뒤쪽은 가장 취약한 결. | |
| HUSBAND_LADDER = [ | |
| {"title": "표현이 서툴러 무심하다 오해받을 때", "emo": "hurt", | |
| "sit": "표현이 서툴러서 그렇지 마음이 없는 건 아닌데, 아내가 무심하다고 오해하니까 좀 서운해."}, | |
| {"title": "무뚝뚝한 말투가 차갑다고 할 때", "emo": "hurt", | |
| "sit": "나한텐 평소 말투인데 아내는 차갑다고 서운해해. 고치려 해도 잘 안 돼서 답답해."}, | |
| {"title": "내 힘든 일에 관심이 줄었을 때", "emo": "hurt", | |
| "sit": "회사에서 힘든 일 있다고 해도 아내가 예전과 달리 '그래?' 하고 넘겨서 서운해."}, | |
| {"title": "취미나 휴식을 한심하게 볼 때", "emo": "angry", | |
| "sit": "일주일 내내 일하고 주말에 잠깐 쉬는 건데, 아내가 한심하다는 듯 봐서 숨이 막혀."}, | |
| {"title": "고치려는데 반복되는 잔소리", "emo": "angry", | |
| "sit": "고치려고 하는데 아내가 자꾸 '왜 안 변하냐'고 해서 오히려 의욕이 꺾여."}, | |
| {"title": "나도 하는데 부족하다고만 할 때", "emo": "recognized", | |
| "sit": "나도 나름 집안일이든 육아든 돕는데, 잘한 건 안 보고 부족하다고만 해서 억울해."}, | |
| {"title": "다른 남편과 비교당할 때", "emo": "recognized", | |
| "sit": "'누구 남편은 이렇게 해주는데'라며 자꾸 비교하니까, 내가 부족한 사람 같아 참담해."}, | |
| {"title": "아이들 앞에서 무시당할 때", "emo": "angry", | |
| "sit": "애들 보는 앞에서 내 방식을 지적하니까, 아빠로서 내 자리가 무너지는 것 같아 무안하고 화가 나."}, | |
| {"title": "돈 버는 기계 취급받을 때", "emo": "lonely", | |
| "sit": "가족 위해 밖에서 버티는데 고생 한마디 없이 돈 얘기만 하니까, 돈 버는 기계 같아 비참하고 외로워."}, | |
| {"title": "힘들다고 기댔는데 밀쳐질 때", "emo": "lonely", | |
| "sit": "자존심 다 내려놓고 힘들다고 기댄 건데 '다들 그래'라며 가볍게 넘기니까, 기댈 곳이 없는 것 같아 비참하고 외로워."}, | |
| ] | |
| # 아내 관점: 남편에게 / 시댁에. 각각 약한 표현(1)→강한 표현(10). 여성 자기검열은 '유난/이기적' 죄책감형. | |
| WIFE_TO_HUSBAND_LADDER = [ | |
| {"title": "얘기해도 건성으로 들을 때", "emo": "hurt", | |
| "sit": "내가 얘기하는데 남편이 폰만 보면서 '응 응' 건성으로 들어. 내 얘기가 안 중요한 것 같아서 서운해."}, | |
| {"title": "애정 표현이 줄었을 때", "emo": "lonely", | |
| "sit": "예전엔 다정했는데 요즘은 표현이 없어. 사랑받는 느낌이 안 들어 외로운데, 이런 거 바라는 게 유치한가 싶기도 해."}, | |
| {"title": "공감 대신 해결책만 줄 때", "emo": "hurt", | |
| "sit": "힘들다고 하면 바로 '그럼 이렇게 해'라고 해결책부터 내. 그냥 '힘들었겠다' 한마디가 듣고 싶었는데 허해."}, | |
| {"title": "내 수고를 몰라줄 때", "emo": "recognized", | |
| "sit": "애도 키우고 살림도 하는데 '수고했다'는 말 한마디가 없어. 내가 하는 게 당연한 일처럼 여겨져서 서운해."}, | |
| {"title": "취미에만 빠져 있을 때", "emo": "hurt", | |
| "sit": "주말마다 남편이 게임이나 취미에만 빠져 있어. 같이 하자고 해도 귀찮아하는 것 같아서 서운하고 내가 짐 같아."}, | |
| {"title": "육아·가사가 나에게만 몰릴 때", "emo": "angry", | |
| "sit": "육아랑 집안일을 거의 내가 다 해. 남편은 '나도 회사에서 힘들다'고만 하고, 나만 이걸 짊어진 것 같아 억울해."}, | |
| {"title": "독박육아가 외로울 때", "emo": "lonely", | |
| "sit": "남편이 바쁜 건 아는데 애를 거의 혼자 키우는 것 같아서 외로워. 힘들다고 하면 유난 같아서 말도 못 했어."}, | |
| {"title": "나를 접은 게 당연시될 때", "emo": "angry", | |
| "sit": "결혼하면서 일도 접고 애를 키웠는데 그걸 당연하게 여겨. 억울하고, 그 사이 나를 좀 잃어버린 것 같아 허전해."}, | |
| {"title": "나는 늘 채우기만 할 때", "emo": "empty", | |
| "sit": "가족 감정을 다 챙기느라 정작 나는 비어가는 것 같아. 다들 나한테 기대기만 하고, 나는 챙김받은 적이 없는 것 같아."}, | |
| {"title": "나를 잃어버린 것 같을 때", "emo": "empty", | |
| "sit": "온종일 누구 엄마, 누구 아내로만 살아. 정작 나 자신은 없는 것 같아 공허한데, 이런 생각 하는 게 배부른 소린가 싶어서."}, | |
| ] | |
| WIFE_TO_INLAW_LADDER = [ | |
| {"title": "연락 없이 불쑥 오실 때", "emo": "hurt", | |
| "sit": "시어머니가 연락도 없이 불쑥 오셔. 가족이니 당연한 건가 싶다가도, 준비 안 된 모습에 눈치 보여서 부담스러워."}, | |
| {"title": "잦은 안부 전화를 강요하실 때", "emo": "hurt", | |
| "sit": "쉬기 바쁜데 자주 전화드려야 해서 지쳐. 할 말도 딱히 없는데 의무적으로 하려니 부담스럽고 서운해."}, | |
| {"title": "살림·음식 방식을 지적하실 때", "emo": "hurt", | |
| "sit": "오실 때마다 '이건 이렇게 해야지' 하셔. 내 방식이 틀린 것 같아서 위축되고 서운해."}, | |
| {"title": "도움 주시며 간섭이 심할 때", "emo": "hurt", | |
| "sit": "생활비를 도와주시는데 그 때문에 간섭이 심하셔. 고맙긴 한데 '이 돈은 우리가 주는 거니까' 하시면 부담스럽고 서운해."}, | |
| {"title": "다른 며느리와 비교하실 때", "emo": "recognized", | |
| "sit": "'누구네 며느리는 이렇다더라'며 자꾸 비교하셔. 내가 부족한 사람 같아 속상하고, 나 나름 하는데 서운해."}, | |
| {"title": "외모·옷차림을 지적하실 때", "emo": "angry", | |
| "sit": "'살 좀 쪄야겠다', '옷이 그게 뭐니' 하며 외모를 지적하셔. 가족이라도 선을 넘는 것 같아 불편해."}, | |
| {"title": "육아 방식에 간섭하실 때", "emo": "angry", | |
| "sit": "애 키우는 방식마다 아니라고 하셔. 손주 사랑인 건 아는데 엄마인 내 결정이 자꾸 뒤집혀서 무시당하는 것 같아 답답해."}, | |
| {"title": "일정을 상의 없이 통보하실 때", "emo": "lonely", | |
| "sit": "명절 일정을 내 사정은 묻지도 않고 통보하듯 정하셔. 나도 이 가족인데 상의가 아니라 통보를 받으니 소외감이 들어."}, | |
| {"title": "자녀 계획을 압박하실 때", "emo": "angry", | |
| "sit": "'때가 있는데 빨리 낳아야지'라며 둘째 계획을 자꾸 재촉하셔. 우리 부부가 알아서 할 일인데 통제받는 것 같아 답답해."}, | |
| {"title": "다녀오면 진이 빠지고 공허할 때", "emo": "empty", | |
| "sit": "시댁에선 계속 긴장하게 돼서 다녀오면 며칠 녹초가 되고 마음이 텅 비어. 누가 뭐라 한 것도 아닌데 이런 내가 예민한가 싶어."}, | |
| ] | |
| # 부부 사다리 라우팅: 터미널 관계키 → (사다리, 헤더, 뒤로갈 관계키) | |
| _L_SUB = " \n<sub>아래로 갈수록 더 깊고 표현하기 어려운 마음이에요 (1=약함 → 10=강함)</sub>" | |
| LADDERS = { | |
| "부부_남편": (HUSBAND_LADDER, "**🤵 남편 → 아내 — 상황을 골라보세요**" + _L_SUB, "부부"), | |
| "부부_아내_남편": (WIFE_TO_HUSBAND_LADDER, "**👰 아내 → 남편 — 상황을 골라보세요**" + _L_SUB, "부부_아내"), | |
| "부부_아내_시댁": (WIFE_TO_INLAW_LADDER, "**👰 아내 → 시댁 — 상황을 골라보세요**" + _L_SUB, "부부_아내"), | |
| } | |
| REL_ORDER = ["연인", "부부", "자녀", "부모"] | |
| REL_LABEL = {"연인": "💞 연인", "부부": "💍 부부", "자녀": "🧒 자녀 (내가 자녀)", "부모": "👪 부모 (내가 부모)"} | |
| TEEN_PW = "qwer" # ⚠️ 소프트 게이트(사춘기 자녀 섹션 분리용) — 클라이언트 노출됨. 실제 접근 제어 아님. | |
| def set_mode(mode, state): | |
| ns = dict(state); ns["mode"] = mode; return ns | |
| def set_scenario_rel(rel, state): | |
| ns = dict(state); ns["scenario_rel"] = rel; return ns | |
| def try_teen_pw(pw, state): | |
| """사춘기 자녀 섹션 소프트 게이트. 정답이면 잠금만 해제(관계는 자녀/부모 중 선택).""" | |
| ns = dict(state) | |
| if (pw or "").strip() == TEEN_PW: | |
| ns["teen_unlocked"] = True | |
| return ns | |
| def set_scenario_emo(emo, state): | |
| ns = dict(state); ns["scenario_emo"] = emo; return ns | |
| def scenario_back(step, state): | |
| """뒤로가기: 해당 단계 하위 선택을 초기화.""" | |
| ns = dict(state) | |
| if step == "mode": | |
| ns["mode"] = None; ns["scenario_rel"] = None; ns["scenario_emo"] = None | |
| elif step == "rel": | |
| ns["scenario_rel"] = None; ns["scenario_emo"] = None | |
| elif step == "emo": | |
| ns["scenario_emo"] = None | |
| return ns | |
| def start_with_scenario(seed_sit, emo, chat, state): | |
| """시나리오(또는 '내 상황 달라요')로 코칭 시작. | |
| seed는 예시로만 보여주고 프롬프트·측정·완성엔 넣지 않음(기존 엔진 그대로).""" | |
| state["emotions"] = [emo] if emo else [] | |
| state["scenario_emo"] = emo | |
| state["topic_chosen"] = True | |
| care = "" | |
| if emo == "empty": # 공허함 care 경계 — 부드러운 연결 안내(엔진은 안 건드림) | |
| care = ("\n\n혹시 이런 텅 빈 느낌이 오래가거나 너무 무겁게 느껴지면, " | |
| "혼자 견디지 말고 믿을 수 있는 어른이나 전문가와 함께 나눠도 좋아요.") | |
| if seed_sit: | |
| reply = ("이런 상황과 비슷한가요?\n\n" | |
| f"예) {seed_sit}\n\n" | |
| "꼭 같지 않아도 괜찮아요. 실제로 있었던 일과 그때 마음을 편하게 적어보세요. " | |
| "짧아도 괜찮아요." + care) | |
| else: | |
| reply = ("편하게 시작해볼게요. 어떤 일이 있었는지, 그때 마음이 어땠는지 적어보세요. " | |
| "짧아도 괜찮아요." + care) | |
| chat = chat + [{"role": "assistant", "content": reply}] | |
| return (chat, state, "", gr.update(visible=False), gr.update(), gr.update()) | |
| def submit_feedback(score, comment, chat, state): | |
| """후기 제출(1~5 + 주관식). 저장 후 UI 닫고 짧게 확인.""" | |
| try: | |
| STORE.log_feedback(state["session"], score, comment, state) | |
| except Exception as e: | |
| if DEBUG: | |
| print("후기 저장 실패:", e) | |
| state["feedback_done"] = True | |
| state["show_feedback"] = False | |
| chat = chat + [{"role": "assistant", "content": "후기 남겨주셔서 고마워요. 잘 받았어요."}] | |
| return (chat, state, "", gr.update(), gr.update(), gr.update()) | |
| def skip_feedback(chat, state): | |
| """후기 건너뜀. 응답률 분석용으로 helpfulness=None 레코드는 남김.""" | |
| try: | |
| STORE.log_feedback(state["session"], None, None, state) | |
| except Exception as e: | |
| if DEBUG: | |
| print("후기(건너뜀) 저장 실패:", e) | |
| state["feedback_done"] = True | |
| state["show_feedback"] = False | |
| return (chat, state, "", gr.update(), gr.update(), gr.update()) | |
| # ============================== UI ============================== | |
| # ============================== 0.9.9 이야기 모드 — 대본·핸들러 ============================== | |
| # 원칙: ① 이야기 대사·측정은 코치 프롬프트에 주입하지 않음(측정·완성 엔진 불변) | |
| # ② 개입 순간 최종 문장은 사용자가 직접 작성(길2) — 힌트는 참고용 | |
| # ③ 상대 반응은 절제(보상은 상대가 아니라 '마음 카드') ④ 나침반은 상대 예측이 아니라 내 문장 실측 | |
| STORIES = [ | |
| { | |
| "id": "late_home", "tag": "💍 부부 · 사과", "title": "늦은 귀가", | |
| "blurb": "밤 11시 40분, 식은 저녁과 켜진 거실 불. \u201c당신한테 나는 뭐야?\u201d라는 물음 앞에 서는 이야기.", | |
| "me": "남편", "partner": "아내", | |
| "partner_state": "상처받아 굳어 있다. 화해를 완결하지 말 것 — 굳음에서 '듣는 중'까지만 움직인다. 다만 진심을 들은 만큼은 짧게 인정해도 된다.", | |
| "style_ex": ["…처음 듣는 얘기네, 그런 말.", "…그런 마음인 줄은, 정말 몰랐어."], | |
| "setup": "밤 11시 40분. 회식이 길어졌다. 현관문을 여는 손이 무겁다. 거실 불이 아직 켜져 있다.", | |
| "lines": [ | |
| ("n", "현관에 들어서자, 소파에 앉아 있던 아내가 고개를 들지 않는다."), | |
| ("them", "아내", "…왔어?"), | |
| ("n", "목소리가 낮다. 식탁 위엔 랩을 씌운 저녁이 그대로 있다."), | |
| ("me", "어… 회식이 좀 길어져서."), | |
| ("them", "아내", "연락 한 통이면 되잖아. 밥 해놓고 기다리는 사람 생각은 안 해?"), | |
| ("me", "미안. 정신이 없었어."), | |
| ("them", "아내", "정신이 없었다… 늘 그래. 회사 일엔 정신이 있고, 집엔 없고."), | |
| ("n", "아내가 리모컨을 내려놓는다. 목소리 끝이 떨린다."), | |
| ("them", "아내", "당신한테 나는 뭐야? 진짜 궁금해서 그래. 말 좀 해봐."), | |
| ("n", "거실이 조용하다. 시계 초침 소리만 크다. — 이제 당신이 말할 차례다."), | |
| ], | |
| "ask": "\u201c당신한테 나는 뭐야?\u201d — 지금 이 사람의 마음에 가장 가까운 것은?", | |
| "emotions": [ | |
| {"label": "미안함", "desc": "기다리게 한 것, 연락하지 않은 것이 마음에 걸린다.", | |
| "ladder": ["늦어서 미안해. 연락 못 한 건 내 잘못이야.", | |
| "미안해. 당신이 기다릴 걸 알면서도 연락을 미뤘어. 그게 제일 미안해.", | |
| "미안해. 오늘만이 아니라, 당신을 자꾸 뒷전으로 미룬 것 같아서… 그게 정말 미안해."], | |
| "close": "사과는 변명보다 늦게, 그러나 마음보다는 빨리 도착했다."}, | |
| {"label": "서러움 — 나도 지쳐 있었다", "desc": "미안하지만, 어디에도 말 못 한 내 지침도 있다.", | |
| "ladder": ["미안해. 요즘 나도 좀 힘들었나 봐.", | |
| "미안해. 근데 나도 요즘 많이 지쳐 있었어. 어디에도 말을 못 했어.", | |
| "미안해. 사실 나, 계속 버티고 있었어. 회사에서도 집에서도 숨 쉴 데가 없어서… 당신한테라도 기대고 싶었나 봐."], | |
| "close": "미안하다는 말 뒤에, 처음으로 자기 마음도 한 줄 얹었다."}, | |
| {"label": "고마움 — 기다려준 사람", "desc": "화의 안쪽에 있는 기다림이 보인다.", | |
| "ladder": ["기다려줘서… 고마워. 미안하고.", | |
| "화내는 거, 당신이 나를 기다렸다는 거잖아. 그게 고맙고 미안해.", | |
| "솔직히, 불 켜진 거실을 보고 안심했어. 아직 나를 기다려주는 사람이 있구나 하고. 미안하고, 고마워."], | |
| "close": "화의 안쪽에 기다림이 있다는 걸, 그는 오늘 처음 말로 꺼냈다."}, | |
| {"label": "답답함 — 나도 할 말은 있다", "desc": "미안하지만, 매번 죄인이 되는 기분도 사실이다.", | |
| "ladder": ["미안한데… 나도 일부러 늦은 건 아니야.", | |
| "미안해. 근데 매번 죄인이 되는 것 같아서, 솔직히 좀 답답하기도 해.", | |
| "미안해. 그런데 나도 답답해. 열심히 산다고 사는 건데, 집에 오면 늘 혼나는 기분이야."], | |
| "close": "듣기 좋은 말 대신, 오래 삼킨 진심을 골랐다."}, | |
| ], | |
| "after": [ | |
| ("them", "아내", "……"), | |
| ("them", "아내", "…처음 듣는 얘기네, 그런 말."), | |
| ("n", "굳었던 어깨가 아주 조금, 내려간 것도 같다. 대화는 이제부터 시작이다."), | |
| ], | |
| "after_blame": [ | |
| ("them", "아내", "……"), | |
| ("n", "아내가 시선을 거둔다. 거실이 아까보다 조금 더 조용해졌다."), | |
| ], | |
| "scene": "밤 11시 40분의 거실. 식은 저녁과 켜진 불, 그리고 \u201c당신한테 나는 뭐야\u201d라는 물음. 그는 오래 망설이다 입을 열었다.", | |
| }, | |
| { | |
| "id": "read_unanswered", "tag": "💞 연애 · 썸", "title": "읽씹 다음 날", | |
| "blurb": "삼십 분 걸려 쓴 메시지에 답이 없던 하루. \u201c하고 싶은 말 있으면 해\u201d라는 말 앞의 이야기.", | |
| "me": "나", "partner": "그 사람", | |
| "partner_state": "고백을 받은 쪽 — 놀람과 두근거림이 섞여 있다. 밀어내지 말 것. 고백에는 사소한 단어가 아니라 그 마음에, 자기 기색(놀람·당황·설렘)을 담아 내용으로 반응하라. 확답(사귀자·나도 좋아해 선언)만은 이 장면에서 하지 말 것 — 기색까지만.", | |
| "style_ex": ["…그런 줄 몰랐어. 말해줘서 고마워.", "잠깐… 나 지금 좀 놀랐어. 그거, 진심이지?"], | |
| "setup": "어제 보낸 메시지에 답이 없었다. 오늘 낮, 그 사람에게서 아무렇지 않게 연락이 왔다. 저녁, 카페 창가 자리.", | |
| "lines": [ | |
| ("n", "그 사람이 웃으며 손을 흔들고 맞은편에 앉는다."), | |
| ("them", "그 사람", "미안 미안, 어제 완전 정신없었어. 폰 볼 틈이 없더라."), | |
| ("me", "아… 그랬구나. 바빴나 보네."), | |
| ("them", "그 사람", "응. 근데 너 어제 뭐라고 보냈더라? 뭔가 길게 왔던 것 같은데."), | |
| ("n", "그 메시지를 쓰는 데 삼십 분이 걸렸다. 지웠다 썼다를 반복하면서."), | |
| ("me", "아니 뭐… 별거 아니었어."), | |
| ("them", "그 사람", "별거 아닌 게 그렇게 길어? …요즘 너 좀 이상해. 하고 싶은 말 있으면 해."), | |
| ("n", "컵 안의 얼음이 달그락, 소리를 낸다. — 이제 당신이 말할 차례다."), | |
| ], | |
| "ask": "\u201c하고 싶은 말 있으면 해\u201d — 지금 이 마음에 가장 가까운 것은?", | |
| "emotions": [ | |
| {"label": "서운함", "desc": "내겐 별거 아닌 메시지가 아니었다.", | |
| "ladder": ["답이 없으니까, 좀 신경 쓰였어.", | |
| "솔직히 서운했어. 나한텐 별거 아닌 메시지가 아니었거든.", | |
| "서운했어. 삼십 분 걸려 쓴 말이 읽히지도 않은 채 하루가 지나니까, 나만 진심인가 싶더라."], | |
| "close": "괜찮은 척의 자리에, 처음으로 진짜 마음이 앉았다."}, | |
| {"label": "불안 — 나는 어떤 사람일까", "desc": "답 없는 하루 동안, 내 자리가 궁금했다.", | |
| "ladder": ["가끔… 네 마음을 잘 모르겠어.", | |
| "답 없는 하루 동안 별생각을 다 했어. 내가 너한테 어떤 사람인지 궁금했고.", | |
| "불안했어. 나는 너한테 우선순위가 아닌가 싶어서. 그 생각이 어제 하루 종일 떠나지 않았어."], | |
| "close": "묻지 못했던 질문을, 오늘은 자기 문장으로 만들었다."}, | |
| {"label": "좋아하는 마음", "desc": "답 하나에 하루가 오르내릴 만큼.", | |
| "ladder": ["…네가 신경 쓰여서 그래.", | |
| "너한테 오는 답 하나에 하루 기분이 왔다 갔다 해. 그 정도로 신경 쓰여.", | |
| "좋아하니까 그래. 답 하나에 온종일 마음이 오르내릴 만큼. 이제 너도 알았으면 했어."], | |
| "close": "돌려 말하기를 그만둔 순간, 마음은 생각보다 단단했다."}, | |
| {"label": "괜찮은 척, 그만두기", "desc": "부담 줄까 봐 계속 삼켰던 말.", | |
| "ladder": ["사실… 별거 아닌 건 아니었어.", | |
| "괜찮은 척했는데, 안 괜찮았어. 그 말 하려고.", | |
| "나 계속 괜찮은 척했어. 부담 줄까 봐. 근데 오늘은 그냥 솔직할게 — 안 괜찮았어."], | |
| "close": "\u2018별거 아니야\u2019를 지우는 데 하루가 걸렸다. 그리고 말했다."}, | |
| ], | |
| "after": [ | |
| ("them", "그 사람", "……"), | |
| ("n", "그 사람이 컵을 만지작거리다, 천천히 시선을 든다."), | |
| ("them", "그 사람", "…그런 줄 몰랐어. 말해줘서 고마워."), | |
| ], | |
| "after_blame": [ | |
| ("them", "그 사람", "……"), | |
| ("n", "그 사람이 컵만 내려다본다. 얼음이 다 녹도록, 말이 없다."), | |
| ], | |
| "scene": "읽히지 않은 메시지와 아무렇지 않은 안부 사이. \u201c하고 싶은 말 있으면 해\u201d라는 말에, 오래 괜찮은 척하던 사람이 입을 열었다.", | |
| }, | |
| { | |
| "id": "credit_taken", "tag": "🏢 직장 · 경계", "title": "회식이 끝나고", | |
| "blurb": "박수는 쏟아졌지만 단상엔 내가 없었다. \u201c서운한 거 있으면 말해\u201d라는 말 앞의 이야기.", | |
| "me": "나", "partner": "선배", | |
| "partner_state": "지적을 받아 머쓱하고 찔린 상태. 변명하고 싶지만 적대는 금지 — 잘못을 소화하는 중이다.", | |
| "style_ex": ["…몰랐네, 그렇게 느낀 줄은.", "그 말 들으니… 할 말이 없다, 미안."], | |
| "setup": "오늘 발표는 박수까지 받았다. 다만 단상에 선 사람은 내가 아니었다. 회식이 끝나고, 선배와 같은 방향 지하철역 앞.", | |
| "lines": [ | |
| ("n", "밤바람. 선배가 캔커피를 딴다."), | |
| ("them", "선배", "오늘 잘 끝났지? 부장님도 만족하시더라."), | |
| ("me", "…네. 잘 끝났죠."), | |
| ("them", "선배", "역시 우리 팀이 손발이 잘 맞아. 다음 것도 이 텐션으로 가자."), | |
| ("n", "\u2018우리 팀\u2019. 하지만 그 자료의 밤샘은 나 혼자의 것이었다."), | |
| ("me", "아, 네…"), | |
| ("them", "선배", "왜, 표정이 왜 그래. 뭐 서운한 거라도 있어? 있으면 말해. 우리 사이에."), | |
| ("n", "역 입구의 불빛이 길게 늘어진다. — 이제 당신이 말할 차례다."), | |
| ], | |
| "ask": "\u201c서운한 거 있으면 말해\u201d — 지금 이 마음에 가장 가까운 것은?", | |
| "emotions": [ | |
| {"label": "억울함", "desc": "내 밤샘이 \u2018우리\u2019라는 말 속에 지워졌다.", | |
| "ladder": ["그 자료… 사실 제가 밤새 만든 거라서요.", | |
| "솔직히 조금 억울했어요. 발표 자료, 제가 혼자 만든 건데 어디에도 제 이름이 없더라고요.", | |
| "억울했습니다. 밤새 만든 결과가 \u2018우리\u2019라는 말 속에 지워지는 걸 보면서, 제 자리가 없다고 느꼈어요."], | |
| "close": "삼켰던 밤샘의 값을, 오늘 처음 말로 청구했다."}, | |
| {"label": "인정받고 싶음", "desc": "바라는 건 박수가 아니라 이름 한 줄.", | |
| "ladder": ["다음엔… 제 이름도 한 줄 들어가면 좋겠어요.", | |
| "저는 결과보다, 제가 한 일이 제 것으로 남는 게 중요해요. 다음엔 그렇게 해주셨으면 해요.", | |
| "인정받고 싶습니다. 대단한 걸 바라는 게 아니라, 제가 한 일에 제 이름이 있는 것 — 그게 저한텐 일하는 이유예요."], | |
| "close": "바란 것은 박수가 아니라 이름 한 줄이었다."}, | |
| {"label": "단호함 — 선을 긋는다", "desc": "관계를 지키기 위해서라도, 이번엔 분명히.", | |
| "ladder": ["이번 같은 방식은… 좀 곤란해요.", | |
| "선배니까 말씀드려요. 남의 결과에 이름 얹는 거, 이번이 마지막이면 좋겠어요.", | |
| "분명히 말씀드릴게요. 제 결과물이 다른 이름으로 나가는 일, 다시는 없었으면 합니다."], | |
| "close": "관계를 지키는 말이 늘 부드러운 것만은 아니다."}, | |
| ], | |
| "after": [ | |
| ("them", "선배", "……"), | |
| ("n", "선배가 캔커피를 한 모금 마신다."), | |
| ("them", "선배", "…몰랐네, 그렇게 느낀 줄은."), | |
| ("n", "답이 다는 아니지만 — 말하지 않았다면, 시작도 없었을 것이다."), | |
| ], | |
| "after_blame": [ | |
| ("them", "선배", "……"), | |
| ("n", "선배가 캔을 구긴다. 역으로 내려가는 발걸음이 빨라졌다."), | |
| ], | |
| "scene": "박수 소리가 남의 것이 된 밤, 지하철역 앞. \u201c서운한 거 있으면 말해\u201d라는 말에, 오래 삼키던 사람이 처음으로 값을 청구했다.", | |
| }, | |
| ] | |
| STORY_BY_ID = {st["id"]: st for st in STORIES} | |
| STORY_RUNG_NAMES = ["순하게", "솔직하게", "직설로"] | |
| CLOSE_BLAME = "오늘의 문장은 상대를 가리켰다. 같은 장면에서, 이번엔 내 마음을 가리켜 볼 수도 있다." | |
| _EXCHANGE_MAX = 3 # 사용자 발화 상한(개입 1 + 답장 2) — 수다로 흐르기 전에 막이 내린다 | |
| STORY_EXCHANGE_ON = os.environ.get("STORY_EXCHANGE", "1") == "1" # A/B 스위치: 0이면 0.9.9 대본 흐름 | |
| _STORY_GEN_BANNED = ["꺼져", "나가", "그만해", "웃기", "어이가", "다 괜찮아", "용서할게", "사랑해", "약속할게", "됐고"] | |
| _STORY_BLAME = ["너 때문", "당신 때문", "네가 문제", "니가 ", "너는 항상", "당신은 항상"] | |
| _STORY_SELF = ["나는", "내가", "난 ", "나도", "저는", "제가", "제 "] | |
| def _story_msg(line): | |
| """대본 한 줄 → 챗봇 메시지. n=지문(이탤릭), them=상대, me=대본 속 나.""" | |
| if line[0] == "n": | |
| return {"role": "assistant", "content": f"*― {line[1]}*"} | |
| if line[0] == "them": | |
| return {"role": "assistant", "content": f"**{line[1]}** · {line[2]}"} | |
| return {"role": "user", "content": line[1]} | |
| def story_pick(story_id, chat, state): | |
| """이야기 선택 → 대본 재생 시작. 세션 생성 + start 이벤트 저장.""" | |
| story = STORY_BY_ID.get(story_id) | |
| if not story: | |
| return chat, state | |
| ns = dict(state) | |
| ns["mode"] = "이야기" | |
| ns["story_id"] = story_id | |
| ns["story_step"] = "play" | |
| ns["story_li"] = 0 | |
| ns["story_after_i"] = 0 | |
| ns["story_emo"] = None; ns["story_emo_label"] = None | |
| ns["story_rung"] = None; ns["story_text"] = None | |
| ns["story_convo"] = []; ns["story_user_turns"] = 0; ns["story_branch"] = None | |
| ns["topic_chosen"] = True | |
| if ns.get("session") is None: | |
| ns["session"] = _uuid.uuid4().hex[:12] | |
| chat = [{"role": "assistant", | |
| "content": (f"🎬 **{story['title']}** · {story['tag']}\n\n*{story['setup']}*\n\n" | |
| f"<sub>한 줄씩 넘기며 보다가 — 가장 중요한 순간에, 당신이 말합니다.</sub>")}] | |
| try: | |
| STORE.log_story_event(ns["session"], "start", ns) | |
| except Exception as e: | |
| if DEBUG: print("story start 저장 실패:", e) | |
| return chat, ns | |
| def story_next(chat, state): | |
| """대본 한 줄 재생. 마지막 줄에 닿으면 개입(마음 찾기)으로.""" | |
| ns = dict(state) | |
| story = STORY_BY_ID.get(ns.get("story_id")) | |
| if not story or ns.get("story_step") != "play": | |
| return chat, ns | |
| li = ns.get("story_li", 0) | |
| if li < len(story["lines"]): | |
| chat = chat + [_story_msg(story["lines"][li])] | |
| ns["story_li"] = li + 1 | |
| if ns["story_li"] >= len(story["lines"]): | |
| ns["story_step"] = "emo" | |
| return chat, ns | |
| def story_set_emo(idx, state): | |
| ns = dict(state) | |
| story = STORY_BY_ID.get(ns.get("story_id")) | |
| if story and idx is not None and 0 <= idx < len(story["emotions"]): | |
| ns["story_emo"] = idx | |
| ns["story_emo_label"] = story["emotions"][idx]["label"] | |
| ns["story_step"] = "rung" | |
| return ns | |
| def story_set_rung(idx, state): | |
| ns = dict(state) | |
| if ns.get("story_emo") is None: # 방어: 마음 미선택 상태면 마음 찾기로 | |
| ns["story_step"] = "emo"; return ns | |
| if idx is not None and 0 <= idx < len(STORY_RUNG_NAMES): | |
| ns["story_rung"] = idx; ns["story_step"] = "write" | |
| return ns | |
| def story_back_emo(state): | |
| ns = dict(state); ns["story_step"] = "emo"; ns["story_rung"] = None; return ns | |
| def story_back_rung(state): | |
| ns = dict(state); ns["story_step"] = "rung"; return ns | |
| def story_preview(text): | |
| """타이핑 실시간 — 내 문장의 실측(프리즘). 상대 반응 예측이 아니라 내 문장의 속성만 본다.""" | |
| t = (text or "").strip() | |
| if not t: | |
| return gr.update(), "*쓰는 동안, 문장이 담은 마음이 나침반에 나타납니다.*" | |
| try: | |
| m = ENGINE.measure(t) | |
| s = signal_strength(m) | |
| except Exception: | |
| return gr.update(), "*측정을 잠시 쉬어갑니다 — 계속 적어주세요.*" | |
| plt.close("all") # 실시간 미리보기 대비 — 이전 figure 정리(메모리 누수 방지) | |
| fig = render_compass([(m["EVA"], m["EAR"], s)], None) | |
| if any(w in t for w in _STORY_BLAME): | |
| note = "⚠️ 지금 문장은 상대를 가리키고 있어요 — \u201c나는 …해서\u201d로 시작하면 내 마음이 담깁니다." | |
| elif s >= CLEAR_THRESHOLD and any(w in t for w in _STORY_SELF): | |
| note = "✅ 내 마음이 문장에 또렷하게 담기고 있어요." | |
| elif s >= CLEAR_THRESHOLD: | |
| note = "감정이 또렷해요. \u2018나는/내가\u2019를 넣으면 더 내 것이 됩니다." | |
| else: | |
| note = "아직 흐릿해요 — 그때의 마음을 한 단어라도 넣어보세요." | |
| cap = (f"**내 문장 실측(프리즘)** — 신호강도 **{s:.2f}** (완성 기준 {CLEAR_THRESHOLD}) · " | |
| f"정서가 {m['EVA']:+.2f} · 각성 {m['EAR']:+.2f}\n\n{note}") | |
| return fig, cap | |
| def _story_gen_filter(text, last_user=None): | |
| """생성 대사 검문 — 전 줄 합침 → 과길이 소프트 트림 → 조각·오류문·적대·완결 화해 폐기. | |
| 0.9.10.6: 앵무새 백스톱 — 사용자 마지막 말의 3-gram을 지운 뒤 알맹이가 2자 이하면 반복으로 보고 폐기. | |
| (보수적 기준 — '응, 마라탕 먹자'처럼 사용자 단어를 정당히 재사용하는 답은 통과)""" | |
| if not text: | |
| return None | |
| t = " ".join(x.strip() for x in text.strip().splitlines() if x.strip()) | |
| t = re.sub(r"^(아내|그 사람|선배|남편|친구|상대|나)\s*[::]\s*", "", t).strip(" \"\u201c\u201d'") | |
| if not t or t.startswith("["): | |
| return None | |
| if len(t) > 90: | |
| mfirst = re.match(r".{6,90}?[.!?…](?=\s|$)", t) | |
| t = mfirst.group(0).strip() if mfirst else None | |
| if not t: | |
| return None | |
| core = re.sub(r"[\s.…!?,~\-·\u201c\u201d\"']+", "", t) | |
| if len(core) < 4: | |
| return None | |
| if any(w in t for w in _STORY_GEN_BANNED): | |
| return None | |
| if last_user: | |
| _cl = lambda x: re.sub(r"[\s.…!?,~\-·\u201c\u201d\"'ㅋㅎ;]+", "", x) | |
| cu = _cl(last_user) | |
| if len(cu) >= 3: | |
| resid = _cl(t) | |
| for i in range(len(cu) - 2): | |
| resid = resid.replace(cu[i:i + 3], "") | |
| if len(resid) <= 2: | |
| return None | |
| if t.endswith(("라니.", "다니.", "말은.", "라고.")) and len(resid) <= 6: | |
| return None # 말꼬리 에코를 흐리며 끝낸 미완결형 → 재생성 | |
| return t | |
| def story_partner_line(story, convo, m, s): | |
| """상대의 '다음 한 마디' 생성 — 온도(분기)는 이미 코드가 결정, 여기서는 표면(문장의 결)만. | |
| 0.9.10.6: 장면 계약 이원화 — 라이트(genre 보유)=생활 말투·질문엔 내용 답변·구체어 허용, | |
| 본편(드라마)=기존 절제 계약 유지(+질문 답변 의무). 실패·계약 위반 → None(대본 폴백).""" | |
| is_lite = bool(story.get("genre")) | |
| if is_lite: | |
| ex_lines = ["- " + x for x in (story.get("style_ex") or [])] | |
| register = ( | |
| f"[장면 성격] {story['genre']}\n" | |
| f"[{story['partner']}의 상태] {story.get('partner_goal', '')}\n" | |
| f"[규칙 — 반드시 지킬 것]\n" | |
| f"- 질문을 받으면 내용으로 답하라 — 구체적인 것(메뉴 이름·가능 여부 등)을 말해도 된다. 질문을 그대로 돌려주지 마라.\n" | |
| f"- {story['me']}의 말을 앵무새처럼 반복하지 마라 — 되받더라도 새 내용을 더하라.\n" | |
| f"- 장면을 한 방에 끝내진 마라(다음 말이 남게). 하지만 회피 기계가 되지도 마라.\n" | |
| f"- 적대 금지: 비난·반격·냉소 금지.\n" | |
| f"- 한두 문장, 60자 이내. 드라마적 침묵(말줄임 남발)·비장한 어조 금지 — 생활 말투로.\n" | |
| f"- 이름표·따옴표·지문 없이 대사만, 줄바꿈 없이 한 줄로 출력.\n" | |
| ) | |
| ex_title = "[말투 예시]\n" | |
| writer = "경쾌한 일상 대화" | |
| else: | |
| ex_lines = ["- " + x for x in (story.get("style_ex") or [])] | |
| if not ex_lines: | |
| for i in range(len(story["after"]) - 1, -1, -1): | |
| if story["after"][i][0] == "them": | |
| ex_lines.append("- " + story["after"][i][2]); break | |
| register = ( | |
| f"[{story['partner']}의 상태] {story.get('partner_state', '아직 마음이 풀리지 않았다 — 굳음에서 듣는 중까지만 움직인다.')}\n" | |
| f"[규칙 — 반드시 지킬 것]\n" | |
| f"- 장면의 매듭(확답·완전한 해소·\u201c다 괜찮아\u201d 선언)은 짓지 마라 — 다음 말이 남게.\n" | |
| f"- 적대 금지: 비난·반격·냉소 금지.\n" | |
| f"- 되받기는 한 번이면 충분하다. 직전에 이미 되받았다면, 이번엔 내용(마음의 기색·사실·행동)으로 반응하라.\n" | |
| f"- 고백·진심을 들으면 사소한 단어를 집지 말고 그 마음에 반응하라. 질문을 받으면 내용으로 답하라.\n" | |
| f"- 완결된 문장으로 — '~라니.', '~말은.'처럼 흐리며 끝내지 마라. 조용한 되물음 한 번은 허용.\n" | |
| f"- 한두 문장, 60자 이내, 말줄임표(…)는 절제해서. 이름표·따옴표·지문 없이 대사만, 줄바꿈 없이 한 줄로 출력.\n" | |
| ) | |
| ex_title = "[이 장면의 말투 예시]\n" | |
| writer = "절제된 한국 드라마" | |
| sys_prompt = (f"당신은 {writer}의 대사 작가다. {story['partner']}의 '다음 한 마디'만 쓴다.\n" | |
| + register + ex_title + "\n".join(ex_lines)) | |
| hist = "\n".join(("{}: {}".format(story["me"] if c["who"] == "me" else story["partner"], c["text"])) for c in convo) | |
| last_user = next((c["text"] for c in reversed(convo) if c["who"] == "me"), "") | |
| user_msg = ( | |
| f"[장면] {story['title']} — {story['setup']}\n" | |
| f"[{story['partner']}가 던진 물음] {story['ask'].split('—')[0].strip()}\n" | |
| f"[지금까지의 대화]\n{hist}\n" | |
| f"[{story['me']}의 마지막 말 측정(프리즘 실측)] 정서가 {m['EVA']:+.2f} · 각성 {m['EAR']:+.2f} · 신호강도 {s:.2f}\n" | |
| f"(각성이 0.6보다 높으면 그 격함을 한 번 짚어도 좋고, 신호가 {GATE}보다 낮으면 가볍게 확인해도 된다.)\n" | |
| + (f"※ {story['partner']}는 이미 {sum(1 for c in convo if c['who'] == 'them')}번 말했다 — 말꼬리 되받기를 반복하지 말고, 이번엔 다른 결로.\n" | |
| if any(c["who"] == "them" for c in convo) else "") | |
| + f"{story['partner']}의 다음 한 마디:" | |
| ) | |
| for _attempt in range(2): # 검문 탈락 시 1회만 재생성 | |
| nudge = "" if _attempt == 0 else "\n(주의: 방금 출력이 규칙을 어겼다 — 앞말 반복·조각 없이, 내용 있는 완결 문장으로 다시.)" | |
| try: | |
| out = gemini(sys_prompt, [], user_msg + nudge) | |
| except Exception as e: | |
| if DEBUG: print("story 생성 예외:", e) | |
| return None | |
| line = _story_gen_filter(out, last_user=last_user) | |
| if line: | |
| return line | |
| if DEBUG: print("story 생성 검문 탈락(원문 80자):", (out or "")[:80]) | |
| return None | |
| def _story_close(chat, story): | |
| """마무리는 항상 대본 — after의 마지막 상대 대사 '뒤' 지문만 얹는다(결말 유보 원칙).""" | |
| idx = -1 | |
| for i in range(len(story["after"]) - 1, -1, -1): | |
| if story["after"][i][0] == "them": | |
| idx = i; break | |
| tail = [l for l in story["after"][idx + 1:] if l[0] == "n"] or [("n", "대화는 이제부터 시작이다.")] | |
| for l in tail: | |
| chat = chat + [_story_msg(l)] | |
| return chat | |
| def _story_blame_beats(chat, story): | |
| for l in story.get("after_blame", [("them", story["partner"], "……"), ("n", "방이 조금 더 조용해졌다.")]): | |
| chat = chat + [_story_msg(l)] | |
| return chat | |
| def story_speak(text, chat, state): | |
| """개입 발화 — 사용자의 문장을 실측·저장하고 대본에 얹는다(길2: 최종 문장은 사용자 것).""" | |
| t = (text or "").strip() | |
| ns = dict(state) | |
| if len(t) < 10: | |
| try: | |
| gr.Warning("조금만 더 — 한두 문장(10자 이상)으로 마음을 담아주세요.") | |
| except Exception: | |
| chat = chat + [{"role": "assistant", "content": "*조금만 더 — 한두 문장(10자 이상)으로 마음을 담아주세요.*"}] | |
| return chat, ns, gr.update(), gr.update() | |
| m, _tp = ENGINE.measure_full(t) | |
| s = signal_strength(m) | |
| _rk = risk_probability(m, _tp) | |
| ns["risks"] = list(ns.get("risks", [])) + [round(_rk, 4) if _rk is not None else -1.0] # 관찰만(게이트 없음) — log_turn.risk로 유입 | |
| ns["turns"] = ns.get("turns", 0) + 1 | |
| ns["coords"] = list(ns.get("coords", [])) + [(m["EVA"], m["EAR"], s)] | |
| ns["story_text"] = t | |
| ns["chosen_text"] = t | |
| ns["chosen_meta"] = {"text": t, | |
| "EVA": round(float(m["EVA"]), 4), "EAR": round(float(m["EAR"]), 4), | |
| "VAL": round(float(m["VAL"]), 4), "REI": round(float(m["REI"]), 4), | |
| "strength": round(float(s), 4), "turn": ns["turns"], "is_story": True} | |
| try: | |
| STORE.log_turn(ns["session"], m, s, ns, utter_len=len(t), kind="story_speak", text=t) | |
| except Exception as e: | |
| if DEBUG: print("story 발화 저장 실패:", e) | |
| chat = chat + [{"role": "user", "content": t}] | |
| ns["story_convo"] = [{"who": "me", "text": t}] | |
| ns["story_user_turns"] = 1 | |
| story = STORY_BY_ID[ns["story_id"]] | |
| # ── 온도 분기는 코드가 결정: 비난 → 침묵 대본 / 그 외 → 생성 교환(켜져 있을 때만) ── | |
| if any(w in t for w in _STORY_BLAME): | |
| ns["story_branch"] = "blame" | |
| chat = _story_blame_beats(chat, story) | |
| ns["story_step"] = "done" | |
| try: | |
| STORE.log_story_event(ns["session"], "branch_blame", ns, extra={"turn": 1}) | |
| except Exception as e: | |
| if DEBUG: print("story 분기 저장 실패:", e) | |
| elif STORY_EXCHANGE_ON: | |
| ns["story_branch"] = "clear" | |
| line = story_partner_line(story, ns["story_convo"], m, s) | |
| try: | |
| STORE.log_story_event(ns["session"], "exchange_gen", ns, extra={"turn": 1, "ok": bool(line)}) | |
| except Exception as e: | |
| if DEBUG: print("story 생성 저장 실패:", e) | |
| if line: | |
| chat = chat + [_story_msg(("them", story["partner"], "……")), | |
| _story_msg(("them", story["partner"], f"{line} <sub>✦ 생성</sub>"))] | |
| ns["story_convo"] = ns["story_convo"] + [{"who": "them", "text": line}] | |
| ns["story_step"] = "exchange" | |
| else: | |
| ns["story_step"] = "after" # 생성 불가 → 0.9.9 대본 흐름으로 강등 | |
| ns["story_after_i"] = 0 | |
| else: | |
| ns["story_branch"] = "clear" | |
| ns["story_step"] = "after" # A/B: 교환 꺼짐(STORY_EXCHANGE=0) → 대본 흐름 | |
| ns["story_after_i"] = 0 | |
| plt.close("all") # 미리보기 누적 figure 정리 | |
| fig = render_compass(ns["coords"], None) | |
| cap = f"말했어요 — 신호강도 **{s:.2f}**. 이제, 상대의 반응을 봐요." | |
| return chat, ns, fig, cap | |
| def story_reply(text, chat, state): | |
| """이야기 교환 — 상대의 생성 응답에 이어 말한다. 매 답장 실측·로깅, 상한 _EXCHANGE_MAX.""" | |
| t = (text or "").strip() | |
| ns = dict(state) | |
| story = STORY_BY_ID.get(ns.get("story_id")) | |
| if not story or ns.get("story_step") != "exchange": | |
| return chat, ns, gr.update(), gr.update() | |
| if len(t) < 6: | |
| try: | |
| gr.Warning("한 문장만 — 6자 이상 담아주세요.") | |
| except Exception: | |
| chat = chat + [{"role": "assistant", "content": "*한 문장만 — 6자 이상 담아주세요.*"}] | |
| return chat, ns, gr.update(), gr.update() | |
| m, _tp = ENGINE.measure_full(t) | |
| s = signal_strength(m) | |
| _rk = risk_probability(m, _tp) | |
| ns["risks"] = list(ns.get("risks", [])) + [round(_rk, 4) if _rk is not None else -1.0] # 관찰만(게이트 없음) — log_turn.risk로 유입 | |
| ns["turns"] = ns.get("turns", 0) + 1 | |
| ns["coords"] = list(ns.get("coords", [])) + [(m["EVA"], m["EAR"], s)] | |
| ns["story_convo"] = list(ns.get("story_convo", [])) + [{"who": "me", "text": t}] | |
| ns["story_user_turns"] = ns.get("story_user_turns", 1) + 1 | |
| try: | |
| STORE.log_turn(ns["session"], m, s, ns, utter_len=len(t), kind="story_reply", text=t) | |
| except Exception as e: | |
| if DEBUG: print("story 답장 저장 실패:", e) | |
| chat = chat + [{"role": "user", "content": t}] | |
| if any(w in t for w in _STORY_BLAME): # 어느 턴이든 비난 → 침묵 대본, 교환 종료 | |
| ns["story_branch"] = "blame" | |
| chat = _story_blame_beats(chat, story) | |
| ns["story_step"] = "done" | |
| else: | |
| line = story_partner_line(story, ns["story_convo"], m, s) | |
| try: | |
| STORE.log_story_event(ns["session"], "exchange_gen", ns, extra={"turn": ns["story_user_turns"], "ok": bool(line)}) | |
| except Exception as e: | |
| if DEBUG: print("story 생성 저장 실패:", e) | |
| if line: | |
| chat = chat + [_story_msg(("them", story["partner"], f"{line} <sub>✦ 생성</sub>"))] | |
| ns["story_convo"] = ns["story_convo"] + [{"who": "them", "text": line}] | |
| if ns["story_user_turns"] >= _EXCHANGE_MAX: | |
| chat = _story_close(chat, story) | |
| ns["story_step"] = "done" | |
| else: | |
| chat = _story_close(chat, story) # 생성 실패 → 조기 마무리(안전) | |
| ns["story_step"] = "done" | |
| plt.close("all") | |
| fig = render_compass(ns["coords"], None) | |
| cap = f"신호강도 **{s:.2f}** — 대화가 이어집니다." if ns["story_step"] == "exchange" else f"신호강도 **{s:.2f}**." | |
| return chat, ns, fig, cap | |
| def story_finish(chat, state): | |
| """'여기까지' — 교환을 접고 마무리 지문(대본)으로. 결말은 언제나 대본의 몫.""" | |
| ns = dict(state) | |
| story = STORY_BY_ID.get(ns.get("story_id")) | |
| if story and ns.get("story_step") == "exchange": | |
| chat = _story_close(chat, story) | |
| ns["story_step"] = "done" | |
| try: | |
| STORE.log_story_event(ns["session"], "exchange_stop", ns) | |
| except Exception as e: | |
| if DEBUG: print("story 종료 저장 실패:", e) | |
| return chat, ns | |
| def story_after_next(chat, state): | |
| """발화 후 절제된 반응 재생. 끝나면 '완료' 버튼 단계로.""" | |
| ns = dict(state) | |
| story = STORY_BY_ID.get(ns.get("story_id")) | |
| if not story or ns.get("story_step") != "after": | |
| return chat, ns | |
| i = ns.get("story_after_i", 0) | |
| if i < len(story["after"]): | |
| chat = chat + [_story_msg(story["after"][i])] | |
| ns["story_after_i"] = i + 1 | |
| if ns["story_after_i"] >= len(story["after"]): | |
| ns["story_step"] = "done" | |
| return chat, ns | |
| def story_show_card(chat, state): | |
| """'완료' 클릭 → 마음 카드 + 후기(기존 흐름 재사용).""" | |
| ns = dict(state) | |
| ns["story_step"] = "card" | |
| ns["show_feedback"] = True | |
| try: | |
| STORE.log_story_event(ns["session"], "done", ns, | |
| extra={"story_text_len": len(ns.get("story_text") or "")}) | |
| except Exception as e: | |
| if DEBUG: print("story done 저장 실패:", e) | |
| chat = chat + [{"role": "assistant", "content": "*🃏 마음 카드가 준비됐어요 — 아래에서 확인해요.*"}] | |
| return chat, ns | |
| def story_retry(chat, state): | |
| """같은 장면, 다른 마음으로 — 대본을 다시 깔고 개입만 반복.""" | |
| ns = dict(state) | |
| story = STORY_BY_ID.get(ns.get("story_id")) | |
| if not story: | |
| return chat, ns | |
| ns["story_step"] = "emo" | |
| ns["story_emo"] = None; ns["story_emo_label"] = None | |
| ns["story_rung"] = None; ns["story_text"] = None | |
| ns["story_convo"] = []; ns["story_user_turns"] = 0; ns["story_branch"] = None | |
| ns["story_after_i"] = 0 | |
| chat = [{"role": "assistant", | |
| "content": f"🎬 **{story['title']}** — 같은 장면, 다른 마음으로.\n\n*{story['setup']}*"}] | |
| chat = chat + [_story_msg(l) for l in story["lines"]] | |
| try: | |
| STORE.log_story_event(ns["session"], "retry", ns) | |
| except Exception as e: | |
| if DEBUG: print("story retry 저장 실패:", e) | |
| return chat, ns | |
| def story_home(chat, state): | |
| """처음으로 — 상태 초기화.""" | |
| ns = new_state() | |
| chat = [{"role": "assistant", "content": OPENING}] | |
| return chat, ns | |
| def story_to_stories(chat, state): | |
| """카드 → 다른 이야기 고르기(세션 유지 — 깔때기 다리①).""" | |
| ns = dict(state) | |
| try: | |
| STORE.log_story_event(ns["session"], "story_to_story", ns) | |
| except Exception as e: | |
| if DEBUG: print("다리 저장 실패:", e) | |
| ns["story_id"] = None; ns["story_step"] = None; ns["story_li"] = 0; ns["story_after_i"] = 0 | |
| ns["story_emo"] = None; ns["story_emo_label"] = None; ns["story_rung"] = None; ns["story_text"] = None | |
| ns["story_convo"] = []; ns["story_user_turns"] = 0; ns["story_branch"] = None | |
| ns["mode"] = "이야기"; ns["topic_chosen"] = False; ns["lite_seen"] = True | |
| chat = [{"role": "assistant", "content": "🎬 다른 이야기로 — 아래에서 골라주세요."}] | |
| return chat, ns | |
| def story_to_emotions(chat, state): | |
| """카드 → 감정 선택 직행(세션 유지 — 깔때기 다리②). 모드 선택 화면을 건너뛴다. | |
| 이야기 상태만 접고 세션·좌표는 이어간다(한 세션의 마음 흐름 — lite_go_main 선례와 동일).""" | |
| ns = dict(state) | |
| try: | |
| STORE.log_story_event(ns["session"], "story_to_main", ns) | |
| except Exception as e: | |
| if DEBUG: print("다리 저장 실패:", e) | |
| ns["story_id"] = None; ns["story_step"] = None; ns["story_li"] = 0; ns["story_after_i"] = 0 | |
| ns["story_emo"] = None; ns["story_emo_label"] = None; ns["story_rung"] = None; ns["story_text"] = None | |
| ns["story_convo"] = []; ns["story_user_turns"] = 0; ns["story_branch"] = None | |
| ns["mode"] = "감정"; ns["topic_chosen"] = False; ns["lite_seen"] = True; ns["emo_pick"] = [] | |
| chat = [{"role": "assistant", "content": "이야기 속 인물의 순간은 여기까지 — 이제 **당신의 마음**입니다.\n\n" | |
| "지금 마음에 가까운 감정을 아래에서 골라 시작해요. 딱 맞는 게 없으면 바로 '시작하기'도 좋아요."}] | |
| return chat, ns | |
| # ============================== 0.9.9.2 라이트 입구(30초) — 데이터·핸들러 ============================== | |
| # 목적: 첫 방문 마찰 최소화(탭만으로 완료) + 일상 공감대 소재. 흐름: 3줄 재생 일괄 → 마음 탭 → 카드 → 평가 → "더 해볼까요?" | |
| # 원칙 유지: 카드엔 항상 "내 문장으로" 문이 열려 있음(자기인식 도구 A 정체성 — 1회차는 유입, 문장은 재방문의 것). | |
| LITE_STORIES = [ | |
| { | |
| "id": "lite_groupchat", "tag": "👥 친구 · 단톡", "title": "읽음 12, 답 0", | |
| "blurb": "단톡방에서 내 말만 조용히 스킵당한 오후.", | |
| "setup": "오후 3시, 점심 얘기로 시끌시끌한 단톡방.", | |
| "partner": "친구", | |
| "invite": ("n", "단톡창이 잠시 조용하다. — 지금이라면, 아까 그 말을 다시 건넬 수 있다."), | |
| "close_n": ("n", "이번 말풍선은 흘려보내지 않았다. 대화는 이제부터다."), | |
| "blame_beats": [("them", "친구", "……"), ("n", "단톡방이 갑자기 더 조용해졌다.")], | |
| "genre": "가벼운 일상 — 단톡방 친구 사이. 무겁지 않게, 톡 말투(짧고 캐주얼, ㅋㅋ 허용).", | |
| "partner_goal": "가볍게 미안해하며 이번엔 제대로 반응한다. 질문에는 내용으로 답한다(주말 되는지 등).", | |
| "style_ex": ["아 미안미안 이제 봤다ㅋㅋ 나 토요일은 돼!", "헐 답한다는 게 까먹었네;; 뭐 하려고 그랬어?"], | |
| "close_flat": ("n", "말은 아직 주머니 속이지만 — 마음의 이름 하나는 챙겼다."), | |
| "lines": [ | |
| ("n", "그 틈에 내가 물었다. \u201c주말에 시간 되는 사람?\u201d"), | |
| ("n", "읽음 12. 답 0. 그 아래로 짤 세 개와 \u2018ㅋㅋㅋ\u2019가 지나갔다."), | |
| ("them", "친구", "아 맞다, 너 뭐라 했었지? 못 봤어 ㅋㅋ"), | |
| ], | |
| "ask": "\u201c못 봤어 ㅋㅋ\u201d — 지금 마음에 가장 가까운 건?", | |
| "choices": [ | |
| {"label": "서운함", "desc": "다들 봤잖아. 나만 공기인가.", | |
| "card_line": "읽음 12에 답 0 — 나만 공기가 된 것 같았다.", | |
| "close": "\u2018ㅋㅋ\u2019 한 글자보다, 내 물음표 하나가 더 오래 남았다."}, | |
| {"label": "머쓱함", "desc": "괜히 물었나. 지우고 싶다.", | |
| "card_line": "괜히 물었나 싶어, 올린 말을 지우고 싶었다.", | |
| "close": "머쓱함은 죄가 아니다 — 먼저 손 내민 사람의 것일 뿐."}, | |
| {"label": "쿨한 척", "desc": "뭐 그럴 수 있지… 근데 왜 신경 쓰이지.", | |
| "card_line": "\u2018뭐 그럴 수 있지\u2019 했는데, 저녁까지 신경 쓰였다.", | |
| "close": "쿨한 척의 안쪽에는 늘 진짜 마음이 하나 접혀 있다."}, | |
| {"label": "욱함", "desc": "한 명은 답해줄 수 있잖아.", | |
| "card_line": "열두 명 중 한 명은 답해줄 수 있잖아 — 욱했다.", | |
| "close": "화는 종종, 기대가 있었다는 증거다."}, | |
| ], | |
| "scene": "읽음 12, 답 0의 단톡방. \u201c못 봤어 ㅋㅋ\u201d 앞에서 잠깐, 마음이 출렁였다.", | |
| }, | |
| { | |
| "id": "lite_cancel", "tag": "💞 약속 · 당일", "title": "두 시간 전 취소", | |
| "blurb": "\u201c미안, 오늘 좀 힘들 것 같아 ㅠ\u201d — 나갈 준비 다 했는데.", | |
| "setup": "약속 두 시간 전. 옷도 골라놨다.", | |
| "partner": "상대", | |
| "invite": ("n", "답장 창이 깜빡인다. — 이번엔, 마음을 접지 않고 보낼 수 있다."), | |
| "close_n": ("n", "전송됨. 골라둔 옷은 다음을 기다리기로 했다. 대화는 이제부터다."), | |
| "blame_beats": [("them", "상대", "……"), ("n", "읽음 표시만 뜨고, 한참 답이 없다.")], | |
| "genre": "가벼운 서운함 — 당일 취소 뒤 메시지 대화. 톡 말투, 과한 비장함 금지.", | |
| "partner_goal": "미안함이 진심임을 보이되 사정(컨디션)도 사실대로. 질문에는 내용으로 답한다.", | |
| "style_ex": ["미안ㅠ 진짜 몸살 기운이라… 이번 주말은 내가 다 맞출게.", "헉 옷까지 골라놨었어…? 아 진짜 미안."], | |
| "close_flat": ("n", "답장은 보내다 말았다. 대신 서운함의 크기는 알았다."), | |
| "lines": [ | |
| ("them", "상대", "미안ㅠ 오늘 컨디션이 너무 안 좋아서… 다음에 보자, 진짜 미안!"), | |
| ("n", "\u2018다음에\u2019라는 말이 유독 가볍게 들렸다."), | |
| ("n", "골라둔 옷이 침대 위에 그대로 있다."), | |
| ], | |
| "ask": "취소 문자를 닫으며 — 지금 마음에 가장 가까운 건?", | |
| "choices": [ | |
| {"label": "서운함", "desc": "나는 이 약속을 기다렸는데.", | |
| "card_line": "기다린 건 나 혼자였나 싶어 서운했다.", | |
| "close": "서운함의 크기는, 기다림의 크기였다."}, | |
| {"label": "안도(솔직히)", "desc": "…사실 나도 좀 귀찮긴 했다.", | |
| "card_line": "솔직히 조금 안도했다 — 나도 씻기 귀찮던 참이었다.", | |
| "close": "가끔은 취소 문자가 서로를 구원한다. 그것도 마음이다."}, | |
| {"label": "걱정", "desc": "진짜 어디 아픈 건 아니겠지.", | |
| "card_line": "서운함보다 먼저, \u2018진짜 아픈가\u2019 걱정이 앞섰다.", | |
| "close": "걱정이 먼저 드는 관계 — 그건 꽤 괜찮은 관계다."}, | |
| {"label": "짜증", "desc": "당일 취소는 좀 아니지.", | |
| "card_line": "이해는 하는데 — 당일 두 시간 전은 좀 아니지 싶었다.", | |
| "close": "짜증의 절반은 \u2018내 시간도 소중하다\u2019는 선언이다."}, | |
| ], | |
| "scene": "약속 두 시간 전의 취소 문자. 침대 위엔 골라둔 옷, 마음엔 이름 붙일 감정 하나.", | |
| }, | |
| { | |
| "id": "lite_whatever", "tag": "🍜 저녁 · 메뉴", "title": "아무거나라더니", | |
| "blurb": "\u201c아무거나\u201d라던 사람이 내가 고르는 족족 \u201c음… 그건 좀\u201d.", | |
| "setup": "저녁 메뉴를 정하는 중.", | |
| "partner": "상대", | |
| "invite": ("n", "상대가 메뉴판을 내려놓고 이쪽을 본다. — 이제 당신이 말할 차례다."), | |
| "close_n": ("n", "메뉴는 아직이다. 대신 '아무거나'의 진짜 뜻이 조금 나왔다 — 대화는 이제부터다."), | |
| "blame_beats": [("them", "상대", "……"), ("n", "상대가 말없이 메뉴판만 넘긴다.")], | |
| "genre": "가벼운 일상 실랑이 — 저녁 메뉴 정하기. 드라마적 침묵·비장함 금지, 생활 말투(구어).", | |
| "partner_goal": "미안함 반, 장난 반. 질문을 받으면 메뉴 이름이나 취향을 구체적으로 말해도 된다 — 다만 한 번에 결론내진 않는다.", | |
| "style_ex": ["아 몰라~ 그냥 네가 시키는 거 한 입만 줘.", "…그럼 마라탕? 아니다, 오늘은 국물 말고."], | |
| "close_flat": ("n", "메뉴도 대화도 미정으로 남았다. 그래도 고르는 쪽만 애쓰던 판은 한 번 멈췄다."), | |
| "lines": [ | |
| ("me", "뭐 먹을래?"), | |
| ("them", "상대", "아무거나~ 진짜 아무거나 다 좋아."), | |
| ("n", "국밥 — \u201c음, 어제 국물\u201d. 파스타 — \u201c느끼할 듯\u201d. 치킨 — \u201c살쪄\u2026\u201d. 세 번째 퇴짜였다."), | |
| ], | |
| "ask": "세 번째 \u201c그건 좀\u201d 앞에서 — 지금 마음에 가장 가까운 건?", | |
| "choices": [ | |
| {"label": "답답함", "desc": "그럼 처음부터 고르지 그랬어.", | |
| "card_line": "\u2018아무거나\u2019의 뜻을 세 번째 퇴짜에서야 알았다 — 답답했다.", | |
| "close": "\u2018아무거나\u2019는 세상에서 가장 어려운 주문이다."}, | |
| {"label": "웃긴 억울함", "desc": "고른 건 난데 왜 내가 혼나는 기분이지.", | |
| "card_line": "고른 건 나, 퇴짜도 나 — 웃긴데 억울했다.", | |
| "close": "억울함에도 웃음이 섞이면, 그건 아직 애정이 남았다는 뜻."}, | |
| {"label": "포기", "desc": "그냥 네가 골라. 나는 따라갈게.", | |
| "card_line": "세 번째에서 조용히 결심했다 — 오늘은 그냥 따라간다.", | |
| "close": "포기가 아니라 휴전이다. 저녁은 먹어야 하니까."}, | |
| {"label": "승부욕", "desc": "반드시 통과할 메뉴를 찾고 만다.", | |
| "card_line": "오기가 생겼다 — 반드시 \u2018오 좋다\u2019를 듣고 만다.", | |
| "close": "사소한 승부욕은 저녁 식탁의 훌륭한 반찬이다."}, | |
| ], | |
| "scene": "\u2018아무거나\u2019와 세 번의 퇴짜 사이. 사소하지만, 분명히 마음이 움직인 순간.", | |
| }, | |
| ] | |
| LITE_BY_ID = {lt["id"]: lt for lt in LITE_STORIES} | |
| def lite_pick(lite_id, chat, state): | |
| """라이트 시작 — 3줄을 한 번에 깔고 바로 마음 선택으로(탭 최소화).""" | |
| lt = LITE_BY_ID.get(lite_id) | |
| if not lt: | |
| return chat, state | |
| ns = dict(state) | |
| ns["mode"] = "라이트" | |
| ns["story_id"] = lite_id # 로깅 재사용: story_id 필드에 라이트 id | |
| ns["story_emo_label"] = None | |
| ns["lite_step"] = "pick" | |
| ns["lite_choice"] = None; ns["lite_rated"] = False; ns["lite_text"] = None | |
| ns["story_convo"] = []; ns["story_user_turns"] = 0; ns["story_branch"] = None | |
| ns["topic_chosen"] = True | |
| if ns.get("session") is None: | |
| ns["session"] = _uuid.uuid4().hex[:12] | |
| chat = [{"role": "assistant", "content": f"⚡ **{lt['title']}** · {lt['tag']}\n\n*{lt['setup']}*"}] | |
| chat += [_story_msg(l) for l in lt["lines"]] | |
| chat += [{"role": "assistant", "content": f"**{lt['ask']}**"}] | |
| try: | |
| STORE.log_story_event(ns["session"], "lite_start", ns) | |
| except Exception as e: | |
| if DEBUG: print("lite start 저장 실패:", e) | |
| return chat, ns | |
| def lite_choose(idx, chat, state): | |
| """마음 탭 → 바로 카드(30초 완료 지점).""" | |
| ns = dict(state) | |
| lt = LITE_BY_ID.get(ns.get("story_id")) | |
| if not lt or idx is None or not (0 <= idx < len(lt["choices"])): | |
| return chat, ns | |
| c = lt["choices"][idx] | |
| ns["lite_choice"] = idx | |
| ns["story_emo_label"] = c["label"] | |
| chat = chat + [{"role": "user", "content": c["label"]}] | |
| if STORY_EXCHANGE_ON: # 0.9.10.4 — 탭 뒤, 상대와 최대 3마디 | |
| ns["lite_step"] = "exchange" | |
| ns["story_convo"] = []; ns["story_user_turns"] = 0; ns["story_branch"] = None | |
| chat = chat + [_story_msg(lt["invite"]), | |
| {"role": "assistant", "content": f"<sub>「{c['label']}」 — 그 마음을 담아, 이번엔 상대에게 당신의 말로.</sub>"}] | |
| else: | |
| ns["lite_step"] = "card" | |
| chat = chat + [{"role": "assistant", "content": "*🃏 마음 카드가 아래에 준비됐어요.*"}] | |
| try: | |
| STORE.log_story_event(ns["session"], "lite_done", ns, extra={"choice_idx": idx, "exchange": STORY_EXCHANGE_ON}) | |
| except Exception as e: | |
| if DEBUG: print("lite done 저장 실패:", e) | |
| return chat, ns | |
| def lite_rate(score, state): | |
| """평가(1~5 또는 None=건너뜀) → '더 해볼까요?' 단계로.""" | |
| ns = dict(state) | |
| ns["lite_rated"] = True | |
| try: | |
| STORE.log_feedback(ns["session"], score, None, ns) # mode=라이트·story_id 포함 저장 | |
| except Exception as e: | |
| if DEBUG: print("lite 평가 저장 실패:", e) | |
| return ns | |
| def lite_open_write(state): | |
| ns = dict(state); ns["lite_step"] = "write"; return ns | |
| def lite_back_card(state): | |
| ns = dict(state); ns["lite_step"] = "card"; return ns | |
| def lite_speak(text, chat, state): | |
| """(선택) 카드의 문을 통해 내 문장으로 — 실측·저장 후 카드 인용을 내 문장으로 교체.""" | |
| t = (text or "").strip() | |
| ns = dict(state) | |
| if len(t) < 5: | |
| chat = chat + [{"role": "assistant", "content": "*한 줄이면 충분해요 — 다섯 자 이상으로 적어주세요.*"}] | |
| return chat, ns, gr.update(), gr.update() | |
| m, _tp = ENGINE.measure_full(t) | |
| s = signal_strength(m) | |
| _rk = risk_probability(m, _tp) | |
| ns["risks"] = list(ns.get("risks", [])) + [round(_rk, 4) if _rk is not None else -1.0] # 관찰만(게이트 없음) — log_turn.risk로 유입 | |
| ns["turns"] = ns.get("turns", 0) + 1 | |
| ns["coords"] = list(ns.get("coords", [])) + [(m["EVA"], m["EAR"], s)] | |
| ns["lite_text"] = t | |
| ns["chosen_text"] = t | |
| ns["chosen_meta"] = {"text": t, | |
| "EVA": round(float(m["EVA"]), 4), "EAR": round(float(m["EAR"]), 4), | |
| "VAL": round(float(m["VAL"]), 4), "REI": round(float(m["REI"]), 4), | |
| "strength": round(float(s), 4), "turn": ns["turns"], "is_lite": True} | |
| try: | |
| STORE.log_turn(ns["session"], m, s, ns, utter_len=len(t), kind="lite_speak", text=t) | |
| except Exception as e: | |
| if DEBUG: print("lite 발화 저장 실패:", e) | |
| chat = chat + [{"role": "user", "content": t}] | |
| ns["lite_step"] = "card" | |
| plt.close("all") | |
| fig = render_compass(ns["coords"], None) | |
| cap = f"내 문장 실측 — 신호강도 **{s:.2f}**." | |
| return chat, ns, fig, cap | |
| def lite_reply(text, chat, state): | |
| """라이트 교환 — 탭으로 이름 붙인 마음을, 상대에게 내 말로(최대 _EXCHANGE_MAX마디). 매 마디 실측·위험도 관찰·로깅.""" | |
| t = (text or "").strip() | |
| ns = dict(state) | |
| lt = LITE_BY_ID.get(ns.get("story_id")) | |
| if not lt or ns.get("lite_step") != "exchange": | |
| return chat, ns, gr.update(), gr.update() | |
| if len(t) < 6: | |
| try: | |
| gr.Warning("한 마디만 — 6자 이상 담아주세요.") | |
| except Exception: | |
| chat = chat + [{"role": "assistant", "content": "*한 마디만 — 6자 이상 담아주세요.*"}] | |
| return chat, ns, gr.update(), gr.update() | |
| m, _tp = ENGINE.measure_full(t) | |
| s = signal_strength(m) | |
| _rk = risk_probability(m, _tp) | |
| ns["risks"] = list(ns.get("risks", [])) + [round(_rk, 4) if _rk is not None else -1.0] # 관찰만(게이트 없음) | |
| ns["turns"] = ns.get("turns", 0) + 1 | |
| ns["coords"] = list(ns.get("coords", [])) + [(m["EVA"], m["EAR"], s)] | |
| ns["story_convo"] = list(ns.get("story_convo", [])) + [{"who": "me", "text": t}] | |
| ns["story_user_turns"] = ns.get("story_user_turns", 0) + 1 | |
| if ns["story_user_turns"] == 1: # 첫 마디 = 카드 인용문(내 문장) | |
| ns["lite_text"] = t; ns["chosen_text"] = t | |
| ns["chosen_meta"] = {"text": t, "EVA": round(float(m["EVA"]), 4), "EAR": round(float(m["EAR"]), 4), | |
| "VAL": round(float(m["VAL"]), 4), "REI": round(float(m["REI"]), 4), | |
| "strength": round(float(s), 4), "turn": ns["turns"], "is_lite": True} | |
| try: | |
| STORE.log_turn(ns["session"], m, s, ns, utter_len=len(t), kind="lite_reply", text=t) | |
| except Exception as e: | |
| if DEBUG: print("lite 답장 저장 실패:", e) | |
| chat = chat + [{"role": "user", "content": t}] | |
| if any(w in t for w in _STORY_BLAME): # 어느 마디든 비난 → 침묵 비트 → 카드 | |
| ns["story_branch"] = "blame" | |
| for l in lt.get("blame_beats", [("them", lt.get("partner", "상대"), "……")]): | |
| chat = chat + [_story_msg(l)] | |
| ns["lite_step"] = "card" | |
| else: | |
| adapted = {"title": lt["title"], "setup": lt["setup"], "ask": lt["ask"], | |
| "me": "나", "partner": lt.get("partner", "상대")} | |
| line = story_partner_line(adapted, ns["story_convo"], m, s) | |
| try: | |
| STORE.log_story_event(ns["session"], "exchange_gen", ns, | |
| extra={"turn": ns["story_user_turns"], "ok": bool(line), "lite": True}) | |
| except Exception as e: | |
| if DEBUG: print("lite 생성 저장 실패:", e) | |
| if line: | |
| chat = chat + [_story_msg(("them", adapted["partner"], f"{line} <sub>✦ 생성</sub>"))] | |
| ns["story_convo"] = ns["story_convo"] + [{"who": "them", "text": line}] | |
| if ns["story_user_turns"] >= _EXCHANGE_MAX: | |
| chat = chat + [_story_msg(lt["close_n"])] | |
| ns["lite_step"] = "card" | |
| else: | |
| chat = chat + [_story_msg(lt.get("close_flat", lt["close_n"]))] # 생성 실패 → 담백한 마무리 | |
| ns["lite_step"] = "card" | |
| plt.close("all") | |
| fig = render_compass(ns["coords"], None) | |
| cap = f"신호강도 **{s:.2f}**" + (" — 대화가 이어집니다." if ns["lite_step"] == "exchange" else ".") | |
| return chat, ns, fig, cap | |
| def lite_finish(chat, state): | |
| """'카드로' — 교환을 접는다(감정 흐름의 출구는 잠그지 않는다 · 이탈도 데이터).""" | |
| ns = dict(state) | |
| lt = LITE_BY_ID.get(ns.get("story_id")) | |
| if lt and ns.get("lite_step") == "exchange": | |
| chat = chat + [_story_msg(lt.get("close_flat", lt["close_n"]))] # 조기 종료 → 담백한 마무리 | |
| ns["lite_step"] = "card" | |
| try: | |
| STORE.log_story_event(ns["session"], "exchange_stop", ns, | |
| extra={"lite": True, "turns": ns.get("story_user_turns", 0)}) | |
| except Exception as e: | |
| if DEBUG: print("lite 종료 저장 실패:", e) | |
| return chat, ns | |
| def lite_go_main(chat, state): | |
| """'더 해볼까요? → 네' — 본 앱(모드 선택)으로. 세션은 이어서(다리).""" | |
| ns = dict(state) | |
| try: | |
| STORE.log_story_event(ns["session"], "lite_to_main", ns) | |
| except Exception as e: | |
| if DEBUG: print("lite→main 저장 실패:", e) | |
| ns["lite_step"] = None; ns["lite_choice"] = None; ns["lite_text"] = None | |
| ns["story_convo"] = []; ns["story_user_turns"] = 0; ns["story_branch"] = None | |
| ns["story_id"] = None; ns["story_emo_label"] = None | |
| ns["mode"] = "감정"; ns["topic_chosen"] = False; ns["emo_pick"] = [] # 0.9.10.5 — 모드 선택 건너뛰고 감정 선택 직행(마찰 제거) | |
| ns["lite_seen"] = True | |
| chat = [{"role": "assistant", "content": "가벼운 이야기는 여기까지 — 이제 **당신의 마음**입니다.\n\n" | |
| "지금 마음에 가까운 감정을 아래에서 골라 시작해요. 딱 맞는 게 없으면 바로 '시작하기'도 좋아요."}] | |
| return chat, ns | |
| def lite_more(chat, state): | |
| """가벼운 이야기 하나 더 — 입구로.""" | |
| ns = dict(state) | |
| ns["lite_step"] = None; ns["lite_choice"] = None; ns["lite_text"] = None | |
| ns["story_convo"] = []; ns["story_user_turns"] = 0; ns["story_branch"] = None | |
| ns["lite_rated"] = False | |
| ns["story_id"] = None; ns["story_emo_label"] = None | |
| ns["mode"] = None; ns["topic_chosen"] = False | |
| ns["lite_seen"] = False # 입구 목록 다시 표시 | |
| chat = [{"role": "assistant", "content": "⚡ 하나 더 — 아래에서 골라주세요."}] | |
| return chat, ns | |
| def build_app(): | |
| with gr.Blocks(title="표현 코칭 (최소 흐름)") as demo: | |
| gr.Markdown("## 표현 코칭 — 마음을 정리하고 표현 다듬기\n*하고 싶은 말을 함께 찾아가는 곳*") | |
| state = gr.State(new_state()) | |
| outs_holder = {} # 핸들러 출력 리스트를 런타임에 담아 @gr.render가 참조 | |
| with gr.Row(): | |
| with gr.Column(scale=3): | |
| _cb_kwargs = dict(value=[{"role": "assistant", "content": OPENING}], | |
| height=440, show_label=False) | |
| try: | |
| if int(gr.__version__.split(".")[0]) < 6: | |
| _cb_kwargs["type"] = "messages" | |
| except Exception: | |
| pass | |
| chatbot = gr.Chatbot(**_cb_kwargs) | |
| # 시작: 감정 주제 다중선택 (@gr.render — topic_chosen 전까지 표시) | |
| # 토글 버튼(선택 시 강조) + '시작하기'. inputs=state로 로드 시 렌더 + 선택(state 변경) 시 재렌더. | |
| def render_topics(s): | |
| if s and s.get("topic_chosen"): | |
| return # 이미 시작했으면 숨김 | |
| _outs = outs_holder.get("outs") | |
| mode = s.get("mode") if s else None | |
| # 0) 모드 선택 | |
| if not mode: | |
| # 0.9.9.2 입구 — 30초 라이트(가벼움·공감대). 통과/건너뛰기 후 모드 선택 표시. | |
| if not s.get("lite_seen"): | |
| gr.Markdown("**⚡ 30초, 어제 그 순간** — 읽고, 마음 하나만 골라보세요. 그게 전부예요.") | |
| for _lt in LITE_STORIES: | |
| gr.Markdown(f"**{_lt['title']}** · {_lt['tag']}<br><sub>{_lt['blurb']}</sub>") | |
| gr.Button(f"⚡ 「{_lt['title']}」 30초 →", size="sm", variant="primary").click( | |
| lambda ch, st, _i=_lt["id"]: lite_pick(_i, ch, st), | |
| [chatbot, state], [chatbot, state]) | |
| gr.Button("건너뛰고 바로 시작 →", size="sm").click( | |
| lambda st: dict(st, lite_seen=True), [state], [state]) | |
| return | |
| gr.Markdown("**어떻게 시작할까요?**") | |
| b1 = gr.Button("💬 감정으로 바로 시작 (기존)", variant="primary") | |
| b1.click(lambda st: set_mode("감정", st), [state], [state]) | |
| b2 = gr.Button("📄 상황 예시로 시작 (시나리오)", variant="secondary") | |
| b2.click(lambda st: set_mode("시나리오", st), [state], [state]) | |
| b3 = gr.Button("🎬 이야기로 시작 — 대본 속 인물로 말해보기", variant="secondary") | |
| b3.click(lambda st: set_mode("이야기", st), [state], [state]) | |
| gr.Markdown("<sub>감정 = 바로 내 마음 적기 · 시나리오 = 비슷한 상황 골라 진입 · 이야기 = 대본을 보다가 중요한 순간에 내가 말하기</sub>") | |
| return | |
| # A) 감정 기반 — 기존 흐름 그대로 | |
| if mode == "감정": | |
| pick = (s.get("emo_pick", []) if s else []) | |
| gr.Markdown("**지금 내 마음에 가까운 걸 모두 골라주세요** (여러 개 선택 가능, 최대 3개)") | |
| for key in EMOTION_ORDER: | |
| em = EMOTIONS[key]; selected = key in pick | |
| label = ("✓ " + em["label"]) if selected else em["label"] | |
| b = gr.Button(label, size="sm", | |
| variant=("primary" if selected else "secondary")) | |
| b.click(lambda st, _k=key: toggle_emotion(_k, st), [state], [state]) | |
| if pick: | |
| names = ", ".join(EMOTIONS[k]["label"].split("·")[0].strip() for k in pick) | |
| gr.Markdown(f"선택: **{names}**") | |
| start = gr.Button("시작하기 →", variant="primary") | |
| start.click(start_with_emotions, [chatbot, state], _outs) | |
| gr.Markdown("<sub>딱 맞는 게 없으면 — 아무것도 고르지 않고 바로 '시작하기'를 눌러도 돼요.</sub>") | |
| gr.Button("← 처음으로", size="sm").click( | |
| lambda st: scenario_back("mode", st), [state], [state]) | |
| return | |
| # C) 이야기 기반 — 대본 속 인물로 말해보기 (0.9.9) | |
| if mode == "이야기": | |
| gr.Markdown("**어떤 이야기로 들어가볼까요?**\n<sub>한 줄씩 넘기며 보다가, 가장 중요한 순간에 당신이 그 인물의 마음을 말합니다.</sub>") | |
| for _st in STORIES: | |
| gr.Markdown(f"**{_st['title']}** · {_st['tag']}<br><sub>{_st['blurb']}</sub>") | |
| gr.Button(f"🎬 「{_st['title']}」 시작 →", size="sm", variant="primary").click( | |
| lambda ch, st, _i=_st["id"]: story_pick(_i, ch, st), | |
| [chatbot, state], [chatbot, state]) | |
| gr.Button("← 처음으로", size="sm").click( | |
| lambda st: scenario_back("mode", st), [state], [state]) | |
| return | |
| # B) 시나리오 기반 — 관계 → 감정 → 시나리오 예시 | |
| rel = s.get("scenario_rel") | |
| if not rel: | |
| gr.Markdown("**누구와의 일인가요?**") | |
| for rk in ["연인", "부부"]: | |
| gr.Button(REL_LABEL[rk], size="sm").click( | |
| lambda st, _r=rk: set_scenario_rel(_r, st), [state], [state]) | |
| # 사춘기 자녀 섹션: 잠금 해제 시 자녀/부모 선택, 아니면 비밀번호 | |
| if s.get("teen_unlocked"): | |
| gr.Markdown("**🧒 사춘기 자녀 — 나는 누구인가요?**") | |
| for rk in ["자녀", "부모"]: | |
| gr.Button(REL_LABEL[rk], size="sm").click( | |
| lambda st, _r=rk: set_scenario_rel(_r, st), [state], [state]) | |
| else: | |
| gr.Markdown("<sub>🔒 '사춘기 자녀' 섹션(자녀·부모)은 허가(비밀번호)가 필요해요.</sub>") | |
| pw = gr.Textbox(show_label=False, placeholder="사춘기 자녀 섹션 비밀번호", type="password") | |
| gr.Button("🔓 열기", size="sm").click(try_teen_pw, [pw, state], [state]) | |
| gr.Button("← 처음으로", size="sm").click( | |
| lambda st: scenario_back("mode", st), [state], [state]) | |
| return | |
| # 부부: 남편/아내 → (아내: 남편/시댁) → 강도 사다리(1~10) | |
| if rel == "부부": # 1차: 누구의 마음인가 | |
| gr.Markdown("**💍 부부 — 누구의 마음인가요?**") | |
| gr.Button("🤵 남편 (내가 남편)", size="sm").click( | |
| lambda st: set_scenario_rel("부부_남편", st), [state], [state]) | |
| gr.Button("👰 아내 (내가 아내)", size="sm").click( | |
| lambda st: set_scenario_rel("부부_아내", st), [state], [state]) | |
| gr.Button("← 처음으로", size="sm").click( | |
| lambda st: scenario_back("rel", st), [state], [state]) | |
| return | |
| if rel == "부부_아내": # 2차(아내): 누구에게 전하나 | |
| gr.Markdown("**👰 아내 — 누구에게 전하고 싶나요?**") | |
| gr.Button("🤵 남편에게", size="sm").click( | |
| lambda st: set_scenario_rel("부부_아내_남편", st), [state], [state]) | |
| gr.Button("🏠 시댁에", size="sm").click( | |
| lambda st: set_scenario_rel("부부_아내_시댁", st), [state], [state]) | |
| gr.Button("← 뒤로", size="sm").click( | |
| lambda st: set_scenario_rel("부부", st), [state], [state]) | |
| return | |
| if rel in LADDERS: # 사다리 표시 | |
| _ladder, _title, _back = LADDERS[rel] | |
| gr.Markdown(_title) | |
| for _i, _sc in enumerate(_ladder, 1): | |
| gr.Button(f"{_i}. {_sc['title']}", size="sm").click( | |
| lambda ch, st, _s=_sc["sit"], _e=_sc["emo"]: start_with_scenario(_s, _e, ch, st), | |
| [chatbot, state], _outs) | |
| gr.Button("✏️ 내 상황은 달라요 — 바로 적을래요", size="sm", variant="primary").click( | |
| lambda ch, st: start_with_scenario(None, None, ch, st), [chatbot, state], _outs) | |
| gr.Button("← 뒤로", size="sm").click( | |
| lambda st, _b=_back: set_scenario_rel(_b, st), [state], [state]) | |
| return | |
| rel_map = SCENARIOS.get(rel, {}) | |
| emo = s.get("scenario_emo") | |
| if not emo: | |
| gr.Markdown(f"**{REL_LABEL[rel]} — 어떤 마음인가요?**") | |
| for key in EMOTION_ORDER: | |
| if key not in rel_map: | |
| continue | |
| gr.Button(EMOTIONS[key]["label"], size="sm").click( | |
| lambda st, _k=key: set_scenario_emo(_k, st), [state], [state]) | |
| gr.Button("← 관계 다시 고르기", size="sm").click( | |
| lambda st: scenario_back("rel", st), [state], [state]) | |
| return | |
| ename = EMOTIONS[emo]["label"].split("·")[0].strip() | |
| gr.Markdown(f"**{ename} — 비슷한 상황이 있나요?** (예시일 뿐이에요, 골라도 자유롭게 벗어나도 돼요)") | |
| for sc in rel_map.get(emo, []): | |
| gr.Button(f"📄 {sc['title']}", size="sm").click( | |
| lambda ch, st, _s=sc["sit"], _e=emo: start_with_scenario(_s, _e, ch, st), | |
| [chatbot, state], _outs) | |
| gr.Button("✏️ 내 상황은 달라요 — 바로 적을래요", size="sm", variant="primary").click( | |
| lambda ch, st, _e=emo: start_with_scenario(None, _e, ch, st), | |
| [chatbot, state], _outs) | |
| gr.Button("← 감정 다시 고르기", size="sm").click( | |
| lambda st: scenario_back("emo", st), [state], [state]) | |
| # 비계 선택지(막막할 때) | |
| with gr.Row(visible=False) as choice_row: | |
| c1 = gr.Button(SCAFFOLD_CHOICES[0], size="sm") | |
| c2 = gr.Button(SCAFFOLD_CHOICES[1], size="sm") | |
| c3 = gr.Button(SCAFFOLD_CHOICES[2], size="sm") | |
| # 길2: 완성 시점 — 표현 후보 선택 (@gr.render 동적 생성, 이 위치에 그려짐) | |
| # 초기 visible=False 컴포넌트 업데이트 실패 문제를 회피: state를 보고 매번 새로 그림. | |
| def render_confirm(s): | |
| if not s or not s.get("show_confirm"): | |
| return | |
| cands = s.get("candidates", [])[-MAX_CAND:] | |
| if not cands: | |
| return | |
| if s.get("hard5_flag"): | |
| gr.Markdown("**여러 표현을 다듬어보셨어요. 가장 마음에 드는 표현을 골라주세요** ↓") | |
| else: | |
| gr.Markdown("**전하고 싶은 표현을 골라주세요** ↓") | |
| _outs = outs_holder.get("outs") | |
| # 힌트(LLM이 사용자 발화를 전달용으로 다듬은 예시) — 후보 위에, 구분해서 표시 | |
| hint = s.get("hint") | |
| if hint: | |
| gr.Markdown(f"<sub>💡 이렇게 말해볼 수도 있어요 (예시일 뿐이에요)</sub>") | |
| hb = gr.Button(f"💡 {hint}", size="sm", variant="primary") | |
| hb.click(lambda ch, st, _h=hint: choose_hint(_h, ch, st), | |
| [chatbot, state], _outs) | |
| gr.Markdown("<sub>— 또는 내가 한 말 중에서 고르기 —</sub>") | |
| for i, c in enumerate(cands): | |
| txt = c["text"] | |
| label = txt if len(txt) <= 40 else txt[:38] + "…" | |
| b = gr.Button(label, size="sm") | |
| b.click(lambda ch, st, _i=i: choose_candidate(_i, ch, st), | |
| [chatbot, state], _outs) | |
| mb = gr.Button("✏️ 더 다듬어볼래요", size="sm", variant="secondary") | |
| mb.click(choose_more, [chatbot, state], _outs) | |
| # 완성 후 후기(선택) — 표현 선택 직후 1회 표시. 도움 정도 1~5 + 주관식 + 건너뛰기. | |
| def render_feedback(s): | |
| if not s or not s.get("show_feedback") or s.get("feedback_done"): | |
| return | |
| _outs = outs_holder.get("outs") | |
| gr.Markdown("**이번 대화가 마음을 표현하는 데 도움이 됐나요?** <sub>(선택 — 안 하셔도 괜찮아요)</sub>") | |
| fb_comment = gr.Textbox(show_label=False, placeholder="한마디 남겨주셔도 좋아요 (선택)", lines=1) | |
| with gr.Row(): | |
| for _sc in [1, 2, 3, 4, 5]: | |
| gr.Button(str(_sc), size="sm").click( | |
| lambda cm, ch, st, _v=_sc: submit_feedback(_v, cm, ch, st), | |
| [fb_comment, chatbot, state], _outs) | |
| gr.Markdown("<sub>1 = 별로 · 5 = 많이 도움됨</sub>") | |
| gr.Button("건너뛰기", size="sm", variant="secondary").click( | |
| lambda ch, st: skip_feedback(ch, st), [chatbot, state], _outs) | |
| # 0.9.9.1 이야기 모드 — state 변경마다 재렌더(감정/세기 버튼이 화면을 즉시 진행시키도록 수정) | |
| def render_story(s): | |
| try: | |
| _render_story_inner(s) | |
| except Exception as _e: | |
| gr.Markdown(f"⚠️ 이야기 화면을 그리다 문제가 났어요: `{type(_e).__name__}: {_e}` — 이 문구를 캡처해 보내주세요.") | |
| def _render_story_inner(s): | |
| if not s or not s.get("story_id") or s.get("lite_step"): | |
| return # 라이트 흐름은 render_lite가 담당(story_id를 로깅용으로 공유하므로 여기서 제외) | |
| step = s.get("story_step") | |
| story = STORY_BY_ID.get(s.get("story_id")) | |
| if not story or not step: | |
| return | |
| if step == "play": | |
| remain = len(story["lines"]) - s.get("story_li", 0) | |
| _lbl = "다음 ▾" if remain > 1 else "▾ 마음 열기" | |
| gr.Button(_lbl, variant="primary").click( | |
| story_next, [chatbot, state], [chatbot, state]) | |
| return | |
| if step == "emo": | |
| gr.Markdown(f"**{story['ask']}**\n\n<sub>정답은 없어요 — 지금 이 인물에게 가장 가깝다고 느끼는 마음을 골라주세요.</sub>") | |
| for _i, _e in enumerate(story["emotions"]): | |
| gr.Button(f"{_e['label']} — {_e['desc']}", size="sm").click( | |
| lambda st, _k=_i: story_set_emo(_k, st), [state], [state]) | |
| return | |
| if step == "rung": | |
| _e = story["emotions"][s["story_emo"]] | |
| gr.Markdown(f"**「{_e['label']}」 — 얼마나 세게 말할까요?**\n\n<sub>같은 마음도 세기가 다릅니다. 참고 문장은 힌트일 뿐, 마지막 문장은 당신이 씁니다.</sub>") | |
| for _i, _nm in enumerate(STORY_RUNG_NAMES): | |
| gr.Button(f"{_nm} — {_e['ladder'][_i]}", size="sm").click( | |
| lambda st, _k=_i: story_set_rung(_k, st), [state], [state]) | |
| gr.Button("← 마음 다시 고르기", size="sm").click( | |
| story_back_emo, [state], [state]) | |
| return | |
| if step == "write": | |
| _e = story["emotions"][s["story_emo"]] | |
| _hint = _e["ladder"][s["story_rung"]] | |
| gr.Markdown("**이제, 당신의 문장으로.**\n\n<sub>힌트는 참고만 — 이 인물의 입으로 나갈 말은 당신이 완성합니다. 쓰는 동안 오른쪽 나침반에 내 문장의 실측 좌표가 나타나요(상대 반응 예측이 아니라 내 문장의 속성입니다).</sub>") | |
| gr.Markdown(f"<sub>💡 참고 문장 ({STORY_RUNG_NAMES[s['story_rung']]})</sub>\n\n*{_hint}*") | |
| stb = gr.Textbox(show_label=False, placeholder="여기에, 지금 이 마음을 한두 문장으로…", lines=2) | |
| stb.change(story_preview, [stb], [compass, compass_cap], show_progress="hidden") | |
| with gr.Row(): | |
| gr.Button("힌트 가져와 고치기", size="sm").click( | |
| lambda _h=_hint: _h, [], [stb]) | |
| gr.Button("이 마음으로 말하기", size="sm", variant="primary").click( | |
| story_speak, [stb, chatbot, state], [chatbot, state, compass, compass_cap]) | |
| gr.Button("← 세기 다시", size="sm").click( | |
| story_back_rung, [state], [state]) | |
| return | |
| if step == "exchange": | |
| _left = _EXCHANGE_MAX - s.get("story_user_turns", 1) | |
| gr.Markdown(f"<sub>상대의 말에 이어서 — 남은 말 **{_left}번**. 다 쓰지 않아도 '여기까지'로 접을 수 있어요. (✦ = AI 생성 · 절제 계약: 화해 완결·적대 금지)</sub>") | |
| rtb = gr.Textbox(show_label=False, placeholder="이어서, 내 말로…", lines=2) | |
| rtb.change(story_preview, [rtb], [compass, compass_cap], show_progress="hidden") | |
| with gr.Row(): | |
| gr.Button("말하기", size="sm", variant="primary").click( | |
| story_reply, [rtb, chatbot, state], [chatbot, state, compass, compass_cap]) | |
| gr.Button("여기까지", size="sm").click( | |
| story_finish, [chatbot, state], [chatbot, state]) | |
| return | |
| if step == "after": | |
| gr.Button("다음 ▾", variant="primary").click( | |
| story_after_next, [chatbot, state], [chatbot, state]) | |
| return | |
| if step == "done": | |
| gr.Button("완료 — 마음 카드 보기", variant="primary").click( | |
| story_show_card, [chatbot, state], [chatbot, state]) | |
| return | |
| if step == "card": | |
| _e = story["emotions"][s["story_emo"]] if s.get("story_emo") is not None else None | |
| _close = CLOSE_BLAME if s.get("story_branch") == "blame" else (_e["close"] if _e else "") | |
| card = (f"### 🃏 마음 카드\n\n{story['scene']}\n\n" | |
| f"> **\u201c{s.get('story_text', '')}\u201d**\n\n" | |
| f"*{_close}*\n\n" | |
| f"<sub>이건 이야기 속 인물의 순간이었어요. **당신에게도 비슷한 순간이 있었나요?** " | |
| f"있었다면 — 아래 '이제 내 마음 정리하기'로 이어가요. 이번엔 대본이 아니라 당신의 이야기입니다.</sub>") | |
| gr.Markdown(card) | |
| gr.Button("💭 이제 내 마음 정리하기 →", variant="primary").click( | |
| story_to_emotions, [chatbot, state], [chatbot, state]) | |
| with gr.Row(): | |
| _retry_lbl = "같은 장면, 내 마음으로 다시" if s.get("story_branch") == "blame" else "같은 장면, 다른 마음으로" | |
| gr.Button(_retry_lbl, size="sm").click( | |
| story_retry, [chatbot, state], [chatbot, state]) | |
| gr.Button("🎬 다른 이야기", size="sm").click( | |
| story_to_stories, [chatbot, state], [chatbot, state]) | |
| gr.Button("처음으로", size="sm").click( | |
| story_home, [chatbot, state], [chatbot, state]) | |
| return | |
| # 0.9.9.2 라이트 입구 — 30초 흐름(@gr.render) | |
| def render_lite(s): | |
| try: | |
| _render_lite_inner(s) | |
| except Exception as _e: | |
| gr.Markdown(f"⚠️ 라이트 화면 오류: `{type(_e).__name__}: {_e}` — 이 문구를 캡처해 보내주세요.") | |
| def _render_lite_inner(s): | |
| if not s or not s.get("lite_step"): | |
| return | |
| lt = LITE_BY_ID.get(s.get("story_id")) | |
| if not lt: | |
| return | |
| step = s["lite_step"] | |
| if step == "pick": | |
| gr.Markdown("<sub>정답은 없어요 — 지금 마음에 가장 가까운 걸 하나만.</sub>") | |
| for _i, _c in enumerate(lt["choices"]): | |
| gr.Button(f"{_c['label']} — {_c['desc']}", size="sm").click( | |
| lambda ch, st, _k=_i: lite_choose(_k, ch, st), | |
| [chatbot, state], [chatbot, state]) | |
| return | |
| if step == "exchange": | |
| _c = lt["choices"][s["lite_choice"]] if s.get("lite_choice") is not None else None | |
| _left = _EXCHANGE_MAX - s.get("story_user_turns", 0) | |
| gr.Markdown(f"<sub>「{_c['label'] if _c else ''}」 그 마음, 상대에게 — 남은 말 **{_left}마디**. (✦ = AI 생성 · 절제 계약)</sub>") | |
| etb = gr.Textbox(show_label=False, placeholder="이번엔, 내 말로…", lines=2) | |
| etb.change(story_preview, [etb], [compass, compass_cap], show_progress="hidden") | |
| with gr.Row(): | |
| gr.Button("말하기", size="sm", variant="primary").click( | |
| lite_reply, [etb, chatbot, state], [chatbot, state, compass, compass_cap]) | |
| gr.Button("카드로 →", size="sm").click( | |
| lite_finish, [chatbot, state], [chatbot, state]) | |
| return | |
| if step == "write": | |
| gr.Markdown("**이 마음, 내 문장으로.** <sub>한 줄이면 충분해요 — 쓰는 동안 오른쪽 나침반에 실측 좌표가 나타나요.</sub>") | |
| ltb = gr.Textbox(show_label=False, placeholder="그때의 마음을 한 줄로…", lines=2) | |
| ltb.change(story_preview, [ltb], [compass, compass_cap], show_progress="hidden") | |
| with gr.Row(): | |
| gr.Button("이 마음으로 남기기", size="sm", variant="primary").click( | |
| lite_speak, [ltb, chatbot, state], [chatbot, state, compass, compass_cap]) | |
| gr.Button("← 카드로", size="sm").click(lite_back_card, [state], [state]) | |
| return | |
| if step == "card": | |
| _c = lt["choices"][s["lite_choice"]] if s.get("lite_choice") is not None else None | |
| if not _c: | |
| return | |
| quote = s.get("lite_text") or _c["card_line"] | |
| _cl = CLOSE_BLAME if s.get("story_branch") == "blame" else _c["close"] | |
| gr.Markdown(f"### 🃏 마음 카드\n\n{lt['scene']}\n\n" | |
| f"**「{_c['label']}」** — \u201c{quote}\u201d\n\n" | |
| f"*{_cl}*\n\n" | |
| f"<sub>당신은 어느 쪽이었나요? — 친구에게 이 앱을 보내 물어봐도 좋아요.</sub>") | |
| if not s.get("lite_text"): | |
| gr.Button("✏️ 이 마음, 내 문장으로 남길래요 (선택)", size="sm").click( | |
| lite_open_write, [state], [state]) | |
| if not s.get("lite_rated"): | |
| _rate_q = "오늘 이 대화, 어땠어요?" if s.get("story_convo") else "오늘 이 30초, 어땠어요?" | |
| gr.Markdown(f"**{_rate_q}** <sub>1 = 글쎄 · 5 = 재밌었어요</sub>") | |
| with gr.Row(): | |
| for _v in [1, 2, 3, 4, 5]: | |
| gr.Button(str(_v), size="sm").click( | |
| lambda st, _x=_v: lite_rate(_x, st), [state], [state]) | |
| gr.Button("건너뛰기", size="sm", variant="secondary").click( | |
| lambda st: lite_rate(None, st), [state], [state]) | |
| return | |
| gr.Markdown("**마음결엔 \u2018내 진짜 상황\u2019을 정리하는 코칭이 있어요. 더 해볼까요?**") | |
| with gr.Row(): | |
| gr.Button("네, 내 마음도 정리해볼래요 →", size="sm", variant="primary").click( | |
| lite_go_main, [chatbot, state], [chatbot, state]) | |
| gr.Button("⚡ 가벼운 이야기 하나 더", size="sm").click( | |
| lite_more, [chatbot, state], [chatbot, state]) | |
| return | |
| with gr.Row(): | |
| msg = gr.Textbox(show_label=False, placeholder="마음을 편하게 적어보세요…", scale=8) | |
| send = gr.Button("보내기", variant="primary", scale=1, min_width=70) | |
| with gr.Column(scale=2): | |
| gr.Markdown("**🧭 감정 나침반** — 내 마음이 어디서 어디로 움직이는지") | |
| compass = gr.Plot(show_label=False) | |
| compass_cap = gr.Markdown("아직 좌표가 없어요. 마음을 적으면 나침반에 표시됩니다.") | |
| gr.Markdown("<sub>측정: KoSimCSE(감정·타이밍·위험 게이트) · 표현 선택: 사용자 · 응답: Gemini · 대화 내용과 측정값은 서비스 개선을 위해 저장됩니다.</sub>") | |
| # 출력: chatbot, state, msg, choice_row, compass, compass_cap (6개) | |
| outs = [chatbot, state, msg, choice_row, compass, compass_cap] | |
| outs_holder["outs"] = outs # @gr.render 내부 핸들러가 런타임에 참조 | |
| msg.submit(respond, [msg, chatbot, state], outs) | |
| send.click(respond, [msg, chatbot, state], outs) | |
| c1.click(pick_choice, [c1, chatbot, state], outs) | |
| c2.click(pick_choice, [c2, chatbot, state], outs) | |
| c3.click(pick_choice, [c3, chatbot, state], outs) | |
| return demo | |
| if __name__ == "__main__": | |
| build_app().launch() | |