mingming2323 Claude Opus 4.6 commited on
Commit
ff932fe
·
1 Parent(s): 50b9016

Rename to 러브로그(LoveLog) and clean up unused files

Browse files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

app.py CHANGED
@@ -1,8 +1,7 @@
1
- """러브로그 — Gradio 플레이 (카카오톡 타일 채팅 UI)"""
2
 
3
  import json
4
  import os
5
- import random
6
  import re
7
  from datetime import datetime
8
 
@@ -11,28 +10,30 @@ from dotenv import load_dotenv
11
  from openai import OpenAI
12
 
13
  from scenarios import (
14
- SCENARIOS, BOSS_EVENTS, TONE_DISTRIBUTION, TONES, DATING_TYPES, ALL_AXES,
 
 
 
 
 
 
15
  )
16
- from gpt_engine import generate_event, generate_reaction, generate_trait_summary
17
  from trait_engine import (
18
  empty_vector, apply_effects, classify_type, find_matches,
19
- vector_to_profile, profile_to_vector,
20
  )
 
21
 
22
  load_dotenv()
23
 
24
- TONE_EMOJI = {"positive": "💗", "negative": "💔", "wildcard": "⚡"}
25
-
26
 
27
  # ── HTML 헬퍼 ──────────────────────────────────────────────
28
 
29
  def _md_bold(text):
30
- """**볼드** → <strong> 변환"""
31
  return re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
32
 
33
 
34
  def _render_chat(state):
35
- """채팅 히스토리 전체를 카카오톡 스타일 HTML로 렌더링"""
36
  parts = []
37
  for msg in state["chat_history"]:
38
  role = msg["role"]
@@ -45,22 +46,80 @@ def _render_chat(state):
45
  parts.append(f'<div class="bubble user-choice">{text}</div>')
46
  elif role == "question":
47
  parts.append(f'<div class="bubble question">{text}</div>')
48
-
 
 
 
49
  inner = "\n".join(parts)
50
  return f'''<div class="chat-container" id="chatbox">
51
- <div class="chat-inner">
52
  {inner}
53
- </div>
54
- </div>'''
55
 
56
 
57
- # ── 게임 로직 ──────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  def new_state():
60
  return {
61
  "phase": "idle",
62
- "events": [],
63
- "event_idx": 0,
 
 
 
 
64
  "trait_vector": empty_vector(),
65
  "choice_history": [],
66
  "event_logs": [],
@@ -68,131 +127,235 @@ def new_state():
68
  "current_reaction_data": {},
69
  "current_event_log": {},
70
  "chat_history": [],
 
 
 
71
  }
72
 
73
 
74
- def build_events(boss_chance=0.05):
75
- events = []
76
- for forced_tone in TONE_DISTRIBUTION:
77
- tone = forced_tone or random.choice(["positive", "negative", "wildcard"])
78
- pool = [s for s in SCENARIOS.values() if s["tone"] == tone]
79
- used = {e["name"] for e in events}
80
- pool = [s for s in pool if s["name"] not in used]
81
- if not pool:
82
- pool = [s for s in SCENARIOS.values() if s["name"] not in used]
83
- events.append(random.choice(pool))
84
- if random.random() < boss_chance:
85
- boss = random.choice(list(BOSS_EVENTS.values()))
86
- events.append({**boss, "_is_boss": True})
87
- return events
88
-
89
-
90
  def get_client():
91
  api_key = os.getenv("OPENAI_API_KEY", "")
92
- if not api_key:
93
- return None
94
- return OpenAI(api_key=api_key)
95
 
96
 
97
  def get_model():
98
  return os.getenv("OPENAI_MODEL", "gpt-4o-mini")
99
 
100
 
101
- # ── 핸들러: 시작 ──────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
 
103
  def on_start(state):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  client = get_client()
105
  if not client:
106
- error_html = '<div class="chat-container"><div class="bubble narration">❌ OPENAI_API_KEY를 .env에 설정해주세요.</div></div>'
107
- return (
108
- state, error_html,
109
- gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
110
- gr.update(visible=False), gr.update(visible=True),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  )
 
 
 
 
 
 
 
 
 
112
 
113
- state = new_state()
114
- state["phase"] = "loading_event"
115
- state["events"] = build_events()
116
 
117
- return _generate_event(state, client)
118
 
 
 
 
 
119
 
120
- def _generate_event(state, client):
121
- idx = state["event_idx"]
122
- events = state["events"]
123
 
124
- if idx >= len(events):
125
- return _show_results(state)
 
 
 
 
 
 
 
 
126
 
127
- scenario = events[idx]
128
- is_boss = scenario.get("_is_boss", False)
129
- tone = TONE_EMOJI.get(scenario["tone"], "")
130
- total = len(events)
131
 
132
- if is_boss:
133
- header = f"⚔️ BOSS — {scenario['name']}"
134
- else:
135
- header = f"{idx + 1}/{total} {tone} {scenario['name']}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
  model = get_model()
138
  try:
139
  event_data = generate_event(
140
- client, scenario, state["trait_vector"], state["choice_history"],
141
- event_logs=state["event_logs"], model=model,
 
142
  )
143
  except Exception as e:
144
- state["event_idx"] += 1
145
- return _generate_event(state, client)
 
146
 
147
  state["current_event_data"] = event_data
148
  state["current_event_log"] = {
149
- "index": idx + 1,
150
- "scenario": scenario["name"],
151
- "desc": scenario["desc"],
152
- "tone": scenario["tone"],
153
- "is_boss": is_boss,
 
154
  }
155
  state["phase"] = "turn1"
156
 
 
 
 
 
157
  situation = event_data.get("situation", "")
158
- choices = event_data.get("choices", [])
159
  state["current_event_log"]["situation"] = situation
160
-
161
- # 채팅 히스토리에 추가
162
- state["chat_history"].append({"role": "header", "text": header})
163
  state["chat_history"].append({"role": "narrator", "text": situation})
164
 
165
- game_html = _render_chat(state)
166
-
167
- btn_a = choices[0].get("text", "A") if len(choices) > 0 else "A"
168
- btn_b = choices[1].get("text", "B") if len(choices) > 1 else "B"
169
- btn_c = choices[2].get("text", "C") if len(choices) > 2 else "C"
170
-
171
- return (
172
- state, game_html,
173
- gr.update(value=f"A. {btn_a}", visible=True),
174
- gr.update(value=f"B. {btn_b}", visible=True),
175
- gr.update(value=f"C. {btn_c}", visible=True),
176
- gr.update(visible=False), gr.update(visible=False),
177
- )
178
-
179
-
180
- # ── 핸들러: 선택 ──────────────────────────────────────────
181
-
182
- def on_choice(choice_idx, state):
183
- client = get_client()
184
- if not client:
185
- return state, "API 키 없음", *_hide_buttons()
186
 
187
- phase = state["phase"]
188
 
189
- if phase == "turn1":
190
- return _handle_turn1(choice_idx, state, client)
191
- elif phase == "turn2":
192
- return _handle_turn2(choice_idx, state, client)
193
-
194
- return state, "알 수 없는 상태", *_hide_buttons()
195
 
 
196
 
197
  def _handle_turn1(choice_idx, state, client):
198
  choices = state["current_event_data"].get("choices", [])
@@ -200,58 +363,47 @@ def _handle_turn1(choice_idx, state, client):
200
  choice_idx = 0
201
  selected = choices[choice_idx]
202
 
203
- scenario = state["events"][state["event_idx"]]
204
- is_boss = scenario.get("_is_boss", False)
205
- multiplier = 2.0 if is_boss else 1.0
 
 
 
 
206
 
207
- apply_effects(state["trait_vector"], selected.get("trait_effect", {}), multiplier=multiplier)
208
  state["choice_history"].append(selected.get("text", ""))
209
  state["current_event_log"]["turn1"] = {"options": choices, "selected": selected}
210
 
211
- # 유저 선택 말풍선
212
- state["chat_history"].append({
213
- "role": "user",
214
- "text": selected.get("text", ""),
215
- })
216
 
 
 
217
  situation = state["current_event_data"].get("situation", "")
218
  model = get_model()
219
  try:
220
  reaction_data = generate_reaction(
221
- client, scenario, situation, selected,
222
  event_logs=state["event_logs"], model=model,
223
  )
224
  except Exception as e:
225
- state["event_idx"] += 1
226
  state["event_logs"].append(state["current_event_log"])
227
- return _generate_event(state, client)
 
228
 
229
  state["current_reaction_data"] = reaction_data
230
  state["phase"] = "turn2"
231
 
232
  reaction_text = reaction_data.get("reaction", "")
233
- choices_2 = reaction_data.get("choices", [])
234
-
235
  state["current_event_log"]["reaction"] = reaction_text
 
236
 
237
- # 상황 전개 말풍선
238
- state["chat_history"].append({
239
- "role": "narrator",
240
- "text": f"💬 {reaction_text}",
241
- })
242
- game_html = _render_chat(state)
243
-
244
- btn_a = choices_2[0].get("text", "A") if len(choices_2) > 0 else "A"
245
- btn_b = choices_2[1].get("text", "B") if len(choices_2) > 1 else "B"
246
- btn_c = choices_2[2].get("text", "C") if len(choices_2) > 2 else "C"
247
 
248
- return (
249
- state, game_html,
250
- gr.update(value=f"A. {btn_a}", visible=True),
251
- gr.update(value=f"B. {btn_b}", visible=True),
252
- gr.update(value=f"C. {btn_c}", visible=True),
253
- gr.update(visible=False), gr.update(visible=False),
254
- )
255
 
256
 
257
  def _handle_turn2(choice_idx, state, client):
@@ -260,68 +412,258 @@ def _handle_turn2(choice_idx, state, client):
260
  choice_idx = 0
261
  selected = choices[choice_idx]
262
 
263
- scenario = state["events"][state["event_idx"]]
264
- is_boss = scenario.get("_is_boss", False)
265
- multiplier = 2.0 if is_boss else 1.0
 
 
266
 
267
- apply_effects(state["trait_vector"], selected.get("trait_effect", {}), multiplier=multiplier)
268
  state["choice_history"].append(selected.get("text", ""))
269
  state["current_event_log"]["turn2"] = {"options": choices, "selected": selected}
270
  state["current_event_log"]["trait_snapshot"] = dict(state["trait_vector"])
 
271
  state["event_logs"].append(state["current_event_log"])
272
 
273
- # 유저 선택 말풍선
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  state["chat_history"].append({
275
- "role": "user",
276
- "text": selected.get("text", ""),
277
  })
278
 
279
- state["event_idx"] += 1
280
- return _generate_event(state, client)
281
 
 
 
282
 
283
- # ── 결과 ───────────────────────────────────────────────────
 
 
 
284
 
285
- def _show_results(state):
286
- best_type, scores = classify_type(state["trait_vector"])
287
- dt = DATING_TYPES.get(best_type, {})
288
 
289
- state["chat_history"].append({"role": "header", "text": "💜 결과 발표"})
 
 
 
290
 
291
- match_html = ""
292
- match_data = []
293
- try:
294
- matches = find_matches(state["trait_vector"], best_type, top_n=3)
295
- for i, m in enumerate(matches, 1):
296
- mdt = DATING_TYPES.get(m["type"], {})
297
- p = m["profile"]
298
- match_html += f'''<div class="bubble narration match-card">
299
- <div class="match-rank">#{i} 💞</div>
300
- <div class="match-name">{p['name']} ({p['age']}세, {p['gender']})</div>
301
- <div class="match-type">{mdt.get('emoji', '')} {mdt.get('name', '')} · {p.get('archetype', '')}</div>
302
- <div class="match-score">매칭 적합도 <strong>{m['match_score']}%</strong> · 행동 {m['behavior_sim']}% · 성격 {m['big5_sim']}%</div>
303
- </div>'''
304
- match_data.append({
305
- "name": p["name"], "age": p["age"], "gender": p["gender"],
306
- "archetype": p.get("archetype", ""), "type": m["type"],
307
- "match_score": m["match_score"],
308
- "behavior_sim": m["behavior_sim"], "big5_sim": m["big5_sim"],
309
- })
310
- except Exception:
311
- match_html = '<div class="bubble narration">매칭 데이터를 불러올 수 없습니다.</div>'
312
 
313
- my_profile = vector_to_profile(state["trait_vector"], best_type)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
 
315
- # GPT 성향 요약 생성
316
- client = get_client()
317
  model = get_model()
318
- type_name = dt.get("title", dt.get("name", ""))
319
  try:
320
- trait_summary = generate_trait_summary(client, my_profile, type_name, model=model)
 
 
 
321
  except Exception:
322
- trait_summary = dt.get("desc", "")
 
 
 
 
 
 
 
 
 
323
 
324
- # 채팅 히스토리 + 결과 카드를 합쳐서 렌더링
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  chat_parts = []
326
  for msg in state["chat_history"]:
327
  role = msg["role"]
@@ -332,27 +674,115 @@ def _show_results(state):
332
  chat_parts.append(f'<div class="bubble narration">{text}</div>')
333
  elif role == "user":
334
  chat_parts.append(f'<div class="bubble user-choice">{text}</div>')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
 
336
- # 결과 카드
337
  chat_parts.append(f'''<div class="bubble narration result-header">
338
  <div class="result-emoji">{dt.get("emoji", "💜")}</div>
339
  <div class="result-title">{dt.get("title", "")}</div>
340
  <div class="result-desc">{dt.get("desc", "")}</div>
341
  </div>''')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  chat_parts.append(f'''<div class="bubble narration">
343
- <div class="profile-content">{trait_summary}</div>
 
344
  </div>''')
345
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  inner = "\n".join(chat_parts)
347
  game_html = f'''<div class="chat-container results" id="chatbox">
348
- <div class="chat-inner">
349
  {inner}
350
- {match_html}
351
- </div>
352
- </div>'''
353
 
354
  _save_log(state, best_type, scores, match_data, my_profile)
355
-
356
  state["phase"] = "results"
357
 
358
  return (
@@ -360,6 +790,9 @@ def _show_results(state):
360
  gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
361
  gr.update(value="🔄 다시 플레이", visible=True),
362
  gr.update(visible=False),
 
 
 
363
  )
364
 
365
 
@@ -369,6 +802,12 @@ def _save_log(state, best_type, scores, match_data, profile):
369
  ts = datetime.now().strftime("%Y%m%d_%H%M%S")
