Spaces:
Sleeping
Sleeping
Upload app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,1201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
자기인식 지원 앱 — 지인 데이터 수집판 (P-25-0322) v0.3-collect
|
| 4 |
+
================================================================================
|
| 5 |
+
데이터 플라이휠 + 영구 저장 + 안전 고지.
|
| 6 |
+
· 측정: KoSimCSE 임베딩으로 가치·인지·감정 4벡터 독립 측정
|
| 7 |
+
· 라벨: 측정값을 '보여주기 전' 독립 자기보고 → 깨끗한 검증쌍
|
| 8 |
+
· 저장: 데이터를 두 스트림으로 분리·구조화하여 HF Dataset에 영구 저장
|
| 9 |
+
- sessions.jsonl : 세션·온보딩 기준선 (1행/세션)
|
| 10 |
+
- labels.jsonl : 라벨 검증쌍 (1행/라벨 턴)
|
| 11 |
+
· 안전: 위기 안내 + 동의 고지(동의 시에만 발화 저장)
|
| 12 |
+
|
| 13 |
+
운영(영구 저장 켜기):
|
| 14 |
+
Space Secret 에 HF_TOKEN(쓰기 권한) 과 HF_DATASET_REPO("아이디/데이터셋명") 설정.
|
| 15 |
+
미설정 시 로컬 파일로만 저장(Colab은 세션 보존, Spaces는 재시작 시 초기화).
|
| 16 |
+
|
| 17 |
+
⚠️ 실험 도구이며 상담·치료가 아님. 발화는 민감정보 — 동의·익명화 전제. 정신건강 위기는 전문기관으로.
|
| 18 |
+
================================================================================
|
| 19 |
+
"""
|
| 20 |
+
import numpy as np
|
| 21 |
+
from numpy.linalg import norm
|
| 22 |
+
import os, json, time, uuid, datetime
|
| 23 |
+
from contextlib import nullcontext
|
| 24 |
+
|
| 25 |
+
VERSION = "0.4.15-consent-save" # 데이터 저장 문제 수정: 동의 체크박스 기본 ON(value=True, 깜빡 방지)+안내문 '원치 않으면 해제'로 변경. do_chat이 미동의 시 채팅 아래 저장 경고 표시. 입력창 아래 '💾 내 데이터 저장 상태 확인' 버튼 추가(저장 ON/OFF·참가자코드·턴수 안내). # (4)Gemini thinking 끔(thinkingBudget=0)+출력상한500 → 토큰·비용·지연 감소. (2)EVA/EAR 자기보고 발화앵커 구체화. (3)단일발화: 짧은발화 길이가중 억제(6자미만 제외)+EVA/EAR 평활(나침반 안흔들림), reveal은 원시값 유지. # 온보딩 Q1~Q4 강도별 4지선다(2→4, 기준선 정밀화). EAR 고각성 라벨에 분노 추가(매우 들뜸·긴장·분노). # 측정 나침반(심플 3축 막대 EVA·EAR·VAL, REI제외; EVA강도신뢰·EAR/VAL방향위주; profile_md→gr.HTML). 성찰질문형 넛지(주제당1회·재청시재제공·조언금지·안전우선). # 자기보고 4지선다(중간 제거, EVA/EAR/VAL/REI+PILOT). VAL 질문 직관화. build_prompt: 약간 긍정적 존댓말+닉네임 호칭+관심 말투, 3턴부터 사용자 유형별 대화 전략. # 체크박스 첫 선택 지연 수정: CheckboxGroup을 빈 choices 대신 공통항목으로 초기화(프라이밍) → 첫 선택부터 즉시 렌더. on_domain은 도메인 선택 시 도메인 항목 추가. # 관심사 체크박스: on_domain이 gr.update 대신 새 CheckboxGroup 인스턴스 반환(6.0에서 빈 choices 갱신 확실). + 진단 로그(이벤트 발동·반환 개수). # 6.0 대응(트레이스백 기반): content가 리스트로 와 .strip() 크래시(=원래 2턴 오류) → content 정규화 헬퍼. Chatbot type 버전조건부(6.0은 생략). README는 버전 핀 제거(4.44.1은 Py3.13 audioop 비호환 → 작동하던 6.x 사용).
|
| 26 |
+
|
| 27 |
+
# ============================== 설정 ==============================
|
| 28 |
+
ENCODER = "BM-K/KoSimCSE-roberta-multitask" # 또는 "BAAI/bge-m3"
|
| 29 |
+
USE_SENTENCE_TRANSFORMERS = ENCODER.startswith("BAAI")
|
| 30 |
+
LLM_MODE = "gemini" # "template" | "gemini" | "local"
|
| 31 |
+
W_BASE = 6.0
|
| 32 |
+
GATE = 0.5 # (구) 하드 신뢰 게이트 — 누적에선 소프트 가중(|coord|)으로 대체됨
|
| 33 |
+
LEN_REF = 150 # 길이 정규화 기준(자): 초과 발화는 가중치를 완만히 감소(긴 글의 분석 편향 완화)
|
| 34 |
+
MIN_MEASURE_LEN = 6 # 이 미만(자)은 측정 신뢰 불가 → 누적·평활에서 제외(가중 0). 데이터: 짧은 발화 어휘노이즈
|
| 35 |
+
RELIABLE_LEN = 18 # 이 이상이면 측정 신뢰(full 가중). MIN~RELIABLE은 약하게 합성(짧을수록 약하게)
|
| 36 |
+
FREE_WEIGHT = 0.5
|
| 37 |
+
DATA_DIR = "data" # 수집 데이터 폴더(분리 저장)
|
| 38 |
+
HF_DATASET_REPO = os.environ.get("HF_DATASET_REPO", "") # 예: "myid/selfaware-data"
|
| 39 |
+
|
| 40 |
+
# Gemini 토큰 단가(USD per 1M tokens): (입력, 출력). 출력엔 thinking 토큰 포함해 합산.
|
| 41 |
+
GEMINI_PRICES = {
|
| 42 |
+
"gemini-2.5-flash": (0.30, 2.50),
|
| 43 |
+
"gemini-2.5-flash-lite": (0.10, 0.40),
|
| 44 |
+
"gemini-flash-latest": (0.30, 2.50),
|
| 45 |
+
}
|
| 46 |
+
def _gemini_cost(model, tin, tout):
|
| 47 |
+
pin, pout = GEMINI_PRICES.get(model, (0.30, 2.50))
|
| 48 |
+
return tin / 1e6 * pin + tout / 1e6 * pout
|
| 49 |
+
|
| 50 |
+
# ============================== 축 정의 문항(한국어 1인칭) ==============================
|
| 51 |
+
CONSTRUCTS = {
|
| 52 |
+
"VAL": {"label": ("자율 지향", "순응 지향"), "pos": [
|
| 53 |
+
"나는 내 삶을 어떻게 살지 스스로 결정하는 것을 좋아한다.",
|
| 54 |
+
"새롭고 독창적인 생각을 떠올리는 것이 나에게 중요하다.",
|
| 55 |
+
"나는 무엇이든 내 방식대로 해결하는 편이다.",
|
| 56 |
+
"내 목표를 스스로 자유롭게 선택하는 것이 중요하다.",
|
| 57 |
+
"남에게 묻기보다 내 판단을 믿고 따르는 편이다.",
|
| 58 |
+
"나는 호기심을 따라 새로운 것을 탐험하는 것을 가치 있게 여긴다.",
|
| 59 |
+
"누가 알려주기보다 세상을 스스로 이해하고 싶다.",
|
| 60 |
+
"나는 독립적으로 일을 해내는 것에 자부심을 느낀다.",
|
| 61 |
+
"남을 따라 하기보다 내 방식을 새로 만드는 편이 좋다.",
|
| 62 |
+
"내 인생의 방향을 스스로 정하는 것이 무척 중요하다.",
|
| 63 |
+
"나는 창의적으로, 내 식대로 시도하는 것을 좋아한다.",
|
| 64 |
+
"나에게 무엇이 옳은지 스스로 판단할 수 있다고 믿는다.",
|
| 65 |
+
], "neg": [
|
| 66 |
+
"나는 사람은 규칙을 잘 지켜야 한다고 생각한다.",
|
| 67 |
+
"시키는 일을 잘 따르는 것이 나에게 중요하다.",
|
| 68 |
+
"나는 예의 바르게 행동하고 잘못된 일을 하지 않으려 애쓴다.",
|
| 69 |
+
"물려받은 전통과 관습을 지키는 것이 나에게 중요하다.",
|
| 70 |
+
"나는 권위와 윗사람을 존중해야 한다고 믿는다.",
|
| 71 |
+
"전통을 존중하는 것을 중요하게 여긴다.",
|
| 72 |
+
"나는 튀기보다 집단에 어울리는 편을 택한다.",
|
| 73 |
+
"정해진 방식을 함부로 의심하지 않는 편이 낫다고 생각한다.",
|
| 74 |
+
"나에게 기대되는 바를 지키는 것이 옳게 느껴진다.",
|
| 75 |
+
"나는 순종적이고 믿음직한 사람이 되는 것을 가치 있게 여긴다.",
|
| 76 |
+
"사회 규범에는 이유가 있으니 지켜야 한다고 믿는다.",
|
| 77 |
+
"나에게는 새로움보다 안정과 질서를 지키는 것이 더 중요하다.",
|
| 78 |
+
# 선택-동사 순응 보강 — 진단 결과 '능동 선택형 순응'(남들 따라 고르기)이
|
| 79 |
+
# 자율로 오측정되던 문제 완화. 독립 데이터 검증 AUC 0.90→0.95.
|
| 80 |
+
"남들이 많이 사는 물건을 골라서 산다.",
|
| 81 |
+
"유행하는 쪽을 보고 그대로 선택한다.",
|
| 82 |
+
"다수가 정한 방향에 내 결정을 맞춘다.",
|
| 83 |
+
"주변에서 고르는 것을 보고 똑같이 고른다.",
|
| 84 |
+
"나도 남들 하는 대로 무난한 쪽을 택한다.",
|
| 85 |
+
"사람들이 좋다고 하는 것을 골라 산다.",
|
| 86 |
+
"내 취향보다 대세를 따라 고르는 편이다.",
|
| 87 |
+
"남들 눈을 의식해서 선택을 정한다.",
|
| 88 |
+
]},
|
| 89 |
+
"REI": {"label": ("분석적", "직관적"), "pos": [
|
| 90 |
+
"나는 깊이 생각해야 하는 문제를 즐긴다.",
|
| 91 |
+
"행동하기 전에 상황을 논리적으로 분석하는 것을 좋아한다.",
|
| 92 |
+
"나는 단계를 밟아 차근차근 따져보는 편이다.",
|
| 93 |
+
"복잡한 문제를 깊이 고민하는 것이 만족스럽다.",
|
| 94 |
+
"나는 결론을 내릴 때 논리와 근거에 의존한다.",
|
| 95 |
+
"문제를 부분으로 나누어 분석하는 것을 좋아한다.",
|
| 96 |
+
"어려운 지적 과제를 풀어내는 것을 즐긴다.",
|
| 97 |
+
"결정할 때 감정보다 이성을 더 믿는다.",
|
| 98 |
+
"나는 결정 전에 장단점을 신중히 따져본다.",
|
| 99 |
+
"추상적이고 분석적인 사고가 즐겁다.",
|
| 100 |
+
"분명한 근거로 뒷받침된 결론을 선호한다.",
|
| 101 |
+
"무언가의 논리를 풀어내면 만족스럽다.",
|
| 102 |
+
"느낌이 좋았지만 가격을 비교하고 구매했다.",
|
| 103 |
+
"느낌보다 데이터를 우선해 전공을 정했다.",
|
| 104 |
+
"감으로 판단하지 않고 사실을 확인했다.",
|
| 105 |
+
"직감이 들어도 먼저 수치를 확인한다.",
|
| 106 |
+
"막연한 느낌을 숫자로 바꿔 확인했다.",
|
| 107 |
+
"직감이 나빴지만 안전 데이터를 검증했다.",
|
| 108 |
+
"감보다 근거를 보고 이직을 결정했다.",
|
| 109 |
+
"느낌에 기대지 않고 논문을 분석했다.",
|
| 110 |
+
"느낌이 와도 계약 조건을 따져본다.",
|
| 111 |
+
"직감을 가설로 세워 실험을 설계했다.",
|
| 112 |
+
], "neg": [
|
| 113 |
+
"나는 보통 직감에 따라 행동한다.",
|
| 114 |
+
"나는 종종 무엇이 옳은지 그냥 느낌으로 안다.",
|
| 115 |
+
"분석보다 첫인상에 더 의존하는 편이다.",
|
| 116 |
+
"나는 많은 결정을 느낌에 따라 내린다.",
|
| 117 |
+
"설명할 수 없어도 내 직관을 믿는다.",
|
| 118 |
+
"내 예감은 대체로 들어맞는다.",
|
| 119 |
+
"나는 그 순간 옳게 느껴지는 대로 하는 편이다.",
|
| 120 |
+
"나는 상황을 본능으로 읽는다.",
|
| 121 |
+
"나는 결정을 느낌으로 더듬어 가는 편이 좋다.",
|
| 122 |
+
"나는 감정과 인상에 따라 선택하곤 한다.",
|
| 123 |
+
"따져보지 않아도 옳다는 걸 아는 경우가 많다.",
|
| 124 |
+
"나는 신중한 분석보다 직관에 더 의존한다.",
|
| 125 |
+
]},
|
| 126 |
+
"EVA": {"label": ("긍정", "부정"), "pos": [
|
| 127 |
+
"지금 나는 즐겁고 기분이 좋다.", "따뜻한 만족감이 느껴진다.",
|
| 128 |
+
"오늘 나는 희망차고 밝다.", "나는 흐뭇하고 만족스럽다.",
|
| 129 |
+
"나는 고맙고 뿌듯하다.", "좋고 기분 좋은 느낌이 함께한다.",
|
| 130 |
+
"나는 행복하고 마음이 가볍다.", "나는 흡족하고 긍정적이다.",
|
| 131 |
+
"지금 내 안에 기쁨이 있다.", "나는 즐겁고 편안하다.",
|
| 132 |
+
"나는 감사하고 마음이 따뜻하다.", "밝은 안녕감이 느껴진다.",
|
| 133 |
+
], "neg": [
|
| 134 |
+
"지금 나는 시무룩하고 우울하다.", "무거운 슬픔이 느껴진다.",
|
| 135 |
+
"오늘 나는 낙담하고 서럽다.", "나는 언짢고 불만스럽다.",
|
| 136 |
+
"나는 비참하고 의기소침하다.", "나쁘고 불쾌한 느낌이 함께한다.",
|
| 137 |
+
"나는 침울하고 풀이 죽었다.", "나는 속상하고 부정적이다.",
|
| 138 |
+
"지금 내 안에 슬픔이 있다.", "나는 슬프고 마음이 불편하다.",
|
| 139 |
+
"나는 씁쓸하고 마음이 차갑다.", "어두운 괴로움이 느껴진다.",
|
| 140 |
+
]},
|
| 141 |
+
"EAR": {"label": ("고각성", "저각성"), "pos": [
|
| 142 |
+
"나는 활력이 넘치고 또렷이 깨어 있다.", "나는 들뜨고 잔뜩 흥분돼 있다.",
|
| 143 |
+
"내 몸이 활성화되고 긴장돼 있다.", "나는 격렬하고 잔뜩 달아올라 있다.",
|
| 144 |
+
"나는 안절부절못하며 에너지가 넘친다.", "심장이 빠르게 뛰는 것 같다.",
|
| 145 |
+
"나는 긴장되고 크게 각성돼 있다.", "나는 안달이 나고 자극받아 있다.",
|
| 146 |
+
"나는 한껏 충전돼 들떠 있다.", "나는 곤두서고 신경이 팽팽하다.",
|
| 147 |
+
"몸에 각성이 솟구치는 느낌이다.", "나는 또렷하고 활기차게 깨어 있다.",
|
| 148 |
+
], "neg": [
|
| 149 |
+
"나는 차분하고 고요하다.", "나는 졸리고 느긋하다.",
|
| 150 |
+
"나는 조용하고 가라앉아 있다.", "나는 나른하고 멍하다.",
|
| 151 |
+
"내 에너지가 낮고 느리게 느껴진다.", "나는 누그러지고 서두르지 않는다.",
|
| 152 |
+
"나는 평온하고 쉬고 있다.", "나는 축 늘어지고 무겁다.",
|
| 153 |
+
"나는 잔잔하고 조용하다.", "나는 졸음이 오고 잦아든다.",
|
| 154 |
+
"깊은 고요함이 느껴진다.", "나는 느긋하고 반쯤 잠든 듯하다.",
|
| 155 |
+
]},
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
# ============================== 온보딩(선택지 = 구성-순수 문장) ==============================
|
| 159 |
+
ONBOARD = [
|
| 160 |
+
{"construct": "VAL", "q": "Q1. 중요한 결정을 앞두고 가까운 사람이 당신과 '다른' 의견을 강하게 말합니다. 당신은?", "choices": [
|
| 161 |
+
("끝까지 내 판단대로 한다", "나는 주변이 강하게 반대해도 끝까지 내 판단대로 결정한다."),
|
| 162 |
+
("대체로 내 생각을 따른다", "나는 주변 의견을 듣더라도 대체로 내 생각을 따라 결정하는 편이다."),
|
| 163 |
+
("주변 의견을 꽤 반영한다", "나는 결정할 때 주변의 의견을 꽤 반영해 조정하는 편이다."),
|
| 164 |
+
("주변 뜻에 맞춰 따른다", "나는 중요한 결정에서 주변의 뜻에 맞춰 따르는 편이다."),
|
| 165 |
+
]},
|
| 166 |
+
{"construct": "VAL", "q": "Q2. 익숙한 안정을 포기해야 새로운 기회를 잡을 수 있습니다. 당신은?", "choices": [
|
| 167 |
+
("과감히 새로 도전한다", "나는 안정을 포기하더라도 과감히 내 방식대로 새롭게 도전한다."),
|
| 168 |
+
("어느 정도 시도해본다", "나는 안정을 조금 양보하더라도 새로운 기회를 어느 정도 시도하는 편이다."),
|
| 169 |
+
("대체로 안정을 지킨다", "나는 새로운 기회보다 대체로 안정과 익숙함을 지키는 편이다."),
|
| 170 |
+
("안정과 조화를 지킨다", "나는 새로운 기회보다 안정과 사람들과의 조화를 확실히 지킨다."),
|
| 171 |
+
]},
|
| 172 |
+
{"construct": "REI", "q": "Q3. 처음 보는 복잡한 문제를 풀어야 합니다. 당신의 첫 반응은?", "choices": [
|
| 173 |
+
("철저히 분석부터 한다", "나는 복잡한 문제를 만나면 자료를 모아 철저히 논리적으로 분석한다."),
|
| 174 |
+
("우선 근거를 따져본다", "나는 복잡한 문제를 만나면 우선 근거를 따져보며 차근차근 접근하는 편이다."),
|
| 175 |
+
("대체로 직감을 따른다", "나는 복잡한 문제를 만나면 대체로 전체 느낌과 직감을 따르는 편이다."),
|
| 176 |
+
("바로 직감으로 간다", "나는 복잡한 문제를 만나면 바로 전체 느낌을 잡아 직감으로 판단한다."),
|
| 177 |
+
]},
|
| 178 |
+
{"construct": "REI", "q": "Q4. 큰 선택에서 '근거'와 '직감'이 서로 다른 방향을 가리킵니다. 당신은?", "choices": [
|
| 179 |
+
("확실히 근거를 따른다", "나는 근거와 데이터가 가리키는 방향을 확실히 따라 결정한다."),
|
| 180 |
+
("대체로 근거를 따른다", "나는 근거와 직감이 부딪치면 대체로 근거 쪽을 따르는 편이다."),
|
| 181 |
+
("대체로 직감을 따른다", "나는 근거와 직감이 부딪치면 대체로 직감 쪽을 따르는 편이다."),
|
| 182 |
+
("확실히 직감을 따른다", "나는 직감과 느낌이 가리키는 방향을 확실히 따라 결정한다."),
|
| 183 |
+
]},
|
| 184 |
+
]
|
| 185 |
+
|
| 186 |
+
# ============================== 라벨 수집 설계 ==============================
|
| 187 |
+
LABEL_SCHEMES = {
|
| 188 |
+
"EVA": {"q": "잠깐 — 방금 그 말을 할 때, 기분은 어느 쪽에 더 가까웠나요?",
|
| 189 |
+
"opts": [("매우 슬픔", -2), ("약간 슬픔", -1), ("약간 즐거움", 1), ("매우 즐거움", 2)],
|
| 190 |
+
"poles": ("즐거운", "슬픈"), "reveal_lead": "기분을 ‘{}’ 쪽으로"},
|
| 191 |
+
"EAR": {"q": "잠깐 — 방금 그 말을 할 때, 마음의 에너지는 어느 쪽이었나요?",
|
| 192 |
+
"opts": [("매우 차분·처짐", -2), ("약간 가라앉음", -1), ("약간 들뜸", 1), ("매우 들뜸·긴장·분노", 2)],
|
| 193 |
+
"poles": ("들뜬·긴장된", "차분한"), "reveal_lead": "에너지를 ‘{}’ 쪽으로"},
|
| 194 |
+
# VAL 직관화: '어땠나요'(추상) → '방금 한 그 일이 누구의 뜻에 가까웠나'(구체).
|
| 195 |
+
"VAL": {"q": "잠깐 — 방금 말씀하신 그 일은 누구의 뜻에 더 가까웠나요? (내 뜻대로 ↔ 남·상황에 맞춰)",
|
| 196 |
+
"opts": [("전적으로 남·상황에 맞춤", -2), ("주로 맞춤", -1), ("주로 내 뜻대로", 1), ("전적으로 내 뜻대로", 2)],
|
| 197 |
+
"poles": ("주도적인", "맞춰주는"), "reveal_lead": "태도를 ‘{}’ 쪽으로"},
|
| 198 |
+
# REI: '말투'(문체) 기준. 측정이 분석↔직관 어휘/문체를 보므로 자기보고도 문체를 묻도록 정렬.
|
| 199 |
+
"REI": {"q": "잠깐 — 방금 ‘말투’는 어느 쪽에 더 가깝나요?",
|
| 200 |
+
"opts": [("매우 직감적 말투", -2), ("약간 직감적", -1), ("약간 분석적", 1), ("매우 분석적 말투", 2)],
|
| 201 |
+
"poles": ("분석적인", "직감적인"), "reveal_lead": "말투를 ‘{}’ 쪽으로"},
|
| 202 |
+
}
|
| 203 |
+
_AXIS_CYCLE = ["EVA", "VAL", "EAR", "REI"]
|
| 204 |
+
def pick_axis(n): return _AXIS_CYCLE[(n - 1) % len(_AXIS_CYCLE)]
|
| 205 |
+
def _now(): return datetime.datetime.now().isoformat(timespec="seconds")
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# ============================== 관심사(2단계 계층형) — 측정 아님: 자기보고 + 대화 맥락 ==============================
|
| 209 |
+
# 1단계 가치영역(우선순위) → 2단계 행복/방해(공통4 + 1순위 분류별3). VAL/REI 측정 축은 건드리지 않음.
|
| 210 |
+
VALUE_DOMAINS = [
|
| 211 |
+
("🏡 가정·관계", "family"), ("🏆 성공·성취", "achievement"), ("🕊️ 자유·자기실현", "freedom"),
|
| 212 |
+
("🌿 안정·평온", "stability"), ("📚 배움·성장", "growth"), ("💪 건강", "health"), ("🎉 재미·즐거움", "fun"),
|
| 213 |
+
]
|
| 214 |
+
COMMON_HAPPY = [("🤝 가까운 사람과 함께할 때", "relation"), ("💗 몸과 마음이 건강할 때", "health"),
|
| 215 |
+
("😄 즐겁고 재미있는 경험을 할 때", "fun"), ("🌿 안정되고 여유로울 때", "stability")]
|
| 216 |
+
DOMAIN_HAPPY = {
|
| 217 |
+
"family": [("👨👩👧 가족과 따뜻한 시간을 보낼 때", "family"), ("💞 소중한 사람에게 힘이 되어줄 때", "relation"), ("🫂 사람들과 깊이 연결됐다고 느낄 때", "relation")],
|
| 218 |
+
"achievement": [("🎯 스스로 정한 목표를 이뤄낼 때", "achievement"), ("📈 실력이 늘고 성장하는 게 느껴질 때", "growth"), ("🏅 노력을 인정받을 때", "recognition")],
|
| 219 |
+
"freedom": [("🧭 스스로 결정하고 선택할 때", "autonomy"), ("✨ 새로운 걸 시도하고 도전할 때", "openness"), ("🪶 무엇에도 얽매이지 않을 때", "autonomy")],
|
| 220 |
+
"stability": [("🛏️ 마음이 편안하고 걱정 없을 때", "stability"), ("🍵 느긋하게 쉴 여유가 있을 때", "stability"), ("🏠 일상이 안정적으로 돌아갈 때", "stability")],
|
| 221 |
+
"growth": [("💡 새로운 걸 배우고 깨달을 때", "growth"), ("🔍 호기심이 채워질 때", "growth"), ("🌱 어제보다 나아진 나를 느낄 때", "growth")],
|
| 222 |
+
"health": [("🏃 몸이 가볍고 활력 있을 때", "health"), ("🧘 마음이 건강하고 단단할 때", "health"), ("😴 잘 쉬고 회복됐을 때", "health")],
|
| 223 |
+
"fun": [("🎮 좋아하는 걸 즐길 때", "fun"), ("🎨 무언가에 몰입하고 빠져들 때", "flow"), ("🤣 마음껏 웃을 때", "fun")],
|
| 224 |
+
}
|
| 225 |
+
COMMON_BARRIER = [("💸 경제적 압박·돈 걱정", "money"), ("⏰ 시간이 부족하고 쫓길 때", "time"),
|
| 226 |
+
("😔 몸이 지치고 아플 때", "health"), ("🪞 나도 모르게 남과 비교하게 될 때", "comparison")]
|
| 227 |
+
DOMAIN_BARRIER = {
|
| 228 |
+
"family": [("💔 가까운 사람과 갈등·서운함이 있을 때", "relation"), ("🫥 외롭거나 단절된 느낌일 때", "relation"), ("🤐 마음을 나눌 사람이 없을 때", "relation")],
|
| 229 |
+
"achievement": [("📉 노력만큼 성과가 안 날 때", "achievement"), ("🪫 실력이 정체된 느낌일 때", "growth"), ("🥀 인정받지 못한다고 느낄 때", "recognition")],
|
| 230 |
+
"freedom": [("⛓️ 내 뜻대로 못 하고 얽매일 때", "autonomy"), ("🚧 선택의 여지가 ��을 때", "autonomy"), ("📋 정해진 틀에 갇힌 느낌일 때", "autonomy")],
|
| 231 |
+
"stability": [("🌪️ 일상이 흔들리고 불안정할 때", "stability"), ("😰 마음이 쉴 틈 없이 불안할 때", "anxiety"), ("🌫️ 미래가 불확실하게 느껴질 때", "uncertainty")],
|
| 232 |
+
"growth": [("🧱 배우고 싶은데 여건이 안 될 때", "growth"), ("😶🌫️ 제자리걸음 같다고 느낄 때", "growth"), ("❓ 뭘 해야 할지 막막할 때", "uncertainty")],
|
| 233 |
+
"health": [("🤒 몸이 자주 아프거나 무거울 때", "health"), ("😣 잠을 못 자고 회복이 안 될 때", "health"), ("🥵 체력이 달릴 때", "health")],
|
| 234 |
+
"fun": [("🫠 즐길 여유나 흥미가 없을 때", "fun"), ("😑 모든 게 지루하게 느껴질 때", "fun"), ("🪫 좋아하던 것도 시큰둥할 때", "fun")],
|
| 235 |
+
}
|
| 236 |
+
_DOMAIN_TAG = {lbl: tag for lbl, tag in VALUE_DOMAINS}
|
| 237 |
+
_HAPPY_TAG = {lbl: tag for lbl, tag in COMMON_HAPPY}
|
| 238 |
+
_BARRIER_TAG = {lbl: tag for lbl, tag in COMMON_BARRIER}
|
| 239 |
+
for _v in DOMAIN_HAPPY.values(): _HAPPY_TAG.update({l: t for l, t in _v})
|
| 240 |
+
for _v in DOMAIN_BARRIER.values(): _BARRIER_TAG.update({l: t for l, t in _v})
|
| 241 |
+
_COMMON_HAPPY_SET = {l for l, _ in COMMON_HAPPY}
|
| 242 |
+
_COMMON_BARRIER_SET = {l for l, _ in COMMON_BARRIER}
|
| 243 |
+
def happy_choices(tag): return [l for l, _ in COMMON_HAPPY + DOMAIN_HAPPY.get(tag, [])]
|
| 244 |
+
def barrier_choices(tag): return [l for l, _ in COMMON_BARRIER + DOMAIN_BARRIER.get(tag, [])]
|
| 245 |
+
def _txt(s): return s.split(" ", 1)[1] if " " in s else s # 이모지 제거(프롬프트용)
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# ============================== 데이터 저장소(분리 스트림 + HF Dataset) ==============================
|
| 249 |
+
class DataStore:
|
| 250 |
+
"""수집 데이터를 sessions/labels 두 스트림으로 분리·구조화. 동의 시에만 발화 저장. HF Dataset 영구화."""
|
| 251 |
+
def __init__(self, data_dir=DATA_DIR, repo=HF_DATASET_REPO, version=VERSION, encoder=ENCODER):
|
| 252 |
+
self.dir = data_dir; os.makedirs(data_dir, exist_ok=True)
|
| 253 |
+
self.labels_path = os.path.join(data_dir, "labels.jsonl")
|
| 254 |
+
self.sessions_path = os.path.join(data_dir, "sessions.jsonl")
|
| 255 |
+
self.interests_path = os.path.join(data_dir, "interests.jsonl")
|
| 256 |
+
self.responses_path = os.path.join(data_dir, "responses.jsonl")
|
| 257 |
+
self.reveals_path = os.path.join(data_dir, "reveals.jsonl")
|
| 258 |
+
self.pilot_path = os.path.join(data_dir, "pilot_labels.jsonl") # 연구 파일럿: 4축 자기라벨 + 측정 + LLM 점수 (원문 미저장)
|
| 259 |
+
self.pilot_saved = 0
|
| 260 |
+
self.version, self.encoder = version, encoder
|
| 261 |
+
self.stats = [] # 실시간 통계용(발화 없음, PII 아님)
|
| 262 |
+
self.resp_stats = [] # 응답 모델/폴백 집계(PII 아님)
|
| 263 |
+
self.tok_in = 0; self.tok_out = 0; self.cost_total = 0.0 # 실측 토큰·비용 누적
|
| 264 |
+
self.saved = 0
|
| 265 |
+
self.scheduler = None
|
| 266 |
+
if repo and os.environ.get("HF_TOKEN"):
|
| 267 |
+
try:
|
| 268 |
+
from huggingface_hub import CommitScheduler
|
| 269 |
+
self.scheduler = CommitScheduler(repo_id=repo, repo_type="dataset",
|
| 270 |
+
folder_path=data_dir, path_in_repo="data",
|
| 271 |
+
every=5, private=True)
|
| 272 |
+
print("HF Dataset 동기화 ON:", repo)
|
| 273 |
+
except Exception as e:
|
| 274 |
+
print("HF Dataset 동기화 OFF:", e)
|
| 275 |
+
if os.path.exists(self.labels_path):
|
| 276 |
+
try:
|
| 277 |
+
for ln in open(self.labels_path, encoding="utf-8"):
|
| 278 |
+
r = json.loads(ln); ax = r["labeled_axis"]
|
| 279 |
+
self.stats.append({"axis": ax, "coord": float(r["coord_" + ax]),
|
| 280 |
+
"label_value": int(r["label_value"])})
|
| 281 |
+
self.saved += 1
|
| 282 |
+
except Exception:
|
| 283 |
+
pass
|
| 284 |
+
if os.path.exists(self.responses_path):
|
| 285 |
+
try:
|
| 286 |
+
for ln in open(self.responses_path, encoding="utf-8"):
|
| 287 |
+
r = json.loads(ln)
|
| 288 |
+
self.resp_stats.append({"model": r.get("response_model"),
|
| 289 |
+
"depth": int(r.get("fallback_depth", 0))})
|
| 290 |
+
self.tok_in += int(r.get("prompt_tokens", 0) or 0)
|
| 291 |
+
self.tok_out += int(r.get("output_tokens", 0) or 0)
|
| 292 |
+
self.cost_total += float(r.get("cost_usd", 0) or 0)
|
| 293 |
+
except Exception:
|
| 294 |
+
pass
|
| 295 |
+
|
| 296 |
+
def _write(self, path, rec):
|
| 297 |
+
lock = self.scheduler.lock if self.scheduler else nullcontext()
|
| 298 |
+
with lock:
|
| 299 |
+
with open(path, "a", encoding="utf-8") as f:
|
| 300 |
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
| 301 |
+
|
| 302 |
+
def save_session(self, session, participant, consent, baseline, onboard_rows):
|
| 303 |
+
if not consent:
|
| 304 |
+
return
|
| 305 |
+
self._write(self.sessions_path, {
|
| 306 |
+
"type": "session", "ts": int(time.time()), "datetime": _now(),
|
| 307 |
+
"session": session, "participant": participant or None,
|
| 308 |
+
"encoder": self.encoder, "app_version": self.version, "consent": True,
|
| 309 |
+
"baseline_VAL": round(float(baseline[0]), 4), "baseline_REI": round(float(baseline[1]), 4),
|
| 310 |
+
"onboard": onboard_rows})
|
| 311 |
+
|
| 312 |
+
def save_interests(self, session, participant, domains, happy, barriers, consent):
|
| 313 |
+
if not consent:
|
| 314 |
+
return
|
| 315 |
+
self._write(self.interests_path, {
|
| 316 |
+
"type": "interests", "ts": int(time.time()), "datetime": _now(),
|
| 317 |
+
"session": session, "participant": participant or None,
|
| 318 |
+
"domains": domains, "happy": happy, "barriers": barriers,
|
| 319 |
+
"app_version": self.version})
|
| 320 |
+
|
| 321 |
+
def add_label(self, session, participant, turn, utt, coords, coords_cum, axis, label, value, consent):
|
| 322 |
+
self.stats.append({"axis": axis, "coord": float(coords[axis]),
|
| 323 |
+
"coord_cum": float(coords_cum[axis]), "label_value": int(value)})
|
| 324 |
+
if not consent:
|
| 325 |
+
return
|
| 326 |
+
self._write(self.labels_path, {
|
| 327 |
+
"type": "label", "ts": int(time.time()), "datetime": _now(),
|
| 328 |
+
"session": session, "participant": participant or None, "turn": int(turn),
|
| 329 |
+
"char_len": len(utt),
|
| 330 |
+
"coord_VAL": round(float(coords["VAL"]), 4), "coord_REI": round(float(coords["REI"]), 4),
|
| 331 |
+
"coord_EVA": round(float(coords["EVA"]), 4), "coord_EAR": round(float(coords["EAR"]), 4),
|
| 332 |
+
"coord_VAL_cum": round(float(coords_cum["VAL"]), 4), "coord_REI_cum": round(float(coords_cum["REI"]), 4),
|
| 333 |
+
"coord_EVA_cum": round(float(coords_cum["EVA"]), 4), "coord_EAR_cum": round(float(coords_cum["EAR"]), 4),
|
| 334 |
+
"labeled_axis": axis, "label_text": label, "label_value": int(value),
|
| 335 |
+
"agree": (None if value == 0 else bool((coords[axis] >= 0) == (value > 0))),
|
| 336 |
+
"agree_cum": (None if value == 0 else bool((coords_cum[axis] >= 0) == (value > 0))),
|
| 337 |
+
"encoder": self.encoder, "app_version": self.version})
|
| 338 |
+
self.saved += 1
|
| 339 |
+
|
| 340 |
+
def add_reveal(self, session, participant, turn, axis, measured_sign, measured_label, self_value, fit_value, consent):
|
| 341 |
+
# (ii) 측정 공개 후 "이게 맞나요?" 재질문. 편향없는 자기보고(labels.jsonl)와 분리된 2차 검증 신호.
|
| 342 |
+
# fit_value: +1 맞음 / 0 모름 / -1 틀림 (사용자가 측정을 본 뒤의 직접 판단)
|
| 343 |
+
if not consent:
|
| 344 |
+
return
|
| 345 |
+
self._write(self.reveals_path, {
|
| 346 |
+
"type": "reveal", "ts": int(time.time()), "datetime": _now(),
|
| 347 |
+
"session": session, "participant": participant or None, "turn": int(turn),
|
| 348 |
+
"labeled_axis": axis, "measured_sign": int(measured_sign), "measured_label": measured_label,
|
| 349 |
+
"self_report_value": int(self_value), "fit_value": int(fit_value),
|
| 350 |
+
"encoder": self.encoder, "app_version": self.version})
|
| 351 |
+
|
| 352 |
+
def add_pilot_label(self, session, participant, turn, char_len, ko, ko_cum, llm, self_labels, consent, act_self=None):
|
| 353 |
+
# 연구 파일럿 — 4축 자기라벨(-2..+2) + KoSimCSE 측정 + LLM(Gemini) 채점(-100..+100).
|
| 354 |
+
# act_self: ACT(신체 활성도) 5번째 축 자기보고(-2..+2) 또는 None. 검증 중 — 측정/LLM은 4축 유지.
|
| 355 |
+
# 프로토콜 5.6: 수집 시점에 점수 계산, 발화 원문은 저장하지 않음(길이만).
|
| 356 |
+
self.pilot_saved += 1
|
| 357 |
+
if not consent:
|
| 358 |
+
return
|
| 359 |
+
rec = {
|
| 360 |
+
"type": "pilot_label", "ts": int(time.time()), "datetime": _now(),
|
| 361 |
+
"session": session, "participant": participant or None, "turn": int(turn),
|
| 362 |
+
"char_len": int(char_len),
|
| 363 |
+
"ko_VAL": round(float(ko["VAL"]), 4), "ko_REI": round(float(ko["REI"]), 4),
|
| 364 |
+
"ko_EVA": round(float(ko["EVA"]), 4), "ko_EAR": round(float(ko["EAR"]), 4),
|
| 365 |
+
"ko_VAL_cum": (round(float(ko_cum["VAL"]), 4) if ko_cum.get("VAL") is not None else None),
|
| 366 |
+
"ko_REI_cum": (round(float(ko_cum["REI"]), 4) if ko_cum.get("REI") is not None else None),
|
| 367 |
+
"ko_EVA_cum": round(float(ko_cum["EVA"]), 4), "ko_EAR_cum": round(float(ko_cum["EAR"]), 4),
|
| 368 |
+
"self_VAL": int(self_labels["VAL"]), "self_REI": int(self_labels["REI"]),
|
| 369 |
+
"self_EVA": int(self_labels["EVA"]), "self_EAR": int(self_labels["EAR"]),
|
| 370 |
+
"self_ACT": (int(act_self) if act_self is not None else None),
|
| 371 |
+
"llm_VAL": (round(float(llm["VAL"]), 1) if llm else None),
|
| 372 |
+
"llm_REI": (round(float(llm["REI"]), 1) if llm else None),
|
| 373 |
+
"llm_EVA": (round(float(llm["EVA"]), 1) if llm else None),
|
| 374 |
+
"llm_EAR": (round(float(llm["EAR"]), 1) if llm else None),
|
| 375 |
+
"llm_model": ("gemini" if llm else None),
|
| 376 |
+
"encoder": self.encoder, "app_version": self.version}
|
| 377 |
+
self._write(self.pilot_path, rec)
|
| 378 |
+
|
| 379 |
+
def add_response(self, session, participant, turn, model, depth, char_len, consent, usage=None):
|
| 380 |
+
# 응답 LLM ���델/폴백/실측 토큰·비용 기록 — 발화 원문 없음(PII 아님)
|
| 381 |
+
self.resp_stats.append({"model": model, "depth": int(depth)})
|
| 382 |
+
tin = int((usage or {}).get("in", 0) or 0)
|
| 383 |
+
tout = int((usage or {}).get("out", 0) or 0)
|
| 384 |
+
cost = _gemini_cost(model, tin, tout) if model else 0.0
|
| 385 |
+
self.tok_in += tin; self.tok_out += tout; self.cost_total += cost
|
| 386 |
+
if not consent:
|
| 387 |
+
return
|
| 388 |
+
self._write(self.responses_path, {
|
| 389 |
+
"type": "response", "ts": int(time.time()), "datetime": _now(),
|
| 390 |
+
"session": session, "participant": participant or None, "turn": int(turn),
|
| 391 |
+
"char_len": int(char_len), "response_model": model, "fallback_depth": int(depth),
|
| 392 |
+
"prompt_tokens": tin, "output_tokens": tout, "cost_usd": round(cost, 6),
|
| 393 |
+
"ok": bool(model is not None), "app_version": self.version})
|
| 394 |
+
|
| 395 |
+
def _resp_section(self):
|
| 396 |
+
if not self.resp_stats:
|
| 397 |
+
return ""
|
| 398 |
+
from collections import Counter
|
| 399 |
+
mc = Counter((r["model"] or "실패") for r in self.resp_stats)
|
| 400 |
+
fb = sum(1 for r in self.resp_stats if r["depth"] >= 1 and r["model"] is not None)
|
| 401 |
+
fail = sum(1 for r in self.resp_stats if r["model"] is None)
|
| 402 |
+
out = [f"\n**응답 모델 사용** (총 {len(self.resp_stats)}턴):"]
|
| 403 |
+
for mdl, ct in mc.most_common():
|
| 404 |
+
out.append(f"- {mdl}: {ct}회")
|
| 405 |
+
out.append(f"- 폴백 발생 {fb}회 · 응답 실패 {fail}회")
|
| 406 |
+
if self.tok_in or self.tok_out:
|
| 407 |
+
out.append(f"- 실측 토큰 누적: 입력 {self.tok_in:,} · 출력 {self.tok_out:,}")
|
| 408 |
+
out.append(f"- 실측 API 비용 누적: ${self.cost_total:.4f} (≈{self.cost_total*1380:,.0f}원)")
|
| 409 |
+
return "\n".join(out)
|
| 410 |
+
|
| 411 |
+
def summary_md(self):
|
| 412 |
+
head = ("🟢 HF Dataset 영구 저장 ON" if self.scheduler
|
| 413 |
+
else "🟡 로컬 저장만 — Spaces는 재시작 시 초기화 (HF_TOKEN·HF_DATASET_REPO 설정 시 영구화)")
|
| 414 |
+
if not self.stats:
|
| 415 |
+
return (f"### 측정 검증 데이터\n{head}\n\n아직 라벨이 없습니다. 대화 후 뜨는 자기보고를 눌러 보세요."
|
| 416 |
+
+ self._resp_section())
|
| 417 |
+
from collections import defaultdict
|
| 418 |
+
by = defaultdict(list)
|
| 419 |
+
for r in self.stats: by[r["axis"]].append(r)
|
| 420 |
+
lines = [f"### 측정 검증 데이터 (라벨 {len(self.stats)}개)", "실사용 발화 측정-자기보고 일치율:"]
|
| 421 |
+
allnn = []
|
| 422 |
+
for ax in ["EVA", "EAR", "VAL", "REI"]:
|
| 423 |
+
nn = [r for r in by.get(ax, []) if r["label_value"] != 0]
|
| 424 |
+
if not nn: continue
|
| 425 |
+
agr = np.mean([(r["coord"] >= 0) == (r["label_value"] > 0) for r in nn])
|
| 426 |
+
allnn += [(r["coord"] >= 0) == (r["label_value"] > 0) for r in nn]
|
| 427 |
+
lines.append(f"- {ax}: **{agr*100:.0f}%** (n={len(nn)})")
|
| 428 |
+
if allnn:
|
| 429 |
+
lines.append(f"\n**전체 일치율 {np.mean(allnn)*100:.0f}%** (우연 기대 50%)")
|
| 430 |
+
rs = self._resp_section()
|
| 431 |
+
if rs:
|
| 432 |
+
lines.append(rs)
|
| 433 |
+
lines.append(f"\n_저장 레코드 {self.saved}개 · {head}_")
|
| 434 |
+
return "\n".join(lines)
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
# ============================== 측정 엔진 ==============================
|
| 438 |
+
class MeasurementEngine:
|
| 439 |
+
def __init__(self, encoder_name=ENCODER):
|
| 440 |
+
self.encoder_name = encoder_name
|
| 441 |
+
self._load_encoder()
|
| 442 |
+
self.axes, self.refs = {}, {}
|
| 443 |
+
for c, d in CONSTRUCTS.items():
|
| 444 |
+
ep, en = self._embed(d["pos"]), self._embed(d["neg"])
|
| 445 |
+
v = ep.mean(0) - en.mean(0); v = v / (norm(v) + 1e-8)
|
| 446 |
+
self.axes[c] = v
|
| 447 |
+
ref = np.vstack([ep, en]) @ v
|
| 448 |
+
self.refs[c] = (ref.mean(), ref.std() + 1e-8)
|
| 449 |
+
|
| 450 |
+
def _load_encoder(self):
|
| 451 |
+
if USE_SENTENCE_TRANSFORMERS:
|
| 452 |
+
from sentence_transformers import SentenceTransformer
|
| 453 |
+
self._st = SentenceTransformer(self.encoder_name)
|
| 454 |
+
else:
|
| 455 |
+
from transformers import AutoModel, AutoTokenizer
|
| 456 |
+
import torch
|
| 457 |
+
self._torch = torch
|
| 458 |
+
self._tok = AutoTokenizer.from_pretrained(self.encoder_name)
|
| 459 |
+
self._mdl = AutoModel.from_pretrained(self.encoder_name).eval()
|
| 460 |
+
|
| 461 |
+
def _embed(self, sents):
|
| 462 |
+
if isinstance(sents, str): sents = [sents]
|
| 463 |
+
if USE_SENTENCE_TRANSFORMERS:
|
| 464 |
+
return np.asarray(self._st.encode(sents, normalize_embeddings=True), dtype=np.float64)
|
| 465 |
+
out = []
|
| 466 |
+
for i in range(0, len(sents), 16):
|
| 467 |
+
b = sents[i:i + 16]
|
| 468 |
+
inp = self._tok(b, padding=True, truncation=True, max_length=64, return_tensors="pt")
|
| 469 |
+
with self._torch.no_grad():
|
| 470 |
+
e = self._mdl(**inp).last_hidden_state[:, 0]
|
| 471 |
+
e = e / (e.norm(dim=1, keepdim=True) + 1e-8)
|
| 472 |
+
out.append(e.cpu().double().numpy())
|
| 473 |
+
return np.vstack(out)
|
| 474 |
+
|
| 475 |
+
def measure(self, text):
|
| 476 |
+
emb = self._embed(text)[0]
|
| 477 |
+
return {c: float((emb @ v - self.refs[c][0]) / self.refs[c][1]) for c, v in self.axes.items()}
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
# ============================== 프로파일 ==============================
|
| 481 |
+
def new_profile():
|
| 482 |
+
return {
|
| 483 |
+
"baseline": {"VAL": None, "REI": None},
|
| 484 |
+
"cum": {"VAL": None, "REI": None},
|
| 485 |
+
"wsum": {"VAL": 0.0, "REI": 0.0}, # 세션 누적 가중치 합 (소프트 게이트용)
|
| 486 |
+
"wcsum": {"VAL": 0.0, "REI": 0.0}, # 세션 누적 (가중치×좌표) 합
|
| 487 |
+
"n": 0, "emotion_traj": [], "last": None,
|
| 488 |
+
"smooth": {"EVA": None, "EAR": None}, # EVA/EAR 약한 합성(평활)값 — 나침반 표시용(단일 발화 노이즈 완화)
|
| 489 |
+
"last_utt": None, "label_axis": None, "participant": None, "interests": None,
|
| 490 |
+
"pending_reveal": None,
|
| 491 |
+
"end_suggested": False, "ended": False,
|
| 492 |
+
"phase": "build", "clarify_queue": [], "clarify_asked": False, "explore_offered": False,
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
def set_baseline(profile, val_coord, rei_coord):
|
| 496 |
+
profile["baseline"]["VAL"] = val_coord
|
| 497 |
+
profile["baseline"]["REI"] = rei_coord
|
| 498 |
+
profile["cum"]["VAL"] = val_coord
|
| 499 |
+
profile["cum"]["REI"] = rei_coord
|
| 500 |
+
return profile
|
| 501 |
+
|
| 502 |
+
def _len_weight(n):
|
| 503 |
+
# 측정 신뢰도 ∝ 발화 길이. 너무 짧으면 제외, 짧으면 약하게(약한 합성), 충분하면 full, 너무 길면 완만 감소.
|
| 504 |
+
if n < MIN_MEASURE_LEN:
|
| 505 |
+
return 0.0 # 제외(어휘 노이즈로 측정 불가)
|
| 506 |
+
if n < RELIABLE_LEN:
|
| 507 |
+
return 0.3 + 0.7 * (n - MIN_MEASURE_LEN) / (RELIABLE_LEN - MIN_MEASURE_LEN) # 0.3→1.0 선형
|
| 508 |
+
if n <= LEN_REF:
|
| 509 |
+
return 1.0
|
| 510 |
+
return (LEN_REF / max(n, 1)) ** 0.5 # 긴 글의 분석 편향 완화(기존)
|
| 511 |
+
|
| 512 |
+
def update_cumulative(profile, m, utt_len=0):
|
| 513 |
+
lw = _len_weight(utt_len)
|
| 514 |
+
for c in ("VAL", "REI"):
|
| 515 |
+
cn = m[c]
|
| 516 |
+
w = abs(cn) * lw # 소프트 게이트(신뢰=|coord|) × 길이 가중(짧으면 0~약하게)
|
| 517 |
+
profile["wsum"][c] += w
|
| 518 |
+
profile["wcsum"][c] += w * cn
|
| 519 |
+
base = profile["baseline"][c] if profile["baseline"][c] is not None else 0.0
|
| 520 |
+
# 기준선 앵커(가중치 W_BASE) + 세션 전체 신뢰가중 누적
|
| 521 |
+
profile["cum"][c] = (W_BASE * base + profile["wcsum"][c]) / (W_BASE + profile["wsum"][c])
|
| 522 |
+
# EVA/EAR 약한 합성(평활): 단일 발화 노이즈 완화. 짧은 발화(lw 작음)는 거의 반영 안 함, 너무 짧으면(lw=0) 이전값 유지.
|
| 523 |
+
for c in ("EVA", "EAR"):
|
| 524 |
+
prev = profile["smooth"][c]
|
| 525 |
+
if prev is None:
|
| 526 |
+
profile["smooth"][c] = float(m[c])
|
| 527 |
+
else:
|
| 528 |
+
alpha = 0.6 * lw
|
| 529 |
+
profile["smooth"][c] = float((1 - alpha) * prev + alpha * m[c])
|
| 530 |
+
profile["emotion_traj"].append(profile["smooth"]["EVA"]) # 궤적은 평활값으로(노이즈 완화)
|
| 531 |
+
profile["n"] += 1
|
| 532 |
+
profile["last"] = m # 원시값 — 재질문(reveal)은 '방금 그 발화'를 비교하므로 원시 유지
|
| 533 |
+
return profile
|
| 534 |
+
|
| 535 |
+
def emotion_trend(profile):
|
| 536 |
+
t = profile["emotion_traj"]
|
| 537 |
+
if len(t) < 2: return 0.0
|
| 538 |
+
k = min(4, len(t))
|
| 539 |
+
return float(np.mean(t[-k:]) - np.mean(t[:k]))
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
# ============================== 응답 LLM(언어 출력만) ==============================
|
| 543 |
+
def build_prompt(profile):
|
| 544 |
+
cum, last = profile["cum"], profile["last"] or {"EVA": 0, "EAR": 0}
|
| 545 |
+
val = "자율 지향(스스로 탐색하도록 도움)" if (cum["VAL"] or 0) >= 0 else "순응 지향(안정감과 명확한 방향 제시)"
|
| 546 |
+
rei = "분석적(논리·근거 중심)" if (cum["REI"] or 0) >= 0 else "직관적(비유·느낌 중심)"
|
| 547 |
+
eva = "즐거운 편" if last["EVA"] >= 0 else "가라앉은 편"
|
| 548 |
+
ear = "높음" if last["EAR"] >= 0 else "낮음"
|
| 549 |
+
nick = (profile.get("participant") or "").strip()
|
| 550 |
+
honorific = f"{nick}님" if nick else "사용자님"
|
| 551 |
+
base = (
|
| 552 |
+
f"당신은 '{honorific}'와 편하게 이야기 나누는 따뜻하고 다정한 대화 상대입니다. "
|
| 553 |
+
"항상 존댓말을 쓰고, 약간 밝고 긍정적인 말투를 쓰되 과하게 들뜨지는 마세요(느낌표·감탄사·이모지 남발 금지). "
|
| 554 |
+
f"사용자에게 진심으로 관심을 보이고, 가끔 자연스럽게 '{honorific}'이라고 불러 주세요(매 문장 호명은 어색하니 가끔만). "
|
| 555 |
+
"가장 중요한 규칙: 사용자가 방금 꺼낸 주제·감정에 먼저 반응하고 그 안에 머무르세요. "
|
| 556 |
+
"다른 고민을 캐묻거나 화제를 돌리지 말고, 사용자가 말한 것에 진짜로 응답하세요. "
|
| 557 |
+
"사용자가 무겁거나 진지한 이야기를 하면 즉시 톤을 낮추고 그 이야기에 충분히 머무르세요. "
|
| 558 |
+
"당신은 AI입니다 — 쉬고 있다거나 감정이 있다는 식으로 자신을 꾸며내지 말고, 사용자에게 집중하세요. "
|
| 559 |
+
"아래 측정 결과를 은근히 반영하되(진단·분석�� 직접 언급 금지):\n"
|
| 560 |
+
f"- 가치관: {val}\n- 인지 방식: {rei}\n- 지금 기분: {eva} · 에너지: {ear}\n"
|
| 561 |
+
"사용자의 가치관·인지 방식에 맞춰 말투를 조절하고, 현재 기분에 톤을 맞추세요. "
|
| 562 |
+
"한국어로 2~3문장, 일상 대화체로 짧게. "
|
| 563 |
+
"매 턴 질문하지 말고 공감으로 자연스럽게 마무리하세요(아래 '지금 할 일'이 있으면 그건 따르세요)."
|
| 564 |
+
)
|
| 565 |
+
phase = profile.get("phase", "build")
|
| 566 |
+
q = profile.get("clarify_queue") or []
|
| 567 |
+
if phase == "build" and profile.get("n", 0) >= 3:
|
| 568 |
+
base += (
|
| 569 |
+
"\n[대화 전략] 대화가 어느 정도 쌓였습니다. 사용자가 지금 무엇을 원하는지 살펴 그에 맞게 이끌어 주세요: "
|
| 570 |
+
"· 감정을 알아주길 원하는 듯하면 → 먼저 그 감정에 정서적으로 공감하기. "
|
| 571 |
+
"· 인정·이해를 원하는 듯하면 → 그 마음과 노력을 충분히 인정하고 이해해 주기. "
|
| 572 |
+
"· 지식·생각을 나누고 싶어 하면 → 흥미를 보이며 그 주제로 함께 이야기 나누기. "
|
| 573 |
+
"· 뿌듯함·자랑을 드러내면 → 진심으로 함께 기뻐하고 축하해 주기. "
|
| 574 |
+
"· 고민을 털어놓고 싶어 하면 → 판단 없이 들어주고 곁에 있어 주기. "
|
| 575 |
+
"사용자가 스스로 더 이야기하고 싶도록 여지를 열어두되(주도권은 사용자에게), 억지로 깊이 캐묻거나 원치 않는 방향으로 몰지 마세요. "
|
| 576 |
+
"어떤 유형인지 확실하지 않으면 일단 공감하며 가볍게 따라가세요."
|
| 577 |
+
)
|
| 578 |
+
base += (
|
| 579 |
+
"\n[넛지] 사용자가 불편하거나 힘든 상황을 이야기하면, 그 주제에서 '한 번만', 스스로 작은 변화를 떠올리도록 돕는 부드러운 질문을 건네세요. "
|
| 580 |
+
"예: \"혹시 지금 마음을 가볍게 해줄 수 있는 게 어떤 게 있을까요?\" "
|
| 581 |
+
"조언이나 해결책을 직접 제시하지 말고(사용자가 스스로 답을 찾도록), 같은 주제에서 반복하지 마세요. "
|
| 582 |
+
"사용자가 새 주제로 넘어가거나 다시 청하면 그때 다시 건네도 됩니다. "
|
| 583 |
+
"사용자가 정말 힘들어 보이면 넛지 대신 그냥 충분히 들어주세요(안녕이 최우선)."
|
| 584 |
+
)
|
| 585 |
+
if phase == "clarify" and q:
|
| 586 |
+
target = q[0]
|
| 587 |
+
if target == "VAL":
|
| 588 |
+
base += ("\n[지금 할 일] 사용자의 '가치관'(스스로 정함 ↔ 주변에 맞춤)이 온보딩에서 불명확했습니다. "
|
| 589 |
+
"구체적인 실제 생활 상황을 하나 들어, 어느 쪽에 가까운지 자연스럽게 확인하는 질문을 '하나만' 하세요. 캐묻지 마세요.")
|
| 590 |
+
else:
|
| 591 |
+
base += ("\n[지금 할 일] 사용자의 '인지 방식'(분석 ↔ 직감)이 온보딩에서 불명확했습니다. "
|
| 592 |
+
"구체적인 실제 생활 상황을 하나 들어, 어느 쪽에 가까운지 자연스럽게 확인하는 질문을 '하나만' 하세요. 캐묻지 마세요.")
|
| 593 |
+
elif phase == "explore":
|
| 594 |
+
bar = ""
|
| 595 |
+
it0 = profile.get("interests")
|
| 596 |
+
if it0 and it0.get("barriers"):
|
| 597 |
+
bar = f"사용자가 힘든 점으로 '{_txt(it0['barriers'][0]['item'])}'을(를) 꼽았습니다. "
|
| 598 |
+
base += ("\n[지금 할 일] 라포가 쌓였습니다. " + bar +
|
| 599 |
+
"사용자가 지금 이야기하는 흐름을 끊지 말고, 그 안에서 한 번만 부드럽게 더 깊이 들어가는 질문을 던져 자기이해를 도와보세요. "
|
| 600 |
+
"사용자가 원치 않거나 부담스러워하면 즉시 물러나 공감으로 돌아가세요. "
|
| 601 |
+
"절대 부정적 감정을 억지로 끌어내거나 캐묻지 마세요. 사용자의 안녕이 최우선입니다.")
|
| 602 |
+
it = profile.get("interests")
|
| 603 |
+
if it:
|
| 604 |
+
ctx = []
|
| 605 |
+
dom = ", ".join(_txt(d["item"]) for d in it.get("domains", []))
|
| 606 |
+
hap = ", ".join(_txt(h["item"]) for h in it.get("happy", [])[:3])
|
| 607 |
+
bar = ", ".join(_txt(b["item"]) for b in it.get("barriers", [])[:3])
|
| 608 |
+
if dom: ctx.append(f"중요시하는 것: {dom}")
|
| 609 |
+
if hap: ctx.append(f"행복요인: {hap}")
|
| 610 |
+
if bar: ctx.append(f"방해요인: {bar}")
|
| 611 |
+
if ctx:
|
| 612 |
+
base += "\n참고 맥락(직접 나열하지 말고 자연스럽게만 반영): " + " · ".join(ctx)
|
| 613 |
+
return base
|
| 614 |
+
|
| 615 |
+
def generate_response(profile, history, user_msg):
|
| 616 |
+
sys_prompt = build_prompt(profile)
|
| 617 |
+
if LLM_MODE == "gemini":
|
| 618 |
+
return _gemini(sys_prompt, history, user_msg)
|
| 619 |
+
if LLM_MODE == "local":
|
| 620 |
+
return (_local(sys_prompt, history, user_msg), "local", 0, None)
|
| 621 |
+
return (_template(profile, user_msg), "template", 0, None)
|
| 622 |
+
|
| 623 |
+
def detect_ambiguity(onboard_rows, baseline_val, baseline_rei):
|
| 624 |
+
"""온보딩에서 명료화가 필요한 구성(VAL/REI)을 반환. 부호충돌 또는 약신호(|기준선|<0.3)."""
|
| 625 |
+
queue = []
|
| 626 |
+
for ax, base in [("VAL", baseline_val), ("REI", baseline_rei)]:
|
| 627 |
+
cs = [r["coord"] for r in onboard_rows if r["axis"] == ax and r.get("coord") is not None]
|
| 628 |
+
conflict = (len(cs) == 2 and (cs[0] >= 0) != (cs[1] >= 0))
|
| 629 |
+
weak = (base is not None and abs(base) < 0.3)
|
| 630 |
+
if conflict or weak:
|
| 631 |
+
queue.append(ax)
|
| 632 |
+
return queue
|
| 633 |
+
|
| 634 |
+
def generate_opening(profile):
|
| 635 |
+
"""앱이 먼저 대화를 여는 첫 메시지(LLM 생성). 관심사 있으면 그걸로, 없으면 직업 질문."""
|
| 636 |
+
it = profile.get("interests")
|
| 637 |
+
if it and it.get("domains"):
|
| 638 |
+
dom = _txt(it["domains"][0]["item"])
|
| 639 |
+
op = (f"사용자가 요즘 중요하게 여기는 것으로 '{dom}'을(를) 꼽았습니다. "
|
| 640 |
+
"이걸 자연스럽게 언급하며 따뜻하게 대화를 여세요. 한두 문장으로 관심을 보이고, "
|
| 641 |
+
"그 주제로 부담 없는 질문을 하나만 하세요.")
|
| 642 |
+
else:
|
| 643 |
+
op = ("사용자가 관심사를 밝히지 않았습니다. 따뜻하게 인사하고, 요즘 어떤 일을 하며 지내는지"
|
| 644 |
+
"(직업이나 하루 일과) 가볍게 물어 대화를 여세요. 한두 문장과 질문 하나로.")
|
| 645 |
+
sys = "당신은 사용자와 편하게 수다 떠는 친근한 대화 상대입니다. 친구처럼 가볍고 자연스럽게, 한국어로 2~3문장. " + op
|
| 646 |
+
return _gemini(sys, [], "(따뜻하게 먼저 대화를 시작해 주세요.)")
|
| 647 |
+
|
| 648 |
+
def _template(profile, user_msg):
|
| 649 |
+
last = profile["last"] or {"EVA": 0, "EAR": 0}
|
| 650 |
+
cum = profile["cum"]
|
| 651 |
+
feel = "마음이 무거우신 듯해요" if last["EVA"] < 0 else "기분이 괜찮아 보이세요"
|
| 652 |
+
nudge = "이 일을 스스로는 어떻게 바라보고 싶으세요?" if (cum["VAL"] or 0) >= 0 \
|
| 653 |
+
else "지금 가장 마음이 놓이는 방향은 어떤 쪽일까요?"
|
| 654 |
+
return f"{feel}. {nudge}"
|
| 655 |
+
|
| 656 |
+
def _msg_text(content):
|
| 657 |
+
# Gradio 버전별 메시지 content 정규화: 4.x는 문자열, 6.0은 리스트([{'type':'text','text':...}])일 수 있음.
|
| 658 |
+
if content is None:
|
| 659 |
+
return ""
|
| 660 |
+
if isinstance(content, str):
|
| 661 |
+
return content
|
| 662 |
+
if isinstance(content, dict):
|
| 663 |
+
return str(content.get("text", "") or "")
|
| 664 |
+
if isinstance(content, (list, tuple)):
|
| 665 |
+
parts = []
|
| 666 |
+
for it in content:
|
| 667 |
+
if isinstance(it, str):
|
| 668 |
+
parts.append(it)
|
| 669 |
+
elif isinstance(it, dict):
|
| 670 |
+
parts.append(str(it.get("text", "") or ""))
|
| 671 |
+
return " ".join(p for p in parts if p)
|
| 672 |
+
return str(content)
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
def _gemini(sys_prompt, history, user_msg):
|
| 676 |
+
import os, requests
|
| 677 |
+
key = os.environ.get("GEMINI_API_KEY", "").strip()
|
| 678 |
+
if not key:
|
| 679 |
+
return ("[GEMINI_API_KEY 가 설정되지 않았습니다. Colab Secrets 또는 Space Secret에 추가하세요.]", None, 0, None)
|
| 680 |
+
models = ["gemini-2.5-flash", "gemini-2.5-flash-lite", "gemini-flash-latest"]
|
| 681 |
+
# 최근 대화 이력 포함 — 맥락 유지. 비용 절제 위해 마지막 6개(=약 3턴)만. (이력 없으면 AI가 직전 흐름을 못 봄)
|
| 682 |
+
hist = (history or [])[-6:]
|
| 683 |
+
while hist and hist[0].get("role") == "assistant":
|
| 684 |
+
hist = hist[1:] # Gemini contents는 user 턴으로 시작해야 함 — 선두 assistant 제거
|
| 685 |
+
contents = []
|
| 686 |
+
for m in hist:
|
| 687 |
+
role = "model" if m.get("role") == "assistant" else "user"
|
| 688 |
+
c = _msg_text(m.get("content")).strip()
|
| 689 |
+
if c:
|
| 690 |
+
contents.append({"role": role, "parts": [{"text": c}]})
|
| 691 |
+
contents.append({"role": "user", "parts": [{"text": user_msg}]})
|
| 692 |
+
payload = {"system_instruction": {"parts": [{"text": sys_prompt}]},
|
| 693 |
+
"contents": contents,
|
| 694 |
+
"generationConfig": {
|
| 695 |
+
"thinkingConfig": {"thinkingBudget": 0}, # thinking 끔 → 출력 토큰·비용·지연 대폭 감소(2.5-flash는 기본 on)
|
| 696 |
+
"maxOutputTokens": 500, # 2~3문장이면 충분 — 폭주 방지 안전망
|
| 697 |
+
"temperature": 0.9,
|
| 698 |
+
}}
|
| 699 |
+
last_err = ""
|
| 700 |
+
for i, mdl in enumerate(models):
|
| 701 |
+
url = (f"https://generativelanguage.googleapis.com/v1beta/models/"
|
| 702 |
+
f"{mdl}:generateContent?key={key}")
|
| 703 |
+
try:
|
| 704 |
+
data = requests.post(url, json=payload, timeout=30).json()
|
| 705 |
+
except Exception as e:
|
| 706 |
+
last_err = f"요청 실패: {e}"; continue
|
| 707 |
+
if "error" in data:
|
| 708 |
+
msg = data["error"].get("message", str(data["error"]))
|
| 709 |
+
last_err = f"{mdl}: {msg}"
|
| 710 |
+
if any(w in msg.lower() for w in ["not found", "not supported", "permission", "model", "quota", "rate", "exhaust"]):
|
| 711 |
+
continue
|
| 712 |
+
return (f"[Gemini 오류] {last_err}", None, i, None)
|
| 713 |
+
cands = data.get("candidates")
|
| 714 |
+
if not cands:
|
| 715 |
+
last_err = f"{mdl}: candidates 없음 ({data.get('promptFeedback', {})})"; continue
|
| 716 |
+
parts = cands[0].get("content", {}).get("parts", [])
|
| 717 |
+
text = "".join(p.get("text", "") for p in parts).strip()
|
| 718 |
+
if text:
|
| 719 |
+
um = data.get("usageMetadata", {}) or {}
|
| 720 |
+
usage = {"in": int(um.get("promptTokenCount", 0) or 0),
|
| 721 |
+
"out": int(um.get("candidatesTokenCount", 0) or 0) + int(um.get("thoughtsTokenCount", 0) or 0)}
|
| 722 |
+
return (text, mdl, i, usage)
|
| 723 |
+
last_err = f"{mdl}: 빈 응답 (finishReason: {cands[0].get('finishReason')})"
|
| 724 |
+
return (f"[Gemini 응답 실패] 마지막 원인 — {last_err}", None, len(models), None)
|
| 725 |
+
|
| 726 |
+
def _gemini_score_4axis(text):
|
| 727 |
+
# 연구 파일럿 — 발화를 4축으로 LLM 채점(-100..+100). 실패 시 None.
|
| 728 |
+
sys = ("다음 한국어 문장을 네 독립 축으로 채점하라. 각 축 -100~+100 정수. "
|
| 729 |
+
"오직 JSON만 출력하고 설명은 절대 쓰지 마라: {\"VAL\":n,\"REI\":n,\"EVA\":n,\"EAR\":n}\n"
|
| 730 |
+
"VAL 자율(+)↔순응(-): 내 기준으로 정하면 +, 주변·규칙에 맞추면 -.\n"
|
| 731 |
+
"REI 분석(+)↔직관(-): 근거·논리로 정하면 +, 직감·느낌으로 정하면 -.\n"
|
| 732 |
+
"EVA 긍정(+)↔부정(-): 감정이 긍정적이면 +, 부정적이면 -.\n"
|
| 733 |
+
"EAR 고각성(+)↔저각성(-): 에너지·흥분·긴장이 높으면 +, 차분·처짐이면 - (감정 좋고나쁨과 무관).\n"
|
| 734 |
+
"해당 축이 무관하면 0.")
|
| 735 |
+
try:
|
| 736 |
+
txt, mdl, depth, usage = _gemini(sys, [], (text or "").strip())
|
| 737 |
+
except Exception:
|
| 738 |
+
return None
|
| 739 |
+
if mdl is None or not txt:
|
| 740 |
+
return None
|
| 741 |
+
import json as _json, re as _re
|
| 742 |
+
s = txt.strip().replace("```json", "").replace("```", "").strip()
|
| 743 |
+
m = _re.search(r"\{.*\}", s, _re.DOTALL)
|
| 744 |
+
if not m:
|
| 745 |
+
return None
|
| 746 |
+
try:
|
| 747 |
+
d = _json.loads(m.group(0))
|
| 748 |
+
return {ax: float(d.get(ax, 0)) for ax in ("VAL", "REI", "EVA", "EAR")}
|
| 749 |
+
except Exception:
|
| 750 |
+
return None
|
| 751 |
+
|
| 752 |
+
# 연구 파일럿 자기라벨 도구 (프로토콜 부록 A) — 4축 5단계
|
| 753 |
+
PILOT_AXES = {
|
| 754 |
+
"EVA": ("기분(감정가)", ["-2 매우 부정적", "-1 약간 부정적", "+1 약간 긍정적", "+2 매우 긍정적"]),
|
| 755 |
+
"EAR": ("에너지·각성", ["-2 매우 차분·처짐", "-1 약간 가라앉음", "+1 약간 들뜸·또렷", "+2 매우 흥분·곤두섬"]),
|
| 756 |
+
"REI": ("사고방식", ["-2 순전히 직감·느낌", "-1 주로 느낌", "+1 주로 근거·논리", "+2 순전히 근거·논리"]),
|
| 757 |
+
"VAL": ("자율성", ["-2 전적으로 주변·규칙", "-1 주로 주변", "+1 주로 내 기준", "+2 전적으로 내 기준"]),
|
| 758 |
+
}
|
| 759 |
+
# ACT(신체 활성도) — 검증 중인 5번째 축 후보. 자기보고만 수집(측정·LLM은 4축 유지).
|
| 760 |
+
# 각성(에너지 높낮이)과 별개로, '몸이 격렬하게 반응/소진된 정도'. 차분(저활성) vs 탈진(고활성) 구분 검증용.
|
| 761 |
+
PILOT_ACT = ("신체 활성도(실험)", ["-2 완전히 이완·고요", "-1 약간 느슨", "+1 약간 격렬·긴장", "+2 매우 격렬·소진"])
|
| 762 |
+
def _parse_pilot_val(opt):
|
| 763 |
+
return int(opt.split()[0]) # "-2 매우..." → -2, "+1 약간..." → 1 (중간 없는 4지선다 대응)
|
| 764 |
+
_PILOT_MAP = {ax: {opt: _parse_pilot_val(opt) for opt in opts} for ax, (_lbl, opts) in PILOT_AXES.items()}
|
| 765 |
+
_PILOT_MAP["ACT"] = {opt: _parse_pilot_val(opt) for opt in PILOT_ACT[1]}
|
| 766 |
+
PILOT_PASSWORD = "qwer"
|
| 767 |
+
|
| 768 |
+
|
| 769 |
+
def _local(sys_prompt, history, user_msg):
|
| 770 |
+
global _LOCAL_PIPE
|
| 771 |
+
try:
|
| 772 |
+
_LOCAL_PIPE
|
| 773 |
+
except NameError:
|
| 774 |
+
from transformers import pipeline
|
| 775 |
+
_LOCAL_PIPE = pipeline("text-generation", model="kakaocorp/kanana-nano-2.1b-instruct",
|
| 776 |
+
device_map="auto", max_new_tokens=180)
|
| 777 |
+
msgs = [{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}]
|
| 778 |
+
try:
|
| 779 |
+
out = _LOCAL_PIPE(msgs)[0]["generated_text"]
|
| 780 |
+
return out[-1]["content"] if isinstance(out, list) else str(out)
|
| 781 |
+
except Exception as e:
|
| 782 |
+
return f"[로컬 모델 오류: {e}]"
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
# ============================== Gradio UI ==============================
|
| 786 |
+
def _wmean(pairs):
|
| 787 |
+
sw = sum(w for _, w in pairs)
|
| 788 |
+
return (sum(v * w for v, w in pairs) / sw) if sw else 0.0
|
| 789 |
+
|
| 790 |
+
def build_app():
|
| 791 |
+
import gradio as gr
|
| 792 |
+
print("측정 엔진 로딩 중 (KoSimCSE)...")
|
| 793 |
+
engine = MeasurementEngine()
|
| 794 |
+
store = DataStore()
|
| 795 |
+
print("준비 완료.")
|
| 796 |
+
label2stmt = [{lab: stmt for lab, stmt in item["choices"]} for item in ONBOARD]
|
| 797 |
+
|
| 798 |
+
def label_update(axis, visible):
|
| 799 |
+
if not visible:
|
| 800 |
+
return gr.update(choices=[], value=None, visible=False)
|
| 801 |
+
sc = LABEL_SCHEMES[axis]
|
| 802 |
+
return gr.update(choices=[o[0] for o in sc["opts"]], label=sc["q"], value=None, visible=True)
|
| 803 |
+
|
| 804 |
+
def do_onboard(*args):
|
| 805 |
+
picks = args[:-4]; consent = args[-4]; participant = args[-3]; session = args[-2]; old_profile = args[-1]
|
| 806 |
+
coords = {"VAL": [], "REI": []}; onboard_rows = []
|
| 807 |
+
for i, item in enumerate(ONBOARD):
|
| 808 |
+
c = item["construct"]
|
| 809 |
+
radio, free = picks[2 * i], picks[2 * i + 1]
|
| 810 |
+
coord = None
|
| 811 |
+
if radio:
|
| 812 |
+
coord = engine.measure(label2stmt[i][radio])[c]
|
| 813 |
+
coords[c].append((coord, 1.0))
|
| 814 |
+
if free and free.strip():
|
| 815 |
+
fc = engine.measure(free.strip())[c]
|
| 816 |
+
coords[c].append((fc, FREE_WEIGHT))
|
| 817 |
+
coord = fc if coord is None else coord
|
| 818 |
+
onboard_rows.append({"axis": c, "choice": radio or None,
|
| 819 |
+
"coord": (round(float(coord), 4) if coord is not None else None)})
|
| 820 |
+
if not coords["VAL"] or not coords["REI"]:
|
| 821 |
+
return old_profile, "가치(Q1·Q2)와 인지(Q3·Q4) 각각에서 최소 한 가지는 선택하거나 입력해 주세요.", gr.update(), gr.update(), gr.update(), store.summary_md()
|
| 822 |
+
profile = set_baseline(new_profile(), _wmean(coords["VAL"]), _wmean(coords["REI"]))
|
| 823 |
+
profile["participant"] = (participant.strip() if participant and participant.strip() else None)
|
| 824 |
+
profile["interests"] = old_profile.get("interests") # 관심사 보존(흐름 A: 관심사 → 온보딩)
|
| 825 |
+
store.save_session(session, profile["participant"], bool(consent),
|
| 826 |
+
(profile["baseline"]["VAL"], profile["baseline"]["REI"]), onboard_rows)
|
| 827 |
+
# 애매한 온보딩 항목 감지 → 명료화 큐
|
| 828 |
+
profile["clarify_queue"] = detect_ambiguity(onboard_rows, profile["baseline"]["VAL"], profile["baseline"]["REI"])
|
| 829 |
+
profile["phase"] = "open"
|
| 830 |
+
# 앱이 먼저 대화를 연다 — 템플릿(LLM 호출 없음, 무료 한도 절약)
|
| 831 |
+
it = profile.get("interests")
|
| 832 |
+
if it and it.get("domains"):
|
| 833 |
+
op_text = (f"안녕! 🙂 아까 '{_txt(it['domains'][0]['item'])}' 얘기 했었죠. "
|
| 834 |
+
"거기서부터 가볍게 시작해도 좋고, 아래 버튼에서 골라도 돼요. 요즘 어때요?")
|
| 835 |
+
else:
|
| 836 |
+
op_text = ("안녕! 🙂 편하게 수다 떨어요. 무슨 얘기부터 할지 고민되면 "
|
| 837 |
+
"아래 버튼에서 하나 골라봐요 — 아니면 그냥 떠오르는 대로 적어도 좋아요.")
|
| 838 |
+
opening_chat = [{"role": "assistant", "content": op_text}]
|
| 839 |
+
return (profile, render_profile(profile), gr.update(value=opening_chat, visible=True),
|
| 840 |
+
gr.update(visible=True), gr.update(visible=True), store.summary_md())
|
| 841 |
+
|
| 842 |
+
END_SUGGEST_TURN = 12
|
| 843 |
+
EXPLORE_TURN = 8
|
| 844 |
+
|
| 845 |
+
def do_chat(msg, chat, profile, session, consent):
|
| 846 |
+
if not msg or not msg.strip():
|
| 847 |
+
return chat, profile, render_profile(profile), "", label_update(None, False), "", store.summary_md(), gr.update(visible=False)
|
| 848 |
+
# 동의 안내: 미동의 상태면 측정·자기보고가 저장되지 않음을 채팅 아래에 명확히 안내
|
| 849 |
+
consent_note = ("" if bool(consent) else
|
| 850 |
+
"⚠️ 현재 **익명 저장 동의가 해제**되어 있어요. 대화는 그대로 되지만 "
|
| 851 |
+
"측정 결과가 **저장되지 않습니다**. 연구에 도움을 주시려면 위 '연구 참여 안내·동의'의 "
|
| 852 |
+
"체크박스를 켜 주세요. (대화 원문은 어떤 경우에도 저장되지 않습니다.)")
|
| 853 |
+
if profile.get("ended"):
|
| 854 |
+
return (chat, profile, render_profile(profile), "", label_update(None, False),
|
| 855 |
+
"대화를 이미 마쳤어요. 더 하려면 페이지를 새로고침해 주세요.", store.summary_md(), gr.update(visible=False))
|
| 856 |
+
if profile["baseline"]["VAL"] is None:
|
| 857 |
+
chat = chat + [{"role": "assistant", "content": "먼저 위 질문 몇 개만 완료해 주세요 🙂"}]
|
| 858 |
+
return chat, profile, render_profile(profile), "", label_update(None, False), consent_note, store.summary_md(), gr.update(visible=False)
|
| 859 |
+
m = engine.measure(msg)
|
| 860 |
+
profile = update_cumulative(profile, m, len(msg))
|
| 861 |
+
profile["last_utt"] = msg
|
| 862 |
+
profile["pending_reveal"] = None # 새 메시지 → 직전 재질문 상태 정리
|
| 863 |
+
profile["label_axis"] = pick_axis(profile["n"])
|
| 864 |
+
# --- 대화 단계 전환 (응답 생성 전) ---
|
| 865 |
+
phase = profile.get("phase", "build")
|
| 866 |
+
if phase == "open":
|
| 867 |
+
# 오프닝에 대한 첫 답변을 받음 → 명료화 필요하면 clarify, 아니면 build
|
| 868 |
+
if profile.get("clarify_queue"):
|
| 869 |
+
profile["phase"] = "clarify"; profile["clarify_asked"] = False
|
| 870 |
+
else:
|
| 871 |
+
profile["phase"] = "build"
|
| 872 |
+
elif phase == "clarify":
|
| 873 |
+
if profile.get("clarify_asked") and profile.get("clarify_queue"):
|
| 874 |
+
profile["clarify_queue"].pop(0) # 직전에 물어본 구성에 답함 → 제거
|
| 875 |
+
if profile.get("clarify_queue"):
|
| 876 |
+
profile["clarify_asked"] = True # 이번 턴에 queue[0]��� 확인
|
| 877 |
+
else:
|
| 878 |
+
profile["phase"] = "build"; profile["clarify_asked"] = False
|
| 879 |
+
elif phase == "build":
|
| 880 |
+
if profile["n"] >= EXPLORE_TURN and not profile.get("explore_offered"):
|
| 881 |
+
profile["phase"] = "explore"; profile["explore_offered"] = True
|
| 882 |
+
elif phase == "explore":
|
| 883 |
+
profile["phase"] = "build" # explore는 '한 번'만 — 다음 턴부터 일반 대화로 복귀(캐묻기 방지)
|
| 884 |
+
reply, rmodel, rdepth, rusage = generate_response(profile, chat, msg)
|
| 885 |
+
store.add_response(session, profile.get("participant"), profile["n"],
|
| 886 |
+
rmodel, rdepth, len(msg), bool(consent), rusage)
|
| 887 |
+
if profile["n"] >= END_SUGGEST_TURN and not profile["end_suggested"]:
|
| 888 |
+
profile["end_suggested"] = True
|
| 889 |
+
reply = reply + "\n\n(오늘 충분히 이야기 나눴어요 🙂 더 하셔도 좋고, 마치려면 아래 '✋ 이제 그만할래'를 눌러주세요.)"
|
| 890 |
+
chat = chat + [{"role": "user", "content": msg}, {"role": "assistant", "content": reply}]
|
| 891 |
+
return chat, profile, render_profile(profile), "", label_update(profile["label_axis"], True), consent_note, store.summary_md(), gr.update(visible=False)
|
| 892 |
+
|
| 893 |
+
def do_end(chat, profile):
|
| 894 |
+
profile["ended"] = True
|
| 895 |
+
profile["pending_reveal"] = None
|
| 896 |
+
chat = chat + [{"role": "assistant",
|
| 897 |
+
"content": "오늘 대화는 여기까지 할게요. 솔직하게 이야기 나눠줘서 고마워요 🙂 "
|
| 898 |
+
"측정 개선에 큰 도움이 됩니다. 더 하고 싶으면 페이지를 새로고침해 주세요."}]
|
| 899 |
+
return (chat, gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
|
| 900 |
+
gr.update(value=None, visible=False), "대화를 마쳤어요. 고마워요 🙂", store.summary_md(),
|
| 901 |
+
gr.update(value=None, visible=False))
|
| 902 |
+
|
| 903 |
+
def check_save_status(profile, consent, session):
|
| 904 |
+
n = profile.get("n", 0) if profile else 0
|
| 905 |
+
if not bool(consent):
|
| 906 |
+
return ("⚠️ **저장 꺼짐** — 현재 익명 저장 동의가 해제되어 있어, 지금까지의 측정이 "
|
| 907 |
+
"저장되지 **않습니다**. 위쪽 '연구 참여 안내·동의'의 체크박스를 켜시면, "
|
| 908 |
+
"이후 대화부터 측정 좌표·자기보고가 익명으로 저장됩니다. "
|
| 909 |
+
"(대화 원문은 어떤 경우에도 저장되지 않아요.)")
|
| 910 |
+
pcode = (profile.get("participant") or "").strip() if profile else ""
|
| 911 |
+
pcode_txt = f"참가자 코드 **{pcode}**로 " if pcode else "익명으로 "
|
| 912 |
+
return (f"✅ **저장 켜짐** — {pcode_txt}측정 좌표와 자기보고가 익명으로 저장되고 있어요. "
|
| 913 |
+
f"지금까지 대화 {n}턴. 솔직하게 이야기해 주셔서 측정 개선에 큰 도움이 됩니다. "
|
| 914 |
+
f"(대화 원문은 저장되지 않고, 길이 등 숫자만 남습니다.)")
|
| 915 |
+
|
| 916 |
+
def do_label(choice, profile, consent, session):
|
| 917 |
+
if not choice or profile.get("last") is None or profile.get("label_axis") is None:
|
| 918 |
+
return "", store.summary_md(), gr.update(), gr.update(visible=False), profile
|
| 919 |
+
axis = profile["label_axis"]
|
| 920 |
+
value = dict(LABEL_SCHEMES[axis]["opts"])[choice]
|
| 921 |
+
coord = profile["last"][axis]
|
| 922 |
+
# 누적 좌표: VAL/REI는 세션 전체 누적, EVA/EAR(상태축)는 턴별값 그대로
|
| 923 |
+
cum = profile["cum"]
|
| 924 |
+
coords_cum = {"VAL": cum["VAL"], "REI": cum["REI"],
|
| 925 |
+
"EVA": profile["last"]["EVA"], "EAR": profile["last"]["EAR"]}
|
| 926 |
+
# 1) 편향없는 자기보고를 '먼저' 저장 (1차 검증 신호 — 측정 공개 전, 불변)
|
| 927 |
+
store.add_label(session, profile.get("participant"), profile["n"],
|
| 928 |
+
profile.get("last_utt", ""), profile["last"], coords_cum, axis, choice, value, bool(consent))
|
| 929 |
+
# 2) (ii) 측정 공개 + "맞나요?" 재질문 — 매 턴이 아니라 3턴마다만(피로 감소). 그 외 턴은 공개 없이 가볍게.
|
| 930 |
+
if profile["n"] % 3 != 0:
|
| 931 |
+
return ("기록했어요. 고마워요 🙂", store.summary_md(),
|
| 932 |
+
gr.update(value=None, visible=False), gr.update(visible=False), profile)
|
| 933 |
+
sch = LABEL_SCHEMES[axis]
|
| 934 |
+
measured_sign = 1 if coord >= 0 else -1
|
| 935 |
+
measured_label = sch["poles"][0] if coord >= 0 else sch["poles"][1]
|
| 936 |
+
profile["pending_reveal"] = {"axis": axis, "turn": int(profile["n"]),
|
| 937 |
+
"measured_sign": measured_sign, "measured_label": measured_label,
|
| 938 |
+
"self_value": int(value)}
|
| 939 |
+
note = ("기록했어요, 고마워요 🙂 하나만 더 — 제가 방금 " + sch["reveal_lead"].format(measured_label)
|
| 940 |
+
+ " 느꼈는데, 실제로도 그랬어요? 맞아도 틀려도 다 도움이 되니 편하게 알려줘요.")
|
| 941 |
+
fit_choices = ["응, 맞아요", "잘 모르겠어요", "아니, 달라요"]
|
| 942 |
+
return (note, store.summary_md(), gr.update(value=None, visible=False),
|
| 943 |
+
gr.update(choices=fit_choices, value=None, visible=True, label="제 느낌이 맞았나요?"), profile)
|
| 944 |
+
|
| 945 |
+
def do_fit(choice, profile, consent, session):
|
| 946 |
+
pr = profile.get("pending_reveal")
|
| 947 |
+
if not choice or not pr:
|
| 948 |
+
return "", gr.update(visible=False), profile
|
| 949 |
+
fit_value = {"응, 맞아요": 1, "잘 모르겠어요": 0, "아니, 달라요": -1}.get(choice, 0)
|
| 950 |
+
store.add_reveal(session, profile.get("participant"), pr["turn"], pr["axis"],
|
| 951 |
+
pr["measured_sign"], pr["measured_label"], pr["self_value"], fit_value, bool(consent))
|
| 952 |
+
profile["pending_reveal"] = None
|
| 953 |
+
if fit_value == 1:
|
| 954 |
+
note = "오, 맞았네요! 알려줘서 고마워요 🙂"
|
| 955 |
+
elif fit_value == -1:
|
| 956 |
+
note = "알려줘서 정말 고마워요 — ‘틀렸다’는 이 한 번이 측정을 더 정확하게 만들어요 🙏"
|
| 957 |
+
else:
|
| 958 |
+
note = "괜찮아요, 그것도 좋은 답이에요. 고마워요 🙂"
|
| 959 |
+
return note, gr.update(value=None, visible=False), profile
|
| 960 |
+
|
| 961 |
+
def on_domain(d1):
|
| 962 |
+
# 빈 상태가 아니라 공통 항목으로 시작 → 첫 선택부터 즉시 렌더(6.0 한 박자 지연 방지).
|
| 963 |
+
print(f"[on_domain] 호출됨 · d1={d1!r} · tag={_DOMAIN_TAG.get(d1)!r}", flush=True)
|
| 964 |
+
tag = _DOMAIN_TAG.get(d1)
|
| 965 |
+
h = happy_choices(tag) if tag else [l for l, _ in COMMON_HAPPY]
|
| 966 |
+
b = barrier_choices(tag) if tag else [l for l, _ in COMMON_BARRIER]
|
| 967 |
+
print(f"[on_domain] happy {len(h)}개 · barrier {len(b)}개 반환", flush=True)
|
| 968 |
+
return (gr.CheckboxGroup(choices=h, value=[], label="요즘 나를 행복하게 하는 것 (최대 3개 · 1순위 선택 시 더 추가됨)"),
|
| 969 |
+
gr.CheckboxGroup(choices=b, value=[], label="행복을 방해하는 것 (최대 3개)"))
|
| 970 |
+
|
| 971 |
+
def save_interests(d1, d2, happy_sel, barrier_sel, consent, session, profile):
|
| 972 |
+
if not d1:
|
| 973 |
+
return "1순위(가장 중요한 것)를 먼저 선택해 주세요.", profile
|
| 974 |
+
if len(happy_sel) > 3 or len(barrier_sel) > 3:
|
| 975 |
+
return "행복·방해 요인은 각각 최대 3개까지만 골라주세요.", profile
|
| 976 |
+
doms = [d1] + ([d2] if (d2 and d2 != "— 없음 —" and d2 != d1) else [])
|
| 977 |
+
domains = [{"item": d, "tag": _DOMAIN_TAG.get(d, ""), "rank": i + 1} for i, d in enumerate(doms)]
|
| 978 |
+
happy = [{"item": h, "tag": _HAPPY_TAG.get(h, ""), "common": h in _COMMON_HAPPY_SET} for h in happy_sel]
|
| 979 |
+
barriers = [{"item": b, "tag": _BARRIER_TAG.get(b, ""), "common": b in _COMMON_BARRIER_SET} for b in barrier_sel]
|
| 980 |
+
profile["interests"] = {"domains": domains, "happy": happy, "barriers": barriers}
|
| 981 |
+
store.save_interests(session, profile.get("participant"), domains, happy, barriers, bool(consent))
|
| 982 |
+
return "관심사를 반영했어요. 이제 편하게 대화를 시작해 보세요.", profile
|
| 983 |
+
|
| 984 |
+
def pilot_unlock(pw):
|
| 985 |
+
if (pw or "").strip() == PILOT_PASSWORD:
|
| 986 |
+
return (True, "✓ 연구 파일럿 입장됨. 대화에서 메시지를 보낸 뒤, 아래에서 그 발화의 네 축을 평가해 저장하세요.",
|
| 987 |
+
gr.update(visible=True))
|
| 988 |
+
return (False, "✗ 비밀번호가 올바르지 않습니다.", gr.update(visible=False))
|
| 989 |
+
|
| 990 |
+
def pilot_save_fn(v_eva, v_ear, v_rei, v_val, v_act, profile, session, consent, unlocked):
|
| 991 |
+
blank = (gr.update(), gr.update(), gr.update(), gr.update(), gr.update())
|
| 992 |
+
if not unlocked:
|
| 993 |
+
return ("먼저 비밀번호로 입장해 주세요.", *blank)
|
| 994 |
+
if not consent:
|
| 995 |
+
return ("저장하려면 상단의 동의에 체크해 주세요 (발화 원문은 저장되지 않습니다).", *blank)
|
| 996 |
+
if profile.get("last_utt") is None or profile.get("last") is None:
|
| 997 |
+
return ("먼저 대화에서 메시지를 한 번 보내 주세요 — 직전에 보낸 발화를 평가합니다.", *blank)
|
| 998 |
+
vals = {"EVA": v_eva, "EAR": v_ear, "REI": v_rei, "VAL": v_val}
|
| 999 |
+
if any(vals[a] is None for a in vals):
|
| 1000 |
+
return ("네 축(기분·에너지·사고방식·자율성)을 모두 평가해 주세요. (활성도는 선택)", *blank)
|
| 1001 |
+
try:
|
| 1002 |
+
self_labels = {a: _PILOT_MAP[a][vals[a]] for a in vals}
|
| 1003 |
+
act_self = _PILOT_MAP["ACT"][v_act] if v_act is not None else None # ACT는 선택(실험 항목)
|
| 1004 |
+
except KeyError:
|
| 1005 |
+
return ("선택지를 다시 골라 주세요.", *blank)
|
| 1006 |
+
llm = _gemini_score_4axis(profile["last_utt"]) # 발화 텍스트는 메모리에서만 사용, 저장 안 함
|
| 1007 |
+
ko = profile["last"]
|
| 1008 |
+
cum = profile.get("cum", {})
|
| 1009 |
+
ko_cum = {"VAL": cum.get("VAL"), "REI": cum.get("REI"),
|
| 1010 |
+
"EVA": profile["last"]["EVA"], "EAR": profile["last"]["EAR"]}
|
| 1011 |
+
store.add_pilot_label(session, profile.get("participant"), profile.get("n", 0),
|
| 1012 |
+
len(profile["last_utt"]), ko, ko_cum, llm, self_labels, bool(consent), act_self)
|
| 1013 |
+
note = "측정·자기라벨·LLM 채점 저장" if llm else "측정·자기라벨 저장 (LLM 채점 실패)"
|
| 1014 |
+
if act_self is not None:
|
| 1015 |
+
note += " (+활성도)"
|
| 1016 |
+
total = getattr(store, "pilot_saved", 0)
|
| 1017 |
+
return (f"✓ 저장됐어요 — {note}. (누적 {total}건) 다음 발화를 보낸 뒤 또 평가할 수 있어요.",
|
| 1018 |
+
gr.update(value=None), gr.update(value=None), gr.update(value=None), gr.update(value=None), gr.update(value=None))
|
| 1019 |
+
|
| 1020 |
+
SELFREPORT_CSS = """
|
| 1021 |
+
#selfreport_box {background:#FFF7E6; border:1px solid #E6C674; border-radius:12px;
|
| 1022 |
+
padding:10px 12px; margin:8px 0 6px 0; box-shadow:0 2px 10px rgba(180,140,40,0.12);}
|
| 1023 |
+
#selfreport_box span, #selfreport_box label {font-weight:600 !important;}
|
| 1024 |
+
#fit_box {background:#E7F4F4; border:1px solid #7FBFC4; border-radius:12px;
|
| 1025 |
+
padding:10px 12px; margin:2px 0 6px 0; box-shadow:0 2px 10px rgba(14,124,134,0.12);}
|
| 1026 |
+
#fit_box span, #fit_box label {font-weight:600 !important;}
|
| 1027 |
+
#selfreport_box, #fit_box {position:sticky; bottom:8px; z-index:30;}
|
| 1028 |
+
"""
|
| 1029 |
+
with gr.Blocks(title="자기인식 지원 (0322 데이터 수집판)", css=SELFREPORT_CSS) as app:
|
| 1030 |
+
gr.Markdown("## 자기인식 지원 대화 — 데이터 수집판")
|
| 1031 |
+
session_state = gr.State("")
|
| 1032 |
+
prof_state = gr.State(new_profile())
|
| 1033 |
+
|
| 1034 |
+
with gr.Accordion("연구 참여 안내 · 동의 (먼저 읽어주세요)", open=True):
|
| 1035 |
+
gr.Markdown(
|
| 1036 |
+
"이 앱은 **측정 도구 개선을 위한 실험**입니다. 대화하면 가치·인지·감정이 자동 측정되고, 가끔 자기보고를 여쭤봅니다. "
|
| 1037 |
+
"동의하시면 **측정 좌표(숫자)와 자기보고 라벨만 익명으로 저장**되고, **대화 원문은 저장하지 않습니다**(길이만 숫자로 남음). "
|
| 1038 |
+
"응답 생성을 위해 입력은 Google Gemini API로 전송됩니다(저장 안 함). "
|
| 1039 |
+
"**아래 체크는 기본으로 켜져 있습니다 — 익명 저장에 참여하시려면 그대로 두시고, 원치 않으면 해제하세요.** 언제든 중단할 수 있습니다.")
|
| 1040 |
+
consent = gr.Checkbox(value=True, label="✅ 익명 저장(측정 좌표·자기보고 라벨만, 발화 원문 제외)에 동의합니다. — 연구에 큰 도움이 됩니다")
|
| 1041 |
+
participant = gr.Textbox(label="참가자 코드 (선택 · 별명 권장 — 예: 라일락-01. 다시 올 땐 같은 코드를 쓰면 변화를 볼 수 있어요)", lines=1)
|
| 1042 |
+
|
| 1043 |
+
with gr.Accordion("1단계 · 관심사 (먼저 알려주세요 · 건너뛰어도 됩니다)", open=True):
|
| 1044 |
+
gr.Markdown("대화를 당신 맥락에 맞추기 위한 선택입니다. 건너뛰어도 됩니다.")
|
| 1045 |
+
with gr.Row():
|
| 1046 |
+
d1 = gr.Dropdown(choices=[l for l, _ in VALUE_DOMAINS], label="1순위 — 요즘 가장 중요한 것")
|
| 1047 |
+
d2 = gr.Dropdown(choices=["— 없음 —"] + [l for l, _ in VALUE_DOMAINS], value="— 없음 —",
|
| 1048 |
+
label="2순위 (선택)")
|
| 1049 |
+
happy_cg = gr.CheckboxGroup(choices=[l for l, _ in COMMON_HAPPY], label="요즘 나를 행복하게 하는 것 (최대 3개 · 1순위 선택 시 더 추가됨)")
|
| 1050 |
+
barrier_cg = gr.CheckboxGroup(choices=[l for l, _ in COMMON_BARRIER], label="행복을 방해하는 것 (최대 3개)")
|
| 1051 |
+
interests_btn = gr.Button("관심사 반영")
|
| 1052 |
+
interests_status = gr.Markdown("")
|
| 1053 |
+
|
| 1054 |
+
with gr.Accordion("2단계 · 짧은 질문 (가치·인지)", open=True):
|
| 1055 |
+
gr.Markdown("가까운 보기를 고르거나 아래 칸에 직접 한 문장 적어 주세요. 완료하면 대화가 시작됩니다.")
|
| 1056 |
+
onboard_inputs = []
|
| 1057 |
+
for item in ONBOARD:
|
| 1058 |
+
gr.Markdown(f"**{item['q']}**")
|
| 1059 |
+
r = gr.Radio(choices=[c[0] for c in item["choices"]], label="선택지")
|
| 1060 |
+
t = gr.Textbox(label="또는 직접 적어주세요 (한 문장 이상, 선택)", lines=2,
|
| 1061 |
+
placeholder="예: 나는 보통 ~한 편이에요. 왜냐하면 ~")
|
| 1062 |
+
onboard_inputs += [r, t]
|
| 1063 |
+
onboard_btn = gr.Button("대화 시작하기", variant="primary")
|
| 1064 |
+
|
| 1065 |
+
with gr.Row():
|
| 1066 |
+
with gr.Column(scale=3):
|
| 1067 |
+
_cb_kwargs = dict(label="대화", height=360, visible=False)
|
| 1068 |
+
try:
|
| 1069 |
+
if int(gr.__version__.split(".")[0]) < 6:
|
| 1070 |
+
_cb_kwargs["type"] = "messages" # 4.x/5.x: 메시지 딕셔너리 쓰려면 필요. 6.0: 인자 자체가 없음(기본 messages)
|
| 1071 |
+
except Exception:
|
| 1072 |
+
pass
|
| 1073 |
+
chatbot = gr.Chatbot(**_cb_kwargs)
|
| 1074 |
+
# 주제 선택 칩 — 시작 시 표시(주제 고민 줄이기), 첫 메시지 후 숨김
|
| 1075 |
+
with gr.Row(visible=False) as topic_row:
|
| 1076 |
+
tb1 = gr.Button("오늘 있었던 일", size="sm")
|
| 1077 |
+
tb2 = gr.Button("요즘 신경 쓰이는 거", size="sm")
|
| 1078 |
+
tb3 = gr.Button("그냥 사는 얘기", size="sm")
|
| 1079 |
+
tb4 = gr.Button("관계 얘기", size="sm")
|
| 1080 |
+
# 자기보고(라벨) — 입력창 '바로 위'에 강조 박스로 표시 (스크롤 없이 즉시 눈에 띄도록)
|
| 1081 |
+
label_radio = gr.Radio(choices=[], label="자기보고", visible=False, elem_id="selfreport_box")
|
| 1082 |
+
fit_radio = gr.Radio(choices=[], label="시스템 측정이 맞나요?", visible=False, elem_id="fit_box")
|
| 1083 |
+
label_status = gr.Markdown("")
|
| 1084 |
+
with gr.Row():
|
| 1085 |
+
msg = gr.Textbox(show_label=False, placeholder="메시지 입력", scale=8)
|
| 1086 |
+
send_btn = gr.Button("입력", variant="primary", scale=1, min_width=64)
|
| 1087 |
+
with gr.Row():
|
| 1088 |
+
save_check_btn = gr.Button("💾 내 데이터 저장 상태 확인", size="sm", scale=1)
|
| 1089 |
+
save_check_status = gr.Markdown("")
|
| 1090 |
+
with gr.Row(visible=False) as helper_row:
|
| 1091 |
+
qb_other = gr.Button("🔄 다른 얘기", size="sm")
|
| 1092 |
+
end_btn = gr.Button("✋ 이제 그만할래", size="sm", variant="secondary")
|
| 1093 |
+
with gr.Column(scale=2):
|
| 1094 |
+
profile_md = gr.HTML("<div style='color:#999;padding:6px;font-family:system-ui;'>대화를 시작하면 여기에 측정 나침반이 보여요.</div>")
|
| 1095 |
+
stats_md = gr.Markdown(store.summary_md())
|
| 1096 |
+
|
| 1097 |
+
with gr.Accordion("도움이 필요하면 — 상담 연락처", open=False):
|
| 1098 |
+
gr.Markdown("이 앱은 자기이해를 돕는 실험 도구이며 상담·치료가 아닙니다. "
|
| 1099 |
+
"힘들거나 위기라고 느껴지면 연락하세요 — **자살예방 109**(24시간) · **정신건강 1577-0199** · 긴급 **119·112**.")
|
| 1100 |
+
|
| 1101 |
+
with gr.Accordion("🔬 연구 파일럿 (비밀번호 필요)", open=False):
|
| 1102 |
+
gr.Markdown(
|
| 1103 |
+
"연구 참가자 전용입니다. 입장하면 **방금 보낸 발화**에 대해 네 축(기분·에너지·사고방식·자율성)을 "
|
| 1104 |
+
"직접 5단계로 평가해 저장합니다. 측정값(KoSimCSE)·자기 라벨·LLM(Gemini) 채점이 함께 기록되며, "
|
| 1105 |
+
"**발화 원문은 저장되지 않습니다**(길이만). 저장은 상단 동의 체크가 있어야 합니다.")
|
| 1106 |
+
pilot_state = gr.State(False)
|
| 1107 |
+
with gr.Row():
|
| 1108 |
+
pilot_pw = gr.Textbox(label="비밀번호", type="password", scale=3)
|
| 1109 |
+
pilot_enter = gr.Button("입장", scale=1, min_width=80)
|
| 1110 |
+
pilot_status = gr.Markdown("")
|
| 1111 |
+
with gr.Group(visible=False) as pilot_panel:
|
| 1112 |
+
gr.Markdown("**직전에 보낸 발화**를 떠올리며, 그때의 상태를 골라 주세요.")
|
| 1113 |
+
sr_eva = gr.Radio(choices=PILOT_AXES["EVA"][1], label="기분 — 그 말을 할 때 내 기분은? (감정의 좋고/나쁨)")
|
| 1114 |
+
sr_ear = gr.Radio(choices=PILOT_AXES["EAR"][1], label="에너지 — 그때 내 에너지·긴장 수준은? (기분 좋고나쁨과 무관, 에너지만)")
|
| 1115 |
+
sr_rei = gr.Radio(choices=PILOT_AXES["REI"][1], label="사고방식 — 그 결정/생각은 무엇으로? (직감 ↔ 근거)")
|
| 1116 |
+
sr_val = gr.Radio(choices=PILOT_AXES["VAL"][1], label="자율성 — 그건 누구의 기준으로? (주변 ↔ 나)")
|
| 1117 |
+
sr_act = gr.Radio(choices=PILOT_ACT[1], label="🧪 신체 활성도 (선택·실험) — 그때 몸이 얼마나 격렬했나/소진됐나? (에너지 높낮이와 별개: 차분한 평온 ↔ 격렬·탈진)")
|
| 1118 |
+
pilot_save = gr.Button("이번 발화 자기라벨 저장", variant="primary")
|
| 1119 |
+
pilot_save_status = gr.Markdown("")
|
| 1120 |
+
|
| 1121 |
+
chat_outputs = [chatbot, prof_state, profile_md, msg, label_radio, label_status, stats_md, fit_radio]
|
| 1122 |
+
def _hide_topics(): return gr.update(visible=False)
|
| 1123 |
+
onboard_btn.click(do_onboard, onboard_inputs + [consent, participant, session_state, prof_state],
|
| 1124 |
+
[prof_state, profile_md, chatbot, topic_row, helper_row, stats_md])
|
| 1125 |
+
msg.submit(do_chat, [msg, chatbot, prof_state, session_state, consent], chat_outputs).then(_hide_topics, None, topic_row)
|
| 1126 |
+
send_btn.click(do_chat, [msg, chatbot, prof_state, session_state, consent], chat_outputs).then(_hide_topics, None, topic_row)
|
| 1127 |
+
save_check_btn.click(check_save_status, [prof_state, consent, session_state], save_check_status)
|
| 1128 |
+
|
| 1129 |
+
def quick_send(text):
|
| 1130 |
+
def _fn(chat, profile, session, consent):
|
| 1131 |
+
return do_chat(text, chat, profile, session, consent)
|
| 1132 |
+
return _fn
|
| 1133 |
+
# 주제 칩 → 그 주제로 가볍게 대화 시작 (클릭 후 칩 숨김)
|
| 1134 |
+
ti = [chatbot, prof_state, session_state, consent]
|
| 1135 |
+
tb1.click(quick_send("오늘 있었던 일 얘기해볼까"), ti, chat_outputs).then(_hide_topics, None, topic_row)
|
| 1136 |
+
tb2.click(quick_send("요즘 좀 신경 쓰이는 일이 있어"), ti, chat_outputs).then(_hide_topics, None, topic_row)
|
| 1137 |
+
tb3.click(quick_send("그냥 사는 얘기나 해볼까"), ti, chat_outputs).then(_hide_topics, None, topic_row)
|
| 1138 |
+
tb4.click(quick_send("사람들이랑 지내는 얘기 좀 해볼까"), ti, chat_outputs).then(_hide_topics, None, topic_row)
|
| 1139 |
+
qb_other.click(quick_send("다른 얘기 하자"), ti, chat_outputs).then(_hide_topics, None, topic_row)
|
| 1140 |
+
end_btn.click(do_end, [chatbot, prof_state],
|
| 1141 |
+
[chatbot, msg, topic_row, helper_row, label_radio, label_status, stats_md, fit_radio])
|
| 1142 |
+
|
| 1143 |
+
label_radio.input(do_label, [label_radio, prof_state, consent, session_state],
|
| 1144 |
+
[label_status, stats_md, label_radio, fit_radio, prof_state])
|
| 1145 |
+
fit_radio.input(do_fit, [fit_radio, prof_state, consent, session_state],
|
| 1146 |
+
[label_status, fit_radio, prof_state])
|
| 1147 |
+
d1.change(on_domain, [d1], [happy_cg, barrier_cg])
|
| 1148 |
+
interests_btn.click(save_interests, [d1, d2, happy_cg, barrier_cg, consent, session_state, prof_state],
|
| 1149 |
+
[interests_status, prof_state])
|
| 1150 |
+
|
| 1151 |
+
pilot_enter.click(pilot_unlock, [pilot_pw], [pilot_state, pilot_status, pilot_panel])
|
| 1152 |
+
pilot_save.click(pilot_save_fn,
|
| 1153 |
+
[sr_eva, sr_ear, sr_rei, sr_val, sr_act, prof_state, session_state, consent, pilot_state],
|
| 1154 |
+
[pilot_save_status, sr_eva, sr_ear, sr_rei, sr_val, sr_act])
|
| 1155 |
+
|
| 1156 |
+
# 각 사용자 접속(페이지 로드)마다 고유 세션 ID 생성 — 사용자 간 세션 분리
|
| 1157 |
+
app.load(lambda: str(uuid.uuid4())[:8], outputs=[session_state])
|
| 1158 |
+
return app
|
| 1159 |
+
|
| 1160 |
+
|
| 1161 |
+
def render_profile(profile):
|
| 1162 |
+
last = profile.get("last")
|
| 1163 |
+
cum = profile.get("cum", {})
|
| 1164 |
+
sm = profile.get("smooth", {})
|
| 1165 |
+
if not last:
|
| 1166 |
+
return "<div style='color:#999;padding:6px;font-family:system-ui;'>대화를 시작하면 여기에 측정 나침반이 보여요.</div>"
|
| 1167 |
+
|
| 1168 |
+
def axis_bar(x, name, neg_pole, pos_pole, trusted):
|
| 1169 |
+
x = 0.0 if x is None else x
|
| 1170 |
+
c = max(-2.5, min(2.5, x))
|
| 1171 |
+
width = abs(c) / 2.5 * 50.0 # 트랙의 절반(50%)이 한쪽 최대
|
| 1172 |
+
color = "#3b82f6" if trusted else "#94a3b8"
|
| 1173 |
+
opacity = "1" if trusted else "0.5"
|
| 1174 |
+
fill = (f"left:50%;width:{width:.0f}%;" if c >= 0 else f"right:50%;width:{width:.0f}%;")
|
| 1175 |
+
tag = "" if trusted else " <span style='font-size:10px;color:#aaa;'>(방향 위주)</span>"
|
| 1176 |
+
return (
|
| 1177 |
+
"<div style='margin:7px 0;'>"
|
| 1178 |
+
"<div style='display:flex;justify-content:space-between;font-size:12px;color:#777;'>"
|
| 1179 |
+
f"<span>{neg_pole}</span><span style='font-weight:600;color:#333;'>{name}{tag}</span><span>{pos_pole}</span></div>"
|
| 1180 |
+
"<div style='position:relative;height:10px;background:#eee;border-radius:5px;margin-top:3px;'>"
|
| 1181 |
+
"<div style='position:absolute;left:50%;top:0;bottom:0;width:1px;background:#bbb;'></div>"
|
| 1182 |
+
f"<div style='position:absolute;top:0;bottom:0;{fill}background:{color};border-radius:5px;opacity:{opacity};'></div>"
|
| 1183 |
+
"</div></div>"
|
| 1184 |
+
)
|
| 1185 |
+
|
| 1186 |
+
bars = (axis_bar(sm.get("EVA"), "기분", "슬픔", "즐거움", True)
|
| 1187 |
+
+ axis_bar(sm.get("EAR"), "에너지", "차분", "들뜸", False)
|
| 1188 |
+
+ axis_bar(cum.get("VAL"), "자율성", "맞춤", "내 뜻", False))
|
| 1189 |
+
tr = emotion_trend(profile)
|
| 1190 |
+
trend = " · ↗ 긍정 흐름" if tr > 0.1 else (" · ↘ 가라앉는 흐름" if tr < -0.1 else " · → 안정")
|
| 1191 |
+
return (
|
| 1192 |
+
"<div style='font-family:system-ui;max-width:430px;'>"
|
| 1193 |
+
"<div style='font-weight:600;margin-bottom:6px;color:#444;'>🧭 측정 나침반 (실시간)</div>"
|
| 1194 |
+
f"{bars}"
|
| 1195 |
+
f"<div style='font-size:11px;color:#aaa;margin-top:6px;'>측정값일 뿐 진단이 아니에요{trend}</div>"
|
| 1196 |
+
"</div>"
|
| 1197 |
+
)
|
| 1198 |
+
|
| 1199 |
+
|
| 1200 |
+
if __name__ == "__main__":
|
| 1201 |
+
build_app().launch()
|