Inhoon commited on
Commit
0287e46
·
verified ·
1 Parent(s): ed30b26

Upload 2 files

Browse files
Files changed (2) hide show
  1. app_coach.py +12 -5
  2. app_coach_v0_9_2_hint.py +1353 -0
app_coach.py CHANGED
@@ -29,7 +29,7 @@ import gradio as gr
29
  # - 변화율(rate-of-change)을 감지하며 (절대값보다 EVA 추세·증감을 봄)
30
  # - 차근차근 표현하게 (단계적으로, 부담 덜며 구체화)
31
  # → 완성 기준도 'EVA 상승'이 아닌 '표현 구체화/신호 안정'으로 새로 정의해야. 데이터 0건이므로 만든 뒤 검증 필수.
32
- VERSION = "coach-0.9.2-hint" # 0.9.1 + 완성 후보 개선: (1) 코치에게 질문(조언요청 "나는 어떻게 해야해?") 후보에서 제외(is_coach_question) (2) 완성 LLM 힌트 1 생성(make_hint — 사용발화 마음 잘 담긴 걸 전달용으보수적 재구성, 내용추가·조언 금) (3) 힌트를 후보 위에 "예시"로 표시+선택가능(choose_hint, 원문 후보 병행=A안). 힌트 실패시 기존 (graceful). DEBUG off·다중선택·CON/FUL 터기록 유지.
33
  DEBUG = False # True면 측정값/상태를 나침반 캡션 아래 표시(임계값 조정용). 평소 False(사용자 화면에 수치 노출 안 함).
34
  ENCODER = "BM-K/KoSimCSE-roberta-multitask"
35
  USE_SENTENCE_TRANSFORMERS = ENCODER.startswith("BAAI")
@@ -536,12 +536,18 @@ import matplotlib
536
  matplotlib.use("Agg")
537
  import matplotlib.pyplot as plt
538
 
 
 
 
 
539
  def render_compass(coords, best_idx=None):
540
  """EVA(x: 부정↔긍정) × EAR(y: 차분↔격앙) 평면에 발화 궤적을 그린다.
541
- coords: [(eva, ear, strength), ...] 순서. best_idx: best 표현 순번(별표).
 
542
  한글 폰트가 없는 환경(HF Space) 대비, 차트 내 텍스트는 영어/기호만 사용."""
543
  fig, ax = plt.subplots(figsize=(4.2, 4.2), dpi=100)
544
- lim = 2.2
 
545
  # 사분면 배경/안내(영어로 — 폰트 안전)
546
  ax.axhline(0, color="#bbb", lw=0.8, zorder=1)
547
  ax.axvline(0, color="#bbb", lw=0.8, zorder=1)
@@ -555,8 +561,9 @@ def render_compass(coords, best_idx=None):
555
  ax.text(lim*0.42, -lim*0.93, "CALM", fontsize=8, color="#27ae60", alpha=0.7, weight="bold")
556
 
557
  if coords:
558
- xs = [max(-lim, min(lim, c[0])) for c in coords]
559
- ys = [max(-lim, min(lim, c[1])) for c in coords]
 
560
  n = len(coords)
561
  # ── B-2: 추세선(EMA 평활) — 측정 원본은 점으로 정직하게, 흐름은 부드러운 선으로 ──
562
  # 긍정 흐름 중 "미안/짜증" 한 발화로 튀어도, 추세선은 이전 긍정을 반영해 완만하게 보상.
 
29
  # - 변화율(rate-of-change)을 감지하며 (절대값보다 EVA 추세·증감을 봄)
30
  # - 차근차근 표현하게 (단계적으로, 부담 덜며 구체화)
31
  # → 완성 기준도 'EVA 상승'이 아닌 '표현 구체화/신호 안정'으로 새로 정의해야. 데이터 0건이므로 만든 뒤 검증 필수.
32
+ VERSION = "coach-0.9.3-compass-norm" # 0.9.2 + 나침반 스케일 정규화: EVA·EAR을 분포(95분위 0.73/0.63, 241발화 근거) 나눠 ±1 기준 . 화면 lim 2.2→1.15로 점이 가장리까지 크게 움직여 다이나믹. 측정값/그는 원본 지(표시 정규화). 2축(EVA·EAR) 유지=변화율 궤적(VAL/REI 단발 불안정이라 대신 2축 선택).
33
  DEBUG = False # True면 측정값/상태를 나침반 캡션 아래 표시(임계값 조정용). 평소 False(사용자 화면에 수치 노출 안 함).
34
  ENCODER = "BM-K/KoSimCSE-roberta-multitask"
35
  USE_SENTENCE_TRANSFORMERS = ENCODER.startswith("BAAI")
 
536
  matplotlib.use("Agg")
537
  import matplotlib.pyplot as plt
538
 
539
+ # 각 축 표시 스케일(95분위 |값|, 241발화 근거). 값을 이걸로 나눠 ±1 기준 정규화.
540
+ # → EVA·EAR이 화면 가장자리까지 크게 움직여 다이나믹하게 보인다. 배포 데이터로 재보정 가능.
541
+ COMPASS_SCALE = {"EVA": 0.73, "EAR": 0.63}
542
+
543
  def render_compass(coords, best_idx=None):
544
  """EVA(x: 부정↔긍정) × EAR(y: 차분↔격앙) 평면에 발화 궤적을 그린다.
545
+ 축을 자기 분포로 정규화(EVA/0.73, EAR/0.63)해면을 채운다.
546
+ coords: [(eva, ear, strength), ...] 원본 측정값. best_idx: best 표현 순번(별표).
547
  한글 폰트가 없는 환경(HF Space) 대비, 차트 내 텍스트는 영어/기호만 사용."""
548
  fig, ax = plt.subplots(figsize=(4.2, 4.2), dpi=100)
549
+ lim = 1.15 # 정규화 후 기준(±1)보다 살짝 여유. 95분위가 ±1 근처에 오도록.
550
+ sx, sy = COMPASS_SCALE["EVA"], COMPASS_SCALE["EAR"]
551
  # 사분면 배경/안내(영어로 — 폰트 안전)
552
  ax.axhline(0, color="#bbb", lw=0.8, zorder=1)
553
  ax.axvline(0, color="#bbb", lw=0.8, zorder=1)
 
561
  ax.text(lim*0.42, -lim*0.93, "CALM", fontsize=8, color="#27ae60", alpha=0.7, weight="bold")
562
 
563
  if coords:
564
+ # 정규화: 축을 자기 스케일로 나눠 ±1 기준. clip은 lim으로.
565
+ xs = [max(-lim, min(lim, c[0] / sx)) for c in coords]
566
+ ys = [max(-lim, min(lim, c[1] / sy)) for c in coords]
567
  n = len(coords)
568
  # ── B-2: 추세선(EMA 평활) — 측정 원본은 점으로 정직하게, 흐름은 부드러운 선으로 ──
569
  # 긍정 흐름 중 "미안/짜증" 한 발화로 튀어도, 추세선은 이전 긍정을 반영해 완만하게 보상.