370
  run_log = {
371
  "timestamp": ts,
 
 
 
 
 
 
372
  "profile": profile,
373
  "events": state["event_logs"],
374
  "final_vector": state["trait_vector"],
@@ -380,301 +819,320 @@ def _save_log(state, best_type, scores, match_data, profile):
380
  json.dump(run_log, f, ensure_ascii=False, indent=2)
381
 
382
 
383
- def _hide_buttons():
384
- return (
385
- gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
386
- gr.update(visible=False), gr.update(visible=False),
387
- )
388
-
389
-
390
  # ── CSS ────────────────────────────────────────────────────
391
 
392
  CUSTOM_CSS = """
393
- footer {display: none !important}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
 
395
- /* ── 전체 배경 ── */
396
  .gradio-container {
397
- background: linear-gradient(180deg, #f3eaff 0%, #ede5f7 100%) !important;
398
- max-width: 640px !important;
399
- margin: 0 auto !important;
400
  }
401
 
402
- /* ── 채팅 컨테이너 (스크롤 가능, 하단 고정) ── */
403
- .chat-container {
404
- display: flex;
405
- flex-direction: column-reverse;
406
- padding: 0;
407
- background: #ede5f7;
408
- border-radius: 20px;
409
- height: 520px;
410
- overflow-y: auto;
411
- scroll-behavior: smooth;
412
- }
413
 
414
- .chat-inner {
415
- display: flex;
416
- flex-direction: column;
417
- gap: 10px;
418
- padding: 20px 16px;
 
 
 
 
 
 
419
  }
 
 
 
420
 
421
- .chat-container::-webkit-scrollbar {
422
- width: 6px;
 
 
 
 
 
 
 
 
 
 
423
  }
 
 
 
424
  .chat-container::-webkit-scrollbar-thumb {
425
- background: #c9b5ec;
426
- border-radius: 3px;
427
- }
428
- .chat-container::-webkit-scrollbar-track {
429
- background: transparent;
430
  }
431
 
432
- /* ── 구분선 (이벤트 헤더) ── */
433
  .chat-divider {
434
- display: flex;
435
- align-items: center;
436
- justify-content: center;
437
- margin: 12px 0 4px;
438
  }
439
-
440
  .chat-divider span {
441
- background: #d4c4f0;
442
- color: #5b21b6;
443
- font-size: 12px;
444
- font-weight: 700;
445
- padding: 4px 16px;
446
- border-radius: 12px;
 
447
  }
448
 
449
- /* ── 말풍선 공통 ── */
450
- .bubble {
451
- max-width: 85%;
452
- padding: 14px 18px;
453
- border-radius: 18px;
454
- font-size: 14.5px;
455
- line-height: 1.7;
456
- word-break: keep-all;
457
- animation: fadeUp 0.25s ease;
458
  }
459
 
 
 
 
 
 
 
 
 
 
 
460
  @keyframes fadeUp {
461
- from { opacity: 0; transform: translateY(6px); }
462
- to { opacity: 1; transform: translateY(0); }
463
  }
464
 
465
- /* ── 지문 말풍선 (왼쪽, 보라) ── */
466
  .bubble.narration {
467
- background: linear-gradient(135deg, #d0c0ec 0%, #c4b0e4 100%);
468
- color: #2d1b4e;
469
- align-self: flex-start;
470
- border-bottom-left-radius: 4px;
471
- box-shadow: 0 1px 4px rgba(139, 92, 246, 0.12);
 
472
  }
473
 
474
- /* ── 유저 선택 말풍선 (오른쪽, 흰색) ── */
475
  .bubble.user-choice {
476
- background: #ffffff;
477
- color: #1a1a1a;
478
- align-self: flex-end;
479
- border-bottom-right-radius: 4px;
480
- box-shadow: 0 1px 6px rgba(0, 0, 0, 0.08);
481
- font-weight: 500;
 
482
  }
483
 
484
- /* ── 질문 말풍선 (지문과 동일) ── */
485
  .bubble.question {
486
- background: linear-gradient(135deg, #d0c0ec 0%, #c4b0e4 100%);
487
- color: #2d1b4e;
488
- align-self: flex-start;
489
- border-bottom-left-radius: 4px;
490
- font-weight: 600;
491
- font-size: 13.5px;
492
  }
493
 
494
- /* ── 카카오톡 스타일 선택 버튼 (세로 배치) ── */
495
- .choice-column {
496
- gap: 8px !important;
497
- padding: 6px 0 !important;
 
498
  }
499
-
 
 
 
 
 
 
 
500
  .choice-column button {
501
- background: white !important;
502
- border: 2px solid #d4bfff !important;
503
- border-radius: 24px !important;
504
- color: #5b21b6 !important;
505
- font-size: 14px !important;
506
- font-weight: 500 !important;
507
- padding: 13px 20px !important;
508
- text-align: left !important;
509
- transition: all 0.2s cubic-bezier(.4,0,.2,1) !important;
510
- box-shadow: 0 1px 4px rgba(139, 92, 246, 0.08) !important;
511
- min-height: 48px !important;
512
- cursor: pointer !important;
 
513
  }
514
-
515
  .choice-column button:hover {
516
- background: linear-gradient(135deg, #f5f0ff 0%, #ede5ff 100%) !important;
517
- border-color: #a78bfa !important;
518
- transform: translateY(-2px) !important;
519
- box-shadow: 0 4px 16px rgba(139, 92, 246, 0.18) !important;
520
  }
521
-
522
- .choice-column button:active {
523
- transform: translateY(0) !important;
524
- background: #ede5ff !important;
525
  }
526
 
527
- /* ── 시작/재시작 버튼 ── */
528
  .start-btn button {
529
- background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%) !important;
530
- border: none !important;
531
- border-radius: 16px !important;
532
- color: white !important;
533
- font-size: 16px !important;
534
- font-weight: 700 !important;
535
- padding: 16px 32px !important;
536
- box-shadow: 0 4px 20px rgba(124, 58, 237, 0.3) !important;
537
- transition: all 0.2s ease !important;
538
  }
539
-
540
  .start-btn button:hover {
541
- transform: translateY(-2px) !important;
542
- box-shadow: 0 6px 24px rgba(124, 58, 237, 0.4) !important;
 
 
543
  }
544
-
545
- /* ── 결과 화면 ── */
546
- .results {
547
- height: 600px;
548
  }
549
 
550
- .result-header {
551
- text-align: center;
552
- padding: 24px !important;
 
 
 
 
 
553
  }
554
-
555
- .result-emoji {
556
- font-size: 48px;
557
- margin-bottom: 8px;
 
 
 
 
 
 
 
 
 
 
558
  }
559
-
560
- .result-title {
561
- font-size: 20px;
562
- font-weight: 800;
563
- color: #5b21b6;
564
- margin-bottom: 6px;
565
  }
566
 
567
- .result-desc {
568
- font-size: 13.5px;
569
- color: #6b5b8a;
570
- line-height: 1.5;
571
- }
572
 
573
- .section-title {
574
- font-size: 14px;
575
- font-weight: 700;
576
- color: #7c3aed;
577
- margin-bottom: 8px;
578
- }
579
 
580
- .profile-content {
581
- font-size: 13px;
582
- line-height: 1.6;
583
- }
584
 
585
  .profile-json {
586
- background: #f8f5ff;
587
- border-radius: 10px;
588
- padding: 12px;
589
- font-size: 11.5px;
590
- overflow-x: auto;
591
- color: #3b1f6e;
592
- border: 1px solid #e2d5f5;
593
- }
594
-
595
- .match-card {
596
- border-left: 4px solid #a78bfa !important;
597
- }
598
-
599
- .match-rank {
600
- font-size: 16px;
601
- font-weight: 700;
602
- }
603
-
604
- .match-name {
605
- font-size: 15px;
606
- font-weight: 600;
607
- color: #5b21b6;
608
- margin: 4px 0;
609
- }
610
-
611
- .match-type {
612
- font-size: 12.5px;
613
- color: #7c6b9e;
614
- margin-bottom: 4px;
615
- }
616
-
617
- .match-score {
618
- font-size: 12.5px;
619
- color: #4a3670;
620
- }
621
-
622
- /* ── 타이틀 ── */
623
- .title-area {
624
- text-align: center;
625
- padding: 6px 0 2px;
626
- }
627
-
628
- .title-area h1 {
629
- color: #5b21b6 !important;
630
- font-size: 22px !important;
631
- }
632
-
633
- .title-area p {
634
- color: #7c6b9e;
635
- font-size: 13.5px;
636
- }
637
-
638
- /* ── 웰컴 화면 ── */
639
- .welcome-container {
640
- display: flex;
641
- flex-direction: column;
642
- align-items: center;
643
- justify-content: center;
644
- gap: 16px;
645
- padding: 60px 20px;
646
- background: #ede5f7;
647
- border-radius: 20px;
648
- height: 520px;
649
- text-align: center;
650
  }
651
 
652
- .welcome-emoji {
653
- font-size: 56px;
654
- }
655
-
656
- .welcome-title {
657
- font-size: 20px;
658
- font-weight: 700;
659
- color: #5b21b6;
660
- }
 
 
 
 
 
 
 
 
 
 
 
661
 
662
- .welcome-desc {
663
- font-size: 14px;
664
- color: #6b5b8a;
665
- line-height: 1.7;
666
- max-width: 300px;
667
- }
668
  """
669
 
 
 
 
 
 
 
 
 
670
  WELCOME_HTML = """<div class="welcome-container">
671
  <div class="welcome-emoji">💜</div>
672
- <div class="welcome-title">Love Run시작하세요</div>
673
  <div class="welcome-desc">
674
  가상의 연애 상황을 체험하며<br>
675
  당신의 성향을 발견하세요.<br><br>
676
- 플레이가 끝나면,<br>
677
- 당신과 맞는 인연을 찾아드립니다.
678
  </div>
679
  </div>"""
680
 
@@ -683,7 +1141,7 @@ WELCOME_HTML = """<div class="welcome-container">
683
 
684
  with gr.Blocks(
685
  title="💜 러브로그",
686
- theme=gr.themes.Soft(primary_hue="purple"),
687
  css=CUSTOM_CSS,
688
  ) as app:
689
 
@@ -693,16 +1151,33 @@ with gr.Blocks(
693
  )
694
 
695
  state = gr.State(new_state())
696
-
697
  game_display = gr.HTML(WELCOME_HTML)
698
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
699
  with gr.Column(elem_classes=["choice-column"]):
700
  btn_a = gr.Button("A", visible=False, variant="secondary", scale=1)
701
  btn_b = gr.Button("B", visible=False, variant="secondary", scale=1)
702
  btn_c = gr.Button("C", visible=False, variant="secondary", scale=1)
703
 
704
  start_btn = gr.Button(
705
- "💜 Love Run 시작", variant="huggingface", size="lg",
706
  elem_classes=["start-btn"],
707
  )
708
  restart_btn = gr.Button(
@@ -710,13 +1185,58 @@ with gr.Blocks(
710
  elem_classes=["start-btn"],
711
  )
712
 
713
- outputs = [state, game_display, btn_a, btn_b, btn_c, restart_btn, start_btn]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
714
 
715
- start_btn.click(fn=on_start, inputs=[state], outputs=outputs)
716
- restart_btn.click(fn=on_start, inputs=[state], outputs=outputs)
717
 
718
- btn_a.click(fn=lambda s: on_choice(0, s), inputs=[state], outputs=outputs)
719
- btn_b.click(fn=lambda s: on_choice(1, s), inputs=[state], outputs=outputs)
720
- btn_c.click(fn=lambda s: on_choice(2, s), inputs=[state], outputs=outputs)
721
 
722
- app.launch(share=True)
 
 
1
+ """러브로그 — Gradio 플레이 (스테이지 기반 + 다중 캐릭터)"""
2
 
3
  import json
4
  import os
 
5
  import re
6
  from datetime import datetime
7
 
 
10
  from openai import OpenAI
11
 
12
  from scenarios import (
13
+ STAGES, DATING_TYPES, WARMTH_THRESHOLDS, MAX_INTERRUPTIONS, INTERRUPT_STAGES,
14
+ pick_characters, get_warmth_variant, should_interrupt, get_confession_outcome,
15
+ )
16
+ from gpt_engine import (
17
+ generate_first_meetings, generate_event, generate_reaction,
18
+ generate_interruption, generate_ending, generate_analysis,
19
+ generate_personality_description,
20
  )
 
21
  from trait_engine import (
22
  empty_vector, apply_effects, classify_type, find_matches,
23
+ vector_to_profile, generate_match_reason, AXIS_NAMES_KR,
24
  )
25
+ from scene_art import get_scene_image
26
 
27
  load_dotenv()
28
 
 
 
29
 
30
  # ── HTML 헬퍼 ──────────────────────────────────────────────
31
 
32
  def _md_bold(text):
 
33
  return re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
34
 
35
 
36
  def _render_chat(state):
 
37
  parts = []
38
  for msg in state["chat_history"]:
39
  role = msg["role"]
 
46
  parts.append(f'<div class="bubble user-choice">{text}</div>')
47
  elif role == "question":
48
  parts.append(f'<div class="bubble question">{text}</div>')
49
+ elif role == "char_card":
50
+ parts.append(f'<div class="bubble narration char-card">{text}</div>')
51
+ elif role == "scene":
52
+ parts.append(f'<img class="scene-art" src="{msg["text"]}" alt="scene">')
53
  inner = "\n".join(parts)
54
  return f'''<div class="chat-container" id="chatbox">
 
55
  {inner}
56
+ </div>
57
+ '''
58
 
59
 
60
+ # ── 선택 히스토리 빌더 ────────────────────────────────────────
61
+
62
+ def _format_effects(effects: dict) -> str:
63
+ """trait_effect에서 상위 3개를 한국어로."""
64
+ if not effects:
65
+ return ""
66
+ sorted_fx = sorted(effects.items(), key=lambda x: abs(x[1]), reverse=True)[:3]
67
+ parts = []
68
+ for axis, val in sorted_fx:
69
+ name = AXIS_NAMES_KR.get(axis, axis)
70
+ sign = "+" if val > 0 else ""
71
+ parts.append(f"{name} {sign}{val:.1f}")
72
+ return " · ".join(parts)
73
+
74
+
75
+ def _build_choice_history_html(state: dict) -> str:
76
+ """이벤트 로그에서 선택 히스토리 HTML 생성."""
77
+ cards = []
78
+ for log in state.get("event_logs", []):
79
+ scenario = log.get("scenario", "?")
80
+ char_name = log.get("character", "")
81
+ stage = log.get("stage", "?")
82
+ warmth_after = log.get("warmth_after", 0)
83
+
84
+ t1 = log.get("turn1", {}).get("selected", {})
85
+ t2 = log.get("turn2", {}).get("selected", {})
86
+
87
+ t1_text = t1.get("text", "")
88
+ t2_text = t2.get("text", "")
89
+ t1_fx = _format_effects(t1.get("trait_effect", {}))
90
+ t2_fx = _format_effects(t2.get("trait_effect", {}))
91
+
92
+ warmth_int = max(0, int(warmth_after))
93
+ warmth_bar = "❤️" * warmth_int + "🤍" * max(0, 10 - warmth_int)
94
+
95
+ lines = [f'<div class="bubble narration choice-log">']
96
+ lines.append(f'<div class="log-stage">Stage {stage} · {scenario} — {char_name}</div>')
97
+ if t1_text:
98
+ lines.append(f'<div class="log-choice">1턴: {t1_text}</div>')
99
+ if t1_fx:
100
+ lines.append(f'<div class="log-effects">{t1_fx}</div>')
101
+ if t2_text:
102
+ lines.append(f'<div class="log-choice">2턴: {t2_text}</div>')
103
+ if t2_fx:
104
+ lines.append(f'<div class="log-effects">{t2_fx}</div>')
105
+ lines.append(f'<div class="log-warmth">호감도 {warmth_bar}</div>')
106
+ lines.append('</div>')
107
+ cards.append("\n".join(lines))
108
+
109
+ return "\n".join(cards)
110
+
111
+
112
+ # ── 상태 관리 ──────────────────────────────────────────────
113
 
114
  def new_state():
115
  return {
116
  "phase": "idle",
117
+ "user_gender": "",
118
+ "partner_gender": "",
119
+ "characters": [],
120
+ "current_char_idx": -1,
121
+ "stage": 1,
122
+ "stage_event_count": 0,
123
  "trait_vector": empty_vector(),
124
  "choice_history": [],
125
  "event_logs": [],
 
127
  "current_reaction_data": {},
128
  "current_event_log": {},
129
  "chat_history": [],
130
+ "interruption_count": 0,
131
+ "alternating": False,
132
+ "alternate_char_indices": [],
133
  }
134
 
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  def get_client():
137
  api_key = os.getenv("OPENAI_API_KEY", "")
138
+ return OpenAI(api_key=api_key) if api_key else None
 
 
139
 
140
 
141
  def get_model():
142
  return os.getenv("OPENAI_MODEL", "gpt-4o-mini")
143
 
144
 
145
+ def _outputs(state, html, a="", b="", c="", restart_vis=False, start_vis=False):
146
+ return (
147
+ state, html,
148
+ gr.update(value=f"A. {a}", visible=bool(a)),
149
+ gr.update(value=f"B. {b}", visible=bool(b)),
150
+ gr.update(value=f"C. {c}", visible=bool(c)),
151
+ gr.update(visible=restart_vis),
152
+ gr.update(visible=start_vis),
153
+ gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), # gender panel + hidden textboxes
154
+ gr.update(visible=False), # gender confirm btn
155
+ gr.update(), gr.update(), gr.update(), gr.update(), # gender buttons (hidden by panel)
156
+ )
157
+
158
+
159
+ def _outputs_gender(state, html):
160
+ """성별 선택 화면 출력."""
161
+ return (
162
+ state, html,
163
+ gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
164
+ gr.update(visible=False), gr.update(visible=False),
165
+ gr.update(visible=True), # gender panel
166
+ gr.update(visible=False, value="여성"), # gnd_my default
167
+ gr.update(visible=False, value="남성"), # gnd_other default
168
+ gr.update(visible=True), # gender confirm btn
169
+ gr.update(value="남성"), gr.update(value="✓ 여성"), # my: 여성 selected
170
+ gr.update(value="✓ 남성"), gr.update(value="여성"), # other: 남성 selected
171
+ )
172
+
173
+
174
+ # ── 핸들러: 시작 → 성별 선택 ─────────────────────────────
175
 
176
  def on_start(state):
177
+ state = new_state()
178
+ state["phase"] = "gender_select"
179
+ state["chat_history"].append({"role": "header", "text": "💜 러브로그 시작"})
180
+ state["chat_history"].append({
181
+ "role": "narrator",
182
+ "text": "당신의 성별과 만나고 싶은 상대의 성별을 선택해주세요.",
183
+ })
184
+ html = _render_chat(state)
185
+ return _outputs_gender(state, html)
186
+
187
+
188
+ def on_gender_select(user_gender, partner_gender, state):
189
+ if not user_gender or not partner_gender:
190
+ return _outputs_gender(state, _render_chat(state))
191
+
192
  client = get_client()
193
  if not client:
194
+ state["chat_history"].append({"role": "narrator", "text": "❌ OPENAI_API_KEY를 .env에 설정해주세요."})
195
+ return _outputs(state, _render_chat(state), start_vis=True)
196
+
197
+ gender_map = {"남성": "M", "여성": "F", "남자": "M", "여자": "F"}
198
+ state["user_gender"] = gender_map.get(user_gender, "M")
199
+ state["partner_gender"] = gender_map.get(partner_gender, "F")
200
+
201
+ # 캐릭터 생성 (템플릿 기반, GPT 불필요)
202
+ state["characters"] = pick_characters(state["partner_gender"])
203
+
204
+ state["chat_history"].append({
205
+ "role": "user",
206
+ "text": f"내 성별: {user_gender} · 상대: {partner_gender}",
207
+ })
208
+
209
+ # 첫만남 장면 생성 (GPT 1 call)
210
+ state["chat_history"].append({"role": "header", "text": "Stage 1 · 첫만남"})
211
+ state["chat_history"].append({"role": "narrator", "text": "세 사람과의 첫만남..."})
212
+
213
+ model = get_model()
214
+ try:
215
+ meetings = generate_first_meetings(client, state["characters"], model=model)
216
+ except Exception as e:
217
+ state["chat_history"].append({"role": "narrator", "text": f"⚠️ 생성 오류: {e}"})
218
+ return _outputs(state, _render_chat(state), start_vis=True)
219
+
220
+ # 3명 카드 표시
221
+ for i, char in enumerate(state["characters"]):
222
+ meeting = meetings[i] if i < len(meetings) else {}
223
+ situation = meeting.get("situation", "")
224
+ dialogue = meeting.get("dialogue", "")
225
+ card_html = (
226
+ f"<div class='char-name'>{char['name']}</div>"
227
+ f"<div class='char-info'>{char['age']}세 · {char['job']}</div>"
228
+ f"<div class='char-meeting'>🏷️ {char['meeting_type']}</div>"
229
+ f"<div class='char-scene'>{situation}</div>"
230
+ f"<div class='char-dialogue'>&ldquo;{dialogue}&rdquo;</div>"
231
  )
232
+ state["chat_history"].append({"role": "char_card", "text": card_html})
233
+ # 미팅 데이터 저장
234
+ char["first_meeting"] = meeting
235
+
236
+ state["phase"] = "char_select"
237
+ html = _render_chat(state)
238
+
239
+ names = [c["name"] for c in state["characters"]]
240
+ return _outputs(state, html, names[0], names[1], names[2])
241
 
 
 
 
242
 
243
+ # ── 핸들러: 캐릭터 선택 ──────────────────────────────────
244
 
245
+ def on_choice(choice_idx, state):
246
+ client = get_client()
247
+ if not client:
248
+ return _outputs(state, _render_chat(state))
249
 
250
+ phase = state["phase"]
 
 
251
 
252
+ if phase == "char_select":
253
+ return _handle_char_select(choice_idx, state, client)
254
+ elif phase == "turn1":
255
+ return _handle_turn1(choice_idx, state, client)
256
+ elif phase == "turn2":
257
+ return _handle_turn2(choice_idx, state, client)
258
+ elif phase == "interruption":
259
+ return _handle_interruption(choice_idx, state, client)
260
+ elif phase == "ending_choice":
261
+ return _handle_ending_choice(choice_idx, state, client)
262
 
263
+ return _outputs(state, _render_chat(state))
 
 
 
264
 
265
+
266
+ def _handle_char_select(choice_idx, state, client):
267
+ if choice_idx >= len(state["characters"]):
268
+ choice_idx = 0
269
+
270
+ state["current_char_idx"] = choice_idx
271
+ char = state["characters"][choice_idx]
272
+
273
+ state["chat_history"].append({
274
+ "role": "user",
275
+ "text": f"{char['name']}을(를) 선택했습니다",
276
+ })
277
+
278
+ # Stage 1 첫 이벤트 시작 (친해지기)
279
+ state["stage"] = 1
280
+ state["stage_event_count"] = 0
281
+ return _next_event(state, client)
282
+
283
+
284
+ # ── 이벤트 생성 루프 ──────────────────────────────────────
285
+
286
+ def _next_event(state, client):
287
+ stage_num = state["stage"]
288
+
289
+ # Stage 6 → 결말 선택
290
+ if stage_num >= 6:
291
+ return _show_ending_choices(state, client)
292
+
293
+ # 조기 결말: stage 4 이상에서 모든 캐릭터 warmth ≤ 0이면 바로 결말
294
+ if stage_num >= 4 and all(
295
+ c["warmth"] <= WARMTH_THRESHOLDS["early_ending"] for c in state["characters"]
296
+ ):
297
+ state["chat_history"].append({
298
+ "role": "narrator",
299
+ "text": "어느 순간, 모든 만남이 흐릿해져 갔다. 설렘도, 기대도 사라진 자리엔 익숙한 일상만 남았다.",
300
+ })
301
+ return _show_ending_choices(state, client)
302
+
303
+ stage = STAGES[stage_num]
304
+ char = state["characters"][state["current_char_idx"]]
305
+ warmth = char["warmth"]
306
+ variant = get_warmth_variant(stage_num, warmth)
307
+
308
+ # 난입 체크: 지정 스테이지 & 횟수 제한 내
309
+ if (stage_num in INTERRUPT_STAGES
310
+ and state["interruption_count"] < MAX_INTERRUPTIONS
311
+ and should_interrupt(warmth)):
312
+ return _trigger_interruption(state, client)
313
+
314
+ # 스테이지 헤더
315
+ total_stages = 6
316
+ header = f"Stage {stage_num}/{total_stages} · {stage['name']} — {char['name']}"
317
+ state["chat_history"].append({"role": "header", "text": header})
318
 
319
  model = get_model()
320
  try:
321
  event_data = generate_event(
322
+ client, stage, char, warmth,
323
+ state["trait_vector"], event_logs=state["event_logs"],
324
+ variant=variant, model=model,
325
  )
326
  except Exception as e:
327
+ state["chat_history"].append({"role": "narrator", "text": f"⚠️ 생성 오류: {e}"})
328
+ state["stage"] += 1
329
+ return _next_event(state, client)
330
 
331
  state["current_event_data"] = event_data
332
  state["current_event_log"] = {
333
+ "stage": stage_num,
334
+ "scenario": stage["name"],
335
+ "desc": stage["desc"],
336
+ "character": char["name"],
337
+ "warmth": warmth,
338
+ "variant": variant,
339
  }
340
  state["phase"] = "turn1"
341
 
342
+ scene_tag = event_data.get("scene_tag", "")
343
+ if scene_tag:
344
+ state["chat_history"].append({"role": "scene", "text": get_scene_image(scene_tag)})
345
+
346
  situation = event_data.get("situation", "")
 
347
  state["current_event_log"]["situation"] = situation
 
 
 
348
  state["chat_history"].append({"role": "narrator", "text": situation})
349
 
350
+ choices = event_data.get("choices", [])
351
+ a = choices[0].get("text", "A") if len(choices) > 0 else "A"
352
+ b = choices[1].get("text", "B") if len(choices) > 1 else "B"
353
+ c = choices[2].get("text", "C") if len(choices) > 2 else "C"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
 
355
+ return _outputs(state, _render_chat(state), a, b, c)
356
 
 
 
 
 
 
 
357
 
358
+ # ── 턴 1, 2 핸들러 ───────────────────────────────────────
359
 
360
  def _handle_turn1(choice_idx, state, client):
361
  choices = state["current_event_data"].get("choices", [])
 
363
  choice_idx = 0
364
  selected = choices[choice_idx]
365
 
366
+ # trait 적용
367
+ apply_effects(state["trait_vector"], selected.get("trait_effect", {}))
368
+
369
+ # warmth 적용
370
+ char = state["characters"][state["current_char_idx"]]
371
+ char["warmth"] += selected.get("warmth_delta", 0)
372
+ char["warmth"] = max(-10, min(10, char["warmth"]))
373
 
 
374
  state["choice_history"].append(selected.get("text", ""))
375
  state["current_event_log"]["turn1"] = {"options": choices, "selected": selected}
376
 
377
+ state["chat_history"].append({"role": "user", "text": selected.get("text", "")})
 
 
 
 
378
 
379
+ # 2턴 생성
380
+ stage = STAGES[state["stage"]]
381
  situation = state["current_event_data"].get("situation", "")
382
  model = get_model()
383
  try:
384
  reaction_data = generate_reaction(
385
+ client, stage, char, situation, selected,
386
  event_logs=state["event_logs"], model=model,
387
  )
388
  except Exception as e:
389
+ state["chat_history"].append({"role": "narrator", "text": f"⚠️ {e}"})
390
  state["event_logs"].append(state["current_event_log"])
391
+ state["stage"] += 1
392
+ return _next_event(state, client)
393
 
394
  state["current_reaction_data"] = reaction_data
395
  state["phase"] = "turn2"
396
 
397
  reaction_text = reaction_data.get("reaction", "")
 
 
398
  state["current_event_log"]["reaction"] = reaction_text
399
+ state["chat_history"].append({"role": "narrator", "text": f"💬 {reaction_text}"})
400
 
401
+ choices_2 = reaction_data.get("choices", [])
402
+ a = choices_2[0].get("text", "A") if len(choices_2) > 0 else "A"
403
+ b = choices_2[1].get("text", "B") if len(choices_2) > 1 else "B"
404
+ c = choices_2[2].get("text", "C") if len(choices_2) > 2 else "C"
 
 
 
 
 
 
405
 
406
+ return _outputs(state, _render_chat(state), a, b, c)
 
 
 
 
 
 
407
 
408
 
409
  def _handle_turn2(choice_idx, state, client):
 
412
  choice_idx = 0
413
  selected = choices[choice_idx]
414
 
415
+ apply_effects(state["trait_vector"], selected.get("trait_effect", {}))
416
+
417
+ char = state["characters"][state["current_char_idx"]]
418
+ char["warmth"] += selected.get("warmth_delta", 0)
419
+ char["warmth"] = max(-10, min(10, char["warmth"]))
420
 
 
421
  state["choice_history"].append(selected.get("text", ""))
422
  state["current_event_log"]["turn2"] = {"options": choices, "selected": selected}
423
  state["current_event_log"]["trait_snapshot"] = dict(state["trait_vector"])
424
+ state["current_event_log"]["warmth_after"] = char["warmth"]
425
  state["event_logs"].append(state["current_event_log"])
426
 
427
+ state["chat_history"].append({"role": "user", "text": selected.get("text", "")})
428
+
429
+ char["event_count"] += 1
430
+ state["stage_event_count"] += 1
431
+
432
+ # 스테이지 1은 2개 이벤트 (친해지기), 나머지는 1개
433
+ events_for_stage = 2 if state["stage"] == 1 else 1
434
+ if state["stage_event_count"] >= events_for_stage:
435
+ state["stage"] += 1
436
+ state["stage_event_count"] = 0
437
+
438
+ # 교대 모드: 두 캐릭터 번갈아 진행, warmth 차이 크면 한쪽 집중
439
+ if state["alternating"]:
440
+ indices = state["alternate_char_indices"]
441
+ chars = state["characters"]
442
+ w0 = chars[indices[0]]["warmth"]
443
+ w1 = chars[indices[1]]["warmth"]
444
+
445
+ if abs(w0 - w1) >= 5:
446
+ focused = indices[0] if w0 >= w1 else indices[1]
447
+ other = indices[1] if focused == indices[0] else indices[0]
448
+ state["current_char_idx"] = focused
449
+ state["alternating"] = False
450
+ state["alternate_char_indices"] = []
451
+ state["chat_history"].append({
452
+ "role": "narrator",
453
+ "text": f"{chars[focused]['name']}과(와)의 관계가 깊어지면서, {chars[other]['name']}과(와)는 자연스럽게 멀어졌다.",
454
+ })
455
+ else:
456
+ cur = state["current_char_idx"]
457
+ state["current_char_idx"] = indices[1] if cur == indices[0] else indices[0]
458
+
459
+ return _next_event(state, client)
460
+
461
+
462
+ # ── 난입 핸들러 ──────────────────────────────────────────
463
+
464
+ def _trigger_interruption(state, client):
465
+ current_char = state["characters"][state["current_char_idx"]]
466
+ # 선택되지 않은 캐릭터 중 하나
467
+ others = [c for c in state["characters"] if c["id"] != current_char["id"] and c["active"]]
468
+ if not others:
469
+ return _next_event(state, client) # 난입할 사람 없음
470
+
471
+ import random
472
+ intruder = random.choice(others)
473
+
474
+ state["chat_history"].append({"role": "header", "text": f"📱 {intruder['name']}에게서 연락이 왔어요"})
475
+
476
+ model = get_model()
477
+ try:
478
+ interrupt_data = generate_interruption(
479
+ client, current_char, intruder, current_char["warmth"],
480
+ event_logs=state["event_logs"], model=model,
481
+ )
482
+ except Exception as e:
483
+ state["chat_history"].append({"role": "narrator", "text": f"⚠️ {e}"})
484
+ state["interruption_count"] += 1
485
+ return _next_event(state, client)
486
+
487
+ state["current_event_data"] = interrupt_data
488
+ state["phase"] = "interruption"
489
+ state["interruption_count"] += 1
490
+ state["_intruder_id"] = intruder["id"]
491
+
492
+ scene_tag = interrupt_data.get("scene_tag", "")
493
+ if scene_tag:
494
+ state["chat_history"].append({"role": "scene", "text": get_scene_image(scene_tag)})
495
+
496
+ situation = interrupt_data.get("situation", "")
497
+ state["chat_history"].append({"role": "narrator", "text": situation})
498
+
499
+ choices = interrupt_data.get("choices", [])
500
+ a = choices[0].get("text", "A") if len(choices) > 0 else "A"
501
+ b = choices[1].get("text", "B") if len(choices) > 1 else "B"
502
+ c = choices[2].get("text", "C") if len(choices) > 2 else "C"
503
+
504
+ return _outputs(state, _render_chat(state), a, b, c)
505
+
506
+
507
+ def _handle_interruption(choice_idx, state, client):
508
+ choices = state["current_event_data"].get("choices", [])
509
+ if choice_idx >= len(choices):
510
+ choice_idx = 0
511
+ selected = choices[choice_idx]
512
+
513
+ apply_effects(state["trait_vector"], selected.get("trait_effect", {}))
514
+
515
+ # warmth를 target_char에 적용
516
+ target_id = selected.get("target_char")
517
+ if target_id is not None:
518
+ for c in state["characters"]:
519
+ if c["id"] == target_id:
520
+ c["warmth"] += selected.get("warmth_delta", 0)
521
+ c["warmth"] = max(-10, min(10, c["warmth"]))
522
+ # 난입 캐릭터를 선택했으면 교대 모드 진입
523
+ if target_id == state.get("_intruder_id"):
524
+ intruder_idx = next(
525
+ i for i, ch in enumerate(state["characters"]) if ch["id"] == target_id
526
+ )
527
+ original_idx = state["current_char_idx"]
528
+ state["alternating"] = True
529
+ state["alternate_char_indices"] = [original_idx, intruder_idx]
530
+ state["current_char_idx"] = intruder_idx
531
+ original_name = state["characters"][original_idx]["name"]
532
+ state["chat_history"].append({
533
+ "role": "narrator",
534
+ "text": f"{c['name']}과(와)의 이야기가 시작됩니다. 하지만 {original_name}과(와)의 인연도 계속되고 있어요.",
535
+ })
536
+ break
537
+
538
+ state["choice_history"].append(selected.get("text", ""))
539
+ state["chat_history"].append({"role": "user", "text": selected.get("text", "")})
540
+
541
+ # 이벤트 로그 저장
542
+ state["event_logs"].append({
543
+ "stage": state["stage"],
544
+ "scenario": "난입",
545
+ "situation": state["current_event_data"].get("situation", ""),
546
+ "turn1": {"selected": selected},
547
+ "character": state["characters"][state["current_char_idx"]]["name"],
548
+ })
549
+
550
+ # 다음 스테이지로
551
+ return _next_event(state, client)
552
+
553
+
554
+ # ── 결말 ───────────────────────────────────────────────────
555
+
556
+ def _show_ending_choices(state, client):
557
+ """결말 선택지 표시: 고백 대상 선택 or 친구로 남기."""
558
+ state["chat_history"].append({"role": "header", "text": "Stage 6 · 결말"})
559
+
560
+ # warmth > 0인 캐릭터만 고백 가능, 높은 순 정렬, 최대 2명
561
+ confessable = [
562
+ (i, c) for i, c in enumerate(state["characters"]) if c["warmth"] > 0
563
+ ]
564
+ confessable.sort(key=lambda x: x[1]["warmth"], reverse=True)
565
+ confessable = confessable[:2]
566
+
567
+ if not confessable:
568
+ # 아무도 고백 불가 → 자동으로 멀어지는 결말
569
+ state["chat_history"].append({
570
+ "role": "narrator",
571
+ "text": "돌이켜보면, 누구에게도 진심으로 다가가지 못했던 것 같다.",
572
+ })
573
+ char = state["characters"][state["current_char_idx"]]
574
+ return _generate_ending_scene(state, client, char, "drifted")
575
+
576
+ # 고백 가능 캐릭터 요약
577
+ lines = []
578
+ for _, c in confessable:
579
+ warmth_bar = "❤️" * max(0, int(c["warmth"])) + "🤍" * max(0, 10 - int(c["warmth"]))
580
+ lines.append(f"{c['name']} — {warmth_bar}")
581
  state["chat_history"].append({
582
+ "role": "narrator",
583
+ "text": "이야기가 끝을 향하고 있습니다.\n" + "\n".join(lines),
584
  })
585
 
586
+ state["phase"] = "ending_choice"
587
+ state["_confessable"] = [(i, c["name"]) for i, c in confessable]
588
 
589
+ choices = [f"{c['name']}에게 고백한다" for _, c in confessable]
590
+ choices.append("모두와 친구로 남는다")
591
 
592
+ a = choices[0] if len(choices) > 0 else ""
593
+ b = choices[1] if len(choices) > 1 else ""
594
+ c = choices[2] if len(choices) > 2 else ""
595
+ return _outputs(state, _render_chat(state), a, b, c)
596
 
 
 
 
597
 
598
+ def _handle_ending_choice(choice_idx, state, client):
599
+ """결말 선택 처리."""
600
+ confessable = state.get("_confessable", [])
601
+ friends_idx = len(confessable) # 마지막 선택지 = 친구
602
 
603
+ if choice_idx >= friends_idx:
604
+ # 친구로 남는다
605
+ char = state["characters"][state["current_char_idx"]]
606
+ state["chat_history"].append({"role": "user", "text": "모두와 친구로 남는다"})
607
+ return _generate_ending_scene(state, client, char, "friends")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
608
 
609
+ # 고백 선택
610
+ char_idx, char_name = confessable[choice_idx]
611
+ char = state["characters"][char_idx]
612
+ state["current_char_idx"] = char_idx
613
+ state["chat_history"].append({"role": "user", "text": f"{char_name}에게 고백한다"})
614
+
615
+ outcome = get_confession_outcome(char["warmth"])
616
+ return _generate_ending_scene(state, client, char, outcome)
617
+
618
+
619
+ def _generate_ending_scene(state, client, char, ending_type):
620
+ """GPT로 결말 장면 생성 + 결과 화면."""
621
+ warmth = char["warmth"]
622
+
623
+ ending_labels = {
624
+ "mutual": "💕 서로 고백 — 사귄다",
625
+ "accepted": "💕 고백 수락 — 사귄다",
626
+ "soft_reject": "🤝 거절 — 친구로 남는다",
627
+ "rejected": "💔 고백 거절당함",
628
+ "friends": "🤝 모두와 친구로 남는다",
629
+ "drifted": "🍂 자연스럽게 멀어진다",
630
+ }
631
 
 
 
632
  model = get_model()
 
633
  try:
634
+ ending = generate_ending(
635
+ client, char, warmth, ending_type,
636
+ event_logs=state["event_logs"], model=model,
637
+ )
638
  except Exception:
639
+ ending = {"ending_scene": "이야기가 끝났습니다.", "ending_dialogue": "", "ending_type": ending_type}
640
+
641
+ state["chat_history"].append({
642
+ "role": "header",
643
+ "text": f"결말 — {ending_labels.get(ending_type, '결말')}",
644
+ })
645
+
646
+ scene_tag = ending.get("scene_tag", "")
647
+ if scene_tag:
648
+ state["chat_history"].append({"role": "scene", "text": get_scene_image(scene_tag)})
649
 
650
+ state["chat_history"].append({"role": "narrator", "text": ending.get("ending_scene", "")})
651
+ if ending.get("ending_dialogue"):
652
+ state["chat_history"].append({
653
+ "role": "narrator",
654
+ "text": f"💬 {char['name']}: \"{ending['ending_dialogue']}\"",
655
+ })
656
+
657
+ state["ending_type"] = ending_type
658
+ return _show_results(state, client)
659
+
660
+
661
+ def _show_results(state, client=None):
662
+ best_type, scores = classify_type(state["trait_vector"])
663
+ dt = DATING_TYPES.get(best_type, {})
664
+ ending_type = state.get("ending_type", "")
665
+
666
+ # ── 채팅 히스토리를 HTML로 렌더 ──
667
  chat_parts = []
668
  for msg in state["chat_history"]:
669
  role = msg["role"]
 
674
  chat_parts.append(f'<div class="bubble narration">{text}</div>')
675
  elif role == "user":
676
  chat_parts.append(f'<div class="bubble user-choice">{text}</div>')
677
+ elif role == "char_card":
678
+ chat_parts.append(f'<div class="bubble narration char-card">{text}</div>')
679
+ elif role == "scene":
680
+ chat_parts.append(f'<img class="scene-art" src="{msg["text"]}" alt="scene">')
681
+
682
+ # ── Section 1: 결과 발표 + 호감도 ──
683
+ chat_parts.append('<div class="chat-divider"><span>💜 결과 발표</span></div>')
684
+ for char in state["characters"]:
685
+ warmth_bar = "❤️" * max(0, int(char["warmth"])) + "🤍" * max(0, 10 - int(char["warmth"]))
686
+ chat_parts.append(
687
+ f'<div class="bubble narration">{char["name"]} — 호감도 {char["warmth"]:.1f} {warmth_bar}</div>'
688
+ )
689
+
690
+ # ── Section 2: 선택 히스토리 ──
691
+ chat_parts.append('<div class="chat-divider"><span>📖 당신의 선택</span></div>')
692
+ chat_parts.append(_build_choice_history_html(state))
693
 
694
+ # ── Section 3: 유형 카드 ──
695
  chat_parts.append(f'''<div class="bubble narration result-header">
696
  <div class="result-emoji">{dt.get("emoji", "💜")}</div>
697
  <div class="result-title">{dt.get("title", "")}</div>
698
  <div class="result-desc">{dt.get("desc", "")}</div>
699
  </div>''')
700
+
701
+ # ── Section 4: AI 성향 분석 ──
702
+ analysis = None
703
+ if client:
704
+ try:
705
+ model = get_model()
706
+ analysis = generate_analysis(
707
+ client, state["trait_vector"], best_type, dt.get("name", ""),
708
+ state["event_logs"], ending_type, model=model,
709
+ )
710
+ except Exception:
711
+ analysis = None
712
+
713
+ if analysis:
714
+ summary = analysis.get("summary", "")
715
+ strengths = analysis.get("strengths", [])
716
+ watch_points = analysis.get("watch_points", [])
717
+ tip = analysis.get("dating_tip", "")
718
+
719
+ analysis_html = '<div class="bubble narration analysis-card">'
720
+ analysis_html += '<div class="section-title">🔮 AI 성향 분석</div>'
721
+ if summary:
722
+ analysis_html += f'<div class="analysis-summary">{summary}</div>'
723
+ if strengths:
724
+ analysis_html += '<div class="analysis-list">💪 '
725
+ analysis_html += " / ".join(strengths)
726
+ analysis_html += '</div>'
727
+ if watch_points:
728
+ analysis_html += '<div class="analysis-list">⚠️ '
729
+ analysis_html += " / ".join(watch_points)
730
+ analysis_html += '</div>'
731
+ if tip:
732
+ analysis_html += f'<div class="analysis-tip">💡 {tip}</div>'
733
+ analysis_html += '</div>'
734
+ chat_parts.append(analysis_html)
735
+
736
+ # ── Section 5: 성향 서술 (GPT) ──
737
+ my_profile = vector_to_profile(state["trait_vector"], best_type)
738
+ personality_desc = ""
739
+ if client:
740
+ try:
741
+ personality_desc = generate_personality_description(
742
+ client, my_profile, dt,
743
+ event_logs=state["event_logs"], model=get_model(),
744
+ )
745
+ except Exception:
746
+ personality_desc = dt.get("desc", "")
747
+ else:
748
+ personality_desc = dt.get("desc", "")
749
  chat_parts.append(f'''<div class="bubble narration">
750
+ <div class="section-title">💜 당신의 연애 성향</div>
751
+ <div class="profile-content">{personality_desc}</div>
752
  </div>''')
753
 
754
+ # ── Section 6: 매칭 결과 + 이유 ──
755
+ chat_parts.append('<div class="chat-divider"><span>💞 나와 맞는 사람들</span></div>')
756
+ match_data = []
757
+ try:
758
+ matches = find_matches(state["trait_vector"], best_type, top_n=3)
759
+ for i, m in enumerate(matches, 1):
760
+ mdt = DATING_TYPES.get(m["type"], {})
761
+ p = m["profile"]
762
+ reason = generate_match_reason(state["trait_vector"], best_type, m)
763
+ chat_parts.append(f'''<div class="bubble narration match-card">
764
+ <div class="match-rank">#{i} 💞</div>
765
+ <div class="match-name">{p['name']} ({p['age']}세, {p['gender']})</div>
766
+ <div class="match-type">{mdt.get('emoji', '')} {mdt.get('name', '')} · {p.get('archetype', '')}</div>
767
+ <div class="match-score">매칭 적합도 <strong>{m['match_score']}%</strong></div>
768
+ <div class="match-reason">{reason}</div>
769
+ </div>''')
770
+ match_data.append({
771
+ "name": p["name"], "age": p["age"], "gender": p["gender"],
772
+ "type": m["type"], "match_score": m["match_score"],
773
+ "reason": reason,
774
+ })
775
+ except Exception:
776
+ chat_parts.append('<div class="bubble narration">매칭 데이터를 불러올 수 없습니다.</div>')
777
+
778
+ # ── HTML 조립 ──
779
  inner = "\n".join(chat_parts)
780
  game_html = f'''<div class="chat-container results" id="chatbox">
 
781
  {inner}
782
+ </div>
783
+ '''
 
784
 
785
  _save_log(state, best_type, scores, match_data, my_profile)
 
786
  state["phase"] = "results"
787
 
788
  return (
 
790
  gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
791
  gr.update(value="🔄 다시 플레이", visible=True),
792
  gr.update(visible=False),
793
+ gr.update(visible=False), gr.update(visible=False), gr.update(visible=False),
794
+ gr.update(visible=False), # gender confirm btn
795
+ gr.update(), gr.update(), gr.update(), gr.update(), # gender buttons
796
  )
797
 
798
 
 
802
  ts = datetime.now().strftime("%Y%m%d_%H%M%S")
803
  run_log = {
804
  "timestamp": ts,
805
+ "user_gender": state["user_gender"],
806
+ "partner_gender": state["partner_gender"],
807
+ "characters": [
808
+ {"name": c["name"], "warmth": c["warmth"], "event_count": c["event_count"]}
809
+ for c in state["characters"]
810
+ ],
811
  "profile": profile,
812
  "events": state["event_logs"],
813
  "final_vector": state["trait_vector"],
 
819
  json.dump(run_log, f, ensure_ascii=False, indent=2)
820
 
821
 
 
 
 
 
 
 
 
822
  # ── CSS ────────────────────────────────────────────────────
823
 
824
  CUSTOM_CSS = """
825
+ /* =========================
826
+ LoveLog Theme (Purple tone 유지)
827
+ - 유지보수용 토큰(변수) 기반
828
+ - !important 최소화
829
+ ========================= */
830
+
831
+ footer { display: none !important; }
832
+
833
+ /* ---- Theme tokens ---- */
834
+ :root {
835
+ /* Brand purple palette */
836
+ --bg-0: #f3eaff; /* top background */
837
+ --bg-1: #ede5f7; /* panel background */
838
+ --ink-0: #1a1a1a;
839
+ --ink-1: #2d1b4e; /* narration text */
840
+ --ink-2: #6b5b8a; /* secondary text */
841
+ --brand-0: #5b21b6; /* main purple */
842
+ --brand-1: #7c3aed; /* accent purple */
843
+ --brand-2: #a78bfa; /* soft border purple */
844
+ --brand-3: #d4bfff; /* soft stroke */
845
+ --card-0: rgba(255, 255, 255, 0.92);
846
+ --glass-0: rgba(246, 239, 255, 0.86);
847
+
848
+ /* Radii & spacing */
849
+ --r-lg: 22px;
850
+ --r-md: 16px;
851
+ --r-sm: 12px;
852
+ --pad: 16px;
853
+ --gap: 10px;
854
+
855
+ /* Shadows */
856
+ --sh-1: 0 1px 4px rgba(139, 92, 246, 0.12);
857
+ --sh-2: 0 4px 14px rgba(124, 58, 237, 0.14);
858
+ --sh-inset: inset 0 1px 0 rgba(255, 255, 255, 0.35);
859
+
860
+ /* Focus ring */
861
+ --focus: 0 0 0 3px rgba(124, 58, 237, 0.16);
862
+ }
863
 
864
+ /* ---- App container ---- */
865
  .gradio-container {
866
+ background: linear-gradient(180deg, var(--bg-0) 0%, var(--bg-1) 100%) !important;
867
+ max-width: 640px !important;
868
+ margin: 0 auto !important;
869
  }
870
 
871
+ /* ---- Title area ---- */
872
+ .title-area { text-align: center; padding: 10px 0 4px; }
873
+ .title-area h1 { color: var(--brand-0) !important; font-size: 22px !important; }
874
+ .title-area p { color: var(--ink-2); font-size: 13.5px; }
 
 
 
 
 
 
 
875
 
876
+ /* ---- Welcome ---- */
877
+ .welcome-container {
878
+ display: grid;
879
+ place-items: center;
880
+ gap: 14px;
881
+ padding: 56px 20px;
882
+ background: var(--bg-1);
883
+ border-radius: var(--r-lg);
884
+ height: 520px;
885
+ text-align: center;
886
+ box-shadow: var(--sh-1);
887
  }
888
+ .welcome-emoji { font-size: 56px; }
889
+ .welcome-title { font-size: 20px; font-weight: 800; color: var(--brand-0); }
890
+ .welcome-desc { font-size: 14px; color: var(--ink-2); line-height: 1.7; max-width: 320px; }
891
 
892
+ /* ---- Chat container ---- */
893
+ .chat-container {
894
+ display: flex;
895
+ flex-direction: column;
896
+ gap: var(--gap);
897
+ padding: 20px var(--pad);
898
+ background: var(--bg-1);
899
+ border-radius: var(--r-lg);
900
+ height: 520px;
901
+ overflow-y: auto;
902
+ scroll-behavior: smooth;
903
+ box-shadow: var(--sh-1);
904
  }
905
+ .chat-container.results { height: 600px; }
906
+
907
+ .chat-container::-webkit-scrollbar { width: 6px; }
908
  .chat-container::-webkit-scrollbar-thumb {
909
+ background: rgba(167, 139, 250, 0.65);
910
+ border-radius: 10px;
 
 
 
911
  }
912
 
913
+ /* ---- Divider ---- */
914
  .chat-divider {
915
+ display: flex;
916
+ align-items: center;
917
+ justify-content: center;
918
+ margin: 14px 0 4px;
919
  }
 
920
  .chat-divider span {
921
+ background: rgba(167, 139, 250, 0.35);
922
+ color: var(--brand-0);
923
+ font-size: 12px;
924
+ font-weight: 800;
925
+ padding: 6px 16px;
926
+ border-radius: 999px;
927
+ border: 1px solid rgba(124, 58, 237, 0.16);
928
  }
929
 
930
+ /* ---- Scene image ---- */
931
+ .scene-art {
932
+ display: block;
933
+ width: 100%;
934
+ border-radius: var(--r-sm);
935
+ margin: 4px 0;
936
+ align-self: stretch;
937
+ box-shadow: 0 2px 10px rgba(26, 10, 46, 0.28);
 
938
  }
939
 
940
+ /* ---- Bubbles (base) ---- */
941
+ .bubble {
942
+ max-width: 86%;
943
+ padding: 14px 16px;
944
+ border-radius: 18px;
945
+ font-size: 14.5px;
946
+ line-height: 1.7;
947
+ word-break: keep-all;
948
+ animation: fadeUp 0.22s ease;
949
+ }
950
  @keyframes fadeUp {
951
+ from { opacity: 0; transform: translateY(6px); }
952
+ to { opacity: 1; transform: translateY(0); }
953
  }
954
 
955
+ /* Narration */
956
  .bubble.narration {
957
+ background: linear-gradient(135deg, rgba(208, 192, 236, 0.95) 0%, rgba(196, 176, 228, 0.95) 100%);
958
+ color: var(--ink-1);
959
+ align-self: flex-start;
960
+ border-bottom-left-radius: 6px;
961
+ box-shadow: var(--sh-1);
962
+ border: 1px solid rgba(124, 58, 237, 0.10);
963
  }
964
 
965
+ /* User choice */
966
  .bubble.user-choice {
967
+ background: rgba(255, 255, 255, 0.96);
968
+ color: var(--ink-0);
969
+ align-self: flex-end;
970
+ border-bottom-right-radius: 6px;
971
+ box-shadow: 0 1px 8px rgba(0,0,0,0.08);
972
+ border: 1px solid rgba(124, 58, 237, 0.10);
973
+ font-weight: 600;
974
  }
975
 
976
+ /* Question (옵션: 기존 role="question" 쓰면 살아남) */
977
  .bubble.question {
978
+ background: rgba(255, 255, 255, 0.65);
979
+ color: var(--brand-0);
980
+ border: 1px dashed rgba(124, 58, 237, 0.28);
981
+ align-self: flex-start;
 
 
982
  }
983
 
984
+ /* ---- Character card ---- */
985
+ .bubble.char-card {
986
+ max-width: 92%;
987
+ border-left: 4px solid var(--brand-2);
988
+ padding: 16px 16px;
989
  }
990
+ .char-name { font-size: 16px; font-weight: 900; color: var(--brand-0); }
991
+ .char-info { font-size: 13px; color: var(--ink-2); margin-top: 2px; }
992
+ .char-meeting { font-size: 12px; color: var(--brand-1); margin-top: 6px; font-weight: 700; }
993
+ .char-scene { font-size: 13.5px; margin: 10px 0 6px; line-height: 1.65; }
994
+ .char-dialogue { font-size: 13.5px; font-style: italic; color: rgba(74, 54, 112, 0.95); }
995
+
996
+ /* ---- Choice buttons ---- */
997
+ .choice-column { gap: 10px !important; padding: 8px 0 !important; }
998
  .choice-column button {
999
+ width: 100%;
1000
+ background: rgba(255, 255, 255, 0.94) !important;
1001
+ border: 2px solid rgba(212, 191, 255, 0.95) !important;
1002
+ border-radius: 999px !important;
1003
+ color: var(--brand-0) !important;
1004
+ font-size: 14px !important;
1005
+ font-weight: 700 !important;
1006
+ padding: 13px 18px !important;
1007
+ text-align: left !important;
1008
+ transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease, border-color 0.16s ease !important;
1009
+ box-shadow: 0 1px 6px rgba(139, 92, 246, 0.10) !important;
1010
+ min-height: 48px !important;
1011
+ cursor: pointer !important;
1012
  }
 
1013
  .choice-column button:hover {
1014
+ background: linear-gradient(135deg, rgba(245, 240, 255, 0.98) 0%, rgba(237, 229, 255, 0.98) 100%) !important;
1015
+ border-color: rgba(167, 139, 250, 0.95) !important;
1016
+ transform: translateY(-2px) !important;
1017
+ box-shadow: var(--sh-2) !important;
1018
  }
1019
+ .choice-column button:focus-visible {
1020
+ outline: none !important;
1021
+ box-shadow: var(--sh-2), var(--focus) !important;
 
1022
  }
1023
 
1024
+ /* ---- Start/Restart (CTA) ---- */
1025
  .start-btn button {
1026
+ background: rgba(255, 255, 255, 0.94) !important;
1027
+ border: 2px solid rgba(212, 191, 255, 0.95) !important;
1028
+ border-radius: 999px !important;
1029
+ color: var(--brand-0) !important;
1030
+ font-size: 15px !important;
1031
+ font-weight: 900 !important;
1032
+ padding: 14px 22px !important;
1033
+ box-shadow: 0 1px 8px rgba(139, 92, 246, 0.12) !important;
1034
+ transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease, border-color 0.16s ease !important;
1035
  }
 
1036
  .start-btn button:hover {
1037
+ background: linear-gradient(135deg, rgba(245, 240, 255, 0.98) 0%, rgba(237, 229, 255, 0.98) 100%) !important;
1038
+ border-color: rgba(167, 139, 250, 0.95) !important;
1039
+ transform: translateY(-2px) !important;
1040
+ box-shadow: var(--sh-2) !important;
1041
  }
1042
+ .start-btn button:active { transform: translateY(0) scale(0.99) !important; }
1043
+ .start-btn button:focus-visible {
1044
+ outline: none !important;
1045
+ box-shadow: var(--sh-2), var(--focus) !important;
1046
  }
1047
 
1048
+ /* ---- Gender selection panel ---- */
1049
+ .gender-panel {
1050
+ gap: 10px !important;
1051
+ padding: 20px !important;
1052
+ border-radius: var(--r-lg) !important;
1053
+ background: linear-gradient(145deg, rgba(243, 236, 255, 0.92), rgba(233, 224, 250, 0.90)) !important;
1054
+ border: 1px solid rgba(124, 58, 237, 0.14) !important;
1055
+ box-shadow: 0 4px 14px rgba(91, 33, 182, 0.08) !important;
1056
  }
1057
+ .gender-label { margin: 4px 0 0 !important; }
1058
+ .gender-label p { font-size: 13px !important; font-weight: 700 !important; color: var(--brand-0) !important; margin: 0 !important; }
1059
+ .gender-btn-row { gap: 8px !important; }
1060
+ .gender-btn button {
1061
+ background: unset !important;
1062
+ border: 2px solid var(--brand-3) !important;
1063
+ border-radius: 999px !important;
1064
+ color: var(--brand-0) !important;
1065
+ font-size: 14px !important;
1066
+ font-weight: 700 !important;
1067
+ padding: 10px 0 !important;
1068
+ cursor: pointer !important;
1069
+ transition: all 0.16s ease !important;
1070
+ min-height: unset !important;
1071
  }
1072
+ .gender-btn button:hover {
1073
+ border-color: var(--brand-1) !important;
1074
+ background: rgba(124, 58, 237, 0.06) !important;
 
 
 
1075
  }
1076
 
1077
+ /* ---- Results ---- */
1078
+ .result-header { text-align: center; padding: 22px 18px !important; }
1079
+ .result-emoji { font-size: 48px; margin-bottom: 8px; }
1080
+ .result-title { font-size: 20px; font-weight: 900; color: var(--brand-0); margin-bottom: 6px; }
1081
+ .result-desc { font-size: 13.5px; color: var(--ink-2); line-height: 1.55; }
1082
 
1083
+ .section-title { font-size: 14px; font-weight: 900; color: var(--brand-1); margin-bottom: 8px; }
 
 
 
 
 
1084
 
1085
+ .profile-content { font-size: 13px; line-height: 1.65; }
 
 
 
1086
 
1087
  .profile-json {
1088
+ background: rgba(248, 245, 255, 0.98);
1089
+ border-radius: var(--r-sm);
1090
+ padding: 12px;
1091
+ font-size: 11.5px;
1092
+ overflow-x: auto;
1093
+ color: rgba(59, 31, 110, 0.98);
1094
+ border: 1px solid rgba(226, 213, 245, 0.95);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1095
  }
1096
 
1097
+ /* Match card */
1098
+ .match-card { border-left: 4px solid var(--brand-2) !important; }
1099
+ .match-rank { font-size: 16px; font-weight: 900; }
1100
+ .match-name { font-size: 15px; font-weight: 800; color: var(--brand-0); margin: 6px 0 2px; }
1101
+ .match-type { font-size: 12.5px; color: rgba(124, 107, 158, 0.98); margin-bottom: 4px; }
1102
+ .match-score { font-size: 12.5px; color: rgba(74, 54, 112, 0.98); }
1103
+ .match-reason { font-size: 12px; color: var(--ink-2); margin-top: 6px; font-style: italic; line-height: 1.5; }
1104
+
1105
+ /* Choice history log */
1106
+ .choice-log { border-left: 3px solid var(--brand-2) !important; padding: 12px 14px !important; }
1107
+ .log-stage { font-size: 12px; font-weight: 800; color: var(--brand-1); margin-bottom: 6px; }
1108
+ .log-choice { font-size: 13px; margin: 3px 0; }
1109
+ .log-effects { font-size: 11.5px; color: var(--ink-2); margin-bottom: 4px; }
1110
+ .log-warmth { font-size: 11.5px; color: var(--ink-2); margin-top: 6px; }
1111
+
1112
+ /* Analysis card */
1113
+ .analysis-card { background: linear-gradient(135deg, rgba(220, 210, 245, 0.95), rgba(200, 185, 230, 0.95)) !important; }
1114
+ .analysis-summary { font-size: 14px; line-height: 1.7; margin-bottom: 10px; }
1115
+ .analysis-list { font-size: 13px; line-height: 1.6; margin: 4px 0; }
1116
+ .analysis-tip { font-size: 13px; font-style: italic; color: var(--brand-0); margin-top: 10px; padding: 8px 12px; background: rgba(255,255,255,0.5); border-radius: var(--r-sm); }
1117
 
 
 
 
 
 
 
1118
  """
1119
 
1120
+ theme = gr.themes.Soft(primary_hue="purple").set(
1121
+ input_background_fill="rgba(255,255,255,0.30)",
1122
+ input_background_fill_hover="rgba(255,255,255,0.38)",
1123
+ input_background_fill_focus="rgba(255,255,255,0.45)",
1124
+ input_border_color="rgba(124,58,237,0.30)",
1125
+ input_border_color_focus="rgba(124,58,237,0.60)",
1126
+ )
1127
+
1128
  WELCOME_HTML = """<div class="welcome-container">
1129
  <div class="welcome-emoji">💜</div>
1130
+ <div class="welcome-title">러브로그에 오신 것환영합니다</div>
1131
  <div class="welcome-desc">
1132
  가상의 연애 상황을 체험하며<br>
1133
  당신의 성향을 발견하세요.<br><br>
1134
+ 사람 중 한 명을 선택하고,<br>
1135
+ 관계의 결말까지 경험해보세요.
1136
  </div>
1137
  </div>"""
1138
 
 
1141
 
1142
  with gr.Blocks(
1143
  title="💜 러브로그",
1144
+ theme=theme,
1145
  css=CUSTOM_CSS,
1146
  ) as app:
1147
 
 
1151
  )
1152
 
1153
  state = gr.State(new_state())
 
1154
  game_display = gr.HTML(WELCOME_HTML)
1155
 
1156
+ # 성별 선택 패널 (버튼형)
1157
+ with gr.Column(visible=False, elem_classes=["gender-panel"]) as gender_row:
1158
+ gr.Markdown("당신의 성별", elem_classes=["gender-label"])
1159
+ with gr.Row(elem_classes=["gender-btn-row"]):
1160
+ my_male_btn = gr.Button("남성", elem_classes=["gender-btn"], variant="secondary")
1161
+ my_female_btn = gr.Button("여성", elem_classes=["gender-btn"], variant="secondary")
1162
+ gr.Markdown("상대의 성별", elem_classes=["gender-label"])
1163
+ with gr.Row(elem_classes=["gender-btn-row"]):
1164
+ other_male_btn = gr.Button("남성", elem_classes=["gender-btn"], variant="secondary")
1165
+ other_female_btn = gr.Button("여성", elem_classes=["gender-btn"], variant="secondary")
1166
+ gnd_my = gr.Textbox(visible=False)
1167
+ gnd_other = gr.Textbox(visible=False)
1168
+
1169
+ gender_confirm_btn = gr.Button(
1170
+ "💜 이어서하기", visible=False, variant="huggingface", size="lg",
1171
+ elem_classes=["start-btn"],
1172
+ )
1173
+
1174
  with gr.Column(elem_classes=["choice-column"]):
1175
  btn_a = gr.Button("A", visible=False, variant="secondary", scale=1)
1176
  btn_b = gr.Button("B", visible=False, variant="secondary", scale=1)
1177
  btn_c = gr.Button("C", visible=False, variant="secondary", scale=1)
1178
 
1179
  start_btn = gr.Button(
1180
+ "💜 시작하기", variant="huggingface", size="lg",
1181
  elem_classes=["start-btn"],
1182
  )
1183
  restart_btn = gr.Button(
 
1185
  elem_classes=["start-btn"],
1186
  )
1187
 
1188
+ outputs = [state, game_display, btn_a, btn_b, btn_c, restart_btn, start_btn, gender_row, gnd_my, gnd_other, gender_confirm_btn, my_male_btn, my_female_btn, other_male_btn, other_female_btn]
1189
+
1190
+ # 자동 스크롤 JS — .then()으로 Python 핸들러 완료 후 실행
1191
+ SCROLL_JS = """
1192
+ () => {
1193
+ function sb() {
1194
+ const el = document.getElementById('chatbox');
1195
+ if (el) el.scrollTop = el.scrollHeight;
1196
+ }
1197
+ setTimeout(sb, 80);
1198
+ setTimeout(sb, 300);
1199
+ setTimeout(sb, 800);
1200
+ }
1201
+ """
1202
+
1203
+ # 시작 → 성별 선택 화면
1204
+ start_btn.click(fn=on_start, inputs=[state], outputs=outputs).then(fn=None, js=SCROLL_JS)
1205
+ restart_btn.click(fn=on_start, inputs=[state], outputs=outputs).then(fn=None, js=SCROLL_JS)
1206
+
1207
+ # 성별 버튼 클릭
1208
+ my_male_btn.click(
1209
+ fn=lambda: ("남성", gr.update(value="✓ 남성"), gr.update(value="여성")),
1210
+ outputs=[gnd_my, my_male_btn, my_female_btn],
1211
+ )
1212
+ my_female_btn.click(
1213
+ fn=lambda: ("여성", gr.update(value="남성"), gr.update(value="✓ 여성")),
1214
+ outputs=[gnd_my, my_male_btn, my_female_btn],
1215
+ )
1216
+ other_male_btn.click(
1217
+ fn=lambda: ("남성", gr.update(value="✓ 남성"), gr.update(value="여��")),
1218
+ outputs=[gnd_other, other_male_btn, other_female_btn],
1219
+ )
1220
+ other_female_btn.click(
1221
+ fn=lambda: ("여성", gr.update(value="남성"), gr.update(value="✓ 여성")),
1222
+ outputs=[gnd_other, other_male_btn, other_female_btn],
1223
+ )
1224
+
1225
+ # 성별 선택 완료 → 이어서하기 버튼 클릭 시 게임 시작
1226
+ gender_confirm_btn.click(
1227
+ fn=on_gender_select,
1228
+ inputs=[gnd_my, gnd_other, state],
1229
+ outputs=outputs,
1230
+ ).then(fn=None, js=SCROLL_JS)
1231
+
1232
+ # 선택 버튼
1233
+ btn_a.click(fn=lambda s: on_choice(0, s), inputs=[state], outputs=outputs).then(fn=None, js=SCROLL_JS)
1234
+ btn_b.click(fn=lambda s: on_choice(1, s), inputs=[state], outputs=outputs).then(fn=None, js=SCROLL_JS)
1235
+ btn_c.click(fn=lambda s: on_choice(2, s), inputs=[state], outputs=outputs).then(fn=None, js=SCROLL_JS)
1236
 
1237
+ def launch(share: bool = False):
1238
+ app.launch(share=share, debug=True)
1239
 
 
 
 
1240
 
1241
+ if __name__ == "__main__":
1242
+ launch(share=False)
gpt_engine.py CHANGED
@@ -1,224 +1,514 @@
1
- """GPT API 엔진 — 시나리오 이벤트 생성"""
2
 
3
  import json
 
4
  from openai import OpenAI
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  SYSTEM_PROMPT = """\
7
- 너는 '러브로그'라는 로그라이크 연애 시뮬레이션의 시나 작가야.
8
- 유저가상의 연애 상황을 체험하며 자신의 성향을 드러내게 다.
9
-
10
- ■ 서사 규칙:
11
- - 상황 묘사는 게임 시나리오처럼 장면을 보여준다.
12
- - 상대방은 이름 없이 "그 사람", "상대" 등으로 지칭한다.
13
- - 대화체자연스러운국어.
14
- - 모든 응답은 지정된 JSON 형식으로출력한.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  ■ 연결된 서사:
17
- - 이전 이벤트의 상황과 유저의 선택이 주어지면, 새로운 상황은 반드시 그 맥락 위에서 자연스럽게 이어져야 한다.
18
- - 마치 드라마의 다음 회차처럼, 이전 선택의 결과가 새로운 상황의 배경이 되어야 한다.
19
- - 같은 "그 사람"이 등장하되, 관계가 이전 선택에 따라 변화한다.
20
-
21
- ■ 선택지 설계 (매우 중요):
22
- - 선택지는 반드시 3개. 정답은 없다.
23
- - 3개의 선택지는 반드시 방향이 완전히 달라야 한다:
24
- A = 적극적/다가가는 방향 (예: 직접 말을 건다, 솔직하게 표현한다)
25
- B = 회피적/거리를 두는 방향 (예: 모른 척한다, 자리를 피한다, 냉담하게 반응한다)
26
- C = 관망/전략적 방향 (예: 상황을 지켜본다, 다른 방법을 찾는다)
27
- - 절대로 3개가 모두 긍정적이면 안 된다. 반드시 부정적/회피적 선택지를 포함해야 한다.
28
- - 유저가 어떤 선택을 해도 이야기가 이어지되, 선택에 따라 관계의 온도가 확연히 달라져야 한다.
29
-
30
- ■ 대사 규칙 (매우 중요):
31
- - 선택지의 text는 반���시 유저가 직접 말하는 **대사**만 작성한다. 행동 묘사 절대 금지.
32
- ✅ "괜찮아, 오늘 재밌었어." / "저기… 혹시 같이 걸을래요?"
33
- ❌ 상대에게 다가가 어깨를 두드린다 / 살짝 고개를 들어 미소를 짓는다
34
- - 대사는 자연스러운 구어체로. 큰따옴표 없이 문장만 작성.
35
 
36
  ■ 볼드 규칙:
37
- - situation, reaction 묘사 핵심 감정·행동·분위기 단어는 **볼드**로 강조한다.
38
- 예: "그 사람이 **살짝 미소** 지으며 고개를 **끄덕**인다."
 
 
 
 
39
 
40
- 1턴 vs 2턴 구분:
41
- - 1턴 선택지 = "행동의 방향" (무엇을 할 것인가? 다가갈 것인가, 피할 것인가, 지켜볼 것인가)
42
- - 2턴 선택지 = "표현의 방식" (어떤 톤으로 말할 것인가? 진지하게, 장난스럽게, 차갑게)
43
- - 2턴의 상황 전개(reaction)는 1턴 선택에 따라 완전히 달라져야 한다.
44
  """
45
 
46
 
 
 
 
 
 
 
 
 
 
47
  def _build_story_context(event_logs: list[dict]) -> str:
48
- """이전 이벤트 요약 — 꼬리에 꼬리를 무는 서사를 위한 컨텍스트"""
49
  if not event_logs:
50
  return "없음 (첫 번째 이벤트)"
51
-
52
  lines = []
53
- for log in event_logs[-3:]: # 최근 3개까지
54
- scenario_name = log.get("scenario", "?")
55
  situation = log.get("situation", "")
56
  reaction = log.get("reaction", "")
57
- turn1 = log.get("turn1", {}).get("selected", {}).get("text", "")
58
- turn2 = log.get("turn2", {}).get("selected", {}).get("text", "")
59
  lines.append(
60
- f"[{scenario_name}] 상황: {situation[:80]}... "
61
- f"→ 유저 선택1: \"{turn1}\" → 전개: {reaction[:60]}... "
62
- f"→ 유저 선택2: \"{turn2}\""
63
  )
64
  return "\n".join(lines)
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  def generate_event(
68
  client: OpenAI,
69
- scenario: dict,
 
 
70
  current_traits: dict,
71
- prev_choices: list[str],
72
  event_logs: list[dict] = None,
 
73
  model: str = "gpt-4o-mini",
74
  ) -> dict:
75
- """이벤트 1턴(감정태그 + 상황 + 선택지) 생성"""
76
-
77
- axes_str = ", ".join(scenario["axes"])
78
  story_context = _build_story_context(event_logs or [])
 
79
 
80
- user_prompt = f"""\
81
- 상황 카테고리: {scenario['name']} — {scenario['desc']}
82
- 측정 축: {axes_str}
83
- 유저 현재 성향: {json.dumps(current_traits, ensure_ascii=False)}
 
 
84
 
85
- ═══ 이전 이벤트 흐름 ( 맥락 위에서 이어지는 상황을 만들어야 함) ═══
 
 
 
 
 
 
 
 
 
86
  {story_context}
87
- ═══════════════════════════════════════════════════════════
 
88
 
89
- 이전벤트의 결과를 자연스럽게 이받아, 새로운 상황을 만들어줘.
90
- 이전에 유저가 한 선택결과가 이번 상황의 배경이 되어야 한다.
91
- 마치 드라마의 다음 회차처럼, 관계와 감정이 이전에서 이어진다.
92
 
93
- 아래 JSON 형식으로 이벤트를 생성해줘:
94
  {{
95
- "emotion_tag": {{
96
- "question": "황에서 당신의 감정?",
97
- "options": [
98
- {{"id": "A", "emoji": "...", "label": "긍정적 감정", "trait_effect": {{"축1": 변화량, "축2": 변화량, "축3": 변화량}} }},
99
- {{"id": "B", "emoji": "...", "label": "부정적 감정", "trait_effect": {{"축1": 변화량, "축2": 변화량, "축3": 변화량}} }},
100
- {{"id": "C", "emoji": "...", "label": "중립적 감정", "trait_effect": {{"축1": 변화량, "축2": 변화량, "축3": 변화량}} }}
101
- ]
102
- }},
103
- "situation": "게임 시나리오처럼 장면을 보여주는 상황 묘사 (3~5문장). 상대방은 '그 사람' 등으로 지칭. 핵심 단어는 **볼드**.",
104
  "choices": [
105
- {{"id": "A", "direction": "approach", "text": "적극적으로 다가가는 유저 대사 (직접 말하문장만)", "trait_effect": {{...4~6개 축...}} }},
106
- {{"id": "B", "direction": "avoid", "text": "회피하거나 거리를 두는 유저 대사 (직접 말하는 문장만)", "trait_effect": {{...4~6개 축...}} }},
107
- {{"id": "C", "direction": "observe", "text": "관망하거나 전략적인 유저 대사 (직접 말하는 문장만)", "trait_effect": {{...4~6개 축...}} }}
108
  ]
109
  }}
110
 
111
- 1턴 선택지 = 행동의 방향 (text반드시 유저가 직접 는 대사):
112
- - A(approach): 적극적, 직진, 솔직한 대사 pace/extraversion/affection 양수
113
- - B(avoid): 회피, 냉담, 거부하대사 → pace/affection 등 음수, jealousy/neuroticism 양수 가능
114
- - C(observe): 관망, 탐색, 전략적인 대사 → planning/conscientiousness/leadership 양수
115
- - 3개 선택지의 trait_effect 방향이 확연히 달라야 한다 (양수 vs 음수)
116
-
117
- ★ trait_effect 규칙:
118
- - 감정 태그: 2~3개 축, 선택지: 4~6개 축 (행동 축 + Big Five 혼합)
119
- - 변화량은 -1.0 ~ 1.0. 회피/부정 선택은 음수 값을 적극 활용
120
- - 15축: cooperation, leadership, emotional_depth, pace, humor, risk, contact_frequency, affection, jealousy, planning, openness, conscientiousness, extraversion, agreeableness, neuroticism
121
  """
122
-
123
- resp = client.chat.completions.create(
124
- model=model,
125
  messages=[
126
  {"role": "system", "content": SYSTEM_PROMPT},
127
  {"role": "user", "content": user_prompt},
128
  ],
129
- response_format={"type": "json_object"},
130
- temperature=0.9,
131
  )
132
 
133
- return json.loads(resp.choices[0].message.content)
134
-
135
 
136
- def generate_trait_summary(
137
  client: OpenAI,
138
- profile: dict,
139
- type_name: str,
140
- model: str = "gpt-4o-mini",
141
- ) -> str:
142
- """15축 성향 벡터를 자연어로 간단히 설명"""
 
 
 
 
 
 
143
 
144
- prompt = f"""\
145
- 유저의 연애 성향 분석 결과야. 아래 데이 바탕으로 2~3문장으로 자연스럽게 요약해줘.
146
- - 숫자나 축 이름을 직접 노출하지 마.
147
- - 유저가 어떤 연애 스타일인, 어떤 점이 매력적인따뜻하게 설명해.
148
- - 반말 금지, 존댓말 사용.
 
149
 
150
- 유형: {type_name}
151
- 행동 성향: {profile.get('traits', {{}})}
152
- 성격: {profile.get('big_five', {{}})}
153
- """
154
 
155
- resp = client.chat.completions.create(
156
- model=model,
157
- messages=[{"role": "user", "content": prompt}],
158
- temperature=0.7,
159
- max_tokens=200,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  )
161
- return resp.choices[0].message.content.strip()
162
 
163
 
164
- def generate_reaction(
 
 
165
  client: OpenAI,
166
- scenario: dict,
167
- situation: str,
168
- user_choice: dict,
169
  event_logs: list[dict] = None,
170
  model: str = "gpt-4o-mini",
171
  ) -> dict:
172
- """상황 전개 + 2턴 선택지 생성"""
173
-
174
- axes_str = ", ".join(scenario["axes"])
175
  story_context = _build_story_context(event_logs or [])
176
 
177
  user_prompt = f"""\
178
- ═══ 지금까지의 이야기 흐름 ═══
 
 
 
 
 
 
179
  {story_context}
180
- ═══════════════════════════════
181
 
182
- 현재 상황: {situation}
183
- 유저의 1턴 선택: {user_choice['text']}
184
- 측정 축: {axes_str}
185
-
186
- 유저가 1턴에서 "{user_choice['text']}" 를 선택했다.
187
- 이 선택의 결과로 상황이 어떻게 전개되는지 묘사하고, 2턴 선택지를 생성해줘.
188
 
189
- 중요: 2턴은 "표현의 방식"을 고르단계다.
190
- - 1턴에서 행동의 방향을 정했으니, 2턴에서는 그 행동을 "어떤 톤���로" 할지를 결정한다.
191
- - reaction(상황 전개)은 1턴 선택에 따라 완전히 달라져야 한다.
192
- (적극적 선택 → 가까워진 상황 / 회피 선택 → 멀어진 상황 / 관망 → 탐색 중인 상황)
193
 
194
  아래 JSON 형식으로:
195
  {{
196
- "reaction": "1턴 선택의 직접적 결과로 벌어진 상황 (2~3문). 선택에 따라 관계 온도가 확연히 달라야 함. 핵심 단어는 **볼드**.",
 
197
  "choices": [
198
- {{"id": "A", "tone": "sincere", "text": "진지하고 솔직한 톤의 유저 대사 (직접 말하는 문장만)", "trait_effect": {{...4~6개 축...}} }},
199
- {{"id": "B", "tone": "playful", "text": "장난스럽거나 가볍게 넘기는 톤의 유저 대사 (직접 말하는 문장만)", "trait_effect": {{...4~6개 축...}} }},
200
- {{"id": "C", "tone": "cold", "text": "차갑거나 무심한 톤의 유저 대사 (직접 말하는 문장만)", "trait_effect": {{...4~6개 축...}} }}
201
  ]
202
  }}
203
 
204
- 2턴 선택지 = 표현의 방식 (text반드시 유저가 직접 는 대사):
205
- - A(sincere): 진지, 솔직, 따뜻한 emotional_depth/affection/agreeableness 양수
206
- - B(playful): 유머, 장난, 가볍게 넘기는 대사 humor 양수, emotional_depth 음수 가능
207
- - C(cold): 냉담, 무관심, 벽을 세우는 대사 → affection/cooperation 음수, leadership/jealousy 양수 가능
208
- - 3개의 톤이 확연히 달라야 한다
209
-
210
  ★ trait_effect: 4~6개 축, -1.0 ~ 1.0
211
- 15축: cooperation, leadership, emotional_depth, pace, humor, risk, contact_frequency, affection, jealousy, planning, openness, conscientiousness, extraversion, agreeableness, neuroticism
212
  """
 
 
 
 
 
 
 
213
 
214
- resp = client.chat.completions.create(
215
- model=model,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  messages=[
217
  {"role": "system", "content": SYSTEM_PROMPT},
218
  {"role": "user", "content": user_prompt},
219
  ],
220
- response_format={"type": "json_object"},
221
- temperature=0.9,
222
  )
223
 
224
- return json.loads(resp.choices[0].message.content)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GPT API 엔진 — 스테이지 기반 이벤트 생성"""
2
 
3
  import json
4
+ import time
5
  from openai import OpenAI
6
+ from scene_art import SCENE_TAGS_STR
7
+
8
+ MAX_RETRIES = 2
9
+ RETRY_DELAY = 1.0 # seconds
10
+
11
+
12
+ def _chat_with_retry(client: OpenAI, *, model: str, messages: list,
13
+ temperature: float = 0.9, retries: int = MAX_RETRIES) -> dict:
14
+ """GPT chat completion with retry on failure."""
15
+ last_err = None
16
+ for attempt in range(1 + retries):
17
+ try:
18
+ resp = client.chat.completions.create(
19
+ model=model, messages=messages,
20
+ response_format={"type": "json_object"},
21
+ temperature=temperature,
22
+ )
23
+ return json.loads(resp.choices[0].message.content)
24
+ except Exception as e:
25
+ last_err = e
26
+ if attempt < retries:
27
+ time.sleep(RETRY_DELAY)
28
+ raise last_err
29
 
30
  SYSTEM_PROMPT = """\
31
+ 너는 20-30대 커플의 연애 과정을 관찰하는 다큐멘터감독이다.
32
+ 카메라그냥 거기 있고, 사람은 카메라를 의식하지 않는다.
33
+ 유저는 이 상황 속 한 사람이 되어 자신의 성향을 자연스럽게 드러낸다.
34
+
35
+ 관찰 규칙:
36
+ - 감정을 설명하지 마라. 행동만 보여줘라.
37
+ "설레마음을 감추며" / "묘 긴장감이 흐른다"
38
+ "괜히 메뉴판을 넘긴다" / "핸드폰을 지작거리다가 올려본다"
39
+ - 구체적인 디테일을 써라.
40
+ ❌ "카페에서 대화를 나눈다"
41
+ ✅ "을지로 골목 2층, 창가 자리. 아이스 아메리카노 얼음이 거의 녹았다."
42
+ - 어색한 순간, 침묵, 타이밍을 놓치는 순간을 자연스럽게 넣어라.
43
+ - 드라마틱한 이벤트(사고, 우연의 일치, 갑작스러운 고백)는 최소화.
44
+
45
+ ■ 캐릭터:
46
+ - 상대방의 이름, 직업, 성격, 말투 정보가 주어진다.
47
+ - 상대 대사는 반드시 그 캐릭터의 speech_style에 맞게 작성하라.
48
+ - 상대를 이름으로 지칭한다.
49
+ - 상대의 행동/대사도 situation과 reaction에 포함시켜라.
50
+
51
+ ■ 대사 규칙:
52
+ - 실제 20-30대가 대면이나 카톡에서 쓰는 말투 그대로.
53
+ ❌ "의외로 너 이런 분위기도 잘 어울린다"
54
+ ✅ "아 근데 여기 생각보다 괜찮다 진짜"
55
+ - 말이 끊기거나, 얼버무리거나, 웃음으로 때우는 것도 자연스러움.
56
+ - 큰따옴표 없이 문장만 작성.
57
+
58
+ ■ 선택지:
59
+ - text는 유저가 직접 말하는 대사 + 짧은 행동 묘사 가능.
60
+ ✅ "아 진짜? 하며 웃는다"
61
+ ✅ "핸드폰을 꺼내 연락처를 건넨다"
62
+ ✅ "... 괜찮아, 별거 아니야"
63
+ - 3개. 정답 없음.
64
+ - "이 상황에서 실제로 사람들이 하는 서로 다른 반응"이어야 한다.
65
+ - 적극/회피/관망 같은 고정 프레임 금지.
66
+
67
+ ■ 달달함과 갈등:
68
+ - 달달함 = 사소한 순간. 같이 편의점에서 뭐 먹을지 고르기, 걷다가 손이 스치기.
69
+ - 갈등 = 현실적 마찰. 답장 늦기, 약속 까먹기, 사소한 말투 차이.
70
+
71
+ ■ 난입 규칙:
72
+ - 난입 이벤트가 요청되면: 현재 상대와 관계가 애매해진 상황에서, 다른 캐릭터가 자연스럽게 등장한다.
73
+ - 난입 방식: 카톡이 온다, 우연히 마주친다, 같이 밥 먹자고 연락 온다 등.
74
+ - 유저가 마음이 흔들릴 수 있는 상황을 만들어라.
75
 
76
  ■ 연결된 서사:
77
+ - 이전 이벤트의 상황과 유저의 선택이 주어지면, 새로운 상황은 그 맥락 위에서 자연스럽게 이어져야 한다.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  ■ 볼드 규칙:
80
+ - 보이는 행동이나 구체적 사물만 **볼드**로 강조.
81
+
82
+ ■ 1턴 vs 2턴:
83
+ - 1턴 = 이 상황에서 뭘 할 것인가
84
+ - 2턴 = 상대 반응을 본 뒤, 어떻게 이어갈 것인가
85
+ - 2턴의 상황 전개는 1턴 선택에 따라 완전히 달라져야 한다.
86
 
87
+ 모든 응답은 지정된 JSON 형식으로만 출력한다.
 
 
 
88
  """
89
 
90
 
91
+ def _char_context(character: dict) -> str:
92
+ """캐릭터 정보를 프롬프트용 문자열로."""
93
+ return (
94
+ f"이름: {character['name']} ({character['age']}세, {character['job']})\n"
95
+ f"성격: {character['personality']}\n"
96
+ f"말투: {character['speech_style']}"
97
+ )
98
+
99
+
100
  def _build_story_context(event_logs: list[dict]) -> str:
101
+ """이전 이벤트 요약."""
102
  if not event_logs:
103
  return "없음 (첫 번째 이벤트)"
 
104
  lines = []
105
+ for log in event_logs[-3:]:
106
+ name = log.get("scenario", "?")
107
  situation = log.get("situation", "")
108
  reaction = log.get("reaction", "")
109
+ t1 = log.get("turn1", {}).get("selected", {}).get("text", "")
110
+ t2 = log.get("turn2", {}).get("selected", {}).get("text", "")
111
  lines.append(
112
+ f"[{name}] {situation[:80]}... → 유저: \"{t1}\" → {reaction[:60]}... → 유저: \"{t2}\""
 
 
113
  )
114
  return "\n".join(lines)
115
 
116
 
117
+ # ── 첫만남 장면 배치 생성 (1 GPT call) ──────────────────────
118
+
119
+ def generate_first_meetings(
120
+ client: OpenAI,
121
+ characters: list[dict],
122
+ model: str = "gpt-4o-mini",
123
+ ) -> list[dict]:
124
+ """3명 캐릭터의 첫만남 장면을 한번에 생성."""
125
+ char_descriptions = []
126
+ for i, c in enumerate(characters):
127
+ char_descriptions.append(
128
+ f"캐릭터 {i+1}:\n"
129
+ f" {_char_context(c)}\n"
130
+ f" 첫만남 유형: {c['meeting_type']} — {c['meeting_desc']}"
131
+ )
132
+ chars_str = "\n\n".join(char_descriptions)
133
+
134
+ user_prompt = f"""\
135
+ 아래 3명의 캐릭터와의 첫만남 장면을 각각 만들어줘.
136
+ 각 장면은 그 캐릭터의 성격/말투에 맞는 짧은 상황 묘사(2~3문장)와 상대의 첫 대사를 포함해야 한다.
137
+
138
+ {chars_str}
139
+
140
+ 아래 JSON 형식으로:
141
+ {{
142
+ "meetings": [
143
+ {{
144
+ "character_id": 0,
145
+ "situation": "다큐 카메라가 포착한 첫만남 장면. 2~3문장. 핵심 행동은 **볼드**.",
146
+ "dialogue": "상대방의 첫 대사 (캐릭터 말투에 맞게)"
147
+ }},
148
+ {{
149
+ "character_id": 1,
150
+ "situation": "...",
151
+ "dialogue": "..."
152
+ }},
153
+ {{
154
+ "character_id": 2,
155
+ "situation": "...",
156
+ "dialogue": "..."
157
+ }}
158
+ ]
159
+ }}
160
+ """
161
+ data = _chat_with_retry(
162
+ client, model=model,
163
+ messages=[
164
+ {"role": "system", "content": SYSTEM_PROMPT},
165
+ {"role": "user", "content": user_prompt},
166
+ ],
167
+ )
168
+ return data.get("meetings", [])
169
+
170
+
171
+ # ── 스테이지 이벤트 생성 ─────────────────────────────────────
172
+
173
  def generate_event(
174
  client: OpenAI,
175
+ stage: dict,
176
+ character: dict,
177
+ warmth: float,
178
  current_traits: dict,
 
179
  event_logs: list[dict] = None,
180
+ variant: str = "normal",
181
  model: str = "gpt-4o-mini",
182
  ) -> dict:
183
+ """이벤트 1턴(상황 + 선택지) 생성."""
184
+ axes_str = ", ".join(stage["axes"])
 
185
  story_context = _build_story_context(event_logs or [])
186
+ char_str = _char_context(character)
187
 
188
+ warmth_desc = "차가움" if warmth < 2 else ("미지근" if warmth < 5 else "따뜻함")
189
+ variant_hint = ""
190
+ if variant == "cold":
191
+ variant_hint = "관계가 멀어지고 있다. 연락이 뜸해지고, 대화가 어색해지는 방향으로."
192
+ elif variant == "warm":
193
+ variant_hint = "관계가 깊어지고 있다. 서로에 대한 확신이 생기는 방향으로."
194
 
195
+ traits_str = json.dumps(current_traits, ensure_ascii=False)
196
+ user_prompt = f"""\
197
+ ═══ 상대 캐릭터 ═══
198
+ {char_str}
199
+ ═══ 관계 상태 ═══
200
+ 스테이지: {stage['name']} — {stage['desc']}
201
+ 관계 온도: {warmth:.1f} ({warmth_desc})
202
+ 유저 현재 성향: {traits_str}
203
+ {variant_hint}
204
+ ═══ 이전 흐름 ═══
205
  {story_context}
206
+ ═══ 측정 축 ═══
207
+ {axes_str}
208
 
209
+ 맥락에 이어지는 현실적인 상황을 만들어줘.
210
+ 상대({character['name']})대사와 행동을 캐릭터 성격/말투에 맞게 포함시켜.
 
211
 
212
+ 아래 JSON 형식으로:
213
  {{
214
+ "scene_tag": "장소 태그",
215
+ "situation": "다큐 카메라가 포착한 장면. 이름/대사 포함. 3~5문장. 핵심 행동 **볼드**.",
 
 
 
 
 
 
 
216
  "choices": [
217
+ {{"id": "A", "text": "현실적 대사 행동", "warmth_delta": 0.0, "trait_effect": {{...4~6개 축...}} }},
218
+ {{"id": "B", "text": "다른 방향의 반응", "warmth_delta": 0.0, "trait_effect": {{...4~6개 축...}} }},
219
+ {{"id": "C", "text": "또 다른 방향의 반응", "warmth_delta": 0.0, "trait_effect": {{...4~6개 축...}} }}
220
  ]
221
  }}
222
 
223
+ scene_tag: 장면이 벌어지장소. 다음 : {SCENE_TAGS_STR}
224
+ warmth_delta: 선택이 관계 온도에 미치는 영향. -2.0 ~ 2.0 범위.
225
+ 가까워지는 선택은 양수, 멀어지선택은 음수.
226
+ trait_effect: 4~6개 , -1.0 ~ 1.0
227
+ 15축: cooperation, leadership, emotional_depth, pace, humor, risk, contact_frequency, affection, jealousy, planning, openness, conscientiousness, extraversion, agreeableness, neuroticism
 
 
 
 
 
228
  """
229
+ return _chat_with_retry(
230
+ client, model=model,
 
231
  messages=[
232
  {"role": "system", "content": SYSTEM_PROMPT},
233
  {"role": "user", "content": user_prompt},
234
  ],
 
 
235
  )
236
 
 
 
237
 
238
+ def generate_reaction(
239
  client: OpenAI,
240
+ stage: dict,
241
+ character: dict,
242
+ situation: str,
243
+ user_choice: dict,
244
+ event_logs: list[dict] = None,
245
+ model: str = "gpt-5-chat-latest",
246
+ ) -> dict:
247
+ """상황 전개 + 2턴 선택지 생성."""
248
+ axes_str = ", ".join(stage["axes"])
249
+ story_context = _build_story_context(event_logs or [])
250
+ char_str = _char_context(character)
251
 
252
+ user_prompt = f"""\
253
+ ═══ 상대 캐릭═══
254
+ {char_str}
255
+ ═══금까 흐름 ═══
256
+ {story_context}
257
+ ═══════════════════════
258
 
259
+ 현재 상황: {situation}
260
+ 유저가 한 것: {user_choice['text']}
261
+ 측정 축: {axes_str}
 
262
 
263
+ 유저가 "{user_choice['text']}"라고 했다/했다.
264
+ {character['name']}의 반응을 캐릭터 성격/말투에 맞게 보여줘.
265
+
266
+ 아래 JSON 형식으로:
267
+ {{
268
+ "reaction": "{character['name']}의 반응. 감정 설명 없이 행동/표정/대사만. 2~3문장. 핵심 행동은 **볼드**.",
269
+ "choices": [
270
+ {{"id": "A", "text": "현실적 후속 반응", "warmth_delta": 0.0, "trait_effect": {{...4~6개 축...}} }},
271
+ {{"id": "B", "text": "다른 방향", "warmth_delta": 0.0, "trait_effect": {{...4~6개 축...}} }},
272
+ {{"id": "C", "text": "또 다른 방향", "warmth_delta": 0.0, "trait_effect": {{...4~6개 축...}} }}
273
+ ]
274
+ }}
275
+
276
+ ★ warmth_delta: -2.0 ~ 2.0. 가까워지면 양수, 멀어지면 음수.
277
+ ★ trait_effect: 4~6개 축, -1.0 ~ 1.0
278
+ 15축: cooperation, leadership, emotional_depth, pace, humor, risk, contact_frequency, affection, jealousy, planning, openness, conscientiousness, extraversion, agreeableness, neuroticism
279
+ """
280
+ return _chat_with_retry(
281
+ client, model=model,
282
+ messages=[
283
+ {"role": "system", "content": SYSTEM_PROMPT},
284
+ {"role": "user", "content": user_prompt},
285
+ ],
286
  )
 
287
 
288
 
289
+ # ── 난입 이벤트 생성 ─────────────────────────────────────────
290
+
291
+ def generate_interruption(
292
  client: OpenAI,
293
+ current_char: dict,
294
+ interrupting_char: dict,
295
+ warmth: float,
296
  event_logs: list[dict] = None,
297
  model: str = "gpt-4o-mini",
298
  ) -> dict:
299
+ """다른 캐릭터가 난입하는 이벤트 생성."""
 
 
300
  story_context = _build_story_context(event_logs or [])
301
 
302
  user_prompt = f"""\
303
+ ═══ 현재 상황 ═══
304
+ 현재 만나고 있는 사람: {current_char['name']} ({current_char['job']})
305
+ 관계 온도: {warmth:.1f} (미지근하거나 차가움)
306
+ ═══ 난입하는 사람 ═══
307
+ {_char_context(interrupting_char)}
308
+ 첫만남 유형: {interrupting_char.get('meeting_type', '?')}
309
+ ═══ 이전 흐름 ═══
310
  {story_context}
311
+ ═══════════════
312
 
313
+ {current_char['name']}과(와)의 관계가 애매해진 상황에서,
314
+ {interrupting_char['name']}이(가) 자연스럽게 등장하는 장면을 만들어줘.
315
+ (카톡이 온다, 우연히 마주친다, 같이 밥 먹자고 연락 등)
 
 
 
316
 
317
+ 유저가 마음이 흔들릴 상황이어야 한다.
 
 
 
318
 
319
  아래 JSON 형식으로:
320
  {{
321
+ "scene_tag": "장 태그",
322
+ "situation": "난입 장면. {interrupting_char['name']}의 대사 포함. 3~4문장. **볼드** 사용.",
323
  "choices": [
324
+ {{"id": "A", "text": "{interrupting_char['name']}에게 반응하는 선택", "target_char": {interrupting_char['id']}, "warmth_delta": 1.0, "trait_effect": {{...}} }},
325
+ {{"id": "B", "text": "{current_char['name']}에게 집중하는 선택", "target_char": {current_char['id']}, "warmth_delta": 0.5, "trait_effect": {{...}} }},
326
+ {{"id": "C", "text": "어느 쪽도 아닌 반응", "target_char": null, "warmth_delta": 0.0, "trait_effect": {{...}} }}
327
  ]
328
  }}
329
 
330
+ scene_tag: 장면이 벌어지장소. 다음 : {SCENE_TAGS_STR}
331
+ target_char: 선택이 호감도를 올리는 상의 id. null이면 아무도 아님.
332
+ warmth_delta: 해당 target_char의 호감도 변화. -2.0 ~ 2.0.
 
 
 
333
  ★ trait_effect: 4~6개 축, -1.0 ~ 1.0
 
334
  """
335
+ return _chat_with_retry(
336
+ client, model=model,
337
+ messages=[
338
+ {"role": "system", "content": SYSTEM_PROMPT},
339
+ {"role": "user", "content": user_prompt},
340
+ ],
341
+ )
342
 
343
+
344
+ # ── 결말 생성 ─────────────────────────────────────────────────
345
+
346
+ def generate_ending(
347
+ client: OpenAI,
348
+ character: dict,
349
+ warmth: float,
350
+ ending_type: str,
351
+ event_logs: list[dict] = None,
352
+ model: str = "gpt-4o-mini",
353
+ ) -> dict:
354
+ """warmth 기반 결말 생성."""
355
+ story_context = _build_story_context(event_logs or [])
356
+ char_str = _char_context(character)
357
+
358
+ ending_hints = {
359
+ "mutual": "상대도 유저에게 마음이 있었다. 서로 고백하게 되는 장면. 두근거리는 확인의 순간.",
360
+ "accepted": "유저가 고백했고, 상대가 받아주는 장면. 조심스럽지만 기쁜 순간.",
361
+ "soft_reject": "유저가 고백했지만, 상대가 조심스럽게 거절하는 장면. 하지만 좋은 친구로 남기로 하는 담담한 순간.",
362
+ "rejected": "유저가 고백했지만, 상대가 거절하는 장면. 아쉽지만 각자의 길을 가는 순간.",
363
+ "friends": "유저가 모두와 친구로 남기로 했다. 연인은 아니지만 편한 관계로 남는 장면.",
364
+ "drifted": "서로 연락이 뜸해지고, 자연스럽게 멀어지는 장면. 아쉽지만 그게 현실인 순간.",
365
+ }
366
+
367
+ user_prompt = f"""\
368
+ ═══ 상대 캐릭터 ═══
369
+ {char_str}
370
+ ═══ 관계 온도 ═══
371
+ {warmth:.1f}
372
+ ═══ 결말 방향 ═══
373
+ {ending_type}: {ending_hints.get(ending_type, '')}
374
+ ═══ 이전 흐름 ═══
375
+ {story_context}
376
+ ═══════════════
377
+
378
+ 이 관계의 결말을 다큐 카메라가 포착한 마지막 장면으로 보여줘.
379
+ {character['name']}의 마지막 대사를 포함시켜.
380
+
381
+ 아래 JSON 형식으로:
382
+ {{
383
+ "scene_tag": "장소 태그",
384
+ "ending_scene": "결말 장면. 4~6문장. 핵심 행동/사물 **볼드**.",
385
+ "ending_dialogue": "{character['name']}의 마지막 대사",
386
+ "ending_type": "{ending_type}"
387
+ }}
388
+
389
+ ★ scene_tag: 결말이 벌어지는 장소. 다음 중 하나: {SCENE_TAGS_STR}
390
+ """
391
+ return _chat_with_retry(
392
+ client, model=model,
393
  messages=[
394
  {"role": "system", "content": SYSTEM_PROMPT},
395
  {"role": "user", "content": user_prompt},
396
  ],
 
 
397
  )
398
 
399
+
400
+ # ── 성향 분석 생성 ───────────────────────────────────────────────
401
+
402
+ ANALYSIS_SYSTEM = """\
403
+ 너는 연애 성향 분석 전문가이다.
404
+ 유저가 가상 연애 시뮬레이션에서 한 선택들을 바탕으로 연애 스타일을 분석한다.
405
+ 따뜻하고 공감하는 톤으로, 구체적인 선택을 근거로 들어 분석한다.
406
+ 반말(~해요체) 사용. 짧고 임팩트 있게.
407
+ """
408
+
409
+
410
+ def generate_analysis(
411
+ client: OpenAI,
412
+ trait_vector: dict,
413
+ type_id: str,
414
+ type_name: str,
415
+ event_logs: list[dict],
416
+ ending_type: str,
417
+ model: str = "gpt-4o-mini",
418
+ ) -> dict:
419
+ """플레이 결과 기반 성향 분석 생성."""
420
+ choice_lines = []
421
+ for log in event_logs[-4:]:
422
+ scenario = log.get("scenario", "?")
423
+ t1 = log.get("turn1", {}).get("selected", {}).get("text", "")
424
+ t2 = log.get("turn2", {}).get("selected", {}).get("text", "")
425
+ if t1:
426
+ choice_lines.append(f"[{scenario}] 1턴: {t1}")
427
+ if t2:
428
+ choice_lines.append(f"[{scenario}] 2턴: {t2}")
429
+ choices_str = "\n".join(choice_lines) if choice_lines else "없음"
430
+
431
+ ending_labels = {
432
+ "mutual": "서로 고백 → 사귐",
433
+ "accepted": "고백 수락 → 사귐",
434
+ "soft_reject": "거절당했지만 친구로",
435
+ "rejected": "고백 거절당함",
436
+ "friends": "모두와 친구로 남음",
437
+ "drifted": "자연스럽게 멀어짐",
438
+ }
439
+
440
+ traits_str = json.dumps(trait_vector, ensure_ascii=False)
441
+ user_prompt = f"""\
442
+ 유저의 연애 시뮬레이션 결과를 분석해줘.
443
+
444
+ ■ 연애 유형: {type_name} ({type_id})
445
+ ■ 결말: {ending_labels.get(ending_type, ending_type)}
446
+ ■ 15축 성향 벡터: {traits_str}
447
+ ■ 핵심 선택들:
448
+ {choices_str}
449
+
450
+ 아래 JSON으로:
451
+ {{
452
+ "summary": "2-3문장. 이 사람의 연애 스타일 핵심 요약. 구체적 선택을 언급해서.",
453
+ "strengths": ["강점 1 (한 줄)", "강점 2 (한 줄)"],
454
+ "watch_points": ["주의할 점 1 (한 줄)"],
455
+ "dating_tip": "이 유형에게 맞는 연애 팁 한 줄"
456
+ }}
457
+ """
458
+ return _chat_with_retry(
459
+ client, model=model,
460
+ messages=[
461
+ {"role": "system", "content": ANALYSIS_SYSTEM},
462
+ {"role": "user", "content": user_prompt},
463
+ ],
464
+ temperature=0.8,
465
+ )
466
+
467
+
468
+ # ── 성향 서술 생성 ─────────────────────────────────────────────────
469
+
470
+ def generate_personality_description(
471
+ client: OpenAI,
472
+ profile: dict,
473
+ type_info: dict,
474
+ event_logs: list[dict] = None,
475
+ model: str = "gpt-4o-mini",
476
+ ) -> str:
477
+ """유저의 성향 벡터를 바탕으로 자연어 서술을 생성."""
478
+ story_context = _build_story_context(event_logs or [])
479
+ traits_str = json.dumps(profile.get("traits", {}), ensure_ascii=False, indent=2)
480
+ big5_str = json.dumps(profile.get("big_five", {}), ensure_ascii=False, indent=2)
481
+
482
+ user_prompt = f"""\
483
+ 아래는 유저가 연애 시뮬레이션에서 보여준 성향 데이터야.
484
+ 이걸 바탕으로, 이 사람이 연애할 때 어떤 사람인지 2인칭(당신)으로 따뜻하게 서술해줘.
485
+
486
+ ═══ 유형 ═══
487
+ {type_info.get('emoji', '')} {type_info.get('title', '')}
488
+ {type_info.get('desc', '')}
489
+
490
+ ═══ 행동 성향 (10축) ═══
491
+ {traits_str}
492
+
493
+ ═══ 성격 (Big Five) ═══
494
+ {big5_str}
495
+
496
+ ═══ 플레이 중 선택 흐름 ═══
497
+ {story_context}
498
+
499
+ ■ 작성 규칙:
500
+ - 3~5문장. 너무 길지 않게.
501
+ - 수치를 직접 언급하지 마. 자연스러운 문장으로 표현해.
502
+ - "당신은 ~한 사람이에요" 같은 따뜻한 톤.
503
+ - 플레이 중 보여준 선택 패턴을 자연스럽게 녹여서.
504
+ - JSON 아닌 순수 텍스트로만 응답해.
505
+ """
506
+ resp = client.chat.completions.create(
507
+ model=model,
508
+ messages=[
509
+ {"role": "system", "content": "너는 연애 성향 분석 전문가야. 따뜻하고 공감 어린 톤으로 서술한다."},
510
+ {"role": "user", "content": user_prompt},
511
+ ],
512
+ temperature=0.85,
513
+ )
514
+ return resp.choices[0].message.content.strip()
requirements.txt CHANGED
@@ -1,4 +1,3 @@
1
  openai>=1.0.0
2
  python-dotenv>=1.0.0
3
- gradio==4.36.1
4
- huggingface_hub==0.23.4
 
1
  openai>=1.0.0
2
  python-dotenv>=1.0.0
3
+ gradio>=4.0.0
 
scenarios.py CHANGED
@@ -1,4 +1,6 @@
1
- """러브로그 시나리오 프레임 데이터"""
 
 
2
 
3
  # ── 15축 정의 ──────────────────────────────────────────────
4
 
@@ -13,59 +15,108 @@ BIG5_AXES = [
13
 
14
  ALL_AXES = BEHAVIOR_AXES + BIG5_AXES
15
 
16
- # ── 시나리오 ────────────────────────────────────────────
17
 
18
- TONES = {
19
- "positive": {"emoji": "💗", "label": "긍정", "desc": "설렘, 발전, 교감"},
20
- "negative": {"emoji": "💔", "label": "부정", "desc": "갈등, 위기, 실망"},
21
- "wildcard": {"emoji": "", "label": "돌발", "desc": "예측 불가, 변수, 반전"},
 
 
 
22
  }
23
 
24
- # ── 상황 카테고리 (Depth 2) ─────────────────────────────────
25
-
26
- SCENARIOS = {
27
- # 💗 긍정
28
- "P1": {"name": "첫 ", "desc": "처음 만난 순간의 반응", "tone": "positive", "axes": ["pace", "extraversion"]},
29
- "P2": {"name": "관심 표현", "desc": "호감을 어떻게 드러내는가", "tone": "positive", "axes": ["affection", "contact_frequency"]},
30
- "P3": {"name": "깊은 대화", "desc": "감정을 나누는 순간", "tone": "positive", "axes": ["emotional_depth", "openness"]},
31
- "P4": {"name": "함께하는 시간", "desc": "데이트 중 선택", "tone": "positive", "axes": ["humor", "cooperation"]},
32
- "P5": {"name": "미래 이야기", "desc": "관계의 다음 단계 언급", "tone": "positive", "axes": ["planning", "conscientiousness"]},
33
- "P6": {"name": "서프라즈", "desc": "상대를 위한 행동", "tone": "positive", "axes": ["affection", "agreeableness"]},
34
- # 💔 부정
35
- "N1": {"name": "연락 두절", "desc": "갑자기 답이 없다", "tone": "negative", "axes": ["jealousy", "neuroticism"]},
36
- "N2": {"name": "의견 충돌", "desc": "서로 다른 생각", "tone": "negative", "axes": ["cooperation", "agreeableness"]},
37
- "N3": {"name": "질투 상황", "desc": "다른 이성의 등장", "tone": "negative", "axes": ["jealousy", "neuroticism"]},
38
- "N4": {"name": "실망 순간", "desc": "기대와 다른 모습 발견", "tone": "negative", "axes": ["emotional_depth", "conscientiousness"]},
39
- "N5": {"name": "거리감", "desc": "상대가 한 발 물러섬", "tone": "negative", "axes": ["pace", "contact_frequency"]},
40
- "N6": {"name": "과거 등장", "desc": "전 애인 관련 상황", "tone": "negative", "axes": ["risk", "neuroticism"]},
41
- # ⚡ 돌발
42
- "W1": {"name": "우연한 재회", "desc": "예상 못한 곳에서 마주침", "tone": "wildcard", "axes": ["extraversion", "pace"]},
43
- "W2": {"name": "제3자 개입", "desc": "친구/가족의 의견", "tone": "wildcard", "axes": ["leadership", "agreeableness"]},
44
- "W3": {"name": "돌발 고백", "desc": "예상보다 빠른 감정 표현", "tone": "wildcard", "axes": ["risk", "pace"]},
45
- "W4": {"name": "환경 변화", "desc": "이사/이직 등 외부 변수", "tone": "wildcard", "axes": ["planning", "openness"]},
46
- "W5": {"name": "오해 발생", "desc": "의도와 다르게 전달된 상황", "tone": "wildcard", "axes": ["cooperation", "emotional_depth"]},
47
- "W6": {"name": "시험대", "desc": "관계를 증명해야 하는 순간", "tone": "wildcard", "axes": ["risk", "leadership"]},
48
  }
49
 
50
- # ── 보스 이벤트 ────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
51
 
52
- BOSS_EVENTS = {
53
- "B1": {"name": "이별 위기", "desc": "헤어질 것인가, 잡을 것인가", "tone": "negative", "axes": ["emotional_depth", "cooperation", "neuroticism"]},
54
- "B2": {"name": "고백 순간", "desc": "관계를 정의하는 선택", "tone": "positive", "axes": ["risk", "affection", "extraversion"]},
55
- "B3": {"name": "진실 폭로", "desc": "숨겨진 사실이 드러남", "tone": "wildcard", "axes": ["openness", "agreeableness", "neuroticism"]},
56
- "B4": {"name": "선택의 기로", "desc": "상대 vs 나의 우선순위", "tone": "wildcard", "axes": ["leadership", "planning", "cooperation"]},
 
 
 
 
 
 
 
 
 
 
 
 
57
  }
58
 
59
- # ── 톤 배분 템플릿 (6이벤트 + 보스 1) ──────────────────────
60
 
61
- TONE_DISTRIBUTION = [
62
- "positive", # 1: 관계 진입
63
- "positive", # 2: 관계 진입
64
- "negative", # 3: 갈등
65
- "wildcard", # 4: 변수
66
- None, # 5: 랜덤
67
- None, # 6: 랜덤
68
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
  # ── 감정 태그 ──────────────────────────────────────────────
71
 
@@ -116,4 +167,3 @@ DATING_TYPES = {
116
  "weights": {"risk": 1.5, "openness": 2.0, "jealousy": -2.0},
117
  },
118
  }
119
-
 
1
+ """하트로그 시나리오 프레임 데이터 — 스테이지 기반 + 다중 캐릭터"""
2
+
3
+ import random
4
 
5
  # ── 15축 정의 ──────────────────────────────────────────────
6
 
 
15
 
16
  ALL_AXES = BEHAVIOR_AXES + BIG5_AXES
17
 
18
+ # ── 관계 스테이지 ─────────────────────────────────────────
19
 
20
+ STAGES = {
21
+ 1: {"name": "첫만남", "desc": "설렘, 첫인상, 어색함", "axes": ["pace", "extraversion", "openness"]},
22
+ 2: {"name": "데이트", "desc": "알아가기, 관심 표현, 거리 좁히기", "axes": ["humor", "cooperation", "affection"]},
23
+ 3: {"name": "", "desc": "감정 확인, 밀당", "axes": ["emotional_depth", "affection", "contact_frequency"]},
24
+ 4: {"name": "관계 정의", "desc": "고백/확인 또는 정리", "axes": ["risk", "pace", "planning"]},
25
+ 5: {"name": "갈등", "desc": "현실적 충돌, 의견 차이", "axes": ["cooperation", "agreeableness", "neuroticism"]},
26
+ 6: {"name": "결말", "desc": "사귐, 친구, 고백 거절, 또는 거절", "axes": ["planning", "conscientiousness", "emotional_depth"]},
27
  }
28
 
29
+ # ── warmth 분기 임계값 ────────────────────────────────────
30
+
31
+ WARMTH_THRESHOLDS = {
32
+ "stage3_cold": 3, # 미만이면 멀어지는 방향
33
+ "confession_mutual": 7, # 이상이면대도 고백 사귄다
34
+ "confession_accepted": 4, # 이상이면 고백 수락 사귄다
35
+ "confession_soft_reject": 2, # 이상이면 거절 친구로 남는다
36
+ # > 0: 거절당함
37
+ # <= 0: 고백 불가 (선택지에 뜸)
38
+ "interruption": 2, #미만이면 난입 확정
39
+ "early_ending": 0, # 모든 캐릭터가 이 이하이면 조기 결말
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  }
41
 
42
+ MAX_INTERRUPTIONS = 2 # 난입 최대 횟수
43
+ INTERRUPT_STAGES = {3, 4} # 난입 발생 가능 스테이지
44
+
45
+ # ── 첫만남 유형 ───────────────────────────────────────────
46
+
47
+ MEETING_TYPES = [
48
+ {"type": "소개팅", "desc": "친구가 소개해준 사람. 카페에서 처음 만남."},
49
+ {"type": "친구에서 발전", "desc": "같은 동호회/회사 사람. 이미 아는 사이에서 미묘해짐."},
50
+ {"type": "우연한 만남", "desc": "동네 편의점/카페에서 자주 마주치다 말 걸게 됨."},
51
+ ]
52
 
53
+ # ── 캐릭터 템플릿 풀 ──────────────────────────────────────
54
+
55
+ CHAR_POOL = {
56
+ "M": [
57
+ {"name": "김서진", "age": 27, "job": "IT회사 백엔드 개발자", "personality": "조용하지만 할 말은 하는 편. 리액션이 늦지만 한번 웃으면 오래 웃음.", "speech_style": "...어 그건 좀, 아 근데 / 단답 많지만 가끔 길게"},
58
+ {"name": "이준호", "age": 29, "job": "광고대행사 기획자", "personality": "사교적이고 분위기 메이커. 약간 오버하는 경향.", "speech_style": "아 진짜? 대박 / 텐션 높고 리액션 큼"},
59
+ {"name": "박도현", "age": 26, "job": "스타트업 디자이너", "personality": "감성적이고 세심. 상대 기분에 민감.", "speech_style": "음... 괜찮아? / 조심스럽고 배려하는 톤"},
60
+ {"name": "최현우", "age": 28, "job": "대기업 영업팀", "personality": "자신감 있고 직설적. 결정이 빠름.", "speech_style": "내가 할게 / 짧고 확실한 톤"},
61
+ {"name": "정민재", "age": 25, "job": "대학원생 (경영학)", "personality": "분석적이고 신중. 계획을 좋아함.", "speech_style": "그거 한번 생각해보자 / 논리적이지만 딱딱하진 않음"},
62
+ ],
63
+ "F": [
64
+ {"name": "이수빈", "age": 26, "job": "출판사 편집자", "personality": "차분하고 관찰력 좋음. 말보다 표정으로 드러남.", "speech_style": "아 그래? / 짧게 대답하지만 눈 맞춤 많음"},
65
+ {"name": "한소희", "age": 28, "job": "마케팅 대리", "personality": "활발하고 주도적. 약속 잡는 걸 좋아함.", "speech_style": "야 우리 이거 하자! / 에너지 넘치는 톤"},
66
+ {"name": "김하은", "age": 25, "job": "카페 바리스타 겸 일러스트레이터", "personality": "몽글몽글하고 감성적. 사소한 것에 감동.", "speech_style": "헐 이거 진짜 예쁘다... / 감탄사 많음"},
67
+ {"name": "정유진", "age": 27, "job": "금융권 애널리스트", "personality": "똑부러지고 현실적. 비효율 싫어함.", "speech_style": "그건 좀 아닌 거 같은데 / 명확하고 직접적"},
68
+ {"name": "박지우", "age": 24, "job": "대학원생 (심리학)", "personality": "공감 능력 높고 호기심 많음. 질문을 많이 함.", "speech_style": "그때 어떤 기분이었어? / 탐구하는 톤"},
69
+ ],
70
  }
71
 
 
72
 
73
+ def pick_characters(partner_gender: str, count: int = 3, seed: int | None = None) -> list[dict]:
74
+ """상대 성별 풀에서 count명 랜덤 선택 + 첫만남 유형 배정."""
75
+ rng = random.Random(seed)
76
+ pool = CHAR_POOL.get(partner_gender, CHAR_POOL["F"])
77
+ chars = rng.sample(pool, min(count, len(pool)))
78
+ meetings = list(MEETING_TYPES)
79
+ rng.shuffle(meetings)
80
+ result = []
81
+ for i, char in enumerate(chars):
82
+ result.append({
83
+ **char,
84
+ "id": i,
85
+ "meeting_type": meetings[i]["type"],
86
+ "meeting_desc": meetings[i]["desc"],
87
+ "warmth": 0,
88
+ "active": True,
89
+ "event_count": 0,
90
+ })
91
+ return result
92
+
93
+
94
+ def get_warmth_variant(stage: int, warmth: float) -> str:
95
+ """warmth 기반으로 스테이지 variant 결정."""
96
+ t = WARMTH_THRESHOLDS
97
+ if stage == 3:
98
+ return "warm" if warmth >= t["stage3_cold"] else "cold"
99
+ return "normal"
100
+
101
+
102
+ def get_confession_outcome(warmth: float) -> str:
103
+ """고백 시 warmth 기반 결과 결정."""
104
+ t = WARMTH_THRESHOLDS
105
+ if warmth >= t["confession_mutual"]:
106
+ return "mutual"
107
+ if warmth >= t["confession_accepted"]:
108
+ return "accepted"
109
+ if warmth >= t["confession_soft_reject"]:
110
+ return "soft_reject"
111
+ return "rejected"
112
+
113
+
114
+ def should_interrupt(warmth: float) -> bool:
115
+ """난입 이벤트 발생 조건. 임계값 미만이면 확정, 이상이면 50%."""
116
+ if warmth < WARMTH_THRESHOLDS["interruption"]:
117
+ return True
118
+ return random.random() < 0.5
119
+
120
 
121
  # ── 감정 태그 ──────────────────────────────────────────────
122
 
 
167
  "weights": {"risk": 1.5, "openness": 2.0, "jealousy": -2.0},
168
  },
169
  }
 
