# -*- 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"🔧 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}")
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아래로 갈수록 더 깊고 표현하기 어려운 마음이에요 (1=약함 → 10=강함)"
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"한 줄씩 넘기며 보다가 — 가장 중요한 순간에, 당신이 말합니다.")}]
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} ✦ 생성"))]
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} ✦ 생성"))]
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"「{c['label']}」 — 그 마음을 담아, 이번엔 상대에게 당신의 말로."}]
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} ✦ 생성"))]
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 변경) 시 재렌더.
@gr.render(inputs=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']}
{_lt['blurb']}")
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("감정 = 바로 내 마음 적기 · 시나리오 = 비슷한 상황 골라 진입 · 이야기 = 대본을 보다가 중요한 순간에 내가 말하기")
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("딱 맞는 게 없으면 — 아무것도 고르지 않고 바로 '시작하기'를 눌러도 돼요.")
gr.Button("← 처음으로", size="sm").click(
lambda st: scenario_back("mode", st), [state], [state])
return
# C) 이야기 기반 — 대본 속 인물로 말해보기 (0.9.9)
if mode == "이야기":
gr.Markdown("**어떤 이야기로 들어가볼까요?**\n한 줄씩 넘기며 보다가, 가장 중요한 순간에 당신이 그 인물의 마음을 말합니다.")
for _st in STORIES:
gr.Markdown(f"**{_st['title']}** · {_st['tag']}
{_st['blurb']}")
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("🔒 '사춘기 자녀' 섹션(자녀·부모)은 허가(비밀번호)가 필요해요.")
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를 보고 매번 새로 그림.
@gr.render(inputs=state, triggers=[chatbot.change])
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"💡 이렇게 말해볼 수도 있어요 (예시일 뿐이에요)")
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("— 또는 내가 한 말 중에서 고르기 —")
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 + 주관식 + 건너뛰기.
@gr.render(inputs=state, triggers=[chatbot.change])
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("**이번 대화가 마음을 표현하는 데 도움이 됐나요?** (선택 — 안 하셔도 괜찮아요)")
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("1 = 별로 · 5 = 많이 도움됨")
gr.Button("건너뛰기", size="sm", variant="secondary").click(
lambda ch, st: skip_feedback(ch, st), [chatbot, state], _outs)
# 0.9.9.1 이야기 모드 — state 변경마다 재렌더(감정/세기 버튼이 화면을 즉시 진행시키도록 수정)
@gr.render(inputs=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정답은 없어요 — 지금 이 인물에게 가장 가깝다고 느끼는 마음을 골라주세요.")
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같은 마음도 세기가 다릅니다. 참고 문장은 힌트일 뿐, 마지막 문장은 당신이 씁니다.")
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힌트는 참고만 — 이 인물의 입으로 나갈 말은 당신이 완성합니다. 쓰는 동안 오른쪽 나침반에 내 문장의 실측 좌표가 나타나요(상대 반응 예측이 아니라 내 문장의 속성입니다).")
gr.Markdown(f"💡 참고 문장 ({STORY_RUNG_NAMES[s['story_rung']]})\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"상대의 말에 이어서 — 남은 말 **{_left}번**. 다 쓰지 않아도 '여기까지'로 접을 수 있어요. (✦ = AI 생성 · 절제 계약: 화해 완결·적대 금지)")
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"이건 이야기 속 인물의 순간이었어요. **당신에게도 비슷한 순간이 있었나요?** "
f"있었다면 — 아래 '이제 내 마음 정리하기'로 이어가요. 이번엔 대본이 아니라 당신의 이야기입니다.")
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)
@gr.render(inputs=state)
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("정답은 없어요 — 지금 마음에 가장 가까운 걸 하나만.")
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"「{_c['label'] if _c else ''}」 그 마음, 상대에게 — 남은 말 **{_left}마디**. (✦ = AI 생성 · 절제 계약)")
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("**이 마음, 내 문장으로.** 한 줄이면 충분해요 — 쓰는 동안 오른쪽 나침반에 실측 좌표가 나타나요.")
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"당신은 어느 쪽이었나요? — 친구에게 이 앱을 보내 물어봐도 좋아요.")
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}** 1 = 글쎄 · 5 = 재밌었어요")
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("측정: KoSimCSE(감정·타이밍·위험 게이트) · 표현 선택: 사용자 · 응답: Gemini · 대화 내용과 측정값은 서비스 개선을 위해 저장됩니다.")
# 출력: 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()