github-actions[bot] commited on
Commit
de3d050
Β·
1 Parent(s): 0a116c1

sync from 7d209ce

Browse files
Files changed (1) hide show
  1. app.py +331 -362
app.py CHANGED
@@ -1,4 +1,4 @@
1
- """DartLab Streamlit Demo β€” DART/EDGAR κ³΅μ‹œ 뢄석."""
2
 
3
  from __future__ import annotations
4
 
@@ -6,7 +6,6 @@ import gc
6
  import io
7
  import os
8
  import re
9
- from collections import OrderedDict
10
 
11
  import pandas as pd
12
  import streamlit as st
@@ -22,99 +21,70 @@ _DOCS_URL = "https://eddmpython.github.io/dartlab/docs/getting-started/quickstar
22
  _COLAB_URL = "https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/showcase/01_quickstart.ipynb"
23
  _REPO_URL = "https://github.com/eddmpython/dartlab"
24
 
 
 
 
 
 
25
  # ── νŽ˜μ΄μ§€ μ„€μ • ──────────────────────────────────────
26
 
27
  st.set_page_config(
28
- page_title="DartLab β€” μ’…λͺ©μ½”λ“œ ν•˜λ‚˜λ‘œ κΈ°μ—… 뢄석",
29
- page_icon="πŸ“Š",
30
  layout="centered",
31
  )
32
 
33
- # ── μ»€μŠ€ν…€ CSS ────────────────────────────────────────
34
 
35
  st.markdown("""
36
  <style>
37
- /* ══════════════════════════════════════════════════
38
- dartlab 닀크 ν…Œλ§ˆ β€” 라이트 λͺ¨λ“œ κ°•μ œ μ˜€λ²„λΌμ΄λ“œ
39
- λžœλ”© νŽ˜μ΄μ§€ 색상 체계: #050811 / #0f1219 / #ea4647
40
- ══════════════════════════════════════════════════ */
41
-
42
- /* μ „μ—­ λ°°κ²½ κ°•μ œ */
43
  html, body, [data-testid="stAppViewContainer"],
44
  [data-testid="stApp"], .main, .block-container {
45
  background-color: #050811 !important;
46
  color: #f1f5f9 !important;
47
  }
48
- [data-testid="stHeader"] {
49
- background: #050811 !important;
50
- }
51
- [data-testid="stSidebar"] {
52
- background: #0f1219 !important;
53
- }
54
 
55
- /* μž…λ ₯ ν•„λ“œ β€” 닀크 κ°•μ œ */
56
- input, textarea, [data-testid="stTextInput"] input,
57
- [data-baseweb="input"] input,
58
- [data-baseweb="textarea"] textarea {
59
  background-color: #0f1219 !important;
60
  color: #f1f5f9 !important;
61
  border-color: #1e2433 !important;
62
- caret-color: #ea4647 !important;
63
- }
64
- [data-baseweb="input"],
65
- [data-baseweb="base-input"] {
66
- background-color: #0f1219 !important;
67
- border-color: #1e2433 !important;
68
  }
69
 
70
- /* μ…€λ ‰νŠΈλ°•μŠ€/λ“œλ‘­λ‹€μš΄ */
71
  [data-baseweb="select"] > div {
72
  background-color: #0f1219 !important;
73
  border-color: #1e2433 !important;
74
  color: #f1f5f9 !important;
75
  }
76
- [data-baseweb="popover"] {
77
- background-color: #0f1219 !important;
78
- border-color: #1e2433 !important;
79
- }
80
- [data-baseweb="menu"] {
81
  background-color: #0f1219 !important;
82
  }
83
- [data-baseweb="menu"] li {
84
- color: #f1f5f9 !important;
85
- }
86
- [data-baseweb="menu"] li:hover {
87
- background-color: #1a1f2b !important;
88
- }
89
-
90
- /* λΌλ””μ˜€ λ²„νŠΌ */
91
- [data-testid="stRadio"] label {
92
- color: #f1f5f9 !important;
93
- }
94
- [data-testid="stRadio"] [data-baseweb="radio"] {
95
- background-color: transparent !important;
96
- }
97
 
98
- /* DataFrame 닀크 κ°•μ œ */
99
- [data-testid="stDataFrame"] {
100
- font-variant-numeric: tabular-nums;
101
- }
102
- [data-testid="stDataFrame"] [data-testid="glideDataEditor"],
103
- [data-testid="stDataFrame"] canvas {
104
- background-color: #0f1219 !important;
105
- }
106
 
107
- /* λ²„νŠΌ */
108
- [data-testid="stBaseButton-primary"] {
 
 
 
109
  background-color: #ea4647 !important;
110
  color: #fff !important;
111
  border: none !important;
112
  font-weight: 600 !important;
113
  }
114
- [data-testid="stBaseButton-primary"]:hover {
 
115
  background-color: #c83232 !important;
116
  }
117
- [data-testid="stBaseButton-secondary"],
118
  [data-testid="stDownloadButton"] button {
119
  background-color: #0f1219 !important;
120
  color: #f1f5f9 !important;
@@ -123,6 +93,12 @@ input, textarea, [data-testid="stTextInput"] input,
123
  [data-testid="stDownloadButton"] button:hover {
124
  border-color: #ea4647 !important;
125
  color: #ea4647 !important;
 
 
 
 
 
 
126
  }
127
 
128
  /* Expander */
@@ -130,43 +106,33 @@ input, textarea, [data-testid="stTextInput"] input,
130
  background-color: #0f1219 !important;
131
  border-color: #1e2433 !important;
132
  }
133
- [data-testid="stExpander"] summary {
134
- color: #f1f5f9 !important;
135
- }
136
- [data-testid="stExpander"] [data-testid="stMarkdownContainer"] {
137
- color: #f1f5f9 !important;
138
- }
139
 
140
  /* Chat */
141
  [data-testid="stChatMessage"] {
142
- background-color: #0f1219 !important;
143
  border-color: #1e2433 !important;
144
  }
145
- [data-testid="stChatInput"] {
146
  background-color: #0f1219 !important;
147
  border-color: #1e2433 !important;
148
- }
149
- [data-testid="stChatInput"] textarea {
150
- background-color: #0f1219 !important;
151
  color: #f1f5f9 !important;
152
  }
153
 
154
- /* κΈ°λ³Έ ν…μŠ€νŠΈ */
155
  p, span, label, h1, h2, h3, h4, h5, h6,
156
  [data-testid="stMarkdownContainer"],
157
  [data-testid="stMarkdownContainer"] p {
158
  color: #f1f5f9 !important;
159
  }
160
- [data-testid="stCaption"], .stCaption {
161
- color: #64748b !important;
162
- }
163
 
164
- /* ── μ»€μŠ€ν…€ μ»΄ν¬λ„ŒνŠΈ ── */
 
165
 
166
- /* 헀더 */
167
  .dl-header {
168
  text-align: center;
169
- padding: 2rem 0 1.2rem;
170
  }
171
  .dl-header img {
172
  border-radius: 50%;
@@ -177,39 +143,20 @@ p, span, label, h1, h2, h3, h4, h5, h6,
177
  -webkit-background-clip: text;
178
  -webkit-text-fill-color: transparent;
179
  background-clip: text;
180
- font-size: 2.6rem !important;
181
  font-weight: 800 !important;
182
- margin: 0.6rem 0 0.15rem !important;
183
  letter-spacing: -0.03em;
184
  }
185
- .dl-header .tagline {
186
- color: #94a3b8 !important;
187
- font-size: 1.08rem;
188
- margin: 0;
189
- }
190
- .dl-header .sub {
191
- color: #64748b !important;
192
- font-size: 0.85rem;
193
- margin: 0.2rem 0 0;
194
- }
195
-
196
- /* μ„Ήμ…˜ 제λͺ© */
197
- .dl-section {
198
- color: #ea4647 !important;
199
- font-weight: 700 !important;
200
- font-size: 1.15rem !important;
201
- border-bottom: 2px solid #ea4647;
202
- padding-bottom: 0.35rem;
203
- margin: 1.5rem 0 0.8rem;
204
- }
205
 
206
- /* κΈ°μ—…μΉ΄λ“œ */
207
  .dl-card {
208
  background: linear-gradient(135deg, #0f1219 0%, #0a0d16 100%);
209
  border: 1px solid #1e2433;
210
  border-radius: 12px;
211
- padding: 1.4rem 1.8rem;
212
- margin: 1rem 0;
213
  position: relative;
214
  overflow: hidden;
215
  }
@@ -220,81 +167,45 @@ p, span, label, h1, h2, h3, h4, h5, h6,
220
  height: 3px;
221
  background: linear-gradient(90deg, #ea4647, #f87171, #fb923c);
222
  }
223
- .dl-card h2 {
224
- color: #f1f5f9 !important;
225
- font-size: 1.5rem !important;
226
- margin: 0 0 1rem !important;
227
- font-weight: 700;
228
- }
229
- .dl-card .meta {
230
- display: flex;
231
- gap: 2.5rem;
232
- flex-wrap: wrap;
233
- }
234
- .dl-card .meta-item {
235
- display: flex;
236
- flex-direction: column;
237
- gap: 0.15rem;
238
- }
239
  .dl-card .meta-label {
240
- color: #64748b !important;
241
- font-size: 0.72rem;
242
- text-transform: uppercase;
243
- letter-spacing: 0.08em;
244
- font-weight: 500;
245
  }
246
  .dl-card .meta-value {
247
- color: #e2e8f0 !important;
248
- font-size: 1.15rem;
249
- font-weight: 600;
250
- font-family: 'JetBrains Mono', 'Fira Code', monospace;
251
  }
252
 
253
- /* μ•ˆλ‚΄ ν…μŠ€νŠΈ */
254
- .dl-guide {
255
- color: #64748b !important;
256
- font-size: 0.88rem;
257
- text-align: center;
258
- margin: 0.2rem 0 0.8rem;
 
259
  }
260
 
261
- /* ν‘Έν„° */
262
  .dl-footer {
263
  text-align: center;
264
- padding: 2rem 0 1rem;
265
  border-top: 1px solid #1e2433;
266
- margin-top: 2.5rem;
267
- color: #475569 !important;
268
- font-size: 0.85rem;
269
- }
270
- .dl-footer a {
271
- color: #94a3b8 !important;
272
- text-decoration: none;
273
- margin: 0 0.6rem;
274
- transition: color 0.2s;
275
- }
276
- .dl-footer a:hover {
277
- color: #ea4647 !important;
278
- }
279
-
280
- /* 빈 μƒνƒœ */
281
- .dl-empty {
282
- text-align: center;
283
  color: #475569 !important;
284
- padding: 3rem 1rem;
285
- font-size: 1rem;
286
  }
 
 
287
 
288
- /* νžˆμ–΄λ‘œ κ·ΈλΌλ””μ–ΈνŠΈ λ°°κ²½ 효과 (λžœλ”© λŠλ‚Œ) */
289
  .dl-hero-glow {
290
  position: fixed;
291
  top: 0; left: 50%;
292
  transform: translateX(-50%);
293
- width: 600px;
294
- height: 400px;
295
- background: radial-gradient(ellipse at top, rgba(234,70,71,0.06) 0%, transparent 60%);
296
- pointer-events: none;
297
- z-index: 0;
298
  }
299
  </style>
300
  """, unsafe_allow_html=True)
