davidkim205 commited on
Commit
1f69a50
·
2 Parent(s): 6b2a7cdcfe6a99

Merge branch 'main' of github.com:davidkim205/wallstreet-ai

Browse files
Files changed (1) hide show
  1. gradio_app.py +1134 -557
gradio_app.py CHANGED
@@ -1,7 +1,10 @@
1
  import argparse
 
 
2
  import html as html_lib
3
  import json
4
  import os
 
5
  import time
6
  from pathlib import Path
7
  from queue import Empty, Queue
@@ -9,71 +12,118 @@ from threading import Thread
9
 
10
  import gradio as gr
11
  import requests
 
12
  from pydantic import BaseModel, ValidationError
13
 
 
 
 
14
  PERSONA_FILE = Path(os.environ.get("PERSONA_FILE", "persona.jsonl"))
15
-
16
- EXAMPLE_QUERIES = [
17
- "AAPL의 최근 실적과 투자 포인트 요약해줘",
18
- "TSLA의 기술적 분석 리포트 작성",
19
- "삼성전자(005930.KS) SWOT 분석",
20
- "쿠팡 매출액 알려줘",
21
- ]
22
-
23
-
24
- AUTO_SCROLL_SCRIPT = """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  <script>
26
- (function () {
27
- function setupAutoScroll() {
28
- const root = document.getElementById("answer-wrapper");
29
- if (!root) return false;
30
-
31
- const scrollToBottom = () => {
32
- root.scrollTop = root.scrollHeight;
33
- };
34
-
35
- scrollToBottom();
36
-
37
- const observer = new MutationObserver(scrollToBottom);
38
- observer.observe(root, { childList: true, subtree: true, characterData: true });
39
-
40
- setInterval(scrollToBottom, 400);
41
- return true;
42
  }
43
-
44
- if (!setupAutoScroll()) {
45
- const timer = setInterval(() => {
46
- if (setupAutoScroll()) clearInterval(timer);
47
- }, 300);
48
  }
 
 
49
  })();
50
  </script>
51
  """
52
 
53
 
54
- def to_markdown(text):
55
- # 텍스트를 마크다운 문자열로 반환
 
 
56
  return text or ""
57
 
58
-
59
- def loading_markdown(message):
60
- # 로딩 메시지를 안전하게 HTML로 감싸서 반환
61
- safe_message = html_lib.escape(message or "")
62
- return (
63
- '<div class="ws-loading shimmer">'
64
- '<div class="ws-loading-title">⏳ 답변 준비 중</div>'
65
- f'<div class="ws-loading-msg">{safe_message}</div>'
66
- '</div>'
67
- )
68
-
69
-
70
  def timer_text(elapsed):
71
- # 타이머 텍스트 형식으로 변환
72
  return f"⏱ {elapsed}"
73
 
74
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  class PersonaLine(BaseModel):
76
- # persona.jsonl 파일 한 줄 스키마
77
  name: str
78
  full_name: str
79
  background: str
@@ -82,12 +132,32 @@ class PersonaLine(BaseModel):
82
  response_style: str
83
  key_principles: list[str]
84
  famous_quotes: list[str] | None = None
85
-
86
-
87
- def load_persona_names():
88
- # persona.jsonl에서 persona 이름 목록 로드
89
- choices = ["없음"]
90
- if PERSONA_FILE.exists():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  with PERSONA_FILE.open("r", encoding="utf-8") as f:
92
  for line in f:
93
  line = line.strip()
@@ -95,593 +165,1104 @@ def load_persona_names():
95
  continue
96
  try:
97
  data = json.loads(line)
98
- # full_name 없으면 name 사용하여 채움
99
- if isinstance(data, dict) and not data.get("full_name"):
 
100
  data["full_name"] = data.get("name", "")
101
- persona = PersonaLine(**data)
102
- name = persona.name.strip()
103
- if name and name not in choices:
104
- choices.append(name)
105
  except (json.JSONDecodeError, TypeError, ValidationError):
106
  continue
107
- return choices
108
-
109
-
110
- def _make_elapsed_factory():
111
- # 경과시간 문자열 생성기 팩토리 반환
112
- start_time = time.time()
113
-
114
- def elapsed_str():
115
- return f"{time.time() - start_time:.1f}초"
116
 
117
- return elapsed_str, start_time
118
 
 
 
 
 
 
 
 
119
 
120
- def generate_persona_stream(info, endpoint):
121
- # API 서버 /persona/ 를 호출하여 persona 생성 (generator: 타이머 + 진행 메시지 표시)
122
- if not info or not info.strip():
123
- yield "인물 정보를 입력해주세요.", "{}", timer_text("0.0초")
124
- return
125
 
126
- persona_endpoint = endpoint.rstrip("/").rsplit("/", 1)[0] + "/persona/"
127
- elapsed_str, _ = _make_elapsed_factory()
128
- result_queue = Queue()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
- def worker():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  try:
132
- resp = requests.post(
133
- persona_endpoint,
134
- json={"info": info.strip()},
135
- timeout=(10, 300),
136
  )
137
- resp.raise_for_status()
138
- result_queue.put(("ok", resp.json()))
139
- except requests.exceptions.ConnectionError:
140
- result_queue.put(("error", f"연결 실패: {persona_endpoint} 확인"))
141
- except requests.exceptions.Timeout:
142
- result_queue.put(("error", "요청 시간 초과"))
143
- except requests.RequestException as exc:
144
- result_queue.put(("error", f"요청 실패: {exc}"))
145
-
146
- Thread(target=worker, daemon=True).start()
147
-
148
- # 완료될 때까지 진행 메시지 + 타이머 갱신
149
- while True:
150
- try:
151
- kind, payload = result_queue.get_nowait()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  break
153
- except Empty:
154
- yield loading_markdown("페르소나 생성 중... (AI가 인물 정보를 검색하고 있습니다)"), "{}", timer_text(elapsed_str())
155
- time.sleep(0.3)
156
-
157
- if kind == "error":
158
- yield payload, "{}", timer_text(elapsed_str())
159
- return
160
-
161
- data = payload
162
-
163
- result_md = f"""**이름**: {data.get('name', '')}
164
-
165
- **배경**: {data.get('background', '')}
166
-
167
- **금융 사고 방식**: {data.get('financial_mindset', '')}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
 
169
- **데이터 분석 방식**: {data.get('data_analysis_approach', '')}
170
 
171
- **답변 스타일**: {data.get('response_style', '')}
 
 
 
 
 
 
 
172
 
173
- **핵심 원칙**: {', '.join(data.get('key_principles', []))}
174
- """
175
- quotes = data.get("famous_quotes") or []
176
- if quotes:
177
- result_md += f"\n**어록**: {' / '.join(quotes)}"
178
 
179
- yield result_md, json.dumps(data, ensure_ascii=False, indent=2), timer_text(elapsed_str())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
 
182
  def stream_analyze(query, persona_name, endpoint):
183
- # 질의에 대해 SSE 스트림을 받아 점진적으로 응답과 메타데이터를 반환
 
 
 
 
184
  query = (query or "").strip()
185
  endpoint = (endpoint or "").strip()
186
  persona_name = (persona_name or "").strip()
187
 
188
  if not query:
189
- yield loading_markdown("질문을 입력해주세요."), timer_text("0.0초"), ""
190
  return
191
  if not endpoint:
192
- yield loading_markdown("스트림 엔드포인트 URL을 입력해주세요."), timer_text("0.0초"), ""
193
  return
194
 
195
- text_acc = ""
196
- result_meta_text = ""
197
- meta_text = ""
198
- stdout_acc = ""
199
- first_delta_received = False
200
- loading_msg = "요청 중..."
201
- worker_finished = False
202
- terminal_event = False
203
- elapsed_str, _ = _make_elapsed_factory()
204
-
205
- event_queue = Queue()
206
-
207
- def build_meta_text():
208
- sections = []
209
- if result_meta_text:
210
- sections.append(result_meta_text)
211
- if stdout_acc:
212
- sections.append(f"[stdout]\n{stdout_acc}")
213
- return "\n\n".join(sections)
214
-
215
- def reader_worker():
216
  try:
217
- payload = {"query": query}
218
  if persona_name and persona_name != "없음":
219
- payload["persona_name"] = persona_name
220
-
221
- with requests.post(
222
- endpoint,
223
- json=payload,
224
- headers={"Accept": "text/event-stream"},
225
- stream=True,
226
- timeout=(10, 300),
227
- ) as response:
228
- response.raise_for_status()
229
-
230
- for raw_line in response.iter_lines(chunk_size=1, decode_unicode=True):
231
- if not raw_line:
232
- continue
233
-
234
- line = raw_line.strip()
235
- if not line.startswith("data:"):
236
- continue
237
-
238
- payload_text = line[5:].strip()
239
  try:
240
- parsed = json.loads(payload_text)
241
  except json.JSONDecodeError:
242
  continue
243
-
244
- event_queue.put(("event", parsed))
245
  except requests.exceptions.ConnectionError:
246
- event_queue.put(("exception", f"연결 실패: {endpoint} 확인"))
247
  except requests.exceptions.Timeout:
248
- event_queue.put(("exception", "요청 시간 초과"))
249
- except requests.RequestException as exc:
250
- event_queue.put(("exception", f"요청 실패: {exc}"))
251
  finally:
252
- event_queue.put(("worker_done", None))
253
 
254
- Thread(target=reader_worker, daemon=True).start()
255
 
256
  while True:
257
  try:
258
- kind, payload = event_queue.get(timeout=0.1)
259
- buffered = [(kind, payload)]
260
  while True:
261
- try:
262
- buffered.append(event_queue.get_nowait())
263
- except Empty:
264
- break
265
  except Empty:
266
- buffered = []
267
 
268
- for kind, payload in buffered:
269
  if kind == "event":
270
- event_type = payload.get("type")
271
-
272
- if event_type == "status":
273
- if not first_delta_received:
274
- loading_msg = payload.get("message", "진행 중...")
275
-
276
- elif event_type == "delta":
 
 
 
 
 
 
 
 
 
 
 
277
  delta = payload.get("delta", "")
278
  if delta:
279
- first_delta_received = True
 
 
280
  text_acc += delta
281
 
282
- elif event_type == "result":
283
  result = payload.get("result", payload)
284
- result_meta_text = json.dumps(result, ensure_ascii=False, indent=2)
285
- meta_text = build_meta_text()
286
- if not text_acc:
287
- llm_response = result.get("llm_response", "")
288
- if llm_response:
289
- first_delta_received = True
290
- text_acc = llm_response
291
-
292
- elif event_type == "stdout":
293
- message = payload.get("message", "")
294
- if message:
295
- stdout_acc += message
296
- meta_text = build_meta_text()
297
-
298
- elif event_type == "error":
299
- message = payload.get("message", "알 수 없는 오류")
300
- if text_acc:
301
- text_acc += f"\n\n\n오류: {message}"
302
- first_delta_received = True
303
- else:
304
- loading_msg = f"오류: {message}"
305
- terminal_event = True
306
-
307
- elif event_type == "done":
308
- terminal_event = True
309
 