app_coach_v0_9_2_hint.py ADDED
@@ -0,0 +1,1353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 표현 코칭 — 최소 흐름 프로토타입 (v0.1)
4
+ 시나리오: 서운함 전하기 (B·여성 고정)
5
+ 흐름: 적기 → 측정 분기(막막/또렷) → 비계(선택지) → 3단계(자기 문장 다듬기) → 완성
6
+
7
+ 측정: KoSimCSE(검증된 엔진) — 신호 강도(max|coord|) 게이트로 막막/또렷 분기
8
+ 응답: Gemini (좌표 요약을 시스템 프롬프트에 + 최근 몇 턴만 — 비용 절제)
9
+
10
+ 목적: '적기→측정분기→3단계'가 작동하는지, '거울+산파'로 느껴지는지 검증.
11
+ 뺀 것(의도적): 맥락 3선택, 데이터 저장, 나침반, 4시나리오 — 검증 후 추가.
12
+
13
+ 실행: Colab/로컬에서 GEMINI_API_KEY 환경변수 또는 Colab Secrets 설정 후 python app_coach.py
14
+ (HF Space면 Space Secret에 GEMINI_API_KEY)
15
+ """
16
+ import os
17
+ import re
18
+ import json
19
+ import time
20
+ from contextlib import nullcontext
21
+ import numpy as np
22
+ from numpy.linalg import norm
23
+ import gradio as gr
24
+
25
+ # ── [향후: 긍정 트랙 설계 메모] ──────────────────────────────────
26
+ # 현재는 '부정 감정 표현' 트랙만 (서운/속상/화남 → 다듬어 전하기). 완성=EVA 상승(격한 감정 정리).
27
+ # 긍정 트랙(호감/고마움/기쁨)은 별도 설계 필요. 핵심 원칙(사용자 지침):
28
+ # - 세기가 너무 세지지 않게 (긍정 감정이 과하게 분출되지 않도록 조절)
29
+ # - 변화율(rate-of-change)을 감지하며 (절대값보다 EVA 추세·증감을 봄)
30
+ # - 차근차근 표현하게 (단계적으로, 부담 덜며 구체화)
31
+ # → 완성 기준도 'EVA 상승'이 아닌 '표현 구체화/신호 안정'으로 새로 정의해야. 데이터 0건이므로 만든 뒤 검증 필수.
32
+ VERSION = "coach-0.9.2-hint" # 0.9.1 + 완성 후보 개선: (1) 코치에게 한 질문(조언요청 "나는 어떻게 해야해?") 후보에서 제외(is_coach_question) (2) 완성 시점 LLM 힌트 1개 생성(make_hint — 사용자 발화 중 마음 잘 담긴 걸 전달용으로 보수적 재구성, 내용추가·조언 금지) (3) 힌트를 후보 위에 "예시"로 표시+선택가능(choose_hint, 원문 후보 병행=A안). 힌트 실패시 기존 후보만(graceful). DEBUG off·다중선택·CON/FUL 데이터기록 유지.
33
+ DEBUG = False # True면 측정값/상태를 나침반 캡션 아래 표시(임계값 조정용). 평소 False(사용자 화면에 수치 노출 안 함).
34
+ ENCODER = "BM-K/KoSimCSE-roberta-multitask"
35
+ USE_SENTENCE_TRANSFORMERS = ENCODER.startswith("BAAI")
36
+
37
+ # HF Dataset 저장 설정 — Space Secret에 HF_TOKEN(쓰기), HF_DATASET_REPO("아이디/coach-data") 설정 시 영구 저장
38
+ HF_DATASET_REPO = os.environ.get("HF_DATASET_REPO", "")
39
+ DATA_DIR = os.environ.get("COACH_DATA_DIR", "/tmp/coach_data")
40
+
41
+ # 신호 강도 게이트 — 검증값(의미분명 1.25 vs 단편 0.37). 이 값으로 막막/또렷 분기.
42
+ GATE = 0.35
43
+ # 또렷 판정: max|coord|가 이 이상이면 '이미 표현됨' → 비계 건너뛰고 3단계
44
+ CLEAR_THRESHOLD = 0.7
45
+
46
+ GEMINI_PRICES = {"gemini-2.5-flash": (0.30, 2.50), "gemini-2.5-flash-lite": (0.10, 0.40)}
47
+
48
+ # ============================== 축 정의 (검증된 CONSTRUCTS) ==============================
49
+ CONSTRUCTS = {
50
+ "VAL": {"label": ("자율 지향", "순응 지향"), "pos": [
51
+ "나는 내 삶을 어떻게 살지 스스로 결정하는 것을 좋아한다.",
52
+ "새롭고 독창적인 생각을 떠올리는 것이 나에게 중요하다.",
53
+ "나는 무엇이든 내 방식대로 해결하는 편이다.",
54
+ "내 목표를 스스로 자유롭게 선택하는 것이 중요하다.",
55
+ "남에게 묻기보다 내 판단을 믿고 따르는 편이다.",
56
+ "나는 호기심을 따라 새로운 것을 탐험하는 것을 가치 있게 여긴다.",
57
+ "누가 알려주기보다 세상을 스스로 이해하고 싶다.",
58
+ "나는 독립적으로 일을 해내는 것에 자부심을 느낀다.",
59
+ "남을 따라 하기보다 내 방식을 새로 만드는 편이 좋다.",
60
+ "내 인생의 방향을 스스로 정하는 것이 무척 중요하다.",
61
+ "나는 창의적으로, 내 식대로 시도하는 것을 좋아한다.",
62
+ "나에게 무엇이 옳은지 스스로 판단할 수 있다고 믿는다.",
63
+ ], "neg": [
64
+ "나는 사람은 규칙을 잘 지켜야 한다고 생각한다.",
65
+ "시키는 일을 잘 따르는 것이 나에게 중요하다.",
66
+ "나는 예의 바르게 행동하고 잘못된 일을 하지 않으려 애쓴다.",
67
+ "물려받은 전통과 관습을 지키는 것이 나에게 중요하다.",
68
+ "나는 권위와 윗사람을 존중해야 한다고 믿는다.",
69
+ "전통을 존중하는 것을 중요하게 여긴다.",
70
+ "나는 튀기보다 집단에 어울리는 편을 택한다.",
71
+ "정해진 방식을 함부로 의심하지 않는 편이 낫다고 생각한다.",
72
+ "나에게 기대되는 바를 지키는 것이 옳게 느껴진다.",
73
+ "나는 순종적이고 믿음직한 사람이 되는 것을 가치 있게 여긴다.",
74
+ "사회 규범에는 이유가 있으니 지켜야 한다고 믿는다.",
75
+ "나에게는 새로움보다 안정과 질서를 지키는 것이 더 중요하다.",
76
+ # 선택-동사 순응 보강 — 진단 결과 '능동 선택형 순응'(남들 따라 고르기)이
77
+ # 자율로 오측정되던 문제 완화. 독립 데이터 검증 AUC 0.90→0.95.
78
+ "남들이 많이 사는 물건을 골라서 산다.",
79
+ "유행하는 쪽을 보고 그대로 선택한다.",
80
+ "다수가 정한 방향에 내 결정을 맞춘다.",
81
+ "주변에서 고르는 것을 보고 똑같이 고른다.",
82
+ "나도 남들 하는 대로 무난한 쪽을 택한다.",
83
+ "사람들이 좋다고 하는 것을 골라 산다.",
84
+ "내 취향보다 대세를 따라 고르는 편이다.",
85
+ "남들 눈을 의식해서 선택을 정한다.",
86
+ ]},
87
+ "REI": {"label": ("분석적", "직관적"), "pos": [
88
+ "나는 깊이 생각해야 하는 문제를 즐긴다.",
89
+ "행동하기 전에 상황을 논리적으로 분석하는 것을 좋아한다.",
90
+ "나는 단계를 밟아 차근차근 따져보는 편이다.",
91
+ "복잡한 문제를 깊이 고민하는 것이 만족스럽다.",
92
+ "나는 결론을 내릴 때 논리와 근거에 의존한다.",
93
+ "문제를 부분으로 나누어 분석하는 것을 좋아한다.",
94
+ "어려운 지적 과제를 풀어내는 것을 즐긴다.",
95
+ "결정할 때 감정보다 이성을 더 믿는다.",
96
+ "나는 결정 전에 장단점을 신중히 따져본다.",
97
+ "추상적이고 분석적인 사고가 즐겁다.",
98
+ "분명한 근거로 뒷받침된 결론을 선호한다.",
99
+ "무언가의 논리를 풀어내면 만족스럽다.",
100
+ "느낌이 좋았지만 가격을 비교하고 구매했다.",
101
+ "느낌보다 데이터를 우선해 전공을 정했다.",
102
+ "감으로 판단하지 않고 사실을 확인했다.",
103
+ "직감이 들어도 먼저 수치를 확인한다.",
104
+ "막연한 느낌을 숫자로 바꿔 확인했다.",
105
+ "직감이 나빴지만 안전 데이터를 검증했다.",
106
+ "감보다 근거를 보고 이직을 결정했다.",
107
+ "느낌에 기대지 않고 논문을 분석했다.",
108
+ "느낌이 와도 계약 조건을 따져본다.",
109
+ "직감을 가설로 세워 실험을 설계했다.",
110
+ ], "neg": [
111
+ "나는 보통 직감에 따라 행동한다.",
112
+ "나는 종종 무엇이 옳은지 그냥 느낌으로 안다.",
113
+ "분석보다 첫인상에 더 의존하는 편이다.",
114
+ "나는 많은 결정을 느낌에 따라 내린다.",
115
+ "설명할 수 없어도 내 직관을 믿는다.",
116
+ "내 예감은 대체로 들어맞는다.",
117
+ "나는 그 순간 옳게 느껴지는 대로 하는 편이다.",
118
+ "나는 상황을 본능으로 읽는다.",
119
+ "나는 결정을 느낌으로 더듬어 가는 편이 좋다.",
120
+ "나는 감정과 인상에 따라 선택하곤 한다.",
121
+ "따져보지 않아도 옳다는 걸 아는 경우가 많다.",
122
+ "나는 신중한 분석보다 직관에 더 의존한다.",
123
+ ]},
124
+ "EVA": {"label": ("긍정", "부정"), "pos": [
125
+ "지금 나는 즐겁고 기분이 좋다.", "따뜻한 만족감이 느껴진다.",
126
+ "오늘 나는 희망차고 밝다.", "나는 흐뭇하고 만족스럽다.",
127
+ "나는 고맙고 뿌듯하다.", "좋고 기분 좋은 느낌이 함께한다.",
128
+ "나는 행복하고 마음이 가볍다.", "나는 흡족하고 긍정적이다.",
129
+ "지금 내 안에 기쁨이 있다.", "나는 즐겁고 편안하다.",
130
+ "나는 감사하고 마음이 따뜻하다.", "밝은 안녕감이 느껴진다.",
131
+ ], "neg": [
132
+ "지금 나는 시무룩하고 우울하다.", "무거운 슬픔이 느껴진다.",
133
+ "오늘 나는 낙담하고 서럽다.", "나는 언짢고 불만스럽다.",
134
+ "나는 비참하고 의기소침하다.", "나쁘고 불쾌한 느낌이 함께한다.",
135
+ "나는 침울하고 풀이 죽었다.", "나는 속상하고 부정적이다.",
136
+ "지금 내 안에 슬픔이 있다.", "나는 슬프고 마음이 불편하다.",
137
+ "나는 씁쓸하고 마음이 차갑다.", "어두운 괴로움이 느껴진다.",
138
+ ]},
139
+ "EAR": {"label": ("고각성", "저각성"), "pos": [
140
+ "나는 활력이 넘치고 또렷이 깨어 있다.", "나는 들뜨고 잔뜩 흥분돼 있다.",
141
+ "내 몸이 활성화되고 긴장돼 있다.", "나는 격렬하고 잔뜩 달아올라 있다.",
142
+ "나는 안절부절못하며 에너지가 넘친다.", "심장이 빠르게 뛰는 것 같다.",
143
+ "나는 긴장되고 크게 각성돼 있다.", "나는 안달이 나고 자극받아 있다.",
144
+ "나는 한껏 충���돼 들떠 있다.", "나는 곤두서고 신경이 팽팽하다.",
145
+ "몸에 각성이 솟구치는 느낌이다.", "나는 또렷하고 활기차게 깨어 있다.",
146
+ ], "neg": [
147
+ "나는 차분하고 고요하다.", "나는 졸리고 느긋하다.",
148
+ "나는 조용하고 가라앉아 있다.", "나는 나른하고 멍하다.",
149
+ "내 에너지가 낮고 느리게 느껴진다.", "나는 누그러지고 서두르지 않는다.",
150
+ "나는 평온하고 쉬고 있다.", "나는 축 늘어지고 무겁다.",
151
+ "나는 잔잔하고 조용하다.", "나는 졸음이 오고 잦아든다.",
152
+ "깊은 고요함이 느껴진다.", "나는 느긋하고 반쯤 잠든 듯하다.",
153
+ ]},
154
+ # ── 외로움/공허 측정용 신규 축 (노트북 검증: 외로움 vs 중립 CON d=-3.70, 공허 vs 중립 FUL d=-4.76, 길이통제 통과) ──
155
+ # ※ 측정·기록만. signal_strength/완성판정에는 미반영(검증 전엔 동작 불변). 실사용 재검증 후 활용 예정.
156
+ "CON": {"label": ("연결됨", "고립됨"), "pos": [
157
+ "나는 사람들과 깊이 이어져 있다고 느낀다.",
158
+ "내 곁에는 마음을 나눌 사람들이 있다.",
159
+ "나는 누군가와 함께 있고 연결되어 있다.",
160
+ "내가 속한 사람들 사이에서 소속감을 느낀다.",
161
+ "나를 아끼고 곁에 있어 주는 사람들이 있다.",
162
+ "나는 다른 사람들과 가깝게 맺어져 있다.",
163
+ ], "neg": [
164
+ "나는 혼자 동떨어져 있다고 느낀다.",
165
+ "내 곁에는 마음을 나눌 사람이 아무도 없다.",
166
+ "나는 외롭고 누구와도 이어져 있지 않다.",
167
+ "어디에도 속하지 못하고 겉도는 느낌이다.",
168
+ "아무도 내 곁에 없는 것 같다.",
169
+ "나는 사람들에게서 멀리 떨어져 단절되어 있다.",
170
+ ]},
171
+ "FUL": {"label": ("충만함", "공허함"), "pos": [
172
+ "내 삶은 의미로 가득 차 있다.",
173
+ "나는 마음이 충만하고 채워져 있다.",
174
+ "하루하루가 의미 있고 보람차다.",
175
+ "내 안이 따뜻하게 채워진 느낌이다.",
176
+ "나는 살아가는 이유와 의미를 느낀다.",
177
+ "내 마음은 풍요롭고 가득하다.",
178
+ ], "neg": [
179
+ "내 마음은 텅 비어 있다.",
180
+ "나는 공허하고 아무것도 느껴지지 않는다.",
181
+ "하루하루가 의미 없이 비어 있다.",
182
+ "내 안이 허전하게 비어 있는 느낌이다.",
183
+ "나는 살아가는 의미를 찾지 못하겠다.",
184
+ "내 마음은 메마르고 텅 비었다.",
185
+ ]},
186
+ }
187
+
188
+ # ============================== 측정 엔진 (검증된 코드 그대로) ==============================
189
+ class MeasurementEngine:
190
+ def __init__(self, encoder_name=ENCODER):
191
+ self.encoder_name = encoder_name
192
+ self._load_encoder()
193
+ self.axes, self.refs = {}, {}
194
+ for c, d in CONSTRUCTS.items():
195
+ ep, en = self._embed(d["pos"]), self._embed(d["neg"])
196
+ v = ep.mean(0) - en.mean(0); v = v / (norm(v) + 1e-8)
197
+ self.axes[c] = v
198
+ ref = np.vstack([ep, en]) @ v
199
+ self.refs[c] = (ref.mean(), ref.std() + 1e-8)
200
+
201
+ def _load_encoder(self):
202
+ if USE_SENTENCE_TRANSFORMERS:
203
+ from sentence_transformers import SentenceTransformer
204
+ self._st = SentenceTransformer(self.encoder_name)
205
+ else:
206
+ from transformers import AutoModel, AutoTokenizer
207
+ import torch
208
+ self._torch = torch
209
+ self._tok = AutoTokenizer.from_pretrained(self.encoder_name)
210
+ self._mdl = AutoModel.from_pretrained(self.encoder_name).eval()
211
+
212
+ def _embed(self, sents):
213
+ if isinstance(sents, str): sents = [sents]
214
+ if USE_SENTENCE_TRANSFORMERS:
215
+ return np.asarray(self._st.encode(sents, normalize_embeddings=True), dtype=np.float64)
216
+ out = []
217
+ for i in range(0, len(sents), 16):
218
+ b = sents[i:i + 16]
219
+ inp = self._tok(b, padding=True, truncation=True, max_length=64, return_tensors="pt")
220
+ with self._torch.no_grad():
221
+ e = self._mdl(**inp).last_hidden_state[:, 0]
222
+ e = e / (e.norm(dim=1, keepdim=True) + 1e-8)
223
+ out.append(e.cpu().double().numpy())
224
+ return np.vstack(out)
225
+
226
+ def measure(self, text):
227
+ emb = self._embed(text)[0]
228
+ return {c: float((emb @ v - self.refs[c][0]) / self.refs[c][1]) for c, v in self.axes.items()}
229
+
230
+ def signal_strength(m):
231
+ """신호 강도 = 전체 축 최대 |coord|. 막막/또렷 분기의 기준."""
232
+ return max(abs(m[c]) for c in ("VAL", "REI", "EVA", "EAR"))
233
+
234
+
235
+ # ── VAL/REI 방향 3단계 분류 (실험적, A+B용) ──────────────────────────────
236
+ # 검증 결과: VAL/REI는 '방향'(자율/순응, 분석/직관)은 안정적으로 갈리나
237
+ # '강도'(매우/약간)는 KoSimCSE가 못 잡음 → 4/8단계 불가, 2~3단계만 유효.
238
+ # 따라서 방향 기반 3단계(양극 + 중립)로만 분류. 강도 표현 금지.
239
+ # 주의: 방향 분류는 동의어 변동에 안정적(범주정확도 VAL 100%/REI 92%)이나,
240
+ # 그 분류가 '실제 성향'과 맞는지는 외부검증 안 됨 → 참고용/실험적.
241
+ # EVA(KNU 0.75 검증)와 달리 강하게 의존하지 말 것.
242
+ VALREI_NEUTRAL_BAND = 0.3 # |값| < 0.3 이면 '뚜렷하지 않음'(중립). 검증에서 쓴 값.
243
+
244
+ def classify3(value, pos_label, neg_label):
245
+ """연속 측정값을 방향 3단계로. 강도는 표현하지 않음(방향만)."""
246
+ if value > VALREI_NEUTRAL_BAND:
247
+ return pos_label
248
+ elif value < -VALREI_NEUTRAL_BAND:
249
+ return neg_label
250
+ else:
251
+ return None # 뚜렷하지 않음 → 성향 언급 안 함
252
+
253
+ def tendency_of(m):
254
+ """측정 m에서 VAL/REI 방향 성향을 추출. (val_dir, rei_dir) 각각 라벨 또는 None."""
255
+ val_dir = classify3(m["VAL"], "자율", "순응")
256
+ rei_dir = classify3(m["REI"], "분석", "직관")
257
+ return val_dir, rei_dir
258
+
259
+
260
+ # ── 반어 의심 플래그 ──────────────────────────────────────────────────────
261
+ # 측정이 약한 '순수 반어'("내가 행복하겠어?" = 불행이나 EVA 양수로 측정됨)를
262
+ # 대화 흐름의 급반전으로 탐지. 자동 처리 안 함 — 플래그만 기록(데이터 수집용).
263
+ # 절대 원칙: EVA 측정값 불변. 완성 판정 등 기존 로직에 안 씀. 진짜 긍정 전환 보호.
264
+ IRONY_PATTERNS = [
265
+ r"겠어\s*\?", r"겠니\s*\?", r"겠나\s*\?", r"겠어요\s*\?", # 수사의문
266
+ r"리가\s*없", r"리\s*없", r"ㄹ\s*리가", r"을\s*리가", # ~리가 없
267
+ r"뭐\s*(좋|행복|즐겁|기쁘)", # 뭐 좋아
268
+ r"(좋|행복|즐거울|기쁠)\s*게\s*(뭐|있)", # 좋을 게 뭐
269
+ ]
270
+
271
+ def has_irony_cue(text):
272
+ """조건2: 반어 형태 단서가 있는가."""
273
+ for p in IRONY_PATTERNS:
274
+ if re.search(p, text):
275
+ return True
276
+ return False
277
+
278
+ def is_eva_reversal(cur_eva, eva_hist, n=3, neg_thresh=-0.2, jump_thresh=0.5):
279
+ """조건1: 직전 부정 흐름에서 현재가 크게 상승해 양수(급반전).
280
+ 이력이 적은 대화 초반에도 탐지되도록, 있는 만큼(최소 1턴)으로 직전 평균을 본다.
281
+ (기록만 — 측정값/완성판정 안 건드림. 반어는 초반에 자주 나오므로 초반 탐지가 중요.)"""
282
+ if len(eva_hist) < 1:
283
+ return False, None
284
+ win = min(len(eva_hist), n) # 있는 만큼(최대 n)
285
+ recent = eva_hist[-win:]
286
+ recent_mean = sum(recent) / len(recent)
287
+ jump = cur_eva - recent_mean
288
+ cond = (recent_mean < neg_thresh) and (jump > jump_thresh) and (cur_eva > 0)
289
+ return cond, {"recent_mean": round(recent_mean, 2), "jump": round(jump, 2), "win": win}
290
+
291
+ def irony_suspect(cur_text, cur_eva, eva_hist):
292
+ """반어 의심 종합 판정. 자동 처리 안 함 — 플래그만 반환.
293
+ level: 강(반어 의심) / 급반전(진짜 전환 가능, 안 건드림) / 형태만 / 없음"""
294
+ c1, c1d = is_eva_reversal(cur_eva, eva_hist)
295
+ c2 = has_irony_cue(cur_text)
296
+ if c1 and c2:
297
+ level = "강"
298
+ elif c1:
299
+ level = "급반전"
300
+ elif c2:
301
+ level = "형태만"
302
+ else:
303
+ level = "없음"
304
+ return {"level": level, "cond1_reversal": c1, "cond2_cue": c2, "detail": c1d}
305
+
306
+
307
+ print("측정 엔진 로딩 중...")
308
+ ENGINE = MeasurementEngine()
309
+ print("측정 엔진 준비 완료:", {k: round(v, 2) for k, v in ENGINE.measure("오늘 정말 행복해").items()})
310
+
311
+ # ============================== 데이터 저장 (측정값·변수만, 발화 원문 제외) ==============================
312
+ def _now():
313
+ return time.strftime("%Y-%m-%dT%H:%M:%S")
314
+
315
+ class CoachDataStore:
316
+ """대화별 측정값·변수·원문을 저장. HF Dataset 영구화(설정 시).
317
+ 원문 수집은 화면 하단 고지로 알림(별도 동의 체크 없음 — 가상 시나리오 + 고지 기반).
318
+ 주의: 일반 공개 시에는 명시적 동의 절차로 강화 필요."""
319
+ def __init__(self, data_dir=DATA_DIR, repo=HF_DATASET_REPO, version=VERSION, encoder=ENCODER):
320
+ self.dir = data_dir
321
+ os.makedirs(data_dir, exist_ok=True)
322
+ self.path = os.path.join(data_dir, "coach_turns.jsonl")
323
+ self.version, self.encoder = version, encoder
324
+ self.saved = 0
325
+ self.scheduler = None
326
+ if repo and os.environ.get("HF_TOKEN"):
327
+ try:
328
+ from huggingface_hub import CommitScheduler, hf_hub_download
329
+ from huggingface_hub.utils import EntryNotFoundError, RepositoryNotFoundError
330
+ # ★ 재시작 복구: CommitScheduler 시작 전에 HF에서 기존 파일을 먼저 받아 로컬에 복원.
331
+ # (이 단계가 없으면 빈 로컬 폴더가 HF로 동기화돼 기존 데이터를 덮어씀 — 데이터 손실 원인.)
332
+ safe_to_sync = True
333
+ if not os.path.exists(self.path) or os.path.getsize(self.path) == 0:
334
+ try:
335
+ cached = hf_hub_download(repo_id=repo, repo_type="dataset",
336
+ filename="data/coach_turns.jsonl",
337
+ token=os.environ.get("HF_TOKEN"))
338
+ import shutil
339
+ shutil.copyfile(cached, self.path)
340
+ n = sum(1 for _ in open(self.path, encoding="utf-8"))
341
+ print(f"HF Dataset 복구: 기존 {n}건 불러옴 → {self.path}")
342
+ except (EntryNotFoundError, RepositoryNotFoundError):
343
+ # HF에 파일/레포가 아직 없음 = 최초 실행. 빈 채로 시작해도 안전(덮어쓸 데이터 없음).
344
+ print("HF Dataset 복구: 기존 파일 없음(최초 실행) — 빈 채로 시작")
345
+ except Exception as de:
346
+ # 그 외 실패(네트워크 등): HF에 데이터가 있을 수 있음 → 빈 폴더로 덮어쓰기 방지 위해 동기화 끔.
347
+ safe_to_sync = False
348
+ print(f"⚠️ HF Dataset 복구 실패(네트워크 등): {de}\n"
349
+ f" → 기존 데이터 덮어쓰기 방지를 위해 이번 세션 자동 동기화를 끕니다. "
350
+ f"로컬에는 계속 저장되며, Space 재시작으로 복구를 재시도하세요.")
351
+ if safe_to_sync:
352
+ self.scheduler = CommitScheduler(repo_id=repo, repo_type="dataset",
353
+ folder_path=data_dir, path_in_repo="data",
354
+ every=5, private=True)
355
+ print("HF Dataset 동기화 ON:", repo)
356
+ else:
357
+ print("HF Dataset 동기화 OFF(안전모드) — 로컬 저장만 유지")
358
+ except Exception as e:
359
+ print("HF Dataset 동기화 OFF:", e)
360
+ else:
361
+ print("HF Dataset 미설정 — 로컬 저장만(재시작 시 초기화). HF_TOKEN·HF_DATASET_REPO 설정 시 영구화.")
362
+
363
+ def write(self, rec):
364
+ lock = self.scheduler.lock if self.scheduler else nullcontext()
365
+ with lock:
366
+ with open(self.path, "a", encoding="utf-8") as f:
367
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
368
+ f.flush()
369
+ try:
370
+ os.fsync(f.fileno()) # 디스크에 즉시 반영(동기화 타이밍 문제 방지)
371
+ except Exception:
372
+ pass
373
+ self.saved += 1
374
+ print(f"[SAVE-DEBUG] write 완료: type={rec.get('type')}, "
375
+ f"누적 {self.saved}건, 파일={self.path}")
376
+
377
+ def log_turn(self, session, m, strength, state, utter_len, kind="user", text=None):
378
+ """발화 한 건의 측정값·변수 기록. text 제공 시 원문도 함께 저장(고지 후 수집)."""
379
+ rec = {
380
+ "type": "coach_turn", "ts": int(time.time()), "datetime": _now(),
381
+ "session": session, "app_version": self.version, "encoder": self.encoder, "kind": kind,
382
+ "EVA": round(float(m["EVA"]), 4), "EAR": round(float(m["EAR"]), 4),
383
+ "VAL": round(float(m["VAL"]), 4), "REI": round(float(m["REI"]), 4),
384
+ "CON": round(float(m.get("CON", 0.0)), 4), "FUL": round(float(m.get("FUL", 0.0)), 4), # 신규 축(기록만 — 외로움/공허 실사용 검증용)
385
+ "strength": round(float(strength), 4),
386
+ "phase": state["phase"], "turn": state["turns"],
387
+ "refine_turns": state["refine_turns"], "stall_count": state["stall_count"],
388
+ "max_strength": round(float(state["max_strength"]), 4),
389
+ "n_candidates": len(state["candidates"]),
390
+ "irony_flag": state.get("irony_flag", "없음"), # 반어 의심 레벨(기록만)
391
+ "emotions": state.get("emotions"), # 사용자가 고른 감정 주제들(EMOTIONS 키 리스트) — 감정별 분석용
392
+ "utter_len": int(utter_len),
393
+ "text": text, # 발화 원문(고지 후 수집). None이면 미저장.
394
+ }
395
+ self.write(rec)
396
+
397
+ def log_selection(self, session, candidates, chosen_idx, emotions=None):
398
+ """길2 완성 선택 기록 — 최종 후보 리스트와 사용자 선택. 원문 포함(고지 후 수집).
399
+ '사용자가 어떤 측정 특성/표현을 선택하는가'를 분석하기 위한 데이터."""
400
+ print(f"[SAVE-DEBUG] log_selection 호출됨: session={session}, "
401
+ f"후보수={len(candidates)}, chosen_idx={chosen_idx}")
402
+ cand_rows = []
403
+ for i, c in enumerate(candidates):
404
+ cand_rows.append({
405
+ "idx": i,
406
+ "EVA": c["EVA"], "EAR": c["EAR"], "VAL": c["VAL"], "REI": c["REI"],
407
+ "strength": c["strength"], "turn": c.get("turn"),
408
+ "len": len(c["text"]),
409
+ "text": c["text"], # 후보 표현 원문(고지 후 수집)
410
+ "chosen": (i == chosen_idx),
411
+ })
412
+ chosen = candidates[chosen_idx] if 0 <= chosen_idx < len(candidates) else None
413
+ rec = {
414
+ "type": "coach_selection", "ts": int(time.time()), "datetime": _now(),
415
+ "session": session, "app_version": self.version, "encoder": self.encoder,
416
+ "n_candidates": len(candidates),
417
+ "chosen_idx": chosen_idx,
418
+ # 선택된 표현의 측정값(값만)
419
+ "chosen_EVA": chosen["EVA"] if chosen else None,
420
+ "chosen_EAR": chosen["EAR"] if chosen else None,
421
+ "chosen_VAL": chosen["VAL"] if chosen else None,
422
+ "chosen_REI": chosen["REI"] if chosen else None,
423
+ "chosen_strength": chosen["strength"] if chosen else None,
424
+ "chosen_turn": chosen.get("turn") if chosen else None,
425
+ "chosen_len": len(chosen["text"]) if chosen else None,
426
+ "chosen_text": chosen["text"] if chosen else None, # 선택된 표현 원문(고지 후 수집)
427
+ # 후보 리스트 전체(값만 — 선택 안 된 것과 비교용)
428
+ "candidates": cand_rows,
429
+ # 선택된 게 신호강도 최고였나?(측정과 사람 선택의 일치 여부 — 핵심 분석 지표)
430
+ "chose_max_strength": (chosen_idx == max(range(len(candidates)),
431
+ key=lambda i: candidates[i]["strength"])) if candidates else None,
432
+ "emotions": emotions, # 사용자가 고른 감정 주제들(EMOTIONS 키 리스트) — 감정별 선택 분석용
433
+ }
434
+ self.write(rec)
435
+
436
+ STORE = CoachDataStore()
437
+ import uuid as _uuid
438
+
439
+ # ============================== 측정 → 자연어 요약 (좌표를 LLM에 압축 전달) ==============================
440
+ def describe_measure(m):
441
+ """측정 좌표를 LLM이 읽을 짧은 자연어로. EVA는 강도 신뢰, 나머지는 방향 위주."""
442
+ eva, ear = m["EVA"], m["EAR"]
443
+ strength = signal_strength(m)
444
+ # EVA(감정가) — 강도까지 신뢰
445
+ if eva > 0.6: emo = "분명히 긍정적"
446
+ elif eva > 0.15: emo = "약간 긍정적"
447
+ elif eva < -0.6: emo = "분명히 부정적(가라앉음)"
448
+ elif eva < -0.15: emo = "약간 부정적"
449
+ else: emo = "중립적이거나 섞임"
450
+ # 신호 강도 — 막막/또렷
451
+ if strength < GATE: clarity = "감정이 흐릿하거나 억압된 듯(신호 약함)"
452
+ elif strength < CLEAR_THRESHOLD: clarity = "감정이 어느 정도 드러남"
453
+ else: clarity = "감정이 또렷하게 표현됨"
454
+ desc = f"감정가: {emo} / 표현 또렷함: {clarity} (신호강도 {strength:.2f})"
455
+ # VAL/REI 방향 성향 (실험적, 참고용) — 뚜렷할 때만, 강도 없이 방향만
456
+ val_dir, rei_dir = tendency_of(m)
457
+ hints = []
458
+ if val_dir == "자율":
459
+ hints.append("스스로 정하려는 결(자율) 쪽")
460
+ elif val_dir == "순응":
461
+ hints.append("주위에 맞추려는 결(순응) 쪽")
462
+ if rei_dir == "분석":
463
+ hints.append("따져 생각하는 결(분석) 쪽")
464
+ elif rei_dir == "직관":
465
+ hints.append("느낌으로 받아들이는 결(직관) 쪽")
466
+ if hints:
467
+ desc += f" / 말하는 결(참고만, 약하게): {', '.join(hints)}"
468
+ return desc
469
+
470
+ # ============================== 코칭 프롬프트 (단계별) ==============================
471
+ SITUATION = ("사용자는 '서운함·속상함·화남 같은 부정적인 마음을 누군가에게 전하고 싶은' 상황입니다. "
472
+ "상대는 연인일 수도, 가족·친구·동료일 수도 있습니다. 어떤 일이 있었고 그 일로 마음이 상했는데, "
473
+ "'이런 마음을 표현해도 되나, 어떻게 말해야 하나' 망설입니다. 고마움이나 미안함 같은 다른 감정이 "
474
+ "함께 섞여 있을 수도 있습니다. 구체적인 상황은 사용자가 직접 이야기합니다 — 미리 단정하지 마세요. "
475
+ "사용자의 성별·나이·관계는 정해져 있지 않습니다.")
476
+
477
+ # ── 감정 주제 8가지 (터치 선택) ──────────────────────────────────────────
478
+ # 실사용 피드백 반영: 감정 직접형(외로움/공허/화남/서운 — 여성 피드백 좋음) +
479
+ # 관계·인정형(인정/관계회복/미안/복잡 — 인정·관계 프레임). 성별 라벨 없이 사용자가 끌리는 걸 선택.
480
+ # 각 감정: label(버튼), note(코치용 상황 설명), tone(코치 접근 톤 힌트).
481
+ # 약한 감정(외로움/공허/관계회복)은 '세기 약하니 차근차근, 변화율로'(사용자 지침) 톤 반영.
482
+ EMOTIONS = {
483
+ "lonely": {
484
+ "label": "🌙 외로움 · 혼자인 것 같아 / 쓸쓸해 / 허전해",
485
+ "note": "사���자는 외로움을 느끼고 있습니다. 누군가에게 그 마음을 전하고 싶거나, 혼자라는 느낌을 털어놓고 싶어 합니다.",
486
+ "tone": "외로움은 격하게 터지기보다 가만히 가라앉는 감정입니다. 다그치지 말고, 그 쓸쓸함을 천천히 말로 옮기도록 차분히 곁을 지켜주세요.",
487
+ },
488
+ "empty": {
489
+ "label": "🫥 공허함 · 마음이 텅 빈 / 의욕이 없어 / 다 부질없어",
490
+ "note": "사용자는 공허함·허전함을 느끼고 있습니다. 무엇이 비어 있는지, 그 마음을 어떻게 표현할지 함께 찾고 싶어 합니다.",
491
+ "tone": "공허함은 또렷한 사건보다 막연한 느낌일 수 있습니다. 억지로 이유를 캐묻지 말고, 어렴풋한 마음을 조금씩 말로 만들어가게 도와주세요.",
492
+ },
493
+ "angry": {
494
+ "label": "🔥 화남 · 욱했어 / 따지고 싶어 / 못 참겠어",
495
+ "note": "사용자는 화가 난 상태입니다. 그 화를 누군가에게 어떻게 전할지, 어떻게 표현해야 할지 막막해합니다.",
496
+ "tone": "화는 강하게 올라오는 감정입니다. 화를 누르라고 하지 말고, 그 화 밑에 있는 진짜 바람(무엇을 원했는지)이 드러나도록 비춰주세요.",
497
+ },
498
+ "hurt": {
499
+ "label": "💧 서운함 · 서운해 / 몰라줘 / 기대했는데",
500
+ "note": "사용자는 누군가에게 서운하고 속상한 마음입니다. 그 마음을 그 사람에게 전하고 싶어 합니다.",
501
+ "tone": "서운함은 기대가 어긋난 자리에서 옵니다. '이런 마음을 가져도 되나' 망설임을 풀어주고, 자기 감정을 그대로 인정하게 도와주세요.",
502
+ },
503
+ "recognized": {
504
+ "label": "🏅 인정받고 싶음 · 알아줬으면 / 노력했는데 / 속상해",
505
+ "note": "사용자는 자신의 노력·마음을 누군가가 알아주길 바랍니다. 인정받고 싶은 마음을 어떻게 전할지 고민합니다.",
506
+ "tone": "인정 욕구는 약점이 아니라 자연스러운 바람입니다. '알아달라'고 말하는 게 부끄럽지 않도록, 그 바람을 떳떳이 표현하게 도와주세요. 감정을 캐묻기보다 '무엇을 알아줬으면 하는지'에 초점을 맞추세요.",
507
+ },
508
+ "reconnect": {
509
+ "label": "🤝 관계 회복 · 멀어졌어 / 다가가고 싶어 / 예전처럼",
510
+ "note": "사용자는 멀어졌거나 소원해진 사람과 다시 가까워지고 싶어 합니다. 그 마음을 어떻게 전할지 찾고 있습니다.",
511
+ "tone": "관계를 잇고 싶은 마음은 조심스럽습니다. 서두르지 말고, '다시 다가가고 싶다'는 마음을 부담 없이 한 걸음씩 말로 옮기게 도와주세요. 상대를 분석하기보다 자기 바람에 집중하게 하세요.",
512
+ },
513
+ "sorry": {
514
+ "label": "🙏 미안함 · 내가 잘못했어 / 사과하고 싶어 / 후회돼",
515
+ "note": "사용자는 누군가에게 미안한 마음이 있습니다. 그 사과를 어떻게 진심으로 전할지 고민합니다.",
516
+ "tone": "사과는 변명이 섞이면 흐려집니다. 자기 행동을 인정하는 마음이 또렷한 말이 되도록, 핑계보다 진심에 머물게 도와주세요.",
517
+ },
518
+ }
519
+
520
+ # 여러 감정을 함께 고른 경우(복합) 코치 톤 — 단일 감정 'mixed' 대신 다중선택으로 대체됨
521
+ COMBINED_TONE = ("섞인 감정은 하나로 누르지 말고, 여러 마음이 함께 있어도 괜찮다고 알려주세요. "
522
+ "그중 지금 가장 전하고 싶은 마음이 무엇인지 천천히 가려내게 도와주세요.")
523
+ EMOTION_ORDER = ["lonely", "empty", "angry", "hurt", "recognized", "reconnect", "sorry"]
524
+
525
+ COACH_RULES = ("당신은 '표현 코칭'을 돕는 따뜻한 대화 상대입니다. 핵심 원칙(반드시 지킬 것):\n"
526
+ "1) 절대 '이렇게 말해'라고 정답 문장을 주지 마세요. 당신은 거울과 산파일 뿐, 사용자가 스스로 자기 말을 찾게 돕습니다.\n"
527
+ "2) 사용자가 직접 쓴 표현을 존중하고, 더 정확한지 '본인이' 판단하게 하세요.\n"
528
+ "3) 감정을 지적·진단하지 말고, 부드럽게 비춰주고 물어보세요.\n"
529
+ "4) 한국어로 2~3문장, 따뜻하고 담백하게. 과한 느낌표·이모지 자제.\n"
530
+ "5) 표현이 서툰 것을 탓하지 말고, 표현하려는 시도 자체를 인정하세요.\n"
531
+ "6) 사용자에게 '상대방이 어떻게 반응할지/생각할지 예상해보라'고 묻지 마세요. 이 앱은 '자기 표현'을 돕는 곳이지 상대를 분석하는 곳이 아닙니다. "
532
+ "상대 입장은 필요할 때 '당신(코치)이 긍정적으로 비춰주는' 것이지, 사용자에게 예측을 시키지 않습니다.")
533
+
534
+ # ============================== 나침반(감정 평면 + 궤적) ==============================
535
+ import matplotlib
536
+ matplotlib.use("Agg")
537
+ import matplotlib.pyplot as plt
538
+
539
+ def render_compass(coords, best_idx=None):
540
+ """EVA(x: 부정↔긍정) × EAR(y: 차분↔격앙) 평면에 발화 궤적을 그린다.
541
+ coords: [(eva, ear, strength), ...] 발화 순서. best_idx: best 표현 순번(별표).
542
+ 한글 폰트가 없는 환경(HF Space) 대비, 차트 내 텍스트는 영어/기호만 사용."""
543
+ fig, ax = plt.subplots(figsize=(4.2, 4.2), dpi=100)
544
+ lim = 2.2
545
+ # 사분면 배경/안내(영어로 — 폰트 안전)
546
+ ax.axhline(0, color="#bbb", lw=0.8, zorder=1)
547
+ ax.axvline(0, color="#bbb", lw=0.8, zorder=1)
548
+ ax.fill_between([-lim, 0], 0, lim, color="#ffe5e5", alpha=0.5, zorder=0) # 좌상: 부정+격앙(분노)
549
+ ax.fill_between([0, lim], 0, lim, color="#fff4e0", alpha=0.5, zorder=0) # 우상: 긍정+격앙(흥분)
550
+ ax.fill_between([-lim, 0], -lim, 0, color="#e8eef7", alpha=0.5, zorder=0) # 좌하: 부정+차분(가라앉음)
551
+ ax.fill_between([0, lim], -lim, 0, color="#e6f5ea", alpha=0.5, zorder=0) # 우하: 긍정+차분(평온)
552
+ ax.text(-lim*0.96, lim*0.88, "ANGRY", fontsize=8, color="#c0392b", alpha=0.7, weight="bold")
553
+ ax.text(lim*0.45, lim*0.88, "EXCITED", fontsize=8, color="#e67e22", alpha=0.7, weight="bold")
554
+ ax.text(-lim*0.96, -lim*0.93, "DOWN", fontsize=8, color="#5a7", alpha=0.7, weight="bold")
555
+ ax.text(lim*0.42, -lim*0.93, "CALM", fontsize=8, color="#27ae60", alpha=0.7, weight="bold")
556
+
557
+ if coords:
558
+ xs = [max(-lim, min(lim, c[0])) for c in coords]
559
+ ys = [max(-lim, min(lim, c[1])) for c in coords]
560
+ n = len(coords)
561
+ # ── B-2: 추세선(EMA 평활) — 측정 원본은 점으로 정직하게, 흐름은 부드러운 선으로 ──
562
+ # 긍정 흐름 중 "미안/짜증" 한 발화로 튀어도, 추세선은 이전 긍정을 반영해 완만하게 보상.
563
+ if n >= 2:
564
+ alpha_ema = 0.45 # 평활 강도(작을수록 더 부드러움 = 과거 더 반영)
565
+ ex, ey = xs[0], ys[0]
566
+ tx, ty = [ex], [ey]
567
+ for i in range(1, n):
568
+ ex = alpha_ema * xs[i] + (1 - alpha_ema) * ex
569
+ ey = alpha_ema * ys[i] + (1 - alpha_ema) * ey
570
+ tx.append(ex); ty.append(ey)
571
+ ax.plot(tx, ty, "-", color="#3498db", lw=2.4, alpha=0.55, zorder=2,
572
+ solid_capstyle="round", label="흐름(추세)")
573
+ # 원본 점은 옅은 점선으로 가볍게 연결(순서 참고용)
574
+ ax.plot(xs, ys, ":", color="#aaa", lw=0.9, alpha=0.5, zorder=2)
575
+ # 원본 점들(측정 그대로 — 정확한 위치)
576
+ for i, (x, y) in enumerate(zip(xs, ys)):
577
+ is_last = (i == n - 1)
578
+ is_best = (best_idx is not None and i == best_idx)
579
+ if is_best:
580
+ ax.scatter([x], [y], s=260, marker="*", color="#f1c40f",
581
+ edgecolors="#b8860b", linewidths=1.2, zorder=5)
582
+ alpha = 0.35 + 0.65 * (i + 1) / n
583
+ color = "#2c3e50" if is_last else "#5d6d7e"
584
+ sz = 130 if is_last else 70
585
+ ax.scatter([x], [y], s=sz, color=color, alpha=alpha, zorder=4,
586
+ edgecolors="white", linewidths=1.0)
587
+ ax.annotate(str(i + 1), (x, y), fontsize=7, color="white",
588
+ ha="center", va="center", zorder=6, weight="bold")
589
+ # 추세선 끝(현재 마음의 '흐름상' 위치) 표시
590
+ if n >= 2:
591
+ ax.scatter([tx[-1]], [ty[-1]], s=90, marker="D", color="#3498db",
592
+ alpha=0.7, zorder=5, edgecolors="white", linewidths=1.0)
593
+
594
+ ax.set_xlim(-lim, lim); ax.set_ylim(-lim, lim)
595
+ ax.set_xlabel("← negative valence positive →", fontsize=8)
596
+ ax.set_ylabel("← calm arousal excited →", fontsize=8)
597
+ ax.set_xticks([]); ax.set_yticks([])
598
+ ax.set_title("Emotion Compass", fontsize=10, weight="bold")
599
+ # 범례(영어 — 폰트 안전): 점=각 발화, 파란선=흐름(추세), 별=가장 또렷한 표현
600
+ from matplotlib.lines import Line2D
601
+ legend_items = [
602
+ Line2D([0], [0], marker="o", color="w", markerfacecolor="#2c3e50", markersize=7, label="each msg"),
603
+ Line2D([0], [0], color="#3498db", lw=2.4, alpha=0.7, label="trend (smoothed)"),
604
+ ]
605
+ if best_idx is not None:
606
+ legend_items.append(Line2D([0], [0], marker="*", color="w", markerfacecolor="#f1c40f",
607
+ markeredgecolor="#b8860b", markersize=12, label="clearest"))
608
+ ax.legend(handles=legend_items, loc="upper right", fontsize=6.5, framealpha=0.85)
609
+ for spine in ax.spines.values():
610
+ spine.set_edgecolor("#ddd")
611
+ fig.tight_layout()
612
+ return fig
613
+
614
+ def compass_caption(coords, best_idx=None):
615
+ """나침반 아래에 붙일 한글 설명(차트 밖이라 폰트 안전)."""
616
+ if not coords:
617
+ return "아직 좌표가 없어요. 마음을 적으면 나침반에 표시됩니다."
618
+ eva, ear, _ = coords[-1]
619
+ # 현재 위치 설명
620
+ val_txt = "긍정적" if eva > 0.3 else ("부정적" if eva < -0.3 else "중립")
621
+ ar_txt = "격앙됨" if ear > 0.3 else ("차분함" if ear < -0.3 else "보통")
622
+ cur = f"지금: 감정가 {val_txt}, 각성 {ar_txt}"
623
+ # 이동 설명(처음 대비)
624
+ move = ""
625
+ if len(coords) >= 2:
626
+ d_eva = eva - coords[0][0]; d_ear = ear - coords[0][1]
627
+ parts = []
628
+ if d_eva > 0.4: parts.append("덜 부정적으로")
629
+ elif d_eva < -0.4: parts.append("더 부정적으로")
630
+ if d_ear < -0.4: parts.append("더 차분하게")
631
+ elif d_ear > 0.4: parts.append("더 격해지게")
632
+ if parts:
633
+ move = " · 처음보다 " + ", ".join(parts) + " 이동했어요"
634
+ star = ""
635
+ if best_idx is not None:
636
+ star = f" · ⭐는 가장 또렷한 표현({best_idx+1}번)"
637
+ return cur + move + star
638
+
639
+ def debug_line(m, strength, state):
640
+ """임계값 조정용 실측 표시(DEBUG=True일 때만). 측정값과 상태를 한 줄로."""
641
+ if not DEBUG:
642
+ return ""
643
+ n_cand = len(state.get("candidates", []))
644
+ irony = state.get("irony_flag", "없음")
645
+ irony_txt = f" | 반어신호={irony}" if irony in ("강", "급반전") else ""
646
+ return (f"<sub>🔧 EVA={m['EVA']:.2f} EAR={m['EAR']:.2f} VAL={m['VAL']:.2f} REI={m['REI']:.2f} "
647
+ f"신호={strength:.2f} | phase={state['phase']} refine턴={state['refine_turns']} "
648
+ f"max신호={state['max_strength']:.2f} stall={state['stall_count']} 후보={n_cand}개{irony_txt}</sub>")
649
+
650
+ def build_coach_prompt(phase, measure_desc, situation_note="", best=None, hard5=False, tendency=None, emotions=None):
651
+ # 사용자가 고른 감정 주제(들)가 있으면 그 맥락·톤을 상황에 반영(감정별 코칭 분기)
652
+ valid = [e for e in (emotions or []) if e in EMOTIONS]
653
+ if len(valid) == 1:
654
+ em = EMOTIONS[valid[0]]
655
+ situation_block = f"{em['note']}\n[이 감정을 대할 때] {em['tone']}"
656
+ elif len(valid) >= 2:
657
+ # 복합: 고른 감정들의 note를 모으고, 복합 전용 톤을 붙임
658
+ names = " · ".join(EMOTIONS[e]["label"].split("·")[0].strip() for e in valid)
659
+ notes = " 그리고 ".join(EMOTIONS[e]["note"] for e in valid)
660
+ situation_block = (f"사용자는 여러 감정({names})이 섞여 있다고 느낍니다. {notes}\n"
661
+ f"[이 감정을 대할 때] {COMBINED_TONE}")
662
+ else:
663
+ situation_block = SITUATION
664
+ base = f"{COACH_RULES}\n\n[상황]\n{situation_block}\n"
665
+ if best:
666
+ base += f"\n[사용자가 지금까지 도달한 가장 또렷한 표현]\n\"{best}\"\n"
667
+ if measure_desc:
668
+ base += f"\n[방금 사용자 발화의 측정 결과 — 은근히 참고하되 숫자나 진단은 직접 언급 금지]\n{measure_desc}\n"
669
+ # 성향 기반 코칭 방향 (실험적, 보조) — 방향만, 강도 없음. 강압 아닌 미세 조정.
670
+ if tendency:
671
+ val_dir, rei_dir = tendency
672
+ guide = []
673
+ if val_dir == "순응":
674
+ guide.append("이 사람은 주위에 맞추려는 결이 보입니다. 자기 마음을 눌러 표현을 망설일 수 있으니, '당신이 느끼는 것 자체가 소중하다'는 쪽으로 자기 표현을 살며시 북돋아 주세요.")
675
+ elif val_dir == "자율":
676
+ guide.append("이 사람은 스스로 정하려는 결이 보입니다. 이미 자기 마음을 꺼내고 있으니, 방향을 바꾸려 들지 말고 그 표현을 존중하며 비춰만 주세요.")
677
+ if rei_dir == "분석":
678
+ guide.append("이 사람은 따져 생각하는 결이 보입니다. 감정을 논리로 정리해 버리지 않도록, 느끼는 것 자체에 잠시 머물게 도와주세요.")
679
+ elif rei_dir == "직관":
680
+ guide.append("이 사람은 느낌으로 받아들이는 결이 보입니다. 막연한 느낌을 한 걸음 더 구체적인 말로 옮겨보도록 부드럽게 도와주세요.")
681
+ if guide:
682
+ base += ("\n[말하는 결에 맞춘 보조 지침 — 약하게 반영, 측정 진단은 절대 입 밖에 내지 말 것]\n"
683
+ + " ".join(guide) + "\n")
684
+ if phase == "scaffold":
685
+ base += ("\n[지금 단계: 비계]\n사용자의 감정이 아직 흐릿합니다(억압·막막). "
686
+ "감정을 '대신 규정'하지 말고, 방향을 좁히도록 도와주세요. 단 선택지(①②③)는 시스템이 따로 제시하므로, "
687
+ "당신은 사용자가 자기 마음을 살짝 더 들여다보도록 공감하며 한 걸음만 이끄세요.")
688
+ elif phase == "graduate":
689
+ base += ("\n[지금 단계: 졸업 전환]\n사용자의 감정이 또렷해졌습니다. "
690
+ "'고마움과 서운함은 함께 있어도 괜찮다'는 점을 비춰 자기검열을 풀어주고, "
691
+ "이제 그 마음을 '직접 한 문장으로 써보도록' 부드럽게 권하세요.")
692
+ elif phase == "refine":
693
+ base += ("\n[지금 단계: 자기 문장 다듬기]\n사용자가 자기 표현을 썼습니다. "
694
+ "그 문장에 어떤 마음이 담겼는지 측정 결과로 '비춰주고'(고쳐주지 말 것), "
695
+ "처음보다 또렷해졌다면 그 변화를 짚어주세요.\n"
696
+ "[중요 — 반복 금지] 매번 똑같은 질문('그 사람이 어떻게 반응할까요/이해하길 바라나요')을 되풀이하지 마세요. "
697
+ "특히 '상대가 어떻게 반응할지 예상해보라'는 식의 질문은 하지 마세요 — 그건 상대를 분석하게 만들어 자기검열을 부추깁니다. "
698
+ "대신 사용자 '자신의 표현'에 집중하게 하세요. 표현이 이미 또렷하면 더 캐묻지 말고, "
699
+ "사용자가 한두 번 더 다듬으면 그것으로 충분합니다. 간결하게 1~2문장으로.")
700
+ elif phase == "confirm":
701
+ if hard5:
702
+ base += ("\n[지금 단계: 완성 확인 — 충분히 다듬음]\n사용자가 여러 번 표현을 다듬었습니다. 위 '가장 또렷한 표현'을 그대로 인용해 따뜻하게 인정한 뒤, "
703
+ "'마음을 충분히 잘 정리하셨어요. 이대로 마무리할까요, 아니면 조금 더 다듬어볼까요?'라고 부드럽게 물으세요. "
704
+ "절대 하지 말 것: 상대 반응 예상 질문, 같은 질문 반복. 2문장 이내.")
705
+ else:
706
+ base += ("\n[지금 단계: 완성 확인 — 딱 한 번만]\n사용자의 표현이 충분히 또렷해졌습니다. 위 '가장 또렷한 표현'을 그대로 인용해 따뜻하게 인정한 뒤, "
707
+ "'이 표현이면 마음이 잘 담긴 것 같아요. 이대로 정리할까요?'라고 딱 한 번만 부드럽게 확인하세요. "
708
+ "절대 하지 말 것: 상대 반응 예상 질문, '더 담아볼까요/덧붙일까요' 같은 추가 다듬기 유도, 같은 질문 반복. 2문장 이내.")
709
+ elif phase == "complete":
710
+ base += ("\n[지금 단계: 완성·마무리 — 질문 없이 끝맺기]\n사용자가 표현을 확정했습니다. 위 '가장 또렷한 표현'을 인용하며 다음을 담아 따뜻하게 맺으세요(2~3문장):\n"
711
+ "1) 그 표현을 긍정적으로 재해석 — 서운함/요구가 아니라 '너와 더 가까이 있고 싶은 소중한 마음'임을 비춰주세요.\n"
712
+ "2) 그 말이 상대에게 '비난'이 아니라 '사랑의 표현'으로 가닿을 수 있음을 당신(코치)이 직접 안심시켜 자기검열을 풀어주세요.\n"
713
+ "3) 준비됐을 때 솔직하게 전해보도록 격려하고 끝맺으세요.\n"
714
+ "★절대 금지: 어떤 질문도 하지 마세요(특히 '상대가 어떻게 반응할까요' 류). 이건 마지막 응답이니 질문 없이 따뜻하게 마침표를 찍으세요.")
715
+ return base
716
+
717
+ # ============================== Gemini 호출 ==============================
718
+ def _msg_text(c):
719
+ if isinstance(c, str): return c
720
+ if isinstance(c, list):
721
+ return " ".join(_msg_text(x) for x in c)
722
+ if isinstance(c, dict):
723
+ return c.get("text", "") or c.get("content", "") or ""
724
+ return str(c) if c is not None else ""
725
+
726
+ def make_hint(candidates, emotions=None):
727
+ """완성 시점 힌트 1개 생성 — 사용자 발화 후보 중 '마음을 가장 잘 담은 것'을 골라
728
+ 상대에게 전할 수 있게 보수적으로 다듬는다(재구성만, 내용 추가·조언·해석 금지).
729
+ 측정이 표현 질을 못 보므로(KoSimCSE 한계) 이 판단·다듬기는 LLM이 맡음.
730
+ 실패 시 None 반환(힌트 없이 진행)."""
731
+ texts = [c["text"] for c in (candidates or []) if c.get("text")]
732
+ if not texts:
733
+ return None
734
+ joined = "\n".join(f"- {t}" for t in texts)
735
+ sysp = (
736
+ "너는 사용자가 마음 속 이야기를 상대에게 전할 수 있게 돕는 도우미다.\n"
737
+ "아래는 사용자가 대화 중 한 말들이다. 이 중에서 사용자의 진짜 마음이 가장 잘 담긴 말을 하나 고르고, "
738
+ "그 말을 상대에게 그대로 전할 수 있는 자연스러운 한 문장으로 다듬어라.\n\n"
739
+ "[반드시 지킬 것]\n"
740
+ "1. 사용자가 하지 않은 말(새로운 내용·이유·상황)을 절대 추가하지 마라.\n"
741
+ "2. 조언·해결책·평가·훈수를 넣지 마라. 사용자의 마음을 대신 전하는 것일 뿐이다.\n"
742
+ "3. 사용자가 '나는 어떻게 해야 하나' 같이 스스로에게 던진 질문이나 도우미에게 한 질문은 고르지 마라. "
743
+ "상대에게 전할 '마음'을 골라라.\n"
744
+ "4. 1인칭으로(내가 상대에게 말하듯), 한 문장으로, 담담하게.\n"
745
+ "5. 설명 없이 다듬은 문장만 출력해라. 따옴표도 붙이지 마라."
746
+ )
747
+ user_msg = f"사용자가 한 말들:\n{joined}\n\n이 중 마음이 가장 잘 담긴 것을 골라 상대에게 전할 한 문장으로 다듬어줘."
748
+ try:
749
+ out = gemini(sysp, [], user_msg)
750
+ except Exception:
751
+ return None
752
+ if not out or out.startswith("["): # 키 미설정 등
753
+ return None
754
+ hint = out.strip().strip('"').strip("'").split("\n")[0].strip()
755
+ # 안전장치: 너무 길거나(다듬기 실패) 비면 버림
756
+ if not hint or len(hint) > 120:
757
+ return None
758
+ # 후보 원문과 완전히 같으면 힌트로서 의미 없음 → 그래도 표시(다듬을 게 없던 것일 수)
759
+ return hint
760
+
761
+ def gemini(sys_prompt, history, user_msg):
762
+ key = os.environ.get("GEMINI_API_KEY", "").strip()
763
+ if not key:
764
+ try:
765
+ from google.colab import userdata
766
+ key = (userdata.get("GEMINI_API_KEY") or "").strip()
767
+ except Exception:
768
+ pass
769
+ if not key:
770
+ return "[GEMINI_API_KEY 미설정 — 환경변수/Colab Secrets/Space Secret에 추가하세요]"
771
+ import requests
772
+ models = ["gemini-2.5-flash", "gemini-2.5-flash-lite", "gemini-flash-latest"]
773
+ # 최근 3턴(6메시지)만 — 비용 절제(입력 누적 방지). 좌표는 시스템 프롬프트에 이미 압축.
774
+ hist = (history or [])[-6:]
775
+ while hist and hist[0].get("role") == "assistant":
776
+ hist = hist[1:]
777
+ contents = []
778
+ for m in hist:
779
+ role = "model" if m.get("role") == "assistant" else "user"
780
+ c = _msg_text(m.get("content")).strip()
781
+ if c: contents.append({"role": role, "parts": [{"text": c}]})
782
+ contents.append({"role": "user", "parts": [{"text": user_msg}]})
783
+ payload = {"system_instruction": {"parts": [{"text": sys_prompt}]},
784
+ "contents": contents,
785
+ "generationConfig": {"thinkingConfig": {"thinkingBudget": 0},
786
+ "maxOutputTokens": 400, "temperature": 0.85}}
787
+ for mdl in models:
788
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{mdl}:generateContent?key={key}"
789
+ try:
790
+ data = requests.post(url, json=payload, timeout=30).json()
791
+ except Exception as e:
792
+ continue
793
+ if "error" in data:
794
+ continue
795
+ cands = data.get("candidates")
796
+ if not cands: continue
797
+ parts = cands[0].get("content", {}).get("parts", [])
798
+ text = "".join(p.get("text", "") for p in parts).strip()
799
+ if text: return text
800
+ return "[응답 생성 실패 — 잠시 후 다시 시도해주세요]"
801
+
802
+ # ============================== 대화 상태 + 흐름 로직 ==============================
803
+ def new_state():
804
+ return {"phase": "start", # start → scaffold → refine → confirm → complete
805
+ "first_strength": None, # 처음 적은 글의 신호강도(성장 비교용)
806
+ "first_text": None,
807
+ "candidates": [], # 길2: refine 표현 후보 [{text, EVA, EAR, VAL, REI, strength, turn}]. UI는 text, 저장은 값.
808
+ "chosen_text": None, # 사용자가 최종 선택한 표현(텍스트 — UI용)
809
+ "chosen_meta": None, # 선택된 후보의 측정값(저장용 — 값만)
810
+ "refine_turns": 0, # refine 단계에 머문 턴 수
811
+ "stall_count": 0, # 새 표현 없이 맴돈 횟수(무한루프 방지)
812
+ "coords": [], # 나침반 궤적: [(eva, ear, strength), ...] 발화별 좌표
813
+ "eva_hist": [], # refine 단계 EVA 이력(조건3: 이전 3턴 평균 비교용)
814
+ "eva_all": [], # 모든 발화 EVA 이력(반어 급반전 판정용 — start/confirm 포함, 직전 흐름 정확히)
815
+ "irony_flag": "없음", # 반어 의심 플래그(기록만 — 측정값 안 건드림)
816
+ "max_strength": 0.0, # 세션 최고 신호강도(완성 조건 판정용 — best '선정'과 무관)
817
+ "done_shown": False, # 완성 마무리 1회 표시 여부
818
+ "show_confirm": False, # 완성 선택 UI 표시 여부(@gr.render가 읽음)
819
+ "hint": None, # 완성 시점 LLM이 다듬은 힌트(전달용 예시 1개)
820
+ "hard5_flag": False, # 완성 멘트 hard5 여부(@gr.render 헤더용)
821
+ "emotions": [], # 사용자가 고른 감정 주제들(EMOTIONS 키 리스트) — 다중선택, 코치 프롬프트 분기용
822
+ "emo_pick": [], # 시작 화면에서 아직 '시작하기' 전 임시 선택(토글) 상태
823
+ "topic_chosen": False, # 감정 주제 선택 완료 여부(시작 화면 버튼 표시 제어)
824
+ "session": None, # 세션 id(저장용, 첫 발화 때 생성)
825
+ "turns": 0}
826
+
827
+ # ── 완성 판정 파라미터 ──
828
+ CLEAR_FOR_DONE = 0.6 # 완성 후보가 되려면 '세션 최고 표현'이 이 이상 또렷해야
829
+ CONVERGE_DELTA = 0.10 # (참고용)
830
+ REPEAT_SIM = 0.80 # (참고용)
831
+ MIN_REFINE_TURNS = 1 # refine 최소 턴
832
+ STALL_LIMIT = 3 # 새 표현 없이 이만큼 맴돌면 강제 완성 제안
833
+
834
+ # ── 완성 4조건 ── (모호한 임계값 추측 대신 명확한 규칙)
835
+ COND_MIN_REFINE = 3 # 조건1: refine 최소 턴
836
+ EXTREME_NEG_EVA = -0.55 # 조건2: EVA가 이보다 위면 '극단 부정 해소'됨(이 아래는 아직 격한 분노)
837
+ HARD_DONE_REFINE = 5 # 조건4: refine 5턴째엔 무조건 완료 선택지
838
+
839
+ def completion_conditions(eva, state):
840
+ """완성 제안 4조건 판정.
841
+ 조건1: refine 3턴 이상
842
+ 조건2: 극단 부정 해소 (현재 EVA > EXTREME_NEG_EVA)
843
+ 조건3: 현재 EVA ≥ 직전 3턴 평균 (감정이 나아지는 추세)
844
+ 조건4: refine 5턴째면 무조건 완료 선택지(1·2·3 무시)
845
+ 반환: (제안할까, 강제5턴인가, 사유)"""
846
+ rt = state["refine_turns"]
847
+ # 조건4 — 5턴째 강제
848
+ if rt >= HARD_DONE_REFINE:
849
+ return True, True, "5턴_강제선택"
850
+ # 조건1
851
+ if rt < COND_MIN_REFINE:
852
+ return False, False, ""
853
+ # 표현 후보가 하나라도 있어야(다듬은 결과)
854
+ if not state["candidates"]:
855
+ return False, False, ""
856
+ # 조건2 — 극단 부정 해소
857
+ if eva <= EXTREME_NEG_EVA:
858
+ return False, False, "조건2미충족(아직 격한 부정)"
859
+ # 조건3 — 직전 3턴 평균 이상
860
+ hist = state["eva_hist"]
861
+ if len(hist) >= 3:
862
+ prev3 = hist[-3:]
863
+ if eva < sum(prev3) / 3:
864
+ return False, False, "조건3미충족(감정 추세 하락)"
865
+ # 1·2·3 모두 충족
866
+ return True, False, "조건123충족"
867
+
868
+ # ── 길2: 표현 후보 수집 ── (측정으로 best를 '선정'하지 않음 — 명백히 표현이 아닌 것만 제외하고 모두 수집)
869
+ # 측정은 어순/구조를 못 보므로(shuffle 검증) best 자동 선정 불가. 대신 사용자가 완성 시 직접 선택.
870
+ # 순수 메타 발화(코칭 소감)만 제외 — '좋았/고마워'가 표현 내용에 들어간 경우는 후보로 살림.
871
+ PURE_META = ["고마워", "고맙습니다", "감사", "도움이 됐", "도움이 되었", "도움 됐", "알겠어", "알겠습니다",
872
+ "이제 알", "정리됐어", "정리되었", "기분이 나아", "덕분"]
873
+ def is_pure_meta(t):
874
+ """발화 전체가 코칭에 대한 소감인지(표현이 아니라). 짧고 메타 표현으로만 구성."""
875
+ s = t.strip()
876
+ if len(s) <= 12 and any(s.startswith(w) or s == w or w in s for w in PURE_META):
877
+ return True
878
+ return False
879
+
880
+ def is_coach_question(t):
881
+ """코치에게 던진 질문(조언 요청)인지 — '전할 표현'이 아니라 '어떻게 해야 하냐'는 물음.
882
+ 예: '나는 어떻게 해야해?', '와이프한테 어떻게 해야 마음을 열 수 있어?', '이럴 땐 어떡해?'
883
+ 이런 건 상대에게 전할 말이 아니므로 완성 후보에서 제외."""
884
+ s = t.strip()
885
+ # 물음표로 끝나고 + 조언/방법을 구하는 표현이 있으면 코치 질문으로 간주
886
+ asks_how = any(k in s for k in ["어떻게 해", "어떡해", "어떻게 하", "어떻해", "어찌해", "어째",
887
+ "방법이 뭐", "방법 좀", "어떻게 풀", "어떻게 대처",
888
+ "뭐라고 해야", "뭐라 해야", "어떤 말을 해야", "조언", "어떻게 말해"])
889
+ asks_self = any(k in s for k in ["나는 어떻게", "내가 어떻게", "난 어떻게", "어떻게 해야 할", "어떻게 해야 돼", "어떻게 해야해"])
890
+ if (s.endswith("?") or s.endswith("?")) and (asks_how or asks_self):
891
+ return True
892
+ if asks_self: # '나는 어떻게 해야해' 류는 물음표 없어도
893
+ return True
894
+ if any(k in s for k in ["조언", "어떻게 해야", "어떡하면", "어떻게 하면 좋"]): # 명시적 조언 요청은 물음표 없어도
895
+ return True
896
+ return False
897
+
898
+ def is_expression_candidate(user_msg, said_done, is_choice):
899
+ """'전할 표현 후보'로 모을지 — 짧은 답/순수메타/선택지/확정어/코치질문만 제외, 나머지는 모두 후보.
900
+ (측정값으로 거르지 않음: 신호강도가 표현 완성도를 반영하지 못함이 확인됨.
901
+ '좋았/고마워'가 표현 내용에 든 경우는 살림 — 순수 메타 발화만 제외)"""
902
+ t = user_msg.strip()
903
+ if said_done or is_choice:
904
+ return False
905
+ if len(t) < 6: # 너무 짧은 단어 조각/맞장구 제외
906
+ return False
907
+ if is_pure_meta(t): # 순수 코칭 소감만 제외
908
+ return False
909
+ if is_coach_question(t): # 코치에게 한 질문(조언 요청)은 '전할 표현'이 아니므로 제외
910
+ return False
911
+ # 단순 맞장구·되묻기(표현 아님) 제외
912
+ fillers = ["응 맞아", "그러게", "그치", "맞아 그래서", "뭐라는", "그건 왜", "그게 다야", "응 그래"]
913
+ if any(t == f or t.startswith(f + " ") for f in fillers):
914
+ return False
915
+ return True
916
+
917
+ def add_candidate(state, user_msg, m, strength):
918
+ """후보 추가(중복 제외). 텍스트 + 측정값을 함께 저장. 최신이 뒤로."""
919
+ t = user_msg.strip()
920
+ for c in state["candidates"]:
921
+ if c["text"] == t:
922
+ return
923
+ state["candidates"].append({
924
+ "text": t,
925
+ "EVA": round(float(m["EVA"]), 4), "EAR": round(float(m["EAR"]), 4),
926
+ "VAL": round(float(m["VAL"]), 4), "REI": round(float(m["REI"]), 4),
927
+ "strength": round(float(strength), 4),
928
+ "turn": state["turns"],
929
+ })
930
+ # 너무 많아지면 최근 6개만(완성 시 선택 부담 줄임)
931
+ if len(state["candidates"]) > 6:
932
+ state["candidates"] = state["candidates"][-6:]
933
+
934
+ # 사용자의 명시적 '확정/충분' 의사 (더 다듬을 게 없다는 명확한 신호만 — 맞장구성 '맞아/응'은 제외)
935
+ DONE_WORDS = ["없는", "없어", "없을", "없다", "충분", "이대로", "이거면", "됐어", "됐고", "이게 다",
936
+ "그게 다", "다인거 같", "다인 것 같", "끝", "이 정도면", "이거로", "이걸로", "그만"]
937
+ # 더 다듬겠다는 의사
938
+ MORE_WORDS = ["더", "바꾸", "다시", "고치", "조금", "다른", "추가", "보태"]
939
+ # 메타 대화(코칭 자체에 대한 응답: 고마움·소감) — '전할 표현'이 아니므로 best 추적에서 제외
940
+ META_WORDS = ["고마", "감사", "도움", "알게", "알겠", "좋았", "좋네", "괜찮았", "덕분", "이제 알", "정리됐", "정리되"]
941
+
942
+ def _cos(a, b):
943
+ if a is None or b is None: return 0.0
944
+ return float(a @ b / ((norm(a) * norm(b)) + 1e-8))
945
+
946
+ SCAFFOLD_CHOICES = ["① 고마운데 뭔가 아쉬운", "② 서운한데 말하기 미안한", "③ 나도 내 마음을 잘 모르겠는"]
947
+
948
+ # 상대 반응 예측을 유도하는 문장 — 생성 응답에서 이런 문장이 나오면 제거(프롬프트가 안 먹혀서 코드로 강제)
949
+ import re as _re
950
+ def strip_prediction(text):
951
+ """상대 반응 예측 유도 문장을 제거. 프롬프트로 막아도 LLM이 어겨서 코드로 강제 차단.
952
+ 문장 단위로 쪼개, '상대(연인 등) + 반응/받아들임/생각' 류를 묻는 의문문이면 그 문장을 뺀다."""
953
+ # 문장 분리(물음표·마침표·줄바꿈 기준, 구분자 유지)
954
+ parts = _re.split(r'(?<=[.!??\n])', text)
955
+ OTHER = ("연인", "상대", "그 사람", "남자친구", "여자친구", "그분", "그가", "그녀",
956
+ "가족", "친구", "동료", "엄마", "아빠", "부모", "남편", "아내", "선배", "후배", "그들")
957
+ REACT = ("반응", "받아들", "어떻게 들", "어떻게 생각", "어떻게 느", "어떤 마음", "어떤 생각")
958
+ kept = []
959
+ for s in parts:
960
+ is_q = ("?" in s) or ("?" in s)
961
+ about_other = any(o in s for o in OTHER)
962
+ about_react = any(r in s for r in REACT)
963
+ # 상대의 반응을 묻는 의문문이면 제거
964
+ if is_q and about_other and about_react:
965
+ continue
966
+ # '상상해/예상해/떠올려'+반응 류도 제거(상대 명시 없어도)
967
+ if is_q and any(g in s for g in ("상상해", "예상해", "떠올려")) and any(r in s for r in ("반응", "받아들")):
968
+ continue
969
+ kept.append(s)
970
+ out = "".join(kept)
971
+ out = _re.sub(r"\n{3,}", "\n\n", out).strip()
972
+ if out:
973
+ return out
974
+ # 전부 제거됨(= 응답이 상대반응 질문뿐이었음) → 자기표현으로 돌리는 중립 멘트로 대체
975
+ return "지금 표현에 당신의 마음이 잘 담긴 것 같아요. 혹시 더 보태고 싶은 말이 있으면 편하게 적어주세요."
976
+
977
+ COMPLETE_CLARITY_THRESHOLD = 0.7 # best_strength 이 미만이면 A(또렷함 낮음), 이상이면 B(또렷함 높음). 실사용으로 조정.
978
+
979
+ def make_completion_message(chosen_text, grew=False):
980
+ """완성 마무리를 코드가 직접 생성(LLM 안 씀 → 상대 반응 질문 구조적 차단).
981
+ 길2: 사용자가 직접 고른 표현(chosen_text)을 인용해 따뜻하게 마무리."""
982
+ bt = (chosen_text or "").strip()
983
+ quote = f'"{bt}"\n\n' if bt else ""
984
+ grew_line = ""
985
+ if grew:
986
+ grew_line = "\n\n처음의 막연한 마음에서 여기까지, 표현이 한 걸음 또렷해졌어요."
987
+ body = ("이 표현에 당신의 마음이 잘 담겼어요. "
988
+ "막막했던 마음을 이렇게 한 문장으로 정리해낸 것 자체가 의미 있는 일이에요.\n\n"
989
+ "준비됐을 때, 편하게 전해보세요. 잘 해내실 거예요. 🙂")
990
+ return f"{quote}{body}{grew_line}"
991
+
992
+ # ── 길2: 후보 선택 버튼 ──
993
+ MAX_CAND = 6 # 후보 버튼 최대 개수
994
+
995
+ def make_candidate_updates(state): # [미사용] 0.8.2부터 @gr.render로 대체
996
+ """후보 버튼들 + '더 다듬기' 버튼의 gr.update 리스트 생성.
997
+ 반환: [버튼1..버튼MAX_CAND, btn_more] (총 MAX_CAND+1개)"""
998
+ cands = state.get("candidates", [])[-MAX_CAND:]
999
+ updates = []
1000
+ for i in range(MAX_CAND):
1001
+ if i < len(cands):
1002
+ txt = cands[i]["text"]
1003
+ label = txt if len(txt) <= 40 else txt[:38] + "…"
1004
+ updates.append(gr.update(value=label, visible=True))
1005
+ else:
1006
+ updates.append(gr.update(visible=False))
1007
+ # 마지막: '더 다듬어볼래요' 버튼(완성 선택 시 항상 표시)
1008
+ updates.append(gr.update(visible=True))
1009
+ return updates
1010
+
1011
+ OPENING = ("안녕하세요. 여기서는 '마음에 있는데, 어떻게 말해야 할지 막막한 이야기'를 같이 정리해볼 수 있어요.\n\n"
1012
+ "지금 내 상황과 가장 가까운 걸 아래에서 하나 골라주세요. 고르고 나면, 무슨 일이 있었는지 편하게 적어보시면 돼요.\n"
1013
+ "(딱 맞는 게 없어도 괜찮아요 — 가장 가까운 걸 고르거나 맨 아래 '여러 감정이 섞여서…'를 골라주세요.)")
1014
+
1015
+ def respond(user_msg, chat, state, is_choice=False):
1016
+ if not user_msg or not user_msg.strip():
1017
+ return (chat, state, "", gr.update(visible=False), gr.update(), gr.update())
1018
+ state["turns"] += 1
1019
+ if state["session"] is None:
1020
+ state["session"] = _uuid.uuid4().hex[:12]
1021
+ m = ENGINE.measure(user_msg)
1022
+ strength = signal_strength(m)
1023
+ # 반어 의심 신호 — 측정 직후, eva_hist에 이번 값 넣기 전(직전 흐름 기준). 기록만, 측정값 불변.
1024
+ irony_sig = irony_suspect(user_msg, m["EVA"], state["eva_all"])
1025
+ state["irony_flag"] = irony_sig["level"]
1026
+ mdesc = describe_measure(m)
1027
+ tnd = tendency_of(m) # VAL/REI 방향 성향 (실험적, 보조 코칭용)
1028
+ show_choices = False
1029
+ show_confirm = False # 완성 제안 시 "더 해볼래요/충분해요" 버튼 표시 여부
1030
+ said_done_now = any(w in user_msg for w in DONE_WORDS)
1031
+ is_meta = any(w in user_msg for w in META_WORDS)
1032
+ # 비계 선택지 문구는 사용자 표현이 아니므로 best에서 제외(버튼 클릭 또는 문구 일치)
1033
+ is_choice = is_choice or (user_msg.strip() in SCAFFOLD_CHOICES)
1034
+ phase_before = state["phase"] # 이번 발화가 도착했을 때의 단계(전환 판단용)
1035
+ # 나침반 좌표 누적(EVA=x, EAR=y)
1036
+ state["coords"].append((m["EVA"], m["EAR"], strength))
1037
+ state["eva_all"].append(m["EVA"]) # 모든 발화 EVA 누적(반어 판정용 — 다음 발화가 이번 흐름을 봄)
1038
+
1039
+ # 흐름 분기
1040
+ if state["phase"] == "start":
1041
+ # 처음 적기 → 신호강도로 막막/또렷 분기
1042
+ state["first_strength"] = strength
1043
+ state["first_text"] = user_msg
1044
+ state["max_strength"] = max(state["max_strength"], strength)
1045
+ if strength >= CLEAR_THRESHOLD:
1046
+ # 또렷 → 바로 다듬기로
1047
+ state["phase"] = "refine"
1048
+ sysp = build_coach_prompt("graduate", mdesc, tendency=tnd, emotions=state.get("emotions"))
1049
+ # 첫 발화가 또렷하면 후보로 수집(표현일 수 있음)
1050
+ if is_expression_candidate(user_msg, said_done_now, is_choice):
1051
+ add_candidate(state, user_msg, m, strength)
1052
+ else:
1053
+ # 막막 → 비계
1054
+ state["phase"] = "scaffold"
1055
+ sysp = build_coach_prompt("scaffold", mdesc, tendency=tnd, emotions=state.get("emotions"))
1056
+ show_choices = True
1057
+ elif state["phase"] == "scaffold":
1058
+ # 비계 중 — 신호 또렷해지면 졸업, 아니면 계속 비계
1059
+ state["max_strength"] = max(state["max_strength"], strength)
1060
+ if strength >= CLEAR_THRESHOLD or any(k in user_msg for k in [
1061
+ "서운", "속상", "화나", "화가", "짜증", "섭섭", "아쉬", "답답", "억울", "미안", "속상해", "슬프"]):
1062
+ state["phase"] = "refine"
1063
+ sysp = build_coach_prompt("graduate", mdesc, tendency=tnd, emotions=state.get("emotions"))
1064
+ # 졸업을 일으킨 발화를 후보로 수집
1065
+ if is_expression_candidate(user_msg, said_done_now, is_choice):
1066
+ add_candidate(state, user_msg, m, strength)
1067
+ else:
1068
+ sysp = build_coach_prompt("scaffold", mdesc, tendency=tnd, emotions=state.get("emotions"))
1069
+ show_choices = True
1070
+ elif state["phase"] == "refine":
1071
+ state["refine_turns"] += 1
1072
+ state["eva_hist"].append(m["EVA"]) # 조건3용 EVA 이력
1073
+ state["max_strength"] = max(state["max_strength"], strength)
1074
+ # 길2: 표현 후보 수집(측정으로 거르지 않음 — 명백히 표현 아닌 것만 제외)
1075
+ if is_expression_candidate(user_msg, said_done_now, is_choice):
1076
+ add_candidate(state, user_msg, m, strength)
1077
+ state["stall_count"] = 0
1078
+ else:
1079
+ state["stall_count"] += 1
1080
+ # 완성 4조건 판정 (명확한 규칙)
1081
+ suggest, hard5, why = completion_conditions(m["EVA"], state)
1082
+ # 사용자가 직접 "충분/없어" 확정하면 바로 완성 선택으로(조건과 별개)
1083
+ said_more = any(w in user_msg for w in MORE_WORDS)
1084
+ explicit_done = said_done_now and not said_more and bool(state["candidates"])
1085
+ if explicit_done or suggest:
1086
+ # 완성 시점 — 사용자가 표현을 직접 선택(길2)
1087
+ state["phase"] = "confirm"
1088
+ sysp = "PICK_EXPRESSION" # 후보 선택 UI 표시(코드 처리)
1089
+ show_confirm = True
1090
+ state["_hard5"] = hard5 and not explicit_done
1091
+ else:
1092
+ grew = ""
1093
+ if state["first_strength"] is not None and state["max_strength"] > state["first_strength"] + 0.2:
1094
+ grew = f"(처음보다 또렷해짐 — 성장 비춰주기)"
1095
+ sysp = build_coach_prompt("refine", mdesc + " " + grew, tendency=tnd, emotions=state.get("emotions"))
1096
+ elif state["phase"] == "confirm":
1097
+ # 완성 제안 단계 — 보통은 버튼으로 처리되지만, 텍스트로 답한 경우 대비
1098
+ affirm = any(w in user_msg for w in DONE_WORDS) or any(k in user_msg for k in ["응", "네", "그래", "좋아", "맞아", "알았", "그치", "그러"])
1099
+ more = any(w in user_msg for w in MORE_WORDS) or ("아니" in user_msg and "아니야" not in user_msg[:4])
1100
+ # 새 표현을 입력했으면 후보에 추가(긍정/더 아닌 실제 표현)
1101
+ if is_expression_candidate(user_msg, said_done_now, is_choice) and not affirm:
1102
+ add_candidate(state, user_msg, m, strength)
1103
+ if more and not affirm:
1104
+ state["phase"] = "refine"
1105
+ state["stall_count"] = 0
1106
+ sysp = build_coach_prompt("refine", mdesc, tendency=tnd, emotions=state.get("emotions"))
1107
+ else:
1108
+ # 다시 후보 선택 UI 표시
1109
+ sysp = "PICK_EXPRESSION"
1110
+ show_confirm = True
1111
+ elif state["phase"] == "complete":
1112
+ # 이미 마무리함 → 반복하지 않고 짧게 응답(종료 상태 유지)
1113
+ sysp = "DONE_ALREADY"
1114
+ else:
1115
+ sysp = build_coach_prompt("refine", mdesc, tendency=tnd, emotions=state.get("emotions"))
1116
+
1117
+ # 응답 생성:
1118
+ # - sysp == "PICK_EXPRESSION": 완성 시점 → 사용자가 표현 직접 선택(길2). 후보 안내 멘트.
1119
+ # - sysp == "DONE_ALREADY": 이미 마무리함 → 반복 없이 짧은 종료 멘트
1120
+ # - 그 외: LLM 생성 후 '상대 반응 예측' 문장 후처리 제거
1121
+ if sysp == "PICK_EXPRESSION":
1122
+ hard5 = state.get("_hard5", False)
1123
+ if hard5:
1124
+ reply = ("여러 표현을 다듬어보셨어요. 충분히 마음이 정리된 것 같아요.\n"
1125
+ "아래에서 가장 마음에 드는 표현을 골라주세요. 더 다듬고 싶으면 '더 해볼래요'를 눌러주세요.")
1126
+ else:
1127
+ reply = ("지금까지 마음을 잘 표현해주셨어요.\n"
1128
+ "아래에서 그 사람에게 전하고 싶은 표현을 골라주세요. 더 다듬고 싶으면 '더 해볼래요'를 눌러주세요.")
1129
+ elif sysp == "DONE_ALREADY":
1130
+ reply = "이미 마음을 잘 정리하셨어요. 준비됐을 때 전해보세요. 새로 이야기하고 싶으면 페이지를 새로고침해 주세요. 🙂"
1131
+ else:
1132
+ reply = gemini(sysp, chat, user_msg)
1133
+ reply = strip_prediction(reply)
1134
+ chat = chat + [{"role": "user", "content": user_msg},
1135
+ {"role": "assistant", "content": reply}]
1136
+ # 측정값·변수 저장(원문 제외, 길이만) — 갱신된 phase/refine_turns 반영
1137
+ try:
1138
+ STORE.log_turn(state["session"], m, strength, state, utter_len=len(user_msg.strip()), kind="user", text=user_msg.strip())
1139
+ except Exception as e:
1140
+ if DEBUG:
1141
+ print("저장 실패:", e)
1142
+ # 나침반 갱신(길2: best 별표 없음 — 측정이 best를 모름)
1143
+ fig = render_compass(state["coords"], None)
1144
+ cap = compass_caption(state["coords"], None)
1145
+ dbg = debug_line(m, strength, state)
1146
+ if dbg:
1147
+ cap = cap + "\n\n" + dbg
1148
+ # 완성 선택 UI는 @gr.render가 state["show_confirm"]/candidates를 보고 그림
1149
+ state["show_confirm"] = show_confirm
1150
+ # 완성 시점이면 힌트 1개 생성(사용자 발화를 전달용으로 보수적으로 다듬은 예시).
1151
+ # 후보가 발화 원문 그대로라 그 자체로는 '전할 표현'이 되기 어려움 → LLM이 다듬어 예시 제공.
1152
+ if show_confirm:
1153
+ cands_for_hint = state.get("candidates", [])[-MAX_CAND:]
1154
+ state["hint"] = make_hint(cands_for_hint, state.get("emotions"))
1155
+ else:
1156
+ state["hint"] = None
1157
+ state["hard5_flag"] = state.get("_hard5", False)
1158
+ return (chat, state, "", gr.update(visible=show_choices), fig, cap)
1159
+
1160
+ def pick_choice(choice, chat, state):
1161
+ # 비계 선택지 — 측정은 하되(분기용) best 추적에서는 제외(is_choice=True)
1162
+ return respond(choice, chat, state, is_choice=True)
1163
+
1164
+ def choose_more(chat, state):
1165
+ """완성 시점에 '더 해볼래요' → refine 복귀(고도화 기회)."""
1166
+ state["phase"] = "refine"
1167
+ state["stall_count"] = 0
1168
+ state["show_confirm"] = False # 완성 선택 UI 닫기
1169
+ reply = ("좋아요. 그럼 조금 더 다듬어볼까요? 지금 표현에서 더 보태거나 바꾸고 싶은 부분, "
1170
+ "또는 아직 못 한 말이 있다면 편하게 적어주세요.")
1171
+ chat = chat + [{"role": "assistant", "content": reply}]
1172
+ fig = render_compass(state["coords"], None)
1173
+ cap = compass_caption(state["coords"], None)
1174
+ return (chat, state, "", gr.update(visible=False), fig, cap)
1175
+
1176
+ def choose_candidate(idx, chat, state):
1177
+ """후보 선택 → 그 표현으로 마무리(길2의 핵심). 선택 데이터 저장."""
1178
+ cands = state.get("candidates", [])[-MAX_CAND:]
1179
+ if idx < 0 or idx >= len(cands):
1180
+ return (chat, state, "", gr.update(visible=False), gr.update(), gr.update())
1181
+ chosen = cands[idx]
1182
+ state["chosen_text"] = chosen["text"]
1183
+ state["chosen_meta"] = chosen
1184
+ state["phase"] = "complete"
1185
+ state["done_shown"] = True
1186
+ state["show_confirm"] = False # 완성 선택 UI 닫기
1187
+ # 선택 결과 저장(측정값 + 원문)
1188
+ try:
1189
+ STORE.log_selection(state["session"], cands, idx, emotions=state.get("emotions"))
1190
+ except Exception as e:
1191
+ if DEBUG:
1192
+ print("선택 저장 실패:", e)
1193
+ grew = (state["first_strength"] is not None and state["max_strength"] > state["first_strength"] + 0.25)
1194
+ reply = make_completion_message(chosen["text"], grew=grew)
1195
+ chat = chat + [{"role": "assistant", "content": reply}]
1196
+ fig = render_compass(state["coords"], None)
1197
+ cap = compass_caption(state["coords"], None)
1198
+ return (chat, state, "", gr.update(visible=False), fig, cap)
1199
+
1200
+ def choose_hint(hint_text, chat, state):
1201
+ """LLM이 다듬은 힌트를 사용자가 선택 → 그 표현으로 마무리.
1202
+ 힌트 선택 여부도 기록(데이터 분석: 사람이 원문 vs 다듬은 힌트 중 무엇을 고르나)."""
1203
+ state["chosen_text"] = hint_text
1204
+ state["chosen_meta"] = {"text": hint_text, "is_hint": True}
1205
+ state["phase"] = "complete"
1206
+ state["done_shown"] = True
1207
+ state["show_confirm"] = False
1208
+ # 힌트 선택 기록 — log_selection에 힌트를 후보로 추가해 'chosen=힌트'로 저장
1209
+ try:
1210
+ cands = state.get("candidates", [])[-MAX_CAND:]
1211
+ hint_cand = {"text": hint_text, "EVA": 0.0, "EAR": 0.0, "VAL": 0.0, "REI": 0.0,
1212
+ "strength": 0.0, "turn": state.get("turns"), "is_hint": True}
1213
+ cands_with_hint = cands + [hint_cand]
1214
+ STORE.log_selection(state["session"], cands_with_hint, len(cands_with_hint) - 1,
1215
+ emotions=state.get("emotions"))
1216
+ except Exception as e:
1217
+ if DEBUG:
1218
+ print("힌트 선택 저장 실패:", e)
1219
+ reply = make_completion_message(hint_text, grew=False)
1220
+ chat = chat + [{"role": "assistant", "content": reply}]
1221
+ fig = render_compass(state["coords"], None)
1222
+ cap = compass_caption(state["coords"], None)
1223
+ return (chat, state, "", gr.update(visible=False), fig, cap)
1224
+
1225
+ def toggle_emotion(emotion_key, state):
1226
+ """시작 화면에서 감정 주제 토글(선택/해제). 임시 선택 emo_pick만 갱신 → @gr.render 재실행으로 강조 반영.
1227
+ 아직 '시작하기' 전이므로 채팅은 시작하지 않음.
1228
+ ※ Gradio가 state 변경을 확실히 감지하도록 새 dict를 반환(같은 객체 반환 시 재렌더 누락 위험)."""
1229
+ if emotion_key not in EMOTIONS:
1230
+ return state
1231
+ pick = list(state.get("emo_pick", []))
1232
+ if emotion_key in pick:
1233
+ pick.remove(emotion_key) # 다시 누르면 해제
1234
+ else:
1235
+ if len(pick) < 3: # 너무 많이 고르면 초점 흐려짐 → 최대 3개
1236
+ pick.append(emotion_key)
1237
+ new_state = dict(state) # 새 객체로 복사 → 변경 감지 보장
1238
+ new_state["emo_pick"] = pick
1239
+ return new_state
1240
+
1241
+ def start_with_emotions(chat, state):
1242
+ """'시작하기' — 선택한 감정들을 확정하고 코칭 시작. 0개여도 시작 가능(바로 상황 적기)."""
1243
+ state["emotions"] = list(state.get("emo_pick", []))
1244
+ state["topic_chosen"] = True
1245
+ n = len(state["emotions"])
1246
+ if n == 0:
1247
+ reply = ("편하게 시작해볼게요. 어떤 일이 있었는지, 그때 마음이 어땠는지 적어보세요. "
1248
+ "짧아도 괜찮아요. (적는 것만으로도 마음이 조금 정리될 거예요.)")
1249
+ else:
1250
+ reply = ("그런 마음이셨군요. 여기서 함께 천천히 들여다볼게요.\n\n"
1251
+ "어떤 일이 있었는지, 그때 마음이 어땠는지 편하게 적어보세요. 짧아도 괜찮아요. "
1252
+ "(적는 것만으로도 마음이 조금 정리될 거예요.)")
1253
+ chat = chat + [{"role": "assistant", "content": reply}]
1254
+ return (chat, state, "", gr.update(visible=False), gr.update(), gr.update())
1255
+
1256
+ # ============================== UI ==============================
1257
+ def build_app():
1258
+ with gr.Blocks(title="표현 코칭 (최소 흐름)") as demo:
1259
+ gr.Markdown("## 표현 코칭 — 마음을 정리하고 표현 다듬기\n*하고 싶은 말을 함께 찾아가는 곳*")
1260
+ state = gr.State(new_state())
1261
+ outs_holder = {} # 핸들러 출력 리스트를 런타임에 담아 @gr.render가 참조
1262
+ with gr.Row():
1263
+ with gr.Column(scale=3):
1264
+ _cb_kwargs = dict(value=[{"role": "assistant", "content": OPENING}],
1265
+ height=440, show_label=False)
1266
+ try:
1267
+ if int(gr.__version__.split(".")[0]) < 6:
1268
+ _cb_kwargs["type"] = "messages"
1269
+ except Exception:
1270
+ pass
1271
+ chatbot = gr.Chatbot(**_cb_kwargs)
1272
+ # 시작: 감정 주제 다중선택 (@gr.render — topic_chosen 전까지 표시)
1273
+ # 토글 버튼(선택 시 강조) + '시작하기'. inputs=state로 로드 시 렌더 + 선택(state 변경) 시 재렌더.
1274
+ @gr.render(inputs=state)
1275
+ def render_topics(s):
1276
+ if s and s.get("topic_chosen"):
1277
+ return # 이미 시작했으면 숨김
1278
+ _outs = outs_holder.get("outs")
1279
+ pick = (s.get("emo_pick", []) if s else [])
1280
+ gr.Markdown("**지금 내 마음에 가까운 걸 모두 골라주세요** (여러 개 선택 가능, 최대 3개)")
1281
+ for key in EMOTION_ORDER:
1282
+ em = EMOTIONS[key]
1283
+ selected = key in pick
1284
+ # 선택되면 primary(강조)+체크, 아니면 secondary
1285
+ label = ("✓ " + em["label"]) if selected else em["label"]
1286
+ b = gr.Button(label, size="sm",
1287
+ variant=("primary" if selected else "secondary"))
1288
+ # 토글: state만 갱신 → 재렌더로 강조 반영 (채팅 시작 안 함)
1289
+ b.click(lambda st, _k=key: toggle_emotion(_k, st), [state], [state])
1290
+ # 선택 요약 + 시작 버튼
1291
+ if pick:
1292
+ names = ", ".join(EMOTIONS[k]["label"].split("·")[0].strip() for k in pick)
1293
+ gr.Markdown(f"선택: **{names}**")
1294
+ start = gr.Button("시작하기 →", variant="primary")
1295
+ start.click(start_with_emotions, [chatbot, state], _outs)
1296
+ gr.Markdown("<sub>딱 맞는 게 없으면 — 아무것도 고르지 않고 바로 '시작하기'를 눌러도 돼요.</sub>")
1297
+ # 비계 선택지(막막할 때)
1298
+ with gr.Row(visible=False) as choice_row:
1299
+ c1 = gr.Button(SCAFFOLD_CHOICES[0], size="sm")
1300
+ c2 = gr.Button(SCAFFOLD_CHOICES[1], size="sm")
1301
+ c3 = gr.Button(SCAFFOLD_CHOICES[2], size="sm")
1302
+ # 길2: 완성 시점 — 표현 후보 선택 (@gr.render 동적 생성, 이 위치에 그려짐)
1303
+ # 초기 visible=False 컴포넌트 업데이트 실패 문제를 회피: state를 보고 매번 새로 그림.
1304
+ @gr.render(inputs=state, triggers=[chatbot.change])
1305
+ def render_confirm(s):
1306
+ if not s or not s.get("show_confirm"):
1307
+ return
1308
+ cands = s.get("candidates", [])[-MAX_CAND:]
1309
+ if not cands:
1310
+ return
1311
+ if s.get("hard5_flag"):
1312
+ gr.Markdown("**여러 표현을 다듬어보셨어요. 가장 마음에 드는 표현을 골라주세요** ↓")
1313
+ else:
1314
+ gr.Markdown("**전하고 싶은 표현을 골라주세요** ↓")
1315
+ _outs = outs_holder.get("outs")
1316
+ # 힌트(LLM이 사용자 발화를 전달용으로 다듬은 예시) — 후보 위에, 구분해서 표시
1317
+ hint = s.get("hint")
1318
+ if hint:
1319
+ gr.Markdown(f"<sub>💡 이렇게 말해볼 수도 있어요 (예시일 뿐이에요)</sub>")
1320
+ hb = gr.Button(f"💡 {hint}", size="sm", variant="primary")
1321
+ hb.click(lambda ch, st, _h=hint: choose_hint(_h, ch, st),
1322
+ [chatbot, state], _outs)
1323
+ gr.Markdown("<sub>— 또는 내가 한 말 중에서 고르기 —</sub>")
1324
+ for i, c in enumerate(cands):
1325
+ txt = c["text"]
1326
+ label = txt if len(txt) <= 40 else txt[:38] + "…"
1327
+ b = gr.Button(label, size="sm")
1328
+ b.click(lambda ch, st, _i=i: choose_candidate(_i, ch, st),
1329
+ [chatbot, state], _outs)
1330
+ mb = gr.Button("✏️ 더 다듬어볼래요", size="sm", variant="secondary")
1331
+ mb.click(choose_more, [chatbot, state], _outs)
1332
+ with gr.Row():
1333
+ msg = gr.Textbox(show_label=False, placeholder="마음을 편하게 적어보세요…", scale=8)
1334
+ send = gr.Button("보내기", variant="primary", scale=1, min_width=70)
1335
+ with gr.Column(scale=2):
1336
+ gr.Markdown("**🧭 감정 나침반** — 내 마음이 어디서 어���로 움직이는지")
1337
+ compass = gr.Plot(show_label=False)
1338
+ compass_cap = gr.Markdown("아직 좌표가 없어요. 마음을 적으면 나침반에 표시됩니다.")
1339
+ gr.Markdown("<sub>측정: KoSimCSE(감정·타이밍) · 표현 선택: 사용자 · 응답: Gemini · 대화 내용과 측정값은 서비스 개선을 위해 저장됩니다.</sub>")
1340
+
1341
+ # 출력: chatbot, state, msg, choice_row, compass, compass_cap (6개)
1342
+ outs = [chatbot, state, msg, choice_row, compass, compass_cap]
1343
+ outs_holder["outs"] = outs # @gr.render 내부 핸들러가 런타임에 참조
1344
+
1345
+ msg.submit(respond, [msg, chatbot, state], outs)
1346
+ send.click(respond, [msg, chatbot, state], outs)
1347
+ c1.click(pick_choice, [c1, chatbot, state], outs)
1348
+ c2.click(pick_choice, [c2, chatbot, state], outs)
1349
+ c3.click(pick_choice, [c3, chatbot, state], outs)
1350
+ return demo
1351
+
1352
+ if __name__ == "__main__":
1353
+ build_app().launch()