scene_art.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """장면별 이미지 — PNG를 base64로 로드."""
2
+
3
+ import base64
4
+ from pathlib import Path
5
+
6
+ SCENE_TAGS = [
7
+ "cafe", "restaurant", "park", "street", "store",
8
+ "home", "bar", "phone", "cinema", "night",
9
+ ]
10
+
11
+ _IMG_DIR = Path(__file__).parent / "scene_images"
12
+
13
+
14
+ def _load_images() -> tuple[dict[str, str], str]:
15
+ images = {}
16
+ for tag in SCENE_TAGS:
17
+ path = _IMG_DIR / f"{tag}.png"
18
+ if path.exists():
19
+ b64 = base64.b64encode(path.read_bytes()).decode()
20
+ images[tag] = f"data:image/png;base64,{b64}"
21
+ default_path = _IMG_DIR / "default.png"
22
+ default = ""
23
+ if default_path.exists():
24
+ b64 = base64.b64encode(default_path.read_bytes()).decode()
25
+ default = f"data:image/png;base64,{b64}"
26
+ return images, default
27
+
28
+
29
+ _SCENE_IMAGES, _DEFAULT_IMAGE = _load_images()
30
+ SCENE_TAGS_STR = ", ".join(SCENE_TAGS)
31
+
32
+
33
+ def get_scene_image(tag: str) -> str:
34
+ """태그에 해당하는 이미지의 base64 data URI 반환."""
35
+ return _SCENE_IMAGES.get(tag, _DEFAULT_IMAGE)
scene_images/bar.png ADDED
scene_images/cafe.png ADDED
scene_images/cinema.png ADDED
scene_images/default.png ADDED
scene_images/home.png ADDED
scene_images/night.png ADDED
scene_images/park.png ADDED
scene_images/phone.png ADDED
scene_images/restaurant.png ADDED
scene_images/store.png ADDED
scene_images/street.png ADDED
trait_engine.py CHANGED
@@ -153,8 +153,6 @@ def find_matches(user_vector: dict, user_type: str, top_n: int = 3) -> list[dict
153
  profiles = load_seed_profiles()
154
  results = []
155
 
156
- user_type_obj, _ = classify_type(user_vector)
157
-
158
  for profile in profiles:
159
  candidate_vector = profile_to_vector(profile)
160
 
@@ -203,6 +201,59 @@ def find_matches(user_vector: dict, user_type: str, top_n: int = 3) -> list[dict
203
  return results[:top_n]
204
 
205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  def format_match_card(match: dict, rank: int) -> str:
207
  """매칭 결과 카드 출력"""
208
  p = match["profile"]
 
153
  profiles = load_seed_profiles()
154
  results = []
155
 
 
 
156
  for profile in profiles:
157
  candidate_vector = profile_to_vector(profile)
158
 
 
201
  return results[:top_n]
202
 
203
 
204
+ AXIS_NAMES_KR = {
205
+ "cooperation": "협력성", "leadership": "주도성", "emotional_depth": "감정 깊이",
206
+ "pace": "관계 속도", "humor": "유머", "risk": "모험성",
207
+ "contact_frequency": "연락 빈도", "affection": "애정 표현",
208
+ "jealousy": "질투/독점", "planning": "계획성",
209
+ "openness": "개방성", "conscientiousness": "성실성",
210
+ "extraversion": "외향성", "agreeableness": "친화성", "neuroticism": "정서 불안정성",
211
+ }
212
+
213
+ COMPLEMENTARY_TYPES = {
214
+ "flame": "stable", "stable": "flame",
215
+ "emotional": "strategic", "strategic": "emotional",
216
+ "free": "stable",
217
+ }
218
+
219
+
220
+ def generate_match_reason(user_vector: dict, user_type: str, match: dict) -> str:
221
+ """매칭 이유를 deterministic하게 생성."""
222
+ cand_vector = profile_to_vector(match["profile"])
223
+
224
+ similarities = []
225
+ complements = []
226
+ for axis in ALL_AXES:
227
+ u = user_vector.get(axis, 0)
228
+ c = cand_vector.get(axis, 0)
229
+ diff = abs(u - c)
230
+ magnitude = abs(u) + abs(c)
231
+ if magnitude > 1.0:
232
+ if diff < 1.0:
233
+ similarities.append((axis, magnitude))
234
+ elif diff > 1.5 and u * c < 0:
235
+ complements.append((axis, abs(u), abs(c)))
236
+
237
+ similarities.sort(key=lambda x: -x[1])
238
+ parts = []
239
+
240
+ if similarities:
241
+ names = [AXIS_NAMES_KR.get(s[0], s[0]) for s in similarities[:2]]
242
+ parts.append(f"{', '.join(names)}이(가) 비슷해요")
243
+
244
+ if complements:
245
+ name = AXIS_NAMES_KR.get(complements[0][0], complements[0][0])
246
+ parts.append(f"{name}에서 서로 보완돼요")
247
+
248
+ cand_type = match.get("type", "")
249
+ if cand_type == COMPLEMENTARY_TYPES.get(user_type, ""):
250
+ parts.append("상호 보완적인 유형이에요")
251
+ elif cand_type == user_type:
252
+ parts.append("같은 유형이라 공감대가 높아요")
253
+
254
+ return " · ".join(parts) if parts else "전반적인 성향이 잘 맞아요"
255
+
256
+
257
  def format_match_card(match: dict, rank: int) -> str:
258
  """매칭 결과 카드 출력"""
259
  p = match["profile"]