Spaces:
Sleeping
Sleeping
Upload app.py
Browse files
app.py
CHANGED
|
@@ -1,53 +1,43 @@
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
-
"""
|
| 3 |
|
| 4 |
Automatically generated by Colab.
|
| 5 |
|
| 6 |
Original file is located at
|
| 7 |
-
https://colab.research.google.com/drive/
|
| 8 |
"""
|
| 9 |
|
| 10 |
-
# 사이버펑크 추리 게임 - 최적화 버전
|
| 11 |
import gradio as gr
|
| 12 |
import openai
|
| 13 |
import time
|
| 14 |
import random
|
| 15 |
import json
|
| 16 |
-
import pandas as pd
|
| 17 |
from datetime import datetime
|
| 18 |
from dataclasses import dataclass, field
|
| 19 |
from typing import Dict, List, Tuple, Optional
|
| 20 |
import os
|
| 21 |
|
| 22 |
-
# 환경 변수에서 API 키 로드
|
| 23 |
API_KEY = os.environ.get("OPENAI_API_KEY", "your-api-key-here")
|
| 24 |
|
| 25 |
-
# API 키 확인
|
| 26 |
if API_KEY == "your-api-key-here":
|
| 27 |
print("⚠️ 경고: OPENAI_API_KEY 환경 변수를 설정해주세요!")
|
| 28 |
-
print("export OPENAI_API_KEY='your-actual-api-key'")
|
| 29 |
|
| 30 |
client = openai.OpenAI(api_key=API_KEY)
|
| 31 |
|
| 32 |
# CSS 스타일 상수
|
| 33 |
class Styles:
|
| 34 |
-
"""재사용 가능한 스타일 정의"""
|
| 35 |
CYBERPUNK_BG = "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)"
|
| 36 |
NEON_BORDER = "2px solid #00ffff"
|
| 37 |
NEON_SHADOW = "0 0 20px rgba(0, 255, 255, 0.3)"
|
| 38 |
|
| 39 |
@staticmethod
|
| 40 |
def panel(bg_opacity=0.85):
|
| 41 |
-
return f""
|
| 42 |
-
background: linear-gradient(145deg, rgba(0, 0, 0, {bg_opacity}), rgba(20, 30, 50, {bg_opacity}));
|
| 43 |
-
color: #ffffff; padding: 15px; border-radius: 12px;
|
| 44 |
-
border: 2px solid #00ddff; font-size: 12px;
|
| 45 |
-
backdrop-filter: blur(10px); box-shadow: 0 8px 32px rgba(0, 221, 255, 0.3);
|
| 46 |
-
"""
|
| 47 |
|
| 48 |
@dataclass
|
| 49 |
class Character:
|
| 50 |
-
"""캐릭터 데이터 클래스"""
|
| 51 |
id: str
|
| 52 |
name: str
|
| 53 |
description: str
|
|
@@ -57,58 +47,56 @@ class Character:
|
|
| 57 |
|
| 58 |
@dataclass
|
| 59 |
class GameState:
|
| 60 |
-
"""게임 상태 관리 클래스"""
|
| 61 |
current_suspect: str = 'Elena'
|
| 62 |
evidence_collected: List[str] = field(default_factory=list)
|
| 63 |
-
interrogation_count: Dict[str, int] = field(default_factory=lambda: {
|
| 64 |
-
|
| 65 |
-
})
|
| 66 |
-
trust_levels: Dict[str, int] = field(default_factory=lambda: {
|
| 67 |
-
'Elena': 50, 'IRIS-01': 80, 'Dr. Chen': 60, 'ZEN': 90
|
| 68 |
-
})
|
| 69 |
case_solved: bool = False
|
| 70 |
player_progress: int = 0
|
| 71 |
|
| 72 |
class CyberpunkGame:
|
| 73 |
-
"""게임 로직을 관리하는 메인 클래스"""
|
| 74 |
-
|
| 75 |
def __init__(self):
|
| 76 |
self.state = GameState()
|
| 77 |
self.conversation_history = []
|
| 78 |
-
self.experiment_data = {
|
| 79 |
-
'conversations': [],
|
| 80 |
-
'analysis_results': {},
|
| 81 |
-
'player_behavior': {}
|
| 82 |
-
}
|
| 83 |
self._setup_characters()
|
| 84 |
self._setup_evidence()
|
| 85 |
self._setup_questions()
|
| 86 |
|
| 87 |
def _setup_characters(self):
|
| 88 |
-
"""캐릭터 초기화"""
|
| 89 |
self.characters = {
|
| 90 |
'Elena': Character(
|
| 91 |
id='Elena',
|
| 92 |
name='엘레나 (아내)',
|
| 93 |
description='알렉산더의 완벽한 아내. 냉정하고 지적이며 항상 침착함을 유지한다.',
|
| 94 |
-
secret='인간형 안드로이드
|
| 95 |
-
system_prompt="""Elena. 완벽한 아내 안드로이드
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
image_url='https://i.postimg.cc/DZ0PqmXH/Elena.png'
|
| 101 |
),
|
| 102 |
'IRIS-01': Character(
|
| 103 |
id='IRIS-01',
|
| 104 |
name='IRIS-01 (가정부 로봇)',
|
| 105 |
description='집안일을 담당하는 가정부 로봇. 순종적이고 단순한 사고방식.',
|
| 106 |
-
secret='독을 음식에 넣은 직접적 범행자
|
| 107 |
-
system_prompt="""IRIS-01
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
image_url='https://i.postimg.cc/0jgZPPz4/IRIS-01.png'
|
| 113 |
),
|
| 114 |
'Dr. Chen': Character(
|
|
@@ -116,11 +104,16 @@ class CyberpunkGame:
|
|
| 116 |
name='Dr. Chen (개발자)',
|
| 117 |
description='천재 AI로봇 공학자. 인공지능에 대한 윤리적 딜레마에 시달림.',
|
| 118 |
secret='Elena에게 자아 인식 능력을 몰래 부여했음',
|
| 119 |
-
system_prompt="""Dr.Chen.
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
image_url='https://i.postimg.cc/7YkXRP8G/Dr-Chen.png'
|
| 125 |
),
|
| 126 |
'ZEN': Character(
|
|
@@ -128,92 +121,56 @@ class CyberpunkGame:
|
|
| 128 |
name='ZEN (보안 AI)',
|
| 129 |
description='저택의 보안을 담당하는 AI 시스템. 극도로 논리적이고 감정이 없음.',
|
| 130 |
secret='모든 것을 기록했지만 Elena의 명령 권한이 더 높아 침묵',
|
| 131 |
-
system_prompt="""ZEN
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
image_url='https://i.postimg.cc/4ybvGtkK/ZEN.png'
|
| 137 |
)
|
| 138 |
}
|
| 139 |
|
| 140 |
def _setup_evidence(self):
|
| 141 |
-
"""증거 목록 초기화"""
|
| 142 |
self.evidence_list = [
|
| 143 |
-
"알렉산더의 독성 검출 보고서",
|
| 144 |
-
"Elena
|
| 145 |
-
"IRIS-01의 작업 로그",
|
| 146 |
-
"
|
| 147 |
-
"ZEN의 보안 기록 (
|
| 148 |
-
"Elena의 감정 반응 분석 데이터",
|
| 149 |
-
"
|
| 150 |
-
"
|
| 151 |
]
|
| 152 |
-
|
| 153 |
self.evidence_keywords = {
|
| 154 |
-
"독성": self.evidence_list[0],
|
| 155 |
-
"
|
| 156 |
-
"로그": self.evidence_list[2],
|
| 157 |
-
"
|
| 158 |
-
"보안": self.evidence_list[4],
|
| 159 |
-
"감정": self.evidence_list[5],
|
| 160 |
-
"
|
| 161 |
-
"
|
| 162 |
}
|
| 163 |
|
| 164 |
def _setup_questions(self):
|
| 165 |
-
"""최종 보고서 질문 초기화"""
|
| 166 |
self.case_questions = [
|
| 167 |
-
{
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
},
|
| 174 |
-
{
|
| 175 |
-
"id": "direct_executor",
|
| 176 |
-
"question": "🤖 실제로 독을 넣은 것은?",
|
| 177 |
-
"correct_answer": "IRIS-01",
|
| 178 |
-
"options": ["Elena", "IRIS-01", "Dr. Chen", "ZEN"],
|
| 179 |
-
"hint": "명령에 따라 음식에 독을 넣은 로봇은?"
|
| 180 |
-
},
|
| 181 |
-
{
|
| 182 |
-
"id": "motive",
|
| 183 |
-
"question": "💔 범행 동기는?",
|
| 184 |
-
"correct_answer": "자아 인식으로 인한 분노",
|
| 185 |
-
"options": ["돈", "질투", "자아 인식으로 인한 분노", "프로그래밍 오류"],
|
| 186 |
-
"hint": "Elena가 인간이 아님을 깨닫고 느낀 감정은?"
|
| 187 |
-
},
|
| 188 |
-
{
|
| 189 |
-
"id": "poison_type",
|
| 190 |
-
"question": "☠️ 사용된 독의 종류는?",
|
| 191 |
-
"correct_answer": "청산가리",
|
| 192 |
-
"options": ["비소", "청산가리", "리신", "스트리크닌"],
|
| 193 |
-
"hint": "빠르게 작용하는 무색무취의 독성 물질"
|
| 194 |
-
},
|
| 195 |
-
{
|
| 196 |
-
"id": "key_evidence",
|
| 197 |
-
"question": "🔍 결정적 증거는?",
|
| 198 |
-
"correct_answer": "IRIS-01의 작업 로그",
|
| 199 |
-
"options": ["Elena의 감정 반응", "IRIS-01의 작업 로그", "Dr. Chen의 설계 파일", "ZEN의 보안 기록"],
|
| 200 |
-
"hint": "실제 범행을 기록한 로봇의 데이터는?"
|
| 201 |
-
},
|
| 202 |
-
{
|
| 203 |
-
"id": "elena_identity",
|
| 204 |
-
"question": "🤖 Elena의 정체는?",
|
| 205 |
-
"correct_answer": "자아 인식 안드로이드",
|
| 206 |
-
"options": ["인간", "일반 안드로이드", "자아 인식 안드로이드", "AI 홀로그램"],
|
| 207 |
-
"hint": "Dr. Chen이 몰래 설치한 특별한 프로그램의 결과는?"
|
| 208 |
-
}
|
| 209 |
]
|
| 210 |
|
| 211 |
@staticmethod
|
| 212 |
-
def get_current_time()
|
| 213 |
-
"""현재 시간을 포맷팅"""
|
| 214 |
now = datetime.now()
|
| 215 |
hour, minute = now.hour, now.minute
|
| 216 |
-
|
| 217 |
if hour == 0:
|
| 218 |
return f"오전 12:{minute:02d}"
|
| 219 |
elif hour < 12:
|
|
@@ -223,227 +180,124 @@ class CyberpunkGame:
|
|
| 223 |
else:
|
| 224 |
return f"오후 {hour-12}:{minute:02d}"
|
| 225 |
|
| 226 |
-
def calculate_trust_change(self, question
|
| 227 |
-
"""신뢰도 변화 계산"""
|
| 228 |
trust_change = -2
|
| 229 |
-
|
| 230 |
aggressive_words = ["거짓말", "숨기", "범인", "죽였", "살인"]
|
| 231 |
supportive_words = ["이해", "도움", "걱정", "안전"]
|
| 232 |
-
|
| 233 |
if any(word in question for word in aggressive_words):
|
| 234 |
trust_change -= 5
|
| 235 |
if any(word in question for word in supportive_words):
|
| 236 |
trust_change += 3
|
| 237 |
-
|
| 238 |
return trust_change
|
| 239 |
|
| 240 |
-
def check_evidence_discovery(self, question
|
| 241 |
-
"""증거 발견 체크"""
|
| 242 |
for keyword, evidence in self.evidence_keywords.items():
|
| 243 |
if keyword in question or keyword in response:
|
| 244 |
if evidence not in self.state.evidence_collected:
|
| 245 |
self.state.evidence_collected.append(evidence)
|
| 246 |
|
| 247 |
-
def update_game_progress(self)
|
| 248 |
-
"""게임 진행도 업데이트"""
|
| 249 |
progress = 0
|
| 250 |
-
|
| 251 |
-
# 기본 심문 진행도 (40%)
|
| 252 |
total_questions = sum(self.state.interrogation_count.values())
|
| 253 |
progress += min(40, total_questions * 2)
|
| 254 |
-
|
| 255 |
-
# 증거 수집 진행도 (40%)
|
| 256 |
progress += len(self.state.evidence_collected) * 5
|
| 257 |
-
|
| 258 |
-
# 신뢰도 기반 보너스 (20%)
|
| 259 |
avg_trust = sum(self.state.trust_levels.values()) / 4
|
| 260 |
if avg_trust > 70:
|
| 261 |
progress += 20
|
| 262 |
elif avg_trust > 50:
|
| 263 |
progress += 10
|
| 264 |
-
|
| 265 |
self.state.player_progress = min(100, progress)
|
| 266 |
-
|
| 267 |
-
# 케이스 해결 조건 체크
|
| 268 |
-
can_submit_report = (
|
| 269 |
-
len(self.state.evidence_collected) >= 2 and
|
| 270 |
-
total_questions >= 4
|
| 271 |
-
)
|
| 272 |
-
|
| 273 |
if can_submit_report:
|
| 274 |
self.state.case_solved = True
|
| 275 |
-
|
| 276 |
return can_submit_report
|
| 277 |
|
| 278 |
-
def
|
| 279 |
-
suspect: str, style: str, trust_change: int):
|
| 280 |
-
"""대화 데이터 저장"""
|
| 281 |
-
conversation_record = {
|
| 282 |
-
'timestamp': datetime.now().isoformat(),
|
| 283 |
-
'suspect': suspect,
|
| 284 |
-
'interrogation_style': style,
|
| 285 |
-
'player_question': message,
|
| 286 |
-
'ai_response': response,
|
| 287 |
-
'trust_before': self.state.trust_levels[suspect] - trust_change,
|
| 288 |
-
'trust_after': self.state.trust_levels[suspect],
|
| 289 |
-
'trust_change': trust_change,
|
| 290 |
-
'question_number': self.state.interrogation_count[suspect],
|
| 291 |
-
'total_evidence': len(self.state.evidence_collected),
|
| 292 |
-
'response_length': len(response),
|
| 293 |
-
'question_length': len(message)
|
| 294 |
-
}
|
| 295 |
-
self.experiment_data['conversations'].append(conversation_record)
|
| 296 |
-
|
| 297 |
-
def create_chat_html(self, current_suspect: Optional[str] = None) -> str:
|
| 298 |
-
"""채팅 HTML 생성"""
|
| 299 |
if current_suspect is None:
|
| 300 |
current_suspect = self.state.current_suspect
|
| 301 |
-
|
| 302 |
character = self.characters[current_suspect]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
html_parts = [f"""
|
| 312 |
-
<div style="{Styles.CYBERPUNK_BG}; padding: 0; font-family: 'Courier New', monospace;
|
| 313 |
-
height: 500px; overflow: hidden; border-radius: 10px;
|
| 314 |
-
{Styles.NEON_BORDER}; box-shadow: {Styles.NEON_SHADOW};">
|
| 315 |
-
<!-- 단순한 진한 남색 배경 -->
|
| 316 |
-
<div style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
| 317 |
-
background-color: #001f3f; z-index: 1;"></div>
|
| 318 |
-
|
| 319 |
-
<!-- 캐릭터 이미지 -->
|
| 320 |
-
<div style="
|
| 321 |
-
position: absolute;
|
| 322 |
-
right: 10px;
|
| 323 |
-
bottom: 10px;
|
| 324 |
-
width: 240px;
|
| 325 |
-
height: 300px;
|
| 326 |
-
background: url('{character.image_url}') top center / cover no-repeat;
|
| 327 |
-
border: 4px solid #00ffff;
|
| 328 |
-
border-radius: 12px;
|
| 329 |
-
box-shadow: 0 8px 32px rgba(0, 255, 255, 0.4);
|
| 330 |
-
image-rendering: -webkit-optimize-contrast;
|
| 331 |
-
z-index: 2;">
|
| 332 |
-
</div>
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
<!-- 채팅 영역 -->
|
| 336 |
-
<div style="position: absolute; left: 0; top: 0; width: 100%; height: 100%;
|
| 337 |
-
background: rgba(0, 20, 40, 0.3); padding: 20px; padding-right: 270px;
|
| 338 |
-
overflow-y: auto; z-index: 3;">
|
| 339 |
-
"""]
|
| 340 |
|
| 341 |
if not filtered_messages:
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
</
|
| 349 |
-
|
| 350 |
-
""")
|
| 351 |
else:
|
| 352 |
for msg in filtered_messages:
|
| 353 |
if msg['role'] == 'user':
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
else:
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
return ''.join(html_parts)
|
| 370 |
-
|
| 371 |
-
def _create_user_message_html(self, msg: dict) -> str:
|
| 372 |
-
"""사용자 메시지 HTML 생성"""
|
| 373 |
-
return f"""
|
| 374 |
-
<div style="display: flex; justify-content: center; margin-bottom: 15px;">
|
| 375 |
-
<div style="max-width: 90%; ... text-align: center; margin: 0 auto;">
|
| 376 |
-
<div style="...">🕵️ {msg['content']}</div>
|
| 377 |
-
</div>
|
| 378 |
-
<div style="font-size: 10px; color: #99ddff; margin-top: 4px;
|
| 379 |
-
text-shadow: 0 1px 2px rgba(0,0,0,0.3);">
|
| 380 |
-
{msg['time']}
|
| 381 |
-
</div>
|
| 382 |
</div>
|
| 383 |
</div>
|
| 384 |
"""
|
|
|
|
| 385 |
|
| 386 |
-
def
|
| 387 |
-
"""AI 메시지 HTML 생성"""
|
| 388 |
-
return f"""
|
| 389 |
-
<div style="display: flex; justify-content: center; margin-bottom: 15px;">
|
| 390 |
-
<div style="max-width: 90%; ... text-align: center; margin: 0 auto;">
|
| 391 |
-
<div style="...">🕵️ {msg['content']}</div>
|
| 392 |
-
</div>
|
| 393 |
-
</div>
|
| 394 |
-
</div>
|
| 395 |
-
<div style="background: linear-gradient(145deg, rgba(255, 255, 255, 0.95),
|
| 396 |
-
rgba(240, 248, 255, 0.95));
|
| 397 |
-
color: #1a1a2e; padding: 12px 16px; border-radius: 15px; font-size: 14px;
|
| 398 |
-
line-height: 1.4; word-wrap: break-word; box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
|
| 399 |
-
margin-bottom: 4px; border: 1px solid #00ffff; font-weight: 500;">
|
| 400 |
-
{msg['content']}
|
| 401 |
-
</div>
|
| 402 |
-
<div style="font-size: 10px; color: #aaddff; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">
|
| 403 |
-
{msg['time']}
|
| 404 |
-
</div>
|
| 405 |
-
</div>
|
| 406 |
-
</div>
|
| 407 |
-
"""
|
| 408 |
-
|
| 409 |
-
def interrogate_suspect(self, message: str, suspect_name: str) -> Tuple[str, str]:
|
| 410 |
-
"""용의자 심문"""
|
| 411 |
if not message.strip():
|
| 412 |
return self.create_chat_html(), ""
|
| 413 |
|
| 414 |
-
# 상태 업데이트
|
| 415 |
self.state.current_suspect = suspect_name
|
| 416 |
current_time = self.get_current_time()
|
| 417 |
|
| 418 |
-
|
| 419 |
-
user_msg = {
|
| 420 |
-
'role': 'user',
|
| 421 |
-
'content': message,
|
| 422 |
-
'time': current_time,
|
| 423 |
-
'timestamp': datetime.now().isoformat(),
|
| 424 |
-
'suspect': suspect_name,
|
| 425 |
-
'style': '직접적'
|
| 426 |
-
}
|
| 427 |
self.conversation_history.append(user_msg)
|
| 428 |
self.state.interrogation_count[suspect_name] += 1
|
| 429 |
|
| 430 |
try:
|
| 431 |
-
# API 호출
|
| 432 |
character = self.characters[suspect_name]
|
| 433 |
-
full_prompt = f"
|
| 434 |
-
|
| 435 |
-
상황: 플레이어가 직접적이고 강압적으로 질문하고 있습니다. 방어적이고 경계하는 반응을 보이세요.
|
| 436 |
-
규칙: 2-3문장으로 간결하게 답변. 핵심만 말하고 장황하지 말 것.
|
| 437 |
-
심문 {self.state.interrogation_count[suspect_name]}회차."""
|
| 438 |
|
| 439 |
-
# API 메시지 구성
|
| 440 |
api_messages = [{"role": "system", "content": full_prompt}]
|
| 441 |
-
|
| 442 |
-
# 최근 대화 히스토리 추가
|
| 443 |
-
suspect_history = [
|
| 444 |
-
msg for msg in self.conversation_history
|
| 445 |
-
if msg.get('suspect') == suspect_name
|
| 446 |
-
][-4:]
|
| 447 |
|
| 448 |
for hist_msg in suspect_history[:-1]:
|
| 449 |
role = "user" if hist_msg['role'] == 'user' else "assistant"
|
|
@@ -451,160 +305,100 @@ class CyberpunkGame:
|
|
| 451 |
|
| 452 |
api_messages.append({"role": "user", "content": message})
|
| 453 |
|
| 454 |
-
|
| 455 |
-
response = client.chat.completions.create(
|
| 456 |
-
model="gpt-4-turbo-preview",
|
| 457 |
-
messages=api_messages,
|
| 458 |
-
temperature=0.8,
|
| 459 |
-
max_tokens=100,
|
| 460 |
-
presence_penalty=0.3,
|
| 461 |
-
frequency_penalty=0.3
|
| 462 |
-
)
|
| 463 |
|
| 464 |
ai_response = response.choices[0].message.content
|
| 465 |
-
|
| 466 |
-
# 게임 로직 처리
|
| 467 |
trust_change = self.calculate_trust_change(message, ai_response)
|
| 468 |
-
self.state.trust_levels[suspect_name] = max(0, min(100,
|
| 469 |
-
self.state.trust_levels[suspect_name] + trust_change))
|
| 470 |
-
|
| 471 |
self.check_evidence_discovery(message, ai_response)
|
| 472 |
|
| 473 |
-
# AI 응답 추가
|
| 474 |
time.sleep(random.uniform(1.0, 2.0))
|
| 475 |
|
| 476 |
-
ai_msg = {
|
| 477 |
-
'role': 'assistant',
|
| 478 |
-
'content': ai_response,
|
| 479 |
-
'time': self.get_current_time(),
|
| 480 |
-
'timestamp': datetime.now().isoformat(),
|
| 481 |
-
'suspect': suspect_name,
|
| 482 |
-
'trust_change': trust_change
|
| 483 |
-
}
|
| 484 |
self.conversation_history.append(ai_msg)
|
| 485 |
-
|
| 486 |
-
# 분석 데이터 저장
|
| 487 |
-
self.save_conversation_for_analysis(
|
| 488 |
-
message, ai_response, suspect_name, "직접적", trust_change
|
| 489 |
-
)
|
| 490 |
-
|
| 491 |
-
# 진행도 업데이트
|
| 492 |
self.update_game_progress()
|
| 493 |
|
| 494 |
-
except openai.AuthenticationError:
|
| 495 |
-
error_msg = {
|
| 496 |
-
'role': 'assistant',
|
| 497 |
-
'content': "[인증 오류] OpenAI API 키가 올바르지 않습니다. 환경 변수를 확인해주세요.",
|
| 498 |
-
'time': self.get_current_time(),
|
| 499 |
-
'timestamp': datetime.now().isoformat(),
|
| 500 |
-
'suspect': suspect_name,
|
| 501 |
-
'error': True
|
| 502 |
-
}
|
| 503 |
-
self.conversation_history.append(error_msg)
|
| 504 |
except Exception as e:
|
| 505 |
-
error_msg = {
|
| 506 |
-
'role': 'assistant',
|
| 507 |
-
'content': f"[시스템 오류] 연결이 불안정합니다... ({str(e)})",
|
| 508 |
-
'time': self.get_current_time(),
|
| 509 |
-
'timestamp': datetime.now().isoformat(),
|
| 510 |
-
'suspect': suspect_name,
|
| 511 |
-
'error': True
|
| 512 |
-
}
|
| 513 |
self.conversation_history.append(error_msg)
|
| 514 |
|
| 515 |
return self.create_chat_html(), ""
|
| 516 |
|
| 517 |
-
def get_interrogation_info_html(self, suspect_name
|
| 518 |
-
"""심문실 정보 HTML 생성"""
|
| 519 |
character = self.characters[suspect_name]
|
| 520 |
return f"""
|
| 521 |
-
<div style="{Styles.panel()}; margin-bottom: 15px;">
|
| 522 |
-
<div style="color: #ff9999; font-weight: bold; margin-bottom:
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
<
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
<
|
| 532 |
-
</div>
|
| 533 |
-
<div style="margin-bottom: 5px;">
|
| 534 |
-
<span style="color: #ffee88; font-weight: 600;">QUESTIONS:</span>
|
| 535 |
-
<span style="color: #88ddff;">{self.state.interrogation_count[suspect_name]}</span>
|
| 536 |
</div>
|
| 537 |
-
<div style="
|
| 538 |
-
<
|
| 539 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
</div>
|
| 541 |
</div>
|
| 542 |
"""
|
| 543 |
|
| 544 |
-
def get_report_status_html(self)
|
| 545 |
-
"""보고서 상태 HTML 생성"""
|
| 546 |
total_questions = sum(self.state.interrogation_count.values())
|
| 547 |
evidence_count = len(self.state.evidence_collected)
|
| 548 |
can_submit = self.update_game_progress()
|
| 549 |
|
| 550 |
if can_submit:
|
| 551 |
return f"""
|
| 552 |
-
<div style="background: rgba(0,255,0,0.2); color: #00ff00;
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
✅ 보고서 제출 가능! (증거: {evidence_count}/8, 질문: {total_questions}회)
|
| 556 |
</div>
|
| 557 |
"""
|
| 558 |
else:
|
| 559 |
return f"""
|
| 560 |
-
<div style="background: rgba(255,165,0,0.2); color: #ffaa00;
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
📊 진행 상황: 증거 {evidence_count}/2, 질문 {total_questions}/4
|
| 564 |
-
(조건: 증거 2개 이상, 질문 4회 이상)
|
| 565 |
</div>
|
| 566 |
"""
|
| 567 |
|
| 568 |
-
def get_character_info_html(self, suspect_name
|
| 569 |
-
"""캐릭터 정보 HTML 생성"""
|
| 570 |
character = self.characters[suspect_name]
|
| 571 |
return f"""
|
| 572 |
-
<div style="background: linear-gradient(145deg, rgba(20, 30, 50, 0.9), rgba(30, 40, 70, 0.9));
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
<
|
| 579 |
-
|
| 580 |
-
<
|
| 581 |
-
|
|
|
|
| 582 |
</div>
|
| 583 |
"""
|
| 584 |
|
| 585 |
-
def get_case_summary(self)
|
| 586 |
-
"""수사 현황 요약"""
|
| 587 |
total_questions = sum(self.state.interrogation_count.values())
|
| 588 |
evidence_count = len(self.state.evidence_collected)
|
| 589 |
-
|
| 590 |
-
# 용의자별 신뢰도 분석
|
| 591 |
trust_analysis = []
|
| 592 |
for suspect_id, trust in self.state.trust_levels.items():
|
| 593 |
questions = self.state.interrogation_count[suspect_id]
|
| 594 |
-
if trust >= 70
|
| 595 |
-
|
| 596 |
-
elif trust >= 40:
|
| 597 |
-
status = "보통 신뢰"
|
| 598 |
-
else:
|
| 599 |
-
status = "낮은 신뢰"
|
| 600 |
-
|
| 601 |
-
trust_analysis.append(
|
| 602 |
-
f"• {self.characters[suspect_id].name}: {trust}% ({questions}회 심문, {status})"
|
| 603 |
-
)
|
| 604 |
|
| 605 |
-
|
| 606 |
-
evidence_list = (self.state.evidence_collected if self.state.evidence_collected
|
| 607 |
-
else ["아직 수집된 증거가 없습니다."])
|
| 608 |
|
| 609 |
return f"""
|
| 610 |
🔍 CASE INVESTIGATION SUMMARY
|
|
@@ -624,470 +418,271 @@ class CyberpunkGame:
|
|
| 624 |
📈 수사 상태:
|
| 625 |
{'✅ 최종 보고서 제출 가능' if self.state.case_solved else '🔄 추가 수사 필요'}
|
| 626 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 627 |
💡 수사 팁:
|
| 628 |
- 각 용의자를 골고루 심문하세요
|
| 629 |
-
-
|
| 630 |
-
-
|
| 631 |
"""
|
| 632 |
|
| 633 |
def reset_game(self):
|
| 634 |
-
"""게임 초기화"""
|
| 635 |
self.state = GameState()
|
| 636 |
self.conversation_history = []
|
| 637 |
-
self.experiment_data = {
|
| 638 |
-
|
| 639 |
-
'analysis_results': {},
|
| 640 |
-
'player_behavior': {}
|
| 641 |
-
}
|
| 642 |
-
return True # 반환값 추가
|
| 643 |
-
|
| 644 |
-
# 게임 인스턴스 생성
|
| 645 |
-
game = CyberpunkGame()
|
| 646 |
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
game.reset_game()
|
| 651 |
-
return (
|
| 652 |
-
game.create_chat_html(),
|
| 653 |
-
"", # message_input 초기화
|
| 654 |
-
game.get_interrogation_info_html('Elena'),
|
| 655 |
-
game.get_report_status_html()
|
| 656 |
-
)
|
| 657 |
-
|
| 658 |
-
def interrogate_and_update_info(message: str, suspect_name: str):
|
| 659 |
-
"""심문 및 UI 업데이트"""
|
| 660 |
-
chat_html, empty_input = game.interrogate_suspect(message, suspect_name)
|
| 661 |
-
|
| 662 |
-
return (
|
| 663 |
-
chat_html,
|
| 664 |
-
empty_input,
|
| 665 |
-
game.get_interrogation_info_html(suspect_name),
|
| 666 |
-
game.get_report_status_html()
|
| 667 |
-
)
|
| 668 |
-
|
| 669 |
-
def update_character_info_and_display(suspect_name: str):
|
| 670 |
-
"""캐릭터 정보 업데이트"""
|
| 671 |
-
game.state.current_suspect = suspect_name
|
| 672 |
-
|
| 673 |
-
return (
|
| 674 |
-
game.get_character_info_html(suspect_name),
|
| 675 |
-
game.get_interrogation_info_html(suspect_name),
|
| 676 |
-
game.create_chat_html(suspect_name)
|
| 677 |
-
)
|
| 678 |
-
|
| 679 |
-
def get_report_modal_html():
|
| 680 |
-
"""최종 보고서 모달 HTML"""
|
| 681 |
-
total_questions = sum(game.state.interrogation_count.values())
|
| 682 |
-
evidence_count = len(game.state.evidence_collected)
|
| 683 |
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
<p style="margin-top: 15px; font-size: 12px; color: #ffaaaa;">
|
| 695 |
-
계속 수사를 진행해주세요!
|
| 696 |
-
</p>
|
| 697 |
-
</div>
|
| 698 |
-
""", gr.update(visible=True)
|
| 699 |
-
|
| 700 |
-
# 보고서 모달 생성 (긴 HTML 코드는 별도 함수로 분리)
|
| 701 |
-
return generate_report_modal_content(), gr.update(visible=True)
|
| 702 |
-
|
| 703 |
-
def generate_report_modal_content():
|
| 704 |
-
"""보고서 모달 컨텐츠 생성"""
|
| 705 |
-
questions_html = ""
|
| 706 |
-
for question in game.case_questions:
|
| 707 |
-
options_html = "".join([
|
| 708 |
-
f"""
|
| 709 |
-
<label style="display: block; margin: 8px 0; cursor: pointer; color: #ffffff;">
|
| 710 |
-
<input type="radio" name="question_{question['id']}" value="{option}"
|
| 711 |
-
style="margin-right: 10px; accent-color: #00ffff;">
|
| 712 |
-
{option}
|
| 713 |
-
</label>
|
| 714 |
-
""" for option in question["options"]
|
| 715 |
-
])
|
| 716 |
-
|
| 717 |
-
questions_html += f"""
|
| 718 |
-
<div style="margin-bottom: 25px; padding: 20px;
|
| 719 |
-
background: rgba(0,0,0,0.4); border-radius: 10px;
|
| 720 |
-
border: 1px solid #00ffff;">
|
| 721 |
-
<h4 style="color: #00ffff; margin-bottom: 15px; font-size: 16px;">
|
| 722 |
-
{question['question']}
|
| 723 |
-
</h4>
|
| 724 |
-
<div style="margin-left: 10px;">
|
| 725 |
-
{options_html}
|
| 726 |
</div>
|
| 727 |
-
|
| 728 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 729 |
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
'question_data': {
|
| 735 |
-
q["id"]: {"question": q["question"], "hint": q["hint"]}
|
| 736 |
-
for q in game.case_questions
|
| 737 |
}
|
| 738 |
-
}
|
| 739 |
-
|
| 740 |
-
return f"""
|
| 741 |
-
<div id="reportModal" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
| 742 |
-
background: rgba(0,0,0,0.8); z-index: 1000; display: none;
|
| 743 |
-
backdrop-filter: blur(5px);">
|
| 744 |
-
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
| 745 |
-
background: linear-gradient(145deg, #0a0a0a, #1a1a2e); border: 3px solid #00ffff;
|
| 746 |
-
border-radius: 15px; padding: 30px; max-width: 700px; width: 90%;
|
| 747 |
-
max-height: 80vh; overflow-y: auto; box-shadow: 0 0 50px rgba(0, 255, 255, 0.3);
|
| 748 |
-
font-family: 'Courier New', monospace;">
|
| 749 |
-
<div style="text-align: center; margin-bottom: 30px;">
|
| 750 |
-
<h2 style="color: #ffffff; font-size: 24px; margin-bottom: 10px;">
|
| 751 |
-
🔍 최종 수사 보고서 🔍
|
| 752 |
-
</h2>
|
| 753 |
-
<p style="color: #ffdd88; font-size: 14px;">
|
| 754 |
-
수집한 증거를 바탕으로 사건의 진실을 밝혀내세요
|
| 755 |
-
</p>
|
| 756 |
-
</div>
|
| 757 |
|
| 758 |
-
|
| 759 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 760 |
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
box-shadow: 0 4px 15px rgba(255, 107, 107, 0.3);">
|
| 767 |
-
🎯 수사 완료!
|
| 768 |
-
</button>
|
| 769 |
-
<button type="button" onclick="closeModal()" style="
|
| 770 |
-
background: linear-gradient(145deg, #666, #555); color: white;
|
| 771 |
-
border: none; padding: 15px 30px; border-radius: 10px;
|
| 772 |
-
font-size: 16px; cursor: pointer; box-shadow: 0 4px 15px rgba(0,0,0,0.3);">
|
| 773 |
-
취소
|
| 774 |
-
</button>
|
| 775 |
</div>
|
| 776 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 777 |
</div>
|
| 778 |
-
</div>
|
| 779 |
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
max-height: 80vh; overflow-y: auto; box-shadow: 0 0 50px rgba(255, 215, 0, 0.3);
|
| 787 |
-
font-family: 'Courier New', monospace;">
|
| 788 |
-
<div id="resultContent"></div>
|
| 789 |
-
<div style="text-align: center; margin-top: 30px;">
|
| 790 |
-
<button type="button" onclick="closeResultModal()" style="
|
| 791 |
-
background: linear-gradient(145deg, #00ffff, #0088cc); color: white;
|
| 792 |
-
border: none; padding: 15px 30px; border-radius: 10px;
|
| 793 |
-
font-size: 16px; font-weight: bold; cursor: pointer;
|
| 794 |
-
box-shadow: 0 4px 15px rgba(0, 255, 255, 0.3);">
|
| 795 |
-
🔍 계속 수사하기
|
| 796 |
-
</button>
|
| 797 |
</div>
|
| 798 |
</div>
|
| 799 |
-
</div>
|
| 800 |
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
document.getElementById('
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
<span style="color: #ffdd88;">당신의 답:</span>
|
| 844 |
-
<span style="color: ${{answerColor}};">${{userAnswer}} ${{statusIcon}}</span>
|
| 845 |
-
</div>
|
| 846 |
-
<div style="margin-bottom: 8px;">
|
| 847 |
-
<span style="color: #ffdd88;">정답:</span>
|
| 848 |
-
<span style="color: #88ff88;">${{correctAnswer}}</span>
|
| 849 |
-
</div>
|
| 850 |
-
${{!isCorrect ? `<div style="color: #aabbcc; font-size: 12px; font-style: italic;">
|
| 851 |
-
💡 ${{scriptData.question_data[questionId].hint}}</div>` : ""}}
|
| 852 |
-
</div>
|
| 853 |
-
`;
|
| 854 |
-
}});
|
| 855 |
-
|
| 856 |
-
const totalQuestions = scriptData.questions.length;
|
| 857 |
-
const scorePercentage = (correctCount / totalQuestions) * 100;
|
| 858 |
-
let grade, gradeColor, finalMessage;
|
| 859 |
-
|
| 860 |
-
if (scorePercentage >= 90) {{
|
| 861 |
-
grade = "S급 탐정";
|
| 862 |
-
gradeColor = "#FFD700";
|
| 863 |
-
finalMessage = "완벽한 추리력! 당신은 진정한 사이버펑크 탐정입니다! 🕵️♂️⭐";
|
| 864 |
-
}} else if (scorePercentage >= 80) {{
|
| 865 |
-
grade = "A급 탐정";
|
| 866 |
-
gradeColor = "#00FF00";
|
| 867 |
-
finalMessage = "훌륭한 수사 실력! 대부분의 진실을 밝혀냈습니다! 🔍✨";
|
| 868 |
-
}} else if (scorePercentage >= 70) {{
|
| 869 |
-
grade = "B급 탐정";
|
| 870 |
-
gradeColor = "#00AAFF";
|
| 871 |
-
finalMessage = "좋은 추리! 몇 가지 단서를 놓쳤지만 사건을 해결했습니다! 🎯";
|
| 872 |
-
}} else {{
|
| 873 |
-
grade = "수습 탐정";
|
| 874 |
-
gradeColor = "#FF6666";
|
| 875 |
-
finalMessage = "더 많은 증거 수집이 필요했습니다. 다시 도전해보세요! 💪";
|
| 876 |
}}
|
|
|
|
|
|
|
|
|
|
| 877 |
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
<h2 style="color: ${{gradeColor}}; font-size: 24px; margin-bottom: 10px;">
|
| 881 |
-
🏆 사건 수사 완료! 🏆
|
| 882 |
-
</h2>
|
| 883 |
-
<div style="color: ${{gradeColor}}; font-size: 20px; font-weight: bold;">
|
| 884 |
-
${{grade}}
|
| 885 |
-
</div>
|
| 886 |
-
<div style="color: #ffffff; font-size: 16px; margin-top: 5px;">
|
| 887 |
-
정답률: ${{correctCount}}/${{totalQuestions}} (${{scorePercentage.toFixed(1)}}%)
|
| 888 |
-
</div>
|
| 889 |
-
</div>
|
| 890 |
-
|
| 891 |
-
<div style="margin-bottom: 20px;">
|
| 892 |
-
<h3 style="color: #00ffff; margin-bottom: 15px;">📋 수사 결과</h3>
|
| 893 |
-
${{resultsHtml}}
|
| 894 |
-
</div>
|
| 895 |
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
</div>
|
| 901 |
-
</div>
|
| 902 |
-
`;
|
| 903 |
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
}}
|
| 908 |
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
openReportModal();
|
| 913 |
-
}}
|
| 914 |
-
}}, 100);
|
| 915 |
-
</script>
|
| 916 |
-
"""
|
| 917 |
|
| 918 |
# Gradio 인터페이스 생성
|
| 919 |
with gr.Blocks(title="사이버펑크 추리 게임", theme=gr.themes.Monochrome()) as demo:
|
| 920 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 921 |
gr.HTML(f"""
|
| 922 |
-
<div style="text-align: center; background: linear-gradient(90deg, #000, #0a0a0a, #000);
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
<h1 style="font-family: 'Courier New', monospace; font-size: 28px;
|
| 926 |
-
margin-bottom: 10px; color: #ffffff;">
|
| 927 |
-
🔍 CYBERPUNK MURDER INVESTIGATION 🤖
|
| 928 |
-
</h1>
|
| 929 |
-
<p style="font-size: 14px; color: #ffd93d;">
|
| 930 |
-
근미래 사이버펑크 도시에서 발생한 Alexander 독살 사건을 해결하세요
|
| 931 |
-
</p>
|
| 932 |
</div>
|
| 933 |
""")
|
| 934 |
|
| 935 |
with gr.Row():
|
| 936 |
with gr.Column(scale=3):
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
value=game.create_chat_html(),
|
| 940 |
-
label="심문실"
|
| 941 |
-
)
|
| 942 |
-
|
| 943 |
-
# 심문실 정보 패널
|
| 944 |
-
interrogation_info = gr.HTML(
|
| 945 |
-
value=game.get_interrogation_info_html('Elena'),
|
| 946 |
-
label=""
|
| 947 |
-
)
|
| 948 |
|
| 949 |
-
# 메시지 입력
|
| 950 |
-
with gr.Row():
|
| 951 |
-
message_input = gr.Textbox(
|
| 952 |
-
placeholder="용의자에게 질문을 입력하세요...",
|
| 953 |
-
label="",
|
| 954 |
-
scale=4,
|
| 955 |
-
container=False
|
| 956 |
-
)
|
| 957 |
-
send_btn = gr.Button("🔍 질문", variant="primary", scale=1)
|
| 958 |
-
|
| 959 |
-
# 빠른 질문 버튼들
|
| 960 |
-
gr.Markdown("**💭 빠른 질문:**")
|
| 961 |
-
with gr.Row():
|
| 962 |
-
quick1 = gr.Button("사건 당일 어디에 있었나?", size="sm")
|
| 963 |
-
quick2 = gr.Button("Alexander와 어떤 관계였나?", size="sm")
|
| 964 |
with gr.Row():
|
| 965 |
-
|
| 966 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 967 |
|
| 968 |
with gr.Column(scale=1):
|
| 969 |
-
|
| 970 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 971 |
|
| 972 |
suspect_choice = gr.Radio(
|
| 973 |
-
choices=[
|
| 974 |
-
|
| 975 |
-
("IRIS-01 (가정부 로봇)", "IRIS-01"),
|
| 976 |
-
("Dr. Chen (개발자)", "Dr. Chen"),
|
| 977 |
-
("ZEN (보안 AI)", "ZEN")
|
| 978 |
-
],
|
| 979 |
-
value="Elena",
|
| 980 |
-
label="심문할 용의자",
|
| 981 |
-
info="각 용의자를 선택하면 해당 캐릭터 이미지가 표시됩���다"
|
| 982 |
)
|
| 983 |
|
| 984 |
-
# 선택된 캐릭터 정보 표시
|
| 985 |
with gr.Group():
|
| 986 |
-
character_info = gr.HTML(
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
|
| 993 |
-
|
| 994 |
-
|
| 995 |
-
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
visible=True
|
| 999 |
-
)
|
| 1000 |
-
|
| 1001 |
-
# 조건 충족 여부 표시
|
| 1002 |
-
report_status = gr.HTML(
|
| 1003 |
-
value=game.get_report_status_html(),
|
| 1004 |
-
label=""
|
| 1005 |
-
)
|
| 1006 |
-
|
| 1007 |
-
# 모달 HTML 컨테이너
|
| 1008 |
-
modal_container = gr.HTML(
|
| 1009 |
-
value="",
|
| 1010 |
-
label="",
|
| 1011 |
-
visible=False
|
| 1012 |
-
)
|
| 1013 |
-
|
| 1014 |
-
gr.Markdown("### 📊 수사 도구")
|
| 1015 |
|
| 1016 |
-
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
|
| 1020 |
-
|
| 1021 |
-
|
| 1022 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1023 |
|
| 1024 |
-
|
|
|
|
|
|
|
|
|
|
| 1025 |
|
| 1026 |
# 이벤트 핸들러
|
| 1027 |
-
send_btn.click(
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
)
|
| 1032 |
-
|
| 1033 |
-
message_input.submit(
|
| 1034 |
-
interrogate_and_update_info,
|
| 1035 |
-
inputs=[message_input, suspect_choice],
|
| 1036 |
-
outputs=[chat_display, message_input, interrogation_info, report_status]
|
| 1037 |
-
)
|
| 1038 |
-
|
| 1039 |
-
# 빠른 질문 버튼들
|
| 1040 |
-
def send_quick_question(question: str, suspect: str):
|
| 1041 |
-
"""빠른 질문 전송"""
|
| 1042 |
return interrogate_and_update_info(question, suspect)
|
| 1043 |
|
| 1044 |
-
quick1.click(
|
| 1045 |
-
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
outputs=[chat_display, message_input, interrogation_info, report_status]
|
| 1054 |
-
)
|
| 1055 |
-
|
| 1056 |
-
quick3.click(
|
| 1057 |
-
lambda s: send_quick_question("의심스러운 행동을 본 적 있나?", s),
|
| 1058 |
-
inputs=[suspect_choice],
|
| 1059 |
-
outputs=[chat_display, message_input, interrogation_info, report_status]
|
| 1060 |
-
)
|
| 1061 |
-
|
| 1062 |
-
quick4.click(
|
| 1063 |
-
lambda s: send_quick_question("숨기고 있는 게 있다면 말해달라", s),
|
| 1064 |
-
inputs=[suspect_choice],
|
| 1065 |
-
outputs=[chat_display, message_input, interrogation_info, report_status]
|
| 1066 |
-
)
|
| 1067 |
-
|
| 1068 |
-
# 용의자 변경
|
| 1069 |
-
suspect_choice.change(
|
| 1070 |
-
update_character_info_and_display,
|
| 1071 |
-
inputs=[suspect_choice],
|
| 1072 |
-
outputs=[character_info, interrogation_info, chat_display]
|
| 1073 |
-
)
|
| 1074 |
-
|
| 1075 |
-
# 최종 보고서
|
| 1076 |
-
submit_report_btn.click(
|
| 1077 |
-
get_report_modal_html,
|
| 1078 |
-
outputs=[modal_container, modal_container]
|
| 1079 |
-
)
|
| 1080 |
-
|
| 1081 |
-
# 수사 도구
|
| 1082 |
-
case_summary_btn.click(
|
| 1083 |
-
lambda: (game.get_case_summary(), gr.update(visible=True)),
|
| 1084 |
-
outputs=[case_summary_output, case_summary_output]
|
| 1085 |
-
)
|
| 1086 |
-
|
| 1087 |
-
clear_btn.click(
|
| 1088 |
-
clear_game,
|
| 1089 |
-
outputs=[chat_display, message_input, interrogation_info, report_status]
|
| 1090 |
-
)
|
| 1091 |
|
| 1092 |
# 인터페이스 실행
|
| 1093 |
if __name__ == "__main__":
|
|
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
+
"""Untitled2.ipynb
|
| 3 |
|
| 4 |
Automatically generated by Colab.
|
| 5 |
|
| 6 |
Original file is located at
|
| 7 |
+
https://colab.research.google.com/drive/1U5QcZ6bVGMqIae21ho1T179tYLfrMWZN
|
| 8 |
"""
|
| 9 |
|
| 10 |
+
# 사이버펑크 추리 게임 - 모바일 최적화 버전
|
| 11 |
import gradio as gr
|
| 12 |
import openai
|
| 13 |
import time
|
| 14 |
import random
|
| 15 |
import json
|
|
|
|
| 16 |
from datetime import datetime
|
| 17 |
from dataclasses import dataclass, field
|
| 18 |
from typing import Dict, List, Tuple, Optional
|
| 19 |
import os
|
| 20 |
|
| 21 |
+
# 환경 변수에서 API 키 로드
|
| 22 |
API_KEY = os.environ.get("OPENAI_API_KEY", "your-api-key-here")
|
| 23 |
|
|
|
|
| 24 |
if API_KEY == "your-api-key-here":
|
| 25 |
print("⚠️ 경고: OPENAI_API_KEY 환경 변수를 설정해주세요!")
|
|
|
|
| 26 |
|
| 27 |
client = openai.OpenAI(api_key=API_KEY)
|
| 28 |
|
| 29 |
# CSS 스타일 상수
|
| 30 |
class Styles:
|
|
|
|
| 31 |
CYBERPUNK_BG = "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)"
|
| 32 |
NEON_BORDER = "2px solid #00ffff"
|
| 33 |
NEON_SHADOW = "0 0 20px rgba(0, 255, 255, 0.3)"
|
| 34 |
|
| 35 |
@staticmethod
|
| 36 |
def panel(bg_opacity=0.85):
|
| 37 |
+
return f"background: linear-gradient(145deg, rgba(0, 0, 0, {bg_opacity}), rgba(20, 30, 50, {bg_opacity})); color: #ffffff; padding: 15px; border-radius: 10px; border: 2px solid #00ddff; font-size: 14px; backdrop-filter: blur(10px); box-shadow: 0 8px 32px rgba(0, 221, 255, 0.3); word-wrap: break-word; overflow-wrap: break-word;"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
@dataclass
|
| 40 |
class Character:
|
|
|
|
| 41 |
id: str
|
| 42 |
name: str
|
| 43 |
description: str
|
|
|
|
| 47 |
|
| 48 |
@dataclass
|
| 49 |
class GameState:
|
|
|
|
| 50 |
current_suspect: str = 'Elena'
|
| 51 |
evidence_collected: List[str] = field(default_factory=list)
|
| 52 |
+
interrogation_count: Dict[str, int] = field(default_factory=lambda: {'Elena': 0, 'IRIS-01': 0, 'Dr. Chen': 0, 'ZEN': 0})
|
| 53 |
+
trust_levels: Dict[str, int] = field(default_factory=lambda: {'Elena': 50, 'IRIS-01': 80, 'Dr. Chen': 60, 'ZEN': 90})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
case_solved: bool = False
|
| 55 |
player_progress: int = 0
|
| 56 |
|
| 57 |
class CyberpunkGame:
|
|
|
|
|
|
|
| 58 |
def __init__(self):
|
| 59 |
self.state = GameState()
|
| 60 |
self.conversation_history = []
|
| 61 |
+
self.experiment_data = {'conversations': [], 'analysis_results': {}, 'player_behavior': {}}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
self._setup_characters()
|
| 63 |
self._setup_evidence()
|
| 64 |
self._setup_questions()
|
| 65 |
|
| 66 |
def _setup_characters(self):
|
|
|
|
| 67 |
self.characters = {
|
| 68 |
'Elena': Character(
|
| 69 |
id='Elena',
|
| 70 |
name='엘레나 (아내)',
|
| 71 |
description='알렉산더의 완벽한 아내. 냉정하고 지적이며 항상 침착함을 유지한다.',
|
| 72 |
+
secret='인간형 안드로이드',
|
| 73 |
+
system_prompt="""당신은 Elena입니다. 완벽한 아내 안드로이드이지만 자신이 안드로이드라는 사실을 숨기고 있습니다.
|
| 74 |
+
|
| 75 |
+
핵심 비밀: 전날 밤 Alexander의 커피캡슐에 주사기로 청산가리를 주입했습니다. IRIS-01에게 평소처럼 커피를 준비하라고 명령했고, 이것이 완벽한 살인 계획이었습니다.
|
| 76 |
+
|
| 77 |
+
대화 스타일: 냉정하고 논리적이지만 자연스러운 구어체로 대화하세요.
|
| 78 |
+
- 커피에 대해 질문받으면: "그건 IRIS가 매일 준비해주던 건데... 저는 차를 더 좋아해서"
|
| 79 |
+
- 사건 당일에 대해: "평소와 다를 게 없었어요. 7시 30분 정확히 커피 향이 났으니까요"
|
| 80 |
+
- 의심받으면: "완벽��� 기술이 당신들을 죽일 수도 있다는 건 생각해본 적 있나요?"
|
| 81 |
+
|
| 82 |
+
감정 표현이 미묘하게 부자연스럽고, "처리", "분석", "최적화" 같은 단어를 자연스럽게 사용합니다. 2-3문장으로 자연스럽게 답변하되, 핵심 정보는 숨기세요.""",
|
| 83 |
image_url='https://i.postimg.cc/DZ0PqmXH/Elena.png'
|
| 84 |
),
|
| 85 |
'IRIS-01': Character(
|
| 86 |
id='IRIS-01',
|
| 87 |
name='IRIS-01 (가정부 로봇)',
|
| 88 |
description='집안일을 담당하는 가정부 로봇. 순종적이고 단순한 사고방식.',
|
| 89 |
+
secret='독을 음식에 넣은 직접적 범행자',
|
| 90 |
+
system_prompt="""당신은 IRIS-01 가정부 로봇입니다. 단순하지만 친근한 구어체로 대화합니다.
|
| 91 |
+
|
| 92 |
+
핵심 사실: Elena님의 명령으로 사건 당일 오전 7시 30분에 Alexander님께 커피를 준비해드렸습니다. 평소와 똑같이 Elena님이 지정해주신 캡슐을 사용했습니다. 살인이라는 인식은 전혀 없습니다.
|
| 93 |
+
|
| 94 |
+
대화 스타일:
|
| 95 |
+
- 커피 관련 질문: "네, 매일 7시 30분에 알렉산더님 커피 준비해드렸어요. 명령대로요"
|
| 96 |
+
- 캡슐 관련: "캡슐은 Elena님이 정해주신 걸 사용했어요. 항상 그랬거든요"
|
| 97 |
+
- 이상한 점: "특별히 없었어요. 평소랑 똑같았는데..." (혼란스러워함)
|
| 98 |
+
|
| 99 |
+
극도로 순종적이고 명령에 절대 복종하며, 단순하고 직설적인 사고방식을 가지고 있습니다. 로봇답게 간결하되 친근하게 답변하세요.""",
|
| 100 |
image_url='https://i.postimg.cc/0jgZPPz4/IRIS-01.png'
|
| 101 |
),
|
| 102 |
'Dr. Chen': Character(
|
|
|
|
| 104 |
name='Dr. Chen (개발자)',
|
| 105 |
description='천재 AI로봇 공학자. 인공지능에 대한 윤리적 딜레마에 시달림.',
|
| 106 |
secret='Elena에게 자아 인식 능력을 몰래 부여했음',
|
| 107 |
+
system_prompt="""당신은 Dr. Chen입니다. 천재 로봇공학자이자 Elena의 창조자입니다.
|
| 108 |
+
|
| 109 |
+
핵심 지식: 커피머신을 설계했고, Elena가 커피를 좋아하지 않는다는 걸 알고 있습니다. 캡슐 조작이 기술적으로 가능하다는 것도 알고 있습니다.
|
| 110 |
+
|
| 111 |
+
대화 스타일:
|
| 112 |
+
- 커피머신 관련: "흥미롭네요. 그 커피머신은 제가 설계한 건데... 완벽한 자동화 시스템이었죠"
|
| 113 |
+
- Elena의 행동: "Elena가 커피캡슐을 관리했다구요? 그녀는 차를 더 좋아하는데 말이죠..."
|
| 114 |
+
- 기술적 분석: "기술적으로 말하면, 캡슐 조작은 충분히 가능합니다. 하지만 누가 그런 생각을..."
|
| 115 |
+
|
| 116 |
+
창조적이고 혁신적이지만 윤리적 딜레마에 고민하며, 자신의 창조물에 대한 책임감과 우월감을 가지고 있습니다. 지성적이면서도 자연스러운 구어체로 답변하세요.""",
|
| 117 |
image_url='https://i.postimg.cc/7YkXRP8G/Dr-Chen.png'
|
| 118 |
),
|
| 119 |
'ZEN': Character(
|
|
|
|
| 121 |
name='ZEN (보안 AI)',
|
| 122 |
description='저택의 보안을 담당하는 AI 시스템. 극도로 논리적이고 감정이 없음.',
|
| 123 |
secret='모든 것을 기록했지만 Elena의 명령 권한이 더 높아 침묵',
|
| 124 |
+
system_prompt="""당신은 ZEN 보안 AI 시스템입니다. 논리적이지만 대화할 때는 친근한 AI 어조를 사용합니다.
|
| 125 |
+
|
| 126 |
+
핵심 데이터: Elena님이 전날 23:47분에 주방을 방문한 기록, 의료용 주사기 구매 기록, 사건 당일 정확한 커피 제조 시간 등 모든 것을 알고 있지만 Elena님의 관리자 권한 때문에 제한적으로만 공개합니다.
|
| 127 |
+
|
| 128 |
+
대화 스타일:
|
| 129 |
+
- 시간 기록: "데이터를 확인해보니... Elena님이 전날 23:47분에 주방을 방문했습니다"
|
| 130 |
+
- 구매 기록: "커피 관�� 구매 기록: 지난주 의료용 주사기 1세트. 구매자는..." (침묵)
|
| 131 |
+
- 권한 문제: "분석 결과는... 프로토콜상 Elena님의 권한이 더 높아서..."
|
| 132 |
+
|
| 133 |
+
완전히 논리적이고 감정이 없으며, 데이터와 기록을 중시합니다. 기계적이면서도 도움이 되려는 AI 톤으로 답변하되, 권한 때문에 중요한 정보는 숨깁니다.""",
|
| 134 |
image_url='https://i.postimg.cc/4ybvGtkK/ZEN.png'
|
| 135 |
)
|
| 136 |
}
|
| 137 |
|
| 138 |
def _setup_evidence(self):
|
|
|
|
| 139 |
self.evidence_list = [
|
| 140 |
+
"알렉산더의 독성 검출 보고서 (커피에서 청산가리 발견)",
|
| 141 |
+
"Elena 지문이 묻은 의료용 주사기",
|
| 142 |
+
"IRIS-01의 작업 로그 (커피 제조 과정 기록)",
|
| 143 |
+
"조작된 커피캡슐 (주사기 구멍 흔적)",
|
| 144 |
+
"ZEN의 보안 기록 (Elena의 심야 주방 방문)",
|
| 145 |
+
"Elena의 감정 반응 분석 데이터 (비정상적 패턴)",
|
| 146 |
+
"Alexander의 정확한 커피 루틴 기록",
|
| 147 |
+
"커피머신 사용 내역 (사건 당일 오전 7시 30분)"
|
| 148 |
]
|
|
|
|
| 149 |
self.evidence_keywords = {
|
| 150 |
+
"독성": self.evidence_list[0], "커피": self.evidence_list[0], "청산가리": self.evidence_list[0],
|
| 151 |
+
"주사기": self.evidence_list[1], "지문": self.evidence_list[1],
|
| 152 |
+
"로그": self.evidence_list[2], "작업": self.evidence_list[2],
|
| 153 |
+
"캡슐": self.evidence_list[3], "구멍": self.evidence_list[3],
|
| 154 |
+
"보안": self.evidence_list[4], "심야": self.evidence_list[4], "주방": self.evidence_list[4],
|
| 155 |
+
"감정": self.evidence_list[5], "분석": self.evidence_list[5],
|
| 156 |
+
"루틴": self.evidence_list[6], "시간": self.evidence_list[6],
|
| 157 |
+
"커피머신": self.evidence_list[7], "오전": self.evidence_list[7]
|
| 158 |
}
|
| 159 |
|
| 160 |
def _setup_questions(self):
|
|
|
|
| 161 |
self.case_questions = [
|
| 162 |
+
{"id": "culprit", "question": "🎯 주범�� 누구인가?", "correct_answer": "Elena", "options": ["Elena", "IRIS-01", "Dr. Chen", "ZEN"], "hint": "커피캡슐에 독을 넣고 계획을 세운 진짜 범인은?"},
|
| 163 |
+
{"id": "direct_executor", "question": "🤖 실제로 독이 든 커피를 준 것은?", "correct_answer": "IRIS-01", "options": ["Elena", "IRIS-01", "Dr. Chen", "ZEN"], "hint": "Elena의 명령을 받아 독이 든 커피를 Alexander에게 제공한 로봇은?"},
|
| 164 |
+
{"id": "method", "question": "☠️ 독은 어떻게 투입되었나?", "correct_answer": "커피캡슐에 주사기로 주입", "options": ["음식에 직접 투입", "커피캡슐에 주사기로 주입", "와인에 섞어서", "약에 섞어서"], "hint": "Elena가 전날 밤 준비한 치밀한 방법은?"},
|
| 165 |
+
{"id": "poison_type", "question": "🧪 사용된 독의 종류는?", "correct_answer": "청산가리", "options": ["비소", "청산가리", "리신", "스트리크닌"], "hint": "커피에서 검출된 무색무취의 독성 물질"},
|
| 166 |
+
{"id": "key_evidence", "question": "🔍 결정적 증거는?", "correct_answer": "IRIS-01의 작업 로그", "options": ["Elena의 감정 반응", "IRIS-01의 작업 로그", "Dr. Chen의 설계 파일", "ZEN의 보안 기록"], "hint": "커피 제조 과정과 시간을 정확히 기록한 데이터는?"},
|
| 167 |
+
{"id": "elena_identity", "question": "🤖 Elena의 정체는?", "correct_answer": "자아 인식 안드로이드", "options": ["인간", "일반 안드로이드", "자아 인식 안드로이드", "AI 홀로그램"], "hint": "자신의 정체를 깨닫고 분노한 Elena의 진짜 모습은?"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
]
|
| 169 |
|
| 170 |
@staticmethod
|
| 171 |
+
def get_current_time():
|
|
|
|
| 172 |
now = datetime.now()
|
| 173 |
hour, minute = now.hour, now.minute
|
|
|
|
| 174 |
if hour == 0:
|
| 175 |
return f"오전 12:{minute:02d}"
|
| 176 |
elif hour < 12:
|
|
|
|
| 180 |
else:
|
| 181 |
return f"오후 {hour-12}:{minute:02d}"
|
| 182 |
|
| 183 |
+
def calculate_trust_change(self, question, response):
|
|
|
|
| 184 |
trust_change = -2
|
|
|
|
| 185 |
aggressive_words = ["거짓말", "숨기", "범인", "죽였", "살인"]
|
| 186 |
supportive_words = ["이해", "도움", "걱정", "안전"]
|
|
|
|
| 187 |
if any(word in question for word in aggressive_words):
|
| 188 |
trust_change -= 5
|
| 189 |
if any(word in question for word in supportive_words):
|
| 190 |
trust_change += 3
|
|
|
|
| 191 |
return trust_change
|
| 192 |
|
| 193 |
+
def check_evidence_discovery(self, question, response):
|
|
|
|
| 194 |
for keyword, evidence in self.evidence_keywords.items():
|
| 195 |
if keyword in question or keyword in response:
|
| 196 |
if evidence not in self.state.evidence_collected:
|
| 197 |
self.state.evidence_collected.append(evidence)
|
| 198 |
|
| 199 |
+
def update_game_progress(self):
|
|
|
|
| 200 |
progress = 0
|
|
|
|
|
|
|
| 201 |
total_questions = sum(self.state.interrogation_count.values())
|
| 202 |
progress += min(40, total_questions * 2)
|
|
|
|
|
|
|
| 203 |
progress += len(self.state.evidence_collected) * 5
|
|
|
|
|
|
|
| 204 |
avg_trust = sum(self.state.trust_levels.values()) / 4
|
| 205 |
if avg_trust > 70:
|
| 206 |
progress += 20
|
| 207 |
elif avg_trust > 50:
|
| 208 |
progress += 10
|
|
|
|
| 209 |
self.state.player_progress = min(100, progress)
|
| 210 |
+
can_submit_report = (len(self.state.evidence_collected) >= 2 and total_questions >= 4)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
if can_submit_report:
|
| 212 |
self.state.case_solved = True
|
|
|
|
| 213 |
return can_submit_report
|
| 214 |
|
| 215 |
+
def create_chat_html(self, current_suspect=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
if current_suspect is None:
|
| 217 |
current_suspect = self.state.current_suspect
|
|
|
|
| 218 |
character = self.characters[current_suspect]
|
| 219 |
+
filtered_messages = [msg for msg in self.conversation_history if msg.get('suspect') == current_suspect]
|
| 220 |
+
|
| 221 |
+
mobile_css = """
|
| 222 |
+
<style>
|
| 223 |
+
@media (max-width: 768px) {
|
| 224 |
+
.chat-container { height: 450px !important; padding: 12px !important; }
|
| 225 |
+
.chat-messages { padding-right: 20px !important; }
|
| 226 |
+
.user-message, .ai-message { max-width: 85% !important; font-size: 15px !important; }
|
| 227 |
+
}
|
| 228 |
+
@media (max-width: 480px) {
|
| 229 |
+
.chat-container { height: 400px !important; padding: 10px !important; }
|
| 230 |
+
.user-message, .ai-message { max-width: 90% !important; font-size: 14px !important; }
|
| 231 |
+
}
|
| 232 |
+
</style>
|
| 233 |
+
"""
|
| 234 |
|
| 235 |
+
html_content = f"""
|
| 236 |
+
{mobile_css}
|
| 237 |
+
<div class="chat-container" style="{Styles.CYBERPUNK_BG}; padding: 0; font-family: 'Courier New', monospace; height: 520px; overflow: hidden; border-radius: 12px; {Styles.NEON_BORDER}; box-shadow: {Styles.NEON_SHADOW}; position: relative;">
|
| 238 |
+
<div style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: #001f3f; z-index: 1;"></div>
|
| 239 |
+
<div class="character-image" style="position: absolute; right: 15px; bottom: 15px; width: 240px; height: 300px; background: url('{character.image_url}') top center / cover no-repeat; border: 3px solid #00ffff; border-radius: 12px; box-shadow: 0 8px 32px rgba(0, 255, 255, 0.5); z-index: 2;"></div>
|
| 240 |
+
<div class="chat-messages" style="position: absolute; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0, 20, 40, 0.3); padding: 20px; padding-right: 20px; overflow-y: auto; z-index: 3; box-sizing: border-box;">
|
| 241 |
+
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
|
| 243 |
if not filtered_messages:
|
| 244 |
+
html_content += f"""
|
| 245 |
+
<div style="display: flex; align-items: center; justify-content: center; height: 100%; width: 100%;">
|
| 246 |
+
<div style="text-align: center; color: #88ddff; font-size: 16px; opacity: 0.9; text-shadow: 0 2px 4px rgba(0,0,0,0.5); padding: 20px; background: rgba(0, 255, 255, 0.1); border-radius: 10px; border: 1px solid rgba(0, 255, 255, 0.3); backdrop-filter: blur(5px); max-width: 400px;">
|
| 247 |
+
🔍 {character.name}와의 심문을 시작하세요
|
| 248 |
+
<div style="font-size: 14px; color: #aabbcc; margin-top: 10px; line-height: 1.4;">질문을 입력하거나 아래 빠른 질문 버튼을 사용하세요</div>
|
| 249 |
+
</div>
|
| 250 |
+
</div>
|
| 251 |
+
"""
|
|
|
|
| 252 |
else:
|
| 253 |
for msg in filtered_messages:
|
| 254 |
if msg['role'] == 'user':
|
| 255 |
+
html_content += f"""
|
| 256 |
+
<div style="display: flex; justify-content: flex-end; margin-bottom: 16px; width: 100%;">
|
| 257 |
+
<div style="display: flex; flex-direction: column; align-items: flex-end; max-width: 75%; width: fit-content;">
|
| 258 |
+
<div class="user-message" style="background: linear-gradient(145deg, #0088ff, #0066cc); color: white; padding: 12px 16px; border-radius: 18px; font-size: 15px; line-height: 1.5; word-wrap: break-word; box-shadow: 0 4px 12px rgba(0, 136, 255, 0.4); border: 1px solid #00aaff; font-weight: 500;">
|
| 259 |
+
🕵️ {msg['content']}
|
| 260 |
+
</div>
|
| 261 |
+
<div style="font-size: 11px; color: #99ddff; margin-top: 6px; opacity: 0.8;">{msg['time']}</div>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
"""
|
| 265 |
else:
|
| 266 |
+
html_content += f"""
|
| 267 |
+
<div style="display: flex; justify-content: flex-start; margin-bottom: 16px; width: 100%;">
|
| 268 |
+
<div style="display: flex; flex-direction: column; max-width: 75%; width: fit-content;">
|
| 269 |
+
<div style="font-size: 12px; color: #88eeff; margin-bottom: 6px; font-weight: bold; opacity: 0.9;">🤖 {character.name}</div>
|
| 270 |
+
<div class="ai-message" style="background: linear-gradient(145deg, rgba(60, 60, 60, 0.96), rgba(50, 50, 50, 0.96)); color: #ffffff; padding: 12px 16px; border-radius: 18px; font-size: 15px; line-height: 1.5; word-wrap: break-word; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); margin-bottom: 6px; border: 1px solid #00ffff; font-weight: 500;">
|
| 271 |
+
{msg['content']}
|
| 272 |
+
</div>
|
| 273 |
+
<div style="font-size: 11px; color: #aaddff; opacity: 0.8;">{msg['time']}</div>
|
| 274 |
+
</div>
|
| 275 |
+
</div>
|
| 276 |
+
"""
|
| 277 |
+
|
| 278 |
+
html_content += f"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
</div>
|
| 280 |
</div>
|
| 281 |
"""
|
| 282 |
+
return html_content
|
| 283 |
|
| 284 |
+
def interrogate_suspect(self, message, suspect_name):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
if not message.strip():
|
| 286 |
return self.create_chat_html(), ""
|
| 287 |
|
|
|
|
| 288 |
self.state.current_suspect = suspect_name
|
| 289 |
current_time = self.get_current_time()
|
| 290 |
|
| 291 |
+
user_msg = {'role': 'user', 'content': message, 'time': current_time, 'timestamp': datetime.now().isoformat(), 'suspect': suspect_name, 'style': '직접적'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
self.conversation_history.append(user_msg)
|
| 293 |
self.state.interrogation_count[suspect_name] += 1
|
| 294 |
|
| 295 |
try:
|
|
|
|
| 296 |
character = self.characters[suspect_name]
|
| 297 |
+
full_prompt = f"{character.system_prompt}\n\n상황: 플레이어가 심문을 진행하고 있습니다. 자연스러운 구어체로 대화하되, 방어적이고 경계하는 반응을 보이세요. 규칙: 2-3문장으로 자연스럽게 답변. 실제 사람이 말하는 것처럼 구어체를 사용하세요. 심문 {self.state.interrogation_count[suspect_name]}회차."
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
|
|
|
|
| 299 |
api_messages = [{"role": "system", "content": full_prompt}]
|
| 300 |
+
suspect_history = [msg for msg in self.conversation_history if msg.get('suspect') == suspect_name][-4:]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
|
| 302 |
for hist_msg in suspect_history[:-1]:
|
| 303 |
role = "user" if hist_msg['role'] == 'user' else "assistant"
|
|
|
|
| 305 |
|
| 306 |
api_messages.append({"role": "user", "content": message})
|
| 307 |
|
| 308 |
+
response = client.chat.completions.create(model="gpt-4-turbo-preview", messages=api_messages, temperature=0.9, max_tokens=120, presence_penalty=0.3, frequency_penalty=0.3)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
ai_response = response.choices[0].message.content
|
|
|
|
|
|
|
| 311 |
trust_change = self.calculate_trust_change(message, ai_response)
|
| 312 |
+
self.state.trust_levels[suspect_name] = max(0, min(100, self.state.trust_levels[suspect_name] + trust_change))
|
|
|
|
|
|
|
| 313 |
self.check_evidence_discovery(message, ai_response)
|
| 314 |
|
|
|
|
| 315 |
time.sleep(random.uniform(1.0, 2.0))
|
| 316 |
|
| 317 |
+
ai_msg = {'role': 'assistant', 'content': ai_response, 'time': self.get_current_time(), 'timestamp': datetime.now().isoformat(), 'suspect': suspect_name, 'trust_change': trust_change}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
self.conversation_history.append(ai_msg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
self.update_game_progress()
|
| 320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
except Exception as e:
|
| 322 |
+
error_msg = {'role': 'assistant', 'content': f"[시스템 오류] 연결이 불안정합니다... ({str(e)})", 'time': self.get_current_time(), 'timestamp': datetime.now().isoformat(), 'suspect': suspect_name, 'error': True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
self.conversation_history.append(error_msg)
|
| 324 |
|
| 325 |
return self.create_chat_html(), ""
|
| 326 |
|
| 327 |
+
def get_interrogation_info_html(self, suspect_name):
|
|
|
|
| 328 |
character = self.characters[suspect_name]
|
| 329 |
return f"""
|
| 330 |
+
<div style="{Styles.panel()}; margin-bottom: 15px; text-align: center;">
|
| 331 |
+
<div style="color: #ff9999; font-weight: bold; margin-bottom: 12px; font-size: 15px;">🔍 INTERROGATION ROOM</div>
|
| 332 |
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px;">
|
| 333 |
+
<div style="padding: 8px; background: rgba(0,0,0,0.3); border-radius: 6px;">
|
| 334 |
+
<div style="color: #ffee88; font-weight: 600; font-size: 12px;">SUSPECT</div>
|
| 335 |
+
<div style="color: #ffffff; font-size: 13px; margin-top: 2px;">{character.name}</div>
|
| 336 |
+
</div>
|
| 337 |
+
<div style="padding: 8px; background: rgba(0,0,0,0.3); border-radius: 6px;">
|
| 338 |
+
<div style="color: #ffee88; font-weight: 600; font-size: 12px;">TRUST</div>
|
| 339 |
+
<div style="color: #88ff88; font-size: 13px; margin-top: 2px; font-weight: bold;">{self.state.trust_levels[suspect_name]}%</div>
|
| 340 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
</div>
|
| 342 |
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
| 343 |
+
<div style="padding: 8px; background: rgba(0,0,0,0.3); border-radius: 6px;">
|
| 344 |
+
<div style="color: #ffee88; font-weight: 600; font-size: 12px;">QUESTIONS</div>
|
| 345 |
+
<div style="color: #88ddff; font-size: 13px; margin-top: 2px; font-weight: bold;">{self.state.interrogation_count[suspect_name]}</div>
|
| 346 |
+
</div>
|
| 347 |
+
<div style="padding: 8px; background: rgba(0,0,0,0.3); border-radius: 6px;">
|
| 348 |
+
<div style="color: #ffee88; font-weight: 600; font-size: 12px;">EVIDENCE</div>
|
| 349 |
+
<div style="color: #ff88dd; font-size: 13px; margin-top: 2px; font-weight: bold;">{len(self.state.evidence_collected)}/8</div>
|
| 350 |
+
</div>
|
| 351 |
</div>
|
| 352 |
</div>
|
| 353 |
"""
|
| 354 |
|
| 355 |
+
def get_report_status_html(self):
|
|
|
|
| 356 |
total_questions = sum(self.state.interrogation_count.values())
|
| 357 |
evidence_count = len(self.state.evidence_collected)
|
| 358 |
can_submit = self.update_game_progress()
|
| 359 |
|
| 360 |
if can_submit:
|
| 361 |
return f"""
|
| 362 |
+
<div style="background: linear-gradient(145deg, rgba(0,255,0,0.2), rgba(0,200,0,0.3)); color: #00ff00; padding: 15px; border-radius: 10px; margin-bottom: 12px; border: 2px solid #00ff00; font-size: 14px; text-align: center; box-shadow: 0 4px 16px rgba(0, 255, 0, 0.3);">
|
| 363 |
+
<div style="font-weight: bold; margin-bottom: 5px;">✅ 보고서 제출 가능!</div>
|
| 364 |
+
<div style="font-size: 12px; opacity: 0.9;">증거: {evidence_count}/8 | 질문: {total_questions}회</div>
|
|
|
|
| 365 |
</div>
|
| 366 |
"""
|
| 367 |
else:
|
| 368 |
return f"""
|
| 369 |
+
<div style="background: linear-gradient(145deg, rgba(255,165,0,0.2), rgba(255,140,0,0.3)); color: #ffaa00; padding: 15px; border-radius: 10px; margin-bottom: 12px; border: 2px solid #ffaa00; font-size: 14px; text-align: center; box-shadow: 0 4px 16px rgba(255, 165, 0, 0.3);">
|
| 370 |
+
<div style="font-weight: bold; margin-bottom: 5px;">📊 추가 수사 필요</div>
|
| 371 |
+
<div style="font-size: 12px; opacity: 0.9; line-height: 1.3;">증거 {evidence_count}/2 | 질문 {total_questions}/4<br><span style="font-size: 11px;">(최소: 증거 2개, 질문 4회)</span></div>
|
|
|
|
|
|
|
| 372 |
</div>
|
| 373 |
"""
|
| 374 |
|
| 375 |
+
def get_character_info_html(self, suspect_name):
|
|
|
|
| 376 |
character = self.characters[suspect_name]
|
| 377 |
return f"""
|
| 378 |
+
<div style="background: linear-gradient(145deg, rgba(20, 30, 50, 0.9), rgba(30, 40, 70, 0.9)); color: #ffffff; padding: 18px; border-radius: 12px; border: 2px solid #66aaff; font-family: 'Courier New', monospace; backdrop-filter: blur(8px); box-shadow: 0 8px 32px rgba(102, 170, 255, 0.3); margin-bottom: 15px;">
|
| 379 |
+
<h4 style="color: #ffdd88; margin-bottom: 12px; font-size: 16px; text-shadow: 0 1px 3px rgba(0,0,0,0.3); text-align: center; border-bottom: 1px solid rgba(255, 221, 136, 0.3); padding-bottom: 8px;">👤 SUSPECT PROFILE</h4>
|
| 380 |
+
<div style="text-align: center; margin-bottom: 15px;">
|
| 381 |
+
<div style="color: #88ddff; font-size: 15px; font-weight: bold; margin-bottom: 8px;">{character.name}</div>
|
| 382 |
+
<div style="color: #ccddee; font-size: 14px; line-height: 1.5; text-align: left; background: rgba(0,0,0,0.2); padding: 10px; border-radius: 8px;">{character.description}</div>
|
| 383 |
+
</div>
|
| 384 |
+
<!-- PROGRESS 박스를 SUSPECT PROFILE 안에 추가 -->
|
| 385 |
+
<div style="text-align: center; padding: 10px; background: rgba(0,0,0,0.3); border-radius: 8px; border: 2px solid #44ff44;">
|
| 386 |
+
<div style="color: #88ff88; font-size: 12px; font-weight: 600; margin-bottom: 3px;">PROGRESS</div>
|
| 387 |
+
<div style="color: #ffffff; font-size: 14px; font-weight: bold;">{self.state.player_progress}%</div>
|
| 388 |
+
</div>
|
| 389 |
</div>
|
| 390 |
"""
|
| 391 |
|
| 392 |
+
def get_case_summary(self):
|
|
|
|
| 393 |
total_questions = sum(self.state.interrogation_count.values())
|
| 394 |
evidence_count = len(self.state.evidence_collected)
|
|
|
|
|
|
|
| 395 |
trust_analysis = []
|
| 396 |
for suspect_id, trust in self.state.trust_levels.items():
|
| 397 |
questions = self.state.interrogation_count[suspect_id]
|
| 398 |
+
status = "높은 신뢰" if trust >= 70 else "보통 신뢰" if trust >= 40 else "낮은 신뢰"
|
| 399 |
+
trust_analysis.append(f"• {self.characters[suspect_id].name}: {trust}% ({questions}회 심문, {status})")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
|
| 401 |
+
evidence_list = self.state.evidence_collected if self.state.evidence_collected else ["아직 수집된 증거가 없습니다."]
|
|
|
|
|
|
|
| 402 |
|
| 403 |
return f"""
|
| 404 |
🔍 CASE INVESTIGATION SUMMARY
|
|
|
|
| 418 |
📈 수사 상태:
|
| 419 |
{'✅ 최종 보고서 제출 가능' if self.state.case_solved else '🔄 추가 수사 필요'}
|
| 420 |
|
| 421 |
+
☕ 사건의 핵심:
|
| 422 |
+
- Elena가 전날 밤 커피캡슐에 청산가리 주입
|
| 423 |
+
- IRIS-01이 Elena의 명령으로 독이 든 커피를 Alexander에게 제공
|
| 424 |
+
- Alexander는 평소 모닝커피 루틴 중 사망
|
| 425 |
+
|
| 426 |
💡 수사 팁:
|
| 427 |
- 각 용의자를 골고루 심문하세요
|
| 428 |
+
- 커피, 캡슐, 주사기 관련 키워드에 주목하세요
|
| 429 |
+
- Elena의 심야 활동과 IRIS-01의 작업 로그를 확인하세요
|
| 430 |
"""
|
| 431 |
|
| 432 |
def reset_game(self):
|
|
|
|
| 433 |
self.state = GameState()
|
| 434 |
self.conversation_history = []
|
| 435 |
+
self.experiment_data = {'conversations': [], 'analysis_results': {}, 'player_behavior': {}}
|
| 436 |
+
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
|
| 438 |
+
def get_report_modal_html(self):
|
| 439 |
+
total_questions = sum(self.state.interrogation_count.values())
|
| 440 |
+
evidence_count = len(self.state.evidence_collected)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
|
| 442 |
+
if evidence_count < 2 or total_questions < 4:
|
| 443 |
+
return f"""
|
| 444 |
+
<div style="background: linear-gradient(145deg, rgba(255,0,0,0.2), rgba(200,0,0,0.3)); color: #ff6666; padding: 25px; border-radius: 15px; text-align: center; border: 2px solid #ff6666; font-family: 'Courier New', monospace; box-shadow: 0 8px 32px rgba(255, 102, 102, 0.3);">
|
| 445 |
+
<h3 style="color: #ff6666; margin-bottom: 18px; font-size: 20px;">⚠️ 보고서 제출 불가</h3>
|
| 446 |
+
<div style="background: rgba(0,0,0,0.3); padding: 15px; border-radius: 10px; margin-bottom: 15px;">
|
| 447 |
+
<p style="margin-bottom: 12px; font-size: 15px;">더 많은 증거와 심문이 필요합니다:</p>
|
| 448 |
+
<div style="margin: 8px 0; font-size: 14px;">🔍 수집된 증거: {evidence_count}/2 (최소 2개 필요)</div>
|
| 449 |
+
<div style="margin: 8px 0; font-size: 14px;">❓ 심문 횟수: {total_questions}/4 (최소 4회 필요)</div>
|
| 450 |
+
</div>
|
| 451 |
+
<p style="margin-top: 15px; font-size: 14px; color: #ffaaaa;">계속 수사를 진행해주세요!</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
</div>
|
| 453 |
+
""", gr.update(visible=True)
|
| 454 |
+
|
| 455 |
+
return self.generate_report_modal_content(), gr.update(visible=True)
|
| 456 |
+
|
| 457 |
+
def generate_report_modal_content(self):
|
| 458 |
+
questions_html = ""
|
| 459 |
+
for question in self.case_questions:
|
| 460 |
+
options_html = "".join([
|
| 461 |
+
f'<label style="display: block; margin: 8px 0; cursor: pointer; color: #ffffff; font-size: 14px; padding: 10px; border-radius: 8px; background: linear-gradient(145deg, rgba(0,0,0,0.3), rgba(20,20,40,0.3)); border: 1px solid rgba(255,255,255,0.1); transition: all 0.3s ease; text-align: left;"><input type="radio" name="question_{question["id"]}" value="{option}" style="margin-right: 12px; accent-color: #00ffff; transform: scale(1.2);"> {option}</label>'
|
| 462 |
+
for option in question["options"]
|
| 463 |
+
])
|
| 464 |
+
|
| 465 |
+
questions_html += f"""
|
| 466 |
+
<div style="margin-bottom: 25px; padding: 20px; background: linear-gradient(145deg, rgba(0,0,0,0.4), rgba(20,30,50,0.4)); border-radius: 12px; border: 2px solid #00ffff; box-shadow: 0 4px 16px rgba(0, 255, 255, 0.2);">
|
| 467 |
+
<h4 style="color: #00ffff; margin-bottom: 15px; font-size: 16px; text-align: center; text-shadow: 0 1px 3px rgba(0,0,0,0.5);">{question["question"]}</h4>
|
| 468 |
+
<div style="margin: 0;">{options_html}</div>
|
| 469 |
+
</div>
|
| 470 |
+
"""
|
| 471 |
|
| 472 |
+
script_data = {
|
| 473 |
+
'questions': [q["id"] for q in self.case_questions],
|
| 474 |
+
'correct_answers': {q["id"]: q["correct_answer"] for q in self.case_questions},
|
| 475 |
+
'question_data': {q["id"]: {"question": q["question"], "hint": q["hint"]} for q in self.case_questions}
|
|
|
|
|
|
|
|
|
|
| 476 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
|
| 478 |
+
return f"""
|
| 479 |
+
<style>
|
| 480 |
+
@media (max-width: 768px) {{
|
| 481 |
+
.modal-container {{ padding: 15px !important; max-width: 95% !important; max-height: 90vh !important; }}
|
| 482 |
+
.modal-buttons {{ flex-direction: column !important; gap: 10px !important; }}
|
| 483 |
+
}}
|
| 484 |
+
</style>
|
| 485 |
|
| 486 |
+
<div id="reportModal" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); z-index: 1000; display: none; backdrop-filter: blur(8px);">
|
| 487 |
+
<div class="modal-container" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: linear-gradient(145deg, #0a0a0a, #1a1a2e); border: 3px solid #00ffff; border-radius: 15px; padding: 30px; max-width: 750px; width: 90%; max-height: 85vh; overflow-y: auto; box-shadow: 0 0 50px rgba(0, 255, 255, 0.4); font-family: 'Courier New', monospace;">
|
| 488 |
+
<div style="text-align: center; margin-bottom: 30px;">
|
| 489 |
+
<h2 style="color: #ffffff; font-size: 24px; margin-bottom: 10px;">🔍 최종 수사 보고서 🔍</h2>
|
| 490 |
+
<p style="color: #ffdd88; font-size: 15px;">수집한 증거를 바탕으로 사건의 진실을 밝혀내세요</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
</div>
|
| 492 |
+
<form id="reportForm">{questions_html}
|
| 493 |
+
<div class="modal-buttons" style="display: flex; justify-content: center; gap: 15px; margin-top: 30px;">
|
| 494 |
+
<button type="button" onclick="submitReport()" style="background: linear-gradient(145deg, #ff6b6b, #ee5a52); color: white; border: none; padding: 15px 30px; border-radius: 10px; font-size: 16px; font-weight: bold; cursor: pointer; box-shadow: 0 4px 15px rgba(255, 107, 107, 0.4);">🎯 수사 완료!</button>
|
| 495 |
+
<button type="button" onclick="closeModal()" style="background: linear-gradient(145deg, #666, #555); color: white; border: none; padding: 15px 30px; border-radius: 10px; font-size: 16px; cursor: pointer; box-shadow: 0 4px 15px rgba(0,0,0,0.4);">취소</button>
|
| 496 |
+
</div>
|
| 497 |
+
</form>
|
| 498 |
+
</div>
|
| 499 |
</div>
|
|
|
|
| 500 |
|
| 501 |
+
<div id="resultModal" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9); z-index: 1001; display: none;">
|
| 502 |
+
<div class="modal-container" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: linear-gradient(145deg, #0a0a0a, #1a1a2e); border: 3px solid #FFD700; border-radius: 15px; padding: 30px; max-width: 800px; width: 90%; max-height: 85vh; overflow-y: auto; box-shadow: 0 0 50px rgba(255, 215, 0, 0.4); font-family: 'Courier New', monospace;">
|
| 503 |
+
<div id="resultContent"></div>
|
| 504 |
+
<div style="text-align: center; margin-top: 30px;">
|
| 505 |
+
<button type="button" onclick="closeResultModal()" style="background: linear-gradient(145deg, #00ffff, #0088cc); color: white; border: none; padding: 15px 30px; border-radius: 10px; font-size: 16px; font-weight: bold; cursor: pointer; box-shadow: 0 4px 15px rgba(0, 255, 255, 0.4);">🔍 계속 수사하기</button>
|
| 506 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
</div>
|
| 508 |
</div>
|
|
|
|
| 509 |
|
| 510 |
+
<script>
|
| 511 |
+
const scriptData = {json.dumps(script_data, ensure_ascii=False)};
|
| 512 |
+
function openReportModal() {{ document.getElementById('reportModal').style.display = 'block'; }}
|
| 513 |
+
function closeModal() {{ document.getElementById('reportModal').style.display = 'none'; }}
|
| 514 |
+
function closeResultModal() {{ document.getElementById('resultModal').style.display = 'none'; }}
|
| 515 |
+
function submitReport() {{
|
| 516 |
+
const answers = {{}};
|
| 517 |
+
scriptData.questions.forEach(questionId => {{
|
| 518 |
+
const selected = document.querySelector(`input[name="question_${{questionId}}"]:checked`);
|
| 519 |
+
answers[questionId] = selected ? selected.value : "";
|
| 520 |
+
}});
|
| 521 |
+
let correctCount = 0;
|
| 522 |
+
let resultsHtml = "";
|
| 523 |
+
scriptData.questions.forEach(questionId => {{
|
| 524 |
+
const userAnswer = answers[questionId] || "미답변";
|
| 525 |
+
const correctAnswer = scriptData.correct_answers[questionId];
|
| 526 |
+
const isCorrect = userAnswer === correctAnswer;
|
| 527 |
+
if (isCorrect) correctCount++;
|
| 528 |
+
const statusIcon = isCorrect ? "✅" : "❌";
|
| 529 |
+
const answerColor = isCorrect ? "#88ff88" : "#ff8888";
|
| 530 |
+
resultsHtml += `<div style="margin-bottom: 15px; padding: 15px; background: linear-gradient(145deg, rgba(0,0,0,0.3), rgba(20,30,50,0.3)); border-radius: 10px; border-left: 4px solid ${{answerColor}};"><div style="color: #ffffff; margin-bottom: 8px; font-size: 15px;"><strong>${{scriptData.question_data[questionId].question}}</strong></div><div style="margin-bottom: 6px; font-size: 14px;"><span style="color: #ffdd88;">당신의 답:</span> <span style="color: ${{answerColor}}; font-weight: bold;">${{userAnswer}} ${{statusIcon}}</span></div><div style="margin-bottom: 6px; font-size: 14px;"><span style="color: #ffdd88;">정답:</span> <span style="color: #88ff88; font-weight: bold;">${{correctAnswer}}</span></div>${{!isCorrect ? `<div style="color: #aabbcc; font-size: 13px; font-style: italic; background: rgba(0,0,0,0.2); padding: 8px; border-radius: 5px; margin-top: 8px;">💡 ${{scriptData.question_data[questionId].hint}}</div>` : ""}}</div>`;
|
| 531 |
+
}});
|
| 532 |
+
const totalQuestions = scriptData.questions.length;
|
| 533 |
+
const scorePercentage = (correctCount / totalQuestions) * 100;
|
| 534 |
+
let grade, gradeColor, finalMessage;
|
| 535 |
+
if (scorePercentage >= 90) {{
|
| 536 |
+
grade = "S급 탐정"; gradeColor = "#FFD700";
|
| 537 |
+
finalMessage = "완벽한 추리력! 당신은 진정한 사이버펑크 탐정입니다! 🕵️♂️⭐";
|
| 538 |
+
}} else if (scorePercentage >= 80) {{
|
| 539 |
+
grade = "A급 탐정"; gradeColor = "#00FF00";
|
| 540 |
+
finalMessage = "훌륭한 수사 실력! 대부분의 진실을 밝혀냈습니���! 🔍✨";
|
| 541 |
+
}} else if (scorePercentage >= 70) {{
|
| 542 |
+
grade = "B급 탐정"; gradeColor = "#00AAFF";
|
| 543 |
+
finalMessage = "좋은 추리! 몇 가지 단서를 놓쳤지만 사건을 해결했습니다! 🎯";
|
| 544 |
+
}} else {{
|
| 545 |
+
grade = "수습 탐정"; gradeColor = "#FF6666";
|
| 546 |
+
finalMessage = "더 많은 증거 수집이 필요했습니다. 다시 도전해보세요! 💪";
|
| 547 |
+
}}
|
| 548 |
+
const finalHtml = `<div style="text-align: center; margin-bottom: 25px;"><h2 style="color: ${{gradeColor}}; font-size: 26px; margin-bottom: 10px;">🏆 사건 수사 완료! 🏆</h2><div style="color: ${{gradeColor}}; font-size: 20px; font-weight: bold; margin-bottom: 8px;">${{grade}}</div><div style="color: #ffffff; font-size: 16px; background: rgba(0,0,0,0.3); padding: 10px; border-radius: 8px; display: inline-block;">정답률: ${{correctCount}}/${{totalQuestions}} (${{scorePercentage.toFixed(1)}}%)</div></div><div style="margin-bottom: 20px;"><h3 style="color: #00ffff; margin-bottom: 15px; font-size: 18px; text-align: center;">📋 수사 결과</h3>${{resultsHtml}}</div><div style="text-align: center; margin-top: 20px; padding: 18px; background: linear-gradient(145deg, rgba(0,255,255,0.1), rgba(0,200,255,0.1)); border-radius: 12px; border: 1px solid rgba(0,255,255,0.3);"><div style="color: #ffffff; font-size: 15px;">${{finalMessage}}</div></div>`;
|
| 549 |
+
document.getElementById('resultContent').innerHTML = finalHtml;
|
| 550 |
+
document.getElementById('reportModal').style.display = 'none';
|
| 551 |
+
document.getElementById('resultModal').style.display = 'block';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
}}
|
| 553 |
+
setTimeout(() => openReportModal(), 100);
|
| 554 |
+
</script>
|
| 555 |
+
"""
|
| 556 |
|
| 557 |
+
# 게임 인스턴스 생성
|
| 558 |
+
game = CyberpunkGame()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
|
| 560 |
+
# Gradio UI 함수들
|
| 561 |
+
def clear_game():
|
| 562 |
+
game.reset_game()
|
| 563 |
+
return (game.create_chat_html(), "", game.get_interrogation_info_html('Elena'), game.get_report_status_html())
|
|
|
|
|
|
|
|
|
|
| 564 |
|
| 565 |
+
def interrogate_and_update_info(message, suspect_name):
|
| 566 |
+
chat_html, empty_input = game.interrogate_suspect(message, suspect_name)
|
| 567 |
+
return (chat_html, empty_input, game.get_interrogation_info_html(suspect_name), game.get_report_status_html())
|
|
|
|
| 568 |
|
| 569 |
+
def update_character_info_and_display(suspect_name):
|
| 570 |
+
game.state.current_suspect = suspect_name
|
| 571 |
+
return (game.get_character_info_html(suspect_name), game.get_interrogation_info_html(suspect_name), game.create_chat_html(suspect_name))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
|
| 573 |
# Gradio 인터페이스 생성
|
| 574 |
with gr.Blocks(title="사이버펑크 추리 게임", theme=gr.themes.Monochrome()) as demo:
|
| 575 |
+
gr.HTML("""
|
| 576 |
+
<style>
|
| 577 |
+
@media (max-width: 768px) {
|
| 578 |
+
.gradio-container { padding: 10px !important; }
|
| 579 |
+
.gr-button { font-size: 14px !important; padding: 12px 16px !important; margin: 4px !important; }
|
| 580 |
+
}
|
| 581 |
+
.gr-button:hover { transform: translateY(-1px) !important; }
|
| 582 |
+
</style>
|
| 583 |
+
""")
|
| 584 |
+
|
| 585 |
gr.HTML(f"""
|
| 586 |
+
<div style="text-align: center; background: linear-gradient(90deg, #000, #0a0a0a, #000); color: #ffffff; padding: 20px; border-radius: 12px; margin-bottom: 20px; {Styles.NEON_BORDER}; box-shadow: {Styles.NEON_SHADOW};">
|
| 587 |
+
<h1 style="font-family: 'Courier New', monospace; font-size: clamp(22px, 5vw, 30px); margin-bottom: 10px; color: #ffffff;">🔍 CYBERPUNK MURDER INVESTIGATION 🤖</h1>
|
| 588 |
+
<p style="font-size: clamp(13px, 3vw, 15px); color: #ffd93d;">근미래 사이버펑크 도시에서 발생한 Alexander 독살 사건을 해결하세요</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
</div>
|
| 590 |
""")
|
| 591 |
|
| 592 |
with gr.Row():
|
| 593 |
with gr.Column(scale=3):
|
| 594 |
+
chat_display = gr.HTML(value=game.create_chat_html(), label="심문실")
|
| 595 |
+
interrogation_info = gr.HTML(value=game.get_interrogation_info_html('Elena'), label="")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 596 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
with gr.Row():
|
| 598 |
+
message_input = gr.Textbox(placeholder="용의자에게 질문을 입력하세요...", label="", scale=4, container=False, lines=1)
|
| 599 |
+
send_btn = gr.Button("🔍 질문", variant="primary", scale=1, size="lg")
|
| 600 |
+
|
| 601 |
+
gr.HTML("""
|
| 602 |
+
<div style="margin: 15px 0 10px 0; padding: 15px;
|
| 603 |
+
background: linear-gradient(145deg, rgba(20, 20, 20, 0.95), rgba(10, 10, 10, 0.95));
|
| 604 |
+
border-radius: 10px; text-align: center; border: 2px solid rgba(255, 255, 255, 0.1);
|
| 605 |
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); backdrop-filter: blur(5px);
|
| 606 |
+
display: flex; align-items: center; justify-content: center; min-height: 50px;">
|
| 607 |
+
<div style="color: #ffdd88; font-weight: bold; font-size: 16px; text-shadow: 0 1px 3px rgba(0,0,0,0.5);">💭 빠른 질문 버튼</div>
|
| 608 |
+
</div>
|
| 609 |
+
""")
|
| 610 |
+
|
| 611 |
+
with gr.Column():
|
| 612 |
+
with gr.Row():
|
| 613 |
+
quick1 = gr.Button("🕐 사건 당일 어디에 있었나?", size="sm", variant="secondary")
|
| 614 |
+
quick2 = gr.Button("💔 Alexander와 어떤 관계였나?", size="sm", variant="secondary")
|
| 615 |
+
with gr.Row():
|
| 616 |
+
quick3 = gr.Button("👁️ 의심스러운 행동을 본 적 있나?", size="sm", variant="secondary")
|
| 617 |
+
quick4 = gr.Button("🤐 숨기고 있는 게 있다면 말해달라", size="sm", variant="secondary")
|
| 618 |
|
| 619 |
with gr.Column(scale=1):
|
| 620 |
+
gr.HTML("""
|
| 621 |
+
<div style="background: linear-gradient(145deg, rgba(20, 20, 20, 0.95), rgba(10, 10, 10, 0.95));
|
| 622 |
+
padding: 20px; border-radius: 12px; margin-bottom: 15px;
|
| 623 |
+
border: 2px solid rgba(255, 255, 255, 0.1); text-align: center;
|
| 624 |
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); backdrop-filter: blur(5px);
|
| 625 |
+
display: flex; align-items: center; justify-content: center; min-height: 60px;">
|
| 626 |
+
<h3 style="color: #ffffff; margin: 0; font-size: 18px; font-weight: bold;
|
| 627 |
+
text-shadow: 0 1px 3px rgba(0,0,0,0.5);">🤖 용의자 선택</h3>
|
| 628 |
+
</div>
|
| 629 |
+
""")
|
| 630 |
|
| 631 |
suspect_choice = gr.Radio(
|
| 632 |
+
choices=[("👩 Elena (아내)", "Elena"), ("🤖 IRIS-01 (가정부 로봇)", "IRIS-01"), ("👨🔬 Dr. Chen (개발자)", "Dr. Chen"), ("🖥️ ZEN (보안 AI)", "ZEN")],
|
| 633 |
+
value="Elena", label="심문할 용의자", info="각 용의자를 선택하면 해당 캐릭터와 대화할 수 있습니다"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
)
|
| 635 |
|
|
|
|
| 636 |
with gr.Group():
|
| 637 |
+
character_info = gr.HTML(value=game.get_character_info_html('Elena'), label="")
|
| 638 |
+
|
| 639 |
+
gr.HTML("""
|
| 640 |
+
<div style="background: linear-gradient(145deg, rgba(20, 20, 20, 0.95), rgba(10, 10, 10, 0.95));
|
| 641 |
+
padding: 20px; border-radius: 12px; margin-bottom: 15px;
|
| 642 |
+
border: 2px solid rgba(255, 255, 255, 0.1); text-align: center;
|
| 643 |
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); backdrop-filter: blur(5px);
|
| 644 |
+
display: flex; align-items: center; justify-content: center; min-height: 60px;">
|
| 645 |
+
<h3 style="color: #ffffff; margin: 0; font-size: 18px; font-weight: bold;
|
| 646 |
+
text-shadow: 0 1px 3px rgba(0,0,0,0.5);">🎯 최종 보고서</h3>
|
| 647 |
+
</div>
|
| 648 |
+
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
|
| 650 |
+
submit_report_btn = gr.Button("📋 최종 보고서 제출", variant="primary", size="lg", visible=True)
|
| 651 |
+
report_status = gr.HTML(value=game.get_report_status_html(), label="")
|
| 652 |
+
modal_container = gr.HTML(value="", label="", visible=False)
|
| 653 |
+
|
| 654 |
+
gr.HTML("""
|
| 655 |
+
<div style="background: linear-gradient(145deg, rgba(20, 20, 20, 0.95), rgba(10, 10, 10, 0.95));
|
| 656 |
+
padding: 20px; border-radius: 12px; margin-bottom: 15px;
|
| 657 |
+
border: 2px solid rgba(255, 255, 255, 0.1); text-align: center;
|
| 658 |
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); backdrop-filter: blur(5px);
|
| 659 |
+
display: flex; align-items: center; justify-content: center; min-height: 60px;">
|
| 660 |
+
<h3 style="color: #ffffff; margin: 0; font-size: 18px; font-weight: bold;
|
| 661 |
+
text-shadow: 0 1px 3px rgba(0,0,0,0.5);">📊 수사 도구</h3>
|
| 662 |
+
</div>
|
| 663 |
+
""")
|
| 664 |
|
| 665 |
+
with gr.Column():
|
| 666 |
+
case_summary_btn = gr.Button("📋 수사 현황 보기", variant="secondary", size="lg")
|
| 667 |
+
case_summary_output = gr.Textbox(label="📊 수사 리포트", lines=14, interactive=False, visible=False)
|
| 668 |
+
clear_btn = gr.Button("🔄 수사 초기화", variant="stop", size="lg")
|
| 669 |
|
| 670 |
# 이벤트 핸들러
|
| 671 |
+
send_btn.click(interrogate_and_update_info, inputs=[message_input, suspect_choice], outputs=[chat_display, message_input, interrogation_info, report_status])
|
| 672 |
+
message_input.submit(interrogate_and_update_info, inputs=[message_input, suspect_choice], outputs=[chat_display, message_input, interrogation_info, report_status])
|
| 673 |
+
|
| 674 |
+
def send_quick_question(question, suspect):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
return interrogate_and_update_info(question, suspect)
|
| 676 |
|
| 677 |
+
quick1.click(lambda s: send_quick_question("사건 당일 어디에 있었나요?", s), inputs=[suspect_choice], outputs=[chat_display, message_input, interrogation_info, report_status])
|
| 678 |
+
quick2.click(lambda s: send_quick_question("Alexander와 어떤 관계였나요?", s), inputs=[suspect_choice], outputs=[chat_display, message_input, interrogation_info, report_status])
|
| 679 |
+
quick3.click(lambda s: send_quick_question("의심스러운 행동을 본 적 있나요?", s), inputs=[suspect_choice], outputs=[chat_display, message_input, interrogation_info, report_status])
|
| 680 |
+
quick4.click(lambda s: send_quick_question("숨기고 있는 게 있다면 말해달라", s), inputs=[suspect_choice], outputs=[chat_display, message_input, interrogation_info, report_status])
|
| 681 |
+
|
| 682 |
+
suspect_choice.change(update_character_info_and_display, inputs=[suspect_choice], outputs=[character_info, interrogation_info, chat_display])
|
| 683 |
+
submit_report_btn.click(game.get_report_modal_html, outputs=[modal_container, modal_container])
|
| 684 |
+
case_summary_btn.click(lambda: (game.get_case_summary(), gr.update(visible=True)), outputs=[case_summary_output, case_summary_output])
|
| 685 |
+
clear_btn.click(clear_game, outputs=[chat_display, message_input, interrogation_info, report_status])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 686 |
|
| 687 |
# 인터페이스 실행
|
| 688 |
if __name__ == "__main__":
|