@@ -304,7 +215,7 @@ p, span, label, h1, h2, h3, h4, h5, h6,
304
 
305
 
306
  def _toPandas(df):
307
- """Polars/pandas DataFrame β†’ pandas λ³€ν™˜."""
308
  if df is None:
309
  return None
310
  if hasattr(df, "to_pandas"):
@@ -312,33 +223,8 @@ def _toPandas(df):
312
  return df
313
 
314
 
315
- @st.cache_resource(max_entries=_MAX_CACHE)
316
- def _getCompany(code: str):
317
- """μΊμ‹œλœ Company λ°˜ν™˜."""
318
- gc.collect()
319
- return dartlab.Company(code)
320
-
321
-
322
- def _resolveCode(query: str) -> tuple[str | None, str | None]:
323
- """쿼리 β†’ (μ’…λͺ©μ½”λ“œ, μ—λŸ¬λ©”μ‹œμ§€)."""
324
- query = query.strip()
325
- if not query:
326
- return None, None
327
-
328
- if re.match(r"^\d{6}$", query) or re.match(r"^[A-Z]{1,5}$", query):
329
- return query, None
330
-
331
- try:
332
- results = dartlab.search(query)
333
- if results is None or len(results) == 0:
334
- return None, f"'{query}' 검색 κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€. μ’…λͺ©μ½”λ“œ(예: 005930)λ₯Ό 직접 μž…λ ₯ν•΄λ³΄μ„Έμš”."
335
- return str(results[0, "stockCode"]), None
336
- except Exception as e:
337
- return None, f"검색 μ‹€νŒ¨: {e}"
338
-
339
-
340
  def _formatDf(df: pd.DataFrame) -> pd.DataFrame:
341
- """숫자 μ»¬λŸΌμ„ μ²œλ‹¨μœ„ 콀마 λ¬Έμžμ—΄λ‘œ λ³€ν™˜ (μ†Œμˆ˜μ  제거)."""
342
  if df is None or df.empty:
343
  return df
344
  result = df.copy()
@@ -351,28 +237,21 @@ def _formatDf(df: pd.DataFrame) -> pd.DataFrame:
351
 
352
 
353
  def _toExcel(df: pd.DataFrame) -> bytes:
354
- """DataFrame β†’ Excel bytes."""
355
  buf = io.BytesIO()
356
  df.to_excel(buf, index=False, engine="openpyxl")
357
  return buf.getvalue()
358
 
359
 
360
- def _showDataFrame(df: pd.DataFrame, key: str = "", downloadName: str = ""):
361
- """DataFrame ν‘œμ‹œ + μ—‘μ…€ λ‹€μš΄λ‘œλ“œ λ²„νŠΌ."""
362
  if df is None or df.empty:
