Spaces:
Running
Running
Upload 2 files
Browse files- app_coach.py +12 -5
- 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.
|
| 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 |
-
|
|
|
|
| 542 |
한글 폰트가 없는 환경(HF Space) 대비, 차트 내 텍스트는 영어/기호만 사용."""
|
| 543 |
fig, ax = plt.subplots(figsize=(4.2, 4.2), dpi=100)
|
| 544 |
-
lim =
|
|
|
|
| 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 |
-
|
| 559 |
-
|
|
|
|
| 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()
|