Upload app.py
Browse files
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">📋 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()
|