363
- st.markdown('<p class="dl-empty">데이터 μ—†μŒ</p>', unsafe_allow_html=True)
364
  return
365
- # ν¬λ§·νŒ…λœ λ²„μ „μœΌλ‘œ ν‘œμ‹œ
366
- st.dataframe(
367
- _formatDf(df),
368
- use_container_width=True,
369
- hide_index=True,
370
- key=key or None,
371
- )
372
- # μ—‘μ…€ λ‹€μš΄λ‘œλ“œ (원본 숫자 μœ μ§€)
373
  if downloadName:
374
  st.download_button(
375
- label="πŸ“₯ Excel λ‹€μš΄λ‘œλ“œ",
376
  data=_toExcel(df),
377
  file_name=f"{downloadName}.xlsx",
378
  mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
@@ -380,17 +259,100 @@ def _showDataFrame(df: pd.DataFrame, key: str = "", downloadName: str = ""):
380
  )
381
 
382
 
383
- # ── AI ────────────────────────────────────────────────
 
 
 
 
384
 
385
- _HAS_OPENAI = bool(os.environ.get("OPENAI_API_KEY"))
386
 
387
- if _HAS_OPENAI:
388
- dartlab.llm.configure(provider="openai", api_key=os.environ["OPENAI_API_KEY"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
 
390
 
391
  def _askAi(stockCode: str, question: str) -> str:
392
- """AI 질문 처리. OpenAI μš°μ„ , μ—†μœΌλ©΄ HF 무료 Inference API."""
393
- # OpenAIκ°€ μ„€μ •λ˜μ–΄ 있으면 dartlab.ask μ‚¬μš©
394
  if _HAS_OPENAI:
395
  try:
396
  q = f"{stockCode} {question}" if stockCode else question
@@ -399,15 +361,13 @@ def _askAi(stockCode: str, question: str) -> str:
399
  except Exception as e:
400
  return f"뢄석 μ‹€νŒ¨: {e}"
401
 
402
- # HF Inference API (토큰 없이도 무료 호좜 κ°€λŠ₯)
403
  try:
404
  from huggingface_hub import InferenceClient
405
- token = os.environ.get("HF_TOKEN") # 있으면 rate limit 높아짐
406
  client = InferenceClient(
407
  model="meta-llama/Llama-3.1-8B-Instruct",
408
  token=token if token else None,
409
  )
410
-
411
  context = _buildAiContext(stockCode)
412
  systemMsg = (
413
  "당신은 ν•œκ΅­ κΈ°μ—… 재무 뢄석 μ „λ¬Έκ°€μž…λ‹ˆλ‹€. "
@@ -415,7 +375,6 @@ def _askAi(stockCode: str, question: str) -> str:
415
  "μˆ«μžλŠ” μ²œλ‹¨μœ„ 콀마λ₯Ό μ‚¬μš©ν•˜κ³ , κ·Όκ±°λ₯Ό λͺ…ν™•νžˆ μ œμ‹œν•˜μ„Έμš”.\n\n"
416
  f"{context}"
417
  )
418
-
419
  response = client.chat_completion(
420
  messages=[
421
  {"role": "system", "content": systemMsg},
@@ -429,46 +388,111 @@ def _askAi(stockCode: str, question: str) -> str:
429
 
430
 
431
  def _buildAiContext(stockCode: str) -> str:
432
- """AI에 전달할 κΈ°μ—… 재무 μ»¨ν…μŠ€νŠΈ ꡬ성."""
433
  try:
434
  c = _getCompany(stockCode)
435
  except Exception:
436
  return f"μ’…λͺ©μ½”λ“œ: {stockCode}"
437
 
438
  parts = [f"κΈ°μ—…: {c.corpName} ({c.stockCode}), μ‹œμž₯: {c.market}"]
 
 
 
 
 
 
 
 
439
 
440
- # IS μš”μ•½
441
- try:
442
- isDf = _toPandas(c.IS)
443
- if isDf is not None and not isDf.empty:
444
- parts.append(f"\n[μ†μ΅κ³„μ‚°μ„œ μš”μ•½]\n{isDf.head(15).to_string()}")
445
- except Exception:
446
- pass
447
 
448
- # BS μš”μ•½
449
- try:
450
- bsDf = _toPandas(c.BS)
451
- if bsDf is not None and not bsDf.empty:
452
- parts.append(f"\n[μž¬λ¬΄μƒνƒœν‘œ μš”μ•½]\n{bsDf.head(15).to_string()}")
453
- except Exception:
454
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
 
456
- # ratios μš”μ•½
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  try:
458
- ratDf = _toPandas(c.ratios)
459
- if ratDf is not None and not ratDf.empty:
460
- parts.append(f"\n[μž¬λ¬΄λΉ„μœ¨]\n{ratDf.head(15).to_string()}")
461
  except Exception:
462
  pass
463
 
464
- return "\n".join(parts)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465
 
466
 
467
  # ── ν”„λ¦¬λ‘œλ“œ ──────────────────────────────────────────
468
 
469
  @st.cache_resource
470
  def _warmup():
471
- """listing μΊμ‹œ μ›Œλ°μ—…."""
472
  try:
473
  dartlab.search("μ‚Όμ„±μ „μž")
474
  except Exception:
@@ -483,157 +507,102 @@ _warmup()
483
  st.markdown(f"""
484
  <div class="dl-hero-glow"></div>
485
  <div class="dl-header">
486
- <img src="{_LOGO_URL}" width="88" height="88" alt="DartLab">
487
  <h1>DartLab</h1>
488
  <p class="tagline">μ’…λͺ©μ½”λ“œ ν•˜λ‚˜. κΈ°μ—…μ˜ 전체 이야기.</p>
489
- <p class="sub">DART Β· EDGAR κ³΅μ‹œ 데이터λ₯Ό κ΅¬μ‘°ν™”ν•˜μ—¬ μ œκ³΅ν•©λ‹ˆλ‹€</p>
490
  </div>
491
  """, unsafe_allow_html=True)
492
 
493
 
494
- # ── μ’…λͺ© μž…λ ₯ ─────────────────────────────────────────
495
 
496
- col1, col2 = st.columns([4, 1])
497
- with col1:
498
- query = st.text_input(
499
- "μ’…λͺ©μ½”λ“œ λ˜λŠ” νšŒμ‚¬λͺ…",
500
- placeholder="005930, μ‚Όμ„±μ „μž, AAPL ...",
501
- label_visibility="collapsed",
502
- )
503
- with col2:
504
- analyzeClicked = st.button("λΆ„μ„ν•˜κΈ°", type="primary", use_container_width=True)
505
-
506
- st.markdown('<p class="dl-guide">μ’…λͺ©μ½”λ“œ(005930) λ˜λŠ” νšŒμ‚¬λͺ…(μ‚Όμ„±μ „μž)을 μž…λ ₯ν•˜κ³  λΆ„μ„ν•˜κΈ°λ₯Ό ν΄λ¦­ν•˜μ„Έμš”</p>', unsafe_allow_html=True)
507
-
508
-
509
- # ── 메인 둜직 ─────────────────────────────────────────
510
-
511
- # μ„Έμ…˜ μƒνƒœ μ΄ˆκΈ°ν™”
512
  if "code" not in st.session_state:
513
  st.session_state.code = ""
514
 
515
- if analyzeClicked and query:
516
- code, err = _resolveCode(query)
517
- if err:
518
- st.error(err)
519
- elif code:
520
- st.session_state.code = code
521
 
522
- code = st.session_state.code
523
 
524
- if code:
525
  try:
526
- c = _getCompany(code)
 
527
  except Exception as e:
528
  st.error(f"κΈ°μ—… λ‘œλ“œ μ‹€νŒ¨: {e}")
529
- st.stop()
530
 
531
- # ── κΈ°μ—… μΉ΄λ“œ ──
532
- currency = ""
533
- if hasattr(c, "currency") and c.currency:
534
- currency = c.currency
535
 
536
- st.markdown(f"""
537
- <div class="dl-card">
538
- <h2>🏒 {c.corpName}</h2>
539
- <div class="meta">
540
- <div class="meta-item">
541
- <span class="meta-label">μ’…λͺ©μ½”λ“œ</span>
542
- <span class="meta-value">{c.stockCode}</span>
543
- </div>
544
- <div class="meta-item">
545
- <span class="meta-label">μ‹œμž₯</span>
546
- <span class="meta-value">{c.market}</span>
547
- </div>
548
- {"<div class='meta-item'><span class='meta-label'>톡화</span><span class='meta-value'>" + currency + "</span></div>" if currency else ""}
549
- </div>
550
- </div>
551
- """, unsafe_allow_html=True)
552
 
553
- # ── μž¬λ¬΄μ œν‘œ ──
554
- st.markdown('<div class="dl-section">πŸ“Š μž¬λ¬΄μ œν‘œ</div>', unsafe_allow_html=True)
555
- st.markdown('<p class="dl-guide">IS(μ†μ΅κ³„μ‚°μ„œ) Β· BS(μž¬λ¬΄μƒνƒœν‘œ) Β· CF(ν˜„κΈˆνλ¦„ν‘œ) Β· ratios(μž¬λ¬΄λΉ„μœ¨)</p>', unsafe_allow_html=True)
556
 
557
- sheetTab = st.radio(
558
- "μ‹œνŠΈ 선택",
559
- ["IS", "BS", "CF", "ratios"],
560
- horizontal=True,
561
- label_visibility="collapsed",
562
- )
563
 
564
- finDf = None
565
- try:
566
- raw = getattr(c, sheetTab, None)
567
- finDf = _toPandas(raw)
568
- except Exception:
569
- pass
570
 
571
- _showDataFrame(finDf, key="finance", downloadName=f"{code}_{sheetTab}")
 
 
 
572
 
573
- # ── Sections ──
574
- topics = []
575
- try:
576
- topics = list(c.topics) if c.topics else []
577
- except Exception:
578
- pass
579
 
580
- if topics:
581
- st.markdown('<div class="dl-section">πŸ“‹ κ³΅μ‹œ 데이터 (Sections)</div>', unsafe_allow_html=True)
582
- st.markdown('<p class="dl-guide">topic을 μ„ νƒν•˜λ©΄ ν•΄λ‹Ή κ³΅μ‹œ ν•­λͺ©μ˜ 기간별 데이터λ₯Ό ν‘œμ‹œν•©λ‹ˆλ‹€</p>', unsafe_allow_html=True)
 
 
 
 
 
 
 
 
583
 
584
- selectedTopic = st.selectbox(
585
- "Topic 선택",
586
- topics,
587
- label_visibility="collapsed",
588
- )
 
 
589
 
590
- if selectedTopic:
591
- secDf = None
592
- secText = ""
593
- try:
594
- result = c.show(selectedTopic)
595
- if result is not None:
596
- if hasattr(result, "to_pandas"):
597
- secDf = _toPandas(result)
598
- else:
599
- secText = str(result)
600
- except Exception as e:
601
- secText = f"쑰회 μ‹€νŒ¨: {e}"
602
-
603
- if secDf is not None:
604
- _showDataFrame(secDf, key="section", downloadName=f"{code}_{selectedTopic}")
605
- elif secText:
606
- st.markdown(secText)
607
-
608
- # ── AI Chat ──
609
- with st.expander("πŸ€– AI 뢄석 (무료)", expanded=False):
610
- st.caption("Llama 3.1 8B 기반 무료 AI 뢄석 Β· λ³΅μž‘ν•œ μ§ˆλ¬Έμ€ OpenAI API μ„€μ • μ‹œ 더 μ •ν™•ν•©λ‹ˆλ‹€")
611
- if True:
612
- if "messages" not in st.session_state:
613
- st.session_state.messages = []
614
-
615
- for msg in st.session_state.messages:
616
- with st.chat_message(msg["role"]):
617
- st.markdown(msg["content"])
618
-
619
- if prompt := st.chat_input("μž¬λ¬΄κ±΄μ „μ„± λΆ„μ„ν•΄μ€˜, λ°°λ‹Ή 이상 μ§•ν›„ μ°Ύμ•„μ€˜ ..."):
620
- st.session_state.messages.append({"role": "user", "content": prompt})
621
- with st.chat_message("user"):
622
- st.markdown(prompt)
623
-
624
- with st.chat_message("assistant"):
625
- with st.spinner("뢄석 쀑..."):
626
- answer = _askAi(code, prompt)
627
- st.markdown(answer)
628
- st.session_state.messages.append({"role": "assistant", "content": answer})
629
-
630
- else:
631
- # λ―Έμž…λ ₯ μƒνƒœ
632
  st.markdown("""
633
- <div class="dl-empty">
634
- <p style="font-size: 1.2rem; color: #94a3b8;">μ’…λͺ©μ½”λ“œλ₯Ό μž…λ ₯ν•˜κ³  <strong>λΆ„μ„ν•˜κΈ°</strong>λ₯Ό ν΄λ¦­ν•˜μ„Έμš”</p>
635
- <p style="color: #475569; margin-top: 0.5rem;">
636
- μ˜ˆμ‹œ: <code>005930</code> (μ‚Όμ„±μ „μž) Β· <code>000660</code> (SKν•˜μ΄λ‹‰μŠ€) Β· <code>AAPL</code> (Apple)
 
 
 
 
 
 
 
637
  </p>
638
  </div>
639
  """, unsafe_allow_html=True)
@@ -643,12 +612,12 @@ else:
643
 
644
  st.markdown(f"""
645
  <div class="dl-footer">
646
- <a href="{_BLOG_URL}">πŸ“– 초보자 κ°€μ΄λ“œ</a> Β·
647
- <a href="{_DOCS_URL}">πŸ“˜ 곡식 λ¬Έμ„œ</a> Β·
648
- <a href="{_COLAB_URL}">πŸ”¬ Colab</a> Β·
649
- <a href="{_REPO_URL}">⭐ GitHub</a>
650
- <br><span style="color:#334155; font-size:0.8rem; margin-top:0.5rem; display:inline-block;">
651
- pip install dartlab Β· Python 3.12+
652
  </span>
653
  </div>
654
  """, unsafe_allow_html=True)
 
1
+ """DartLab Streamlit Demo β€” AI μ±„νŒ… 기반 κΈ°μ—… 뢄석."""
2
 
3
  from __future__ import annotations
4
 
 
6
  import io
7
  import os
8
  import re
 
9
 
10
  import pandas as pd
11
  import streamlit as st
 
21
  _COLAB_URL = "https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/showcase/01_quickstart.ipynb"
22
  _REPO_URL = "https://github.com/eddmpython/dartlab"
23
 
24
+ _HAS_OPENAI = bool(os.environ.get("OPENAI_API_KEY"))
25
+
26
+ if _HAS_OPENAI:
27
+ dartlab.llm.configure(provider="openai", api_key=os.environ["OPENAI_API_KEY"])
28
+
29
  # ── νŽ˜μ΄μ§€ μ„€μ • ──────────────────────────────────────
30
 
31
  st.set_page_config(
32
+ page_title="DartLab β€” AI κΈ°μ—… 뢄석",
33
+ page_icon=None,
34
  layout="centered",
35
  )
36
 
37
+ # ── CSS ───────────────────────────────────────────────
38
 
39
  st.markdown("""
40
  <style>
41
+ /* 닀크 ν…Œλ§ˆ κ°•μ œ */
 
 
 
 
 
42
  html, body, [data-testid="stAppViewContainer"],
43
  [data-testid="stApp"], .main, .block-container {
44
  background-color: #050811 !important;
45
  color: #f1f5f9 !important;
46
  }
47
+ [data-testid="stHeader"] { background: #050811 !important; }
48
+ [data-testid="stSidebar"] { background: #0f1219 !important; }
 
 
 
 
49
 
50
+ /* μž…λ ₯ ν•„λ“œ */
51
+ input, textarea,
52
+ [data-baseweb="input"] input, [data-baseweb="textarea"] textarea,
53
+ [data-baseweb="input"], [data-baseweb="base-input"] {
54
  background-color: #0f1219 !important;
55
  color: #f1f5f9 !important;
56
  border-color: #1e2433 !important;
 
 
 
 
 
 
57
  }
58
 
59
+ /* μ…€λ ‰νŠΈ/λ“œλ‘­λ‹€μš΄ */
60
  [data-baseweb="select"] > div {
61
  background-color: #0f1219 !important;
62
  border-color: #1e2433 !important;
63
  color: #f1f5f9 !important;
64
  }
65
+ [data-baseweb="popover"], [data-baseweb="menu"] {
 
 
 
 
66
  background-color: #0f1219 !important;
67
  }
68
+ [data-baseweb="menu"] li { color: #f1f5f9 !important; }
69
+ [data-baseweb="menu"] li:hover { background-color: #1a1f2b !important; }
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
+ /* λΌλ””μ˜€ */
72
+ [data-testid="stRadio"] label { color: #f1f5f9 !important; }
 
 
 
 
 
 
73
 
74
+ /* λ²„νŠΌ β€” dartlab primary 톡일 */
75
+ button, [data-testid="stBaseButton-primary"],
76
+ [data-testid="stBaseButton-secondary"],
77
+ [data-testid="stFormSubmitButton"] button,
78
+ [data-testid="stChatInputSubmitButton"] {
79
  background-color: #ea4647 !important;
80
  color: #fff !important;
81
  border: none !important;
82
  font-weight: 600 !important;
83
  }
84
+ button:hover, [data-testid="stBaseButton-primary"]:hover,
85
+ [data-testid="stChatInputSubmitButton"]:hover {
86
  background-color: #c83232 !important;
87
  }
 
88
  [data-testid="stDownloadButton"] button {
89
  background-color: #0f1219 !important;
90
  color: #f1f5f9 !important;
 
93
  [data-testid="stDownloadButton"] button:hover {
94
  border-color: #ea4647 !important;
95
  color: #ea4647 !important;
96
+ background-color: #0f1219 !important;
97
+ }
98
+ /* expander 토글은 배경색 제거 */
99
+ [data-testid="stExpander"] button {
100
+ background-color: transparent !important;
101
+ color: #f1f5f9 !important;
102
  }
103
 
104
  /* Expander */
 
106
  background-color: #0f1219 !important;
107
  border-color: #1e2433 !important;
108
  }
 
 
 
 
 
 
109
 
110
  /* Chat */
111
  [data-testid="stChatMessage"] {
112
+ background-color: #0a0e17 !important;
113
  border-color: #1e2433 !important;
114
  }
115
+ [data-testid="stChatInput"], [data-testid="stChatInput"] textarea {
116
  background-color: #0f1219 !important;
117
  border-color: #1e2433 !important;
 
 
 
118
  color: #f1f5f9 !important;
119
  }
120
 
121
+ /* ν…μŠ€νŠΈ */
122
  p, span, label, h1, h2, h3, h4, h5, h6,
123
  [data-testid="stMarkdownContainer"],
124
  [data-testid="stMarkdownContainer"] p {
125
  color: #f1f5f9 !important;
126
  }
127
+ [data-testid="stCaption"] { color: #64748b !important; }
 
 
128
 
129
+ /* DataFrame */
130
+ [data-testid="stDataFrame"] { font-variant-numeric: tabular-nums; }
131
 
132
+ /* μ»€μŠ€ν…€ */
133
  .dl-header {
134
  text-align: center;
135
+ padding: 1.5rem 0 0.5rem;
136
  }
137
  .dl-header img {
138
  border-radius: 50%;
 
143
  -webkit-background-clip: text;
144
  -webkit-text-fill-color: transparent;
145
  background-clip: text;
146
+ font-size: 2.4rem !important;
147
  font-weight: 800 !important;
148
+ margin: 0.5rem 0 0.1rem !important;
149
  letter-spacing: -0.03em;
150
  }
151
+ .dl-header .tagline { color: #94a3b8 !important; font-size: 1rem; margin: 0; }
152
+ .dl-header .sub { color: #64748b !important; font-size: 0.82rem; margin: 0.15rem 0 0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
 
154
  .dl-card {
155
  background: linear-gradient(135deg, #0f1219 0%, #0a0d16 100%);
156
  border: 1px solid #1e2433;
157
  border-radius: 12px;
158
+ padding: 1.2rem 1.5rem;
159
+ margin: 0.8rem 0;
160
  position: relative;
161
  overflow: hidden;
162
  }
 
167
  height: 3px;
168
  background: linear-gradient(90deg, #ea4647, #f87171, #fb923c);
169
  }
170
+ .dl-card h3 { color: #f1f5f9 !important; font-size: 1.3rem !important; margin: 0 0 0.8rem !important; }
171
+ .dl-card .meta { display: flex; gap: 2.5rem; flex-wrap: wrap; }
172
+ .dl-card .meta-item { display: flex; flex-direction: column; gap: 0.1rem; }
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  .dl-card .meta-label {
174
+ color: #64748b !important; font-size: 0.72rem;
175
+ text-transform: uppercase; letter-spacing: 0.08em;
 
 
 
176
  }
177
  .dl-card .meta-value {
178
+ color: #e2e8f0 !important; font-size: 1.1rem; font-weight: 600;
179
+ font-family: 'JetBrains Mono', monospace;
 
 
180
  }
181
 
182
+ .dl-section {
183
+ color: #ea4647 !important;
184
+ font-weight: 700 !important;
185
+ font-size: 1.05rem !important;
186
+ border-bottom: 2px solid #ea4647;
187
+ padding-bottom: 0.3rem;
188
+ margin: 1rem 0 0.6rem;
189
  }
190
 
 
191
  .dl-footer {
192
  text-align: center;
193
+ padding: 1.5rem 0 0.8rem;
194
  border-top: 1px solid #1e2433;
195
+ margin-top: 2rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  color: #475569 !important;
197
+ font-size: 0.82rem;
 
198
  }
199
+ .dl-footer a { color: #94a3b8 !important; text-decoration: none; margin: 0 0.5rem; }
200
+ .dl-footer a:hover { color: #ea4647 !important; }
201
 
 
202
  .dl-hero-glow {
203
  position: fixed;
204
  top: 0; left: 50%;
205
  transform: translateX(-50%);
206
+ width: 600px; height: 400px;
207
+ background: radial-gradient(ellipse at top, rgba(234,70,71,0.05) 0%, transparent 60%);
208
+ pointer-events: none; z-index: 0;
 
 
209
  }
210
  </style>
211
  """, unsafe_allow_html=True)
 
215
 
216
 
217
  def _toPandas(df):
218
+ """Polars/pandas DataFrame -> pandas."""
219
  if df is None:
220
  return None
221
  if hasattr(df, "to_pandas"):
 
223
  return df
224
 
225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  def _formatDf(df: pd.DataFrame) -> pd.DataFrame:
227
+ """숫자λ₯Ό μ²œλ‹¨μœ„ 콀마 λ¬Έμžμ—΄λ‘œ λ³€ν™˜ (μ†Œμˆ˜μ  제거)."""
228
  if df is None or df.empty:
229
  return df
230
  result = df.copy()
 
237
 
238
 
239
  def _toExcel(df: pd.DataFrame) -> bytes:
240
+ """DataFrame -> Excel bytes."""
241
  buf = io.BytesIO()
242
  df.to_excel(buf, index=False, engine="openpyxl")
243
  return buf.getvalue()
244
 
245
 
246
+ def _showDf(df: pd.DataFrame, key: str = "", downloadName: str = ""):
247
+ """DataFrame ν‘œμ‹œ + Excel λ‹€μš΄λ‘œλ“œ."""
248
  if df is None or df.empty:
249
+ st.caption("데이터 μ—†μŒ")
250
  return
251
+ st.dataframe(_formatDf(df), use_container_width=True, hide_index=True, key=key or None)
 
 
 
 
 
 
 
252
  if downloadName:
253
  st.download_button(
254
+ label="Excel λ‹€μš΄λ‘œλ“œ",
255
  data=_toExcel(df),
256
  file_name=f"{downloadName}.xlsx",
257
  mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
 
259
  )
260
 
261
 
262
+ @st.cache_resource(max_entries=_MAX_CACHE)
263
+ def _getCompany(code: str):
264
+ """μΊμ‹œλœ Company."""
265
+ gc.collect()
266
+ return dartlab.Company(code)
267
 
 
268
 
269
+ # ── μ’…λͺ©μ½”λ“œ μΆ”μΆœ ────────────────────────────────────
270
+
271
+
272
+ def _extractCode(message: str) -> str | None:
273
+ """λ©”μ‹œμ§€μ—μ„œ μ’…λͺ©μ½”λ“œ/νšŒμ‚¬λͺ… μΆ”μΆœ."""
274
+ msg = message.strip()
275
+
276
+ # 6자리 숫자
277
+ m = re.search(r"\b(\d{6})\b", msg)
278
+ if m:
279
+ return m.group(1)
280
+
281
+ # 영문 티컀 (단독 λŒ€λ¬Έμž 1~5자)
282
+ m = re.search(r"\b([A-Z]{1,5})\b", msg)
283
+ if m:
284
+ return m.group(1)
285
+
286
+ # ν•œκΈ€ νšŒμ‚¬λͺ… β†’ dartlab.search
287
+ cleaned = re.sub(
288
+ r"(에\s*λŒ€ν•΄|에\s*λŒ€ν•œ|μ—λŒ€ν•΄|μ’€|의|λ₯Ό|을|은|λŠ”|이|κ°€|도|만|λΆ€ν„°|κΉŒμ§€|ν•˜κ³ |μ΄λž‘|λž‘|둜|으둜|와|κ³Ό|ν•œν…Œ|μ—μ„œ|μ—κ²Œ)\b",
289
+ " ",
290
+ msg,
291
+ )
292
+ # λΆˆν•„μš”ν•œ 동사/쑰동사 제거
293
+ cleaned = re.sub(
294
+ r"\b(μ•Œλ €μ€˜|λ³΄μ—¬μ€˜|뢄석|ν•΄μ€˜|해봐|μ–΄λ•Œ|보자|볼래|쀘|ν•΄|μ’€|μš”)\b",
295
+ " ",
296
+ cleaned,
297
+ )
298
+ tokens = re.findall(r"[κ°€-힣A-Za-z0-9]+", cleaned)
299
+ # κΈ΄ 토큰 μš°μ„  (νšŒμ‚¬λͺ…일 κ°€λŠ₯μ„± λ†’μŒ)
300
+ tokens.sort(key=len, reverse=True)
301
+ for token in tokens:
302
+ if len(token) >= 2:
303
+ try:
304
+ results = dartlab.search(token)
305
+ if results is not None and len(results) > 0:
306
+ return str(results[0, "stockCode"])
307
+ except Exception:
308
+ continue
309
+ return None
310
+
311
+
312
+ def _detectTopic(message: str) -> str | None:
313
+ """λ©”μ‹œμ§€μ—μ„œ νŠΉμ • topic ν‚€μ›Œλ“œ 감지."""
314
+ topicMap = {
315
+ "λ°°λ‹Ή": "dividend",
316
+ "μ£Όμ£Ό": "majorHolder",
317
+ "λŒ€μ£Όμ£Ό": "majorHolder",
318
+ "직원": "employee",
319
+ "μž„μ›": "executive",
320
+ "μž„μ›λ³΄μˆ˜": "executivePay",
321
+ "보수": "executivePay",
322
+ "μ„Έκ·Έλ¨ΌνŠΈ": "segments",
323
+ "λΆ€λ¬Έ": "segments",
324
+ "사업뢀": "segments",
325
+ "μœ ν˜•μžμ‚°": "tangibleAsset",
326
+ "λ¬΄ν˜•μžμ‚°": "intangibleAsset",
327
+ "μ›μž¬λ£Œ": "rawMaterial",
328
+ "수주": "salesOrder",
329
+ "μ œν’ˆ": "productService",
330
+ "μžνšŒμ‚¬": "subsidiary",
331
+ "쒅속": "subsidiary",
332
+ "뢀채": "contingentLiability",
333
+ "우발": "contingentLiability",
334
+ "νŒŒμƒ": "riskDerivative",
335
+ "사채": "bond",
336
+ "μ΄μ‚¬νšŒ": "boardOfDirectors",
337
+ "감사": "audit",
338
+ "μžλ³Έλ³€λ™": "capitalChange",
339
+ "μžκΈ°μ£Όμ‹": "treasuryStock",
340
+ "μ‚¬μ—…κ°œμš”": "business",
341
+ "사업보고": "business",
342
+ "μ—°ν˜": "companyHistory",
343
+ }
344
+ msg = message.lower()
345
+ for keyword, topic in topicMap.items():
346
+ if keyword in msg:
347
+ return topic
348
+ return None
349
+
350
+
351
+ # ── AI ────────────────────────────────────────────────
352
 
353
 
354
  def _askAi(stockCode: str, question: str) -> str:
355
+ """AI 질문. OpenAI μš°μ„ , HF 무료 fallback."""
 
356
  if _HAS_OPENAI:
357
  try:
358
  q = f"{stockCode} {question}" if stockCode else question
 
361
  except Exception as e:
362
  return f"뢄석 μ‹€νŒ¨: {e}"
363
 
 
364
  try:
365
  from huggingface_hub import InferenceClient
366
+ token = os.environ.get("HF_TOKEN")
367
  client = InferenceClient(
368
  model="meta-llama/Llama-3.1-8B-Instruct",
369
  token=token if token else None,
370
  )
 
371
  context = _buildAiContext(stockCode)
372
  systemMsg = (
373
  "당신은 ν•œκ΅­ κΈ°μ—… 재무 뢄석 μ „λ¬Έκ°€μž…λ‹ˆλ‹€. "
 
375
  "μˆ«μžλŠ” μ²œλ‹¨μœ„ 콀마λ₯Ό μ‚¬μš©ν•˜κ³ , κ·Όκ±°λ₯Ό λͺ…ν™•νžˆ μ œμ‹œν•˜μ„Έμš”.\n\n"
376
  f"{context}"
377
  )
 
378
  response = client.chat_completion(
379
  messages=[
380
  {"role": "system", "content": systemMsg},
 
388
 
389
 
390
  def _buildAiContext(stockCode: str) -> str:
391
+ """AI μ»¨ν…μŠ€νŠΈ ꡬ성."""
392
  try:
393
  c = _getCompany(stockCode)
394
  except Exception:
395
  return f"μ’…λͺ©μ½”λ“œ: {stockCode}"
396
 
397
  parts = [f"κΈ°μ—…: {c.corpName} ({c.stockCode}), μ‹œμž₯: {c.market}"]
398
+ for name, attr in [("μ†μ΅κ³„μ‚°μ„œ", "IS"), ("μž¬λ¬΄μƒνƒœν‘œ", "BS"), ("μž¬λ¬΄λΉ„μœ¨", "ratios")]:
399
+ try:
400
+ df = _toPandas(getattr(c, attr, None))
401
+ if df is not None and not df.empty:
402
+ parts.append(f"\n[{name}]\n{df.head(15).to_string()}")
403
+ except Exception:
404
+ pass
405
+ return "\n".join(parts)
406
 
 
 
 
 
 
 
 
407
 
408
+ # ── λŒ€μ‹œλ³΄λ“œ λ Œλ”λ§ ──────────────────────────────────
409
+
410
+
411
+ def _renderCompanyCard(c):
412
+ """κΈ°μ—… μΉ΄λ“œ."""
413
+ currency = ""
414
+ if hasattr(c, "currency") and c.currency:
415
+ currency = c.currency
416
+ currencyHtml = (
417
+ f"<div class='meta-item'><span class='meta-label'>톡화</span>"
418
+ f"<span class='meta-value'>{currency}</span></div>"
419
+ if currency else ""
420
+ )
421
+ st.markdown(f"""
422
+ <div class="dl-card">
423
+ <h3>{c.corpName}</h3>
424
+ <div class="meta">
425
+ <div class="meta-item">
426
+ <span class="meta-label">μ’…λͺ©μ½”λ“œ</span>
427
+ <span class="meta-value">{c.stockCode}</span>
428
+ </div>
429
+ <div class="meta-item">
430
+ <span class="meta-label">μ‹œμž₯</span>
431
+ <span class="meta-value">{c.market}</span>
432
+ </div>
433
+ {currencyHtml}
434
+ </div>
435
+ </div>
436
+ """, unsafe_allow_html=True)
437
+
438
 
439
+ def _renderFullDashboard(c, code: str):
440
+ """전체 재무 λŒ€μ‹œλ³΄λ“œ."""
441
+ _renderCompanyCard(c)
442
+
443
+ # μž¬λ¬΄μ œν‘œ
444
+ st.markdown('<div class="dl-section">μž¬λ¬΄μ œν‘œ</div>', unsafe_allow_html=True)
445
+ for label, attr in [("IS (μ†μ΅κ³„μ‚°μ„œ)", "IS"), ("BS (μž¬λ¬΄μƒνƒœν‘œ)", "BS"),
446
+ ("CF (ν˜„κΈˆνλ¦„ν‘œ)", "CF"), ("ratios (μž¬λ¬΄λΉ„μœ¨)", "ratios")]:
447
+ with st.expander(label, expanded=(attr == "IS")):
448
+ try:
449
+ df = _toPandas(getattr(c, attr, None))
450
+ _showDf(df, key=f"dash_{attr}", downloadName=f"{code}_{attr}")
451
+ except Exception:
452
+ st.caption("λ‘œλ“œ μ‹€νŒ¨")
453
+
454
+ # Sections
455
+ topics = []
456
  try:
457
+ topics = list(c.topics) if c.topics else []
 
 
458
  except Exception:
459
  pass
460
 
461
+ if topics:
462
+ st.markdown('<div class="dl-section">κ³΅μ‹œ 데이터</div>', unsafe_allow_html=True)
463
+ selectedTopic = st.selectbox("topic", topics, label_visibility="collapsed", key="dash_topic")
464
+ if selectedTopic:
465
+ try:
466
+ result = c.show(selectedTopic)
467
+ if result is not None:
468
+ if hasattr(result, "to_pandas"):
469
+ _showDf(_toPandas(result), key="dash_sec", downloadName=f"{code}_{selectedTopic}")
470
+ else:
471
+ st.markdown(str(result))
472
+ except Exception as e:
473
+ st.caption(f"쑰회 μ‹€νŒ¨: {e}")
474
+
475
+
476
+ def _renderTopicData(c, code: str, topic: str):
477
+ """νŠΉμ • topic λ°μ΄ν„°λ§Œ λ Œλ”λ§."""
478
+ try:
479
+ result = c.show(topic)
480
+ if result is not None:
481
+ if hasattr(result, "to_pandas"):
482
+ _showDf(_toPandas(result), key=f"topic_{topic}", downloadName=f"{code}_{topic}")
483
+ else:
484
+ st.markdown(str(result))
485
+ else:
486
+ st.caption(f"'{topic}' 데이터 μ—†μŒ")
487
+ except Exception as e:
488
+ st.caption(f"쑰회 μ‹€νŒ¨: {e}")
489
 
490
 
491
  # ── ν”„λ¦¬λ‘œλ“œ ──────────────────────────────────────────
492
 
493
  @st.cache_resource
494
  def _warmup():
495
+ """listing μΊμ‹œ."""
496
  try:
497
  dartlab.search("μ‚Όμ„±μ „μž")
498
  except Exception:
 
507
  st.markdown(f"""
508
  <div class="dl-hero-glow"></div>
509
  <div class="dl-header">
510
+ <img src="{_LOGO_URL}" width="80" height="80" alt="DartLab">
511
  <h1>DartLab</h1>
512
  <p class="tagline">μ’…λͺ©μ½”λ“œ ν•˜λ‚˜. κΈ°μ—…μ˜ 전체 이야기.</p>
513
+ <p class="sub">DART / EDGAR κ³΅μ‹œ 데이터λ₯Ό κ΅¬μ‘°ν™”ν•˜μ—¬ μ œκ³΅ν•©λ‹ˆλ‹€</p>
514
  </div>
515
  """, unsafe_allow_html=True)
516
 
517
 
518
+ # ── μ„Έμ…˜ μ΄ˆκΈ°ν™” ──────────────────────────────────────
519
 
520
+ if "messages" not in st.session_state:
521
+ st.session_state.messages = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  if "code" not in st.session_state:
523
  st.session_state.code = ""
524
 
 
 
 
 
 
 
525
 
526
+ # ── λŒ€μ‹œλ³΄λ“œ μ˜μ—­ (μ’…λͺ©μ΄ 있으면 ν‘œμ‹œ) ───────��────────
527
 
528
+ if st.session_state.code:
529
  try:
530
+ _dashCompany = _getCompany(st.session_state.code)
531
+ _renderFullDashboard(_dashCompany, st.session_state.code)
532
  except Exception as e:
533
  st.error(f"κΈ°μ—… λ‘œλ“œ μ‹€νŒ¨: {e}")
 
534
 
535
+ st.markdown("---")
 
 
 
536
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
 
538
+ # ── μ±„νŒ… μ˜μ—­ ────────────────────────────────────────
 
 
539
 
540
+ # νžˆμŠ€ν† λ¦¬ ν‘œμ‹œ
541
+ for msg in st.session_state.messages:
542
+ with st.chat_message(msg["role"]):
543
+ st.markdown(msg["content"])
 
 
544
 
545
+ # μž…λ ₯
546
+ if prompt := st.chat_input("μ‚Όμ„±μ „μžμ— λŒ€ν•΄ μ•Œλ €μ€˜, λ°°λ‹Ή ν˜„ν™©μ€? ..."):
547
+ # μ‚¬μš©μž λ©”μ‹œμ§€ ν‘œμ‹œ
548
+ st.session_state.messages.append({"role": "user", "content": prompt})
549
+ with st.chat_message("user"):
550
+ st.markdown(prompt)
551
 
552
+ # μ’…λͺ©μ½”λ“œ μΆ”μΆœ μ‹œλ„
553
+ newCode = _extractCode(prompt)
554
+ if newCode and newCode != st.session_state.code:
555
+ st.session_state.code = newCode
556
 
557
+ code = st.session_state.code
 
 
 
 
 
558
 
559
+ if not code:
560
+ # μ’…λͺ© λͺ» 찾음
561
+ reply = "μ’…λͺ©μ„ μ°Ύμ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€. νšŒμ‚¬λͺ…μ΄λ‚˜ μ’…λͺ©μ½”λ“œλ₯Ό ν¬ν•¨ν•΄μ„œ λ‹€μ‹œ μ§ˆλ¬Έν•΄μ£Όμ„Έμš”.\n\n예: μ‚Όμ„±μ „μžμ— λŒ€ν•΄ μ•Œλ €μ€˜, 005930 뢄석, AAPL 재무"
562
+ st.session_state.messages.append({"role": "assistant", "content": reply})
563
+ with st.chat_message("assistant"):
564
+ st.markdown(reply)
565
+ else:
566
+ # 응닡 생성
567
+ with st.chat_message("assistant"):
568
+ # νŠΉμ • topic 감지
569
+ topic = _detectTopic(prompt)
570
 
571
+ if topic:
572
+ # νŠΉμ • topic만 보여주기
573
+ try:
574
+ c = _getCompany(code)
575
+ _renderTopicData(c, code, topic)
576
+ except Exception:
577
+ pass
578
 
579
+ # AI μš”μ•½
580
+ with st.spinner("뢄석 쀑..."):
581
+ aiAnswer = _askAi(code, prompt)
582
+ st.markdown(aiAnswer)
583
+
584
+ st.session_state.messages.append({"role": "assistant", "content": aiAnswer})
585
+
586
+ # λŒ€μ‹œλ³΄λ“œ 갱신을 μœ„ν•΄ rerun
587
+ if newCode and newCode != "":
588
+ st.rerun()
589
+
590
+
591
+ # ── 초기 μ•ˆλ‚΄ (λŒ€ν™” 없을 λ•Œ) ─────────────────────────
592
+
593
+ if not st.session_state.messages and not st.session_state.code:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
594
  st.markdown("""
595
+ <div style="text-align: center; color: #64748b; padding: 2rem 1rem;">
596
+ <p style="font-size: 1.1rem; color: #94a3b8;">
597
+ μ•„λž˜ μž…λ ₯창에 μžμ—°μ–΄λ‘œ μ§ˆλ¬Έν•˜μ„Έμš”
598
+ </p>
599
+ <p style="margin-top: 0.5rem;">
600
+ <code>μ‚Όμ„±μ „μžμ— λŒ€ν•΄ μ•Œλ €μ€˜</code> &middot;
601
+ <code>005930 뢄석</code> &middot;
602
+ <code>AAPL 재무 λ³΄μ—¬μ€˜</code>
603
+ </p>
604
+ <p style="margin-top: 0.3rem; font-size: 0.85rem;">
605
+ μ’…λͺ©μ„ λ§ν•˜λ©΄ μž¬λ¬΄μ œν‘œ/κ³΅μ‹œ 데이터가 λ°”λ‘œ ν‘œμ‹œλ˜κ³ , AIκ°€ 뢄석을 λ§λΆ™μž…λ‹ˆλ‹€
606
  </p>
607
  </div>
608
  """, unsafe_allow_html=True)
 
612
 
613
  st.markdown(f"""
614
  <div class="dl-footer">
615
+ <a href="{_BLOG_URL}">초보자 κ°€μ΄λ“œ</a> /
616
+ <a href="{_DOCS_URL}">곡식 λ¬Έμ„œ</a> /
617
+ <a href="{_COLAB_URL}">Colab</a> /
618
+ <a href="{_REPO_URL}">GitHub</a>
619
+ <br><span style="color:#334155; font-size:0.78rem; margin-top:0.4rem; display:inline-block;">
620
+ pip install dartlab
621
  </span>
622
  </div>
623
  """, unsafe_allow_html=True)