310
  elif kind == "exception":
311
- loading_msg = str(payload)
312
- terminal_event = True
313
 
314
  elif kind == "worker_done":
315
- worker_finished = True
316
 
317
- elapsed = elapsed_str()
318
- if first_delta_received:
319
- yield to_markdown(text_acc), timer_text(elapsed), meta_text
320
  else:
321
- yield loading_markdown(loading_msg), timer_text(elapsed), meta_text
 
322
 
323
- if worker_finished and terminal_event:
324
- break
325
- if worker_finished and not terminal_event:
326
- if not first_delta_received:
327
- loading_msg = "연결 종료"
328
- yield loading_markdown(loading_msg), timer_text(elapsed_str()), meta_text
329
- else:
330
- yield to_markdown(text_acc), timer_text(elapsed_str()), meta_text
331
  break
332
 
 
 
 
 
 
 
 
333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  def create_app(default_endpoint):
335
- # Gradio 생성 및 레이아웃 구성
336
- custom_css = """
337
- @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap");
338
-
339
- :root {
340
- --ws-bg: #f7faf9;
341
- --ws-surface: #ffffff;
342
- --ws-border: #dbe5e2;
343
- --ws-text: #182022;
344
- --ws-muted: #5d6b70;
345
- --ws-accent: #0f766e;
346
- --ws-code-bg: #f1f5f9;
347
- }
348
-
349
- .gradio-container {
350
- background: radial-gradient(circle at top left, #edf9f6 0%, #f8fbfc 35%, #fdfefe 100%);
351
- }
352
-
353
- .gradio-container,
354
- .gradio-container :is(h1, h2, h3, h4, h5, h6, p, span, div, label, button, input, textarea, select) {
355
- font-family: "IBM Plex Sans KR", "Noto Sans KR", "Source Sans 3", sans-serif !important;
356
- letter-spacing: 0.005em;
357
- }
358
-
359
- .ws-loading {
360
- position: relative;
361
- overflow: hidden;
362
- border: 1px solid #cde8e3;
363
- border-radius: 12px;
364
- background: linear-gradient(180deg, #f9fefd 0%, #f3fbf9 100%);
365
- padding: 14px 16px;
366
- }
367
-
368
- .ws-loading-title {
369
- color: #0f766e;
370
- font-weight: 700;
371
- margin-bottom: 6px;
372
- }
373
-
374
- .ws-loading-msg {
375
- color: #365055;
376
- font-size: 14px;
377
- }
378
-
379
- .shimmer::after {
380
- content: "";
381
- position: absolute;
382
- top: 0;
383
- left: -140%;
384
- width: 80%;
385
- height: 100%;
386
- background: linear-gradient(
387
- 100deg,
388
- rgba(255, 255, 255, 0) 0%,
389
- rgba(255, 255, 255, 0.55) 45%,
390
- rgba(255, 255, 255, 0) 100%
391
- );
392
- animation: ws-shimmer 1.6s ease-in-out infinite;
393
- }
394
-
395
- @keyframes ws-shimmer {
396
- 0% { left: -140%; }
397
- 100% { left: 150%; }
398
- }
399
-
400
- #timer-row {
401
- margin-top: 8px;
402
- display: flex;
403
- justify-content: flex-end;
404
- }
405
-
406
- #timer-row p {
407
- margin: 0 !important;
408
- padding: 4px 10px;
409
- border-radius: 999px;
410
- background: #e6fffb;
411
- border: 1px solid #99f6e4;
412
- color: #0f766e;
413
- font-size: 12px;
414
- font-weight: 600;
415
- }
416
-
417
- #timer-row,
418
- #timer-row > .wrap,
419
- #timer-row > div.prose,
420
- #timer-row > .html-container {
421
- border: none !important;
422
- box-shadow: none !important;
423
- background: transparent !important;
424
- padding: 0 !important;
425
- margin: 0 !important;
426
- }
427
-
428
- #timer-row hr {
429
- display: none !important;
430
- border: 0 !important;
431
- margin: 0 !important;
432
- }
433
-
434
-
435
- #answer-wrapper {
436
- min-height: 420px;
437
- max-height: 42vh;
438
- overflow-y: auto !important;
439
- border: 1px solid var(--ws-border) !important;
440
- border-radius: 14px !important;
441
- background: var(--ws-surface) !important;
442
- padding: 16px 20px !important;
443
- box-shadow: 0 8px 24px rgba(16, 24, 40, 0.06) !important;
444
- }
445
-
446
- #answer-wrapper, #answer-wrapper .md {
447
- color: var(--ws-text) !important;
448
- line-height: 1.72 !important;
449
- font-size: 15px !important;
450
- font-family: "IBM Plex Sans KR", "Noto Sans KR", "Source Sans 3", sans-serif !important;
451
- letter-spacing: 0.005em;
452
- }
453
-
454
- #answer-wrapper h1, #answer-wrapper h2, #answer-wrapper h3 {
455
- margin: 0.8em 0 0.35em !important;
456
- letter-spacing: -0.01em;
457
- color: #0b3b39 !important;
458
- }
459
-
460
- #answer-wrapper p {
461
- margin: 0.35em 0 !important;
462
- }
463
-
464
- #answer-wrapper strong {
465
- font-weight: 650;
466
- letter-spacing: 0.01em;
467
- }
468
-
469
- #answer-wrapper ul, #answer-wrapper ol {
470
- margin: 0.4em 0 !important;
471
- padding-left: 1.4em !important;
472
- }
473
-
474
- #answer-wrapper li {
475
- margin: 0.15em 0 !important;
476
- }
477
-
478
- #answer-wrapper blockquote {
479
- margin: 0.8em 0 !important;
480
- padding: 0.65em 0.9em !important;
481
- border-left: 4px solid #14b8a6 !important;
482
- background: #f0fdfa !important;
483
- color: #115e59 !important;
484
- border-radius: 8px;
485
- }
486
-
487
- #answer-wrapper a {
488
- color: #0f766e !important;
489
- text-decoration: underline;
490
- text-underline-offset: 2px;
491
- }
492
-
493
- #answer-wrapper code {
494
- background: var(--ws-code-bg) !important;
495
- color: #0b3b39 !important;
496
- border: 1px solid #d9e2ec;
497
- border-radius: 6px;
498
- padding: 0.1em 0.35em;
499
- font-size: 0.92em;answer-wrapper
500
- background: #0f172a !important;
501
- color: #e2e8f0 !important;
502
- border-radius: 10px;
503
- border: 1px solid #1e293b;
504
- padding: 0.85em 1em !important;
505
- overflow-x: auto;
506
- }
507
-
508
- #answer-wrapper pre code {
509
- background: transparent !important;
510
- border: none;
511
- color: inherit !important;
512
- padding: 0;
513
- }
514
-
515
- #answer-wrapper table {
516
- width: 100%;
517
- border-collapse: collapse;
518
- margin: 0.7em 0;
519
- border: 1px solid #dbe5e2;
520
- }
521
-
522
- #answer-wrapper th {
523
- background: #eef6f4;
524
- color: #0f3f3b;
525
- font-weight: 600;
526
- }
527
-
528
- #answer-wrapper th,
529
- #answer-wrapper td {
530
- border: 1px solid #dbe5e2;
531
- padding: 0.5em 0.6em;
532
- text-align: left;
533
- vertical-align: top;
534
- }
535
-
536
- #answer-wrapper > .wrap,
537
- #answer-wrapper > div.prose,
538
- #answer-wrapper > .html-container {
539
- padding: 0 !important;
540
- margin: 0 !important;
541
- border: none !important;
542
- box-shadow: none !important;
543
- }
544
-
545
- #meta-box {
546
- max-height: 300px;
547
- overflow-y: auto;
548
- }
549
-
550
- #persona-result-wrapper {
551
- min-height: 200px;
552
- max-height: 50vh;
553
- overflow-y: auto !important;
554
- border: 1px solid var(--ws-border) !important;
555
- border-radius: 14px !important;
556
- background: var(--ws-surface) !important;
557
- padding: 16px 20px !important;
558
- box-shadow: 0 8px 24px rgba(16, 24, 40, 0.06) !important;
559
- }
560
-
561
- /* 드롭다운 열릴 때 페이지 스크롤 고정 */
562
- body:has(.options:not(.hide)) {
563
- overflow: hidden !important;
564
- }
565
- """
566
 
567
- theme = gr.themes.Soft(
568
- primary_hue="emerald",
569
- secondary_hue="blue",
570
- neutral_hue="slate",
571
- radius_size="lg",
572
- )
573
 
574
- with gr.Blocks(title="Wallstreet-AI", css=custom_css, theme=theme) as demo:
575
- gr.Markdown("## 📈 Wallstreet-AI")
576
- gr.Markdown("A finance AI that combines earnings, news, and market trends in one place.")
577
 
578
  with gr.Tabs():
 
 
 
 
579
  with gr.Tab("💬 질문하기"):
580
- with gr.Row():
581
- with gr.Column(scale=3):
582
- endpoint = gr.Textbox(
583
- label="SSE Endpoint",
584
- value=default_endpoint,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
  )
586
- persona_dropdown = gr.Dropdown(
587
- label="페르소나 선택",
588
- choices=load_persona_names(),
589
- value="없음",
590
- interactive=True,
 
 
 
 
 
591
  )
592
- refresh_btn = gr.Button("🔄 페르소나 목록 새로고침", size="sm")
593
- query = gr.Textbox(
594
- label="질문",
595
- lines=3,
596
- value=EXAMPLE_QUERIES[0],
 
 
 
 
597
  )
598
- with gr.Row():
599
- run_btn = gr.Button("🔍 질문하기", variant="primary", scale=3)
600
- clear_btn = gr.Button("🗑 초기화", scale=1)
601
-
602
- with gr.Column(scale=1):
603
- gr.Markdown("**Example Questions**")
604
- for ex in EXAMPLE_QUERIES:
605
- gr.Button(ex, size="sm").click(
606
- fn=lambda x=ex: x, outputs=query
607
- )
608
 
609
- answer = gr.Markdown(value=to_markdown(""), label="답변", elem_id="answer-wrapper")
610
- timer = gr.Markdown(value=timer_text("0.0초"), elem_id="timer-row")
611
- meta = gr.Code(label="진행 과정 출력", language="json", elem_id="meta-box")
612
 
613
- gr.HTML(AUTO_SCROLL_SCRIPT, visible=False)
 
 
 
614
 
