Commit ·
ff932fe
1
Parent(s): 50b9016
Rename to 러브로그(LoveLog) and clean up unused files
Browse filesCo-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- app.py +940 -420
- gpt_engine.py +432 -142
- requirements.txt +1 -2
- scenarios.py +96 -46
- scene_art.py +35 -0
- scene_images/bar.png +0 -0
- scene_images/cafe.png +0 -0
- scene_images/cinema.png +0 -0
- scene_images/default.png +0 -0
- scene_images/home.png +0 -0
- scene_images/night.png +0 -0
- scene_images/park.png +0 -0
- scene_images/phone.png +0 -0
- scene_images/restaurant.png +0 -0
- scene_images/store.png +0 -0
- scene_images/street.png +0 -0
- trait_engine.py +53 -2
app.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
-
"""러브로그 — Gradio 플레이 (
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
|
| 54 |
-
|
| 55 |
|
| 56 |
|
| 57 |
-
# ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
def new_state():
|
| 60 |
return {
|
| 61 |
"phase": "idle",
|
| 62 |
-
"
|
| 63 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 107 |
-
return (
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
-
state = new_state()
|
| 114 |
-
state["phase"] = "loading_event"
|
| 115 |
-
state["events"] = build_events()
|
| 116 |
|
| 117 |
-
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
-
|
| 121 |
-
idx = state["event_idx"]
|
| 122 |
-
events = state["events"]
|
| 123 |
|
| 124 |
-
if
|
| 125 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
-
|
| 128 |
-
is_boss = scenario.get("_is_boss", False)
|
| 129 |
-
tone = TONE_EMOJI.get(scenario["tone"], "")
|
| 130 |
-
total = len(events)
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
model = get_model()
|
| 138 |
try:
|
| 139 |
event_data = generate_event(
|
| 140 |
-
client,
|
| 141 |
-
event_logs=state["event_logs"],
|
|
|
|
| 142 |
)
|
| 143 |
except Exception as e:
|
| 144 |
-
state["
|
| 145 |
-
|
|
|
|
| 146 |
|
| 147 |
state["current_event_data"] = event_data
|
| 148 |
state["current_event_log"] = {
|
| 149 |
-
"
|
| 150 |
-
"scenario":
|
| 151 |
-
"desc":
|
| 152 |
-
"
|
| 153 |
-
"
|
|
|
|
| 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 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 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 |
-
|
| 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 |
-
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 222 |
event_logs=state["event_logs"], model=model,
|
| 223 |
)
|
| 224 |
except Exception as e:
|
| 225 |
-
state["
|
| 226 |
state["event_logs"].append(state["current_event_log"])
|
| 227 |
-
|
|
|
|
| 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 |
-
|
| 239 |
-
|
| 240 |
-
|
| 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 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
| 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": "
|
| 276 |
-
"text":
|
| 277 |
})
|
| 278 |
|
| 279 |
-
state["
|
| 280 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 290 |
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
|
| 315 |
-
# GPT 성향 요약 생성
|
| 316 |
-
client = get_client()
|
| 317 |
model = get_model()
|
| 318 |
-
type_name = dt.get("title", dt.get("name", ""))
|
| 319 |
try:
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
| 321 |
except Exception:
|
| 322 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
|
|
|
| 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 |
-
|
| 351 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
|
| 395 |
-
/*
|
| 396 |
.gradio-container {
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
}
|
| 401 |
|
| 402 |
-
/*
|
| 403 |
-
.
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
padding: 0;
|
| 407 |
-
background: #ede5f7;
|
| 408 |
-
border-radius: 20px;
|
| 409 |
-
height: 520px;
|
| 410 |
-
overflow-y: auto;
|
| 411 |
-
scroll-behavior: smooth;
|
| 412 |
-
}
|
| 413 |
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
}
|
|
|
|
|
|
|
|
|
|
| 420 |
|
| 421 |
-
|
| 422 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
}
|
|
|
|
|
|
|
|
|
|
| 424 |
.chat-container::-webkit-scrollbar-thumb {
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
}
|
| 428 |
-
.chat-container::-webkit-scrollbar-track {
|
| 429 |
-
background: transparent;
|
| 430 |
}
|
| 431 |
|
| 432 |
-
/*
|
| 433 |
.chat-divider {
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
}
|
| 439 |
-
|
| 440 |
.chat-divider span {
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
|
|
|
| 447 |
}
|
| 448 |
|
| 449 |
-
/*
|
| 450 |
-
.
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
animation: fadeUp 0.25s ease;
|
| 458 |
}
|
| 459 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
@keyframes fadeUp {
|
| 461 |
-
|
| 462 |
-
|
| 463 |
}
|
| 464 |
|
| 465 |
-
/*
|
| 466 |
.bubble.narration {
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
|
|
|
| 472 |
}
|
| 473 |
|
| 474 |
-
/*
|
| 475 |
.bubble.user-choice {
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
|
|
|
| 482 |
}
|
| 483 |
|
| 484 |
-
/*
|
| 485 |
.bubble.question {
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
font-weight: 600;
|
| 491 |
-
font-size: 13.5px;
|
| 492 |
}
|
| 493 |
|
| 494 |
-
/*
|
| 495 |
-
.
|
| 496 |
-
|
| 497 |
-
|
|
|
|
| 498 |
}
|
| 499 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
.choice-column button {
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
|
|
|
| 513 |
}
|
| 514 |
-
|
| 515 |
.choice-column button:hover {
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
}
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
background: #ede5ff !important;
|
| 525 |
}
|
| 526 |
|
| 527 |
-
/*
|
| 528 |
.start-btn button {
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
}
|
| 539 |
-
|
| 540 |
.start-btn button:hover {
|
| 541 |
-
|
| 542 |
-
|
|
|
|
|
|
|
| 543 |
}
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
}
|
| 549 |
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
}
|
| 554 |
-
|
| 555 |
-
.
|
| 556 |
-
|
| 557 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
}
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
font-weight: 800;
|
| 563 |
-
color: #5b21b6;
|
| 564 |
-
margin-bottom: 6px;
|
| 565 |
}
|
| 566 |
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 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 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 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 |
-
|
| 653 |
-
|
| 654 |
-
}
|
| 655 |
-
|
| 656 |
-
.
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 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">
|
| 673 |
<div class="welcome-desc">
|
| 674 |
가상의 연애 상황을 체험하며<br>
|
| 675 |
당신의 성향을 발견하세요.<br><br>
|
| 676 |
-
|
| 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=
|
| 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 |
-
"💜
|
| 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 |
-
|
| 716 |
-
|
| 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 |
-
|
|
|
|
|
|
| 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'>“{dialogue}”</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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
■
|
| 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:]:
|
| 54 |
-
|
| 55 |
situation = log.get("situation", "")
|
| 56 |
reaction = log.get("reaction", "")
|
| 57 |
-
|
| 58 |
-
|
| 59 |
lines.append(
|
| 60 |
-
f"[{
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
| 84 |
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
{story_context}
|
| 87 |
-
══════
|
|
|
|
| 88 |
|
| 89 |
-
위
|
| 90 |
-
|
| 91 |
-
마치 드라마의 다음 회차처럼, 관계와 감정이 이전에서 이어진다.
|
| 92 |
|
| 93 |
-
아래 JSON 형식으로
|
| 94 |
{{
|
| 95 |
-
"
|
| 96 |
-
|
| 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", "
|
| 106 |
-
{{"id": "B", "
|
| 107 |
-
{{"id": "C", "
|
| 108 |
]
|
| 109 |
}}
|
| 110 |
|
| 111 |
-
★
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 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 |
-
|
| 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
|
| 137 |
client: OpenAI,
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
|
|
|
| 149 |
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
"""
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
)
|
| 161 |
-
return resp.choices[0].message.content.strip()
|
| 162 |
|
| 163 |
|
| 164 |
-
|
|
|
|
|
|
|
| 165 |
client: OpenAI,
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
event_logs: list[dict] = None,
|
| 170 |
model: str = "gpt-4o-mini",
|
| 171 |
) -> dict:
|
| 172 |
-
"""
|
| 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 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
유저가 1턴에서 "{user_choice['text']}" 를 선택했다.
|
| 187 |
-
이 선택의 결과로 상황이 어떻게 전개되는지 묘사하고, 2턴 선택지를 생성해줘.
|
| 188 |
|
| 189 |
-
|
| 190 |
-
- 1턴에서 행동의 방향을 정했으니, 2턴에서는 그 행동을 "어떤 톤���로" 할지를 결정한다.
|
| 191 |
-
- reaction(상황 전개)은 1턴 선택에 따라 완전히 달라져야 한다.
|
| 192 |
-
(적극적 선택 → 가까워진 상황 / 회피 선택 → 멀어진 상황 / 관망 → 탐색 중인 상황)
|
| 193 |
|
| 194 |
아래 JSON 형식으로:
|
| 195 |
{{
|
| 196 |
-
"
|
|
|
|
| 197 |
"choices": [
|
| 198 |
-
{{"id": "A", "
|
| 199 |
-
{{"id": "B", "
|
| 200 |
-
{{"id": "C", "
|
| 201 |
]
|
| 202 |
}}
|
| 203 |
|
| 204 |
-
★
|
| 205 |
-
|
| 206 |
-
|
| 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 |
-
|
| 215 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
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 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
-
# ──
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
#
|
| 28 |
-
"
|
| 29 |
-
"
|
| 30 |
-
"
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
"
|
| 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 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
"
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
}
|
| 58 |
|
| 59 |
-
# ── 톤 배분 템플릿 (6이벤트 + 보스 1) ──────────────────────
|
| 60 |
|
| 61 |
-
|
| 62 |
-
"
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 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"]
|