june-woo commited on
Commit
2e20f08
·
verified ·
1 Parent(s): 0279a41

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +932 -0
app.py ADDED
@@ -0,0 +1,932 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import html as html_lib
3
+ import io
4
+ import json
5
+ import os
6
+ import re
7
+ import sys
8
+ import time
9
+ from dataclasses import asdict, is_dataclass
10
+ from pathlib import Path
11
+ from queue import Empty, Queue
12
+ from threading import Thread
13
+
14
+ import gradio as gr
15
+ from pydantic import BaseModel, ValidationError
16
+
17
+ # ─────────────────────────────────────────────────────────────
18
+ # 설정
19
+ # ─────────────────────────────────────────────────────────────
20
+ os.environ.setdefault("LLM_MODEL_NAME", "gpt-4o-mini")
21
+ os.environ.setdefault("LOG_FILE", "analysis_results.jsonl")
22
+ os.environ.setdefault("PERSONA_FILE", "persona.jsonl")
23
+
24
+ PERSONA_FILE = Path(os.environ.get("PERSONA_FILE", "persona.jsonl"))
25
+ IMAGE_CACHE_DIR = Path(".persona_images")
26
+ IMAGE_CACHE_DIR.mkdir(exist_ok=True)
27
+
28
+ LOCAL_IMAGE_DIR = Path(os.environ.get("LOCAL_IMAGE_DIR", Path(__file__).parent / "persona_images"))
29
+ DEFAULT_IMAGE_FILENAME = "default.png"
30
+
31
+ EXAMPLES_BY_TYPE = {
32
+ "screener": "PER 낮은 대형주 추천해주세요",
33
+ "technical": "Apple(AAPL) 차트 분석해주세요",
34
+ "fundamental": "Microsoft 재무상태 어때요?",
35
+ "news_summary": "Tesla 최근 뉴스 요약해 주세요",
36
+ "comparison": "Apple vs Microsoft 비교 분석해 주세요",
37
+ "earnings": "2025년 4분기 삼성전자 실적은 어땠나요?",
38
+ "swot": "OpenAI 경쟁력 분석해 주세요",
39
+ "general": "Tesla(TSLA) 어떻게 보시나요?",
40
+ "watchlist": "내 관심종목(삼성전자, SK하이닉스, Apple, Microsoft, Tesla) 현황 봐주세요",
41
+ }
42
+
43
+ EXAMPLE_QUERIES = list(EXAMPLES_BY_TYPE.values())
44
+
45
+ AUTO_SCROLL_JS = """
46
+ <script>
47
+ (function(){
48
+ function scroll(){
49
+ var el = document.getElementById('log-scroll') || document.getElementById('answer-scroll');
50
+ if(el) el.scrollTop = el.scrollHeight;
51
+ }
52
+ var mo = new MutationObserver(scroll);
53
+ function attach(){
54
+ var root = document.getElementById('output-col') || document.body;
55
+ mo.observe(root, {childList:true, subtree:true, characterData:true});
56
+ scroll();
57
+ }
58
+ attach();
59
+ setInterval(scroll, 400);
60
+ })();
61
+ </script>
62
+ """
63
+
64
+
65
+ # ─────────────────────────────────────────────────────────────
66
+ # 헬퍼
67
+ # ─────────────────────────────────────────────────────────────
68
+ def to_md(text):
69
+ return text or ""
70
+
71
+ def timer_text(elapsed):
72
+ return f"⏱ {elapsed}"
73
+
74
+ def _make_elapsed():
75
+ t0 = time.time()
76
+ return lambda: f"{time.time()-t0:.1f}초"
77
+
78
+ _PAREN_MD = re.compile(r'\s*\(\s*\[[^\]]*\]\([^)]*\)\s*\)')
79
+ _MD_LINK = re.compile(r'\[([^\]]*)\]\([^)]*\)')
80
+ _PAREN_URL= re.compile(r'\s*\(https?://[^\)]*\)')
81
+ _BARE_URL = re.compile(r'https?://\S+')
82
+ _PAREN_DOM= re.compile(r'\s*\([a-zA-Z0-9._-]+\.[a-zA-Z]{2,6}\)')
83
+
84
+ def _safe(text):
85
+ t = text or ""
86
+ t = _PAREN_MD.sub('', t)
87
+ t = _MD_LINK.sub(r'\1', t)
88
+ t = _PAREN_URL.sub('', t)
89
+ t = _BARE_URL.sub('', t)
90
+ t = _PAREN_DOM.sub('', t)
91
+ t = re.sub(r'[ \t]{2,}', ' ', t).strip()
92
+ t = re.sub(r'\.\s*\.', '.', t)
93
+ return html_lib.escape(t).replace("\n", "<br>")
94
+
95
+
96
+ # ─────────────────────────────────────────────────────────────
97
+ # 진행 로그 HTML 빌더
98
+ # ─────────────────────────────────────────────────────────────
99
+ STATUS_ICONS = {
100
+ "요청 수신": "📡", "인텐트": "🧠", "도구": "🔧", "시장": "📊",
101
+ "뉴스": "📰", "컨텍스트": "🗂", "LLM": "✨", "완료": "✅",
102
+ }
103
+
104
+ def _status_icon(msg):
105
+ for k, v in STATUS_ICONS.items():
106
+ if k in msg:
107
+ return v
108
+ return "⏳"
109
+
110
+
111
+ # ─────────────────────────────────────────────────────────────
112
+ # 페르소나 모델 & 파일 IO
113
+ # ─────────────────────────────────────────────────────────────
114
+ class PersonaLine(BaseModel):
115
+ name: str
116
+ full_name: str
117
+ summary: str
118
+ financial_mindset: str
119
+ data_analysis_approach: str
120
+ response_style: str
121
+ key_principles: list[str]
122
+ famous_quotes: list[str] | None = None
123
+ image_path: str | None = None
124
+
125
+
126
+ _persona_cache: list = []
127
+ _persona_cache_mtime: float = 0.0
128
+
129
+
130
+ def _parse_personas():
131
+ global _persona_cache, _persona_cache_mtime
132
+ try:
133
+ mtime = PERSONA_FILE.stat().st_mtime if PERSONA_FILE.exists() else 0.0
134
+ except OSError:
135
+ mtime = 0.0
136
+ if mtime == _persona_cache_mtime and _persona_cache:
137
+ return _persona_cache
138
+ personas = []
139
+ if not PERSONA_FILE.exists():
140
+ _persona_cache, _persona_cache_mtime = personas, mtime
141
+ return personas
142
+ try:
143
+ with PERSONA_FILE.open("r", encoding="utf-8") as f:
144
+ for line in f:
145
+ line = line.strip()
146
+ if not line:
147
+ continue
148
+ try:
149
+ data = json.loads(line)
150
+ if not isinstance(data, dict):
151
+ continue
152
+ if not data.get("full_name"):
153
+ data["full_name"] = data.get("name", "")
154
+ if "background" in data and "summary" not in data:
155
+ data["summary"] = data["background"]
156
+ personas.append(PersonaLine(**data))
157
+ except (json.JSONDecodeError, TypeError, ValidationError):
158
+ continue
159
+ except OSError:
160
+ pass
161
+ _persona_cache, _persona_cache_mtime = personas, mtime
162
+ return personas
163
+
164
+
165
+ def load_persona_names():
166
+ choices = ["없음"]
167
+ for p in _parse_personas():
168
+ n = p.name.strip()
169
+ if n and n not in choices:
170
+ choices.append(n)
171
+ return choices
172
+
173
+
174
+ def load_persona_summary(name):
175
+ if not name or name == "없음":
176
+ return ""
177
+ for p in _parse_personas():
178
+ if p.name.strip() == name:
179
+ summary = (p.summary[:80] + "…") if len(p.summary) > 80 else p.summary
180
+ return f"**{p.full_name}**\n\n{summary}"
181
+ return ""
182
+
183
+
184
+ # ─────────────────────────────────────────────────────────────
185
+ # 출력 패널 빌더
186
+ # ─────────────────────────────────────────────────────────────
187
+ def _make_log_html(log_lines):
188
+ if not log_lines:
189
+ return ''
190
+ rows = []
191
+ for t, text in log_lines:
192
+ safe = html_lib.escape(text)
193
+ if t == "status":
194
+ icon = next((v for k, v in STATUS_ICONS.items() if k in text), "⏳")
195
+ rows.append(f'<div class="log-status">{icon} <span>{safe}</span></div>')
196
+ elif t == "stdout":
197
+ rows.append(f'<div class="log-stdout"><pre>{safe}</pre></div>')
198
+ elif t == "error":
199
+ rows.append(f'<div class="log-error">❌ {safe}</div>')
200
+ elif t == "done":
201
+ rows.append('<div class="log-done">✅ 분석 완료</div>')
202
+ return "\n".join(rows)
203
+
204
+
205
+ def _wrap_log(inner):
206
+ return (
207
+ '<div id="output-panel" class="phase-log">'
208
+ '<div class="panel-header"><span class="panel-title">Progress</span></div>'
209
+ '<div id="log-scroll">' + inner + '</div>'
210
+ '</div>'
211
+ )
212
+
213
+
214
+ def _wrap_answer(log_html, md_html, timer_str):
215
+ log_section = (
216
+ '<details class="result-log-section">'
217
+ '<summary class="result-log-header">Progress <span class="log-toggle-hint">Click to show/hide</span></summary>'
218
+ '<div class="result-log-body">' + log_html + '</div>'
219
+ '</details>'
220
+ ) if log_html else ''
221
+
222
+ answer_section = (
223
+ '<div class="result-answer-section">'
224
+ '<div class="result-answer-header">'
225
+ '<span class="result-answer-badge">&#128203; Final results</span>'
226
+ f'<span class="panel-timer">{html_lib.escape(timer_str)}</span>'
227
+ '</div>'
228
+ '<div id="answer-scroll" class="md-body">' + md_html + '</div>'
229
+ '</div>'
230
+ )
231
+
232
+ return (
233
+ '<div id="output-panel" class="phase-answer">'
234
+ '<div class="panel-header">'
235
+ '<span class="panel-title">Analysis results</span>'
236
+ f'<span class="panel-timer">{html_lib.escape(timer_str)}</span>'
237
+ '</div>'
238
+ '<div id="answer-scroll-wrap">'
239
+ + log_section
240
+ + answer_section
241
+ + '</div>'
242
+ '</div>'
243
+ )
244
+
245
+
246
+ def _md_to_html(text):
247
+ import re as _re
248
+
249
+ _URL_RE = _re.compile(r'https?://[^\s,,、\))\]】]+')
250
+
251
+ def _url_badge(url: str) -> str:
252
+ clean = url.rstrip('.,;:')
253
+ m = _re.match(r'https?://(?:www\.)?([^/\s]+)', clean)
254
+ label = m.group(1) if m else clean
255
+ esc = html_lib.escape(clean)
256
+ return f'<a href="{esc}" target="_blank" class="md-link">🔗 {html_lib.escape(label)}</a>'
257
+
258
+ def _clean_source_line(s: str) -> str:
259
+ s = _re.sub(r'\s*[—–-]{1,2}\s*(https?://)', r' \1', s)
260
+ return s
261
+
262
+ def process_inline(s: str) -> str:
263
+ s = _clean_source_line(s)
264
+ parts = []
265
+ last = 0
266
+ for m in _URL_RE.finditer(s):
267
+ before = s[last:m.start()]
268
+ before = html_lib.escape(before)
269
+ before = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', before)
270
+ before = _re.sub(r'(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)', r'<em>\1</em>', before)
271
+ before = _re.sub(r'`(.+?)`', r'<code>\1</code>', before)
272
+ parts.append(before)
273
+ parts.append(_url_badge(m.group(0)))
274
+ last = m.end()
275
+ tail = html_lib.escape(s[last:])
276
+ tail = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', tail)
277
+ tail = _re.sub(r'(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)', r'<em>\1</em>', tail)
278
+ tail = _re.sub(r'`(.+?)`', r'<code>\1</code>', tail)
279
+ parts.append(tail)
280
+ return ''.join(parts)
281
+
282
+ BULLET_RE = _re.compile(
283
+ r'^([ \t]*)'
284
+ r'(\d+[.)]\s+|[-•·○◦▸▹*]\s+)'
285
+ r'(.+)$'
286
+ )
287
+ HEADER_RE = _re.compile(r'^(#{1,4})\s+(.+)$')
288
+
289
+ def indent_level(spaces: str) -> int:
290
+ n = spaces.count('\t') * 4 + spaces.count(' ')
291
+ return n // 2
292
+
293
+ lines = text.split('\n')
294
+ out: list[str] = []
295
+ list_stack: list[tuple[int, str]] = []
296
+ para_lines: list[str] = []
297
+
298
+ def flush_para():
299
+ if para_lines:
300
+ out.append('<p>' + process_inline(' '.join(para_lines)) + '</p>')
301
+ para_lines.clear()
302
+
303
+ def open_list(depth: int, tag: str):
304
+ while list_stack and list_stack[-1][0] > depth:
305
+ _, t = list_stack.pop()
306
+ out.append(f'</{t}>')
307
+ if list_stack and list_stack[-1][0] == depth:
308
+ if list_stack[-1][1] != tag:
309
+ _, t = list_stack.pop()
310
+ out.append(f'</{t}>')
311
+ out.append(f'<{tag}>')
312
+ list_stack.append((depth, tag))
313
+ else:
314
+ out.append(f'<{tag}>')
315
+ list_stack.append((depth, tag))
316
+
317
+ def close_all_lists():
318
+ while list_stack:
319
+ _, t = list_stack.pop()
320
+ out.append(f'</{t}>')
321
+
322
+ for line in lines:
323
+ stripped = line.strip()
324
+
325
+ if not stripped:
326
+ flush_para()
327
+ close_all_lists()
328
+ continue
329
+
330
+ mh = HEADER_RE.match(stripped)
331
+ if mh:
332
+ flush_para(); close_all_lists()
333
+ level = min(len(mh.group(1)), 4)
334
+ out.append(f'<h{level}>{process_inline(mh.group(2))}</h{level}>')
335
+ continue
336
+
337
+ if _URL_RE.fullmatch(stripped):
338
+ flush_para()
339
+ badge = _url_badge(stripped)
340
+ if list_stack:
341
+ out.append(f'<li class="md-link-item">{badge}</li>')
342
+ else:
343
+ out.append(f'<p class="md-link-p">{badge}</p>')
344
+ continue
345
+
346
+ mb = BULLET_RE.match(line)
347
+ if mb:
348
+ flush_para()
349
+ spaces = mb.group(1)
350
+ marker = mb.group(2)
351
+ content = mb.group(3).strip()
352
+ depth = indent_level(spaces)
353
+ is_ordered = bool(_re.match(r'\d+', marker.strip()))
354
+ tag = 'ol' if is_ordered else 'ul'
355
+ open_list(depth, tag)
356
+
357
+ if is_ordered:
358
+ num = int(_re.match(r'(\d+)', marker.strip()).group(1))
359
+ out.append(f'<li value="{num}">{process_inline(content)}</li>')
360
+ else:
361
+ out.append(f'<li>{process_inline(content)}</li>')
362
+ continue
363
+
364
+ close_all_lists()
365
+ para_lines.append(stripped)
366
+
367
+ flush_para()
368
+ close_all_lists()
369
+ return '\n'.join(out)
370
+
371
+
372
+ IDLE_PANEL = (
373
+ '<div id="output-panel" class="phase-idle">'
374
+ '<div class="idle-msg">🔍 Enter your question on the left and click Ask a Question..</div>'
375
+ '</div>'
376
+ )
377
+
378
+
379
+ # ─────────────────────────────────────────────────────────────
380
+ # stdout 캡처 (pipeline 내부 print → Queue)
381
+ # ─────────────────────────────────────────────────────────────
382
+ class _StdoutCapture(io.TextIOBase):
383
+ def __init__(self, q: Queue):
384
+ self._q = q
385
+
386
+ def write(self, s: str):
387
+ if s:
388
+ self._q.put(("stdout", s))
389
+ return len(s)
390
+
391
+ def flush(self):
392
+ pass
393
+
394
+
395
+ # ─────────────────────────────────────────────────────────────
396
+ # 스트림 분석 — pipeline() 직접 호출
397
+ # ─────────────────────────────────────────────────────────────
398
+ def stream_analyze(query, persona_name, api_key, history=None):
399
+ query = (query or "").strip()
400
+ persona_name = (persona_name or "").strip()
401
+ api_key = (api_key or "").strip()
402
+
403
+ if not query:
404
+ yield (_wrap_log('<div class="log-error">❌ Please enter your question.</div>'), "", "")
405
+ return
406
+ if not api_key:
407
+ yield (_wrap_log('<div class="log-error">❌ Please enter your OpenAI API key.</div>'), "", "")
408
+ return
409
+
410
+ os.environ["LLM_MODEL_API_KEY"] = api_key
411
+
412
+ try:
413
+ from pipeline import pipeline
414
+ except Exception as e:
415
+ yield (_wrap_log(f'<div class="log-error">❌ pipeline 로드 실패: {html_lib.escape(str(e))}</div>'), "", "")
416
+ return
417
+
418
+ text_acc = ""
419
+ log_lines = []
420
+ frozen_log = ""
421
+ result_json = ""
422
+ first_delta = False
423
+ worker_done = False
424
+ elapsed = _make_elapsed()
425
+ eq: Queue = Queue()
426
+
427
+ def status_cb(msg: str):
428
+ eq.put(("status", msg))
429
+
430
+ def delta_cb(delta: str):
431
+ eq.put(("delta", delta))
432
+
433
+ def reader():
434
+ capture = _StdoutCapture(eq)
435
+ old_stdout = sys.stdout
436
+ sys.stdout = capture
437
+ try:
438
+ pname = persona_name if persona_name and persona_name != "없음" else None
439
+ result = pipeline(
440
+ query,
441
+ persona_name=pname,
442
+ status_callback=status_cb,
443
+ stream_callback=delta_cb,
444
+ stream=True,
445
+ history=history or [],
446
+ )
447
+ eq.put(("result", result))
448
+ except Exception as exc:
449
+ eq.put(("exception", str(exc)))
450
+ finally:
451
+ sys.stdout = old_stdout
452
+ eq.put(("worker_done", None))
453
+
454
+ Thread(target=reader, daemon=True).start()
455
+
456
+ while True:
457
+ try:
458
+ kind, payload = eq.get(timeout=0.1)
459
+ buf = [(kind, payload)]
460
+ while True:
461
+ try: buf.append(eq.get_nowait())
462
+ except Empty: break
463
+ except Empty:
464
+ buf = []
465
+
466
+ for kind, payload in buf:
467
+ if kind == "status":
468
+ if not first_delta:
469
+ log_lines.append(("status", payload))
470
+
471
+ elif kind == "stdout":
472
+ if not first_delta:
473
+ if log_lines and log_lines[-1][0] == "stdout":
474
+ log_lines[-1] = ("stdout", log_lines[-1][1] + payload)
475
+ else:
476
+ log_lines.append(("stdout", payload))
477
+
478
+ elif kind == "delta":
479
+ if payload:
480
+ if not first_delta:
481
+ first_delta = True
482
+ frozen_log = _make_log_html(log_lines)
483
+ text_acc += payload
484
+
485
+ elif kind == "result":
486
+ result = payload
487
+ try:
488
+ result_dict = asdict(result) if is_dataclass(result) else dict(result)
489
+ result_json = json.dumps(result_dict, ensure_ascii=False, indent=2, default=str)
490
+ except Exception:
491
+ result_json = ""
492
+ if not text_acc:
493
+ llm = getattr(result, "llm_response", "") or ""
494
+ if llm:
495
+ first_delta = True
496
+ frozen_log = _make_log_html(log_lines)
497
+ text_acc = llm
498
+
499
+ elif kind == "exception":
500
+ log_lines.append(("error", str(payload)))
501
+
502
+ elif kind == "worker_done":
503
+ worker_done = True
504
+
505
+ t = timer_text(elapsed())
506
+ if first_delta:
507
+ panel = _wrap_answer(frozen_log, _md_to_html(text_acc), t)
508
+ else:
509
+ panel = _wrap_log(_make_log_html(log_lines))
510
+ yield (panel, t, result_json)
511
+
512
+ if worker_done:
513
+ break
514
+
515
+ t = timer_text(elapsed())
516
+ if first_delta:
517
+ panel = _wrap_answer(frozen_log, _md_to_html(text_acc), t)
518
+ else:
519
+ panel = _wrap_log(_make_log_html(log_lines))
520
+ yield (panel, t, result_json)
521
+
522
+
523
+ # ─────────────────────────────────────────────────────────────
524
+ # CSS (gradio_app.py 원본 그대로)
525
+ # ─────────────────────────────────────────────────────────────
526
+ CSS = """
527
+ @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");
528
+
529
+ :root {
530
+ --ws-bg: #f7faf9;
531
+ --ws-surface: #ffffff;
532
+ --ws-border: #dbe5e2;
533
+ --ws-text: #182022;
534
+ --ws-muted: #5d6b70;
535
+ --ws-accent: #0f766e;
536
+ --ws-accent2: #14b8a6;
537
+ --ws-code-bg: #f1f5f9;
538
+ --ws-green-bg: #f0fdfa;
539
+ --ws-green-border: #99f6e4;
540
+
541
+ --panel-height: 680px;
542
+
543
+ --pf-left-h: 130px;
544
+ --pf-right-h: calc(var(--pf-left-h) * 3 + 12px * 2);
545
+ }
546
+
547
+ .gradio-container,
548
+ .gradio-container :is(h1,h2,h3,h4,h5,h6,p,span,div,label,button,input,textarea,select) {
549
+ font-family: "IBM Plex Sans KR","Noto Sans KR","Source Sans 3",sans-serif !important;
550
+ letter-spacing: 0.005em;
551
+ }
552
+ .gradio-container {
553
+ background: radial-gradient(circle at top left,#edf9f6 0%,#f8fbfc 35%,#fdfefe 100%) !important;
554
+ }
555
+
556
+ #ws-header {
557
+ background: linear-gradient(135deg,#f0fdfa 0%,#e8faf7 55%,#f7faf9 100%);
558
+ border: 1px solid #b2e8e2;
559
+ border-radius: 14px;
560
+ padding: 20px 28px 16px;
561
+ margin-bottom: 8px;
562
+ position: relative; overflow: hidden;
563
+ }
564
+ #ws-header::before {
565
+ content:""; position:absolute; top:-70px; right:-70px;
566
+ width:260px; height:260px;
567
+ background:radial-gradient(circle,rgba(20,184,166,.13) 0%,transparent 68%);
568
+ pointer-events:none;
569
+ }
570
+ #ws-header h1 { font-size:22px !important; font-weight:700 !important; color:#0b3b39 !important; margin:0 0 4px !important; }
571
+ #ws-header p { font-size:13px !important; color:var(--ws-muted) !important; margin:0 !important; }
572
+ #ws-header .ws-badge {
573
+ display:inline-block; padding:1px 8px; border-radius:20px;
574
+ font-size:10px; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
575
+ background:rgba(15,118,110,.1); border:1px solid rgba(15,118,110,.25);
576
+ color:var(--ws-accent); margin-right:7px; vertical-align:middle;
577
+ }
578
+
579
+ .tab-nav button {
580
+ background:transparent !important; color:var(--ws-muted) !important;
581
+ border:none !important; border-bottom:2px solid transparent !important;
582
+ font-size:13px !important; font-weight:600 !important;
583
+ padding:8px 18px !important; border-radius:0 !important;
584
+ transition:color .18s,border-color .18s !important;
585
+ }
586
+ .tab-nav button.selected,.tab-nav button:hover {
587
+ color:var(--ws-accent) !important; border-bottom-color:var(--ws-accent) !important;
588
+ background:transparent !important;
589
+ }
590
+
591
+ #qa-row { align-items: stretch !important; }
592
+
593
+ #input-col {
594
+ background: var(--ws-surface);
595
+ border: 1px solid var(--ws-border) !important;
596
+ border-radius: 14px !important;
597
+ padding: 18px 20px !important;
598
+ box-shadow: 0 2px 12px rgba(16,24,40,.04);
599
+ display: flex; flex-direction: column; gap: 10px;
600
+ height: auto !important;
601
+ min-height: var(--panel-height);
602
+ overflow-y: visible !important;
603
+ box-sizing: border-box;
604
+ }
605
+
606
+ .ws-label {
607
+ font-size: 10px; font-weight: 700; text-transform: uppercase;
608
+ letter-spacing: .08em; color: var(--ws-muted); margin-bottom: 4px;
609
+ }
610
+ .ws-divider { border:none; border-top:1px solid var(--ws-border); margin:10px 0; }
611
+
612
+ #persona-summary {
613
+ background: linear-gradient(180deg,#f9fefd 0%,#f3fbf9 100%) !important;
614
+ border: 1px solid #cde8e3 !important; border-radius: 10px !important;
615
+ padding: 10px 14px !important; font-size: 13px !important;
616
+ color: var(--ws-text) !important;
617
+ }
618
+ #persona-summary > .wrap,#persona-summary > div.prose { padding:0!important;border:none!important;box-shadow:none!important; }
619
+
620
+ #example-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 5px; }
621
+ #example-grid button {
622
+ width: 100% !important; text-align: left !important;
623
+ background: var(--ws-code-bg) !important; border: 1px solid var(--ws-border) !important;
624
+ border-radius: 8px !important; color: var(--ws-text) !important;
625
+ font-size: 11px !important; padding: 7px 10px !important;
626
+ white-space: normal !important; line-height: 1.4 !important;
627
+ transition: border-color .18s, color .18s, background .18s !important;
628
+ min-height: 44px;
629
+ }
630
+ #example-grid button:hover {
631
+ background: var(--ws-green-bg) !important; border-color: var(--ws-accent2) !important;
632
+ color: var(--ws-accent) !important;
633
+ }
634
+
635
+ #run-btn {
636
+ background: linear-gradient(135deg,#0f766e 0%,#14b8a6 100%) !important;
637
+ border: none !important; color: #fff !important; font-weight: 700 !important;
638
+ font-size: 13px !important; border-radius: 9px !important;
639
+ transition: opacity .18s, transform .12s !important;
640
+ }
641
+ #run-btn:hover { opacity:.87!important; transform:translateY(-1px)!important; }
642
+ #clear-btn {
643
+ background: var(--ws-code-bg) !important; border: 1px solid var(--ws-border) !important;
644
+ color: var(--ws-muted) !important; border-radius: 9px !important; font-size:12px!important;
645
+ }
646
+ #clear-btn:hover { border-color:var(--ws-accent)!important; color:var(--ws-accent)!important; }
647
+
648
+ #refresh-btn {
649
+ min-width:34px!important; padding:0 8px!important;
650
+ background:var(--ws-code-bg)!important; border:1px solid var(--ws-border)!important;
651
+ color:var(--ws-muted)!important; border-radius:8px!important; font-size:15px!important;
652
+ }
653
+ #refresh-btn:hover { color:var(--ws-accent)!important; border-color:var(--ws-accent)!important; background:var(--ws-green-bg)!important; }
654
+
655
+ #output-col {
656
+ border: 1px solid var(--ws-border) !important;
657
+ border-radius: 14px !important;
658
+ overflow: hidden !important;
659
+ background: var(--ws-surface);
660
+ box-shadow: 0 2px 12px rgba(16,24,40,.04);
661
+ height: var(--panel-height) !important;
662
+ display: flex !important; flex-direction: column !important;
663
+ box-sizing: border-box;
664
+ min-height: var(--panel-height) !important;
665
+ height: auto !important;
666
+ }
667
+ #output-col > .wrap, #output-col > div {
668
+ padding: 0 !important; margin: 0 !important;
669
+ border: none !important; box-shadow: none !important;
670
+ height: 100% !important;
671
+ display: flex !important; flex-direction: column !important;
672
+ overflow: hidden !important; min-height: 0 !important;
673
+ }
674
+
675
+ #output-panel { display:flex; flex-direction:column; height:100%; overflow:hidden; min-height:0; }
676
+
677
+ .panel-header {
678
+ display:flex; align-items:center; justify-content:space-between;
679
+ padding:10px 16px; background:#f7faf9;
680
+ border-bottom:1px solid var(--ws-border); flex-shrink:0;
681
+ }
682
+ .panel-title { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--ws-muted); }
683
+ .panel-timer {
684
+ font-size:11px; font-weight:600; color:var(--ws-accent);
685
+ background:var(--ws-green-bg); border:1px solid var(--ws-green-border);
686
+ padding:2px 10px; border-radius:999px;
687
+ }
688
+ .idle-msg { display:flex; align-items:center; justify-content:center; flex:1; color:var(--ws-muted); font-size:13px; }
689
+
690
+ #log-scroll { flex:1; overflow-y:auto; padding:14px 18px; font-size:12px; line-height:1.7; }
691
+ .log-status { display:flex; align-items:flex-start; gap:7px; padding:3px 0; color:var(--ws-text); }
692
+ .log-status span { color:var(--ws-text); }
693
+ .log-stdout pre {
694
+ margin:3px 0; padding:4px 10px;
695
+ background:#f1f8f7; border-left:3px solid var(--ws-accent2); border-radius:0 5px 5px 0;
696
+ font-family:"JetBrains Mono","IBM Plex Mono",monospace !important;
697
+ font-size:11px !important; color:var(--ws-muted); white-space:pre-wrap; word-break:break-all;
698
+ }
699
+ .log-done { color:var(--ws-accent); font-weight:700; padding:4px 0; }
700
+ .log-error { color:#e53e3e; padding:4px 0; }
701
+
702
+ #answer-scroll-wrap { display:flex; flex-direction:column; height:calc(var(--panel-height) - 42px); overflow:hidden; }
703
+ .result-log-section { border-bottom:2px solid var(--ws-border); background:#f7faf9; flex-shrink:0; max-height:140px; overflow:hidden; }
704
+ .result-log-section[open] { overflow-y:auto; }
705
+ .result-log-header {
706
+ font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--ws-muted);
707
+ padding:7px 18px; cursor:pointer; display:flex; align-items:center; justify-content:space-between;
708
+ list-style:none; user-select:none;
709
+ }
710
+ .result-log-header::-webkit-details-marker { display:none; }
711
+ .result-log-header::after { content:"▲"; font-size:9px; color:var(--ws-muted); transition:transform 0.2s; }
712
+ .result-log-section:not([open]) .result-log-header::after { transform:rotate(180deg); }
713
+ .result-log-section:not([open]) ~ .result-answer-section #answer-scroll { height:calc(var(--panel-height) - 78px) !important; }
714
+ .log-toggle-hint { font-size:9px; font-weight:400; color:#9bb0ac; margin-left:6px; text-transform:none; letter-spacing:0; }
715
+ .result-log-body { padding:0 18px 10px; font-size:12px; line-height:1.7; }
716
+ .result-answer-section { padding:0; flex:1; display:flex; flex-direction:column; min-height:0; overflow:hidden; }
717
+ .result-answer-header {
718
+ display:flex; align-items:center; justify-content:space-between;
719
+ padding:12px 18px 8px; border-bottom:1px solid var(--ws-border);
720
+ background:linear-gradient(135deg,#f0fdfa 0%,#e8faf7 100%); flex-shrink:0;
721
+ }
722
+ .result-answer-badge { font-size:13px; font-weight:700; color:var(--ws-accent); letter-spacing:.01em; }
723
+ #answer-scroll { padding:18px 22px; overflow-y:auto !important; height:calc(var(--panel-height) - 118px) !important; box-sizing:border-box; }
724
+
725
+ .md-body { color:var(--ws-text); line-height:1.75; font-size:14.5px; }
726
+ .md-body h1,.md-body h2,.md-body h3,.md-body h4 { color:#0b3b39; margin:.85em 0 .35em; }
727
+ .md-body h2 { font-size:16px; border-bottom:1px solid var(--ws-border); padding-bottom:5px; }
728
+ .md-body h3 { font-size:14px; color:var(--ws-accent); }
729
+ .md-body h4 { font-size:13px; }
730
+ .md-body p { margin:.4em 0; }
731
+ .md-body strong { font-weight:700; }
732
+ .md-body em { font-style:italic; }
733
+ .md-body a.md-link {
734
+ display:inline-flex; align-items:center; gap:4px;
735
+ color:var(--ws-accent); font-size:12.5px; font-weight:500; text-decoration:none;
736
+ background:var(--ws-green-bg); border:1px solid var(--ws-green-border);
737
+ border-radius:6px; padding:2px 9px; transition:background .15s,border-color .15s;
738
+ }
739
+ .md-body a.md-link:hover { background:#ccfbf1; border-color:var(--ws-accent); }
740
+ .md-body li.md-link-item { list-style:none; margin:3px 0; }
741
+ .md-body p.md-link-p { margin:3px 0; }
742
+ .md-body ul,.md-body ol { margin:.4em 0; padding-left:1.5em; }
743
+ .md-body ul ul,.md-body ol ol,.md-body ul ol,.md-body ol ul { margin:.2em 0; padding-left:1.4em; }
744
+ .md-body li { margin:.2em 0; }
745
+ .md-body li > ul,.md-body li > ol { margin-top:.15em; }
746
+ .md-body code { background:var(--ws-code-bg); color:#0b3b39; border:1px solid #d9e2ec; border-radius:5px; padding:.1em .35em; font-size:.91em; font-family:"JetBrains Mono","IBM Plex Mono",monospace; }
747
+ .md-body pre { background:#0f172a; color:#e2e8f0; border-radius:10px; border:1px solid #1e293b; padding:.85em 1em; overflow-x:auto; margin:.7em 0; }
748
+ .md-body pre code { background:transparent; border:none; color:inherit; padding:0; }
749
+ .md-body blockquote { margin:.8em 0; padding:.6em .9em; border-left:4px solid var(--ws-accent2); background:var(--ws-green-bg); color:#115e59; border-radius:0 8px 8px 0; }
750
+ .md-body table { width:100%; border-collapse:collapse; margin:.7em 0; }
751
+ .md-body th { background:#eef6f4; color:#0f3f3b; font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.04em; padding:7px 10px; border:1px solid var(--ws-border); }
752
+ .md-body td { border:1px solid var(--ws-border); padding:6px 10px; vertical-align:top; }
753
+ .md-body tr:hover td { background:#f9fefd; }
754
+
755
+ #timer-row { display:none !important; }
756
+
757
+ #result-json-wrap { border-top:2px solid var(--ws-border); background:var(--ws-surface); }
758
+ #result-json-wrap .accordion-header { padding:10px 16px !important; }
759
+ #meta-box { max-height:220px; overflow-y:auto; background:var(--ws-surface)!important; border:none!important; }
760
+ #meta-box code,#meta-box pre { font-family:"JetBrains Mono",monospace!important; font-size:11.5px!important; color:var(--ws-muted)!important; background:transparent!important; }
761
+
762
+ ::-webkit-scrollbar { width:5px; height:5px; }
763
+ ::-webkit-scrollbar-track { background:transparent; }
764
+ ::-webkit-scrollbar-thumb { background:var(--ws-border); border-radius:3px; }
765
+ ::-webkit-scrollbar-thumb:hover { background:#aec5c1; }
766
+ body:has(.options:not(.hide)) { overflow:hidden!important; }
767
+
768
+ @media (max-width: 768px) {
769
+ #example-grid { grid-template-columns:1fr 1fr!important; }
770
+ :root { --panel-height:500px; }
771
+ #output-col { max-height:none!important; }
772
+ }
773
+ """
774
+
775
+ HEADER_HTML = """
776
+ <div id="ws-header">
777
+ <h1>📈 Wallstreet AI</h1>
778
+ <p>
779
+ Financial analysis assistant combining legendary investor personas with
780
+ prices, fundamentals, earnings, news, and technical indicators
781
+ </p>
782
+ </div>
783
+ """
784
+
785
+
786
+ # ─────────────────────────────────────────────────────────────
787
+ # Gradio 앱 — Ask a Question 탭만
788
+ # ─────────────────────────────────────────────────────────────
789
+ def create_app():
790
+ theme = gr.themes.Soft(primary_hue="emerald", secondary_hue="teal",
791
+ neutral_hue="slate", radius_size="lg")
792
+
793
+ with gr.Blocks(title="Wallstreet AI", analytics_enabled=False) as demo:
794
+
795
+ gr.HTML(HEADER_HTML)
796
+
797
+ # ════════════════════════════════════════════
798
+ # Ask a Question (단일 탭 없이 바로 배치)
799
+ # ════════════════════════════════════════════
800
+ with gr.Row(equal_height=True, elem_id="qa-row"):
801
+
802
+ with gr.Column(scale=1, min_width=280, elem_id="input-col"):
803
+
804
+ # API Key
805
+ api_key_input = gr.Textbox(
806
+ label="🔑 OpenAI API Key",
807
+ placeholder="sk-...",
808
+ type="password",
809
+ )
810
+
811
+ gr.HTML("<hr class='ws-divider'>")
812
+
813
+ # 페르소나 선택
814
+ gr.HTML("<p class='ws-label'>Persona</p>")
815
+ with gr.Row():
816
+ persona_dd = gr.Dropdown(
817
+ label="", choices=load_persona_names(),
818
+ value="없음", interactive=True,
819
+ scale=5, show_label=False,
820
+ )
821
+ refresh_btn = gr.Button("↺", size="sm", scale=1,
822
+ min_width=34, elem_id="refresh-btn")
823
+ persona_summary = gr.Markdown(value="", elem_id="persona-summary", visible=True)
824
+
825
+ gr.HTML("<hr class='ws-divider'>")
826
+
827
+ # 예시 질문
828
+ gr.HTML("<p class='ws-label'>Example Questions</p>")
829
+ with gr.Column(elem_id="example-grid"):
830
+ example_btns = []
831
+ for example_text in EXAMPLES_BY_TYPE.values():
832
+ b = gr.Button(example_text, size="sm")
833
+ example_btns.append((b, example_text))
834
+
835
+ gr.HTML("<hr class='ws-divider'>")
836
+
837
+ # 질문 입력
838
+ gr.HTML("<p class='ws-label'>Ask Question</p>")
839
+ query_input = gr.Textbox(
840
+ label="",
841
+ placeholder="종목명, 티커, 분석 요청을 입력하세요...",
842
+ lines=3, value=EXAMPLE_QUERIES[0], show_label=False,
843
+ )
844
+ with gr.Row():
845
+ run_btn = gr.Button("🔍 Ask Question", variant="primary",
846
+ scale=3, elem_id="run-btn")
847
+ clear_btn = gr.Button("Newchat", scale=1, elem_id="clear-btn")
848
+
849
+ with gr.Column(scale=1, min_width=300, elem_id="output-col"):
850
+ output_panel = gr.HTML(value=IDLE_PANEL, show_label=False)
851
+ timer = gr.Markdown(value="", visible=False)
852
+
853
+ # 채팅 히스토리 (내부 state)
854
+ chat_history = gr.State([])
855
+
856
+ with gr.Row(elem_id="result-json-wrap"):
857
+ with gr.Column():
858
+ gr.HTML("<p class='ws-label'>📄 Original Data(JSON)</p>")
859
+ meta = gr.Code(label="", language="json", elem_id="meta-box", show_label=False)
860
+
861
+ gr.HTML(AUTO_SCROLL_JS, visible=False)
862
+
863
+ # ── 이벤트 핸들러 ──────────────────────────────
864
+ def on_run(q, persona, api_key, history):
865
+ for panel, t, rj in stream_analyze(q, persona, api_key, history):
866
+ yield panel, t, rj
867
+
868
+ def update_history(q, rj, history):
869
+ new_history = list(history)
870
+ new_history.append({"role": "user", "content": q})
871
+ try:
872
+ result = json.loads(rj)
873
+ llm_response = result.get("llm_response", "")
874
+ if llm_response:
875
+ new_history.append({"role": "assistant", "content": llm_response})
876
+ except (json.JSONDecodeError, AttributeError, TypeError):
877
+ pass
878
+ return new_history
879
+
880
+ run_click = run_btn.click(
881
+ fn=on_run,
882
+ inputs=[query_input, persona_dd, api_key_input, chat_history],
883
+ outputs=[output_panel, timer, meta],
884
+ )
885
+ run_then = run_click.then(
886
+ fn=update_history,
887
+ inputs=[query_input, meta, chat_history],
888
+ outputs=[chat_history],
889
+ )
890
+ submit_click = query_input.submit(
891
+ fn=on_run,
892
+ inputs=[query_input, persona_dd, api_key_input, chat_history],
893
+ outputs=[output_panel, timer, meta],
894
+ )
895
+ submit_then = submit_click.then(
896
+ fn=update_history,
897
+ inputs=[query_input, meta, chat_history],
898
+ outputs=[chat_history],
899
+ )
900
+ clear_btn.click(
901
+ fn=lambda: (IDLE_PANEL, "", "", [], "없음", ""),
902
+ outputs=[output_panel, timer, meta, chat_history, persona_dd, query_input],
903
+ cancels=[run_click, run_then, submit_click, submit_then],
904
+ )
905
+ refresh_btn.click(
906
+ fn=lambda: gr.update(choices=load_persona_names(), value="없음"),
907
+ outputs=[persona_dd],
908
+ )
909
+
910
+ def on_persona_change(name):
911
+ return gr.update(value=load_persona_summary(name))
912
+
913
+ persona_dd.change(fn=on_persona_change, inputs=[persona_dd], outputs=[persona_summary])
914
+
915
+ for _b, _text in example_btns:
916
+ _b.click(fn=lambda t=_text: t, outputs=[query_input])
917
+
918
+ return demo, theme
919
+
920
+
921
+ # ─────────────────────────────────────────────────────────────
922
+ # 진입점
923
+ # ─────────────────────────────────────────────────────────────
924
+ def main():
925
+ app, theme = create_app()
926
+ app.queue(default_concurrency_limit=8, max_size=64)
927
+ app.launch(server_name="0.0.0.0", server_port=7860,
928
+ debug=True, theme=theme, css=CSS)
929
+
930
+
931
+ if __name__ == "__main__":
932
+ main()