615
  run_btn.click(
616
- fn=stream_analyze,
617
- inputs=[query, persona_dropdown, endpoint],
618
- outputs=[answer, timer, meta],
619
  )
620
- query.submit(
621
- fn=stream_analyze,
622
- inputs=[query, persona_dropdown, endpoint],
623
- outputs=[answer, timer, meta],
624
  )
625
  clear_btn.click(
626
- fn=lambda: (to_markdown(""), timer_text("0.0초"), ""),
627
- outputs=[answer, timer, meta],
628
  )
629
  refresh_btn.click(
630
- fn=lambda: gr.Dropdown(choices=load_persona_names(), value="없음"),
631
- outputs=[persona_dropdown],
632
  )
633
 
634
- with gr.Tab("🧑‍💼 페르소나 만들기"):
635
- gr.Markdown("### 페르소나 생성")
636
- gr.Markdown(
637
- "금융 인물의 이름이나 설명을 입력하면 AI가 해당 인물의 금융 사고방식, "
638
- "분석 스타일, 답변 스타일을 자동으로 생성합니다. "
639
- )
640
 
641
- with gr.Row():
642
- with gr.Column(scale=2):
643
- persona_info_input = gr.Textbox(
644
- label="인물 정보",
645
- placeholder="예: 워렌 버핏, JP모건, 가타야마 아키라 ...",
646
- lines=3,
647
- )
648
- persona_gen_btn = gr.Button("✨ 페르소나 생성", variant="primary")
649
-
650
- with gr.Column(scale=1):
651
- gr.Markdown("**예시 인물**")
652
- example_personas = ["워렌 버핏", "JP모건", "가타야마 아키라"]
653
- for ep in example_personas:
654
- gr.Button(ep, size="sm").click(
655
- fn=lambda x=ep: x, outputs=persona_info_input
656
- )
657
 
