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