658
- persona_result_md = gr.Markdown(
659
- value="",
660
- label="생성 결과",
661
- elem_id="persona-result-wrapper",
662
- )
663
- persona_timer = gr.Markdown(value=timer_text("0.0초"), elem_id="timer-row")
664
- persona_result_json = gr.Code(
665
- label="페르소나 JSON",
666
- language="json",
667
- elem_id="meta-box",
668
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
669
 
670
  persona_gen_btn.click(
671
  fn=generate_persona_stream,
672
- inputs=[persona_info_input, endpoint],
673
- outputs=[persona_result_md, persona_result_json, persona_timer],
674
  )
675
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
676
  return demo
677
 
678
 
 
 
 
679
  def main():
680
  parser = argparse.ArgumentParser(description="Wallstreet-AI Gradio UI")
681
- parser.add_argument("--api-url", type=str, default="http://0.0.0.0:8000/analyze/", help="FastAPI SSE 엔드포인트 URL")
682
- parser.add_argument("--share", action="store_true")
683
  parser.add_argument("--server-name", type=str, default="0.0.0.0")
684
- parser.add_argument("--port", type=int, default=7860)
685
  args = parser.parse_args()
686
 
687
  print(f"FastAPI : {args.api_url}")
@@ -689,13 +1270,9 @@ def main():
689
 
690
  app = create_app(args.api_url)
691
  app.queue(default_concurrency_limit=8, max_size=64)
692
- app.launch(
693
- share=args.share,
694
- server_name=args.server_name,
695
- server_port=args.port,
696
- debug=True,
697
- )
698
 
699
 
700
  if __name__ == "__main__":
701
- main()
 
1
  import argparse
2
+ import base64
3
+ import hashlib
4
  import html as html_lib
5
  import json
6
  import os
7
+ import re
8
  import time
9
  from pathlib import Path
10
  from queue import Empty, Queue
 
12
 
13
  import gradio as gr
14
  import requests
15
+ from openai import OpenAI
16
  from pydantic import BaseModel, ValidationError
17
 
18
+ # ─────────────────────────────────────────────────────────────
19
+ # 설정
20
+ # ─────────────────────────────────────────────────────────────
21
  PERSONA_FILE = Path(os.environ.get("PERSONA_FILE", "persona.jsonl"))
22
+ DEFAULT_ENDPOINT = os.environ.get("API_ENDPOINT", "http://127.0.0.1:8000/analyze/")
23
+ IMAGE_CACHE_DIR = Path(".persona_images")
24
+ IMAGE_CACHE_DIR.mkdir(exist_ok=True)
25
+
26
+ _openai_client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
27
+
28
+ EXAMPLES_BY_TYPE = {
29
+ "screener": "PER 낮은 대형주 추천해주세요",
30
+ "technical": "Apple(AAPL) 차트 분석해주세요",
31
+ "fundamental": "Microsoft 재무상태 어때요?",
32
+ "news_summary": "Tesla 최근 뉴스 요약해 주세요",
33
+ "comparison": "Apple vs Microsoft 비교 분석해 주세요",
34
+ "earnings": "2025년 4분기 삼성전자 실적은 어땠나요?",
35
+ "swot": "OpenAI 경쟁력 분석해 주세요",
36
+ "general": "Tesla(TSLA) 어떻게 보시나요?",
37
+ "watchlist": "내 관심종목(삼성전자, SK하이닉스, Apple, Microsoft, Tesla) 현황 봐주세요",
38
+ }
39
+
40
+ ANALYSIS_TYPE_LABELS = {
41
+ "screener": "스크리너",
42
+ "technical": "기술적 분석",
43
+ "fundamental": "기본적 분석",
44
+ "news_summary": "뉴스 요약",
45
+ "comparison": "비교 분석",
46
+ "earnings": "실적 분석",
47
+ "swot": "SWOT 분석",
48
+ "general": "일반 질문",
49
+ "watchlist": "관심종목",
50
+ }
51
+
52
+ EXAMPLE_QUERIES = list(EXAMPLES_BY_TYPE.values())
53
+
54
+ AUTO_SCROLL_JS = """
55
  <script>
56
+ (function(){
57
+ function scroll(){
58
+ var el = document.getElementById('log-scroll') || document.getElementById('answer-scroll');
59
+ if(el) el.scrollTop = el.scrollHeight;
 
 
 
 
 
 
 
 
 
 
 
 
60
  }
61
+ var mo = new MutationObserver(scroll);
62
+ function attach(){
63
+ var root = document.getElementById('output-col') || document.body;
64
+ mo.observe(root, {childList:true, subtree:true, characterData:true});
65
+ scroll();
66
  }
67
+ attach();
68
+ setInterval(scroll, 400);
69
  })();
70
  </script>
71
  """
72
 
73
 
74
+ # ─────────────────────────────────────────────────────────────
75
+ # 헬퍼
76
+ # ─────────────────────────────────────────────────────────────
77
+ def to_md(text):
78
  return text or ""
79
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  def timer_text(elapsed):
 
81
  return f"⏱ {elapsed}"
82
 
83
+ def _make_elapsed():
84
+ t0 = time.time()
85
+ return lambda: f"{time.time()-t0:.1f}초"
86
+
87
+ # 마크다운 링크·URL 제거
88
+ _PAREN_MD = re.compile(r'\s*\(\s*\[[^\]]*\]\([^)]*\)\s*\)')
89
+ _MD_LINK = re.compile(r'\[([^\]]*)\]\([^)]*\)')
90
+ _PAREN_URL= re.compile(r'\s*\(https?://[^\)]*\)')
91
+ _BARE_URL = re.compile(r'https?://\S+')
92
+ _PAREN_DOM= re.compile(r'\s*\([a-zA-Z0-9._-]+\.[a-zA-Z]{2,6}\)')
93
+
94
+ def _safe(text):
95
+ t = text or ""
96
+ t = _PAREN_MD.sub('', t)
97
+ t = _MD_LINK.sub(r'\1', t)
98
+ t = _PAREN_URL.sub('', t)
99
+ t = _BARE_URL.sub('', t)
100
+ t = _PAREN_DOM.sub('', t)
101
+ t = re.sub(r'[ \t]{2,}', ' ', t).strip()
102
+ t = re.sub(r'\.\s*\.', '.', t)
103
+ return html_lib.escape(t).replace("\n", "<br>")
104
+
105
+
106
+ # ─────────────────────────────────────────────────────────────
107
+ # 진행 로그 HTML 빌더
108
+ # ─────────────────────────────────────────────────────────────
109
+ STATUS_ICONS = {
110
+ "요청 수신": "📡", "인텐트": "🧠", "도구": "🔧", "시장": "📊",
111
+ "뉴스": "📰", "컨텍스트": "🗂", "LLM": "✨", "완료": "✅",
112
+ }
113
+
114
+ def _status_icon(msg):
115
+ for k, v in STATUS_ICONS.items():
116
+ if k in msg:
117
+ return v
118
+ return "⏳"
119
+
120
+
121
+
122
+
123
+ # ─────────────────────────────────────────────────────────────
124
+ # 페르소나 모델 & 파일 IO
125
+ # ─────────────────────────────────────────────────────────────
126
  class PersonaLine(BaseModel):
 
127
  name: str
128
  full_name: str
129
  background: str
 
132
  response_style: str
133
  key_principles: list[str]
134
  famous_quotes: list[str] | None = None
135
+ birth_year: str | None = None
136
+ nationality: str | None = None
137
+ net_worth: str | None = None
138
+ company: str | None = None
139
+ title: str | None = None
140
+ investment_style: str | None = None
141
+ notable_trades: list[str] | None = None
142
+
143
+
144
+ _persona_cache: list = []
145
+ _persona_cache_mtime: float = 0.0
146
+
147
+
148
+ def _parse_personas():
149
+ global _persona_cache, _persona_cache_mtime
150
+ try:
151
+ mtime = PERSONA_FILE.stat().st_mtime if PERSONA_FILE.exists() else 0.0
152
+ except OSError:
153
+ mtime = 0.0
154
+ if mtime == _persona_cache_mtime and _persona_cache:
155
+ return _persona_cache
156
+ personas = []
157
+ if not PERSONA_FILE.exists():
158
+ _persona_cache, _persona_cache_mtime = personas, mtime
159
+ return personas
160
+ try:
161
  with PERSONA_FILE.open("r", encoding="utf-8") as f:
162
  for line in f:
163
  line = line.strip()
 
165
  continue
166
  try:
167
  data = json.loads(line)
168
+ if not isinstance(data, dict):
169
+ continue
170
+ if not data.get("full_name"):
171
  data["full_name"] = data.get("name", "")
172
+ personas.append(PersonaLine(**data))
 
 
 
173
  except (json.JSONDecodeError, TypeError, ValidationError):
174
  continue
175
+ except OSError:
176
+ pass
177
+ _persona_cache, _persona_cache_mtime = personas, mtime
178
+ return personas
 
 
 
 
 
179
 
 
180
 
181
+ def load_persona_names():
182
+ choices = ["없음"]
183
+ for p in _parse_personas():
184
+ n = p.name.strip()
185
+ if n and n not in choices:
186
+ choices.append(n)
187
+ return choices
188
 
 
 
 
 
 
189
 
190
+ def load_persona_summary(name):
191
+ if not name or name == "없음":
192
+ return ""
193
+ for p in _parse_personas():
194
+ if p.name.strip() == name:
195
+ title_str = f" · {p.title}" if p.title else ""
196
+ company_str = f" ({p.company})" if p.company else ""
197
+ summary = (p.financial_mindset[:80] + "…") if len(p.financial_mindset) > 80 else p.financial_mindset
198
+ return f"**{p.full_name}**{title_str}{company_str}\n\n{summary}"
199
+ return ""
200
+
201
+
202
+ # ─────────────────────────────────────────────────────────────
203
+ # 프��필 카드
204
+ # ─────────────────────────────────────────────────────────────
205
+ _AVATAR_COLORS = [
206
+ ("#0f766e","#ccfbf1"),("#0e7490","#cffafe"),("#1d4ed8","#dbeafe"),
207
+ ("#7c3aed","#ede9fe"),("#b45309","#fef3c7"),("#be185d","#fce7f3"),
208
+ ]
209
 
210
+ def _initials(name):
211
+ parts = name.strip().split()
212
+ if not parts: return "?"
213
+ if len(parts) == 1: return parts[0][:2].upper()
214
+ return (parts[0][0]+parts[-1][0]).upper()
215
+
216
+ def _avatar_color(name):
217
+ return _AVATAR_COLORS[sum(ord(c) for c in name) % len(_AVATAR_COLORS)]
218
+
219
+ def build_profile_html(p: PersonaLine):
220
+ bg, fg = _avatar_color(p.full_name)
221
+ svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120">
222
+ <defs><linearGradient id="ag" x1="0" y1="0" x2="1" y2="1">
223
+ <stop offset="0%" stop-color="{bg}"/><stop offset="100%" stop-color="{bg}cc"/>
224
+ </linearGradient></defs>
225
+ <circle cx="60" cy="60" r="60" fill="#dbe5e2"/>
226
+ <circle cx="60" cy="60" r="58" fill="url(#ag)"/>
227
+ <ellipse cx="60" cy="48" rx="18" ry="20" fill="{fg}55"/>
228
+ <ellipse cx="60" cy="90" rx="30" ry="22" fill="{fg}44"/>
229
+ <circle cx="60" cy="60" r="58" fill="none" stroke="{fg}66" stroke-width="2"/>
230
+ </svg>"""
231
+ meta_rows = []
232
+ for label, val in [
233
+ ("출생", p.birth_year), ("국적", p.nationality), ("소속", p.company),
234
+ ("직책", p.title), ("자산 규모", p.net_worth), ("투자 스타일", p.investment_style),
235
+ ]:
236
+ if val:
237
+ meta_rows.append(f'<div class="pf-meta-row"><span class="pf-meta-label">{_safe(label)}</span>'
238
+ f'<span class="pf-meta-val">{_safe(val)}</span></div>')
239
+ principles = "".join(f"<li>{_safe(x)}</li>" for x in (p.key_principles or []))
240
+ quotes = "".join(f'<blockquote class="pf-quote">&#8220;{_safe(q)}&#8221;</blockquote>' for q in (p.famous_quotes or []))
241
+ trades = "".join(f'<div class="pf-trade-item">▸ {_safe(t)}</div>' for t in (p.notable_trades or []))
242
+
243
+ return f"""<div class="pf-card">
244
+ <div class="pf-header">
245
+ <div class="pf-avatar">{svg}</div>
246
+ <div class="pf-header-info">
247
+ <h2 class="pf-name">{_safe(p.full_name)}</h2>
248
+ <p class="pf-subtitle">{_safe(p.title or "")}{("&nbsp;·&nbsp;" + _safe(p.company)) if p.company else ""}</p>
249
+ <p class="pf-bg">{_safe(p.background)}</p>
250
+ </div>
251
+ </div>
252
+ {('<div class="pf-meta-grid">' + "".join(meta_rows) + '</div>') if meta_rows else ''}
253
+ <div class="pf-section"><h3 class="pf-section-title">💡 투자 철학</h3><p class="pf-text">{_safe(p.financial_mindset)}</p></div>
254
+ <div class="pf-section"><h3 class="pf-section-title">📊 데이터 분석 방식</h3><p class="pf-text">{_safe(p.data_analysis_approach)}</p></div>
255
+ <div class="pf-section"><h3 class="pf-section-title">🗣 답변 스타일</h3><p class="pf-text">{_safe(p.response_style)}</p></div>
256
+ {('<div class="pf-section"><h3 class="pf-section-title">📌 핵심 원칙</h3><ul class="pf-list">' + principles + '</ul></div>') if principles else ''}
257
+ {('<div class="pf-section"><h3 class="pf-section-title">📁 주요 투자 사례</h3><div class="pf-trades">' + trades + '</div></div>') if trades else ''}
258
+ {('<div class="pf-section">' + quotes + '</div>') if quotes else ''}
259
+ </div>"""
260
+
261
+ def get_profile_html(name):
262
+ if not name or name == "없음":
263
+ return '<p class="pf-empty">왼쪽에서 투자자를 선택하세요.</p>'
264
+ for p in _parse_personas():
265
+ if p.name.strip() == name:
266
+ return build_profile_html(p)
267
+ return '<p class="pf-empty">해당 페르소나 정보를 찾을 수 없습니다.</p>'
268
+
269
+
270
+ # ─────────────────────────────────────────────────────────────
271
+ # Wikipedia 인물 사진 (캐시 포함)
272
+ # ─────────────────────────────────────────────────────────────
273
+ def _image_cache_path(full_name: str) -> Path:
274
+ key = hashlib.md5(full_name.encode()).hexdigest()
275
+ return IMAGE_CACHE_DIR / f"{key}.b64"
276
+
277
+
278
+ def _extract_english_name(full_name: str) -> str:
279
+ """full_name에서 영어 이름을 추출. 괄호 안 영어가 있으면 그것을 우선 사용."""
280
+ paren_match = re.search(r'\(([A-Za-z][^)]+)\)', full_name)
281
+ if paren_match:
282
+ return paren_match.group(1).strip()
283
+ ascii_part = re.sub(r'[^\x00-\x7F]+', '', full_name).strip()
284
+ return ascii_part if ascii_part else full_name
285
+
286
+
287
+ def _extract_english_keywords(text: str) -> str:
288
+ """한국어 텍스트에서 영어 단어/고유명사만 추출."""
289
+ words = re.findall(r'[A-Za-z][A-Za-z\s&.]{2,}', text)
290
+ # 짧거나 일반적인 단어 제거
291
+ stopwords = {"the", "and", "for", "with", "from", "that", "this", "are", "was", "has"}
292
+ result = []
293
+ for w in words:
294
+ w = w.strip()
295
+ if w.lower() not in stopwords and len(w) > 3:
296
+ result.append(w)
297
+ if len(result) >= 3:
298
+ break
299
+ return " ".join(result)
300
+
301
+ def _translate_to_english_name(name: str) -> str:
302
+ """OpenAI를 사용해 가장 가능성 높은 영어 Wikipedia 이름으로 변환"""
303
+ try:
304
+ resp = _openai_client.chat.completions.create(
305
+ model="gpt-5-mini",
306
+ messages=[
307
+ {"role": "system", "content": "Convert a person's name into the most likely English Wikipedia page title. Only output the name."},
308
+ {"role": "user", "content": name}
309
+ ],
310
+ temperature=0
311
+ )
312
+ return resp.choices[0].message.content.strip()
313
+ except Exception:
314
+ return name
315
+ def _wikidata_image(name: str) -> str:
316
+ """Wikidata에서 이미지 가져오기 (fallback)"""
317
+ try:
318
+ url = "https://www.wikidata.org/w/api.php"
319
+ params = {
320
+ "action": "wbsearchentities",
321
+ "search": name,
322
+ "language": "en",
323
+ "format": "json",
324
+ "limit": 1
325
+ }
326
+ r = requests.get(url, params=params, timeout=10).json()
327
+ if not r.get("search"):
328
+ return ""
329
+
330
+ entity_id = r["search"][0]["id"]
331
+
332
+ entity_url = f"https://www.wikidata.org/wiki/Special:EntityData/{entity_id}.json"
333
+ data = requests.get(entity_url, timeout=10).json()
334
+
335
+ claims = data["entities"][entity_id].get("claims", {})
336
+ if "P18" in claims:
337
+ filename = claims["P18"][0]["mainsnak"]["datavalue"]["value"]
338
+ return f"https://commons.wikimedia.org/wiki/Special:FilePath/{filename}"
339
+
340
+ except Exception:
341
+ pass
342
+
343
+ return ""
344
+ def _wikipedia_search_image(query: str, headers: dict) -> str:
345
+ """Wikipedia search API로 쿼리에 맞는 첫 번째 인물 사진을 반환."""
346
+ import urllib.parse
347
+ search_url = (
348
+ "https://en.wikipedia.org/w/api.php"
349
+ f"?action=query&list=search&srsearch={urllib.parse.quote(query)}"
350
+ "&srnamespace=0&srlimit=1&format=json"
351
+ )
352
+ resp = requests.get(search_url, headers=headers, timeout=10)
353
+ resp.raise_for_status()
354
+ results = resp.json().get("query", {}).get("search", [])
355
+ if not results:
356
+ return ""
357
+ page_title = results[0]["title"]
358
+ img_url = (
359
+ "https://en.wikipedia.org/w/api.php"
360
+ f"?action=query&titles={urllib.parse.quote(page_title)}"
361
+ "&prop=pageimages&format=json&pithumbsize=500"
362
+ )
363
+ resp2 = requests.get(img_url, headers=headers, timeout=10)
364
+ resp2.raise_for_status()
365
+ pages = resp2.json().get("query", {}).get("pages", {})
366
+ for page in pages.values():
367
+ thumb = page.get("thumbnail", {}).get("source")
368
+ if thumb:
369
+ return thumb
370
+ return ""
371
+
372
+ def _fetch_from_multi_wiki(name):
373
+ langs = ["en", "ko", "ja"]
374
+
375
+ for lang in langs:
376
  try:
377
+ url = (
378
+ f"https://{lang}.wikipedia.org/w/api.php"
379
+ f"?action=query&titles={name}"
380
+ "&prop=pageimages&format=json&pithumbsize=500"
381
  )
382
+ r = requests.get(url, timeout=8).json()
383
+ pages = r.get("query", {}).get("pages", {})
384
+ for page in pages.values():
385
+ if page.get("thumbnail"):
386
+ return page["thumbnail"]["source"]
387
+ except:
388
+ continue
389
+ return ""
390
+
391
+ def _fetch_wikipedia_image(full_name, background=None):
392
+
393
+ # 1. Wikidata (가장 강력)
394
+ img = _wikidata_image(full_name)
395
+ if img:
396
+ return img
397
+
398
+ # 2. 다국어 wiki
399
+ img = _fetch_from_multi_wiki(full_name)
400
+ if img:
401
+ return img
402
+
403
+ # 3. 영어 이름 variants
404
+ queries = [
405
+ full_name,
406
+ _extract_english_name(full_name),
407
+ _translate_to_english_name(full_name),
408
+ ]
409
+
410
+ if background:
411
+ queries.append(_extract_english_keywords(background))
412
+
413
+ for q in queries:
414
+ headers = {"User-Agent": "Mozilla/5.0"}
415
+
416
+ img = _wikipedia_search_image(q, headers)
417
+ if img:
418
+ return img
419
+
420
+ return ""
421
+
422
+ def generate_persona_image(name: str) -> str:
423
+ """투자자 이름으로 Wikipedia 실제 사진을 가져와 base64 data-URL을 반환."""
424
+ if not name or name == "없음":
425
+ return ""
426
+
427
+ persona = None
428
+ for p in _parse_personas():
429
+ if p.name.strip() == name:
430
+ persona = p
431
  break
432
+ if persona is None:
433
+ return ""
434
+
435
+ cache_path = _image_cache_path(persona.full_name)
436
+ if cache_path.exists():
437
+ return cache_path.read_text()
438
+
439
+ try:
440
+ data_url = _fetch_wikipedia_image(persona.full_name, persona.background)
441
+ if data_url:
442
+ cache_path.write_text(data_url)
443
+ return data_url
444
+ return ""
445
+ except Exception as e:
446
+ return f"__error__{e}"
447
+
448
+
449
+ def build_profile_html_with_image(name: str) -> str:
450
+ """이미지 생성 후 프로필 HTML을 반환 (버튼 클릭용)."""
451
+ if not name or name == "없음":
452
+ return '<p class="pf-empty">왼쪽에서 투자자를 선택하세요.</p>'
453
+
454
+ persona = None
455
+ for p in _parse_personas():
456
+ if p.name.strip() == name:
457
+ persona = p
458
+ break
459
+ if persona is None:
460
+ return '<p class="pf-empty">해당 페르소나 정보를 찾을 수 없습니다.</p>'
461
+
462
+ data_url = generate_persona_image(name)
463
+ if data_url and not data_url.startswith("__error__"):
464
+ img_html = f'<img src="{data_url}" style="width:120px;height:120px;border-radius:50%;object-fit:cover;border:2px solid #ccc">'
465
+ else:
466
+ img_html = None # 실패 시 기본 SVG 아바타 유지
467
+
468
+ html = build_profile_html(persona)
469
+ if img_html:
470
+ html = re.sub(
471
+ r'<div class="pf-avatar">.*?</div>',
472
+ f'<div class="pf-avatar">{img_html}</div>',
473
+ html,
474
+ flags=re.DOTALL,
475
+ )
476
+ return html
477
+
478
+
479
+ # ─────────────────────────────────────────────────────────────
480
+ # 스트림 분석 (핵심 로직)
481
+ # ─────────────────────────────────────────────────────────────
482
+ def _make_log_html(log_lines):
483
+ """log_lines: list of (type, text)"""
484
+ if not log_lines:
485
+ return ''
486
+ rows = []
487
+ for t, text in log_lines:
488
+ safe = html_lib.escape(text)
489
+ if t == "status":
490
+ icon = next((v for k,v in STATUS_ICONS.items() if k in text), "⏳")
491
+ rows.append(f'<div class="log-status">{icon} <span>{safe}</span></div>')
492
+ elif t == "stdout":
493
+ rows.append(f'<div class="log-stdout"><pre>{safe}</pre></div>')
494
+ elif t == "error":
495
+ rows.append(f'<div class="log-error">❌ {safe}</div>')
496
+ elif t == "done":
497
+ rows.append('<div class="log-done">✅ 분석 완료</div>')
498
+ return "\n".join(rows)
499
+
500
+
501
+ def _wrap_log(inner):
502
+ return (
503
+ '<div id="output-panel" class="phase-log">'
504
+ '<div class="panel-header"><span class="panel-title">진행 과정</span></div>'
505
+ '<div id="log-scroll">' + inner + '</div>'
506
+ '</div>'
507
+ )
508
 
 
509
 
510
+ def _wrap_answer(md_html, timer_str):
511
+ return (
512
+ '<div id="output-panel" class="phase-answer">'
513
+ '<div class="panel-header"><span class="panel-title">분석 결과</span>'
514
+ f'<span class="panel-timer">{html_lib.escape(timer_str)}</span></div>'
515
+ '<div id="answer-scroll" class="md-body">' + md_html + '</div>'
516
+ '</div>'
517
+ )
518
 
 
 
 
 
 
519
 
520
+ def _md_to_html(text):
521
+ """마크다운 텍스트를 간단한 HTML로 변환 (Gradio Markdown 렌더러 대신)."""
522
+ import re as _re
523
+ t = html_lib.escape(text)
524
+ # 헤더
525
+ t = _re.sub(r'(?m)^#### (.+)$', r'<h4>\1</h4>', t)
526
+ t = _re.sub(r'(?m)^### (.+)$', r'<h3>\1</h3>', t)
527
+ t = _re.sub(r'(?m)^## (.+)$', r'<h2>\1</h2>', t)
528
+ t = _re.sub(r'(?m)^# (.+)$', r'<h1>\1</h1>', t)
529
+ # bold / italic
530
+ t = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', t)
531
+ t = _re.sub(r'\*(.+?)\*', r'<em>\1</em>', t)
532
+ # 인라인 코드
533
+ t = _re.sub(r'`(.+?)`', r'<code>\1</code>', t)
534
+ # 리스트
535
+ t = _re.sub(r'(?m)^- (.+)$', r'<li>\1</li>', t)
536
+ t = _re.sub(r'(?m)^\d+\. (.+)$',r'<li>\1</li>', t)
537
+ # 줄바꿈
538
+ t = t.replace('\n\n', '</p><p>')
539
+ t = t.replace('\n', '<br>')
540
+ return '<p>' + t + '</p>'
541
+
542
+
543
+ IDLE_PANEL = (
544
+ '<div id="output-panel" class="phase-idle">'
545
+ '<div class="idle-msg">🔍 왼쪽에서 질문을 입력하고 질문하기를 누르세요.</div>'
546
+ '</div>'
547
+ )
548
 
549
 
550
  def stream_analyze(query, persona_name, endpoint):
551
+ """
552
+ Yields: (panel_html, timer_md, result_json)
553
+ - delta 전: panel_html = 진행 과정 로그 HTML
554
+ - delta 후: panel_html = 분석 결과 HTML (누적)
555
+ """
556
  query = (query or "").strip()
557
  endpoint = (endpoint or "").strip()
558
  persona_name = (persona_name or "").strip()
559
 
560
  if not query:
561
+ yield (_wrap_log('<div class="log-error">❌ 질문을 입력해주세요.</div>'), "", "")
562
  return
563
  if not endpoint:
564
+ yield (_wrap_log('<div class="log-error">❌ API 엔드포인트 확인해주세요.</div>'), "", "")
565
  return
566
 
567
+ text_acc = ""
568
+ log_lines = []
569
+ frozen_log = ""
570
+ result_json = ""
571
+ first_delta = False
572
+ worker_done = False
573
+ terminal = False
574
+ elapsed = _make_elapsed()
575
+ eq: Queue = Queue()
576
+
577
+ def reader():
 
 
 
 
 
 
 
 
 
 
578
  try:
579
+ body = {"query": query}
580
  if persona_name and persona_name != "없음":
581
+ body["persona_name"] = persona_name
582
+ with requests.post(endpoint, json=body,
583
+ headers={"Accept": "text/event-stream"},
584
+ stream=True, timeout=(10, 300)) as resp:
585
+ resp.raise_for_status()
586
+ for raw in resp.iter_lines(chunk_size=1, decode_unicode=True):
587
+ if not raw: continue
588
+ line = raw.strip()
589
+ if not line.startswith("data:"): continue
 
 
 
 
 
 
 
 
 
 
 
590
  try:
591
+ eq.put(("event", json.loads(line[5:].strip())))
592
  except json.JSONDecodeError:
593
  continue
 
 
594
  except requests.exceptions.ConnectionError:
595
+ eq.put(("exception", f"연결 실패: {endpoint}"))
596
  except requests.exceptions.Timeout:
597
+ eq.put(("exception", "요청 시간 초과"))
598
+ except requests.RequestException as e:
599
+ eq.put(("exception", f"요청 실패: {e}"))
600
  finally:
601
+ eq.put(("worker_done", None))
602
 
603
+ Thread(target=reader, daemon=True).start()
604
 
605
  while True:
606
  try:
607
+ kind, payload = eq.get(timeout=0.1)
608
+ buf = [(kind, payload)]
609
  while True:
610
+ try: buf.append(eq.get_nowait())
611
+ except Empty: break
 
 
612
  except Empty:
613
+ buf = []
614
 
615
+ for kind, payload in buf:
616
  if kind == "event":
617
+ et = payload.get("type")
618
+
619
+ if et == "status":
620
+ if not first_delta:
621
+ msg = payload.get("message", "")
622
+ if msg:
623
+ log_lines.append(("status", msg))
624
+
625
+ elif et == "stdout":
626
+ if not first_delta:
627
+ msg = payload.get("message", "")
628
+ if msg:
629
+ if log_lines and log_lines[-1][0] == "stdout":
630
+ log_lines[-1] = ("stdout", log_lines[-1][1] + msg)
631
+ else:
632
+ log_lines.append(("stdout", msg))
633
+
634
+ elif et == "delta":
635
  delta = payload.get("delta", "")
636
  if delta:
637
+ if not first_delta:
638
+ first_delta = True
639
+ frozen_log = _make_log_html(log_lines)
640
  text_acc += delta
641
 
642
+ elif et == "result":
643
  result = payload.get("result", payload)
644
+ result_json = json.dumps(result, ensure_ascii=False, indent=2)
645
+ if not text_acc and result.get("llm_response"):
646
+ first_delta = True
647
+ frozen_log = _make_log_html(log_lines)
648
+ text_acc = result["llm_response"]
649
+ terminal = True
650
+
651
+ elif et == "error":
652
+ msg = payload.get("message", "오류 발생")
653
+ log_lines.append(("error", msg))
654
+ terminal = True
655
+
656
+ elif et == "done":
657
+ terminal = True
 
 
 
 
 
 
 
 
 
 
 
658
 
659
  elif kind == "exception":
660
+ log_lines.append(("error", str(payload)))
661
+ terminal = True
662
 
663
  elif kind == "worker_done":
664
+ worker_done = True
665
 
666
+ t = timer_text(elapsed())
667
+ if first_delta:
668
+ panel = _wrap_answer(_md_to_html(text_acc), t)
669
  else:
670
+ panel = _wrap_log(_make_log_html(log_lines))
671
+ yield (panel, t, result_json)
672
 
673
+ if worker_done:
 
 
 
 
 
 
 
674
  break
675
 
676
+ # 최종
677
+ t = timer_text(elapsed())
678
+ if first_delta:
679
+ panel = _wrap_answer(_md_to_html(text_acc), t)
680
+ else:
681
+ panel = _wrap_log(_make_log_html(log_lines))
682
+ yield (panel, t, result_json)
683
 
684
+
685
+ # ────────────────────────────────────────────────────��────────
686
+ # 페르소나 생성 스트림
687
+ # ─────────────────────────────────────────────────────────────
688
+ def generate_persona_stream(info, endpoint):
689
+ if not info or not info.strip():
690
+ yield "인물 정보를 입력해주세요.", "{}", timer_text("0.0초")
691
+ return
692
+
693
+ persona_ep = endpoint.rstrip("/").rsplit("/", 1)[0] + "/persona/"
694
+ elapsed = _make_elapsed()
695
+ q: Queue = Queue()
696
+
697
+ def worker():
698
+ try:
699
+ r = requests.post(persona_ep, json={"info": info.strip()}, timeout=(10, 300))
700
+ r.raise_for_status()
701
+ q.put(("ok", r.json()))
702
+ except requests.exceptions.ConnectionError:
703
+ q.put(("error", f"연결 실패: {persona_ep}"))
704
+ except requests.exceptions.Timeout:
705
+ q.put(("error", "요청 시간 초과"))
706
+ except requests.RequestException as e:
707
+ q.put(("error", f"요청 실패: {e}"))
708
+
709
+ Thread(target=worker, daemon=True).start()
710
+
711
+ while True:
712
+ try:
713
+ kind, payload = q.get_nowait(); break
714
+ except Empty:
715
+ yield (
716
+ '<div class="ws-loading shimmer"><div class="ws-loading-title">⏳ 페르소나 생성 중...</div>'
717
+ '<div class="ws-loading-msg">AI가 인물 정보를 검색하고 있습니다</div></div>',
718
+ "{}", timer_text(elapsed())
719
+ )
720
+ time.sleep(0.3)
721
+
722
+ if kind == "error":
723
+ yield payload, "{}", timer_text(elapsed())
724
+ return
725
+
726
+ data = payload
727
+ md = "\n\n".join([
728
+ f"**이름**: {data.get('name','')}",
729
+ f"**배경**: {data.get('background','')}",
730
+ f"**금융 사고 방식**: {data.get('financial_mindset','')}",
731
+ f"**데이터 분석 방식**: {data.get('data_analysis_approach','')}",
732
+ f"**답변 스타일**: {data.get('response_style','')}",
733
+ f"**핵심 원칙**: {', '.join(data.get('key_principles',[]))}",
734
+ ])
735
+ if data.get("famous_quotes"):
736
+ md += f"\n\n**어록**: {' / '.join(data['famous_quotes'])}"
737
+ yield md, json.dumps(data, ensure_ascii=False, indent=2), timer_text(elapsed())
738
+
739
+
740
+ # ─────────────────────────────────────────────────────────────
741
+ # CSS
742
+ # ─────────────────────────────────────────────────────────────
743
+ CSS = """
744
+ @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap");
745
+
746
+ :root {
747
+ --ws-bg: #f7faf9;
748
+ --ws-surface: #ffffff;
749
+ --ws-border: #dbe5e2;
750
+ --ws-text: #182022;
751
+ --ws-muted: #5d6b70;
752
+ --ws-accent: #0f766e;
753
+ --ws-accent2: #14b8a6;
754
+ --ws-code-bg: #f1f5f9;
755
+ --ws-green-bg: #f0fdfa;
756
+ --ws-green-border: #99f6e4;
757
+ }
758
+
759
+ /* ── 전역 폰트 ── */
760
+ .gradio-container,
761
+ .gradio-container :is(h1,h2,h3,h4,h5,h6,p,span,div,label,button,input,textarea,select) {
762
+ font-family: "IBM Plex Sans KR","Noto Sans KR","Source Sans 3",sans-serif !important;
763
+ letter-spacing: 0.005em;
764
+ }
765
+ .gradio-container {
766
+ background: radial-gradient(circle at top left,#edf9f6 0%,#f8fbfc 35%,#fdfefe 100%) !important;
767
+ }
768
+
769
+ /* ── 헤더 ── */
770
+ #ws-header {
771
+ background: linear-gradient(135deg,#f0fdfa 0%,#e8faf7 55%,#f7faf9 100%);
772
+ border: 1px solid #b2e8e2;
773
+ border-radius: 14px;
774
+ padding: 20px 28px 16px;
775
+ margin-bottom: 8px;
776
+ position: relative; overflow: hidden;
777
+ }
778
+ #ws-header::before {
779
+ content:""; position:absolute; top:-70px; right:-70px;
780
+ width:260px; height:260px;
781
+ background:radial-gradient(circle,rgba(20,184,166,.13) 0%,transparent 68%);
782
+ pointer-events:none;
783
+ }
784
+ #ws-header h1 { font-size:22px !important; font-weight:700 !important; color:#0b3b39 !important; margin:0 0 4px !important; }
785
+ #ws-header p { font-size:13px !important; color:var(--ws-muted) !important; margin:0 !important; }
786
+ #ws-header .ws-badge {
787
+ display:inline-block; padding:1px 8px; border-radius:20px;
788
+ font-size:10px; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
789
+ background:rgba(15,118,110,.1); border:1px solid rgba(15,118,110,.25);
790
+ color:var(--ws-accent); margin-right:7px; vertical-align:middle;
791
+ }
792
+
793
+ /* ── 탭 ── */
794
+ .tab-nav button {
795
+ background:transparent !important; color:var(--ws-muted) !important;
796
+ border:none !important; border-bottom:2px solid transparent !important;
797
+ font-size:13px !important; font-weight:600 !important;
798
+ padding:8px 18px !important; border-radius:0 !important;
799
+ transition:color .18s,border-color .18s !important;
800
+ }
801
+ .tab-nav button.selected,.tab-nav button:hover {
802
+ color:var(--ws-accent) !important; border-bottom-color:var(--ws-accent) !important;
803
+ background:transparent !important;
804
+ }
805
+
806
+ /* ══════════════════════════════════════════════
807
+ 질문하기 탭 — 좌우 분할 레이아웃
808
+ ══════════════════════════════════════════════ */
809
+
810
+ /* 좌: 입력 패널 */
811
+ #input-col {
812
+ background: var(--ws-surface);
813
+ border: 1px solid var(--ws-border) !important;
814
+ border-radius: 14px !important;
815
+ padding: 18px 20px !important;
816
+ box-shadow: 0 2px 12px rgba(16,24,40,.04);
817
+ display: flex; flex-direction: column; gap: 10px;
818
+ }
819
+
820
+ .ws-label {
821
+ font-size: 10px; font-weight: 700; text-transform: uppercase;
822
+ letter-spacing: .08em; color: var(--ws-muted); margin-bottom: 4px;
823
+ }
824
+ .ws-divider { border:none; border-top:1px solid var(--ws-border); margin:10px 0; }
825
+
826
+ /* 페르소나 요약 */
827
+ #persona-summary {
828
+ background: linear-gradient(180deg,#f9fefd 0%,#f3fbf9 100%) !important;
829
+ border: 1px solid #cde8e3 !important; border-radius: 10px !important;
830
+ padding: 10px 14px !important; font-size: 13px !important;
831
+ color: var(--ws-text) !important;
832
+ }
833
+ #persona-summary > .wrap,#persona-summary > div.prose { padding:0!important;border:none!important;box-shadow:none!important; }
834
+
835
+ /* 예시 버튼 그리드 */
836
+ #example-grid {
837
+ display: grid;
838
+ grid-template-columns: 1fr 1fr 1fr;
839
+ gap: 5px;
840
+ }
841
+ #example-grid button {
842
+ width: 100% !important; text-align: left !important;
843
+ background: var(--ws-code-bg) !important; border: 1px solid var(--ws-border) !important;
844
+ border-radius: 8px !important; color: var(--ws-text) !important;
845
+ font-size: 11px !important; padding: 7px 10px !important;
846
+ white-space: normal !important; line-height: 1.4 !important;
847
+ transition: border-color .18s, color .18s, background .18s !important;
848
+ min-height: 44px;
849
+ }
850
+ #example-grid button:hover {
851
+ background: var(--ws-green-bg) !important; border-color: var(--ws-accent2) !important;
852
+ color: var(--ws-accent) !important;
853
+ }
854
+
855
+ /* 분석 버튼 */
856
+ #run-btn {
857
+ background: linear-gradient(135deg,#0f766e 0%,#14b8a6 100%) !important;
858
+ border: none !important; color: #fff !important; font-weight: 700 !important;
859
+ font-size: 13px !important; border-radius: 9px !important;
860
+ transition: opacity .18s, transform .12s !important;
861
+ }
862
+ #run-btn:hover { opacity:.87!important; transform:translateY(-1px)!important; }
863
+ #clear-btn {
864
+ background: var(--ws-code-bg) !important; border: 1px solid var(--ws-border) !important;
865
+ color: var(--ws-muted) !important; border-radius: 9px !important; font-size:12px!important;
866
+ transition: border-color .18s,color .18s !important;
867
+ }
868
+ #clear-btn:hover { border-color:var(--ws-accent)!important; color:var(--ws-accent)!important; }
869
+
870
+ /* 새로고침 버튼 */
871
+ #refresh-btn {
872
+ min-width:34px!important; padding:0 8px!important;
873
+ background:var(--ws-code-bg)!important; border:1px solid var(--ws-border)!important;
874
+ color:var(--ws-muted)!important; border-radius:8px!important; font-size:15px!important;
875
+ transition:color .18s,border-color .18s!important;
876
+ }
877
+ #refresh-btn:hover { color:var(--ws-accent)!important; border-color:var(--ws-accent)!important; background:var(--ws-green-bg)!important; }
878
+
879
+ /* 우: 출력 컬럼 */
880
+ #output-col {
881
+ border: 1px solid var(--ws-border) !important;
882
+ border-radius: 14px !important;
883
+ overflow: hidden;
884
+ background: var(--ws-surface);
885
+ box-shadow: 0 2px 12px rgba(16,24,40,.04);
886
+ }
887
+ /* output-col 안의 Gradio 래퍼들 여백 제거 */
888
+ #output-col > .wrap, #output-col > div {
889
+ padding: 0 !important; margin: 0 !important;
890
+ border: none !important; box-shadow: none !important;
891
+ }
892
+
893
+ /* 단일 출력 패널 */
894
+ #output-panel {
895
+ display: flex;
896
+ flex-direction: column;
897
+ min-height: 520px;
898
+ }
899
+
900
+ /* 패널 헤더 */
901
+ .panel-header {
902
+ display: flex; align-items: center; justify-content: space-between;
903
+ padding: 10px 16px;
904
+ background: #f7faf9;
905
+ border-bottom: 1px solid var(--ws-border);
906
+ flex-shrink: 0;
907
+ }
908
+ .panel-title {
909
+ font-size: 11px; font-weight: 700; text-transform: uppercase;
910
+ letter-spacing: .07em; color: var(--ws-muted);
911
+ }
912
+ .panel-timer {
913
+ font-size: 11px; font-weight: 600; color: var(--ws-accent);
914
+ background: var(--ws-green-bg); border: 1px solid var(--ws-green-border);
915
+ padding: 2px 10px; border-radius: 999px;
916
+ }
917
+
918
+ /* 대기 상태 */
919
+ .idle-msg {
920
+ display: flex; align-items: center; justify-content: center;
921
+ height: 480px;
922
+ color: var(--ws-muted); font-size: 13px;
923
+ }
924
+
925
+ /* 로그 단계 */
926
+ #log-scroll {
927
+ flex: 1;
928
+ overflow-y: auto;
929
+ padding: 14px 18px;
930
+ font-size: 12px; line-height: 1.7;
931
+ max-height: calc(100vh - 260px);
932
+ }
933
+ .log-status {
934
+ display: flex; align-items: flex-start; gap: 7px;
935
+ padding: 3px 0; color: var(--ws-text);
936
+ }
937
+ .log-status span { color: var(--ws-text); }
938
+ .log-stdout pre {
939
+ margin: 3px 0; padding: 4px 10px;
940
+ background: #f1f8f7; border-left: 3px solid var(--ws-accent2);
941
+ border-radius: 0 5px 5px 0;
942
+ font-family: "JetBrains Mono","IBM Plex Mono",monospace !important;
943
+ font-size: 11px !important; color: var(--ws-muted);
944
+ white-space: pre-wrap; word-break: break-all;
945
+ }
946
+ .log-done { color: var(--ws-accent); font-weight: 700; padding: 4px 0; }
947
+ .log-error { color: #e53e3e; padding: 4px 0; }
948
+
949
+ /* 답변 단계 */
950
+ #answer-scroll {
951
+ flex: 1;
952
+ overflow-y: auto;
953
+ padding: 18px 22px;
954
+ max-height: calc(100vh - 260px);
955
+ }
956
+ .md-body {
957
+ color: var(--ws-text); line-height: 1.75; font-size: 14.5px;
958
+ font-family: "IBM Plex Sans KR","Noto Sans KR","Source Sans 3",sans-serif;
959
+ }
960
+ .md-body h1,.md-body h2,.md-body h3,.md-body h4 { color: #0b3b39; margin: .85em 0 .35em; }
961
+ .md-body h2 { font-size: 16px; border-bottom: 1px solid var(--ws-border); padding-bottom: 5px; }
962
+ .md-body h3 { font-size: 14px; color: var(--ws-accent); }
963
+ .md-body h4 { font-size: 13px; }
964
+ .md-body p { margin: .4em 0; }
965
+ .md-body strong { font-weight: 700; }
966
+ .md-body em { font-style: italic; }
967
+ .md-body ul,.md-body ol { margin: .4em 0; padding-left: 1.5em; }
968
+ .md-body li { margin: .2em 0; }
969
+ .md-body code {
970
+ background: var(--ws-code-bg); color: #0b3b39;
971
+ border: 1px solid #d9e2ec; border-radius: 5px;
972
+ padding: .1em .35em; font-size: .91em;
973
+ font-family: "JetBrains Mono","IBM Plex Mono",monospace;
974
+ }
975
+ .md-body pre {
976
+ background: #0f172a; color: #e2e8f0;
977
+ border-radius: 10px; border: 1px solid #1e293b;
978
+ padding: .85em 1em; overflow-x: auto; margin: .7em 0;
979
+ }
980
+ .md-body pre code { background: transparent; border: none; color: inherit; padding: 0; }
981
+ .md-body blockquote {
982
+ margin: .8em 0; padding: .6em .9em;
983
+ border-left: 4px solid var(--ws-accent2);
984
+ background: var(--ws-green-bg); color: #115e59;
985
+ border-radius: 0 8px 8px 0;
986
+ }
987
+ .md-body table { width:100%; border-collapse:collapse; margin:.7em 0; }
988
+ .md-body th {
989
+ background:#eef6f4; color:#0f3f3b; font-weight:600; font-size:12px;
990
+ text-transform:uppercase; letter-spacing:.04em;
991
+ padding:7px 10px; border:1px solid var(--ws-border);
992
+ }
993
+ .md-body td { border:1px solid var(--ws-border); padding:6px 10px; vertical-align:top; }
994
+ .md-body tr:hover td { background:#f9fefd; }
995
+
996
+ /* 타이머는 패널 헤더 안에 내장됨 */
997
+
998
+ /* ── 하단 JSON 고정 ── */
999
+ #result-json-wrap {
1000
+ border-top: 2px solid var(--ws-border);
1001
+ background: var(--ws-surface);
1002
+ }
1003
+ #result-json-wrap .accordion-header { padding: 10px 16px !important; }
1004
+ #meta-box {
1005
+ max-height: 220px; overflow-y: auto;
1006
+ background: var(--ws-surface)!important; border:none!important;
1007
+ }
1008
+ #meta-box code,#meta-box pre {
1009
+ font-family:"JetBrains Mono",monospace!important;
1010
+ font-size:11.5px!important; color:var(--ws-muted)!important; background:transparent!important;
1011
+ }
1012
+
1013
+ /* ── 로딩 (페르소나 탭용) ── */
1014
+ .ws-loading {
1015
+ position:relative; overflow:hidden; border:1px solid #cde8e3; border-radius:12px;
1016
+ background:linear-gradient(180deg,#f9fefd 0%,#f3fbf9 100%); padding:14px 16px;
1017
+ }
1018
+ .ws-loading-title { color:var(--ws-accent); font-weight:700; margin-bottom:6px; }
1019
+ .ws-loading-msg { color:#365055; font-size:13px; }
1020
+ .shimmer::after {
1021
+ content:""; position:absolute; top:0; left:-140%; width:80%; height:100%;
1022
+ background:linear-gradient(100deg,rgba(255,255,255,0) 0%,rgba(255,255,255,.55) 45%,rgba(255,255,255,0) 100%);
1023
+ animation:ws-shimmer 1.6s ease-in-out infinite;
1024
+ }
1025
+ @keyframes ws-shimmer { 0%{left:-140%} 100%{left:150%} }
1026
+
1027
+ /* ── 페르소나 생성 탭 ── */
1028
+ #persona-result-wrapper {
1029
+ min-height:180px; max-height:50vh; overflow-y:auto!important;
1030
+ border:1px solid var(--ws-border)!important; border-radius:14px!important;
1031
+ background:var(--ws-surface)!important; padding:20px 24px!important;
1032
+ color:var(--ws-text)!important; font-size:14px!important; line-height:1.72!important;
1033
+ }
1034
+
1035
+ /* ── 프로필 카드 ── */
1036
+ #profile-wrapper { max-height:78vh; overflow-y:auto; padding:4px 2px; }
1037
+ .pf-empty { color:var(--ws-muted); font-size:14px; text-align:center; padding:40px 20px; }
1038
+ .pf-card { background:var(--ws-surface); border:1px solid var(--ws-border); border-radius:16px; overflow:hidden; box-shadow:0 4px 20px rgba(16,24,40,.07); }
1039
+ .pf-header { display:flex; gap:24px; align-items:flex-start; padding:28px 28px 20px; background:linear-gradient(135deg,#f0fdfa 0%,#e8faf7 60%,#f7faf9 100%); border-bottom:1px solid var(--ws-border); }
1040
+ .pf-avatar { flex-shrink:0; width:120px; height:120px; border-radius:50%; overflow:hidden; border:3px solid #b2e8e2; box-shadow:0 4px 16px rgba(15,118,110,.18); }
1041
+ .pf-avatar svg { display:block; width:100%; height:100%; }
1042
+ .pf-header-info { flex:1; min-width:0; }
1043
+ .pf-name { font-size:22px!important; font-weight:700!important; color:#0b3b39!important; margin:0 0 4px!important; }
1044
+ .pf-subtitle { font-size:13px!important; color:var(--ws-accent)!important; font-weight:600!important; margin:0 0 10px!important; }
1045
+ .pf-bg { font-size:13px!important; color:var(--ws-muted)!important; line-height:1.6!important; margin:0!important; }
1046
+ .pf-meta-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); border-bottom:1px solid var(--ws-border); }
1047
+ .pf-meta-row { display:flex; flex-direction:column; padding:12px 20px; border-right:1px solid var(--ws-border); }
1048
+ .pf-meta-row:last-child { border-right:none; }
1049
+ .pf-meta-label { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--ws-muted); margin-bottom:3px; }
1050
+ .pf-meta-val { font-size:14px; font-weight:600; color:var(--ws-text); }
1051
+ .pf-section { padding:18px 24px; border-bottom:1px solid #eef4f2; }
1052
+ .pf-section:last-child { border-bottom:none; }
1053
+ .pf-section-title { font-size:12px!important; font-weight:700!important; text-transform:uppercase!important; letter-spacing:.07em!important; color:var(--ws-accent)!important; margin:0 0 8px!important; }
1054
+ .pf-text { font-size:14px!important; color:var(--ws-text)!important; line-height:1.7!important; margin:0!important; }
1055
+ .pf-list { margin:0!important; padding-left:1.2em!important; }
1056
+ .pf-list li { font-size:14px!important; color:var(--ws-text)!important; line-height:1.65!important; margin:4px 0!important; }
1057
+ .pf-trades { display:flex; flex-direction:column; gap:6px; }
1058
+ .pf-trade-item { font-size:13px; color:var(--ws-text); background:var(--ws-code-bg); border-left:3px solid var(--ws-accent2); border-radius:0 6px 6px 0; padding:6px 12px; }
1059
+ .pf-quote { margin:6px 0!important; padding:.6em 1em!important; border-left:4px solid var(--ws-accent2)!important; background:var(--ws-green-bg)!important; color:#115e59!important; border-radius:0 8px 8px 0; font-size:14px!important; font-style:italic; }
1060
+
1061
+ /* ── 스크롤바 ── */
1062
+ ::-webkit-scrollbar { width:5px; height:5px; }
1063
+ ::-webkit-scrollbar-track { background:transparent; }
1064
+ ::-webkit-scrollbar-thumb { background:var(--ws-border); border-radius:3px; }
1065
+ ::-webkit-scrollbar-thumb:hover { background:#aec5c1; }
1066
+ body:has(.options:not(.hide)) { overflow:hidden!important; }
1067
+
1068
+ /* ── 반응형 ── */
1069
+ @media (max-width: 768px) {
1070
+ #example-grid { grid-template-columns: 1fr 1fr !important; }
1071
+ .pf-header { flex-direction:column; }
1072
+ .pf-avatar { width:80px; height:80px; }
1073
+ }
1074
+ """
1075
+
1076
+ HEADER_HTML = """
1077
+ <div id="ws-header">
1078
+ <h1>📈 Wallstreet AI</h1>
1079
+ <p><span class="ws-badge">Live</span>실적 · 뉴스 · 시장 트렌드를 한 곳에서 &mdash; AI 금융 분석 플랫폼</p>
1080
+ </div>
1081
+ """
1082
+
1083
+
1084
+ # ─────────────────────────────────────────────────────────────
1085
+ # Gradio 앱 빌드
1086
+ # ─────────────────────────────────────────────────────────────
1087
  def create_app(default_endpoint):
1088
+ theme = gr.themes.Soft(primary_hue="emerald", secondary_hue="teal",
1089
+ neutral_hue="slate", radius_size="lg")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1090
 
1091
+ with gr.Blocks(title="Wallstreet AI", css=CSS, theme=theme,
1092
+ analytics_enabled=False) as demo:
1093
+
1094
+ # 엔드포인트 — 숨김 상태로 보관 (UI에서 안 보임)
1095
+ endpoint_state = gr.State(default_endpoint)
 
1096
 
1097
+ gr.HTML(HEADER_HTML)
 
 
1098
 
1099
  with gr.Tabs():
1100
+
1101
+ # ════════════════════════════════════════════
1102
+ # TAB 1 : 질문하기 (좌/우 반반)
1103
+ # ════════════════════════════════════════════
1104
  with gr.Tab("💬 질문하기"):
1105
+ with gr.Row(equal_height=False):
1106
+
1107
+ # ── 좌: 입력 패널 ──────────────────
1108
+ with gr.Column(scale=1, min_width=280, elem_id="input-col"):
1109
+
1110
+ # 페르소나
1111
+ gr.HTML("<p class='ws-label'>페르소나</p>")
1112
+ with gr.Row():
1113
+ persona_dd = gr.Dropdown(
1114
+ label="", choices=load_persona_names(),
1115
+ value="없음", interactive=True,
1116
+ scale=5, show_label=False,
1117
+ )
1118
+ refresh_btn = gr.Button("↺", size="sm", scale=1,
1119
+ min_width=34, elem_id="refresh-btn")
1120
+ persona_summary = gr.Markdown(value="",elem_id="persona-summary",visible=True)
1121
+
1122
+ gr.HTML("<hr class='ws-divider'>")
1123
+
1124
+ # 예시 질문 (유형별 9개 버튼)
1125
+ gr.HTML("<p class='ws-label'>예시 질문</p>")
1126
+ with gr.Column(elem_id="example-grid"):
1127
+ example_btns = []
1128
+ for key, example_text in EXAMPLES_BY_TYPE.items():
1129
+ label = ANALYSIS_TYPE_LABELS[key]
1130
+ b = gr.Button(f"{label}: {example_text}", size="sm")
1131
+ example_btns.append((b, example_text))
1132
+
1133
+ gr.HTML("<hr class='ws-divider'>")
1134
+
1135
+ # 질문 입력
1136
+ gr.HTML("<p class='ws-label'>질문 입력</p>")
1137
+ query_input = gr.Textbox(
1138
+ label="",
1139
+ placeholder="종목명, 티커, 분석 요청을 입력하세요...",
1140
+ lines=3, value=EXAMPLE_QUERIES[0], show_label=False,
1141
  )
1142
+ with gr.Row():
1143
+ run_btn = gr.Button("🔍 질문하기", variant="primary",
1144
+ scale=3, elem_id="run-btn")
1145
+ clear_btn = gr.Button("초기화", scale=1, elem_id="clear-btn")
1146
+
1147
+ # ── 우: 단일 출력 패널 ──────────────────
1148
+ with gr.Column(scale=1, min_width=300, elem_id="output-col"):
1149
+ output_panel = gr.HTML(
1150
+ value=IDLE_PANEL,
1151
+ show_label=False,
1152
  )
1153
+ timer = gr.Markdown(value="", visible=False) # 내부용 더미
1154
+
1155
+ # 하단 JSON (전체 너비)
1156
+ with gr.Row(elem_id="result-json-wrap"):
1157
+ with gr.Column():
1158
+ gr.HTML("<p class='ws-label'>📄 원본 데이터 (JSON)</p>")
1159
+ meta = gr.Code(
1160
+ label="", language="json",
1161
+ elem_id="meta-box", show_label=False,
1162
  )
 
 
 
 
 
 
 
 
 
 
1163
 
1164
+ gr.HTML(AUTO_SCROLL_JS, visible=False)
 
 
1165
 
1166
+ # ── 이벤트 ──
1167
+ def on_run(q, persona, ep):
1168
+ for panel, t, rj in stream_analyze(q, persona, ep):
1169
+ yield panel, t, rj
1170
 
1171
  run_btn.click(
1172
+ fn=on_run,
1173
+ inputs=[query_input, persona_dd, endpoint_state],
1174
+ outputs=[output_panel, timer, meta],
1175
  )
1176
+ query_input.submit(
1177
+ fn=on_run,
1178
+ inputs=[query_input, persona_dd, endpoint_state],
1179
+ outputs=[output_panel, timer, meta],
1180
  )
1181
  clear_btn.click(
1182
+ fn=lambda: (IDLE_PANEL, "", ""),
1183
+ outputs=[output_panel, timer, meta],
1184
  )
1185
  refresh_btn.click(
1186
+ fn=lambda: gr.update(choices=load_persona_names(), value="없음"),
1187
+ outputs=[persona_dd],
1188
  )
1189
 
1190
+ def on_persona_change(name):
1191
+ info = load_persona_summary(name)
1192
+ return gr.update(value=info)
 
 
 
1193
 
1194
+ persona_dd.change(
1195
+ fn=on_persona_change, inputs=[persona_dd], outputs=[persona_summary])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1196
 
1197
+ # 예시 버튼 클릭 → query_input 에 채우기
1198
+ for _b, _text in example_btns:
1199
+ _b.click(fn=lambda t=_text: t, outputs=[query_input])
1200
+
1201
+ # ════════════════════════════════════════════
1202
+ # TAB 2 : 페르소나 만들기
1203
+ # ════════════════════════════════════════════
1204
+ with gr.Tab("🧑‍💼 페르소나 만들기"):
1205
+ gr.HTML("<p class='ws-label'>금융 인물 이름이나 설명을 입력하면 AI가 투자 철학·분석 스타일을 자동으로 구성합니다.</p>")
1206
+ with gr.Row(equal_height=False):
1207
+ with gr.Column(scale=3, min_width=300):
1208
+ persona_input = gr.Textbox(
1209
+ label="인물 정보",
1210
+ placeholder="예: 워렌 버핏, JP모건, 가타야마 아키라 ...", lines=3)
1211
+ persona_gen_btn = gr.Button("✨ 페르소나 생성", variant="primary")
1212
+ with gr.Column(scale=1, min_width=140):
1213
+ gr.HTML("<p class='ws-label'>예시 인물</p>")
1214
+ for ep in ["워렌 버핏", "JP모건", "가타��마 아키라"]:
1215
+ gr.Button(ep, size="sm").click(fn=lambda x=ep: x, outputs=[persona_input])
1216
+
1217
+ persona_result = gr.Markdown(value="", label="생성 결과",
1218
+ elem_id="persona-result-wrapper")
1219
+ persona_timer = gr.Markdown(value="", elem_id="timer-row")
1220
+ with gr.Accordion("📄 페르소나 JSON", open=False):
1221
+ persona_json = gr.Code(label="", language="json",
1222
+ elem_id="meta-box", show_label=False)
1223
 
1224
  persona_gen_btn.click(
1225
  fn=generate_persona_stream,
1226
+ inputs=[persona_input, endpoint_state],
1227
+ outputs=[persona_result, persona_json, persona_timer],
1228
  )
1229
 
1230
+ # ════════════════════════════════════════════
1231
+ # TAB 3 : 투자자 프로필
1232
+ # ════════════════════════════════════════════
1233
+ with gr.Tab("👤 투자자 프로필"):
1234
+ with gr.Row():
1235
+ with gr.Column(scale=1, min_width=180):
1236
+ gr.HTML("<p class='ws-label'>투자자 선택</p>")
1237
+ with gr.Row():
1238
+ profile_dd = gr.Dropdown(
1239
+ label="", choices=load_persona_names(), value="없음",
1240
+ interactive=True, scale=5, show_label=False)
1241
+ profile_refresh = gr.Button("↺", size="sm", scale=1,
1242
+ min_width=34, elem_id="refresh-btn")
1243
+ with gr.Column(scale=3): pass
1244
+
1245
+ profile_card = gr.HTML(
1246
+ value='<p class="pf-empty">왼쪽에서 투자자를 선택하세요.</p>',
1247
+ elem_id="profile-wrapper")
1248
+
1249
+ profile_dd.change(fn=build_profile_html_with_image, inputs=[profile_dd], outputs=[profile_card])
1250
+ profile_refresh.click(
1251
+ fn=lambda: gr.update(choices=load_persona_names(), value="없음"),
1252
+ outputs=[profile_dd])
1253
+
1254
  return demo
1255
 
1256
 
1257
+ # ─────────────────────────────────────────────────────────────
1258
+ # 진입점
1259
+ # ─────────────────────────────────────────────────────────────
1260
  def main():
1261
  parser = argparse.ArgumentParser(description="Wallstreet-AI Gradio UI")
1262
+ parser.add_argument("--api-url", type=str, default=DEFAULT_ENDPOINT)
1263
+ parser.add_argument("--share", action="store_true")
1264
  parser.add_argument("--server-name", type=str, default="0.0.0.0")
1265
+ parser.add_argument("--port", type=int, default=7860)
1266
  args = parser.parse_args()
1267
 
1268
  print(f"FastAPI : {args.api_url}")
 
1270
 
1271
  app = create_app(args.api_url)
1272
  app.queue(default_concurrency_limit=8, max_size=64)
1273
+ app.launch(share=args.share, server_name=args.server_name,
1274
+ server_port=args.port, debug=True)
 
 
 
 
1275
 
1276
 
1277
  if __name__ == "__main__":
1278
+ main()