diff --git a/.streamlit/config.toml b/.streamlit/config.toml
deleted file mode 100644
index 10097aabe303621b02f86a514561cf4390aefcbc..0000000000000000000000000000000000000000
--- a/.streamlit/config.toml
+++ /dev/null
@@ -1,7 +0,0 @@
-[theme]
-base = "dark"
-primaryColor = "#ea4647"
-backgroundColor = "#050811"
-secondaryBackgroundColor = "#0f1219"
-textColor = "#f1f5f9"
-font = "sans serif"
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..3de25e1a2215a843a81c9dbc7f5d76d3c7119d2a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,18 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY pyproject.toml README.md ./
+COPY src/ src/
+
+RUN pip install --no-cache-dir -e ".[ai]"
+
+ENV SPACE_ID=1
+
+EXPOSE 7860
+
+CMD ["python", "-m", "dartlab.server"]
diff --git a/README.md b/README.md
index 76f21d3b1d0758c1d4acb997d19474778ac8a064..0eb3e1e14e4a775e1ad6788c97410ce3d83411a4 100644
--- a/README.md
+++ b/README.md
@@ -3,9 +3,7 @@ title: DartLab
emoji: ๐
colorFrom: red
colorTo: yellow
-sdk: streamlit
-sdk_version: "1.45.1"
-app_file: app.py
+sdk: docker
pinned: true
license: mit
short_description: DART + EDGAR disclosure analysis
diff --git a/README_PROJECT.md b/README_PROJECT.md
new file mode 100644
index 0000000000000000000000000000000000000000..8ba73cf502cfd40de6decb84e4c4705d958175f9
--- /dev/null
+++ b/README_PROJECT.md
@@ -0,0 +1,1108 @@
+
+
+> **Note:** DartLab is under active development. APIs may change between versions, and documentation may lag behind the latest code.
+
+## Install
+
+Requires **Python 3.12+**.
+
+```bash
+# Core โ financial statements, sections, Company
+uv add dartlab
+
+# or with pip
+pip install dartlab
+```
+
+### Optional Extras
+
+Install only what you need:
+
+```bash
+uv add "dartlab[ai]" # web UI, server, streaming (FastAPI + uvicorn)
+uv add "dartlab[llm]" # LLM analysis (OpenAI)
+uv add "dartlab[charts]" # Plotly charts, network graphs (plotly + networkx + scipy)
+uv add "dartlab[mcp]" # MCP server for Claude Desktop / Code / Cursor
+uv add "dartlab[channel]" # web UI + cloudflared tunnel sharing
+uv add "dartlab[channel-ngrok]" # web UI + ngrok tunnel sharing
+uv add "dartlab[channel-full]" # all channels + Telegram / Slack / Discord bots
+uv add "dartlab[all]" # everything above (except channel bots)
+```
+
+**Common combinations:**
+
+```bash
+# financial analysis + AI chat
+uv add "dartlab[ai,llm]"
+
+# full analysis suite โ charts, AI, LLM
+uv add "dartlab[ai,llm,charts]"
+
+# share analysis with team via tunnel
+uv add "dartlab[channel]"
+```
+
+### From Source
+
+```bash
+git clone https://github.com/eddmpython/dartlab.git
+cd dartlab && uv pip install -e ".[all]"
+
+# or with pip
+pip install -e ".[all]"
+```
+
+PyPI releases are published only when the core is stable. If you want the latest features (including experimental ones like audit, forecast, valuation), clone the repo directly โ but expect occasional breaking changes.
+
+### Desktop App (Alpha)
+
+Skip all installation steps โ download the standalone Windows launcher:
+
+- **[Download DartLab.exe](https://github.com/eddmpython/dartlab-desktop/releases/latest/download/DartLab.exe)** from [dartlab-desktop](https://github.com/eddmpython/dartlab-desktop)
+- Also available from the [DartLab landing page](https://eddmpython.github.io/dartlab/)
+
+One-click launch โ no Python, no terminal, no package manager required. The desktop app bundles the web UI with a built-in Python runtime.
+
+> **Alpha** โ functional but incomplete. The desktop app is a Windows-only `.exe` launcher. macOS/Linux are not yet supported.
+
+---
+
+**No data setup required.** When you create a `Company`, dartlab automatically downloads the required data from [HuggingFace](https://huggingface.co/datasets/eddmpython/dartlab-data) (DART) or SEC API (EDGAR). The second run loads instantly from local cache.
+
+## Quick Start
+
+Pick any company. Get the whole picture.
+
+```python
+import dartlab
+
+# Samsung Electronics โ from raw filings to structured data
+c = dartlab.Company("005930")
+c.sections # every topic, every period, side by side
+c.show("businessOverview") # what this company actually does
+c.diff("businessOverview") # what changed since last year
+c.BS # standardized balance sheet
+c.ratios # 47 financial ratios, already calculated
+
+# Apple โ same interface, different country
+us = dartlab.Company("AAPL")
+us.show("business")
+us.ratios
+
+# No code needed โ ask in natural language
+dartlab.ask("Analyze Samsung Electronics financial health")
+```
+
+## What DartLab Is
+
+A public company files hundreds of pages every quarter. Inside those pages is everything โ revenue trends, risk warnings, management strategy, competitive position. The complete truth about a company, written by the company itself.
+
+Nobody reads it.
+
+Not because they don't want to. Because the same information is named differently by every company, structured differently every year, and scattered across formats designed for regulators, not readers. The same "revenue" appears as `ifrs-full_Revenue`, `dart_Revenue`, `SalesRevenue`, or dozens of Korean variations.
+
+DartLab changes who can access this information. Two engines turn raw filings into one comparable map:
+
+### The Two Problems DartLab Solves
+
+**1. The same company says different things differently every year.**
+
+Sections horizontalization normalizes every disclosure section into a **topic ร period** grid. Different titles across years and industries all resolve to the same canonical topic:
+
+```
+ 2025Q4 2024Q4 2024Q3 2023Q4 โฆ
+companyOverview โ โ โ โ
+businessOverview โ โ โ โ
+productService โ โ โ โ
+salesOrder โ โ โ โ
+employee โ โ โ โ
+dividend โ โ โ โ
+audit โ โ โ โ
+โฆ (98 canonical topics)
+```
+
+```
+Before (raw section titles): After (canonical topic):
+Samsung "II. ์ฌ์
์ ๋ด์ฉ" โ businessOverview
+Hyundai "II. ์ฌ์
์ ๋ด์ฉ [์๋์ฐจ๋ถ๋ฌธ]" โ businessOverview
+Kakao "2. ์ฌ์
์ ๋ด์ฉ" โ businessOverview
+```
+
+The mapping pipeline: **text normalization** โ **545 hardcoded title mappings** โ **73 regex patterns** โ canonical topic. ~95%+ mapping rate across all listed companies. Each cell keeps the full text with heading/body separation, tables, and original evidence. Comparing "what did the company say about risk last year vs. this year" becomes a single `diff()` call.
+
+**2. Every company names the same number differently.**
+
+Account standardization normalizes every XBRL account through a 4-step pipeline:
+
+```
+Raw XBRL account_id
+ โ Strip prefixes (ifrs-full_, dart_, ifrs_, ifrs-smes_)
+ โ English ID synonyms (59 rules)
+ โ Korean name synonyms (104 rules)
+ โ Learned mapping table (34,249 entries)
+ โ Result: revenue, operatingIncome, totalAssets, โฆ
+```
+
+```
+Before (raw XBRL): After (standardized):
+Company account_id account_nm โ snakeId label
+Samsung ifrs-full_Revenue ์์ต(๋งค์ถ์ก) โ revenue ๋งค์ถ์ก
+SK Hynix dart_Revenue ๋งค์ถ์ก โ revenue ๋งค์ถ์ก
+LG Energy Revenue ๋งค์ถ โ revenue ๋งค์ถ์ก
+```
+
+~97% mapping rate. Cross-company comparison requires zero manual work. Combined with `scanAccount` / `scanRatio`, you can compare a single metric across **2,700+ companies** in one call.
+
+### Principles โ Accessibility and Reliability
+
+These two principles govern every public API:
+
+**Accessibility** โ One stock code is all you need. `import dartlab` provides access to every feature. No internal DTOs, no extra imports, no data setup. `Company("005930")` auto-downloads from [HuggingFace](https://huggingface.co/datasets/eddmpython/dartlab-data).
+
+**Reliability** โ Numbers are raw originals from DART/EDGAR. Missing data returns `None`, never a guess. `trace(topic)` shows which source was chosen and why. Errors are never swallowed.
+
+### Company โ The Merged Map
+
+`Company` uses `sections` as the spine, then overlays stronger data sources:
+
+```
+Layer What it provides Priority
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+docs Section text, tables, evidence Base spine
+finance BS, IS, CF, ratios, time series Replaces numeric topics
+report 28 structured APIs (DART only) Fills structured topics
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+profile Merged view (default for users) Highest
+```
+
+```python
+c.docs.sections # pure text source (sections spine)
+c.finance.BS # authoritative financial statements
+c.report.extract() # structured DART API data
+c.profile.sections # merged view โ what users see by default
+```
+
+`c.sections` is the merged view. `c.trace("BS")` tells you which source was chosen and why.
+
+### Architecture โ Layered by Responsibility
+
+DartLab follows a strict layered architecture where each layer only depends on layers below it:
+
+```
+L0 core/ Protocols, finance utils, docs utils, registry
+L1 providers/ Country-specific data (DART, EDGAR, EDINET)
+ gather/ External market data (Naver, Yahoo, FRED)
+ market/ Market-wide scanning (2,700+ companies)
+L2 analysis/ Analytical engines (valuation, risk, insights, event study)
+L3 ai/ LLM-powered analysis (9 providers)
+```
+
+Import direction is enforced by CI โ no reverse dependencies allowed.
+
+### Extensibility โ Zero Core Modification
+
+Adding a new country requires zero changes to core code:
+
+1. Create a provider package under `providers/`
+2. Implement `canHandle(code) -> bool` and `priority() -> int`
+3. Register via `entry_points` in `pyproject.toml`
+
+```python
+dartlab.Company("005930") # โ DART provider (priority 10)
+dartlab.Company("AAPL") # โ EDGAR provider (priority 20)
+```
+
+The facade iterates providers by priority โ first match wins. This follows the same pattern as OpenBB's provider system and scikit-learn's estimator registration.
+
+## Core Features
+
+### Show, Trace, Diff
+
+```python
+c = dartlab.Company("005930")
+
+# show โ open any topic with source-aware priority
+c.show("BS") # โ finance DataFrame
+c.show("overview") # โ sections-based text + tables
+c.show("dividend") # โ report DataFrame (all quarters)
+c.show("IS", period=["2024Q4", "2023Q4"]) # compare specific periods
+
+# trace โ why a topic came from docs, finance, or report
+c.trace("BS") # โ {"primarySource": "finance", ...}
+
+# diff โ text change detection (3 modes)
+c.diff() # full summary
+c.diff("businessOverview") # topic history
+c.diff("businessOverview", "2024", "2025") # line-by-line diff
+```
+
+What the output looks like:
+
+```
+>>> c.show("businessOverview")
+shape: (12, 5)
+โโโโโโโโโโโโโฌโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โ blockType โ nodeType โ 2024 โ 2023 โ
+โโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
+โ text โ heading โ 1. ์ฐ์
์ ํน์ฑ โ 1. ์ฐ์
์ ํน์ฑ โ
+โ text โ body โ ๋ฐ๋์ฒด ์ฐ์
์ ๊ธฐ์ ์ง์ฝ์ โฆ โ ๋ฐ๋์ฒด ์ฐ์
์ ๊ธฐ์ ์ง์ฝ์ โฆ โ
+โ table โ null โ DataFrame(5ร3) โ DataFrame(5ร3) โ
+โโโโโโโโโโโโโดโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+>>> c.diff("businessOverview", "2023", "2024")
+โโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โ status โ text โ
+โโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
+โ added โ AI ๋ฐ๋์ฒด ์์ ๊ธ์ฆ์ ๋ฐ๋ฅธ HBM ๋งค์ถ ํ๋ โฆ โ
+โ modified โ ๋งค์ถ์ก 258.9์กฐ์ โ 300.9์กฐ์ โ
+โ removed โ ๋ฐ๋์ฒด ๋ถ๋ฌธ ์์ต์ฑ ์
ํ ์ฐ๋ ค โฆ โ
+โโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+```
+
+### Finance
+
+```python
+c.BS # balance sheet (account ร period, newest first)
+c.IS # income statement
+c.CF # cash flow
+c.ratios # ratio time series DataFrame (6 categories ร period)
+c.finance.ratioSeries # ratio time series across years
+c.finance.timeseries # raw account time series
+c.annual # annual time series
+c.filings() # disclosure document list (Tier 1 Stable)
+```
+
+All accounts are normalized through the 4-step standardization pipeline โ Samsung's `revenue` and LG's `revenue` are the same `snakeId`. Ratios cover 6 categories: profitability, stability, growth, efficiency, cashflow, and valuation.
+
+### Market-wide Financial Screening
+
+Scan a single account or ratio across **all listed companies** in one call โ 2,700+ DART firms or 500+ EDGAR firms. Returns a wide Polars DataFrame (rows = companies, columns = periods, newest first).
+
+```python
+import dartlab
+
+# scan a single account across all listed companies
+dartlab.scanAccount("๋งค์ถ์ก") # revenue, quarterly standalone
+dartlab.scanAccount("operating_profit", annual=True) # annual basis
+dartlab.scanAccount("total_assets", market="edgar") # US EDGAR
+
+# scan a ratio across all listed companies
+dartlab.scanRatio("roe") # quarterly ROE for all firms
+dartlab.scanRatio("debtRatio", annual=True) # annual debt-to-equity
+
+# list available ratios (13 ratios: profitability, stability, growth, efficiency, cashflow)
+dartlab.scanRatioList()
+```
+
+Accepts both Korean names (`๋งค์ถ์ก`) and English snakeIds (`sales`) โ same 4-step normalization as Company finance. Reads 2,700+ parquet files in parallel via ThreadPool, typically completes in ~3 seconds.
+
+> **Requires pre-downloaded data.** Market-wide functions (`scanAccount`, `screen`, `digest`, etc.) operate on local data โ individual `Company()` calls only download one firm at a time. Download all data first:
+> ```python
+> pip install dartlab[hf]
+> dartlab.downloadAll("finance") # ~600 MB, 2,700+ firms
+> dartlab.downloadAll("report") # ~320 MB (governance/workforce/capital/debt)
+> dartlab.downloadAll("docs") # ~8 GB (digest/signal โ large)
+> ```
+
+## Review โ Structured Company Analysis
+
+> **Experimental** โ the review system is under active development. Templates, blocks, and output formats may change between versions.
+
+DartLab's review system assembles financial data into structured, readable reports.
+
+### Templates
+
+Pre-built block combinations that cover key analysis areas:
+
+```python
+c = dartlab.Company("005930")
+
+c.review("์์ต๊ตฌ์กฐ") # revenue structure โ segments, growth, concentration
+c.review("์๊ธ์กฐ๋ฌ") # capital structure โ debt, liquidity, interest burden
+c.review() # all templates
+```
+
+### Block Assembly
+
+Every review is built from reusable blocks. Get the full block dictionary and assemble your own:
+
+```python
+from dartlab.review import blocks, Review
+
+b = blocks(c) # dict of 16 pre-built blocks
+list(b.keys()) # โ ["profile", "segmentComposition", "growth", ...]
+
+# pick what you need
+Review([
+ b["segmentComposition"],
+ b["growth"],
+ c.select("IS", ["๋งค์ถ์ก"]), # mix with raw data
+])
+```
+
+### Reviewer โ AI Layer
+
+Add LLM-powered opinions on top of data blocks. Works with any provider:
+
+```python
+c.reviewer() # all sections + AI opinion
+c.reviewer("์์ต๊ตฌ์กฐ") # single section + AI
+c.reviewer(guide="Evaluate from semiconductor cycle perspective") # custom guide
+```
+
+**Free AI providers** โ no paid API key required:
+
+| Provider | Setup |
+|----------|-------|
+| Gemini | `dartlab setup gemini` |
+| Groq | `dartlab setup groq` |
+| Cerebras | `dartlab setup cerebras` |
+| Mistral | `dartlab setup mistral` |
+
+Or use any OpenAI-compatible endpoint:
+```bash
+dartlab setup custom --base-url http://localhost:11434/v1 # Ollama local
+```
+
+### Customization
+
+- **Templates**: Pre-defined block combinations (`์์ต๊ตฌ์กฐ`, `์๊ธ์กฐ๋ฌ`)
+- **Free assembly**: Mix any blocks + raw DataFrames in `Review([...])`
+- **Guide**: Pass `guide="..."` to `c.reviewer()` for domain-specific AI analysis
+- **Layout**: `ReviewLayout(indentH1=2, gapAfterH1=1, ...)` for rendering control
+- **Render formats**: `review.render("rich" | "html" | "markdown" | "json")`
+
+See [notebooks/marimo/sampleReview.py](notebooks/marimo/sampleReview.py) for interactive examples.
+
+## Additional Features
+
+> Features below are **beta** or **experimental** โ APIs may change. See [stability](docs/stability.md).
+
+### Insights (beta)
+
+> **Beta** โ API may change after a warning. See [stability](docs/stability.md).
+
+```python
+c.insights # 10-area analysis
+c.insights.grades() # โ {"performance": "A", "profitability": "B", โฆ}
+c.insights.performance.grade # โ "A"
+c.insights.performance.details # โ ["Revenue growth +8.3%", โฆ]
+c.insights.anomalies # โ outliers and red flags
+
+# distress scorecard โ 6-model bankruptcy/fraud prediction
+c.insights.distress # Altman Z-Score, Beneish M-Score, Ohlson O-Score,
+ # Merton Distance-to-Default, Piotroski F-Score, Sloan Ratio
+```
+
+### Valuation, Forecast & Simulation
+
+```python
+dartlab.valuation("005930") # DCF + DDM + relative valuation
+dartlab.forecast("005930") # revenue forecast (4-source ensemble)
+dartlab.simulation("005930") # scenario simulation (macro presets)
+
+# also available as Company methods
+c.valuation()
+c.forecast(horizon=3)
+c.simulation(scenarios=["adverse", "rate_hike"])
+```
+
+Auto-detects currency โ KRW for DART companies, USD for EDGAR. Works with both `dartlab.valuation("AAPL")` and `dartlab.valuation("005930")`.
+
+### Audit (beta)
+
+> **Beta** โ API may change after a warning. See [stability](docs/stability.md).
+
+```python
+dartlab.audit("005930") # 11 red flag detectors
+
+# Benford's Law (digit distribution), auditor change (PCAOB AS 3101),
+# going concern (ISA 570), internal control (SOX 302/404),
+# revenue quality (Dechow & Dichev), Merton default probability, ...
+```
+
+### Market Intelligence (beta)
+
+> **Beta** โ API may change after a warning. See [stability](docs/stability.md).
+
+```python
+dartlab.digest() # market-wide disclosure change digest
+dartlab.digest(sector="๋ฐ๋์ฒด") # sector filter
+dartlab.groupHealth() # group health: network ร financial ratios
+```
+
+### Modules
+
+DartLab exposes 100+ modules across 6 categories:
+
+```bash
+dartlab modules # list all modules
+dartlab modules --category finance # filter by category
+dartlab modules --search dividend # search by keyword
+```
+
+```python
+c.topics # list all available topics for this company
+```
+
+Categories: `finance` (statements, ratios), `report` (dividend, governance, audit), `notes` (K-IFRS annotations), `disclosure` (narrative text), `analysis` (insights, rankings), `raw` (original parquets).
+
+### Charts & Visualization (beta)
+
+> **Beta** โ API may change after a warning. See [stability](docs/stability.md).
+
+```python
+c = dartlab.Company("005930")
+
+# one-liner Plotly charts
+dartlab.chart.revenue(c).show() # revenue + operating margin combo
+dartlab.chart.cashflow(c).show() # operating/investing/financing CF
+dartlab.chart.dividend(c).show() # DPS + yield + payout ratio
+dartlab.chart.profitability(c).show() # ROE, operating margin, net margin
+
+# auto-detect all available charts
+specs = dartlab.chart.auto_chart(c)
+dartlab.chart.chart_from_spec(specs[0]).show()
+
+# generic charts from any DataFrame
+dartlab.chart.line(c.dividend, y=["dps"])
+dartlab.chart.bar(df, x="year", y=["revenue", "operating_income"], stacked=True)
+```
+
+Data tools:
+
+```python
+dartlab.table.yoy_change(c.dividend, value_cols=["dps"]) # add YoY% columns
+dartlab.table.format_korean(c.BS, unit="๋ฐฑ๋ง์") # 1.2์กฐ์, 350์ต์
+dartlab.table.summary_stats(c.dividend, value_cols=["dps"]) # mean/CAGR/trend
+dartlab.text.extract_keywords(narrative) # frequency-based keywords
+dartlab.text.sentiment_indicators(narrative) # positive/negative/risk
+```
+
+Install chart dependencies: `uv add "dartlab[charts]"`
+
+### Network โ Affiliate Map (beta)
+
+> **Beta** โ API may change after a warning. See [stability](docs/stability.md).
+
+```python
+c = dartlab.Company("005930")
+
+# interactive vis.js graph in browser
+c.network().show() # ego view (1 hop)
+c.network(hops=2).show() # 2-hop neighborhood
+
+# DataFrame views
+c.network("members") # group affiliates
+c.network("edges") # investment/shareholder connections
+c.network("cycles") # circular ownership paths
+
+# full market network
+dartlab.network().show()
+```
+
+### Market Scan (beta)
+
+> **Beta** โ API may change after a warning. See [stability](docs/stability.md).
+
+```python
+c = dartlab.Company("005930")
+
+# one company โ market-wide
+c.governance() # single company
+c.governance("all") # full market DataFrame
+dartlab.governance() # module-level scan
+dartlab.workforce()
+dartlab.capital()
+dartlab.debt()
+
+# screening & benchmarking
+dartlab.screen() # multi-factor screening
+dartlab.benchmark() # peer comparison
+dartlab.signal() # change detection signals
+```
+
+### Market Data Collection (beta)
+
+> **Beta** โ API may change after a warning. See [stability](docs/stability.md).
+
+The Gather engine collects external market data as **Polars DataFrames** โ timeseries by default. Every request goes through automatic fallback chains, circuit breaker isolation, and TTL caching. All methods are synchronous โ async parallel execution is handled internally.
+
+```python
+import dartlab
+
+# OHLCV timeseries โ adjusted prices, 6000+ trading days in a single request
+dartlab.price("005930") # KR: 1-year default, Polars DataFrame
+dartlab.price("005930", start="2015-01-01") # custom range
+dartlab.price("AAPL", market="US") # US via Yahoo Finance chart API
+dartlab.price("005930", snapshot=True) # opt-in: current price snapshot
+
+# supply/demand flow timeseries (KR only)
+dartlab.flow("005930") # DataFrame (date, foreignNet, institutionNet, ...)
+
+# macro indicators โ full wide DataFrame
+dartlab.macro() # KR 12 indicators (CPI, rates, FX, production, ...)
+dartlab.macro("US") # US 25 indicators (GDP, CPI, Fed Funds, S&P500, ...)
+dartlab.macro("CPI") # single indicator (auto-detects KR)
+dartlab.macro("FEDFUNDS") # single indicator (auto-detects US)
+
+# consensus, news
+dartlab.consensus("005930") # target price & analyst opinion
+dartlab.news("์ผ์ฑ์ ์") # Google News RSS โ DataFrame
+```
+
+**How data is collected โ don't worry, it's safe:**
+
+| Source | Data | Method |
+|--------|------|--------|
+| Naver Chart API | KR OHLCV (adjusted prices) | `fchart.stock.naver.com` โ 1 request per stock, max 6000 days |
+| Yahoo Finance v8 | US/Global OHLCV | `query2.finance.yahoo.com/v8/finance/chart` โ public chart API |
+| ECOS (Bank of Korea) | KR macro indicators | Official API with user's own key |
+| FRED (St. Louis Fed) | US macro indicators | Official API with user's own key |
+| Naver Mobile API | Consensus, flow, sector PER | `m.stock.naver.com/api` โ JSON endpoints |
+| FMP | Fallback for US history | Financial Modeling Prep API (optional) |
+
+**Safety infrastructure:**
+
+- **Rate limiting** โ per-domain RPM caps (Naver 30, ECOS 30, FRED 120) with async queue
+- **Circuit breaker** โ 3 consecutive failures โ source disabled for 60s, half-open retry
+- **Fallback chains** โ KR: naver โ yahoo_direct โ yahoo / US: yahoo_direct โ fmp โ yahoo
+- **Stale-while-revalidate** โ returns cached data on failure, warns via `log.warning`
+- **User-Agent rotation** โ randomized per request to avoid fingerprinting
+- **No silent failures** โ all API errors logged at warning level, never swallowed
+- **No scraping** โ all sources are public APIs or official data endpoints
+
+### Cross-Border Analysis (beta)
+
+> **Beta** โ API may change after a warning. See [stability](docs/stability.md).
+
+```python
+c = dartlab.Company("005930")
+
+# keyword frequency across disclosure periods
+c.keywordTrend(keyword="AI") # topic ร period ร keyword count
+c.keywordTrend() # all 54 built-in keywords
+
+# news headlines
+c.news() # recent 30 days
+dartlab.news("AAPL", market="US") # US company news
+
+# global peer mapping (WICS โ GICS sector)
+dartlab.crossBorderPeers("005930") # โ ["AAPL", "MSFT", "NVDA", "TSM", "AVGO"]
+
+# currency conversion (FRED-based)
+from dartlab.engines.common.finance import getExchangeRate, convertValue
+getExchangeRate("KRW") # KRW/USD rate
+convertValue(1_000_000, "KRW", "USD") # โ ~730.0
+
+# audit opinion normalization (KR/EN/JP โ canonical code)
+from dartlab.engines.common.audit import normalizeAuditOpinion
+normalizeAuditOpinion("์ ์ ") # โ "unqualified"
+normalizeAuditOpinion("Qualified") # โ "qualified"
+```
+
+Disclosure gap detection runs automatically inside `c.insights` โ flags mismatches between text changes and financial health (e.g. risk text surges while financials are stable).
+
+### Export (experimental)
+
+> **Experimental** โ Breaking changes possible. Not for production.
+
+```bash
+dartlab excel "005930" -o samsung.xlsx
+```
+
+Install: `uv add "dartlab[ai]"` (Excel export is included in the AI extras).
+
+### Plugins
+
+```python
+dartlab.plugins() # list loaded plugins
+dartlab.reload_plugins() # rescan after installing a plugin
+```
+
+Plugins can extend DartLab with custom data sources, tools, or analysis engines. See `dartlab plugin create --help` for scaffolding.
+
+## EDGAR (US)
+
+Same `Company` interface, same account standardization pipeline, different data source. EDGAR data is auto-fetched from the SEC API โ no pre-download needed:
+
+```python
+us = dartlab.Company("AAPL")
+
+us.sections # 10-K/10-Q sections with heading/body
+us.show("business") # business description
+us.show("10-K::item1ARiskFactors") # risk factors
+us.BS # SEC XBRL balance sheet
+us.ratios # same 47 ratios
+us.diff("10-K::item7Mdna") # MD&A text changes
+us.insights # 10-area grades (A~F)
+
+# analyst functions โ auto-detect USD
+dartlab.valuation("AAPL") # DCF + DDM + relative (USD)
+dartlab.forecast("AAPL") # revenue forecast (USD)
+dartlab.simulation("AAPL") # scenario simulation (US macro presets)
+```
+
+The interface is identical โ same methods, same structure:
+
+```python
+# Korea (DART) # US (EDGAR)
+c = dartlab.Company("005930") c = dartlab.Company("AAPL")
+c.sections c.sections
+c.show("businessOverview") c.show("business")
+c.BS c.BS
+c.ratios c.ratios
+c.diff("businessOverview") c.diff("10-K::item7Mdna")
+c.insights.grades() c.insights.grades()
+```
+
+### DART vs EDGAR Namespaces
+
+| | DART | EDGAR |
+|---------------|:--------------:|:--------------:|
+| `docs` | โ | โ |
+| `finance` | โ | โ |
+| `report` | โ (28 API types) | โ (not applicable) |
+| `profile` | โ | โ |
+
+DART has a `report` namespace with 28 structured disclosure APIs (dividend, governance, executive compensation, etc.). This does not exist in EDGAR โ SEC filings are structured differently.
+
+**EDGAR topic naming**: Topics use `{formType}::{itemId}` format. Short aliases also work:
+
+```python
+us.show("10-K::item1Business") # full form
+us.show("business") # short alias
+us.show("risk") # โ 10-K::item1ARiskFactors
+us.show("mdna") # โ 10-K::item7Mdna
+```
+
+## AI Analysis
+
+> **Experimental** โ the AI analysis layer and `analysis/` engines are under active development. APIs, output formats, and available tools may change between versions.
+
+> **Tip:** New to financial analysis or prefer natural language? Use `dartlab.ask()` โ the AI assistant handles everything from data download to analysis. No coding knowledge required.
+
+DartLab includes a built-in AI analysis layer that feeds structured company data to LLMs. **No code required** โ you can ask questions in plain language and DartLab handles everything: data selection, context assembly, and streaming the answer.
+
+```bash
+# terminal one-liner โ no Python needed
+dartlab ask "์ผ์ฑ์ ์ ์ฌ๋ฌด๊ฑด์ ์ฑ ๋ถ์ํด์ค"
+```
+
+DartLab structures the data, selects relevant context (financials, insights, sector benchmarks), and lets the LLM explain:
+
+```
+$ dartlab ask "์ผ์ฑ์ ์ ์ฌ๋ฌด๊ฑด์ ์ฑ ๋ถ์ํด์ค"
+
+์ผ์ฑ์ ์์ ์ฌ๋ฌด๊ฑด์ ์ฑ์ A๋ฑ๊ธ์
๋๋ค.
+
+โธ ๋ถ์ฑ๋น์จ 31.8% โ ์
์ข
ํ๊ท (45.2%) ๋๋น ์ํธ
+โธ ์ ๋๋น์จ 258.6% โ 200% ์์ ๊ธฐ์ค ์ํ
+โธ ์ด์๋ณด์๋ฐฐ์ 22.1๋ฐฐ โ ์ด์ ๋ถ๋ด ๋งค์ฐ ๋ฎ์
+โธ ROE ํ๋ณต์ธ: 1.6% โ 10.2% (4๋ถ๊ธฐ ์ฐ์ ๊ฐ์ )
+
+[๋ฐ์ดํฐ ์ถ์ฒ: 2024Q4 ์ฌ์
๋ณด๊ณ ์, dartlab insights ์์ง]
+```
+
+For real-time market-wide disclosure questions (e.g. "์ต๊ทผ 7์ผ ์์ฃผ๊ณต์ ์๋ ค์ค"), the AI uses your `OpenDART API key` to search recent filings directly. Store the key in project `.env` or via UI Settings.
+
+The 2-tier architecture means basic analysis works with any provider, while tool-calling providers (OpenAI, Claude) can go deeper by requesting additional data mid-conversation.
+
+### Python API
+
+```python
+import dartlab
+
+# streams to stdout, returns full text
+answer = dartlab.ask("์ผ์ฑ์ ์ ์ฌ๋ฌด๊ฑด์ ์ฑ ๋ถ์ํด์ค")
+
+# provider + model override
+answer = dartlab.ask("์ผ์ฑ์ ์ ๋ถ์", provider="openai", model="gpt-4o")
+
+# data filtering
+answer = dartlab.ask("์ผ์ฑ์ ์ ํต์ฌ ํฌ์ธํธ", include=["BS", "IS"])
+
+# analysis pattern (framework-guided)
+answer = dartlab.ask("์ผ์ฑ์ ์ ๋ถ์", pattern="financial")
+
+# agent mode โ LLM selects tools for deeper analysis
+answer = dartlab.chat("005930", "๋ฐฐ๋น ์ถ์ธ๋ฅผ ๋ถ์ํ๊ณ ์ด์ ์งํ๋ฅผ ์ฐพ์์ค")
+```
+
+### CLI
+
+```bash
+# provider setup โ free providers first
+dartlab setup # list all providers
+dartlab setup gemini # Google Gemini (free)
+dartlab setup groq # Groq (free)
+
+# status
+dartlab status # all providers (table view)
+dartlab status --cost # cumulative token/cost stats
+
+# ask questions (streaming by default)
+dartlab ask "์ผ์ฑ์ ์ ์ฌ๋ฌด๊ฑด์ ์ฑ ๋ถ์ํด์ค"
+dartlab ask "AAPL risk analysis" -p ollama
+dartlab ask --continue "๋ฐฐ๋น ์ถ์ธ๋?"
+
+# auto-generate report
+dartlab report "์ผ์ฑ์ ์" -o report.md
+
+# web UI
+dartlab # open browser UI
+dartlab --help # show all commands
+```
+
+
+All CLI commands (16)
+
+| Category | Command | Description |
+|----------|---------|-------------|
+| Data | `show` | Open any topic by name |
+| Data | `search` | Find companies by name or code |
+| Data | `statement` | BS / IS / CF / SCE output |
+| Data | `sections` | Raw docs sections |
+| Data | `profile` | Company index and facts |
+| Data | `modules` | List all available modules |
+| AI | `ask` | Natural language question |
+| AI | `report` | Auto-generate analysis report |
+| Export | `excel` | Export to Excel (experimental) |
+| Collect | `collect` | Download / refresh / batch collect |
+| Collect | `collect --check` | Check freshness (new filings) |
+| Collect | `collect --incremental` | Incremental collect (missing only) |
+| Server | `ai` | Launch web UI (localhost:8400) |
+| Server | `share` | Tunnel sharing (ngrok / cloudflared) |
+| Server | `status` | Provider connection status |
+| Server | `setup` | Provider setup wizard |
+| MCP | `mcp` | Start MCP stdio server |
+| Plugin | `plugin` | Create / list plugins |
+
+
+
+### Providers
+
+**Free API key providers** โ sign up, paste the key, start analyzing:
+
+| Provider | Free Tier | Model | Setup |
+|----------|-----------|-------|-------|
+| `gemini` | Gemini 2.5 Pro/Flash free | Gemini 2.5 | `dartlab setup gemini` |
+| `groq` | 6Kโ30K TPM free | LLaMA 3.3 70B | `dartlab setup groq` |
+| `cerebras` | 1M tokens/day permanent | LLaMA 3.3 70B | `dartlab setup cerebras` |
+| `mistral` | 1B tokens/month free | Mistral Small | `dartlab setup mistral` |
+
+**Other providers:**
+
+| Provider | Auth | Cost | Tool Calling |
+|----------|------|------|:---:|
+| `oauth-codex` | ChatGPT subscription (Plus/Team/Enterprise) | Included in subscription | Yes |
+| `openai` | API key (`OPENAI_API_KEY`) | Pay-per-token | Yes |
+| `ollama` | Local install, no account needed | Free | Depends on model |
+| `codex` | Codex CLI installed locally | Free (uses your Codex session) | Yes |
+| `custom` | Any OpenAI-compatible endpoint | Varies | Varies |
+
+**Auto-fallback:** Set multiple free API keys and DartLab automatically switches to the next provider when one hits its rate limit. Use `provider="free"` to enable the fallback chain:
+
+```python
+dartlab.ask("์ผ์ฑ์ ์ ๋ถ์", provider="free")
+```
+
+**Why no Claude provider?** Anthropic does not offer OAuth-based access. Without OAuth, there is no way to let users authenticate with their existing subscription โ we would have to ask users to paste API keys, which goes against DartLab's frictionless design. If Anthropic adds OAuth support in the future, we will add a Claude provider. For now, Claude works through **MCP** (see below) โ Claude Desktop, Claude Code, and Cursor can call DartLab's 60 tools directly.
+
+**`oauth-codex`** is the recommended provider โ if you have a ChatGPT subscription, it works out of the box with no API keys. Run `dartlab setup oauth-codex` to authenticate.
+
+**Web UI (`dartlab`)** launches a browser-based chat interface for interactive analysis. This feature is currently **experimental** โ we are evaluating the right scope and UX for visualization and collaborative features.
+
+Install AI dependencies: `uv add "dartlab[ai]"`
+
+### Project Settings (`.dartlab.yml`)
+
+```yaml
+company: 005930 # default company
+provider: openai # default LLM provider
+model: gpt-4o # default model
+verbose: false
+```
+
+## MCP โ AI Assistant Integration
+
+DartLab includes a built-in [MCP](https://modelcontextprotocol.io/) server that exposes 60 tools (16 global + 44 per-company) to Claude Desktop, Claude Code, Cursor, and any MCP-compatible client.
+
+```bash
+uv add "dartlab[mcp]"
+```
+
+### Claude Desktop
+
+Add to `claude_desktop_config.json`:
+
+```json
+{
+ "mcpServers": {
+ "dartlab": {
+ "command": "uv",
+ "args": ["run", "dartlab", "mcp"]
+ }
+ }
+}
+```
+
+### Claude Code
+
+```bash
+claude mcp add dartlab -- uv run dartlab mcp
+```
+
+Or add to `~/.claude/settings.json`:
+
+```json
+{
+ "mcpServers": {
+ "dartlab": {
+ "command": "uv",
+ "args": ["run", "dartlab", "mcp"]
+ }
+ }
+}
+```
+
+### Cursor
+
+Add to `.cursor/mcp.json` with the same config format as Claude Desktop.
+
+### What's Available
+
+Once connected, your AI assistant can:
+
+- **Search** โ find companies by name or code (`search_company`)
+- **Show** โ read any disclosure topic (`show_topic`, `list_topics`, `diff_topic`)
+- **Finance** โ balance sheet, income statement, cash flow, ratios (`get_financial_statements`, `get_ratios`)
+- **Analysis** โ insights, sector ranking, valuation (`get_insight`, `get_ranking`)
+- **EDGAR** โ same tools work for US companies (`stock_code: "AAPL"`)
+
+Auto-generate config for your platform:
+
+```bash
+dartlab mcp --config claude-desktop
+dartlab mcp --config claude-code
+dartlab mcp --config cursor
+```
+
+## OpenAPI โ Raw Public APIs
+
+Use source-native wrappers when you want raw disclosure APIs directly.
+
+### OpenDart (Korea)
+
+> **Note:** `Company` does **not** require an API key โ it uses pre-built datasets.
+> `OpenDart` uses the raw DART API and requires a key from [opendart.fss.or.kr](https://opendart.fss.or.kr) (free).
+> Recent filing-list AI questions across the whole market also use this key. In the UI, open Settings and manage `OpenDART API key` there.
+
+```python
+from dartlab import OpenDart
+
+d = OpenDart()
+d.search("์นด์นด์ค", listed=True)
+d.filings("์ผ์ฑ์ ์", "2024")
+d.finstate("์ผ์ฑ์ ์", 2024)
+d.report("์ผ์ฑ์ ์", "๋ฐฐ๋น", 2024)
+```
+
+### OpenEdgar (US)
+
+> **No API key required.** SEC EDGAR is a public API โ no registration needed.
+
+```python
+from dartlab import OpenEdgar
+
+e = OpenEdgar()
+e.search("Apple")
+e.filings("AAPL", forms=["10-K", "10-Q"])
+e.companyFactsJson("AAPL")
+```
+
+## Data
+
+**No manual setup required.** When you create a `Company`, dartlab automatically downloads the required data.
+
+| Dataset | Coverage | Size | Source |
+|---------|----------|------|--------|
+| DART docs | 2,500+ companies | ~8 GB | [HuggingFace](https://huggingface.co/datasets/eddmpython/dartlab-data/tree/main/dart/docs) |
+| DART finance | 2,700+ companies | ~600 MB | [HuggingFace](https://huggingface.co/datasets/eddmpython/dartlab-data/tree/main/dart/finance) |
+| DART report | 2,700+ companies | ~320 MB | [HuggingFace](https://huggingface.co/datasets/eddmpython/dartlab-data/tree/main/dart/report) |
+| EDGAR | On-demand | โ | SEC API (auto-fetched) |
+
+### 3-Step Data Pipeline
+
+```
+dartlab.Company("005930")
+ โ
+ โโ 1. Local cache โโโโ already have it? done (instant)
+ โ
+ โโ 2. HuggingFace โโโโ auto-download (~seconds, no key needed)
+ โ
+ โโ 3. DART API โโโโโโโโ collect with your API key (needs key)
+```
+
+If a company is not in HuggingFace, dartlab collects data directly from DART โ this requires an API key:
+
+```bash
+dartlab setup dart-key
+```
+
+### Freshness โ Automatic Update Detection
+
+DartLab uses a 3-layer freshness system to keep your local data current:
+
+| Layer | Method | Cost |
+|-------|--------|------|
+| L1 | HTTP HEAD โ ETag comparison with HuggingFace | ~0.5s, few hundred bytes |
+| L2 | Local file age (90-day TTL fallback) | instant (local) |
+| L3 | DART API โ `rcept_no` diff (requires API key) | 1 API call, ~1s |
+
+When you open a `Company`, dartlab checks if newer data exists. If a new disclosure was filed:
+
+```python
+c = dartlab.Company("005930")
+# [dartlab] โ 005930 โ ์ ๊ณต์ 2๊ฑด ๋ฐ๊ฒฌ (์ฌ์
๋ณด๊ณ ์ (2024.12))
+# โข ์ฆ๋ถ ์์ง: dartlab collect --incremental 005930
+# โข ๋๋ Python: c.update()
+
+c.update() # incremental collect โ only missing filings
+```
+
+```bash
+# CLI freshness check
+dartlab collect --check 005930 # single company
+dartlab collect --check # scan all local companies (7 days)
+
+# incremental collect โ only missing filings
+dartlab collect --incremental 005930 # single company
+dartlab collect --incremental # all local companies with new filings
+```
+
+### Batch Collection (DART API)
+
+```bash
+dartlab collect --batch # all listed, missing only
+dartlab collect --batch -c finance 005930 # specific category + company
+dartlab collect --batch --mode all # re-collect everything
+```
+
+## Try It Now
+
+### Live Demo (No Install)
+
+Try DartLab instantly โ no Python, no terminal, no setup:
+
+**[โ Open Live Demo](https://huggingface.co/spaces/eddmpython/dartlab)** โ enter a stock code, see financials immediately
+
+Or open a [Colab notebook](https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/showcase/01_quickstart.ipynb) in your browser.
+
+### Marimo Notebooks
+
+> Data is automatically downloaded on first use. No setup required unless collecting new companies directly from DART.
+
+```bash
+uv add dartlab marimo
+marimo edit notebooks/marimo/dartCompany.py # Korean company (DART)
+marimo edit notebooks/marimo/edgarCompany.py # US company (EDGAR)
+marimo edit notebooks/marimo/aiAnalysis.py # AI analysis examples
+```
+
+### Colab Notebooks
+
+**Showcase** (English โ global audience):
+
+| Notebook | Topic |
+|---|---|
+| [](https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/showcase/01_quickstart.ipynb) | **Quick Start** โ analyze any company in 3 lines |
+| [](https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/showcase/02_financial_analysis.ipynb) | **Financial Analysis** โ statements, time series, ratios |
+| [](https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/showcase/03_kr_us_compare.ipynb) | **Korea vs US** โ Samsung vs Apple side-by-side |
+| [](https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/showcase/04_risk_diff.ipynb) | **Risk Diff** โ track disclosure changes (Bloomberg can't) |
+| [](https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/showcase/05_sector_screening.ipynb) | **Sector Screening** โ 8 presets, sector benchmarks |
+| [](https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/showcase/06_insight_anomaly.ipynb) | **Insight & Anomaly** โ 10-area grading, 6 anomaly rules |
+| [](https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/showcase/07_network_governance.ipynb) | **Network & Governance** โ corporate relationship graph |
+| [](https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/showcase/08_signal_trend.ipynb) | **Signal Trends** โ 48-keyword disclosure monitoring |
+| [](https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/showcase/09_ai_analysis.ipynb) | **AI Analysis** โ `dartlab.ask()` with 9 LLM providers |
+| [](https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/showcase/10_disclosure_deep_dive.ipynb) | **Disclosure Deep Dive** โ sections architecture |
+
+
+ํ๊ตญ์ด Tutorials
+
+| Notebook | Topic |
+|---|---|
+| [](https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/getting-started/quickstart.ipynb) | **๋น ๋ฅธ ์์** โ sections, show, trace, diff |
+| [](https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/tutorials/02_financial_statements.ipynb) | **์ฌ๋ฌด์ ํ** โ BS, IS, CF |
+| [](https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/tutorials/04_ratios.ipynb) | **์ฌ๋ฌด๋น์จ** โ 47๊ฐ ๋น์จ |
+| [](https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/tutorials/06_disclosure.ipynb) | **๊ณต์ ํ
์คํธ** โ sections ํ์ฑ |
+| [](https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/tutorials/09_edgar.ipynb) | **EDGAR** โ ๋ฏธ๊ตญ SEC |
+
+
+
+## Documentation
+
+- Docs: https://eddmpython.github.io/dartlab/
+- Sections guide: https://eddmpython.github.io/dartlab/docs/getting-started/sections
+- Quick start: https://eddmpython.github.io/dartlab/docs/getting-started/quickstart
+- API overview: https://eddmpython.github.io/dartlab/docs/api/overview
+- Beginner guide (Korean): https://eddmpython.github.io/dartlab/blog/dartlab-easy-start/
+
+### Blog
+
+The [DartLab Blog](https://eddmpython.github.io/dartlab/blog/) covers practical disclosure analysis โ how to read reports, interpret patterns, and spot risk signals. 120+ articles across three categories:
+
+- **Disclosure Systems** โ structure and mechanics of DART/EDGAR filings
+- **Report Reading** โ practical guide to audit reports, preliminary earnings, restatements
+- **Financial Interpretation** โ financial statements, ratios, and disclosure signals
+
+## Stability
+
+| Tier | Scope |
+|------|-------|
+| **Stable** | DART Company (sections, show, trace, diff, BS/IS/CF, CIS, index, filings, profile), EDGAR Company core, valuation, forecast, simulation |
+| **Beta** | EDGAR power-user (SCE, notes, freq, coverage), insights, distress, ratios, timeseries, network, governance, workforce, capital, debt, chart/table/text tools, ask/chat, OpenDart, OpenEdgar, Server API, MCP, CLI subcommands |
+| **Experimental** | AI tool calling, export |
+| **Alpha** | Desktop App (Windows .exe) โ functional but incomplete, Sections Viewer โ not yet fully structured |
+
+See [docs/stability.md](docs/stability.md).
+
+## Contributing
+
+The project prefers **experiments before engine changes**. If you want to propose a parser or mapping change, validate it in `experiments/` first and bring the verified result back into the engine.
+
+- **Experiment folder**: `experiments/XXX_camelCaseName/` โ each file must be independently runnable with actual results in its docstring
+- **Data contributions** (e.g. `accountMappings.json`, `sectionMappings.json`): only accepted when backed by experiment evidence โ no manual bulk edits
+- Issues and PRs in Korean or English are both welcome
+
+## License
+
+MIT
diff --git a/app.py b/app.py
deleted file mode 100644
index be890b237f3ab7dcee7dca48626569c0c11a9b72..0000000000000000000000000000000000000000
--- a/app.py
+++ /dev/null
@@ -1,623 +0,0 @@
-"""DartLab Streamlit Demo โ AI ์ฑํ
๊ธฐ๋ฐ ๊ธฐ์
๋ถ์."""
-
-from __future__ import annotations
-
-import gc
-import io
-import os
-import re
-
-import pandas as pd
-import streamlit as st
-
-import dartlab
-
-# โโ ์ค์ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-_MAX_CACHE = 2
-_LOGO_URL = "https://raw.githubusercontent.com/eddmpython/dartlab/master/.github/assets/logo.png"
-_BLOG_URL = "https://eddmpython.github.io/dartlab/blog/dartlab-easy-start/"
-_DOCS_URL = "https://eddmpython.github.io/dartlab/docs/getting-started/quickstart"
-_COLAB_URL = "https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/showcase/01_quickstart.ipynb"
-_REPO_URL = "https://github.com/eddmpython/dartlab"
-
-_HAS_OPENAI = bool(os.environ.get("OPENAI_API_KEY"))
-
-if _HAS_OPENAI:
- dartlab.llm.configure(provider="openai", api_key=os.environ["OPENAI_API_KEY"])
-
-# โโ ํ์ด์ง ์ค์ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-st.set_page_config(
- page_title="DartLab โ AI ๊ธฐ์
๋ถ์",
- page_icon=None,
- layout="centered",
-)
-
-# โโ CSS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-st.markdown("""
-
-""", unsafe_allow_html=True)
-
-
-# โโ ์ ํธ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-
-def _toPandas(df):
- """Polars/pandas DataFrame -> pandas."""
- if df is None:
- return None
- if hasattr(df, "to_pandas"):
- return df.to_pandas()
- return df
-
-
-def _formatDf(df: pd.DataFrame) -> pd.DataFrame:
- """์ซ์๋ฅผ ์ฒ๋จ์ ์ฝค๋ง ๋ฌธ์์ด๋ก ๋ณํ (์์์ ์ ๊ฑฐ)."""
- if df is None or df.empty:
- return df
- result = df.copy()
- for col in result.columns:
- if pd.api.types.is_numeric_dtype(result[col]):
- result[col] = result[col].apply(
- lambda x: f"{int(x):,}" if pd.notna(x) and x == x else ""
- )
- return result
-
-
-def _toExcel(df: pd.DataFrame) -> bytes:
- """DataFrame -> Excel bytes."""
- buf = io.BytesIO()
- df.to_excel(buf, index=False, engine="openpyxl")
- return buf.getvalue()
-
-
-def _showDf(df: pd.DataFrame, key: str = "", downloadName: str = ""):
- """DataFrame ํ์ + Excel ๋ค์ด๋ก๋."""
- if df is None or df.empty:
- st.caption("๋ฐ์ดํฐ ์์")
- return
- st.dataframe(_formatDf(df), use_container_width=True, hide_index=True, key=key or None)
- if downloadName:
- st.download_button(
- label="Excel ๋ค์ด๋ก๋",
- data=_toExcel(df),
- file_name=f"{downloadName}.xlsx",
- mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- key=f"dl_{key}" if key else None,
- )
-
-
-@st.cache_resource(max_entries=_MAX_CACHE)
-def _getCompany(code: str):
- """์บ์๋ Company."""
- gc.collect()
- return dartlab.Company(code)
-
-
-# โโ ์ข
๋ชฉ์ฝ๋ ์ถ์ถ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-
-def _extractCode(message: str) -> str | None:
- """๋ฉ์์ง์์ ์ข
๋ชฉ์ฝ๋/ํ์ฌ๋ช
์ถ์ถ."""
- msg = message.strip()
-
- # 6์๋ฆฌ ์ซ์
- m = re.search(r"\b(\d{6})\b", msg)
- if m:
- return m.group(1)
-
- # ์๋ฌธ ํฐ์ปค (๋จ๋
๋๋ฌธ์ 1~5์)
- m = re.search(r"\b([A-Z]{1,5})\b", msg)
- if m:
- return m.group(1)
-
- # ํ๊ธ ํ์ฌ๋ช
โ dartlab.search
- cleaned = re.sub(
- r"(์\s*๋ํด|์\s*๋ํ|์๋ํด|์ข|์|๋ฅผ|์|์|๋|์ด|๊ฐ|๋|๋ง|๋ถํฐ|๊น์ง|ํ๊ณ |์ด๋|๋|๋ก|์ผ๋ก|์|๊ณผ|ํํ
|์์|์๊ฒ)\b",
- " ",
- msg,
- )
- # ๋ถํ์ํ ๋์ฌ/์กฐ๋์ฌ ์ ๊ฑฐ
- cleaned = re.sub(
- r"\b(์๋ ค์ค|๋ณด์ฌ์ค|๋ถ์|ํด์ค|ํด๋ด|์ด๋|๋ณด์|๋ณผ๋|์ค|ํด|์ข|์)\b",
- " ",
- cleaned,
- )
- tokens = re.findall(r"[๊ฐ-ํฃA-Za-z0-9]+", cleaned)
- # ๊ธด ํ ํฐ ์ฐ์ (ํ์ฌ๋ช
์ผ ๊ฐ๋ฅ์ฑ ๋์)
- tokens.sort(key=len, reverse=True)
- for token in tokens:
- if len(token) >= 2:
- try:
- results = dartlab.search(token)
- if results is not None and len(results) > 0:
- return str(results[0, "์ข
๋ชฉ์ฝ๋"])
- except Exception:
- continue
- return None
-
-
-def _detectTopic(message: str) -> str | None:
- """๋ฉ์์ง์์ ํน์ topic ํค์๋ ๊ฐ์ง."""
- topicMap = {
- "๋ฐฐ๋น": "dividend",
- "์ฃผ์ฃผ": "majorHolder",
- "๋์ฃผ์ฃผ": "majorHolder",
- "์ง์": "employee",
- "์์": "executive",
- "์์๋ณด์": "executivePay",
- "๋ณด์": "executivePay",
- "์ธ๊ทธ๋จผํธ": "segments",
- "๋ถ๋ฌธ": "segments",
- "์ฌ์
๋ถ": "segments",
- "์ ํ์์ฐ": "tangibleAsset",
- "๋ฌดํ์์ฐ": "intangibleAsset",
- "์์ฌ๋ฃ": "rawMaterial",
- "์์ฃผ": "salesOrder",
- "์ ํ": "productService",
- "์ํ์ฌ": "subsidiary",
- "์ข
์": "subsidiary",
- "๋ถ์ฑ": "contingentLiability",
- "์ฐ๋ฐ": "contingentLiability",
- "ํ์": "riskDerivative",
- "์ฌ์ฑ": "bond",
- "์ด์ฌํ": "boardOfDirectors",
- "๊ฐ์ฌ": "audit",
- "์๋ณธ๋ณ๋": "capitalChange",
- "์๊ธฐ์ฃผ์": "treasuryStock",
- "์ฌ์
๊ฐ์": "business",
- "์ฌ์
๋ณด๊ณ ": "business",
- "์ฐํ": "companyHistory",
- }
- msg = message.lower()
- for keyword, topic in topicMap.items():
- if keyword in msg:
- return topic
- return None
-
-
-# โโ AI โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-
-def _askAi(stockCode: str, question: str) -> str:
- """AI ์ง๋ฌธ. OpenAI ์ฐ์ , HF ๋ฌด๋ฃ fallback."""
- if _HAS_OPENAI:
- try:
- q = f"{stockCode} {question}" if stockCode else question
- answer = dartlab.ask(q, stream=False, raw=False)
- return answer or "์๋ต ์์"
- except Exception as e:
- return f"๋ถ์ ์คํจ: {e}"
-
- try:
- from huggingface_hub import InferenceClient
- token = os.environ.get("HF_TOKEN")
- client = InferenceClient(
- model="meta-llama/Llama-3.1-8B-Instruct",
- token=token if token else None,
- )
- context = _buildAiContext(stockCode)
- systemMsg = (
- "๋น์ ์ ํ๊ตญ ๊ธฐ์
์ฌ๋ฌด ๋ถ์ ์ ๋ฌธ๊ฐ์
๋๋ค. "
- "์๋ ์ฌ๋ฌด ๋ฐ์ดํฐ๋ฅผ ๋ฐํ์ผ๋ก ์ฌ์ฉ์์ ์ง๋ฌธ์ ํ๊ตญ์ด๋ก ๋ต๋ณํ์ธ์. "
- "์ซ์๋ ์ฒ๋จ์ ์ฝค๋ง๋ฅผ ์ฌ์ฉํ๊ณ , ๊ทผ๊ฑฐ๋ฅผ ๋ช
ํํ ์ ์ํ์ธ์.\n\n"
- f"{context}"
- )
- response = client.chat_completion(
- messages=[
- {"role": "system", "content": systemMsg},
- {"role": "user", "content": question},
- ],
- max_tokens=1024,
- )
- return response.choices[0].message.content or "์๋ต ์์"
- except Exception as e:
- return f"AI ๋ถ์ ์คํจ: {e}"
-
-
-def _buildAiContext(stockCode: str) -> str:
- """AI ์ปจํ
์คํธ ๊ตฌ์ฑ."""
- try:
- c = _getCompany(stockCode)
- except Exception:
- return f"์ข
๋ชฉ์ฝ๋: {stockCode}"
-
- parts = [f"๊ธฐ์
: {c.corpName} ({c.stockCode}), ์์ฅ: {c.market}"]
- for name, attr in [("์์ต๊ณ์ฐ์", "IS"), ("์ฌ๋ฌด์ํํ", "BS"), ("์ฌ๋ฌด๋น์จ", "ratios")]:
- try:
- df = _toPandas(getattr(c, attr, None))
- if df is not None and not df.empty:
- parts.append(f"\n[{name}]\n{df.head(15).to_string()}")
- except Exception:
- pass
- return "\n".join(parts)
-
-
-# โโ ๋์๋ณด๋ ๋ ๋๋ง โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-
-def _renderCompanyCard(c):
- """๊ธฐ์
์นด๋."""
- currency = ""
- if hasattr(c, "currency") and c.currency:
- currency = c.currency
- currencyHtml = (
- f"ํตํ"
- f"{currency}
"
- if currency else ""
- )
- st.markdown(f"""
-
- """, unsafe_allow_html=True)
-
-
-def _renderFullDashboard(c, code: str):
- """์ ์ฒด ์ฌ๋ฌด ๋์๋ณด๋."""
- _renderCompanyCard(c)
-
- # ์ฌ๋ฌด์ ํ
- st.markdown('์ฌ๋ฌด์ ํ
', unsafe_allow_html=True)
- for label, attr in [("IS (์์ต๊ณ์ฐ์)", "IS"), ("BS (์ฌ๋ฌด์ํํ)", "BS"),
- ("CF (ํ๊ธํ๋ฆํ)", "CF"), ("ratios (์ฌ๋ฌด๋น์จ)", "ratios")]:
- with st.expander(label, expanded=(attr == "IS")):
- try:
- df = _toPandas(getattr(c, attr, None))
- _showDf(df, key=f"dash_{attr}", downloadName=f"{code}_{attr}")
- except Exception:
- st.caption("๋ก๋ ์คํจ")
-
- # Sections
- topics = []
- try:
- topics = list(c.topics) if c.topics else []
- except Exception:
- pass
-
- if topics:
- st.markdown('๊ณต์ ๋ฐ์ดํฐ
', unsafe_allow_html=True)
- selectedTopic = st.selectbox("topic", topics, label_visibility="collapsed", key="dash_topic")
- if selectedTopic:
- try:
- result = c.show(selectedTopic)
- if result is not None:
- if hasattr(result, "to_pandas"):
- _showDf(_toPandas(result), key="dash_sec", downloadName=f"{code}_{selectedTopic}")
- else:
- st.markdown(str(result))
- except Exception as e:
- st.caption(f"์กฐํ ์คํจ: {e}")
-
-
-def _renderTopicData(c, code: str, topic: str):
- """ํน์ topic ๋ฐ์ดํฐ๋ง ๋ ๋๋ง."""
- try:
- result = c.show(topic)
- if result is not None:
- if hasattr(result, "to_pandas"):
- _showDf(_toPandas(result), key=f"topic_{topic}", downloadName=f"{code}_{topic}")
- else:
- st.markdown(str(result))
- else:
- st.caption(f"'{topic}' ๋ฐ์ดํฐ ์์")
- except Exception as e:
- st.caption(f"์กฐํ ์คํจ: {e}")
-
-
-# โโ ํ๋ฆฌ๋ก๋ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-@st.cache_resource
-def _warmup():
- """listing ์บ์."""
- try:
- dartlab.search("์ผ์ฑ์ ์")
- except Exception:
- pass
- return True
-
-_warmup()
-
-
-# โโ ํค๋ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-st.markdown(f"""
-
-
-""", unsafe_allow_html=True)
-
-
-# โโ ์ธ์
์ด๊ธฐํ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-if "messages" not in st.session_state:
- st.session_state.messages = []
-if "code" not in st.session_state:
- st.session_state.code = ""
-
-
-# โโ ๋์๋ณด๋ ์์ญ (์ข
๋ชฉ์ด ์์ผ๋ฉด ํ์) โโโโโโโโโโโโโโโโ
-
-if st.session_state.code:
- try:
- _dashCompany = _getCompany(st.session_state.code)
- _renderFullDashboard(_dashCompany, st.session_state.code)
- except Exception as e:
- st.error(f"๊ธฐ์
๋ก๋ ์คํจ: {e}")
-
- st.markdown("---")
-
-
-# โโ ์ฑํ
์์ญ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-# ํ์คํ ๋ฆฌ ํ์
-for msg in st.session_state.messages:
- with st.chat_message(msg["role"]):
- st.markdown(msg["content"])
-
-# ์
๋ ฅ
-if prompt := st.chat_input("์ผ์ฑ์ ์์ ๋ํด ์๋ ค์ค, ๋ฐฐ๋น ํํฉ์? ..."):
- # ์ฌ์ฉ์ ๋ฉ์์ง ํ์
- st.session_state.messages.append({"role": "user", "content": prompt})
- with st.chat_message("user"):
- st.markdown(prompt)
-
- # ์ข
๋ชฉ์ฝ๋ ์ถ์ถ ์๋
- newCode = _extractCode(prompt)
- if newCode and newCode != st.session_state.code:
- st.session_state.code = newCode
-
- code = st.session_state.code
-
- if not code:
- # ์ข
๋ชฉ ๋ชป ์ฐพ์
- reply = "์ข
๋ชฉ์ ์ฐพ์ง ๋ชปํ์ต๋๋ค. ํ์ฌ๋ช
์ด๋ ์ข
๋ชฉ์ฝ๋๋ฅผ ํฌํจํด์ ๋ค์ ์ง๋ฌธํด์ฃผ์ธ์.\n\n์: ์ผ์ฑ์ ์์ ๋ํด ์๋ ค์ค, 005930 ๋ถ์, AAPL ์ฌ๋ฌด"
- st.session_state.messages.append({"role": "assistant", "content": reply})
- with st.chat_message("assistant"):
- st.markdown(reply)
- else:
- # ์๋ต ์์ฑ
- with st.chat_message("assistant"):
- # ํน์ topic ๊ฐ์ง
- topic = _detectTopic(prompt)
-
- if topic:
- # ํน์ topic๋ง ๋ณด์ฌ์ฃผ๊ธฐ
- try:
- c = _getCompany(code)
- _renderTopicData(c, code, topic)
- except Exception:
- pass
-
- # AI ์์ฝ
- with st.spinner("๋ถ์ ์ค..."):
- aiAnswer = _askAi(code, prompt)
- st.markdown(aiAnswer)
-
- st.session_state.messages.append({"role": "assistant", "content": aiAnswer})
-
- # ๋์๋ณด๋ ๊ฐฑ์ ์ ์ํด rerun
- if newCode and newCode != "":
- st.rerun()
-
-
-# โโ ์ด๊ธฐ ์๋ด (๋ํ ์์ ๋) โโโโโโโโโโโโโโโโโโโโโโโโโ
-
-if not st.session_state.messages and not st.session_state.code:
- st.markdown("""
-
-
- ์๋ ์
๋ ฅ์ฐฝ์ ์์ฐ์ด๋ก ์ง๋ฌธํ์ธ์
-
-
- ์ผ์ฑ์ ์์ ๋ํด ์๋ ค์ค ·
- 005930 ๋ถ์ ·
- AAPL ์ฌ๋ฌด ๋ณด์ฌ์ค
-
-
- ์ข
๋ชฉ์ ๋งํ๋ฉด ์ฌ๋ฌด์ ํ/๊ณต์ ๋ฐ์ดํฐ๊ฐ ๋ฐ๋ก ํ์๋๊ณ , AI๊ฐ ๋ถ์์ ๋ง๋ถ์
๋๋ค
-
-
- """, unsafe_allow_html=True)
-
-
-# โโ ํธํฐ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-st.markdown(f"""
-
-""", unsafe_allow_html=True)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..edfedd3275963548107dfee86436d176411d7539
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,240 @@
+[project]
+name = "dartlab"
+version = "0.7.10"
+description = "DART ์ ์๊ณต์ + EDGAR ๊ณต์๋ฅผ ํ๋์ ํ์ฌ ๋งต์ผ๋ก โ Python ์ฌ๋ฌด ๋ถ์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ"
+readme = "README.md"
+license = {file = "LICENSE"}
+requires-python = ">=3.12"
+authors = [
+ {name = "eddmpython"}
+]
+keywords = [
+ "dart",
+ "edgar",
+ "sec",
+ "financial-statements",
+ "korea",
+ "disclosure",
+ "accounting",
+ "polars",
+ "sections",
+ "mcp",
+ "ai-analysis",
+ "annual-report",
+ "10-k",
+ "xbrl",
+ "์ ์๊ณต์",
+ "์ฌ๋ฌด์ ํ",
+ "์ฌ์
๋ณด๊ณ ์",
+ "๊ณต์๋ถ์",
+ "๋คํธ",
+]
+classifiers = [
+ "Development Status :: 5 - Production/Stable",
+ "Intended Audience :: Developers",
+ "Intended Audience :: Science/Research",
+ "Intended Audience :: Financial and Insurance Industry",
+ "Intended Audience :: End Users/Desktop",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Topic :: Office/Business :: Financial",
+ "Topic :: Office/Business :: Financial :: Accounting",
+ "Topic :: Office/Business :: Financial :: Investment",
+ "Topic :: Scientific/Engineering :: Information Analysis",
+ "Natural Language :: Korean",
+ "Natural Language :: English",
+ "Typing :: Typed",
+]
+dependencies = [
+ "alive-progress>=3.3.0,<4",
+ "beautifulsoup4>=4.14.3,<5",
+ "lxml>=6.0.2,<7",
+ "marimo>=0.20.4,<1",
+ "openpyxl>=3.1.5,<4",
+ "diff-match-patch>=20230430",
+ "httpx>=0.28.1,<1",
+ "orjson>=3.10.0,<4",
+ "polars>=1.0.0,<2",
+ "requests>=2.32.5,<3",
+ "rich>=14.3.3,<15",
+ "plotly>=5.0.0,<6",
+ "mcp[cli]>=1.0",
+]
+
+[project.optional-dependencies]
+llm = [
+ "openai>=1.0.0,<3",
+ "google-genai>=1.0.0,<2",
+]
+llm-anthropic = [
+ "openai>=1.0.0,<3",
+ "google-genai>=1.0.0,<2",
+ "anthropic>=0.30.0,<2",
+]
+charts = [
+ "networkx>=3.6.1,<4",
+ "scipy>=1.17.1,<2",
+]
+ai = [
+ "fastapi>=0.135.1,<1",
+ "httpx>=0.28.1,<1",
+ "msgpack>=1.1.0,<2",
+ "uvicorn[standard]>=0.30.0,<1",
+ "sse-starlette>=2.0.0,<3",
+]
+mcp = [
+ "mcp[cli]>=1.0,<2",
+]
+display = [
+ "great-tables>=0.15.0,<1",
+ "itables>=2.0.0,<3",
+]
+altair = [
+ "altair>=5.0.0,<6",
+]
+hf = [
+ "huggingface-hub>=0.20.0,<1",
+]
+ui = [
+ "dartlab[ai]",
+]
+channel = [
+ "dartlab[ai]",
+ "pycloudflared>=0.3",
+]
+channel-ngrok = [
+ "dartlab[ai]",
+ "pyngrok>=7.0,<8",
+]
+channel-full = [
+ "dartlab[channel,channel-ngrok]",
+ "python-telegram-bot>=21.0,<22",
+ "slack-bolt>=1.18,<2",
+ "discord.py>=2.4,<3",
+]
+all = [
+ "openai>=1.0.0,<3",
+ "anthropic>=0.30.0,<2",
+ "networkx>=3.6.1,<4",
+ "scipy>=1.17.1,<2",
+ "fastapi>=0.135.1,<1",
+ "httpx>=0.28.1,<1",
+ "msgpack>=1.1.0,<2",
+ "uvicorn[standard]>=0.30.0,<1",
+ "sse-starlette>=2.0.0,<3",
+]
+
+[project.scripts]
+dartlab = "dartlab.cli.main:main"
+
+[project.entry-points."dartlab.plugins"]
+
+[project.urls]
+Homepage = "https://eddmpython.github.io/dartlab/"
+Repository = "https://github.com/eddmpython/dartlab"
+Documentation = "https://eddmpython.github.io/dartlab/docs/"
+Issues = "https://github.com/eddmpython/dartlab/issues"
+Changelog = "https://eddmpython.github.io/dartlab/docs/changelog"
+Demo = "https://huggingface.co/spaces/eddmpython/dartlab"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/dartlab"]
+exclude = [
+ "**/_reference/**",
+ "src/dartlab/engines/edinet/**",
+ "src/dartlab/engines/esg/**",
+ "src/dartlab/engines/event/**",
+ "src/dartlab/engines/supply/**",
+ "src/dartlab/engines/watch/**",
+]
+
+[tool.hatch.build.targets.sdist]
+include = [
+ "src/dartlab/**/*.py",
+ "src/dartlab/**/*.json",
+ "src/dartlab/**/*.parquet",
+ "README.md",
+ "LICENSE",
+]
+exclude = [
+ "**/_reference/**",
+ "src/dartlab/engines/edinet/**",
+ "src/dartlab/engines/esg/**",
+ "src/dartlab/engines/event/**",
+ "src/dartlab/engines/supply/**",
+ "src/dartlab/engines/watch/**",
+]
+
+[tool.ruff]
+target-version = "py312"
+line-length = 120
+exclude = ["experiments", "*/_reference"]
+
+[tool.ruff.lint]
+select = ["E", "F", "I"]
+ignore = ["E402", "E501", "E741", "F841"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "-v --tb=short"
+asyncio_mode = "auto"
+markers = [
+ "requires_data: ๋ก์ปฌ parquet ๋ฐ์ดํฐ ํ์ (CI์์ skip)",
+ "unit: ์์ ๋ก์ง/mock๋ง โ ๋ฐ์ดํฐ ๋ก๋ ์์, ๋ณ๋ ฌ ์์ ",
+ "integration: Company 1๊ฐ ๋ก๋ฉ ํ์ โ ์ค๊ฐ ๋ฌด๊ฒ",
+ "heavy: ๋๋ ๋ฐ์ดํฐ ๋ก๋ โ ๋จ๋
์คํ ํ์",
+]
+
+[tool.coverage.run]
+source = ["dartlab"]
+omit = [
+ "src/dartlab/ui/*",
+ "src/dartlab/engines/ai/providers/*",
+]
+
+[tool.coverage.report]
+show_missing = true
+skip_empty = true
+exclude_lines = [
+ "pragma: no cover",
+ "if __name__",
+ "raise NotImplementedError",
+]
+
+[tool.pyright]
+pythonVersion = "3.12"
+typeCheckingMode = "basic"
+include = ["src/dartlab"]
+exclude = [
+ "src/dartlab/engines/ai/providers/**",
+ "src/dartlab/ui/**",
+ "experiments/**",
+]
+reportMissingTypeStubs = false
+reportUnknownParameterType = false
+reportUnknownMemberType = false
+reportUnknownVariableType = false
+
+[tool.bandit]
+exclude_dirs = ["experiments", "tests"]
+skips = ["B101"]
+
+[dependency-groups]
+dev = [
+ "build>=1.4.0",
+ "dartlab[all]",
+ "hatchling>=1.29.0",
+ "pillow>=12.1.1",
+ "pre-commit>=4.0.0",
+ "pyright>=1.1.0",
+ "pytest>=9.0.2",
+ "pytest-asyncio>=0.24.0",
+ "pytest-cov>=6.0.0",
+]
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index e9e66e99b31fbbbba682987e32e4fc2a43a7aab7..0000000000000000000000000000000000000000
--- a/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-dartlab>=0.7.8
-streamlit>=1.45,<2
-openpyxl>=3.1
-huggingface_hub>=0.25
diff --git a/src/dartlab/API_SPEC.md b/src/dartlab/API_SPEC.md
new file mode 100644
index 0000000000000000000000000000000000000000..0b155bba27538cd4f37927f35e268571628ef978
--- /dev/null
+++ b/src/dartlab/API_SPEC.md
@@ -0,0 +1,450 @@
+# dartlab API ์คํ
+
+์ด ๋ฌธ์๋ `scripts/generateSpec.py`์ ์ํด ์๋ ์์ฑ๋ฉ๋๋ค. ์ง์ ์์ ํ์ง ๋ง์ธ์.
+
+
+---
+
+## Company (ํตํฉ facade)
+
+์
๋ ฅ์ ์๋ ํ๋ณํ์ฌ DART ๋๋ EDGAR ์์ฅ ์ ์ฉ Company๋ฅผ ์์ฑํ๋ค.
+ํ์ฌ DART Company์ ๊ณต๊ฐ ์ง์
์ ์ **index โ show(topic) โ trace(topic)** ์ด๋ค.
+`profile`์ ํฅํ terminal/notebook ๋ฌธ์ํ ๋ณด๊ณ ์ ๋ทฐ๋ก ํ์ฅ๋ ์์ ์ด๋ค.
+
+```python
+import dartlab
+
+kr = dartlab.Company("005930")
+kr = dartlab.Company("์ผ์ฑ์ ์")
+us = dartlab.Company("AAPL")
+
+kr.market # "KR"
+us.market # "US"
+```
+
+### ํ๋ณ ๊ท์น
+
+| ์
๋ ฅ | ๊ฒฐ๊ณผ | ์์ |
+|------|------|------|
+| 6์๋ฆฌ ์ซ์ | DART Company | `Company("005930")` |
+| ํ๊ธ ํฌํจ | DART Company | `Company("์ผ์ฑ์ ์")` |
+| ์๋ฌธ 1~5์๋ฆฌ | EDGAR Company | `Company("AAPL")` |
+
+## DART Company
+
+### ํ์ฌ ๊ณต๊ฐ ์ง์
์
+
+| surface | ์ค๋ช
|
+|---------|------|
+| `index` | ํ์ฌ ๋ฐ์ดํฐ ๊ตฌ์กฐ ์ธ๋ฑ์ค DataFrame |
+| `show(topic)` | topic์ ์ค์ ๋ฐ์ดํฐ payload ์กฐํ |
+| `trace(topic, period)` | docs / finance / report source provenance ์กฐํ |
+| `docs` | pure docs source namespace |
+| `finance` | authoritative finance source namespace |
+| `report` | authoritative structured disclosure source namespace |
+| `profile` | ํฅํ ๋ณด๊ณ ์ํ ๋ ๋์ฉ ์์ฝ ๋ทฐ |
+
+### ์ ์ ๋ฉ์๋
+
+| ๋ฉ์๋ | ๋ฐํ | ์ค๋ช
|
+|--------|------|------|
+| `dartlab.providers.dart.Company.listing()` | DataFrame | KRX ์ ์ฒด ์์ฅ๋ฒ์ธ ๋ชฉ๋ก |
+| `dartlab.providers.dart.Company.search(keyword)` | DataFrame | ํ์ฌ๋ช
๋ถ๋ถ ๊ฒ์ |
+| `dartlab.providers.dart.Company.status()` | DataFrame | ๋ก์ปฌ ๋ณด์ ์ ์ฒด ์ข
๋ชฉ ์ธ๋ฑ์ค |
+| `dartlab.providers.dart.Company.resolve(codeOrName)` | str \| None | ์ข
๋ชฉ์ฝ๋/ํ์ฌ๋ช
โ ์ข
๋ชฉ์ฝ๋ |
+
+### ํต์ฌ property
+
+| property | ๋ฐํ | ์ค๋ช
|
+|----------|------|------|
+| `BS` | DataFrame | ์ฌ๋ฌด์ํํ |
+| `IS` | DataFrame | ์์ต๊ณ์ฐ์ |
+| `CIS` | DataFrame | ํฌ๊ด์์ต๊ณ์ฐ์ |
+| `CF` | DataFrame | ํ๊ธํ๋ฆํ |
+| `SCE` | tuple \| DataFrame | ์๋ณธ๋ณ๋ํ |
+| `sections` | DataFrame | merged topic x period company table |
+| `timeseries` | (series, periods) | ๋ถ๊ธฐ๋ณ standalone ์๊ณ์ด |
+| `annual` | (series, years) | ์ฐ๋๋ณ ์๊ณ์ด |
+| `ratios` | RatioResult | ์ฌ๋ฌด๋น์จ |
+| `index` | DataFrame | ํ์ฌ ๊ตฌ์กฐ ์ธ๋ฑ์ค |
+| `docs` | Accessor | pure docs source |
+| `finance` | Accessor | authoritative finance source |
+| `report` | Accessor | authoritative report source |
+| `profile` | _BoardView | ํฅํ ๋ณด๊ณ ์ํ ๋ทฐ ์์ฝ |
+| `sector` | SectorInfo | ์นํฐ ๋ถ๋ฅ |
+| `insights` | AnalysisResult | 7์์ญ ์ธ์ฌ์ดํธ ๋ฑ๊ธ |
+| `rank` | RankInfo | ์์ฅ ์์ |
+| `notes` | Notes | K-IFRS ์ฃผ์ ์ ๊ทผ |
+| `market` | str | `"KR"` |
+
+### ๋ฉ์๋
+
+| ๋ฉ์๋ | ๋ฐํ | ์ค๋ช
|
+|--------|------|------|
+| `get(name)` | Result | ๋ชจ๋ ์ ์ฒด Result ๊ฐ์ฒด |
+| `all()` | dict | ์ ์ฒด ๋ฐ์ดํฐ dict |
+| `show(topic, period=None, raw=False)` | Any | topic payload ์กฐํ |
+| `trace(topic, period=None)` | dict \| None | ์ ํ source provenance ์กฐํ |
+| `fsSummary(period)` | AnalysisResult | ์์ฝ์ฌ๋ฌด์ ๋ณด |
+| `getTimeseries(period, fsDivPref)` | (series, periods) | ์ปค์คํ
์๊ณ์ด |
+| `getRatios(fsDivPref)` | RatioResult | ์ปค์คํ
๋น์จ |
+
+`index`๋ ํ์ฌ ์ ์ฒด ๊ตฌ์กฐ๋ฅผ ๋จผ์ ๋ณด์ฌ์ฃผ๊ณ , `show(topic)`๊ฐ ์ค์ ๋ฐ์ดํฐ๋ฅผ ์ฐ๋ค.
+`trace(topic)`๋ ๊ฐ์ topic์์ docs / finance / report ์ค ์ด๋ค source๊ฐ ์ฑํ๋๋์ง ์ค๋ช
ํ๋ค.
+docs๊ฐ ์๋ ํ์ฌ๋ `docsStatus` ์๋ด row์ `ํ์ฌ ์ฌ์
๋ณด๊ณ ์ ๋ถ์ฌ` notice๊ฐ ํ์๋๋ค.
+
+report/disclosure property๋ registry์์ ์๋ ๋์คํจ์น๋๋ค (`_MODULE_REGISTRY`).
+๋ฑ๋ก๋ ๋ชจ๋ property๋ ์๋ "๋ฐ์ดํฐ ๋ ์ง์คํธ๋ฆฌ" ์น์
์ฐธ์กฐ.
+
+## EDGAR Company
+
+```python
+import dartlab
+
+us = dartlab.Company("AAPL")
+us.ticker # "AAPL"
+us.cik # "0000320193"
+```
+
+### property
+
+| property | ๋ฐํ | ์ค๋ช
|
+|----------|------|------|
+| `timeseries` | (series, periods) | ๋ถ๊ธฐ๋ณ standalone ์๊ณ์ด |
+| `annual` | (series, years) | ์ฐ๋๋ณ ์๊ณ์ด |
+| `ratios` | RatioResult | ์ฌ๋ฌด๋น์จ |
+| `insights` | AnalysisResult | 7์์ญ ์ธ์ฌ์ดํธ ๋ฑ๊ธ |
+| `market` | str | `"US"` |
+
+---
+
+## ๋ฐ์ดํฐ ๋ ์ง์คํธ๋ฆฌ
+
+`core/registry.py`์ ๋ฑ๋ก๋ ์ ์ฒด ๋ฐ์ดํฐ ์์ค ๋ชฉ๋ก.
+
+๋ชจ๋ ์ถ๊ฐ = registry์ DataEntry ํ ์ค ์ถ๊ฐ โ Company, Excel, LLM, Server, Skills ์ ๋ถ ์๋ ๋ฐ์.
+
+### ์๊ณ์ด ์ฌ๋ฌด์ ํ (finance)
+
+| name | label | dataType | description |
+|------|-------|----------|-------------|
+| `annual.IS` | ์์ต๊ณ์ฐ์(์ฐ๋๋ณ) | `timeseries` | ์ฐ๋๋ณ ์์ต๊ณ์ฐ์ ์๊ณ์ด. ๋งค์ถ์ก, ์์
์ด์ต, ์์ด์ต ๋ฑ ์ ์ฒด ๊ณ์ . |
+| `annual.BS` | ์ฌ๋ฌด์ํํ(์ฐ๋๋ณ) | `timeseries` | ์ฐ๋๋ณ ์ฌ๋ฌด์ํํ ์๊ณ์ด. ์์ฐ, ๋ถ์ฑ, ์๋ณธ ์ ์ฒด ๊ณ์ . |
+| `annual.CF` | ํ๊ธํ๋ฆํ(์ฐ๋๋ณ) | `timeseries` | ์ฐ๋๋ณ ํ๊ธํ๋ฆํ ์๊ณ์ด. ์์
/ํฌ์/์ฌ๋ฌดํ๋ ํ๊ธํ๋ฆ. |
+| `timeseries.IS` | ์์ต๊ณ์ฐ์(๋ถ๊ธฐ๋ณ) | `timeseries` | ๋ถ๊ธฐ๋ณ ์์ต๊ณ์ฐ์ standalone ์๊ณ์ด. |
+| `timeseries.BS` | ์ฌ๋ฌด์ํํ(๋ถ๊ธฐ๋ณ) | `timeseries` | ๋ถ๊ธฐ๋ณ ์ฌ๋ฌด์ํํ ์์ ์์ก ์๊ณ์ด. |
+| `timeseries.CF` | ํ๊ธํ๋ฆํ(๋ถ๊ธฐ๋ณ) | `timeseries` | ๋ถ๊ธฐ๋ณ ํ๊ธํ๋ฆํ standalone ์๊ณ์ด. |
+
+### ๊ณต์ ํ์ฑ ๋ชจ๋ (report)
+
+| name | label | dataType | description |
+|------|-------|----------|-------------|
+| `BS` | ์ฌ๋ฌด์ํํ | `dataframe` | K-IFRS ์ฐ๊ฒฐ ์ฌ๋ฌด์ํํ. finance XBRL ์ ๊ทํ(snakeId) ๊ธฐ๋ฐ, ํ์ฌ๊ฐ ๋น๊ต ๊ฐ๋ฅ. finance ์์ผ๋ฉด docs fallback. |
+| `IS` | ์์ต๊ณ์ฐ์ | `dataframe` | K-IFRS ์ฐ๊ฒฐ ์์ต๊ณ์ฐ์. finance XBRL ์ ๊ทํ ๊ธฐ๋ฐ. ๋งค์ถ์ก, ์์
์ด์ต, ์์ด์ต ๋ฑ ์ ์ฒด ๊ณ์ ํฌํจ. |
+| `CF` | ํ๊ธํ๋ฆํ | `dataframe` | K-IFRS ์ฐ๊ฒฐ ํ๊ธํ๋ฆํ. finance XBRL ์ ๊ทํ ๊ธฐ๋ฐ. ์์
/ํฌ์/์ฌ๋ฌดํ๋ ํ๊ธํ๋ฆ. |
+| `fsSummary` | ์์ฝ์ฌ๋ฌด์ ๋ณด | `dataframe` | DART ๊ณต์ ์์ฝ์ฌ๋ฌด์ ๋ณด. ๋ค๋
๊ฐ ์ฃผ์ ์ฌ๋ฌด์งํ ๋น๊ต. |
+| `segments` | ๋ถ๋ฌธ์ ๋ณด | `dataframe` | ์ฌ์
๋ถ๋ฌธ๋ณ ๋งค์ถยท์ด์ต ๋ฐ์ดํฐ. ๋ถ๋ฌธ๊ฐ ์์ต์ฑ ๋น๊ต ๊ฐ๋ฅ. |
+| `tangibleAsset` | ์ ํ์์ฐ | `dataframe` | ์ ํ์์ฐ ๋ณ๋ํ. ์ทจ๋/์ฒ๋ถ/๊ฐ๊ฐ์๊ฐ ๋ด์ญ. |
+| `costByNature` | ๋น์ฉ์ฑ๊ฒฉ๋ณ๋ถ๋ฅ | `dataframe` | ๋น์ฉ์ ์ฑ๊ฒฉ๋ณ๋ก ๋ถ๋ฅํ ์๊ณ์ด. ์์ฌ๋ฃ๋น, ์ธ๊ฑด๋น, ๊ฐ๊ฐ์๊ฐ๋น ๋ฑ. |
+| `dividend` | ๋ฐฐ๋น | `dataframe` | ๋ฐฐ๋น ์๊ณ์ด. ์ฐ๋๋ณ DPS, ๋ฐฐ๋น์ด์ก, ๋ฐฐ๋น์ฑํฅ, ๋ฐฐ๋น์์ต๋ฅ . |
+| `majorHolder` | ์ต๋์ฃผ์ฃผ | `dataframe` | ์ต๋์ฃผ์ฃผ ์ง๋ถ์จ ์๊ณ์ด. ์ง๋ถ ๋ณ๋์ ๊ฒฝ์๊ถ ์์ ์ฑ์ ํต์ฌ ์งํ. |
+| `employee` | ์ง์ํํฉ | `dataframe` | ์ง์ ์, ํ๊ท ๊ทผ์์ฐ์, ํ๊ท ์ฐ๋ด ์๊ณ์ด. |
+| `subsidiary` | ์ํ์ฌํฌ์ | `dataframe` | ์ข
์ํ์ฌ ํฌ์ ์๊ณ์ด. ์ง๋ถ์จ, ์ฅ๋ถ๊ฐ์ก ๋ณ๋. |
+| `bond` | ์ฑ๋ฌด์ฆ๊ถ | `dataframe` | ์ฌ์ฑ, CP ๋ฑ ์ฑ๋ฌด์ฆ๊ถ ๋ฐํยท์ํ ์๊ณ์ด. |
+| `shareCapital` | ์ฃผ์ํํฉ | `dataframe` | ๋ฐํ์ฃผ์์, ์๊ธฐ์ฃผ์, ์ ํต์ฃผ์์ ์๊ณ์ด. |
+| `executive` | ์์ํํฉ | `dataframe` | ๋ฑ๊ธฐ์์ ๊ตฌ์ฑ ์๊ณ์ด. ์ฌ๋ด์ด์ฌ/์ฌ์ธ์ด์ฌ/๋น์๋ฌด์ด์ฌ ๊ตฌ๋ถ. |
+| `executivePay` | ์์๋ณด์ | `dataframe` | ์์ ์ ํ๋ณ ๋ณด์ ์๊ณ์ด. ๋ฑ๊ธฐ์ด์ฌ/์ฌ์ธ์ด์ฌ/๊ฐ์ฌ ๊ตฌ๋ถ. |
+| `audit` | ๊ฐ์ฌ์๊ฒฌ | `dataframe` | ์ธ๋ถ๊ฐ์ฌ์ธ์ ๊ฐ์ฌ์๊ฒฌ๊ณผ ๊ฐ์ฌ๋ณด์ ์๊ณ์ด. ์ ์ ์ธ ์๊ฒฌ์ ์ค๋ ์ํ ์ ํธ. |
+| `boardOfDirectors` | ์ด์ฌํ | `dataframe` | ์ด์ฌํ ๊ตฌ์ฑ ๋ฐ ํ๋ ์๊ณ์ด. ๊ฐ์ตํ์, ์ถ์๋ฅ ํฌํจ. |
+| `capitalChange` | ์๋ณธ๋ณ๋ | `dataframe` | ์๋ณธ๊ธ ๋ณ๋ ์๊ณ์ด. ๋ณดํต์ฃผ/์ฐ์ ์ฃผ ์ฃผ์์ยท์ก๋ฉด ๋ณ๋. |
+| `contingentLiability` | ์ฐ๋ฐ๋ถ์ฑ | `dataframe` | ์ฑ๋ฌด๋ณด์ฆ, ์์ก ํํฉ. ์ ์ฌ์ ์ฌ๋ฌด ๋ฆฌ์คํฌ ์งํ. |
+| `internalControl` | ๋ด๋ถํต์ | `dataframe` | ๋ด๋ถํ๊ณ๊ด๋ฆฌ์ ๋ ๊ฐ์ฌ์๊ฒฌ ์๊ณ์ด. |
+| `relatedPartyTx` | ๊ด๊ณ์๊ฑฐ๋ | `dataframe` | ๋์ฃผ์ฃผ ๋ฑ๊ณผ์ ๋งค์ถยท๋งค์
๊ฑฐ๋ ์๊ณ์ด. ์ด์ ๊ฐ๊ฒฉ ๋ฆฌ์คํฌ ํ์ธ. |
+| `rnd` | R&D | `dataframe` | ์ฐ๊ตฌ๊ฐ๋ฐ๋น์ฉ ์๊ณ์ด. ๊ธฐ์ ํฌ์ ๊ฐ๋ ํ๋จ. |
+| `sanction` | ์ ์ฌํํฉ | `dataframe` | ํ์ ์ ์ฌ, ๊ณผ์ง๊ธ, ์์
์ ์ง ๋ฑ ๊ท์ ์กฐ์น ์ด๋ ฅ. |
+| `affiliateGroup` | ๊ณ์ด์ฌ | `dataframe` | ๊ธฐ์
์ง๋จ ์์ ๊ณ์ดํ์ฌ ํํฉ. ์์ฅ/๋น์์ฅ ๊ตฌ๋ถ. |
+| `fundraising` | ์ฆ์๊ฐ์ | `dataframe` | ์ ์์ฆ์, ๋ฌด์์ฆ์, ๊ฐ์ ์ด๋ ฅ. |
+| `productService` | ์ฃผ์์ ํ | `dataframe` | ์ฃผ์ ์ ํ/์๋น์ค๋ณ ๋งค์ถ์ก๊ณผ ๋น์ค. |
+| `salesOrder` | ๋งค์ถ์์ฃผ | `dataframe` | ๋งค์ถ์ค์ ๋ฐ ์์ฃผ ํํฉ. |
+| `riskDerivative` | ์ํ๊ด๋ฆฌ | `dataframe` | ํ์จยท์ด์์จยท์ํ๊ฐ๊ฒฉ ๋ฆฌ์คํฌ ๊ด๋ฆฌ. ํ์์ํ ๋ณด์ ํํฉ. |
+| `articlesOfIncorporation` | ์ ๊ด | `dataframe` | ์ ๊ด ๋ณ๊ฒฝ ์ด๋ ฅ. ์ฌ์
๋ชฉ์ ์ถ๊ฐยท๋ณ๊ฒฝ์ผ๋ก ์ ์ฌ์
์ง์ถ ํ์
. |
+| `otherFinance` | ๊ธฐํ์ฌ๋ฌด | `dataframe` | ๋์์ถฉ๋น๊ธ, ์ฌ๊ณ ์์ฐ ๊ด๋ จ ๊ธฐํ ์ฌ๋ฌด ๋ฐ์ดํฐ. |
+| `companyHistory` | ์ฐํ | `dataframe` | ํ์ฌ ์ฃผ์ ์ฐํ ์ด๋ฒคํธ ๋ชฉ๋ก. |
+| `shareholderMeeting` | ์ฃผ์ฃผ์ดํ | `dataframe` | ์ฃผ์ฃผ์ดํ ์๊ฑด ๋ฐ ์๊ฒฐ ๊ฒฐ๊ณผ. |
+| `auditSystem` | ๊ฐ์ฌ์ ๋ | `dataframe` | ๊ฐ์ฌ์์ํ ๊ตฌ์ฑ ๋ฐ ํ๋ ํํฉ. |
+| `affiliate` | ๊ด๊ณ๊ธฐ์
ํฌ์ | `dataframe` | ๊ด๊ณ๊ธฐ์
/๊ณต๋๊ธฐ์
ํฌ์ ๋ณ๋ ์๊ณ์ด. ์ง๋ถ๋ฒ์์ต, ๊ธฐ์ด/๊ธฐ๋ง ์ฅ๋ถ๊ฐ ํฌํจ. |
+| `investmentInOther` | ํ๋ฒ์ธ์ถ์ | `dataframe` | ํ๋ฒ์ธ ์ถ์ ํํฉ. ํฌ์๋ชฉ์ , ์ง๋ถ์จ, ์ฅ๋ถ๊ฐ ๋ฑ. |
+| `companyOverviewDetail` | ํ์ฌ๊ฐ์ | `dict` | ์ค๋ฆฝ์ผ, ์์ฅ์ผ, ๋ํ์ด์ฌ, ์ฃผ์, ์ฃผ์์ฌ์
๋ฑ ๊ธฐ๋ณธ ์ ๋ณด. |
+| `holderOverview` | ์ฃผ์ฃผํํฉ | `custom` | 5% ์ด์ ์ฃผ์ฃผ, ์์ก์ฃผ์ฃผ ํํฉ, ์๊ฒฐ๊ถ ํํฉ. majorHolder๋ณด๋ค ์์ธํ ์ฃผ์ฃผ ๊ตฌ์ฑ. |
+
+### ์์ ํ ๊ณต์ (disclosure)
+
+| name | label | dataType | description |
+|------|-------|----------|-------------|
+| `business` | ์ฌ์
์๋ด์ฉ | `text` | ์ฌ์
๋ณด๊ณ ์ '์ฌ์
์ ๋ด์ฉ' ์์ . ์ฌ์
๊ตฌ์กฐ์ ํํฉ ํ์
. |
+| `companyOverview` | ํ์ฌ๊ฐ์์ ๋ | `dict` | ๊ณต์ ๊ธฐ๋ฐ ํ์ฌ ์ ๋ ๊ฐ์ ๋ฐ์ดํฐ. |
+| `mdna` | MD&A | `text` | ์ด์ฌ์ ๊ฒฝ์์ง๋จ ๋ฐ ๋ถ์์๊ฒฌ. ๊ฒฝ์์ง ์๊ฐ์ ์ค์ ํ๊ฐ์ ์ ๋ง. |
+| `rawMaterial` | ์์ฌ๋ฃ์ค๋น | `dict` | ์์ฌ๋ฃ ๋งค์
, ์ ํ์์ฐ ํํฉ, ์์คํฌ์ ๋ฐ์ดํฐ. |
+| `sections` | ์ฌ์
๋ณด๊ณ ์์น์
| `dataframe` | ์ฌ์
๋ณด๊ณ ์ ์ ์ฒด ์น์
ํ
์คํธ๋ฅผ topic(ํ) ร period(์ด) DataFrame์ผ๋ก ๊ตฌ์กฐํ. leaf title ๊ธฐ์ค ์ํ ๋น๊ต ๊ฐ๋ฅ. ์ฐ๊ฐ+๋ถ๊ธฐ+๋ฐ๊ธฐ ์ ๊ธฐ๊ฐ ํฌํจ. |
+
+### K-IFRS ์ฃผ์ (notes)
+
+| name | label | dataType | description |
+|------|-------|----------|-------------|
+| `notes.receivables` | ๋งค์ถ์ฑ๊ถ | `dataframe` | K-IFRS ๋งค์ถ์ฑ๊ถ ์ฃผ์. ์ฑ๊ถ ์์ก ๋ฐ ๋์์ถฉ๋น๊ธ ๋ด์ญ. |
+| `notes.inventory` | ์ฌ๊ณ ์์ฐ | `dataframe` | K-IFRS ์ฌ๊ณ ์์ฐ ์ฃผ์. ์์ฌ๋ฃ/์ฌ๊ณตํ/์ ํ ๋ด์ญ๋ณ ๊ธ์ก. |
+| `notes.tangibleAsset` | ์ ํ์์ฐ(์ฃผ์) | `dataframe` | K-IFRS ์ ํ์์ฐ ๋ณ๋ ์ฃผ์. ํ ์ง, ๊ฑด๋ฌผ, ๊ธฐ๊ณ ๋ฑ ํญ๋ชฉ๋ณ ๋ณ๋. |
+| `notes.intangibleAsset` | ๋ฌดํ์์ฐ | `dataframe` | K-IFRS ๋ฌดํ์์ฐ ์ฃผ์. ์์
๊ถ, ๊ฐ๋ฐ๋น ๋ฑ ํญ๋ชฉ๋ณ ๋ณ๋. |
+| `notes.investmentProperty` | ํฌ์๋ถ๋์ฐ | `dataframe` | K-IFRS ํฌ์๋ถ๋์ฐ ์ฃผ์. ๊ณต์ ๊ฐ์น ๋ฐ ๋ณ๋ ๋ด์ญ. |
+| `notes.affiliates` | ๊ด๊ณ๊ธฐ์
(์ฃผ์) | `dataframe` | K-IFRS ๊ด๊ณ๊ธฐ์
ํฌ์ ์ฃผ์. ์ง๋ถ๋ฒ ์ ์ฉ ๋ด์ญ. |
+| `notes.borrowings` | ์ฐจ์
๊ธ | `dataframe` | K-IFRS ์ฐจ์
๊ธ ์ฃผ์. ๋จ๊ธฐ/์ฅ๊ธฐ ์ฐจ์
์์ก ๋ฐ ์ด์์จ. |
+| `notes.provisions` | ์ถฉ๋น๋ถ์ฑ | `dataframe` | K-IFRS ์ถฉ๋น๋ถ์ฑ ์ฃผ์. ํ๋งค๋ณด์ฆ, ์์ก, ๋ณต๊ตฌ ๋ฑ. |
+| `notes.eps` | ์ฃผ๋น์ด์ต | `dataframe` | K-IFRS ์ฃผ๋น์ด์ต ์ฃผ์. ๊ธฐ๋ณธ/ํฌ์ EPS ๊ณ์ฐ ๋ด์ญ. |
+| `notes.lease` | ๋ฆฌ์ค | `dataframe` | K-IFRS ๋ฆฌ์ค ์ฃผ์. ์ฌ์ฉ๊ถ์์ฐ, ๋ฆฌ์ค๋ถ์ฑ ๋ด์ญ. |
+| `notes.segments` | ๋ถ๋ฌธ์ ๋ณด(์ฃผ์) | `dataframe` | K-IFRS ๋ถ๋ฌธ์ ๋ณด ์ฃผ์. ์ฌ์
๋ถ๋ฌธ๋ณ ์์ธ ๋ฐ์ดํฐ. |
+| `notes.costByNature` | ๋น์ฉ์์ฑ๊ฒฉ๋ณ๋ถ๋ฅ(์ฃผ์) | `dataframe` | K-IFRS ๋น์ฉ์ ์ฑ๊ฒฉ๋ณ ๋ถ๋ฅ ์ฃผ์. |
+
+### ์๋ณธ ๋ฐ์ดํฐ (raw)
+
+| name | label | dataType | description |
+|------|-------|----------|-------------|
+| `rawDocs` | ๊ณต์ ์๋ณธ | `dataframe` | ๊ณต์ ๋ฌธ์ ์๋ณธ parquet. ๊ฐ๊ณต ์ ์ ์ฒด ํ
์ด๋ธ๊ณผ ํ
์คํธ. |
+| `rawFinance` | XBRL ์๋ณธ | `dataframe` | XBRL ์ฌ๋ฌด์ ํ ์๋ณธ parquet. ๋งคํ/์ ๊ทํ ์ ์๋ณธ ๋ฐ์ดํฐ. |
+| `rawReport` | ๋ณด๊ณ ์ ์๋ณธ | `dataframe` | ์ ๊ธฐ๋ณด๊ณ ์ API ์๋ณธ parquet. ํ์ฑ ์ ์๋ณธ ๋ฐ์ดํฐ. |
+
+### ๋ถ์ ์์ง (analysis)
+
+| name | label | dataType | description |
+|------|-------|----------|-------------|
+| `ratios` | ์ฌ๋ฌด๋น์จ | `ratios` | financeEngine์ด ์๋๊ณ์ฐํ ์์ต์ฑยท์์ ์ฑยท๋ฐธ๋ฅ์์ด์
๋น์จ. |
+| `insight` | ์ธ์ฌ์ดํธ | `custom` | 7์์ญ A~F ๋ฑ๊ธ ๋ถ์ (์ค์ , ์์ต์ฑ, ๊ฑด์ ์ฑ, ํ๊ธํ๋ฆ, ์ง๋ฐฐ๊ตฌ์กฐ, ๋ฆฌ์คํฌ, ๊ธฐํ). |
+| `sector` | ์นํฐ๋ถ๋ฅ | `custom` | WICS 11๋ ์นํฐ ๋ถ๋ฅ. ๋๋ถ๋ฅ/์ค๋ถ๋ฅ + ์นํฐ๋ณ ํ๋ผ๋ฏธํฐ. |
+| `rank` | ์์ฅ์์ | `custom` | ์ ์ฒด ์์ฅ ๋ฐ ์นํฐ ๋ด ๋งค์ถ/์์ฐ/์ฑ์ฅ๋ฅ ์์. |
+| `keywordTrend` | ํค์๋ ํธ๋ ๋ | `dataframe` | ๊ณต์ ํ
์คํธ ํค์๋ ๋น๋ ์ถ์ด (topic ร period ร keyword). 54๊ฐ ๋ด์ฅ ํค์๋ ๋๋ ์ฌ์ฉ์ ์ง์ . |
+| `news` | ๋ด์ค | `dataframe` | ์ต๊ทผ ๋ด์ค ์์ง (KR: Google News ํ๊ตญ์ด, US: Google News ์์ด). ๋ ์ง/์ ๋ชฉ/์ถ์ฒ/URL. |
+| `crossBorderPeers` | ๊ธ๋ก๋ฒ ํผ์ด | `custom` | WICSโGICS ์นํฐ ๋งคํ ๊ธฐ๋ฐ ๊ธ๋ก๋ฒ ํผ์ด ์ถ์ฒ. ํ๊ตญ ์ข
๋ชฉ์ ๋ฏธ๊ตญ ๋์ข
๊ธฐ์
๋ฆฌ์คํธ. |
+
+---
+
+## ์ฃผ์ ๋ฐ์ดํฐ ํ์
+
+### RatioResult
+
+๋น์จ ๊ณ์ฐ ๊ฒฐ๊ณผ (์ต์ ๋จ์ผ ์์ ).
+
+| ํ๋ | ํ์
| ๊ธฐ๋ณธ๊ฐ |
+|------|------|--------|
+| `revenueTTM` | `float | None` | None |
+| `operatingIncomeTTM` | `float | None` | None |
+| `netIncomeTTM` | `float | None` | None |
+| `operatingCashflowTTM` | `float | None` | None |
+| `investingCashflowTTM` | `float | None` | None |
+| `totalAssets` | `float | None` | None |
+| `totalEquity` | `float | None` | None |
+| `ownersEquity` | `float | None` | None |
+| `totalLiabilities` | `float | None` | None |
+| `currentAssets` | `float | None` | None |
+| `currentLiabilities` | `float | None` | None |
+| `cash` | `float | None` | None |
+| `shortTermBorrowings` | `float | None` | None |
+| `longTermBorrowings` | `float | None` | None |
+| `bonds` | `float | None` | None |
+| `grossProfit` | `float | None` | None |
+| `costOfSales` | `float | None` | None |
+| `sga` | `float | None` | None |
+| `inventories` | `float | None` | None |
+| `receivables` | `float | None` | None |
+| `payables` | `float | None` | None |
+| `tangibleAssets` | `float | None` | None |
+| `intangibleAssets` | `float | None` | None |
+| `retainedEarnings` | `float | None` | None |
+| `profitBeforeTax` | `float | None` | None |
+| `incomeTaxExpense` | `float | None` | None |
+| `financeIncome` | `float | None` | None |
+| `financeCosts` | `float | None` | None |
+| `capex` | `float | None` | None |
+| `dividendsPaid` | `float | None` | None |
+| `depreciationExpense` | `float | None` | None |
+| `noncurrentAssets` | `float | None` | None |
+| `noncurrentLiabilities` | `float | None` | None |
+| `roe` | `float | None` | None |
+| `roa` | `float | None` | None |
+| `roce` | `float | None` | None |
+| `operatingMargin` | `float | None` | None |
+| `netMargin` | `float | None` | None |
+| `preTaxMargin` | `float | None` | None |
+| `grossMargin` | `float | None` | None |
+| `ebitdaMargin` | `float | None` | None |
+| `costOfSalesRatio` | `float | None` | None |
+| `sgaRatio` | `float | None` | None |
+| `effectiveTaxRate` | `float | None` | None |
+| `incomeQualityRatio` | `float | None` | None |
+| `debtRatio` | `float | None` | None |
+| `currentRatio` | `float | None` | None |
+| `quickRatio` | `float | None` | None |
+| `cashRatio` | `float | None` | None |
+| `equityRatio` | `float | None` | None |
+| `interestCoverage` | `float | None` | None |
+| `netDebt` | `float | None` | None |
+| `netDebtRatio` | `float | None` | None |
+| `noncurrentRatio` | `float | None` | None |
+| `workingCapital` | `float | None` | None |
+| `revenueGrowth` | `float | None` | None |
+| `operatingProfitGrowth` | `float | None` | None |
+| `netProfitGrowth` | `float | None` | None |
+| `assetGrowth` | `float | None` | None |
+| `equityGrowthRate` | `float | None` | None |
+| `revenueGrowth3Y` | `float | None` | None |
+| `totalAssetTurnover` | `float | None` | None |
+| `fixedAssetTurnover` | `float | None` | None |
+| `inventoryTurnover` | `float | None` | None |
+| `receivablesTurnover` | `float | None` | None |
+| `payablesTurnover` | `float | None` | None |
+| `operatingCycle` | `float | None` | None |
+| `fcf` | `float | None` | None |
+| `operatingCfMargin` | `float | None` | None |
+| `operatingCfToNetIncome` | `float | None` | None |
+| `operatingCfToCurrentLiab` | `float | None` | None |
+| `capexRatio` | `float | None` | None |
+| `dividendPayoutRatio` | `float | None` | None |
+| `fcfToOcfRatio` | `float | None` | None |
+| `roic` | `float | None` | None |
+| `dupontMargin` | `float | None` | None |
+| `dupontTurnover` | `float | None` | None |
+| `dupontLeverage` | `float | None` | None |
+| `debtToEbitda` | `float | None` | None |
+| `ccc` | `float | None` | None |
+| `dso` | `float | None` | None |
+| `dio` | `float | None` | None |
+| `dpo` | `float | None` | None |
+| `piotroskiFScore` | `int | None` | None |
+| `piotroskiMaxScore` | `int` | 9 |
+| `altmanZScore` | `float | None` | None |
+| `beneishMScore` | `float | None` | None |
+| `sloanAccrualRatio` | `float | None` | None |
+| `ohlsonOScore` | `float | None` | None |
+| `ohlsonProbability` | `float | None` | None |
+| `altmanZppScore` | `float | None` | None |
+| `springateSScore` | `float | None` | None |
+| `zmijewskiXScore` | `float | None` | None |
+| `eps` | `float | None` | None |
+| `bps` | `float | None` | None |
+| `dps` | `float | None` | None |
+| `per` | `float | None` | None |
+| `pbr` | `float | None` | None |
+| `psr` | `float | None` | None |
+| `evEbitda` | `float | None` | None |
+| `marketCap` | `float | None` | None |
+| `sharesOutstanding` | `int | None` | None |
+| `ebitdaEstimated` | `bool` | True |
+| `currency` | `str` | KRW |
+| `warnings` | `list` | [] |
+
+### InsightResult
+
+๋จ์ผ ์์ญ ๋ถ์ ๊ฒฐ๊ณผ.
+
+| ํ๋ | ํ์
| ๊ธฐ๋ณธ๊ฐ |
+|------|------|--------|
+| `grade` | `str` | |
+| `summary` | `str` | |
+| `details` | `list` | [] |
+| `risks` | `list` | [] |
+| `opportunities` | `list` | [] |
+
+### Anomaly
+
+์ด์์น ํ์ง ๊ฒฐ๊ณผ.
+
+| ํ๋ | ํ์
| ๊ธฐ๋ณธ๊ฐ |
+|------|------|--------|
+| `severity` | `str` | |
+| `category` | `str` | |
+| `text` | `str` | |
+| `value` | `Optional` | None |
+
+### Flag
+
+๋ฆฌ์คํฌ/๊ธฐํ ํ๋๊ทธ.
+
+| ํ๋ | ํ์
| ๊ธฐ๋ณธ๊ฐ |
+|------|------|--------|
+| `level` | `str` | |
+| `category` | `str` | |
+| `text` | `str` | |
+
+### AnalysisResult
+
+์ข
ํฉ ๋ถ์ ๊ฒฐ๊ณผ.
+
+| ํ๋ | ํ์
| ๊ธฐ๋ณธ๊ฐ |
+|------|------|--------|
+| `corpName` | `str` | |
+| `stockCode` | `str` | |
+| `isFinancial` | `bool` | |
+| `performance` | `InsightResult` | |
+| `profitability` | `InsightResult` | |
+| `health` | `InsightResult` | |
+| `cashflow` | `InsightResult` | |
+| `governance` | `InsightResult` | |
+| `risk` | `InsightResult` | |
+| `opportunity` | `InsightResult` | |
+| `predictability` | `Optional` | None |
+| `uncertainty` | `Optional` | None |
+| `coreEarnings` | `Optional` | None |
+| `anomalies` | `list` | [] |
+| `distress` | `Optional` | None |
+| `summary` | `str` | |
+| `profile` | `str` | |
+
+### SectorInfo
+
+์นํฐ ๋ถ๋ฅ ๊ฒฐ๊ณผ.
+
+| ํ๋ | ํ์
| ๊ธฐ๋ณธ๊ฐ |
+|------|------|--------|
+| `sector` | `Sector` | |
+| `industryGroup` | `IndustryGroup` | |
+| `confidence` | `float` | |
+| `source` | `str` | |
+
+### SectorParams
+
+์นํฐ๋ณ ๋ฐธ๋ฅ์์ด์
ํ๋ผ๋ฏธํฐ.
+
+| ํ๋ | ํ์
| ๊ธฐ๋ณธ๊ฐ |
+|------|------|--------|
+| `discountRate` | `float` | |
+| `growthRate` | `float` | |
+| `perMultiple` | `float` | |
+| `pbrMultiple` | `float` | |
+| `evEbitdaMultiple` | `float` | |
+| `label` | `str` | |
+| `description` | `str` | |
+
+### RankInfo
+
+๋จ์ผ ์ข
๋ชฉ์ ๋ญํฌ ์ ๋ณด.
+
+| ํ๋ | ํ์
| ๊ธฐ๋ณธ๊ฐ |
+|------|------|--------|
+| `stockCode` | `str` | |
+| `corpName` | `str` | |
+| `sector` | `str` | |
+| `industryGroup` | `str` | |
+| `revenue` | `Optional` | None |
+| `totalAssets` | `Optional` | None |
+| `revenueGrowth3Y` | `Optional` | None |
+| `revenueRank` | `Optional` | None |
+| `revenueTotal` | `int` | 0 |
+| `revenueRankInSector` | `Optional` | None |
+| `revenueSectorTotal` | `int` | 0 |
+| `assetRank` | `Optional` | None |
+| `assetTotal` | `int` | 0 |
+| `assetRankInSector` | `Optional` | None |
+| `assetSectorTotal` | `int` | 0 |
+| `growthRank` | `Optional` | None |
+| `growthTotal` | `int` | 0 |
+| `growthRankInSector` | `Optional` | None |
+| `growthSectorTotal` | `int` | 0 |
+| `sizeClass` | `str` | |
diff --git a/src/dartlab/STATUS.md b/src/dartlab/STATUS.md
new file mode 100644
index 0000000000000000000000000000000000000000..d11e1e048e7af4df57c7833b73d10e76d4ee843d
--- /dev/null
+++ b/src/dartlab/STATUS.md
@@ -0,0 +1,81 @@
+# src/dartlab
+
+## ๊ฐ์
+DART ๊ณต์ ๋ฐ์ดํฐ ํ์ฉ ๋ผ์ด๋ธ๋ฌ๋ฆฌ. ์ข
๋ชฉ์ฝ๋ ๊ธฐ๋ฐ API.
+
+## ๊ตฌ์กฐ
+```
+dartlab/
+โโโ core/ # ๊ณตํต ๊ธฐ๋ฐ (๋ฐ์ดํฐ ๋ก๋ฉ, ๋ณด๊ณ ์ ์ ํ, ํ
์ด๋ธ ํ์ฑ, ์ฃผ์ ์ถ์ถ)
+โโโ finance/ # ์ฌ๋ฌด ๋ฐ์ดํฐ (36๊ฐ ๋ชจ๋)
+โ โโโ summary/ # ์์ฝ์ฌ๋ฌด์ ๋ณด ์๊ณ์ด
+โ โโโ statements/ # ์ฐ๊ฒฐ์ฌ๋ฌด์ ํ (BS, IS, CF)
+โ โโโ segment/ # ๋ถ๋ฌธ๋ณ ๋ณด๊ณ (์ฃผ์)
+โ โโโ affiliate/ # ๊ด๊ณ๊ธฐ์
ยท๊ณต๋๊ธฐ์
(์ฃผ์)
+โ โโโ costByNature/ # ๋น์ฉ์ ์ฑ๊ฒฉ๋ณ ๋ถ๋ฅ (์ฃผ์)
+โ โโโ tangibleAsset/ # ์ ํ์์ฐ (์ฃผ์)
+โ โโโ notesDetail/ # ์ฃผ์ ์์ธ (23๊ฐ ํค์๋)
+โ โโโ dividend/ # ๋ฐฐ๋น
+โ โโโ majorHolder/ # ์ต๋์ฃผ์ฃผยท์ฃผ์ฃผํํฉ
+โ โโโ shareCapital/ # ์ฃผ์ ํํฉ
+โ โโโ employee/ # ์ง์ ํํฉ
+โ โโโ subsidiary/ # ์ํ์ฌ ํฌ์
+โ โโโ bond/ # ์ฑ๋ฌด์ฆ๊ถ
+โ โโโ audit/ # ๊ฐ์ฌ์๊ฒฌยท๋ณด์
+โ โโโ executive/ # ์์ ํํฉ
+โ โโโ executivePay/ # ์์ ๋ณด์
+โ โโโ boardOfDirectors/ # ์ด์ฌํ
+โ โโโ capitalChange/ # ์๋ณธ๊ธ ๋ณ๋
+โ โโโ contingentLiability/ # ์ฐ๋ฐ๋ถ์ฑ
+โ โโโ internalControl/ # ๋ด๋ถํต์
+โ โโโ relatedPartyTx/ # ๊ด๊ณ์ ๊ฑฐ๋
+โ โโโ rnd/ # R&D ๋น์ฉ
+โ โโโ sanction/ # ์ ์ฌ ํํฉ
+โ โโโ affiliateGroup/ # ๊ณ์ด์ฌ ๋ชฉ๋ก
+โ โโโ fundraising/ # ์ฆ์/๊ฐ์
+โ โโโ productService/ # ์ฃผ์ ์ ํ/์๋น์ค
+โ โโโ salesOrder/ # ๋งค์ถ/์์ฃผ
+โ โโโ riskDerivative/ # ์ํ๊ด๋ฆฌ/ํ์๊ฑฐ๋
+โ โโโ articlesOfIncorporation/ # ์ ๊ด
+โ โโโ otherFinance/ # ๊ธฐํ ์ฌ๋ฌด
+โ โโโ companyHistory/ # ํ์ฌ ์ฐํ
+โ โโโ shareholderMeeting/ # ์ฃผ์ฃผ์ดํ
+โ โโโ auditSystem/ # ๊ฐ์ฌ์ ๋
+โ โโโ investmentInOther/ # ํ๋ฒ์ธ์ถ์
+โ โโโ companyOverviewDetail/ # ํ์ฌ๊ฐ์ ์์ธ
+โโโ disclosure/ # ๊ณต์ ์์ ํ (4๊ฐ ๋ชจ๋)
+โ โโโ business/ # ์ฌ์
์ ๋ด์ฉ
+โ โโโ companyOverview/ # ํ์ฌ์ ๊ฐ์ (์ ๋)
+โ โโโ mdna/ # MD&A
+โ โโโ rawMaterial/ # ์์ฌ๋ฃยท์ค๋น
+โโโ company.py # ํตํฉ ์ ๊ทผ (property ๊ธฐ๋ฐ, lazy + cache)
+โโโ notes.py # K-IFRS ์ฃผ์ ํตํฉ ์ ๊ทผ
+โโโ config.py # ์ ์ญ ์ค์ (verbose)
+```
+
+## API ์์ฝ
+```python
+import dartlab
+
+c = dartlab.Company("005930")
+c.index # ํ์ฌ ๊ตฌ์กฐ ์ธ๋ฑ์ค
+c.show("BS") # topic payload
+c.trace("dividend") # source trace
+c.BS # ์ฌ๋ฌด์ํํ DataFrame
+c.dividend # ๋ฐฐ๋น ์๊ณ์ด DataFrame
+
+import dartlab
+dartlab.verbose = False # ์งํ ํ์ ๋๊ธฐ
+```
+
+## ํํฉ
+- 2026-03-06: core/ + finance/summary/ ์ด๊ธฐ ๊ตฌ์ถ
+- 2026-03-06: finance/statements/, segment/, affiliate/ ์ถ๊ฐ
+- 2026-03-06: ์ ์ฒด ํจํค์ง ๊ฐ์ โ stockCode ์๊ทธ๋์ฒ, ํซ๋ผ์ธ ์ค๊ณ, API_SPEC.md
+- 2026-03-07: finance/ 11๊ฐ ๋ชจ๋ ์ถ๊ฐ (dividend~bond, costByNature)
+- 2026-03-07: disclosure/ 4๊ฐ ๋ชจ๋ ์ถ๊ฐ (business, companyOverview, mdna, rawMaterial)
+- 2026-03-07: finance/ ์ฃผ์ ๋ชจ๋ ์ถ๊ฐ (notesDetail, tangibleAsset)
+- 2026-03-07: finance/ 7๊ฐ ๋ชจ๋ ์ถ๊ฐ (audit~internalControl, rnd, sanction)
+- 2026-03-07: finance/ 7๊ฐ ๋ชจ๋ ์ถ๊ฐ (affiliateGroup~companyHistory, shareholderMeeting~investmentInOther, companyOverviewDetail)
+- 2026-03-08: analyze โ fsSummary ๋ฆฌ๋ค์ด๋ฐ, ๊ณ์ ๋ช
ํน์๋ฌธ์ ์ ๋ฆฌ
+- 2026-03-08: Company ์ฌ์ค๊ณ โ property ๊ธฐ๋ฐ ์ ๊ทผ, Notes ํตํฉ, all(), verbose ์ค์
diff --git a/src/dartlab/__init__.py b/src/dartlab/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..f38d0485a19eacff79437cbad88b027560bb3b8e
--- /dev/null
+++ b/src/dartlab/__init__.py
@@ -0,0 +1,1008 @@
+"""DART ๊ณต์ ๋ฐ์ดํฐ ํ์ฉ ๋ผ์ด๋ธ๋ฌ๋ฆฌ."""
+
+import sys
+from importlib.metadata import PackageNotFoundError
+from importlib.metadata import version as _pkg_version
+
+from dartlab import ai as llm
+from dartlab import config, core
+from dartlab.company import Company
+from dartlab.core.env import loadEnv as _loadEnv
+from dartlab.core.select import ChartResult, SelectResult
+from dartlab.gather.fred import Fred
+from dartlab.gather.listing import codeToName, fuzzySearch, getKindList, nameToCode, searchName
+from dartlab.providers.dart.company import Company as _DartEngineCompany
+from dartlab.providers.dart.openapi.dart import Dart, OpenDart
+from dartlab.providers.edgar.openapi.edgar import OpenEdgar
+from dartlab.review import Review
+
+# .env ์๋ ๋ก๋ โ API ํค ๋ฑ ํ๊ฒฝ๋ณ์
+_loadEnv()
+
+try:
+ __version__ = _pkg_version("dartlab")
+except PackageNotFoundError:
+ __version__ = "0.0.0"
+
+
+def search(keyword: str):
+ """์ข
๋ชฉ ๊ฒ์ (KR + US ํตํฉ).
+
+ Example::
+
+ import dartlab
+ dartlab.search("์ผ์ฑ์ ์")
+ dartlab.search("AAPL")
+ """
+ if any("\uac00" <= ch <= "\ud7a3" for ch in keyword):
+ return _DartEngineCompany.search(keyword)
+ if keyword.isascii() and keyword.isalpha():
+ try:
+ from dartlab.providers.edgar.company import Company as _US
+
+ return _US.search(keyword)
+ except (ImportError, AttributeError, NotImplementedError):
+ pass
+ return _DartEngineCompany.search(keyword)
+
+
+def listing(market: str | None = None):
+ """์ ์ฒด ์์ฅ๋ฒ์ธ ๋ชฉ๋ก.
+
+ Args:
+ market: "KR" ๋๋ "US". None์ด๋ฉด KR ๊ธฐ๋ณธ.
+
+ Example::
+
+ import dartlab
+ dartlab.listing() # KR ์ ์ฒด
+ dartlab.listing("US") # US ์ ์ฒด (ํฅํ)
+ """
+ if market and market.upper() == "US":
+ try:
+ from dartlab.providers.edgar.company import Company as _US
+
+ return _US.listing()
+ except (ImportError, AttributeError, NotImplementedError):
+ raise NotImplementedError("US listing์ ์์ง ์ง์๋์ง ์์ต๋๋ค")
+ return _DartEngineCompany.listing()
+
+
+def collect(
+ *codes: str,
+ categories: list[str] | None = None,
+ incremental: bool = True,
+) -> dict[str, dict[str, int]]:
+ """์ง์ ์ข
๋ชฉ DART ๋ฐ์ดํฐ ์์ง (OpenAPI). ๋ฉํฐํค ์ ๋ณ๋ ฌ.
+
+ Example::
+
+ import dartlab
+ dartlab.collect("005930") # ์ผ์ฑ์ ์ ์ ์ฒด
+ dartlab.collect("005930", "000660", categories=["finance"]) # ์ฌ๋ฌด๋ง
+ """
+ from dartlab.providers.dart.openapi.batch import batchCollect
+
+ return batchCollect(list(codes), categories=categories, incremental=incremental)
+
+
+def collectAll(
+ *,
+ categories: list[str] | None = None,
+ mode: str = "new",
+ maxWorkers: int | None = None,
+ incremental: bool = True,
+) -> dict[str, dict[str, int]]:
+ """์ ์ฒด ์์ฅ์ข
๋ชฉ DART ๋ฐ์ดํฐ ์์ง. DART_API_KEY(S) ํ์. ๋ฉํฐํค ์ ๋ณ๋ ฌ.
+
+ Example::
+
+ import dartlab
+ dartlab.collectAll() # ์ ์ฒด ๋ฏธ์์ง ์ข
๋ชฉ
+ dartlab.collectAll(categories=["finance"]) # ์ฌ๋ฌด๋ง
+ dartlab.collectAll(mode="all") # ๊ธฐ์์ง ํฌํจ ์ ์ฒด
+ """
+ from dartlab.providers.dart.openapi.batch import batchCollectAll
+
+ return batchCollectAll(
+ categories=categories,
+ mode=mode,
+ maxWorkers=maxWorkers,
+ incremental=incremental,
+ )
+
+
+def downloadAll(category: str = "finance", *, forceUpdate: bool = False) -> None:
+ """HuggingFace์์ ์ ์ฒด ์์ฅ ๋ฐ์ดํฐ๋ฅผ ๋ค์ด๋ก๋. pip install dartlab[hf] ํ์.
+
+ scanAccount, screen, digest ๋ฑ ์ ์ฌ(ๅ
จ็คพ) ๋ถ์ ๊ธฐ๋ฅ์ ๋ก์ปฌ์ ์ ์ฒด ๋ฐ์ดํฐ๊ฐ ์์ด์ผ ๋์ํฉ๋๋ค.
+ ์ด ํจ์๋ก ์นดํ
๊ณ ๋ฆฌ๋ณ ์ ์ฒด ๋ฐ์ดํฐ๋ฅผ ์ฌ์ ๋ค์ด๋ก๋ํ์ธ์.
+
+ Args:
+ category: "finance" (์ฌ๋ฌด ~600MB), "docs" (๊ณต์ ~8GB), "report" (๋ณด๊ณ ์ ~320MB).
+ forceUpdate: True๋ฉด ์ด๋ฏธ ์๋ ํ์ผ๋ ์ต์ ์ผ๋ก ๊ฐฑ์ .
+
+ Examples::
+
+ import dartlab
+ dartlab.downloadAll("finance") # ์ฌ๋ฌด ์ ์ฒด โ scanAccount/screen/benchmark ๋ฑ์ ํ์
+ dartlab.downloadAll("report") # ๋ณด๊ณ ์ ์ ์ฒด โ governance/workforce/capital/debt์ ํ์
+ dartlab.downloadAll("docs") # ๊ณต์ ์ ์ฒด โ digest/signal์ ํ์ (๋์ฉ๋ ~8GB)
+ """
+ from dartlab.core.dataLoader import downloadAll as _downloadAll
+
+ _downloadAll(category, forceUpdate=forceUpdate)
+
+
+def checkFreshness(stockCode: str, *, forceCheck: bool = False):
+ """์ข
๋ชฉ์ ๋ก์ปฌ ๋ฐ์ดํฐ๊ฐ ์ต์ ์ธ์ง DART API๋ก ํ์ธ.
+
+ Example::
+
+ import dartlab
+ result = dartlab.checkFreshness("005930")
+ result.isFresh # True/False
+ result.missingCount # ๋๋ฝ ๊ณต์ ์
+ """
+ from dartlab.providers.dart.openapi.freshness import (
+ checkFreshness as _check,
+ )
+
+ return _check(stockCode, forceCheck=forceCheck)
+
+
+def network():
+ """ํ๊ตญ ์์ฅ์ฌ ์ ์ฒด ๊ด๊ณ ์ง๋.
+
+ Example::
+
+ import dartlab
+ dartlab.network().show() # ๋ธ๋ผ์ฐ์ ์์ ์ ์ฒด ๋คํธ์ํฌ
+ """
+ from dartlab.market.network import build_graph, export_full
+ from dartlab.tools.network import render_network
+
+ data = build_graph()
+ full = export_full(data)
+ return render_network(
+ full["nodes"],
+ full["edges"],
+ "ํ๊ตญ ์์ฅ์ฌ ๊ด๊ณ ๋คํธ์ํฌ",
+ )
+
+
+def governance():
+ """ํ๊ตญ ์์ฅ์ฌ ์ ์ฒด ์ง๋ฐฐ๊ตฌ์กฐ ์ค์บ.
+
+ Example::
+
+ import dartlab
+ df = dartlab.governance()
+ """
+ from dartlab.market.governance import scan_governance
+
+ return scan_governance()
+
+
+def workforce():
+ """ํ๊ตญ ์์ฅ์ฌ ์ ์ฒด ์ธ๋ ฅ/๊ธ์ฌ ์ค์บ.
+
+ Example::
+
+ import dartlab
+ df = dartlab.workforce()
+ """
+ from dartlab.market.workforce import scan_workforce
+
+ return scan_workforce()
+
+
+def capital():
+ """ํ๊ตญ ์์ฅ์ฌ ์ ์ฒด ์ฃผ์ฃผํ์ ์ค์บ.
+
+ Example::
+
+ import dartlab
+ df = dartlab.capital()
+ """
+ from dartlab.market.capital import scan_capital
+
+ return scan_capital()
+
+
+def debt():
+ """ํ๊ตญ ์์ฅ์ฌ ์ ์ฒด ๋ถ์ฑ ๊ตฌ์กฐ ์ค์บ.
+
+ Example::
+
+ import dartlab
+ df = dartlab.debt()
+ """
+ from dartlab.market.debt import scan_debt
+
+ return scan_debt()
+
+
+def screen(preset: str = "๊ฐ์น์ฃผ"):
+ """์์ฅ ์คํฌ๋ฆฌ๋ โ ํ๋ฆฌ์
๊ธฐ๋ฐ ์ข
๋ชฉ ํํฐ.
+
+ Args:
+ preset: ํ๋ฆฌ์
์ด๋ฆ ("๊ฐ์น์ฃผ", "์ฑ์ฅ์ฃผ", "ํด์ด๋ผ์ด๋", "ํ๊ธ๋ถ์",
+ "๊ณ ์ํ", "์๋ณธ์ ์", "์ํ๊ณ ์์ต", "๋ํ์์ ").
+
+ Example::
+
+ import dartlab
+ df = dartlab.screen("๊ฐ์น์ฃผ") # ROEโฅ10, ๋ถ์ฑโค100 ๋ฑ
+ df = dartlab.screen("๊ณ ์ํ") # ๋ถ์ฑโฅ200, ICR<3
+ """
+ from dartlab.analysis.comparative.rank.screen import screen as _screen
+
+ return _screen(preset)
+
+
+def benchmark():
+ """์นํฐ๋ณ ํต์ฌ ๋น์จ ๋ฒค์น๋งํฌ (P10, median, P90).
+
+ Example::
+
+ import dartlab
+ bm = dartlab.benchmark() # ์นํฐ ร ๋น์จ ์ ์ ๋ฒ์
+ """
+ from dartlab.analysis.comparative.rank.screen import benchmark as _benchmark
+
+ return _benchmark()
+
+
+def signal(keyword: str | None = None):
+ """์์ ํ ๊ณต์ ์์ฅ ์๊ทธ๋ โ ํค์๋ ํธ๋ ๋ ํ์ง.
+
+ Args:
+ keyword: ํน์ ํค์๋๋ง ํํฐ. None์ด๋ฉด ์ ์ฒด 48๊ฐ ํค์๋.
+
+ Example::
+
+ import dartlab
+ df = dartlab.signal() # ์ ์ฒด ํค์๋ ํธ๋ ๋
+ df = dartlab.signal("AI") # AI ํค์๋ ์ฐ๋๋ณ ์ถ์ด
+ """
+ from dartlab.market.signal import scan_signal
+
+ return scan_signal(keyword)
+
+
+def news(query: str, *, market: str = "KR", days: int = 30):
+ """๊ธฐ์
๋ด์ค ์์ง.
+
+ Args:
+ query: ๊ธฐ์
๋ช
๋๋ ํฐ์ปค.
+ market: "KR" ๋๋ "US".
+ days: ์ต๊ทผ N์ผ.
+
+ Example::
+
+ import dartlab
+ dartlab.news("์ผ์ฑ์ ์")
+ dartlab.news("AAPL", market="US")
+ """
+ from dartlab.gather import getDefaultGather
+
+ return getDefaultGather().news(query, market=market, days=days)
+
+
+def price(
+ stockCode: str, *, market: str = "KR", start: str | None = None, end: str | None = None, snapshot: bool = False
+):
+ """์ฃผ๊ฐ ์๊ณ์ด (๊ธฐ๋ณธ 1๋
OHLCV) ๋๋ ์ค๋
์ท.
+
+ Example::
+
+ import dartlab
+ dartlab.price("005930") # 1๋
OHLCV ์๊ณ์ด
+ dartlab.price("005930", start="2020-01-01") # ๊ธฐ๊ฐ ์ง์
+ dartlab.price("005930", snapshot=True) # ํ์ฌ๊ฐ ์ค๋
์ท
+ """
+ from dartlab.gather import getDefaultGather
+
+ return getDefaultGather().price(stockCode, market=market, start=start, end=end, snapshot=snapshot)
+
+
+def consensus(stockCode: str, *, market: str = "KR"):
+ """์ปจ์ผ์์ค โ ๋ชฉํ๊ฐ, ํฌ์์๊ฒฌ.
+
+ Example::
+
+ import dartlab
+ dartlab.consensus("005930")
+ dartlab.consensus("AAPL", market="US")
+ """
+ from dartlab.gather import getDefaultGather
+
+ return getDefaultGather().consensus(stockCode, market=market)
+
+
+def flow(stockCode: str, *, market: str = "KR"):
+ """์๊ธ ์๊ณ์ด โ ์ธ๊ตญ์ธ/๊ธฐ๊ด ๋งค๋งค ๋ํฅ (KR ์ ์ฉ).
+
+ Example::
+
+ import dartlab
+ dartlab.flow("005930")
+ # [{"date": "20260325", "foreignNet": -6165053, "institutionNet": 2908773, ...}, ...]
+ """
+ from dartlab.gather import getDefaultGather
+
+ return getDefaultGather().flow(stockCode, market=market)
+
+
+def macro(market: str = "KR", indicator: str | None = None, *, start: str | None = None, end: str | None = None):
+ """๊ฑฐ์ ์งํ ์๊ณ์ด โ ECOS(KR) / FRED(US).
+
+ ์ธ์ ์์ผ๋ฉด ์นดํ๋ก๊ทธ ์ ์ฒด ์งํ๋ฅผ wide DataFrame์ผ๋ก ๋ฐํ.
+
+ Example::
+
+ import dartlab
+ dartlab.macro() # KR ์ ์ฒด ์งํ wide DF (22๊ฐ)
+ dartlab.macro("US") # US ์ ์ฒด ์งํ wide DF (50๊ฐ)
+ dartlab.macro("CPI") # CPI (์๋ KR ๊ฐ์ง)
+ dartlab.macro("FEDFUNDS") # ์ฐ๋ฐฉ๊ธฐ๊ธ๊ธ๋ฆฌ (์๋ US ๊ฐ์ง)
+ dartlab.macro("KR", "CPI") # ๋ช
์์ KR + CPI
+ dartlab.macro("US", "SP500") # ๋ช
์์ US + S&P500
+ """
+ from dartlab.gather import getDefaultGather
+
+ return getDefaultGather().macro(market, indicator, start=start, end=end)
+
+
+def crossBorderPeers(stockCode: str, *, topK: int = 5):
+ """ํ๊ตญ ์ข
๋ชฉ์ ๊ธ๋ก๋ฒ ํผ์ด ์ถ์ฒ (WICSโGICS ๋งคํ).
+
+ Args:
+ stockCode: ํ๊ตญ ์ข
๋ชฉ์ฝ๋.
+ topK: ๋ฐํํ ํผ์ด ์.
+
+ Example::
+
+ import dartlab
+ dartlab.crossBorderPeers("005930") # โ ["AAPL", "MSFT", ...]
+ """
+ from dartlab.analysis.comparative.peer.discover import crossBorderPeers as _cb
+
+ return _cb(stockCode, topK=topK)
+
+
+def setup(provider: str | None = None):
+ """AI provider ์ค์ ์๋ด + ์ธํฐ๋ํฐ๋ธ ์ค์ .
+
+ Args:
+ provider: ํน์ provider ์ค์ . None์ด๋ฉด ์ ์ฒด ํํฉ.
+
+ Example::
+
+ import dartlab
+ dartlab.setup() # ์ ์ฒด provider ํํฉ
+ dartlab.setup("chatgpt") # ChatGPT OAuth ๋ธ๋ผ์ฐ์ ๋ก๊ทธ์ธ
+ dartlab.setup("openai") # OpenAI API ํค ์ค์
+ dartlab.setup("ollama") # Ollama ์ค์น ์๋ด
+ """
+ from dartlab.core.ai.guide import (
+ provider_guide,
+ providers_status,
+ resolve_alias,
+ )
+
+ if provider is None:
+ print(providers_status())
+ return
+
+ provider = resolve_alias(provider)
+
+ if provider == "oauth-codex":
+ _setup_oauth_interactive()
+ elif provider == "openai":
+ _setup_openai_interactive()
+ else:
+ print(provider_guide(provider))
+
+
+def _setup_oauth_interactive():
+ """๋
ธํธ๋ถ/CLI์์ ChatGPT OAuth ๋ธ๋ผ์ฐ์ ๋ก๊ทธ์ธ."""
+ try:
+ from dartlab.ai.providers.support.oauth_token import is_authenticated
+
+ if is_authenticated():
+ print("\n โ ChatGPT OAuth ์ด๋ฏธ ์ธ์ฆ๋์ด ์์ต๋๋ค.")
+ print(' ์ฌ์ธ์ฆ: dartlab.setup("chatgpt") # ์ฌ์คํํ๋ฉด ๊ฐฑ์ \n')
+ return
+ except ImportError:
+ pass
+
+ try:
+ from dartlab.cli.commands.setup import _do_oauth_login
+
+ _do_oauth_login()
+ except ImportError:
+ print("\n ChatGPT OAuth ๋ธ๋ผ์ฐ์ ๋ก๊ทธ์ธ:")
+ print(" CLI์์ ์คํ: dartlab setup oauth-codex\n")
+
+
+def _setup_openai_interactive():
+ """๋
ธํธ๋ถ์์ OpenAI API ํค ์ธ๋ผ์ธ ์ค์ ."""
+ import os
+
+ from dartlab.core.ai.guide import provider_guide
+
+ existing_key = os.environ.get("OPENAI_API_KEY")
+ if existing_key:
+ print(f"\n โ OPENAI_API_KEY ํ๊ฒฝ๋ณ์๊ฐ ์ค์ ๋์ด ์์ต๋๋ค. (sk-...{existing_key[-4:]})\n")
+ return
+
+ print(provider_guide("openai"))
+ print()
+
+ try:
+ from getpass import getpass
+
+ key = getpass(" API ํค ์
๋ ฅ (Enter๋ก ๊ฑด๋๋ฐ๊ธฐ): ").strip()
+ if key:
+ llm.configure(provider="openai", api_key=key)
+ print("\n โ OpenAI API ํค๊ฐ ์ค์ ๋์์ต๋๋ค.\n")
+ else:
+ print("\n ๊ฑด๋๋ฐ์์ต๋๋ค.\n")
+ except (EOFError, KeyboardInterrupt):
+ print("\n ๊ฑด๋๋ฐ์์ต๋๋ค.\n")
+
+
+def _auto_stream(gen) -> str:
+ """Generator๋ฅผ ์๋นํ๋ฉด์ stdout์ ์คํธ๋ฆฌ๋ฐ ์ถ๋ ฅ, ์ ์ฒด ํ
์คํธ ๋ฐํ."""
+ import sys
+
+ chunks: list[str] = []
+ for chunk in gen:
+ chunks.append(chunk)
+ sys.stdout.write(chunk)
+ sys.stdout.flush()
+ sys.stdout.write("\n")
+ sys.stdout.flush()
+ return "".join(chunks)
+
+
+def ask(
+ *args: str,
+ include: list[str] | None = None,
+ exclude: list[str] | None = None,
+ provider: str | None = None,
+ model: str | None = None,
+ stream: bool = True,
+ raw: bool = False,
+ reflect: bool = False,
+ pattern: str | None = None,
+ **kwargs,
+):
+ """LLM์๊ฒ ๊ธฐ์
์ ๋ํด ์ง๋ฌธ.
+
+ Args:
+ *args: ์์ฐ์ด ์ง๋ฌธ (1๊ฐ) ๋๋ (์ข
๋ชฉ, ์ง๋ฌธ) 2๊ฐ.
+ provider: LLM provider ("openai", "codex", "oauth-codex", "ollama").
+ model: ๋ชจ๋ธ override.
+ stream: True๋ฉด ์คํธ๋ฆฌ๋ฐ ์ถ๋ ฅ (๊ธฐ๋ณธ๊ฐ). False๋ฉด ์กฐ์ฉํ ์ ์ฒด ํ
์คํธ ๋ฐํ.
+ raw: True๋ฉด Generator๋ฅผ ์ง์ ๋ฐํ (์ปค์คํ
UI์ฉ).
+ include: ํฌํจํ ๋ฐ์ดํฐ ๋ชจ๋.
+ exclude: ์ ์ธํ ๋ฐ์ดํฐ ๋ชจ๋.
+ reflect: True๋ฉด ๋ต๋ณ ์์ฒด ๊ฒ์ฆ (1ํ reflection).
+
+ Returns:
+ str: ์ ์ฒด ๋ต๋ณ ํ
์คํธ. (raw=True์ผ ๋๋ง Generator[str])
+
+ Example::
+
+ import dartlab
+ dartlab.llm.configure(provider="openai", api_key="sk-...")
+
+ # ํธ์ถํ๋ฉด ์คํธ๋ฆฌ๋ฐ ์ถ๋ ฅ + ์ ์ฒด ํ
์คํธ ๋ฐํ
+ answer = dartlab.ask("์ผ์ฑ์ ์ ์ฌ๋ฌด๊ฑด์ ์ฑ ๋ถ์ํด์ค")
+
+ # provider + model ์ง์
+ answer = dartlab.ask("์ผ์ฑ์ ์ ๋ถ์", provider="openai", model="gpt-4o")
+
+ # (์ข
๋ชฉ, ์ง๋ฌธ) ๋ถ๋ฆฌ
+ answer = dartlab.ask("005930", "์์
์ด์ต๋ฅ ์ถ์ธ๋?")
+
+ # ์กฐ์ฉํ ์ ์ฒด ํ
์คํธ๋ง (๋ฐฐ์น์ฉ)
+ answer = dartlab.ask("์ผ์ฑ์ ์ ๋ถ์", stream=False)
+
+ # Generator ์ง์ ์ ์ด (์ปค์คํ
UI์ฉ)
+ for chunk in dartlab.ask("์ผ์ฑ์ ์ ๋ถ์", raw=True):
+ custom_process(chunk)
+ """
+ from dartlab.ai.runtime.standalone import ask as _ask
+
+ # provider ๋ฏธ์ง์ ์ auto-detect
+ if provider is None:
+ from dartlab.core.ai.detect import auto_detect_provider
+
+ detected = auto_detect_provider()
+ if detected is None:
+ from dartlab.core.ai.guide import no_provider_message
+
+ msg = no_provider_message()
+ print(msg)
+ raise RuntimeError("AI provider๊ฐ ์ค์ ๋์ง ์์์ต๋๋ค. dartlab.setup()์ ์คํํ์ธ์.")
+ provider = detected
+
+ if len(args) == 2:
+ company = Company(args[0])
+ question = args[1]
+ elif len(args) == 1:
+ from dartlab.core.resolve import resolve_from_text
+
+ company, question = resolve_from_text(args[0])
+ if company is None:
+ raise ValueError(
+ f"์ข
๋ชฉ์ ์ฐพ์ ์ ์์ต๋๋ค: '{args[0]}'\n"
+ "์ข
๋ชฉ๋ช
๋๋ ์ข
๋ชฉ์ฝ๋๋ฅผ ํฌํจํด ์ฃผ์ธ์.\n"
+ "์: dartlab.ask('์ผ์ฑ์ ์ ์ฌ๋ฌด๊ฑด์ ์ฑ ๋ถ์ํด์ค')"
+ )
+ elif len(args) == 0:
+ raise TypeError("์ง๋ฌธ์ ์
๋ ฅํด ์ฃผ์ธ์. ์: dartlab.ask('์ผ์ฑ์ ์ ๋ถ์ํด์ค')")
+ else:
+ raise TypeError(f"์ธ์๋ 1~2๊ฐ๋ง ํ์ฉ๋ฉ๋๋ค (๋ฐ์ ์: {len(args)})")
+
+ if raw:
+ return _ask(
+ company,
+ question,
+ include=include,
+ exclude=exclude,
+ provider=provider,
+ model=model,
+ stream=stream,
+ reflect=reflect,
+ pattern=pattern,
+ **kwargs,
+ )
+
+ if not stream:
+ return _ask(
+ company,
+ question,
+ include=include,
+ exclude=exclude,
+ provider=provider,
+ model=model,
+ stream=False,
+ reflect=reflect,
+ pattern=pattern,
+ **kwargs,
+ )
+
+ gen = _ask(
+ company,
+ question,
+ include=include,
+ exclude=exclude,
+ provider=provider,
+ model=model,
+ stream=True,
+ reflect=reflect,
+ pattern=pattern,
+ **kwargs,
+ )
+ return _auto_stream(gen)
+
+
+def chat(
+ codeOrName: str,
+ question: str,
+ *,
+ provider: str | None = None,
+ model: str | None = None,
+ max_turns: int = 5,
+ on_tool_call=None,
+ on_tool_result=None,
+ **kwargs,
+) -> str:
+ """์์ด์ ํธ ๋ชจ๋: LLM์ด ๋๊ตฌ๋ฅผ ์ ํํ์ฌ ์ฌํ ๋ถ์.
+
+ Args:
+ codeOrName: ์ข
๋ชฉ์ฝ๋, ํ์ฌ๋ช
, ๋๋ US ticker.
+ question: ์ง๋ฌธ ํ
์คํธ.
+ provider: LLM provider.
+ model: ๋ชจ๋ธ override.
+ max_turns: ์ต๋ ๋๊ตฌ ํธ์ถ ๋ฐ๋ณต ํ์.
+
+ Example::
+
+ import dartlab
+ dartlab.chat("005930", "๋ฐฐ๋น ์ถ์ธ๋ฅผ ๋ถ์ํ๊ณ ์ด์ ์งํ๋ฅผ ์ฐพ์์ค")
+ """
+ from dartlab.ai.runtime.standalone import chat as _chat
+
+ company = Company(codeOrName)
+ return _chat(
+ company,
+ question,
+ provider=provider,
+ model=model,
+ max_turns=max_turns,
+ on_tool_call=on_tool_call,
+ on_tool_result=on_tool_result,
+ **kwargs,
+ )
+
+
+def plugins():
+ """๋ก๋๋ ํ๋ฌ๊ทธ์ธ ๋ชฉ๋ก ๋ฐํ.
+
+ Example::
+
+ import dartlab
+ dartlab.plugins() # [PluginMeta(name="esg-scores", ...)]
+ """
+ from dartlab.core.plugins import discover, get_loaded_plugins
+
+ discover()
+ return get_loaded_plugins()
+
+
+def reload_plugins():
+ """ํ๋ฌ๊ทธ์ธ ์ฌ์ค์บ โ pip install ํ ์ฌ์์ ์์ด ์ฆ์ ์ธ์.
+
+ Example::
+
+ # 1. ์ ํ๋ฌ๊ทธ์ธ ์ค์น
+ # !uv pip install dartlab-plugin-esg
+
+ # 2. ์ฌ์ค์บ
+ dartlab.reload_plugins()
+
+ # 3. ์ฆ์ ์ฌ์ฉ
+ dartlab.Company("005930").show("esgScore")
+ """
+ from dartlab.core.plugins import rediscover
+
+ return rediscover()
+
+
+def audit(codeOrName: str):
+ """๊ฐ์ฌ Red Flag ๋ถ์.
+
+ Example::
+
+ import dartlab
+ dartlab.audit("005930")
+ """
+ c = Company(codeOrName)
+ from dartlab.analysis.financial.insight.pipeline import analyzeAudit
+
+ return analyzeAudit(c)
+
+
+def forecast(codeOrName: str, *, horizon: int = 3):
+ """๋งค์ถ ์์๋ธ ์์ธก.
+
+ Example::
+
+ import dartlab
+ dartlab.forecast("005930")
+ """
+ c = Company(codeOrName)
+ from dartlab.analysis.forecast.revenueForecast import forecastRevenue
+
+ ts = c.finance.timeseries
+ if ts is None:
+ return None
+ series = ts[0] if isinstance(ts, tuple) else ts
+ currency = getattr(c, "currency", "KRW")
+ return forecastRevenue(
+ series,
+ stockCode=getattr(c, "stockCode", None),
+ sectorKey=getattr(c, "sectorKey", None),
+ market=getattr(c, "market", "KR"),
+ horizon=horizon,
+ currency=currency,
+ )
+
+
+def valuation(codeOrName: str, *, shares: int | None = None):
+ """์ข
ํฉ ๋ฐธ๋ฅ์์ด์
(DCF + DDM + ์๋๊ฐ์น).
+
+ Example::
+
+ import dartlab
+ dartlab.valuation("005930")
+ """
+ c = Company(codeOrName)
+ from dartlab.analysis.valuation.valuation import fullValuation
+
+ ts = c.finance.timeseries
+ if ts is None:
+ return None
+ series = ts[0] if isinstance(ts, tuple) else ts
+ currency = getattr(c, "currency", "KRW")
+ if shares is None:
+ profile = getattr(c, "profile", None)
+ if profile:
+ shares = getattr(profile, "sharesOutstanding", None)
+ if shares:
+ shares = int(shares)
+ return fullValuation(series, shares=shares, currency=currency)
+
+
+def insights(codeOrName: str):
+ """7์์ญ ๋ฑ๊ธ ๋ถ์.
+
+ Example::
+
+ import dartlab
+ dartlab.insights("005930")
+ """
+ c = Company(codeOrName)
+ from dartlab.analysis.financial.insight import analyze
+
+ return analyze(c.stockCode, company=c)
+
+
+def simulation(codeOrName: str, *, scenarios: list[str] | None = None):
+ """๊ฒฝ์ ์๋๋ฆฌ์ค ์๋ฎฌ๋ ์ด์
.
+
+ Example::
+
+ import dartlab
+ dartlab.simulation("005930")
+ """
+ c = Company(codeOrName)
+ from dartlab.analysis.forecast.simulation import simulateAllScenarios
+
+ ts = c.finance.timeseries
+ if ts is None:
+ return None
+ series = ts[0] if isinstance(ts, tuple) else ts
+ return simulateAllScenarios(
+ series,
+ sectorKey=getattr(c, "sectorKey", None),
+ scenarios=scenarios,
+ )
+
+
+def research(codeOrName: str, *, sections: list[str] | None = None, includeMarket: bool = True):
+ """์ข
ํฉ ๊ธฐ์
๋ถ์ ๋ฆฌํฌํธ.
+
+ Example::
+
+ import dartlab
+ dartlab.research("005930")
+ """
+ c = Company(codeOrName)
+ from dartlab.analysis.financial.research import generateResearch
+
+ return generateResearch(c, sections=sections, includeMarket=includeMarket)
+
+
+def groupHealth():
+ """๊ทธ๋ฃน์ฌ ๊ฑด์ ์ฑ ๋ถ์ โ ๋คํธ์ํฌ ร ์ฌ๋ฌด๋น์จ ๊ต์ฐจ.
+
+ Returns:
+ (summary, weakLinks) ํํ.
+
+ Example::
+
+ import dartlab
+ summary, weakLinks = dartlab.groupHealth()
+ """
+ from dartlab.market.network.health import groupHealth as _groupHealth
+
+ return _groupHealth()
+
+
+def scanAccount(
+ snakeId: str,
+ *,
+ market: str = "dart",
+ sjDiv: str | None = None,
+ fsPref: str = "CFS",
+ annual: bool = False,
+):
+ """์ ์ข
๋ชฉ ๋จ์ผ ๊ณ์ ์๊ณ์ด.
+
+ Args:
+ snakeId: ๊ณ์ ์๋ณ์. ์๋ฌธ("sales") ๋๋ ํ๊ธ("๋งค์ถ์ก") ๋ชจ๋ ๊ฐ๋ฅ.
+ market: "dart" (ํ๊ตญ, ๊ธฐ๋ณธ) ๋๋ "edgar" (๋ฏธ๊ตญ).
+ sjDiv: ์ฌ๋ฌด์ ํ ๊ตฌ๋ถ ("IS", "BS", "CF"). None์ด๋ฉด ์๋ ๊ฒฐ์ . (dart๋ง)
+ fsPref: ์ฐ๊ฒฐ/๋ณ๋ ์ฐ์ ์์ ("CFS"=์ฐ๊ฒฐ ์ฐ์ , "OFS"=๋ณ๋ ์ฐ์ ). (dart๋ง)
+ annual: True๋ฉด ์ฐ๊ฐ (๊ธฐ๋ณธ False=๋ถ๊ธฐ๋ณ standalone).
+
+ Example::
+
+ import dartlab
+ dartlab.scanAccount("๋งค์ถ์ก") # DART ๋ถ๊ธฐ๋ณ
+ dartlab.scanAccount("๋งค์ถ์ก", annual=True) # DART ์ฐ๊ฐ
+ dartlab.scanAccount("sales", market="edgar") # EDGAR ๋ถ๊ธฐ๋ณ
+ dartlab.scanAccount("total_assets", market="edgar", annual=True)
+ """
+ if market == "edgar":
+ from dartlab.providers.edgar.finance.scanAccount import scanAccount as _edgarScan
+
+ return _edgarScan(snakeId, annual=annual)
+
+ from dartlab.providers.dart.finance.scanAccount import scanAccount as _scan
+
+ return _scan(snakeId, sjDiv=sjDiv, fsPref=fsPref, annual=annual)
+
+
+def scanRatio(
+ ratioName: str,
+ *,
+ market: str = "dart",
+ fsPref: str = "CFS",
+ annual: bool = False,
+):
+ """์ ์ข
๋ชฉ ๋จ์ผ ์ฌ๋ฌด๋น์จ ์๊ณ์ด.
+
+ Args:
+ ratioName: ๋น์จ ์๋ณ์ ("roe", "operatingMargin", "debtRatio" ๋ฑ).
+ market: "dart" (ํ๊ตญ, ๊ธฐ๋ณธ) ๋๋ "edgar" (๋ฏธ๊ตญ).
+ fsPref: ์ฐ๊ฒฐ/๋ณ๋ ์ฐ์ ์์. (dart๋ง)
+ annual: True๋ฉด ์ฐ๊ฐ (๊ธฐ๋ณธ False=๋ถ๊ธฐ๋ณ).
+
+ Example::
+
+ import dartlab
+ dartlab.scanRatio("roe") # DART ๋ถ๊ธฐ๋ณ
+ dartlab.scanRatio("operatingMargin", annual=True) # DART ์ฐ๊ฐ
+ dartlab.scanRatio("roe", market="edgar", annual=True) # EDGAR ์ฐ๊ฐ
+ """
+ if market == "edgar":
+ from dartlab.providers.edgar.finance.scanAccount import scanRatio as _edgarRatio
+
+ return _edgarRatio(ratioName, annual=annual)
+
+ from dartlab.providers.dart.finance.scanAccount import scanRatio as _ratio
+
+ return _ratio(ratioName, fsPref=fsPref, annual=annual)
+
+
+def scanRatioList():
+ """์ฌ์ฉ ๊ฐ๋ฅํ ๋น์จ ๋ชฉ๋ก.
+
+ Example::
+
+ import dartlab
+ dartlab.scanRatioList()
+ """
+ from dartlab.providers.dart.finance.scanAccount import scanRatioList as _list
+
+ return _list()
+
+
+def digest(
+ *,
+ sector: str | None = None,
+ top_n: int = 20,
+ format: str = "dataframe",
+ stock_codes: list[str] | None = None,
+ verbose: bool = False,
+):
+ """์์ฅ ์ ์ฒด ๊ณต์ ๋ณํ ๋ค์ด์ ์คํธ.
+
+ ๋ก์ปฌ์ ๋ค์ด๋ก๋๋ docs ๋ฐ์ดํฐ๋ฅผ ์ํํ๋ฉฐ ์ค์๋ ๋์ ๋ณํ๋ฅผ ์ง๊ณํ๋ค.
+
+ Args:
+ sector: ์นํฐ ํํฐ (์: "๋ฐ๋์ฒด"). None์ด๋ฉด ์ ์ฒด.
+ top_n: ์์ N๊ฐ.
+ format: "dataframe", "markdown", "json".
+ stock_codes: ์ง์ ์ข
๋ชฉ์ฝ๋ ๋ชฉ๋ก ์ง์ .
+ verbose: ์งํ ์ํฉ ์ถ๋ ฅ.
+
+ Example::
+
+ import dartlab
+ dartlab.digest() # ์ ์ฒด ์์ฅ
+ dartlab.digest(sector="๋ฐ๋์ฒด") # ์นํฐ๋ณ
+ dartlab.digest(format="markdown") # ๋งํฌ๋ค์ด ์ถ๋ ฅ
+ """
+ from dartlab.analysis.accounting.watch.digest import build_digest
+ from dartlab.analysis.accounting.watch.scanner import scan_market
+
+ scan_df = scan_market(
+ sector=sector,
+ top_n=top_n,
+ stock_codes=stock_codes,
+ verbose=verbose,
+ )
+
+ if format == "dataframe":
+ return scan_df
+
+ title = f"{sector} ์นํฐ ๋ณํ ๋ค์ด์ ์คํธ" if sector else None
+ return build_digest(scan_df, format=format, title=title, top_n=top_n)
+
+
+class _Module(sys.modules[__name__].__class__):
+ """dartlab.verbose / dartlab.dataDir / dartlab.chart|table|text ํ๋ก์."""
+
+ @property
+ def verbose(self):
+ return config.verbose
+
+ @verbose.setter
+ def verbose(self, value):
+ config.verbose = value
+
+ @property
+ def dataDir(self):
+ return config.dataDir
+
+ @dataDir.setter
+ def dataDir(self, value):
+ config.dataDir = str(value)
+
+ def __getattr__(self, name):
+ if name in ("chart", "table", "text"):
+ import importlib
+
+ mod = importlib.import_module(f"dartlab.tools.{name}")
+ setattr(self, name, mod)
+ return mod
+ raise AttributeError(f"module 'dartlab' has no attribute {name!r}")
+
+
+sys.modules[__name__].__class__ = _Module
+
+
+__all__ = [
+ "Company",
+ "Dart",
+ "Fred",
+ "OpenDart",
+ "OpenEdgar",
+ "config",
+ "core",
+ "engines",
+ "llm",
+ "ask",
+ "chat",
+ "setup",
+ "search",
+ "listing",
+ "collect",
+ "collectAll",
+ "downloadAll",
+ "network",
+ "screen",
+ "benchmark",
+ "signal",
+ "news",
+ "crossBorderPeers",
+ "audit",
+ "forecast",
+ "valuation",
+ "insights",
+ "simulation",
+ "governance",
+ "workforce",
+ "capital",
+ "debt",
+ "groupHealth",
+ "research",
+ "digest",
+ "scanAccount",
+ "scanRatio",
+ "scanRatioList",
+ "plugins",
+ "reload_plugins",
+ "verbose",
+ "dataDir",
+ "getKindList",
+ "codeToName",
+ "nameToCode",
+ "searchName",
+ "fuzzySearch",
+ "chart",
+ "table",
+ "text",
+ "Review",
+ "SelectResult",
+ "ChartResult",
+]
diff --git a/src/dartlab/ai/DEV.md b/src/dartlab/ai/DEV.md
new file mode 100644
index 0000000000000000000000000000000000000000..50aa3452cd55234d9f87cae2ed46d189686b5f4a
--- /dev/null
+++ b/src/dartlab/ai/DEV.md
@@ -0,0 +1,224 @@
+# AI Engine Development Guide
+
+## Source Of Truth
+
+- ๋ฐ์ดํฐ source-of-truth: `src/dartlab/core/registry.py`
+- AI capability source-of-truth: `src/dartlab/core/capabilities.py`
+
+## ํ์ฌ ๊ตฌ์กฐ ์์น
+
+- `core.analyze()`๊ฐ AI ์ค์ผ์คํธ๋ ์ด์
์ ๋จ์ผ ์ง์
์ ์ด๋ค.
+- `tools/registry.py`๋ capability ์ ์๋ฅผ runtime์ ๋ฐ์ธ๋ฉํ๋ ๋ ์ด์ด๋ค.
+- `server/streaming.py`, `mcp/__init__.py`, UI SSE client๋ capability ๊ฒฐ๊ณผ๋ฅผ ์๋นํ๋ adapter๋ค.
+- Svelte UI๋ source-of-truth๊ฐ ์๋๋ผ render sink๋ค.
+- OpenDART ์ต๊ทผ ๊ณต์๋ชฉ๋ก retrieval๋ `core.analyze()`์์ company ์ ๋ฌด์ ๋ฌด๊ดํ๊ฒ ๊ฐ์ ๊ฒฝ๋ก๋ก ํฉ๋ฅํ๋ค.
+
+## ํจํค์ง ๊ตฌ์กฐ
+
+- `runtime/`
+ - `core.py`: ์ค์ผ์คํธ๋ ์ดํฐ
+ - `events.py`: canonical/legacy ์ด๋ฒคํธ ๊ณ์ฝ
+ - `pipeline.py`: pre-compute pipeline
+ - `post_processing.py`: navigate/validation/auto-artifact ํ์ฒ๋ฆฌ
+ - `standalone.py`: public ask/chat bridge
+ - `validation.py`: ์ซ์ ๊ฒ์ฆ
+- `conversation/`
+ - `dialogue.py`, `history.py`, `intent.py`, `focus.py`, `prompts.py`
+ - `suggestions.py`: ํ์ฌ ์ํ ๊ธฐ๋ฐ ์ถ์ฒ ์ง๋ฌธ ์์ฑ
+ - `data_ready.py`: docs/finance/report ๊ฐ์ฉ์ฑ ์์ฝ
+- `context/`
+ - `builder.py`: structured context build
+ - `snapshot.py`: headline snapshot
+ - `company_adapter.py`: facade mismatch adapter
+ - `dartOpenapi.py`: OpenDART filing intent ํ์ฑ + recent filing context
+- `tools/`
+ - `registry.py`: tool/capability binding (`useSuperTools` ํ๋๊ทธ๋ก ๋ชจ๋ ์ ํ)
+ - `runtime.py`: tool execution runtime
+ - `selector.py`: capability ๊ธฐ๋ฐ ๋๊ตฌ ์ ํ + Super Tool ์ ์ฉ prompt ๋ถ๊ธฐ
+ - `plugin.py`: external tool plugin bridge
+ - `coding.py`: coding runtime bridge
+ - `recipes.py`: ์ง๋ฌธ ์ ํ๋ณ ์ ํ ๋ถ์ ๋ ์ํผ
+ - `routeHint.py`: ํค์๋โ๋๊ตฌ ๋งคํ (Super Tool ๋ชจ๋์์ deprecated)
+ - `superTools/`: **7๊ฐ Super Tool dispatcher** (explore/finance/analyze/market/openapi/system/chart)
+ - `defaults/`: ๊ธฐ์กด 101๊ฐ ๋๊ตฌ ๋ฑ๋ก (๋ ๊ฑฐ์ ๋ชจ๋์์ ์ฌ์ฉ)
+- `providers/support/`
+ - `codex_cli.py`, `cli_setup.py`, `ollama_setup.py`, `oauth_token.py`
+ - provider ๊ตฌํ์ด ์ง์ ์ฐ๋ CLI/OAuth ๋ณด์กฐ ๊ณ์ธต
+
+๋ฃจํธ shim ๋ชจ๋(`core.py`, `tools_registry.py`, `dialogue.py` ๋ฑ)์ ์ ๊ฑฐ๋์๋ค. ์ ์ฝ๋๋ ๋ฐ๋์ ํ์ ํจํค์ง ๊ฒฝ๋ก(`runtime/`, `conversation/`, `context/`, `tools/`, `providers/support/`)๋ฅผ ์ง์ importํ๋ค.
+
+## Super Tool ์ํคํ
์ฒ (2026-03-25)
+
+101๊ฐ ๋๊ตฌ๋ฅผ 7๊ฐ Super Tool dispatcher๋ก ํตํฉ. ollama(์ํ ๋ชจ๋ธ)์์ ์๋ ํ์ฑํ.
+
+### ๋ชจ๋ธ ์๊ตฌ์ฌํญ
+- **์ต์**: tool calling ์ง์ + 14B ํ๋ผ๋ฏธํฐ ์ด์ (์: qwen3:14b, llama3.1:8b-instruct)
+- **๊ถ์ฅ**: GPT-4o, Claude Sonnet ์ด์ โ tool calling + ํ๊ตญ์ด + ๋ณตํฉ ํ๋ผ๋ฏธํฐ ๋์ ์ฒ๋ฆฌ
+- **๋ถ์ ํฉ**: 8B ์ดํ ์ํ ๋ชจ๋ธ (qwen3:4b/8b) โ action dispatch ํจํด์ ์ดํดํ์ง ๋ชปํจ, hallucination ๋ค๋ฐ
+- ์คํ 009 ๊ฒ์ฆ ๊ฒฐ๊ณผ: qwen3:4b tool ์ ํ๋ 33%, qwen3:8b 0%. ์ํ ๋ชจ๋ธ์ tool calling AI ๋ถ์์ ์ฌ์ฉ ๋ถ๊ฐ.
+
+### ํ์ฑํ ์กฐ๊ฑด
+- **๋ชจ๋ provider์์ Super Tool ๊ธฐ๋ณธ ํ์ฑํ** (`_useSuperTools = True`)
+- `build_tool_runtime(company, useSuperTools=False)`๋ก ๋ ๊ฑฐ์ ๋ชจ๋ ์๋ ์ ํ ๊ฐ๋ฅ
+- Route Hint(`routeHint.py`)๋ deprecated โ Super Tool enum description์ด ๋์ฒด
+
+### 7๊ฐ Super Tool
+| Tool | ํตํฉ ๋์ | action enum |
+|------|----------|-------------|
+| `explore` | show_topic, list_topics, trace, diff, info, filings, search | 7 |
+| `finance` | get_data, list_modules, ratios, growth, yoy, anomalies, report, search | 8 |
+| `analyze` | insight, sector, rank, esg, valuation, changes, audit | 7 |
+| `market` | price, consensus, history, screen | 4 |
+| `openapi` | dartCall, searchFilings, capabilities | 3 |
+| `system` | spec, features, searchCompany, dataStatus, suggest | 5 |
+| `chart` | navigate, chart | 2 |
+
+### ๋์ enum
+- `explore.target`: company.topics์์ ์ถ์ถ (์ผ์ฑ์ ์ ๊ธฐ์ค 53๊ฐ) + ํ๊ตญ์ด ๋ผ๋ฒจ
+- `finance.module`: scan_available_modules์์ ์ถ์ถ (9๊ฐ) + ํ๊ตญ์ด ๋ผ๋ฒจ
+- `finance.apiType`: company.report.availableApiTypes์์ ์ถ์ถ (24๊ฐ) + ํ๊ตญ์ด ๋ผ๋ฒจ
+- enum description์ `topicLabels.py`์ ํ๊ตญ์ด ๋ผ๋ฒจ๊ณผ aliases ํฌํจ
+
+### ํ๊ตญ์ด ๋ผ๋ฒจ source of truth
+- `core/topicLabels.py`: 70๊ฐ topic ร ํ๊ตญ์ด ๋ผ๋ฒจ + ๊ฒ์ aliases
+- UI์ `topicLabels.js`์ ๋์ผ ๋งคํ + AI์ฉ aliases ์ถ๊ฐ
+
+## UI Action ๊ณ์ฝ
+
+- canonical payload๋ `UiAction`์ด๋ค.
+- render payload๋ `ViewSpec` + `WidgetSpec` schema๋ฅผ ๊ธฐ์ค์ผ๋ก ํ๋ค.
+- widget id(`chart`, `comparison`, `insight_dashboard`, `table`)๋ UI widget registry์ ๋ฑ๋ก๋ ๊ฒ๋ง ์ฌ์ฉํ๋ค.
+- ํ์ฉ action:
+ - `navigate`
+ - `render`
+ - `update`
+ - `toast`
+- canonical SSE UI ์ด๋ฒคํธ๋ `ui_action` ํ๋๋ง ์ ์งํ๋ค.
+- auto artifact๋ ๋ณ๋ chart ์ด๋ฒคํธ๊ฐ ์๋๋ผ canonical `render` UI action์ผ๋ก ์ฃผ์
ํ๋ค.
+- Svelte ์ธก AI bridge/helper๋ `src/dartlab/ui/src/lib/ai/`์ ๋๋ค. `App.svelte`๋ provider/profile ๋๊ธฐํ์ stream wiring๋ง ์ฐ๊ฒฐํ๋ shell๋ก ์ ์งํ๋ค.
+
+## Provider Surface
+
+- ๊ณต์ GPT ๊ตฌ๋
๊ณ์ ๊ฒฝ๋ก๋ ๋ ๊ฐ๋ค.
+ - `codex`: Codex CLI ๋ก๊ทธ์ธ ๊ธฐ๋ฐ
+ - `oauth-codex`: ChatGPT OAuth ์ง์ ์ฐ๊ฒฐ ๊ธฐ๋ฐ
+- ๊ณต๊ฐ provider surface๋ `codex`, `oauth-codex`, `openai`, `ollama`, `custom`๋ง ์ ์งํ๋ค.
+- `claude` provider๋ public surface์์ ์ ๊ฑฐ๋์๋ค. ๋จ์ Claude ๊ด๋ จ ์ฝ๋๋ legacy/internal ์ฉ๋๋ก๋ง ์ทจ๊ธํ๋ค.
+- provider alias(`chatgpt`, `chatgpt-oauth`)๋ ๋ ์ด์ ๊ณต๊ฐ/ํธํ surface์ ๋์ง ์๋๋ค.
+- ask/CLI/server/UI๋ ๊ฐ์ provider ๋ฌธ์์ด์ ๊ณต์ ํด์ผ ํ๋ฉฐ, ์ GPT ๊ฒฝ๋ก๋ฅผ ์ถ๊ฐํ ๋๋ ์ด ๋ฌธ์์ `core/ai/providers.py`, `server/api/ai.py`, `ui/src/App.svelte`, `cli/context.py`๋ฅผ ๊ฐ์ด ๊ฐฑ์ ํ๋ค.
+
+## Shared Profile
+
+- AI ์ค์ source-of-truth๋ `~/.dartlab/ai_profile.json`๊ณผ ๊ณตํต secret store๋ค.
+- `dartlab.llm.configure()`๋ ๋ฉ๋ชจ๋ฆฌ ์ ์ฉ setter๊ฐ ์๋๋ผ shared profile writer๋ค.
+- profile schema๋ `defaultProvider + roles(analysis, summary, coding, ui_control)` ๊ตฌ์กฐ๋ค.
+- UI๋ provider/model์ localStorage์ ์ ์ฅํ์ง ์๊ณ `/api/ai/profile`๊ณผ `/api/ai/profile/events`๋ฅผ ํตํด ๋๊ธฐํํ๋ค.
+- API key๋ profile JSON์ ์ ์ฅํ์ง ์๊ณ secret store์๋ง ์ ์ฅํ๋ค.
+- OAuth ํ ํฐ๋ legacy `oauth_token.json` ๋์ ๊ณตํต secret store๋ก ์ด๋ํ๋ค.
+- Ollama preload/probe๋ ์ ํ provider๊ฐ `ollama`์ผ ๋๋ง ์ ๊ทน์ ์ผ๋ก ์ํํ๋ค. ๋ค๋ฅธ provider๊ฐ ์ ํ๋ ์ํ์์๋ ์ํ ์กฐํ๋ lazy probe๊ฐ ๊ธฐ๋ณธ์ด๋ค.
+- OpenDART ํค๋ provider secret store๋ก ํก์ํ์ง ์๊ณ ํ๋ก์ ํธ `.env`๋ฅผ source-of-truth๋ก ์ ์งํ๋ค.
+
+## Company Adapter ์์น
+
+- AI ๋ ์ด์ด๋ `company.ratios` ๊ฐ์ facade surface๋ฅผ ์ง์ ์ ๋ขฐํ์ง ์๋๋ค.
+- headline ratio / ratio series๋ `src/dartlab/ai/context/company_adapter.py`๋ก๋ง ์ ๊ทผํ๋ค.
+- facade์ ์์ง surface mismatch๋ฅผ ๋ฐ๊ฒฌํ๋ฉด AI ์ฝ๋ ๊ณณ๊ณณ์์ ๋ถ๊ธฐํ์ง ๋ง๊ณ adapter์ ํก์ํ๋ค.
+
+## Ask Context ์ ์ฑ
+
+- ๊ธฐ๋ณธ `ask`๋ cheap-first๋ค. ์ง๋ฌธ์ ๋ง๋ ์ต์ source๋ง ์ฝ๊ณ , `docs/finance/report` ์ ์ฒด ์ ๋ก๋ฉ์ ๊ธ์งํ๋ค.
+- ์ผ๋ฐ `ask`์ ๊ธฐ๋ณธ context tier๋ `focused`๋ค. `full` tier๋ `report_mode=True`์ผ ๋๋ง ํ์ฉํ๋ค.
+- tool-capable provider(`openai`, `ollama`, `custom`)๋ง `use_tools=True`์ผ ๋ `skeleton` tier๋ฅผ ์ฌ์ฉํ๋ค.
+- `oauth-codex` ๊ธฐ๋ณธ ask๋ ๋ ์ด์ `full`๋ก ๋จ์ด์ง์ง ์๋๋ค.
+- `auto diff`๋ `full` tier์์๋ง ์๋ ๊ณ์ฐํ๋ค. ๊ธฐ๋ณธ ask์์๋ `company.diff()`๋ฅผ ์ ํ ํธ์ถํ์ง ์๋๋ค.
+- ์ง๋ฌธ ํด์์ route-first๊ฐ ์๋๋ผ **candidate-module-first**๋ค. ๋จผ์ `sections / notes / report / finance` ํ๋ณด๋ฅผ ๋์์ ๋ชจ์ผ๊ณ , ์ค์ ์กด์ฌํ๋ ๋ชจ๋๋ง ์ปจํ
์คํธ์ ์ฃ๋๋ค.
+- `costByNature`, `rnd`, `segments`์ฒ๋ผ sections topic์ด ์๋์ด๋ direct/notes ๊ฒฝ๋ก๋ก ์กด์ฌํ๋ฉด `ask`๊ฐ ์ฐ์ ํ์ํ๋ค.
+- ์ผ๋ฐ `ask`์์ ํฌํจ๋ ๋ชจ๋์ด ์์ผ๋ฉด `"๋ฐ์ดํฐ ์์"`์ด๋ผ๊ณ ๋ตํ๋ฉด ์คํจ๋ก ๋ณธ๋ค. false-unavailable ๋ฐฉ์ง๊ฐ ๊ธฐ๋ณธ ๊ณ์ฝ์ด๋ค.
+- tool calling์ด ๋นํ์ฑํ๋ ask์์๋ `show_topic()` ๊ฐ์ ํธ์ถ ๊ณํ์ ๋ฌธ์ฅ์ผ๋ก ์ถ๋ ฅํ์ง ์๋๋ค. ์ด๋ฏธ ์ ๊ณต๋ ์ปจํ
์คํธ๋ง์ผ๋ก ๋ฐ๋ก ๋ตํ๊ณ , ๋ชจํธํ ๋๋ง ํ ๋ฌธ์ฅ ํ์ธ ์ง๋ฌธ์ ํ๋ค.
+- **๋ถ๊ธฐ ์ง๋ฌธ ์ ์ฑ
**: "๋ถ๊ธฐ", "๋ถ๊ธฐ๋ณ", "quarterly", "QoQ", "์ ๋ถ๊ธฐ" ๋ฑ ๋ถ๊ธฐ ํค์๋๊ฐ ๊ฐ์ง๋๋ฉด:
+ - route๋ฅผ `hybrid`๋ก ์ ํํ์ฌ sections + finance ์์ชฝ ๋ชจ๋ ํฌํจํ๋ค.
+ - `company.timeseries`์์ IS/CF ๋ถ๊ธฐ๋ณ standalone ๋ฐ์ดํฐ๋ฅผ ์ต๊ทผ 8๋ถ๊ธฐ๋ง ์ถ์ถํ์ฌ context์ ์ฃผ์
ํ๋ค.
+ - `fsSummary`๋ฅผ sections exclude ๋ชฉ๋ก์์ ์ผ์ ํด์ ํ์ฌ ๋ถ๊ธฐ ์์ฝ๋ ํฌํจํ๋ค.
+ - response_contract์ ๋ถ๊ธฐ ๋ฐ์ดํฐ ํ์ฉ ์ง์๋ฅผ ์ถ๊ฐํ๋ค.
+- **finance route sections ๋ณด์กฐ ์ ์ฑ
**: route=finance์ผ ๋๋ `businessStatus`, `businessOverview` ์ค ์กด์ฌํ๋ topic 1๊ฐ๋ฅผ ๊ฒฝ๋ outline์ผ๋ก ์ฃผ์
ํ๋ค. "์ ์ด์ต๋ฅ ์ด ๋ณํ๋์ง" ๊ฐ์ ๋งฅ๋ฝ์ LLM์ด ์ค๋ช
ํ ์ ์๊ฒ ํ๋ค.
+- **context budget**: focused=10000, full=16000. ๋ถ๊ธฐ ๋ฐ์ดํฐ + sections ๋ณด์กฐ๋ฅผ ์์ฉํ ์ ์๋ ํฌ๊ธฐ.
+
+## Persona Eval ๋ฃจํ
+
+- ask ์ฅ๊ธฐ ๊ฐ์ ์ ๊ธฐ๋ณธ ๋จ์๋ **์ค์ฌ์ฉ ๋ก๊ทธ๊ฐ ์๋๋ผ curated ์ง๋ฌธ ์ธํธ replay**๋ค.
+- source-of-truth๋ `src/dartlab/ai/eval/personaCases.json`์ด๋ค.
+- ์ฌ๋ ๊ฒ์ ์ด๋ ฅ source-of-truth๋ `src/dartlab/ai/eval/reviewLog/.jsonl`์ด๋ค.
+- persona ์ถ์ ์ต์ `assistant`, `data_manager`, `operator`, `installer`, `research_gather`, `accountant`, `business_owner`, `investor`, `analyst`๋ฅผ ์ ์งํ๋ค.
+- ๊ฐ case๋ ์ง๋ฌธ๋ง ์ ์ฅํ์ง ์๋๋ค.
+ - `expectedRoute`
+ - `expectedModules`
+ - `mustInclude`
+ - `mustNotSay`
+ - `forbiddenUiTerms`
+ - `allowedClarification`
+ - `expectedFollowups`
+ - `groundTruthFacts`
+- ์ ask ์คํจ๋ ๋ฐ๋ก ํ๋กฌํํธ hotfix๋ก ๋ฎ์ง ์๊ณ ๋จผ์ ์๋๋ก ๋ถ๋ฅํ๋ค.
+ - `routing_failure`
+ - `retrieval_failure`
+ - `false_unavailable`
+ - `generation_failure`
+ - `ui_wording_failure`
+ - `data_gap`
+ - `runtime_error`
+- replay runner source-of-truth๋ `src/dartlab/ai/eval/replayRunner.py`๋ค.
+- ์ค์ replay๋ฅผ ๊ฒํ ํ ๋๋ ๊ฒฐ๊ณผ๋ง ๋จ๊ธฐ์ง ์๊ณ ๋ฐ๋์ `reviewedAt / effectiveness / improvementActions / notes`๋ฅผ ๊ฐ์ด ๋จ๊ธด๋ค.
+- review log๋ persona๋ณ๋ก ๋ถ๋ฆฌํ๋ค.
+ - `reviewLog/accountant.jsonl`
+ - `reviewLog/investor.jsonl`
+ - `reviewLog/analyst.jsonl`
+- ๋ค์ ํ์ฐจ replay๋ ๊ฐ์ persona ํ์ผ์ ์ด์ด์ ๋ณด๊ณ , `ํจ๊ณผ์ ์ด์๋์ง`์ `์ด๋ฒ ๊ฐ์ ์ผ๋ก ์ค์ฌ์ผ ํ failure type`์ ๊ฐ์ด ์ ๋๋ค.
+- ๊ฐ์ ๋ฃจํ๋ ํญ์ `์ง๋ฌธ ์ธํธ ์ถ๊ฐ โ replay โ failure taxonomy ํ์ธ โ AI fix vs DartLab core fix ๋ถ๋ฆฌ โ ํ๊ท ์ฌ์คํ` ์์๋ก ๊ฐ๋ค.
+- "์ฅ๊ธฐ ํ์ต"์ ๋ชจ๋ธ ํ์ต์ด ์๋๋ผ ์ด replay/backlog ๋ฃจํ๋ฅผ ๋ปํ๋ค.
+- replay์์ ๋ฐ๋ณต ์คํจํ ์ง๋ฌธ ๋ฌถ์์ generic ambiguity๋ก ๋จ๊ธฐ์ง ๋ง๊ณ ๊ฐ์ ๊ท์น์ผ๋ก ์น๊ฒฉํ๋ค.
+ - `๋ถ์ค ์งํ`๋ฅ ์ง๋ฌธ โ `finance` route ๊ณ ์
+ - `์์
์ด์ต๋ฅ + ๋น์ฉ ๊ตฌ์กฐ + ์ฌ์
๋ณํ` โ `IS + costByNature + businessOverview/productService` ๊ฐ์ hybrid, clarification ๊ธ์ง
+ - `์ต๊ทผ ๊ณต์ + ์ฌ์
๊ตฌ์กฐ ๋ณํ` โ `disclosureChanges`์ `businessOverview/productService`๋ฅผ ๊ฐ์ด ํ์
+- **groundTruthFacts๋ ์๋ ํ๋์ฝ๋ฉ์ด ์๋๋ผ `truthHarvester`๋ก ์๋ ์์ฑํ๋ค.**
+ - `scripts/harvestEvalTruth.py`๋ก ๋ฐฐ์น ์คํ, `--severity critical,high`๋ถํฐ ์ฐ์ ์ฑ์
+ - finance ์์ง์์ IS/BS/CF ํต์ฌ ๊ณ์ + ratios๋ฅผ ์๋ ์ถ์ถ
+ - `truthAsOf` ๋ ์ง๋ก ๋ฐ์ดํฐ ์์ ์ ๊ธฐ๋ก
+- **๊ฒฐ์ ๋ก ์ ๊ฒ์ฆ(๋ผ์ฐํ
/๋ชจ๋)์ LLM ํธ์ถ ์์ด CI์์ ๋งค ์ปค๋ฐ ๊ฒ์ฆํ๋ค.**
+ - `tests/test_eval_deterministic.py` โ personaCases.json์ expectedRoute/๋ชจ๋/๊ตฌ์กฐ ๋ฌด๊ฒฐ์ฑ ๊ฒ์ฆ
+ - personaCases์ ์ผ์ด์ค๋ฅผ ์ถ๊ฐํ๋ฉด ์๋์ผ๋ก ๊ฒฐ์ ๋ก ์ ํ
์คํธ๋ ์คํ๋จ
+ - `@pytest.mark.unit` โ `test-lock.sh` 1๋จ๊ณ์์ ์คํ
+- **๋ฐฐ์น replay๋ `scripts/runEvalBatch.py`๋ก ์๋ํํ๋ค.**
+ - `--provider`, `--model`, `--severity`, `--persona`, `--compare latest` ํํฐ
+ - ๊ฒฐ๊ณผ๋ `eval/batchResults/` JSONL๋ก ์ ์ฅ, ์ด์ ๋ฐฐ์น์ ํ๊ท ๋น๊ต ์ง์
+- **replaySuite()๋ Company ์บ์ 3๊ฐ ์ ํ์ผ๋ก OOM์ ๋ฐฉ์งํ๋ค.**
+ - 4๋ฒ์งธ Company ๋ก๋ ์ ๊ฐ์ฅ ์ค๋๋ ์บ์ ์ ๊ฑฐ + `gc.collect()`
+
+## User Language ์์น
+
+- UI ๊ธฐ๋ณธ surface์์๋ internal module/method ์ด๋ฆ์ ์ง์ ๋
ธ์ถํ์ง ์๋๋ค.
+- ask ๋ด๋ถ debug/meta์ eval/log์์๋ raw module ์ด๋ฆ์ ์ ์งํด๋ ๋๋ค.
+- runtime `meta` / `done`์๋ raw `includedModules`์ ํจ๊ป ์ฌ์ฉ์์ฉ `includedEvidence` label์ ๊ฐ์ด ์ค์ด ๋ณด๋ธ๋ค.
+- UI evidence panel, transparency badges, modal title์ ์ฌ์ฉ์์ฉ evidence label์ ์ฐ์ ์ฌ์ฉํ๋ค.
+- tool ์ด๋ฆ๋ UI์์๋ ์ฌ์ฉ์ ํ๋ ๊ธฐ์ค ๋ฌธ๊ตฌ๋ก ๋ณด์ฌ์ค๋ค.
+ - ์: `list_live_filings` โ `์ค์๊ฐ ๊ณต์ ๋ชฉ๋ก ์กฐํ`
+ - ์: `get_data` โ `์ฌ๋ฌดยท๊ณต์ ๋ฐ์ดํฐ ์กฐํ`
+- ask ๋ณธ๋ฌธ๋ ๊ธฐ๋ณธ์ ์ผ๋ก ์ฌ์ฉ์ ์ธ์ด๋ฅผ ์ด๋ค.
+ - `IS/BS/CF/ratios/TTM` โ `์์ต๊ณ์ฐ์/์ฌ๋ฌด์ํํ/ํ๊ธํ๋ฆํ/์ฌ๋ฌด๋น์จ/์ต๊ทผ 4๋ถ๊ธฐ ํฉ์ฐ`
+ - `costByNature/businessOverview/productService` โ `์ฑ๊ฒฉ๋ณ ๋น์ฉ ๋ถ๋ฅ/์ฌ์
์ ๊ฐ์/์ ํยท์๋น์ค`
+ - `topic/period/source` โ `ํญ๋ชฉ/์์ /์ถ์ฒ`
+
+## Sections First Retrieval
+
+- `sections`๋ ๊ธฐ๋ณธ์ ์ผ๋ก โ๋ณธ๋ฌธ ๋ฉ์ด๋ฆฌโ๊ฐ ์๋๋ผ โretrieval indexโ๋ก ์ด๋ค.
+- sections ๊ณ์ด ์ง๋ฌธ์ `topics() -> outline(topic) -> contextSlices -> raw docs sections block` ์์๋ก ์ขํ๋ค.
+- `contextSlices`๊ฐ ask์ ๊ธฐ๋ณธ evidence layer๋ค. `outline(topic)`๋ ์ธ๋ฑ์ค/์ปค๋ฒ๋ฆฌ์ง ํ์ธ์ฉ์ด๊ณ , ์ค์ ๊ทผ๊ฑฐ ๋ฌธ์ฅ์ `contextSlices`์์ ๋จผ์ ํ์ํ๋ค.
+- `retrievalBlocks/raw sections`๋ `contextSlices`๋ง์ผ๋ก ๊ทผ๊ฑฐ๊ฐ ๋ถ์กฑํ ๋๋ง ์ถ๊ฐ๋ก ์ฐ๋ค.
+- ์ผ๋ฐ ์ฌ๋ฌด ์ง๋ฌธ์์๋ `sections`, `report`, `insights`, `change summary`๋ฅผ ์๋์ผ๋ก ๋ถ์ด์ง ์๋๋ค.
+- ๋ฐฐ๋น/์ง์/์ต๋์ฃผ์ฃผ/๊ฐ์ฌ์ฒ๋ผ ๋ช
์์ ์ธ report ์ง๋ฌธ์์๋ง report pivot/context๋ฅผ ์ฌ๋ฆฐ๋ค.
+
+## Follow-up Continuity
+
+- ํ์ ํด์ด `์ต๊ทผ 5๊ฐ๋
`, `๊ทธ๋ผ`, `์ด์ด์`์ฒ๋ผ ์งง์ ๊ธฐ๊ฐ/์ฐ์ ์ง๋ฌธ์ด๋ฉด ์ง์ assistant `includedModules`๋ฅผ ์ด์ด๋ฐ์ ๊ฐ์ ๋ถ์ ์ถ์ ์ ์งํ๋ค.
+- ์ด ์์์ ์๋ฌด ์ง๋ฌธ์๋ ์ ์ฉํ์ง ์๊ณ `follow_up` ๋ชจ๋ + ๊ธฐ๊ฐ/์ฐ์ ํํธ๊ฐ ์์ ๋๋ง ์ ์ฉํ๋ค.
+- ๊ฐํ direct intent ์ง๋ฌธ(`์ฑ๊ฒฉ๋ณ ๋น์ฉ`, `์ธ๊ฑด๋น`, `๊ฐ๊ฐ์๊ฐ`, `๋ฌผ๋ฅ๋น`)์ clarification ์์ด ๋ฐ๋ก `costByNature`๋ฅผ ํ์ํ๋ค.
+- `costByNature` ๊ฐ์ ๋ค๊ธฐ๊ฐ direct module์ด ํฌํจ๋๋ฉด ๊ธฐ๊ฐ์ด ๋น์ด ์์ด๋ ์ต์ ์์ ๊ณผ ์ต๊ทผ ์ถ์ธ๋ฅผ ๋จผ์ ๋ตํ๋ค. ์ฐ๋ ๊ธฐ์ค์ ๋จผ์ ๋ค์ ๋ฌป์ง ์๋๋ค.
diff --git a/src/dartlab/ai/STATUS.md b/src/dartlab/ai/STATUS.md
new file mode 100644
index 0000000000000000000000000000000000000000..b0d7785f851cb9f3778de3b8f328ed1e53963e6a
--- /dev/null
+++ b/src/dartlab/ai/STATUS.md
@@ -0,0 +1,200 @@
+# AI Engine โ Provider ํํฉ ๋ฐ ์ ์ง๋ณด์ ์ฒดํฌ๋ฆฌ์คํธ
+
+## Provider ๋ชฉ๋ก (7๊ฐ)
+
+| Provider | ํ์ผ | ์ธ์ฆ | ๊ธฐ๋ณธ ๋ชจ๋ธ | ์์ ์ฑ |
+|----------|------|------|----------|--------|
+| `openai` | openai_compat.py | API Key | gpt-4o | **์์ ** โ ๊ณต์ SDK |
+| `ollama` | ollama.py | ์์ (localhost) | llama3.1 | **์์ ** โ ๋ก์ปฌ |
+| `custom` | openai_compat.py | API Key | gpt-4o | **์์ ** โ OpenAI ํธํ |
+| `chatgpt` | providers/__init__.py alias | `codex`๋ก ์ ๊ทํ | codex mirror | **ํธํ์ฉ alias** โ ๊ณต๊ฐ surface ๋น๋
ธ์ถ |
+| `codex` | codex.py | CLI ์ธ์
| CLI config ๋๋ gpt-4.1 | **๊ณต์ ๊ฒฝ๋ก ์ฐ์ ** โ Codex CLI ์์กด |
+| `oauth-codex` | oauthCodex.py | ChatGPT OAuth | gpt-5.4 | **๊ณต๊ฐ ๊ฒฝ๋ก** โ ๋น๊ณต์ backend API ์์กด |
+| `claude-code` | claude_code.py | CLI ์ธ์
| sonnet | **๋ณด๋ฅ์ค** โ OAuth ์ง์ ์ ๋น๊ณต๊ฐ |
+
+---
+
+## ํ์ฌ ๊ณต๊ฐ ๊ฒฝ๋ก
+
+- ChatGPT ๊ตฌ๋
๊ณ์ ๊ฒฝ๋ก๋ 2๊ฐ๋ค.
+ - `codex`: Codex CLI ๋ก๊ทธ์ธ ๊ธฐ๋ฐ
+ - `oauth-codex`: ChatGPT OAuth ์ง์ ์ฐ๊ฒฐ ๊ธฐ๋ฐ
+- ๊ณต๊ฐ provider surface๋ `codex`, `oauth-codex`, `openai`, `ollama`, `custom`๋ง ์ ์งํ๋ค.
+- `claude` provider๋ public surface์์ ์ ๊ฑฐ๋์๊ณ legacy/internal ์ฝ๋๋ก๋ง ๋จ์ ์๋ค.
+- `chatgpt`๋ ๊ธฐ์กด ์ค์ /ํธํ์ฑ ๋๋ฌธ์ ๋ด๋ถ alias๋ก๋ง ๋จ์ ์์ผ๋ฉฐ ์ค์ ๊ตฌํ์ `codex`๋ก ์ ๊ทํ๋๋ค.
+- `chatgpt-oauth`๋ ๋ด๋ถ/ํธํ alias๋ก๋ง ๋จ์ ์์ผ๋ฉฐ ์ค์ ๊ตฌํ์ `oauth-codex`๋ก ์ ๊ทํ๋๋ค.
+
+## Tool Runtime ๊ธฐ๋ฐ
+
+- ๋๊ตฌ ๋ฑ๋ก/์คํ์ `tool_runtime.py`์ `ToolRuntime`์ผ๋ก ๋ถ๋ฆฌ๋๊ธฐ ์์ํ๋ค.
+- `tools_registry.py`๋ ํ์ฌ ํธํ ๋ํผ ์ญํ ์ ํ๋ฉฐ, ์ธ์
๋ณ/์์ด์ ํธ๋ณ isolated runtime ์์ฑ์ด ๊ฐ๋ฅํ๋ค.
+- coding executor๋ `coding_runtime.py`๋ก ๋ถ๋ฆฌ๋๊ธฐ ์์ํ๊ณ , backend registry๋ฅผ ํตํด ๊ด๋ฆฌํ๋ค.
+- ํ์ค ์ฝ๋ ์์
์ง์
์ ์ `run_coding_task`์ด๋ฉฐ `run_codex_task`๋ Codex compatibility alias๋ก ์ ์งํ๋ค.
+- ๋ค์ ๋จ๊ณ๋ Codex ์ธ backend๋ฅผ ์ด runtime ๋ค์ ์ถ๊ฐํ๋, ๊ณต๊ฐ provider surface์๋ ๋ถ๋ฆฌํ๋ ๊ฒ์ด๋ค.
+
+## ChatGPT OAuth Provider โ ํต์ฌ ๋ฆฌ์คํฌ
+
+### ์ ์ทจ์ฝํ๊ฐ
+
+`oauth-codex` provider๋ **OpenAI ๋น๊ณต์ ๋ด๋ถ API** (`chatgpt.com/backend-api/codex/responses`)๋ฅผ ์ฌ์ฉํ๋ค.
+๊ณต์ OpenAI API (`api.openai.com`)๊ฐ ์๋๋ฏ๋ก **์๊ณ ์์ด ๋ณ๊ฒฝ/์ฐจ๋จ๋ ์ ์๋ค**.
+
+### ์ ๊ธฐ ์ฒดํฌ ํญ๋ชฉ
+
+**1. ์๋ํฌ์ธํธ ๋ณ๊ฒฝ**
+- ํ์ฌ: `https://chatgpt.com/backend-api/codex/responses`
+- ํ์ผ: [oauthCodex.py](providers/oauthCodex.py) `CODEX_API_BASE`, `CODEX_RESPONSES_PATH`
+- OpenAI๊ฐ URL ๊ฒฝ๋ก๋ฅผ ๋ณ๊ฒฝํ๋ฉด ์ฆ์ 404/403 ๋ฐ์
+- ํ์ธ๋ฒ: `dartlab status` ์คํ โ chatgpt available ํ์ธ
+
+**2. OAuth ์ธ์ฆ ํ๋ผ๋ฏธํฐ**
+- Client ID: `app_EMoamEEZ73f0CkXaXp7hrann` (Codex CLI์์ ์ถ์ถ)
+- ํ์ผ: [oauthToken.py](../oauthToken.py) `CHATGPT_CLIENT_ID`
+- OpenAI๊ฐ client_id๋ฅผ ๊ฐฑ์ ํ๊ฑฐ๋ revokeํ๋ฉด ๋ก๊ทธ์ธ ๋ถ๊ฐ
+- ํ์ธ๋ฒ: OAuth ๋ก๊ทธ์ธ ์๋ โ "invalid_client" ์๋ฌ ์ฌ๋ถ
+
+**3. SSE ์ด๋ฒคํธ ํ์
**
+- ํ์ฌ ํ์ฑํ๋ ํ์
3๊ฐ:
+ - `response.output_text.delta` โ ํ
์คํธ ์ฒญํฌ
+ - `response.content_part.delta` โ ์ปจํ
์ธ ์ฒญํฌ
+ - `response.output_item.done` โ ์์ดํ
์๋ฃ
+- ํ์ผ: [oauthCodex.py](providers/oauthCodex.py) `stream()`, `_parse_sse_response()`
+- OpenAI๊ฐ ์ด๋ฒคํธ ์คํค๋ง๋ฅผ ๋ณ๊ฒฝํ๋ฉด ์๋ต์ด ๋น ๋ฌธ์์ด๋ก ๋์์ด
+- ํ์ธ๋ฒ: ์คํธ๋ฆฌ๋ฐ ์๋ต์ด ๋์ฐฉํ๋๋ฐ ํ
์คํธ๊ฐ ๋น์ด์์ผ๋ฉด ์ด๋ฒคํธ ํ์
๋ณ๊ฒฝ ์์ฌ
+
+**4. ์์ฒญ ํค๋**
+- `originator: codex_cli_rs` โ Codex CLI ์ฌ์นญ
+- `OpenAI-Beta: responses=experimental` โ ์คํ API ํ๋๊ทธ
+- ํ์ผ: [oauthCodex.py](providers/oauthCodex.py) `_build_headers()`
+- ์ด ํค๋ ์์ด๋ 403 ๋ฐํ๋จ
+- OpenAI๊ฐ originator ๊ฒ์ฆ์ ๊ฐํํ๋ฉด ์ฐจ๋จ๋จ
+
+**5. ๋ชจ๋ธ ๋ชฉ๋ก**
+- `AVAILABLE_MODELS` ๋ฆฌ์คํธ๋ ์๋ ๊ด๋ฆฌ
+- ํ์ผ: [oauthCodex.py](providers/oauthCodex.py) `AVAILABLE_MODELS`
+- ์ ๋ชจ๋ธ ์ถ์/ํ๊ธฐ ์ ์๋ ์
๋ฐ์ดํธ ํ์
+- GPT-4 ์๋ฆฌ์ฆ (gpt-4, gpt-4-turbo ๋ฑ)๋ ์ด๋ฏธ ์ ๊ฑฐ๋จ
+
+**6. ํ ํฐ ๋ง๋ฃ ์ ์ฑ
**
+- access_token: expires_in ๊ธฐ์ค (ํ์ฌ ~1์๊ฐ)
+- refresh_token: ๋ง๋ฃ ์ ์ฑ
๋ถ๋ช
(OpenAI ๋ฏธ๊ณต๊ฐ)
+- ํ์ผ: [oauthToken.py](../oauthToken.py) `get_valid_token()`, `refresh_access_token()`
+- refresh_token์ด ๋ง๋ฃ๋๋ฉด ์ฌ๋ก๊ทธ์ธ ํ์
+- ํ์ธ๋ฒ: ๋ฉฐ์น ๋ฐฉ์น ํ ์์ฒญ โ 401 + refresh ์คํจ ์ฌ๋ถ
+
+### ๋ธ๋ ์ดํน ์ฒด์ธ์ง ๋์ ์์
+
+1. ์ฌ์ฉ์๊ฐ "ChatGPT ์๋จ" ๋ณด๊ณ
+2. `dartlab status` ๋ก available ํ์ธ
+3. available=False โ OAuth ๋ก๊ทธ์ธ ์ฌ์๋
+4. ๋ก๊ทธ์ธ ์คํจ โ client_id ๋ณ๊ฒฝ ํ์ธ (opencode-openai-codex-auth ์ฐธ์กฐ)
+5. ๋ก๊ทธ์ธ ์ฑ๊ณต์ธ๋ฐ API ํธ์ถ ์คํจ โ ์๋ํฌ์ธํธ/ํค๋ ๋ณ๊ฒฝ ํ์ธ
+6. API ํธ์ถ ์ฑ๊ณต์ธ๋ฐ ์๋ต ๋น์ด์์ โ SSE ์ด๋ฒคํธ ํ์
๋ณ๊ฒฝ ํ์ธ
+
+### ์ํ๊ณ ๋น๊ต โ ๋๊ฐ ๊ฐ์ API๋ฅผ ์ฐ๋๊ฐ
+
+ChatGPT OAuth(`chatgpt.com/backend-api`)๋ฅผ ์ฌ์ฉํ๋ ํ๋ก์ ํธ๋ **์ ๋ถ openai/codex CLI ์ญ๊ณตํ** ๊ธฐ๋ฐ์ด๋ค.
+
+| ํ๋ก์ ํธ | ์ธ์ด | Client ID | ๋ชจ๋ธ ๋ชฉ๋ก | refresh ์คํจ ์ฒ๋ฆฌ | ํ ํฐ ์ ์ฅ |
+|----------|------|-----------|----------|------------------|----------|
+| **openai/codex** (๊ณต์) | Rust | ํ๋์ฝ๋ฉ | `/models` ๋์ + 5๋ถ ์บ์ | 4๊ฐ์ง ๋ถ๋ฅ | ํ์ผ/ํค๋ง/๋ฉ๋ชจ๋ฆฌ 3์ค |
+| **opencode plugin** | TS | ๋์ผ ๋ณต์ | ์ฌ์ฉ์ ์ค์ ์์กด | ๋จ์ throw | ํ๋ ์์ํฌ ์์ |
+| **ai-sdk-provider** | TS | ๋์ผ ๋ณต์ | 3๊ฐ ํ๋์ฝ๋ฉ | ๋จ์ throw | codex auth.json ์ฌ์ฌ์ฉ |
+| **dartlab** (ํ์ฌ) | Python | ๋์ผ ๋ณต์ | 13๊ฐ ํ๋์ฝ๋ฉ | None ๋ฐํ | `~/.dartlab/oauth_token.json` |
+
+**๊ณตํต ํน์ง:**
+- Client ID `app_EMoamEEZ73f0CkXaXp7hrann` ์ ์ ๋์ผ (OpenAI public OAuth client)
+- `originator: codex_cli_rs` ํค๋ ์ ์ ๋์ผ
+- OpenAI๊ฐ ์ด ๊ฐ๋ค์ ๋ฐ๊พธ๋ฉด **์ ๋ถ ๋์์ ๊นจ์ง**
+
+**openai/codex๋ง์ ์ฐจ๋ณ์ (dartlab์ ์๋ ๊ฒ):**
+1. Token Exchange โ OAuth ํ ํฐ โ `api.openai.com` ํธํ API Key ๋ณํ
+2. Device Code Flow โ headless ํ๊ฒฝ (์๋ฒ, SSH) ์ธ์ฆ ์ง์
+3. ๋ชจ๋ธ ๋ชฉ๋ก ๋์ ์กฐํ โ `/models` ์๋ํฌ์ธํธ + ์บ์ + bundled fallback
+4. Keyring ์ ์ฅ โ OS ํค์ฒด์ธ (macOS Keychain, Windows Credential Manager)
+5. refresh ์คํจ 4๋จ๊ณ ๋ถ๋ฅ โ expired / reused / revoked / other
+6. WebSocket SSE ์ด์ค ์ง์
+
+**์ฐธ๊ณ : opencode์ oh-my-opencode(ํ oh-my-openagent)๋ ChatGPT OAuth๋ฅผ ์ฌ์ฉํ์ง ์๋๋ค.**
+- opencode: GitHub Copilot API ์ธ์ฆ (๋ค๋ฅธ ์์คํ
)
+- oh-my-openagent: MCP ์๋ฒ ํ์ค OAuth 2.0 + PKCE (ํ๋ฌ๊ทธ์ธ)
+
+### ์ถ์ ๋์ ๋ ํฌ์งํ ๋ฆฌ
+
+๋ณ๊ฒฝ์ฌํญ ๊ฐ์ง๋ฅผ ์ํด ๋ค์ ๋ ํฌ๋ฅผ ์ถ์ ํ๋ค.
+
+| ๋ ํฌ | ์ถ์ ์ด์ | Watch ๋์ |
+|------|----------|-----------|
+| **openai/codex** | canonical ๊ตฌํ. Client ID, ์๋ํฌ์ธํธ, ํค๋์ ์๋ณธ | `codex-rs/core/src/auth.rs`, `model_provider_info.rs` |
+| **numman-ali/opencode-openai-codex-auth** | ๋น ๋ฅธ ๋ณ๊ฒฝ ๋ฐ์ (TS๋ผ ์ฝ๊ธฐ ์ฌ์) | `lib/auth/`, `lib/constants.ts` |
+| **ben-vargas/ai-sdk-provider-chatgpt-oauth** | Vercel AI SDK ํธํ ์ฐธ์กฐ | `src/auth/` |
+
+### ํฅํ ๊ฐ์ ํ๋ณด (codex์์ ๊ฐ์ ธ์ฌ ์ ์๋ ๊ฒ)
+
+1. **๋ชจ๋ธ ๋ชฉ๋ก ๋์ ์กฐํ** โ `chatgpt.com/backend-api/codex/models` ํธ์ถ + JSON ์บ์
+2. **refresh ์คํจ ๋ถ๋ฅ** โ expired/reused/revoked ๊ตฌ๋ถํ์ฌ ์ฌ์ฉ์์๊ฒ ๊ตฌ์ฒด์ ์๋ด
+3. **Token Exchange** โ OAuth โ API Key ๋ณํ์ผ๋ก `api.openai.com` ํธํ (๋์ผ ์๋ํฌ์ธํธ)
+
+---
+
+## Codex CLI Provider โ ๋ฆฌ์คํฌ
+
+### ์ ์ทจ์ฝํ๊ฐ
+
+`codex` provider๋ OpenAI `codex` CLI ๋ฐ์ด๋๋ฆฌ๋ฅผ subprocess๋ก ํธ์ถํ๋ค.
+CLI์ JSONL ์ถ๋ ฅ ํฌ๋งท์ด ๋ณ๊ฒฝ๋๋ฉด ํ์ฑ ์คํจ.
+
+### ํ์ฌ ๋์
+
+- `~/.codex/config.toml`์ model ์ค์ ์ ์ฐ์ ํก์
+- `codex --help`, `codex exec --help`๋ฅผ ์ฝ์ด command/sandbox capability๋ฅผ ๋์ ๊ฐ์ง
+- ์ผ๋ฐ ์ง์๋ `read-only`, ์ฝ๋ ์์ ์๋๋ `workspace-write` sandbox ์ฐ์
+- ๋ณ๋ `run_codex_task` tool๋ก ๋ค๋ฅธ provider์์๋ Codex CLI ์ฝ๋ ์์
์์ ๊ฐ๋ฅ
+
+### ์ฒดํฌ ํญ๋ชฉ
+
+- CLI ์ถ๋ ฅ ํฌ๋งท: `item.completed.item.agent_message.text` ๊ฒฝ๋ก
+- CLI ํ๋๊ทธ: `--json`, `--sandbox ...`, `--model ...`, `--skip-git-repo-check`
+- CLI ์ค์น: `npm install -g @openai/codex`
+- ํ์ผ: [codex.py](providers/codex.py)
+
+---
+
+## Claude Code CLI Provider โ ๋ณด๋ฅ์ค
+
+### ํ์ฌ ์ํ
+
+VSCode ํ๊ฒฝ์์ `CLAUDECODE` ํ๊ฒฝ๋ณ์๊ฐ ์ค์ ๋์ด SDK fallback ๋ชจ๋๋ก ์ง์
ํ์ง๋ง,
+SDK fallback์์ API key ์ถ์ถ(`claude auth status --json`)์ด ๋ subprocess๋ฅผ ํธ์ถํ๋ ์ํ ๋ฌธ์ .
+
+### ์๋ ค์ง ์ด์
+
+- ํ
์คํธ 31/32 pass, `test_complete_timeout` 1๊ฐ fail
+- VSCode ๋ด์์ CLI ํธ์ถ์ด hang๋๋ ์ผ์ด์ค (์ค์ฒฉ ์ธ์
)
+- `_probe_cli()` 8์ด ํ์์์์ผ๋ก hang ๊ฐ์ง ํ SDK ์ ํ
+- ํ์ผ: [claude_code.py](providers/claude_code.py)
+
+---
+
+## ์์ Provider โ ํน์ด์ฌํญ ์์
+
+### openai / custom (openai_compat.py)
+- ๊ณต์ `openai` Python SDK ์ฌ์ฉ
+- ๋ฒ์ ์
๋ฐ์ดํธ ์ SDK breaking change๋ง ์ฃผ์
+- tool calling ์ง์
+
+### claude (claude.py)
+- ๊ณต์ `anthropic` Python SDK + OpenAI ํ๋ก์ ์ด์ค ๋ชจ๋
+- base_url ์์ผ๋ฉด OpenAI ํธํ, ์์ผ๋ฉด Anthropic ๋ค์ดํฐ๋ธ
+
+### ollama (ollama.py)
+- localhost:11434 OpenAI ํธํ ์๋ํฌ์ธํธ
+- `preload()`, `get_installed_models()`, `complete_json()` ์ถ๊ฐ ๊ธฐ๋ฅ
+- tool calling ์ง์ (v0.3.0+)
+
+---
+
+## ๋ง์ง๋ง ์ ๊ฒ์ผ
+
+- 2026-03-10: ChatGPT OAuth ์ ์ ๋์ ํ์ธ (gpt-5.4)
+- 2026-03-10: Claude Code ๋ณด๋ฅ (VSCode ํ๊ฒฝ์ด์)
diff --git a/src/dartlab/ai/__init__.py b/src/dartlab/ai/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..d1c099e2c02b5bcc7ef15cba8ab3b0b506fe6485
--- /dev/null
+++ b/src/dartlab/ai/__init__.py
@@ -0,0 +1,119 @@
+"""LLM ๊ธฐ๋ฐ ๊ธฐ์
๋ถ์ ์์ง."""
+
+from __future__ import annotations
+
+from dartlab.ai.types import LLMConfig, LLMResponse
+from dartlab.core.ai import (
+ AI_ROLES,
+ DEFAULT_ROLE,
+ get_profile_manager,
+ get_provider_spec,
+ normalize_provider,
+ normalize_role,
+)
+
+
+def configure(
+ provider: str = "codex",
+ model: str | None = None,
+ api_key: str | None = None,
+ base_url: str | None = None,
+ role: str | None = None,
+ temperature: float = 0.3,
+ max_tokens: int = 4096,
+ system_prompt: str | None = None,
+) -> None:
+ """๊ณตํต AI profile์ ๊ฐฑ์ ํ๋ค."""
+ normalized = normalize_provider(provider) or provider
+ if get_provider_spec(normalized) is None:
+ raise ValueError(f"์ง์ํ์ง ์๋ provider: {provider}")
+ normalized_role = normalize_role(role)
+ if role is not None and normalized_role is None:
+ raise ValueError(f"์ง์ํ์ง ์๋ role: {role}. ์ง์: {AI_ROLES}")
+ manager = get_profile_manager()
+ manager.update(
+ provider=normalized,
+ model=model,
+ role=normalized_role,
+ base_url=base_url,
+ temperature=temperature,
+ max_tokens=max_tokens,
+ system_prompt=system_prompt,
+ updated_by="code",
+ )
+ if api_key:
+ spec = get_provider_spec(normalized)
+ if spec and spec.auth_kind == "api_key":
+ manager.save_api_key(normalized, api_key, updated_by="code")
+
+
+def get_config(provider: str | None = None, *, role: str | None = None) -> LLMConfig:
+ """ํ์ฌ ๊ธ๋ก๋ฒ LLM ์ค์ ๋ฐํ."""
+ normalized_role = normalize_role(role)
+ resolved = get_profile_manager().resolve(provider=provider, role=normalized_role)
+ return LLMConfig(**resolved)
+
+
+def status(provider: str | None = None, *, role: str | None = None) -> dict:
+ """LLM ์ค์ ๋ฐ provider ์ํ ํ์ธ."""
+ from dartlab.ai.providers import create_provider
+
+ normalized_role = normalize_role(role)
+ config = get_config(provider, role=normalized_role)
+ selected_provider = config.provider
+ llm = create_provider(config)
+ available = llm.check_available()
+
+ result = {
+ "provider": selected_provider,
+ "role": normalized_role or DEFAULT_ROLE,
+ "model": llm.resolved_model,
+ "available": available,
+ "defaultProvider": get_profile_manager().load().default_provider,
+ }
+
+ if selected_provider == "ollama":
+ from dartlab.ai.providers.support.ollama_setup import detect_ollama
+
+ result["ollama"] = detect_ollama()
+
+ if selected_provider == "codex":
+ from dartlab.ai.providers.support.cli_setup import detect_codex
+
+ result["codex"] = detect_codex()
+
+ if selected_provider == "oauth-codex":
+ from dartlab.ai.providers.support import oauth_token as oauthToken
+
+ token_stored = False
+ try:
+ token_stored = oauthToken.load_token() is not None
+ except (OSError, ValueError):
+ token_stored = False
+
+ try:
+ authenticated = oauthToken.is_authenticated()
+ account_id = oauthToken.get_account_id() if authenticated else None
+ except (
+ AttributeError,
+ OSError,
+ RuntimeError,
+ ValueError,
+ oauthToken.TokenRefreshError,
+ ):
+ authenticated = False
+ account_id = None
+
+ result["oauth-codex"] = {
+ "authenticated": authenticated,
+ "tokenStored": token_stored,
+ "accountId": account_id,
+ }
+
+ return result
+
+
+from dartlab.ai import aiParser as ai
+from dartlab.ai.tools.plugin import get_plugin_registry, tool
+
+__all__ = ["configure", "get_config", "status", "LLMConfig", "LLMResponse", "ai", "tool", "get_plugin_registry"]
diff --git a/src/dartlab/ai/agent.py b/src/dartlab/ai/agent.py
new file mode 100644
index 0000000000000000000000000000000000000000..3b86faf41dc8683ac5e2fc5b309f74142c37e704
--- /dev/null
+++ b/src/dartlab/ai/agent.py
@@ -0,0 +1,30 @@
+"""ํธํ shim โ ์ค์ ๊ตฌํ์ runtime/agent.py๋ก ์ด๋๋จ.
+
+๊ธฐ์กด import ๊ฒฝ๋ก๋ฅผ ์ ์งํ๊ธฐ ์ํ re-export.
+"""
+
+from dartlab.ai.runtime.agent import ( # noqa: F401
+ AGENT_SYSTEM_ADDITION,
+ PLANNING_PROMPT,
+ _reflect_on_answer,
+ agent_loop,
+ agent_loop_planning,
+ agent_loop_stream,
+ build_agent_system_addition,
+)
+from dartlab.ai.tools.selector import selectTools # noqa: F401
+
+# ํ์ํธํ: _select_tools โ selectTools ๋ํผ
+_select_tools = selectTools
+
+__all__ = [
+ "AGENT_SYSTEM_ADDITION",
+ "PLANNING_PROMPT",
+ "_reflect_on_answer",
+ "_select_tools",
+ "agent_loop",
+ "agent_loop_planning",
+ "agent_loop_stream",
+ "build_agent_system_addition",
+ "selectTools",
+]
diff --git a/src/dartlab/ai/aiParser.py b/src/dartlab/ai/aiParser.py
new file mode 100644
index 0000000000000000000000000000000000000000..41afc7400d3e5f97c711d33b0ba859faee63ca1a
--- /dev/null
+++ b/src/dartlab/ai/aiParser.py
@@ -0,0 +1,500 @@
+"""AI ๋ณด์กฐ ํ์ฑ โ ๊ธฐ์กด ํ์ ์ถ๋ ฅ์ AI๊ฐ ํ์ฒ๋ฆฌํ์ฌ ๊ฐํ.
+
+๊ธฐ์กด ํ์๋ฅผ ๊ต์ฒดํ์ง ์๋๋ค. ํ์๊ฐ ์์ฐํ DataFrame/ํ
์คํธ๋ฅผ
+LLM์ด ํด์ยท์์ฝยท๊ฒ์ฆํ๋ ํ์ฒ๋ฆฌ ๋ ์ด์ด.
+
+๊ธฐ์กด LLM provider ์์คํ
์ฌ์ฌ์ฉ: dartlab.llm.configure() ์ค์ ์ ๊ทธ๋๋ก ํ์ฉ.
+
+์ฌ์ฉ๋ฒ::
+
+ import dartlab
+ dartlab.llm.configure(provider="ollama", model="llama3.2")
+
+ c = dartlab.Company("005930")
+
+ # ์์ฝ
+ dartlab.llm.ai.summarize(c.IS)
+
+ # ๊ณ์ ํด์
+ dartlab.llm.ai.interpret_accounts(c.BS)
+
+ # ์ด์์น ํ์ง
+ dartlab.llm.ai.detect_anomalies(c.dividend)
+
+ # ํ
์คํธ ๋ถ๋ฅ
+ dartlab.llm.ai.classify_text(c.mdna)
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+import polars as pl
+
+from dartlab.ai.metadata import get_meta
+
+_AI_PARSER_ERRORS = (ImportError, OSError, RuntimeError, TypeError, ValueError)
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# ๋ด๋ถ LLM ํธ์ถ
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def _llm_call(prompt: str, system: str = "") -> str:
+ """๋ด๋ถ LLM ํธ์ถ. ๊ธ๋ก๋ฒ ์ค์ ๋ provider ์ฌ์ฉ."""
+ from dartlab.ai import get_config
+ from dartlab.ai.providers import create_provider
+
+ config = get_config()
+ provider = create_provider(config)
+
+ messages = []
+ if system:
+ messages.append({"role": "system", "content": system})
+ messages.append({"role": "user", "content": prompt})
+
+ response = provider.complete(messages)
+ return response.answer
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# ์์ฝ
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def summarize(
+ data: pl.DataFrame | str | list,
+ *,
+ module_name: str | None = None,
+ lang: str = "ko",
+) -> str:
+ """DataFrame, ํ
์คํธ, ๋๋ ๋ฆฌ์คํธ๋ฅผ 2~5๋ฌธ์ฅ์ผ๋ก ์์ฝ.
+
+ Args:
+ data: DataFrame (๋งํฌ๋ค์ด ๋ณํ ํ ์์ฝ), str (์ง์ ์์ฝ), list (๊ฒฐํฉ ํ ์์ฝ)
+ module_name: ๋ฉํ๋ฐ์ดํฐ ํ์ฉ์ ์ํ ๋ชจ๋๋ช
+ lang: "ko" ๋๋ "en"
+
+ Returns:
+ ์์ฝ ํ
์คํธ (2~5๋ฌธ์ฅ)
+ """
+ from dartlab.ai.context.builder import df_to_markdown
+
+ # ๋ฐ์ดํฐ โ ํ
์คํธ
+ if isinstance(data, pl.DataFrame):
+ meta = get_meta(module_name) if module_name else None
+ text = df_to_markdown(data, meta=meta)
+ elif isinstance(data, list):
+ parts = []
+ for item in data[:10]:
+ if hasattr(item, "title") and hasattr(item, "text"):
+ parts.append(f"[{item.title}]\n{item.text[:500]}")
+ else:
+ parts.append(str(item)[:500])
+ text = "\n\n".join(parts)
+ else:
+ text = str(data)[:3000]
+
+ # ๋ฉํ๋ฐ์ดํฐ ์ปจํ
์คํธ
+ context = ""
+ if module_name:
+ meta = get_meta(module_name)
+ if meta:
+ context = f"์ด ๋ฐ์ดํฐ๋ '{meta.label}'์
๋๋ค. {meta.description}\n\n"
+
+ system = "ํ๊ตญ์ด๋ก ๋ต๋ณํ์ธ์." if lang == "ko" else "Answer in English."
+
+ prompt = (
+ f"{context}"
+ f"๋ค์ ๋ฐ์ดํฐ๋ฅผ 2~5๋ฌธ์ฅ์ผ๋ก ํต์ฌ๋ง ์์ฝํ์ธ์.\n"
+ f"์์น๋ฅผ ๊ตฌ์ฒด์ ์ผ๋ก ์ธ์ฉํ๊ณ , ์ฃผ์ ์ถ์ธ์ ํน์ด์ฌํญ์ ํฌํจํ์ธ์.\n\n"
+ f"{text}"
+ )
+
+ return _llm_call(prompt, system=system)
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# ๊ณ์ ํด์
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def interpret_accounts(
+ df: pl.DataFrame,
+ *,
+ account_col: str = "๊ณ์ ๋ช
",
+ module_name: str | None = None,
+) -> pl.DataFrame:
+ """์ฌ๋ฌด์ ํ์ '์ค๋ช
' ์ปฌ๋ผ ์ถ๊ฐ. ๊ฐ ๊ณ์ ๋ช
์ ์๋ฏธ๋ฅผ LLM์ด ํด์.
+
+ LLM 1ํ ํธ์ถ๋ก ์ ์ฒด ๊ณ์ ์ผ๊ด ํด์ (๊ฐ๋ณ ํธ์ถ ์๋).
+
+ Args:
+ df: ๊ณ์ ๋ช
์ปฌ๋ผ์ด ์๋ ์ฌ๋ฌด์ ํ DataFrame
+ account_col: ๊ณ์ ๋ช
์ปฌ๋ผ๋ช
+ module_name: "BS", "IS", "CF" ๋ฑ
+
+ Returns:
+ ์๋ณธ + '์ค๋ช
' ์ปฌ๋ผ์ด ์ถ๊ฐ๋ DataFrame
+ """
+ if account_col not in df.columns:
+ return df
+
+ accounts = df[account_col].to_list()
+ if not accounts:
+ return df
+
+ # ์ ์ผํ ๊ณ์ ๋ช
๋ง ์ถ์ถ
+ unique_accounts = list(dict.fromkeys(accounts))
+
+ module_hint = ""
+ if module_name:
+ meta = get_meta(module_name)
+ if meta:
+ module_hint = f"์ด ๋ฐ์ดํฐ๋ '{meta.label}'({meta.description})์
๋๋ค.\n"
+
+ prompt = (
+ f"{module_hint}"
+ f"๋ค์ K-IFRS ๊ณ์ ๋ช
๊ฐ๊ฐ์ ๋ํด ํ ์ค(20์ ์ด๋ด)๋ก ์ค๋ช
ํ์ธ์.\n"
+ f"ํ์: ๊ณ์ ๋ช
: ์ค๋ช
\n\n" + "\n".join(unique_accounts)
+ )
+
+ answer = _llm_call(prompt, system="ํ๊ตญ์ด๋ก ๋ต๋ณํ์ธ์. ๊ฐ ๊ณ์ ์ ๋ํด ๊ฐ๊ฒฐํ๊ฒ ์ค๋ช
๋ง ํ์ธ์.")
+
+ # ์๋ต ํ์ฑ: "๊ณ์ ๋ช
: ์ค๋ช
" ํํ
+ desc_map: dict[str, str] = {}
+ for line in answer.strip().split("\n"):
+ line = line.strip().lstrip("- ").lstrip("ยท ")
+ if ":" in line:
+ parts = line.split(":", 1)
+ key = parts[0].strip()
+ val = parts[1].strip()
+ desc_map[key] = val
+
+ # ๋งคํ
+ descriptions = []
+ for acct in accounts:
+ desc = desc_map.get(acct, "")
+ if not desc:
+ # ๋ถ๋ถ ๋งค์นญ ์๋
+ for k, v in desc_map.items():
+ if k in acct or acct in k:
+ desc = v
+ break
+ descriptions.append(desc)
+
+ return df.with_columns(pl.Series("์ค๋ช
", descriptions))
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# ์ด์์น ํ์ง
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+@dataclass
+class Anomaly:
+ """ํ์ง๋ ์ด์์น."""
+
+ column: str
+ year: str
+ value: Any
+ prev_value: Any
+ change_pct: float | None
+ anomaly_type: str # "spike", "sign_reversal", "outlier", "missing"
+ severity: str = "medium" # "high", "medium", "low"
+ description: str = ""
+
+
+def _statistical_prescreen(
+ df: pl.DataFrame,
+ *,
+ year_col: str = "year",
+ threshold_pct: float = 50.0,
+) -> list[Anomaly]:
+ """์์ ํต๊ณ ๊ธฐ๋ฐ ์ด์์น ์ฌ์ ํ์ง (LLM ์์ด ๋์).
+
+ ํ์ง ๊ธฐ์ค:
+ - YoY ๋ณ๋ threshold_pct% ์ด๊ณผ
+ - ๋ถํธ ๋ฐ์ (์โ์, ์โ์)
+ - 2ฯ ์ดํ
+ """
+ if year_col not in df.columns:
+ return []
+
+ df_sorted = df.sort(year_col)
+ numeric_cols = [
+ c for c in df.columns if c != year_col and df[c].dtype in (pl.Float64, pl.Float32, pl.Int64, pl.Int32)
+ ]
+
+ anomalies = []
+ years = df_sorted[year_col].to_list()
+
+ for col in numeric_cols:
+ values = df_sorted[col].to_list()
+ non_null = [v for v in values if v is not None]
+
+ if len(non_null) < 2:
+ continue
+
+ mean_val = sum(non_null) / len(non_null)
+ if len(non_null) > 1:
+ variance = sum((v - mean_val) ** 2 for v in non_null) / (len(non_null) - 1)
+ std_val = variance**0.5
+ else:
+ std_val = 0
+
+ for i in range(1, len(values)):
+ cur = values[i]
+ prev = values[i - 1]
+
+ if cur is None or prev is None:
+ continue
+
+ # YoY ๋ณ๋
+ if prev != 0:
+ change = (cur - prev) / abs(prev) * 100
+ if abs(change) > threshold_pct:
+ severity = "high" if abs(change) > 100 else "medium"
+ anomalies.append(
+ Anomaly(
+ column=col,
+ year=str(years[i]),
+ value=cur,
+ prev_value=prev,
+ change_pct=round(change, 1),
+ anomaly_type="spike",
+ severity=severity,
+ )
+ )
+
+ # ๋ถํธ ๋ฐ์
+ if (prev > 0 and cur < 0) or (prev < 0 and cur > 0):
+ anomalies.append(
+ Anomaly(
+ column=col,
+ year=str(years[i]),
+ value=cur,
+ prev_value=prev,
+ change_pct=None,
+ anomaly_type="sign_reversal",
+ severity="high",
+ )
+ )
+
+ # 2ฯ ์ดํ
+ if std_val > 0 and abs(cur - mean_val) > 2 * std_val:
+ anomalies.append(
+ Anomaly(
+ column=col,
+ year=str(years[i]),
+ value=cur,
+ prev_value=None,
+ change_pct=None,
+ anomaly_type="outlier",
+ severity="medium",
+ )
+ )
+
+ # ์ค๋ณต ์ ๊ฑฐ (๊ฐ์ year+column)
+ seen = set()
+ unique = []
+ for a in anomalies:
+ key = (a.column, a.year, a.anomaly_type)
+ if key not in seen:
+ seen.add(key)
+ unique.append(a)
+
+ return unique
+
+
+def detect_anomalies(
+ df: pl.DataFrame,
+ *,
+ module_name: str | None = None,
+ year_col: str = "year",
+ threshold_pct: float = 50.0,
+ use_llm: bool = True,
+) -> list[Anomaly]:
+ """2๋จ๊ณ ์ด์์น ํ์ง.
+
+ Stage 1: ํต๊ณ ์ฌ์ ์คํฌ๋ฆฌ๋ (LLM ์์ด ํญ์ ๋์)
+ Stage 2: LLM ํด์ (use_llm=True์ด๊ณ LLM ์ค์ ์)
+
+ Args:
+ df: ์๊ณ์ด DataFrame
+ module_name: ๋ชจ๋๋ช
(๋ฉํ๋ฐ์ดํฐ ํ์ฉ)
+ threshold_pct: YoY ๋ณ๋ ์๊ณ๊ฐ (%)
+ use_llm: True๋ฉด LLM์ผ๋ก ํด์ ์ถ๊ฐ
+
+ Returns:
+ Anomaly ๋ฆฌ์คํธ (severity ๋ด๋ฆผ์ฐจ์)
+ """
+ anomalies = _statistical_prescreen(df, year_col=year_col, threshold_pct=threshold_pct)
+
+ if not anomalies:
+ return []
+
+ # Stage 2: LLM ํด์
+ if use_llm and anomalies:
+ try:
+ meta_ctx = ""
+ if module_name:
+ meta = get_meta(module_name)
+ if meta:
+ meta_ctx = f"๋ฐ์ดํฐ: {meta.label} ({meta.description})\n"
+
+ lines = []
+ for a in anomalies[:10]: # ์ต๋ 10๊ฐ๋ง
+ if a.anomaly_type == "spike":
+ lines.append(
+ f"- {a.column} {a.year}๋
: {a.prev_value:,.0f} โ {a.value:,.0f} (YoY {a.change_pct:+.1f}%)"
+ )
+ elif a.anomaly_type == "sign_reversal":
+ lines.append(f"- {a.column} {a.year}๋
: ๋ถํธ ๋ฐ์ {a.prev_value:,.0f} โ {a.value:,.0f}")
+ elif a.anomaly_type == "outlier":
+ lines.append(f"- {a.column} {a.year}๋
: ์ด์์น {a.value:,.0f}")
+
+ prompt = (
+ f"{meta_ctx}"
+ f"๋ค์ ์ฌ๋ฌด ๋ฐ์ดํฐ ์ด์์น๋ค์ ๋ํด ๊ฐ๊ฐ ํ ์ค๋ก ๊ฐ๋ฅํ ์์ธ์ ์ค๋ช
ํ์ธ์.\n\n" + "\n".join(lines)
+ )
+
+ answer = _llm_call(prompt, system="ํ๊ตญ์ด๋ก ๊ฐ๊ฒฐํ๊ฒ ๋ต๋ณํ์ธ์.")
+
+ # ์๋ต์์ ์ค๋ช
์ถ์ถํ์ฌ anomalies์ ๋งคํ
+ desc_lines = [l.strip().lstrip("- ").lstrip("ยท ") for l in answer.strip().split("\n") if l.strip()]
+ for i, a in enumerate(anomalies[:10]):
+ if i < len(desc_lines):
+ a.description = desc_lines[i]
+
+ except _AI_PARSER_ERRORS:
+ # LLM ์คํจ ์ ํต๊ณ ๊ฒฐ๊ณผ๋ง ๋ฐํ
+ pass
+
+ # severity ์ ๋ ฌ
+ severity_order = {"high": 0, "medium": 1, "low": 2}
+ anomalies.sort(key=lambda a: severity_order.get(a.severity, 1))
+
+ return anomalies
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# ํ
์คํธ ๋ถ๋ฅ
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def classify_text(text: str) -> dict:
+ """๊ณต์ ํ
์คํธ์์ ๊ฐ์ฑ, ํต์ฌํ ํฝ, ๋ฆฌ์คํฌ, ๊ธฐํ ์ถ์ถ.
+
+ MD&A, ์ฌ์
์ ๋ด์ฉ ๋ฑ ์์ ํ ํ
์คํธ๋ฅผ ๊ตฌ์กฐํ๋ ๋ถ์ ๊ฒฐ๊ณผ๋ก ๋ณํ.
+
+ Returns:
+ {
+ "sentiment": "๊ธ์ " | "๋ถ์ " | "์ค๋ฆฝ",
+ "key_topics": list[str],
+ "risks": list[str],
+ "opportunities": list[str],
+ "summary": str,
+ }
+ """
+ if not text:
+ return {
+ "sentiment": "์ค๋ฆฝ",
+ "key_topics": [],
+ "risks": [],
+ "opportunities": [],
+ "summary": "",
+ }
+
+ # ํ
์คํธ ๊ธธ์ด ์ ํ
+ truncated = text[:3000] if len(text) > 3000 else text
+
+ prompt = (
+ "๋ค์ ๊ณต์ ํ
์คํธ๋ฅผ ๋ถ์ํ์ฌ ์๋ ํ์์ผ๋ก ๋ต๋ณํ์ธ์.\n\n"
+ "๊ฐ์ฑ: (๊ธ์ /๋ถ์ /์ค๋ฆฝ)\n"
+ "ํต์ฌํ ํฝ: (์ผํ๋ก ๊ตฌ๋ถ, 3~5๊ฐ)\n"
+ "๋ฆฌ์คํฌ: (์ผํ๋ก ๊ตฌ๋ถ)\n"
+ "๊ธฐํ: (์ผํ๋ก ๊ตฌ๋ถ)\n"
+ "์์ฝ: (2~3๋ฌธ์ฅ)\n\n"
+ f"ํ
์คํธ:\n{truncated}"
+ )
+
+ answer = _llm_call(prompt, system="ํ๊ตญ์ด๋ก ๋ต๋ณํ์ธ์. ์ฃผ์ด์ง ํ์์ ์ ํํ ๋ฐ๋ฅด์ธ์.")
+
+ # ์๋ต ํ์ฑ
+ result = {
+ "sentiment": "์ค๋ฆฝ",
+ "key_topics": [],
+ "risks": [],
+ "opportunities": [],
+ "summary": "",
+ }
+
+ for line in answer.strip().split("\n"):
+ line = line.strip()
+ if line.startswith("๊ฐ์ฑ:"):
+ val = line.split(":", 1)[1].strip()
+ if "๊ธ์ " in val:
+ result["sentiment"] = "๊ธ์ "
+ elif "๋ถ์ " in val:
+ result["sentiment"] = "๋ถ์ "
+ else:
+ result["sentiment"] = "์ค๋ฆฝ"
+ elif line.startswith("ํต์ฌํ ํฝ:"):
+ val = line.split(":", 1)[1].strip()
+ result["key_topics"] = [t.strip() for t in val.split(",") if t.strip()]
+ elif line.startswith("๋ฆฌ์คํฌ:"):
+ val = line.split(":", 1)[1].strip()
+ result["risks"] = [t.strip() for t in val.split(",") if t.strip()]
+ elif line.startswith("๊ธฐํ:"):
+ val = line.split(":", 1)[1].strip()
+ result["opportunities"] = [t.strip() for t in val.split(",") if t.strip()]
+ elif line.startswith("์์ฝ:"):
+ result["summary"] = line.split(":", 1)[1].strip()
+
+ return result
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# ํตํฉ ๋ถ์
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def analyze_module(
+ company: Any,
+ module_name: str,
+) -> dict:
+ """๋จ์ผ ๋ชจ๋ ์ ์ฒด AI ๋ถ์.
+
+ summarize + detect_anomalies + (interpret_accounts if applicable) ์ผ๊ด ์คํ.
+
+ Returns:
+ {
+ "summary": str,
+ "anomalies": list[Anomaly],
+ "interpreted_df": pl.DataFrame | None,
+ }
+ """
+ data = getattr(company, module_name, None)
+ if data is None:
+ return {"summary": "๋ฐ์ดํฐ ์์", "anomalies": [], "interpreted_df": None}
+
+ result: dict[str, Any] = {}
+
+ # ์์ฝ
+ result["summary"] = summarize(data, module_name=module_name)
+
+ # ์ด์์น ํ์ง (DataFrame์ธ ๊ฒฝ์ฐ๋ง)
+ if isinstance(data, pl.DataFrame):
+ result["anomalies"] = detect_anomalies(data, module_name=module_name)
+ else:
+ result["anomalies"] = []
+
+ # ๊ณ์ ํด์ (BS/IS/CF๋ง)
+ if module_name in ("BS", "IS", "CF") and isinstance(data, pl.DataFrame) and "๊ณ์ ๋ช
" in data.columns:
+ result["interpreted_df"] = interpret_accounts(data, module_name=module_name)
+ else:
+ result["interpreted_df"] = None
+
+ return result
diff --git a/src/dartlab/ai/context/__init__.py b/src/dartlab/ai/context/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..0beaca641776ece7592e6173862356f0fcc22d54
--- /dev/null
+++ b/src/dartlab/ai/context/__init__.py
@@ -0,0 +1,9 @@
+"""AI context package."""
+
+from . import builder as _builder
+from . import company_adapter as _company_adapter
+from . import dartOpenapi as _dart_openapi
+from . import snapshot as _snapshot
+
+for _module in (_builder, _snapshot, _company_adapter, _dart_openapi):
+ globals().update({name: getattr(_module, name) for name in dir(_module) if not name.startswith("__")})
diff --git a/src/dartlab/ai/context/builder.py b/src/dartlab/ai/context/builder.py
new file mode 100644
index 0000000000000000000000000000000000000000..6014efcca835d839990ff2bc9d803f1998e3a4d8
--- /dev/null
+++ b/src/dartlab/ai/context/builder.py
@@ -0,0 +1,1960 @@
+"""Company ๋ฐ์ดํฐ๋ฅผ LLM context๋ก ๋ณํ.
+
+๋ฉํ๋ฐ์ดํฐ ๊ธฐ๋ฐ ์ปฌ๋ผ ์ค๋ช
, ํ์ ์งํ ์๋๊ณ์ฐ, ๋ถ์ ํํธ๋ฅผ ํฌํจํ์ฌ
+LLM์ด ์ ํํ๊ฒ ๋ถ์ํ ์ ์๋ ๊ตฌ์กฐํ๋ ๋งํฌ๋ค์ด ์ปจํ
์คํธ๋ฅผ ์์ฑํ๋ค.
+
+๋ถํ ๋ชจ๋:
+- formatting.py: DataFrame ๋งํฌ๋ค์ด ๋ณํ, ํฌ๋งทํ
, ํ์ ์งํ ๊ณ์ฐ
+- finance_context.py: ์ฌ๋ฌด/๊ณต์ ๋ฐ์ดํฐ โ LLM ์ปจํ
์คํธ ๋งํฌ๋ค์ด ์์ฑ
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+import polars as pl
+
+from dartlab.ai.context.company_adapter import get_headline_ratios
+from dartlab.ai.context.finance_context import (
+ _QUESTION_ACCOUNT_FILTER,
+ _QUESTION_MODULES, # noqa: F401 โ re-export for tests
+ _build_finance_engine_section,
+ _build_ratios_section,
+ _build_report_sections,
+ _buildQuarterlySection,
+ _detect_year_hint,
+ _get_quarter_counts,
+ _resolve_module_data,
+ _topic_name_set,
+ detect_year_range,
+ scan_available_modules,
+)
+from dartlab.ai.context.formatting import (
+ _compute_derived_metrics,
+ _filter_key_accounts,
+ _format_usd,
+ _format_won,
+ _get_sector, # noqa: F401 โ re-export for runtime/core.py
+ df_to_markdown,
+)
+from dartlab.ai.metadata import MODULE_META
+
+_CONTEXT_ERRORS = (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError)
+
+_ROUTE_FINANCE_TYPES = frozenset({"๊ฑด์ ์ฑ", "์์ต์ฑ", "์ฑ์ฅ์ฑ", "์๋ณธ"})
+_ROUTE_SECTIONS_TYPES = frozenset({"์ฌ์
", "๋ฆฌ์คํฌ", "๊ณต์"})
+_ROUTE_REPORT_KEYWORDS: dict[str, str] = {
+ "๋ฐฐ๋น": "dividend",
+ "์ง์": "employee",
+ "์์": "executive",
+ "์ต๋์ฃผ์ฃผ": "majorHolder",
+ "์ฃผ์ฃผ": "majorHolder",
+ "๊ฐ์ฌ": "audit",
+ "์๊ธฐ์ฃผ์": "treasuryStock",
+}
+_ROUTE_SECTIONS_KEYWORDS = frozenset(
+ {
+ "๊ณต์",
+ "์ฌ์
",
+ "๋ฆฌ์คํฌ",
+ "๊ด๊ณ์ฌ",
+ "์ง๋ฐฐ๊ตฌ์กฐ",
+ "๊ทผ๊ฑฐ",
+ "๋ณํ",
+ "์ต๊ทผ ๊ณต์",
+ "๋ฌด์จ ์ฌ์
",
+ "๋ญํ๋",
+ "์ด๋ค ํ์ฌ",
+ "ESG",
+ "ํ๊ฒฝ",
+ "์ฌํ์ ์ฑ
์",
+ "ํ์",
+ "๊ธฐํ",
+ "๊ณต๊ธ๋ง",
+ "๊ณต๊ธ์ฌ",
+ "๊ณ ๊ฐ ์ง์ค",
+ "๋ณํ ๊ฐ์ง",
+ "๋ฌด์์ด ๋ฌ๋ผ",
+ "๊ณต์ ๋ณ๊ฒฝ",
+ }
+)
+_ROUTE_HYBRID_KEYWORDS = frozenset({"์ข
ํฉ", "์ ๋ฐ", "์ ์ฒด", "๋น๊ต", "๋ฐธ๋ฅ์์ด์
", "์ ์ ์ฃผ๊ฐ", "๋ชฉํ๊ฐ", "DCF"})
+_ROUTE_FINANCE_KEYWORDS = frozenset(
+ {
+ "์ฌ๋ฌด",
+ "์์
์ด์ต",
+ "์์
์ด์ต๋ฅ ",
+ "๋งค์ถ",
+ "์์ด์ต",
+ "์ค์ ",
+ "ํ๊ธํ๋ฆ",
+ "๋ถ์ฑ",
+ "์์ฐ",
+ "์์ต์ฑ",
+ "๊ฑด์ ์ฑ",
+ "์ฑ์ฅ์ฑ",
+ "์ด์ต๋ฅ ",
+ "๋ง์ง",
+ "revenue",
+ "profit",
+ "margin",
+ "cash flow",
+ "cashflow",
+ "debt",
+ "asset",
+ }
+)
+_ROUTE_REPORT_FINANCE_HINTS = frozenset(
+ {
+ "์ง์ ๊ฐ๋ฅ",
+ "์ง์๊ฐ๋ฅ",
+ "์ง์์ฑ",
+ "ํ๊ธํ๋ฆ",
+ "ํ๊ธ",
+ "์ค์ ",
+ "์์
์ด์ต",
+ "์์ด์ต",
+ "์ปค๋ฒ",
+ "ํ๋จ",
+ "ํ๊ฐ",
+ "๊ฐ๋ฅํ์ง",
+ }
+)
+_ROUTE_DISTRESS_KEYWORDS = frozenset(
+ {
+ "๋ถ์ค",
+ "๋ถ์ค ์งํ",
+ "์๊ธฐ ์งํ",
+ "์ฌ๋ฌด ์๊ธฐ",
+ "์ ๋์ฑ ์๊ธฐ",
+ "์๊ธ ์๋ฐ",
+ "์ํ ๋ถ๋ด",
+ "์ด์๋ณด์",
+ "์กด์ ๊ฐ๋ฅ",
+ "going concern",
+ "distress",
+ }
+)
+_SUMMARY_REQUEST_KEYWORDS = frozenset({"์ข
ํฉ", "์ ๋ฐ", "์ ์ฒด", "์์ฝ", "๊ฐ๊ด", "ํ๋์"})
+_QUARTERLY_HINTS = frozenset(
+ {
+ "๋ถ๊ธฐ",
+ "๋ถ๊ธฐ๋ณ",
+ "quarterly",
+ "quarter",
+ "Q1",
+ "Q2",
+ "Q3",
+ "Q4",
+ "1๋ถ๊ธฐ",
+ "2๋ถ๊ธฐ",
+ "3๋ถ๊ธฐ",
+ "4๋ถ๊ธฐ",
+ "๋ฐ๊ธฐ",
+ "๋ฐ๊ธฐ๋ณ",
+ "QoQ",
+ "์ ๋ถ๊ธฐ",
+ }
+)
+
+
+def _detectGranularity(question: str) -> str:
+ """์ง๋ฌธ์์ ์๊ฐ ๋จ์ ๊ฐ์ง: 'quarterly' | 'annual'."""
+ if any(k in question for k in _QUARTERLY_HINTS):
+ return "quarterly"
+ return "annual"
+
+
+_SECTIONS_TYPE_DEFAULTS: dict[str, list[str]] = {
+ "์ฌ์
": ["businessOverview", "productService", "salesOrder"],
+ "๋ฆฌ์คํฌ": ["riskDerivative", "contingentLiability", "internalControl"],
+ "๊ณต์": ["disclosureChanges", "subsequentEvents", "otherReference"],
+ "์ง๋ฐฐ๊ตฌ์กฐ": ["governanceOverview", "boardOfDirectors", "holderOverview"],
+}
+_SECTIONS_KEYWORD_TOPICS: dict[str, list[str]] = {
+ "๊ด๊ณ์ฌ": ["affiliateGroupDetail", "subsidiaryDetail", "investedCompany"],
+ "์ง๋ฐฐ๊ตฌ์กฐ": ["governanceOverview", "boardOfDirectors", "holderOverview"],
+ "๋ฌด์จ ์ฌ์
": ["businessOverview", "productService"],
+ "๋ญํ๋": ["businessOverview", "productService"],
+ "์ด๋ค ํ์ฌ": ["businessOverview", "companyHistory"],
+ "์ต๊ทผ ๊ณต์": ["disclosureChanges", "subsequentEvents"],
+ "๋ณํ": ["disclosureChanges", "businessStatus"],
+ "ESG": ["governanceOverview", "boardOfDirectors"],
+ "ํ๊ฒฝ": ["businessOverview"],
+ "๊ณต๊ธ๋ง": ["segments", "rawMaterial"],
+ "๊ณต๊ธ์ฌ": ["segments", "rawMaterial"],
+ "๋ณํ ๊ฐ์ง": ["disclosureChanges", "businessStatus"],
+}
+_FINANCIAL_ONLY = {"BS", "IS", "CF", "fsSummary", "ratios"}
+_SECTIONS_ROUTE_EXCLUDE_TOPICS = {
+ "fsSummary",
+ "financialStatements",
+ "financialNotes",
+ "consolidatedStatements",
+ "consolidatedNotes",
+ "dividend",
+ "employee",
+ "majorHolder",
+ "audit",
+}
+_FINANCE_STATEMENT_MODULES = frozenset({"BS", "IS", "CF", "CIS", "SCE"})
+_FINANCE_CONTEXT_MODULES = _FINANCE_STATEMENT_MODULES | {"ratios"}
+_BALANCE_SHEET_HINTS = frozenset({"๋ถ์ฑ", "์์ฐ", "์ ๋", "์ฐจ์
", "์๋ณธ", "๋ ๋ฒ๋ฆฌ์ง", "๊ฑด์ ์ฑ", "์์ "})
+_CASHFLOW_HINTS = frozenset({"ํ๊ธํ๋ฆ", "ํ๊ธ", "fcf", "์๊ธ", "์ปค๋ฒ", "๋ฐฐ๋น์ง๊ธ", "์ง์ ๊ฐ๋ฅ", "์ง์๊ฐ๋ฅ"})
+_INCOME_STATEMENT_HINTS = frozenset(
+ {"๋งค์ถ", "์์
์ด์ต", "์์ด์ต", "์์ต", "๋ง์ง", "์ด์ต๋ฅ ", "์ค์ ", "์๊ฐ", "๋น์ฉ", "ํ๊ด๋น"}
+)
+_RATIO_HINTS = frozenset({"๋น์จ", "๋ง์ง", "์ด์ต๋ฅ ", "์์ต์ฑ", "๊ฑด์ ์ฑ", "์ฑ์ฅ์ฑ", "์์ ์ฑ", "์ง์ ๊ฐ๋ฅ", "์ง์๊ฐ๋ฅ"})
+_DIRECT_HINT_MAP: dict[str, list[str]] = {
+ "์ฑ๊ฒฉ๋ณ ๋น์ฉ": ["costByNature"],
+ "๋น์ฉ์ ์ฑ๊ฒฉ": ["costByNature"],
+ "์ธ๊ฑด๋น": ["costByNature"],
+ "๊ฐ๊ฐ์๊ฐ": ["costByNature"],
+ "๊ด๊ณ ์ ์ ๋น": ["costByNature"],
+ "ํ๋งค์ด์ง๋น": ["costByNature"],
+ "์ง๊ธ์์๋ฃ": ["costByNature"],
+ "์ด๋ฐ๋น": ["costByNature"],
+ "๋ฌผ๋ฅ๋น": ["costByNature"],
+ "์ฐ๊ตฌ๊ฐ๋ฐ": ["rnd"],
+ "r&d": ["rnd"],
+ "์ธ๊ทธ๋จผํธ": ["segments"],
+ "๋ถ๋ฌธ์ ๋ณด": ["segments"],
+ "์ฌ์
๋ถ๋ฌธ": ["segments"],
+ "๋ถ๋ฌธ๋ณ": ["segments"],
+ "์ ํ๋ณ": ["productService"],
+ "์๋น์ค๋ณ": ["productService"],
+}
+_CANDIDATE_ALIASES = {
+ "segment": "segments",
+ "operationalAsset": "tangibleAsset",
+}
+_MARGIN_DRIVER_MARGIN_HINTS = frozenset({"์์
์ด์ต๋ฅ ", "๋ง์ง", "์ด์ต๋ฅ ", "margin"})
+_MARGIN_DRIVER_COST_HINTS = frozenset({"๋น์ฉ ๊ตฌ์กฐ", "์๊ฐ ๊ตฌ์กฐ", "๋น์ฉ", "์๊ฐ", "ํ๊ด๋น", "๋งค์ถ์๊ฐ"})
+_MARGIN_DRIVER_BUSINESS_HINTS = frozenset({"์ฌ์
๋ณํ", "์ฌ์
๋ณํ", "์ฌ์
๊ตฌ์กฐ", "์ฌ์
๊ตฌ์กฐ"})
+_RECENT_DISCLOSURE_BUSINESS_HINTS = frozenset({"์ฌ์
๋ณํ", "์ฌ์
๋ณํ", "์ฌ์
๊ตฌ์กฐ", "์ฌ์
๊ตฌ์กฐ"})
+_PERIOD_COLUMN_RE = re.compile(r"^\d{4}(?:Q[1-4])?$")
+
+
+def _section_key_to_module_name(key: str) -> str:
+ if key.startswith("report_"):
+ return key.removeprefix("report_")
+ if key.startswith("module_"):
+ return key.removeprefix("module_")
+ if key.startswith("section_"):
+ return key.removeprefix("section_")
+ return key
+
+
+def _module_name_to_section_keys(name: str) -> list[str]:
+ return [
+ name,
+ f"report_{name}",
+ f"module_{name}",
+ f"section_{name}",
+ ]
+
+
+def _build_module_section(name: str, data: Any, *, compact: bool, max_rows: int | None = None) -> str | None:
+ meta = MODULE_META.get(name)
+ label = meta.label if meta else name
+ max_rows_value = max_rows or (8 if compact else 15)
+
+ if isinstance(data, pl.DataFrame):
+ if data.is_empty():
+ return None
+ md = df_to_markdown(data, max_rows=max_rows_value, meta=meta, compact=True)
+ return f"\n## {label}\n{md}"
+
+ if isinstance(data, dict):
+ items = list(data.items())[:max_rows_value]
+ lines = [f"\n## {label}"]
+ lines.extend(f"- {k}: {v}" for k, v in items)
+ return "\n".join(lines)
+
+ if isinstance(data, list):
+ max_items = min(meta.maxRows if meta else 10, 5 if compact else 10)
+ lines = [f"\n## {label}"]
+ for item in data[:max_items]:
+ if hasattr(item, "title") and hasattr(item, "chars"):
+ lines.append(f"- **{item.title}** ({item.chars}์)")
+ else:
+ lines.append(f"- {item}")
+ if len(data) > max_items:
+ lines.append(f"(... ์์ {max_items}๊ฑด, ์ ์ฒด {len(data)}๊ฑด)")
+ return "\n".join(lines)
+
+ text = str(data).strip()
+ if not text:
+ return None
+ max_text = 500 if compact else 1000
+ return f"\n## {label}\n{text[:max_text]}"
+
+
+def _resolve_context_route(
+ question: str,
+ *,
+ include: list[str] | None,
+ q_types: list[str],
+) -> str:
+ if include:
+ return "hybrid"
+
+ if _detectGranularity(question) == "quarterly":
+ return "hybrid"
+
+ if _has_margin_driver_pattern(question):
+ return "hybrid"
+
+ if _has_distress_pattern(question):
+ return "finance"
+
+ if _has_recent_disclosure_business_pattern(question):
+ return "sections"
+
+ question_lower = question.lower()
+ q_set = set(q_types)
+ has_report = any(keyword in question for keyword in _ROUTE_REPORT_KEYWORDS)
+ has_sections = any(keyword in question for keyword in _ROUTE_SECTIONS_KEYWORDS) or bool(
+ q_set & _ROUTE_SECTIONS_TYPES
+ )
+ has_finance_keyword = any(keyword in question_lower for keyword in _ROUTE_FINANCE_KEYWORDS)
+ has_finance = has_finance_keyword or bool(q_set & _ROUTE_FINANCE_TYPES)
+ has_report_finance_hint = any(keyword in question for keyword in _ROUTE_REPORT_FINANCE_HINTS)
+
+ if has_report and (has_finance_keyword or has_sections or has_report_finance_hint):
+ return "hybrid"
+
+ for keyword in _ROUTE_REPORT_KEYWORDS:
+ if keyword in question:
+ return "report"
+
+ if has_sections:
+ return "sections"
+
+ if q_set and q_set.issubset(_ROUTE_FINANCE_TYPES):
+ return "finance"
+
+ if has_finance:
+ return "finance"
+
+ if q_set and len(q_set) > 1:
+ return "hybrid"
+
+ if q_set & {"์ข
ํฉ"}:
+ return "hybrid"
+
+ if any(keyword in question for keyword in _ROUTE_HYBRID_KEYWORDS):
+ return "hybrid"
+
+ return "finance" if q_set else "hybrid"
+
+
+def _append_unique(items: list[str], value: str | None) -> None:
+ if value and value not in items:
+ items.append(value)
+
+
+def _normalize_candidate_module(name: str) -> str:
+ return _CANDIDATE_ALIASES.get(name, name)
+
+
+def _question_has_any(question: str, keywords: set[str] | frozenset[str]) -> bool:
+ lowered = question.lower()
+ return any(keyword.lower() in lowered for keyword in keywords)
+
+
+def _has_distress_pattern(question: str) -> bool:
+ return _question_has_any(question, _ROUTE_DISTRESS_KEYWORDS)
+
+
+def _has_margin_driver_pattern(question: str) -> bool:
+ return (
+ _question_has_any(question, _MARGIN_DRIVER_MARGIN_HINTS)
+ and _question_has_any(question, _MARGIN_DRIVER_COST_HINTS)
+ and _question_has_any(question, _MARGIN_DRIVER_BUSINESS_HINTS)
+ )
+
+
+def _has_recent_disclosure_business_pattern(question: str) -> bool:
+ lowered = question.lower()
+ return "์ต๊ทผ ๊ณต์" in lowered and _question_has_any(question, _RECENT_DISCLOSURE_BUSINESS_HINTS)
+
+
+def _resolve_direct_hint_modules(question: str) -> list[str]:
+ selected: list[str] = []
+ lowered = question.lower()
+ for keyword, modules in _DIRECT_HINT_MAP.items():
+ if keyword.lower() in lowered:
+ for module_name in modules:
+ _append_unique(selected, _normalize_candidate_module(module_name))
+ return selected
+
+
+def _apply_question_specific_boosts(question: str, selected: list[str]) -> None:
+ if _has_distress_pattern(question):
+ for module_name in ("BS", "IS", "CF", "ratios"):
+ _append_unique(selected, module_name)
+
+ if _has_margin_driver_pattern(question):
+ for module_name in ("IS", "costByNature", "businessOverview", "productService"):
+ _append_unique(selected, module_name)
+
+ if _has_recent_disclosure_business_pattern(question):
+ for module_name in ("businessOverview", "productService"):
+ _append_unique(selected, module_name)
+
+
+def _resolve_candidate_modules(
+ question: str,
+ *,
+ include: list[str] | None = None,
+ exclude: list[str] | None = None,
+) -> list[str]:
+ selected: list[str] = []
+
+ if include:
+ for name in include:
+ _append_unique(selected, _normalize_candidate_module(name))
+ else:
+ for module_name in _resolve_direct_hint_modules(question):
+ _append_unique(selected, module_name)
+
+ for name in _resolve_tables(question, None, exclude):
+ _append_unique(selected, _normalize_candidate_module(name))
+
+ _apply_question_specific_boosts(question, selected)
+
+ if exclude:
+ excluded = {_normalize_candidate_module(name) for name in exclude}
+ selected = [name for name in selected if name not in excluded]
+
+ specific_modules = set(selected) - (_FINANCE_CONTEXT_MODULES | {"fsSummary"})
+ if specific_modules and not _question_has_any(question, _SUMMARY_REQUEST_KEYWORDS):
+ selected = [name for name in selected if name != "fsSummary"]
+
+ return selected
+
+
+def _available_sections_topics(company: Any) -> set[str]:
+ docs = getattr(company, "docs", None)
+ sections = getattr(docs, "sections", None)
+ if sections is None:
+ return set()
+
+ manifest = sections.outline() if hasattr(sections, "outline") else None
+ if isinstance(manifest, pl.DataFrame) and "topic" in manifest.columns:
+ return {topic for topic in manifest["topic"].drop_nulls().to_list() if isinstance(topic, str) and topic}
+
+ if hasattr(sections, "topics"):
+ try:
+ return {topic for topic in sections.topics() if isinstance(topic, str) and topic}
+ except _CONTEXT_ERRORS:
+ return set()
+ return set()
+
+
+def _available_report_modules(company: Any) -> set[str]:
+ report = getattr(company, "report", None)
+ if report is None:
+ return set()
+
+ for attr_name in ("availableApiTypes", "apiTypes"):
+ try:
+ values = getattr(report, attr_name, None)
+ except _CONTEXT_ERRORS:
+ values = None
+ if isinstance(values, list):
+ return {str(value) for value in values if isinstance(value, str) and value}
+ return set()
+
+
+def _available_notes_modules(company: Any) -> set[str]:
+ notes = getattr(company, "notes", None)
+ if notes is None:
+ docs = getattr(company, "docs", None)
+ notes = getattr(docs, "notes", None) if docs is not None else None
+ if notes is None or not hasattr(notes, "keys"):
+ return set()
+
+ try:
+ return {str(value) for value in notes.keys() if isinstance(value, str) and value}
+ except _CONTEXT_ERRORS:
+ return set()
+
+
+def _resolve_candidate_plan(
+ company: Any,
+ question: str,
+ *,
+ route: str,
+ include: list[str] | None = None,
+ exclude: list[str] | None = None,
+) -> dict[str, list[str]]:
+ requested = _resolve_candidate_modules(question, include=include, exclude=exclude)
+ sections_set = _available_sections_topics(company) if route in {"sections", "hybrid"} else set()
+ report_set = _available_report_modules(company) if route in {"report", "hybrid"} else set()
+ notes_set = _available_notes_modules(company) if route == "hybrid" else set()
+ explicit_direct = set(_resolve_direct_hint_modules(question))
+ boosted_direct: list[str] = []
+ _apply_question_specific_boosts(question, boosted_direct)
+ explicit_direct.update(name for name in boosted_direct if name not in _FINANCE_CONTEXT_MODULES)
+ if include:
+ explicit_direct.update(_normalize_candidate_module(name) for name in include)
+
+ sections: list[str] = []
+ report: list[str] = []
+ finance: list[str] = []
+ direct: list[str] = []
+ verified: list[str] = []
+
+ for name in requested:
+ normalized = _normalize_candidate_module(name)
+ if normalized in _FINANCE_CONTEXT_MODULES:
+ if route in {"finance", "hybrid"}:
+ _append_unique(finance, normalized)
+ _append_unique(verified, normalized)
+ continue
+ if normalized in sections_set and normalized not in _SECTIONS_ROUTE_EXCLUDE_TOPICS:
+ _append_unique(sections, normalized)
+ _append_unique(verified, normalized)
+ continue
+ if normalized in report_set:
+ _append_unique(report, normalized)
+ _append_unique(verified, normalized)
+ continue
+ if normalized in notes_set and normalized in explicit_direct:
+ _append_unique(direct, normalized)
+ _append_unique(verified, normalized)
+ continue
+
+ if normalized in explicit_direct:
+ data = _resolve_module_data(company, normalized)
+ if data is not None:
+ _append_unique(direct, normalized)
+ _append_unique(verified, normalized)
+
+ return {
+ "requested": requested,
+ "sections": sections,
+ "report": report,
+ "finance": finance,
+ "direct": direct,
+ "verified": verified,
+ }
+
+
+def _resolve_finance_modules_for_question(
+ question: str,
+ *,
+ q_types: list[str],
+ route: str,
+ candidate_plan: dict[str, list[str]],
+) -> list[str]:
+ selected: list[str] = []
+ finance_candidates = [name for name in candidate_plan.get("finance", []) if name in _FINANCE_STATEMENT_MODULES]
+
+ if _has_margin_driver_pattern(question):
+ _append_unique(selected, "IS")
+
+ if route == "finance":
+ if _question_has_any(question, _INCOME_STATEMENT_HINTS):
+ _append_unique(selected, "IS")
+ if _question_has_any(question, _BALANCE_SHEET_HINTS):
+ _append_unique(selected, "BS")
+ if _question_has_any(question, _CASHFLOW_HINTS):
+ _append_unique(selected, "CF")
+ if not selected:
+ selected.extend(["IS", "BS", "CF"])
+ elif route == "hybrid":
+ has_finance_signal = bool(finance_candidates) and (
+ _question_has_any(question, _BALANCE_SHEET_HINTS | _CASHFLOW_HINTS | _RATIO_HINTS)
+ or bool(set(q_types) & _ROUTE_FINANCE_TYPES)
+ or any(name in candidate_plan.get("report", []) for name in ("dividend", "shareCapital"))
+ )
+ if not has_finance_signal:
+ return []
+
+ for module_name in finance_candidates:
+ _append_unique(selected, module_name)
+
+ if not selected:
+ if _question_has_any(question, _CASHFLOW_HINTS):
+ selected.extend(["IS", "CF"])
+ elif _question_has_any(question, _BALANCE_SHEET_HINTS):
+ selected.extend(["IS", "BS"])
+ else:
+ selected.append("IS")
+
+ if route == "finance" or _question_has_any(question, _RATIO_HINTS) or bool(set(q_types) & _ROUTE_FINANCE_TYPES):
+ _append_unique(selected, "ratios")
+ elif route == "hybrid" and {"dividend", "shareCapital"} & set(candidate_plan.get("report", [])):
+ _append_unique(selected, "ratios")
+
+ return selected
+
+
+def _build_direct_module_context(
+ company: Any,
+ modules: list[str],
+ *,
+ compact: bool,
+ question: str,
+) -> dict[str, str]:
+ result: dict[str, str] = {}
+ for name in modules:
+ try:
+ data = _resolve_module_data(company, name)
+ except _CONTEXT_ERRORS:
+ data = None
+ if data is None:
+ continue
+ if isinstance(data, pl.DataFrame):
+ data = _trim_period_columns(data, question, compact=compact)
+ section = _build_module_section(name, data, compact=compact)
+ if section:
+ result[name] = section
+ return result
+
+
+def _trim_period_columns(data: pl.DataFrame, question: str, *, compact: bool) -> pl.DataFrame:
+ if data.is_empty():
+ return data
+
+ period_cols = [column for column in data.columns if isinstance(column, str) and _PERIOD_COLUMN_RE.fullmatch(column)]
+ if len(period_cols) <= 1:
+ return data
+
+ def sort_key(value: str) -> tuple[int, int]:
+ if "Q" in value:
+ year, quarter = value.split("Q", 1)
+ return int(year), int(quarter)
+ return int(value), 9
+
+ ordered_periods = sorted(period_cols, key=sort_key)
+ keep_periods = _detect_year_hint(question)
+ if compact:
+ keep_periods = min(keep_periods, 5)
+ else:
+ keep_periods = min(keep_periods, 8)
+ if len(ordered_periods) <= keep_periods:
+ return data
+
+ selected_periods = ordered_periods[-keep_periods:]
+ base_columns = [column for column in data.columns if column not in period_cols]
+ return data.select(base_columns + selected_periods)
+
+
+def _build_response_contract(
+ question: str,
+ *,
+ included_modules: list[str],
+ route: str,
+) -> str | None:
+ lines = ["## ์๋ต ๊ณ์ฝ", "- ์๋ ๋ชจ๋์ ์ด๋ฏธ ๋ก์ปฌ dartlab ๋ฐ์ดํฐ์์ ํ์ธ๋์ด ํฌํจ๋์์ต๋๋ค."]
+ lines.append(f"- ํฌํจ ๋ชจ๋: {', '.join(included_modules)}")
+ lines.append("- ํฌํจ๋ ๋ชจ๋์ ๋ณด๊ณ ๋ '๋ฐ์ดํฐ๊ฐ ์๋ค'๊ณ ๋งํ์ง ๋ง์ธ์.")
+ lines.append("- ํต์ฌ ๊ฒฐ๋ก 1~2๋ฌธ์ฅ์ ๋จผ์ ์ ์ํ๊ณ , ๋ฐ๋ก ๊ทผ๊ฑฐ ํ๋ ๊ทผ๊ฑฐ bullet์ ๋ถ์ด์ธ์.")
+ lines.append(
+ "- `explore()` ๊ฐ์ ๋๊ตฌ ํธ์ถ ๊ณํ์ด๋ ๋ด๋ถ ์ ์ฐจ ์ค๋ช
์ ๋ต๋ณ ๋ณธ๋ฌธ์ ์ฐ์ง ๋ง๊ณ ๋ฐ๋ก ๋ถ์ ๊ฒฐ๊ณผ๋ฅผ ๋งํ์ธ์."
+ )
+ lines.append(
+ "- ๋ต๋ณ ๋ณธ๋ฌธ์์๋ `IS/BS/CF/ratios/TTM/topic/period/source` ๊ฐ์ ๋ด๋ถ ์ฝ์ด๋ ํ๋๋ช
์ ๊ทธ๋๋ก ์ฐ์ง ๋ง๊ณ "
+ "`์์ต๊ณ์ฐ์/์ฌ๋ฌด์ํํ/ํ๊ธํ๋ฆํ/์ฌ๋ฌด๋น์จ/์ต๊ทผ 4๋ถ๊ธฐ ํฉ์ฐ/ํญ๋ชฉ/์์ /์ถ์ฒ`์ฒ๋ผ ์ฌ์ฉ์ ์ธ์ด๋ก ๋ฐ๊พธ์ธ์."
+ )
+ lines.append(
+ "- `costByNature`, `businessOverview`, `productService` ๊ฐ์ ๋ด๋ถ ๋ชจ๋๋ช
๋ ๊ฐ๊ฐ "
+ "`์ฑ๊ฒฉ๋ณ ๋น์ฉ ๋ถ๋ฅ`, `์ฌ์
์ ๊ฐ์`, `์ ํยท์๋น์ค`์ฒ๋ผ ๋ฐ๊ฟ ์ฐ์ธ์."
+ )
+
+ module_set = set(included_modules)
+ if "costByNature" in module_set:
+ lines.append("- `costByNature`๊ฐ ์์ผ๋ฉด ์์ ๋น์ฉ ํญ๋ชฉ 3~5๊ฐ์ ์ต๊ทผ ๊ธฐ๊ฐ ๋ณํ ๋ฐฉํฅ์ ๋จผ์ ์์ฝํ์ธ์.")
+ lines.append("- ๊ธฐ๊ฐ์ด ๋ช
์๋์ง ์์๋ ์ต์ ์์ ๊ณผ ์ต๊ทผ ์ถ์ธ๋ฅผ ๋จผ์ ๋ตํ๊ณ , ์ฐ๋ ๊ธฐ์ค์ ๋ค์ ๋ฌป์ง ๋ง์ธ์.")
+ if "dividend" in module_set:
+ lines.append("- `dividend`๊ฐ ์์ผ๋ฉด DPSยท๋ฐฐ๋น์์ต๋ฅ ยท๋ฐฐ๋น์ฑํฅ์ ๋จผ์ ์์ฝํ์ธ์.")
+ lines.append(
+ "- `dividend`๊ฐ ์๋๋ฐ๋ ๋ฐฐ๋น ๋ฐ์ดํฐ๊ฐ ์๋ค๊ณ ๋งํ์ง ๋ง์ธ์. ์ฒซ ๋ฌธ์ฅ์ด๋ ์ฒซ ํ์์ DPS์ ๋ฐฐ๋น์์ต๋ฅ ์ ์ง์ ์ธ์ฉํ์ธ์."
+ )
+ if {"dividend", "IS", "CF"} <= module_set or {"dividend", "CF"} <= module_set:
+ lines.append("- `dividend`์ `IS/CF`๊ฐ ๊ฐ์ด ์์ผ๋ฉด ๋ฐฐ๋น์ ์ด์ต/ํ๊ธํ๋ฆ ์ปค๋ฒ ์ฌ๋ถ๋ฅผ ํ ์ค๋ก ๋ช
์ํ์ธ์.")
+ if _has_distress_pattern(question):
+ lines.append(
+ "- `๋ถ์ค ์งํ` ์ง๋ฌธ์ด๋ฉด ๊ฑด์ ์ฑ ๊ฒฐ๋ก ์ ๋จผ์ ๋งํ๊ณ , ์์ต์ฑยทํ๊ธํ๋ฆยท์ฐจ์
๋ถ๋ด ์์ผ๋ก ์งง๊ฒ ์ ๋ฆฌํ์ธ์."
+ )
+ if route == "sections" or any(keyword in question for keyword in ("๊ทผ๊ฑฐ", "์", "์ต๊ทผ ๊ณต์ ๊ธฐ์ค", "์ถ์ฒ")):
+ lines.append("- ๊ทผ๊ฑฐ ์ง๋ฌธ์ด๋ฉด `topic`, `period`, `source`๋ฅผ ์ต์ 2๊ฐ ๋ช
์ํ์ธ์.")
+ lines.append(
+ "- `period`์ `source`๋ outline ํ์ ๋์จ ์ค์ ๊ฐ์ ์ฐ์ธ์. '์ต๊ทผ ๊ณต์ ๊ธฐ์ค' ๊ฐ์ ํฌ๊ด ํํ์ผ๋ก ๋ญ๊ฐ์ง ๋ง์ธ์."
+ )
+ lines.append("- ๋ณธ๋ฌธ์์๋ `topic/period/source` ๋์ `ํญ๋ชฉ/์์ /์ถ์ฒ`์ฒ๋ผ ์์ฐ์ด๋ฅผ ์ฐ์ธ์.")
+ hasQuarterly = any(m.endswith("_quarterly") for m in module_set)
+ if hasQuarterly:
+ lines.append("- **๋ถ๊ธฐ๋ณ ๋ฐ์ดํฐ๊ฐ ํฌํจ๋์์ต๋๋ค. '๋ถ๊ธฐ ๋ฐ์ดํฐ๊ฐ ์๋ค'๊ณ ์ ๋ ๋งํ์ง ๋ง์ธ์.**")
+ lines.append("- ๋ถ๊ธฐ๋ณ ์ถ์ด๋ฅผ ํ
์ด๋ธ๋ก ์ ๋ฆฌํ๊ณ , ์ ๋ถ๊ธฐ ๋๋น(QoQ)์ ์ ๋
๋๊ธฐ ๋๋น(YoY) ๋ณํ๋ฅผ ํจ๊ป ๋ณด์ฌ์ฃผ์ธ์.")
+ lines.append(
+ "- `IS_quarterly`, `CF_quarterly` ๊ฐ์ ๋ด๋ถ๋ช
๋์ `๋ถ๊ธฐ๋ณ ์์ต๊ณ์ฐ์`, `๋ถ๊ธฐ๋ณ ํ๊ธํ๋ฆํ`๋ก ์ฐ์ธ์."
+ )
+
+ # โโ ๋๊ตฌ ์ถ์ฒ ํํธ โโ
+ hasFinancial = {"IS", "BS"} <= module_set or {"IS", "CF"} <= module_set
+ if hasFinancial:
+ lines.append(
+ "- **์ถ๊ฐ ๋ถ์ ์ถ์ฒ**: `finance(action='ratios')`๋ก ์ฌ๋ฌด๋น์จ ํ์ธ, "
+ "`explore(action='search', keyword='...')`๋ก ๋ณํ ์์ธ ํ์
."
+ )
+ elif not module_set & {"IS", "BS", "CF", "ratios"}:
+ lines.append(
+ "- **์ฌ๋ฌด ๋ฐ์ดํฐ ๋ฏธํฌํจ**: `finance(action='modules')`๋ก ์ฌ์ฉ ๊ฐ๋ฅ ๋ชจ๋ ํ์ธ, "
+ "`explore(action='topics')`๋ก topic ๋ชฉ๋ก ํ์ธ ์ถ์ฒ."
+ )
+ return "\n".join(lines)
+
+
+def _build_clarification_context(
+ company: Any,
+ question: str,
+ *,
+ candidate_plan: dict[str, list[str]],
+) -> str | None:
+ if _has_margin_driver_pattern(question):
+ return None
+
+ lowered = question.lower()
+ module_set = set(candidate_plan.get("verified", []))
+ has_cost_by_nature = "costByNature" in module_set
+ if not has_cost_by_nature and "costByNature" in set(candidate_plan.get("requested", [])):
+ try:
+ has_cost_by_nature = _resolve_module_data(company, "costByNature") is not None
+ except _CONTEXT_ERRORS:
+ has_cost_by_nature = False
+ has_is = "IS" in module_set or "IS" in set(candidate_plan.get("requested", []))
+ if not has_cost_by_nature or not has_is:
+ return None
+ if "๋น์ฉ" not in lowered:
+ return None
+ if any(keyword in lowered for keyword in ("์ฑ๊ฒฉ", "์ธ๊ฑด๋น", "๊ฐ๊ฐ์๊ฐ", "๊ด๊ณ ์ ์ ", "ํ๊ด", "๋งค์ถ์๊ฐ")):
+ return None
+
+ return (
+ "## Clarification Needed\n"
+ "- ํ์ฌ ๋ก์ปฌ์์ ๋ ํด์์ด ๋ชจ๋ ๊ฐ๋ฅํฉ๋๋ค.\n"
+ "- `costByNature`: ์ธ๊ฑด๋นยท๊ฐ๊ฐ์๊ฐ๋น ๊ฐ์ ์ฑ๊ฒฉ๋ณ ๋น์ฉ ๋ถ๋ฅ\n"
+ "- `IS`: ๋งค์ถ์๊ฐยทํ๊ด๋น ๊ฐ์ ๊ธฐ๋ฅ๋ณ ๋น์ฉ ์ด์ก\n"
+ "- ์ฌ์ฉ์์ ์๋๊ฐ ๋ ์ค ์ด๋ ์ชฝ์ธ์ง ๊ฒฐ๋ก ์ ๋ฐ๊พธ๋ฏ๋ก, ๋จผ์ ํ ๋ฌธ์ฅ์ผ๋ก ์ด๋ ๊ด์ ์ ์ํ๋์ง ํ์ธํ์ธ์.\n"
+ "- ํ์ธ ์ง๋ฌธ์ ํ ๋ฌธ์ฅ๋ง ํ์ธ์. ๊ฐ์ ๋ฌธ์ฅ์ ๋ฐ๋ณตํ์ง ๋ง์ธ์."
+ )
+
+
+def _resolve_report_modules_for_question(
+ question: str,
+ *,
+ include: list[str] | None = None,
+ exclude: list[str] | None = None,
+) -> list[str]:
+ modules: list[str] = []
+
+ for keyword, name in _ROUTE_REPORT_KEYWORDS.items():
+ if keyword in question and name not in modules:
+ modules.append(name)
+
+ if include:
+ for name in include:
+ if (
+ name in {"dividend", "employee", "majorHolder", "executive", "audit", "treasuryStock"}
+ and name not in modules
+ ):
+ modules.append(name)
+
+ if exclude:
+ modules = [name for name in modules if name not in exclude]
+
+ return modules
+
+
+def _resolve_sections_topics(
+ company: Any,
+ question: str,
+ *,
+ q_types: list[str],
+ candidates: list[str] | None = None,
+ include: list[str] | None = None,
+ exclude: list[str] | None = None,
+ limit: int = 2,
+) -> list[str]:
+ docs = getattr(company, "docs", None)
+ sections = getattr(docs, "sections", None)
+ if sections is None:
+ return []
+
+ manifest = sections.outline() if hasattr(sections, "outline") else None
+ available = (
+ manifest["topic"].drop_nulls().to_list()
+ if isinstance(manifest, pl.DataFrame) and "topic" in manifest.columns
+ else sections.topics()
+ if hasattr(sections, "topics")
+ else []
+ )
+ availableTopics = [topic for topic in available if isinstance(topic, str) and topic]
+ availableSet = set(availableTopics)
+ if not availableSet:
+ return []
+
+ selected: list[str] = []
+ isQuarterly = _detectGranularity(question) == "quarterly"
+
+ def append(topic: str) -> None:
+ if topic in _SECTIONS_ROUTE_EXCLUDE_TOPICS:
+ if not (isQuarterly and topic == "fsSummary"):
+ return
+ if topic in availableSet and topic not in selected:
+ selected.append(topic)
+
+ if isQuarterly:
+ append("fsSummary")
+
+ if include:
+ for name in include:
+ append(name)
+
+ if _has_recent_disclosure_business_pattern(question):
+ append("disclosureChanges")
+ append("businessOverview")
+
+ candidate_source = _resolve_tables(question, None, exclude) if candidates is None else candidates
+ for name in candidate_source:
+ append(name)
+
+ for q_type in q_types:
+ for topic in _SECTIONS_TYPE_DEFAULTS.get(q_type, []):
+ append(topic)
+
+ for keyword, topics in _SECTIONS_KEYWORD_TOPICS.items():
+ if keyword in question:
+ for topic in topics:
+ append(topic)
+
+ if candidates is None and not selected and availableTopics:
+ selected.append(availableTopics[0])
+
+ return selected[:limit]
+
+
+def _build_sections_context(
+ company: Any,
+ topics: list[str],
+ *,
+ compact: bool,
+) -> dict[str, str]:
+ docs = getattr(company, "docs", None)
+ sections = getattr(docs, "sections", None)
+ if sections is None:
+ return {}
+
+ try:
+ context_slices = getattr(docs, "contextSlices", None) if docs is not None else None
+ except _CONTEXT_ERRORS:
+ context_slices = None
+
+ result: dict[str, str] = {}
+ for topic in topics:
+ outline = sections.outline(topic) if hasattr(sections, "outline") else None
+ if outline is None or not isinstance(outline, pl.DataFrame) or outline.is_empty():
+ continue
+
+ label_fn = getattr(company, "_topicLabel", None)
+ label = label_fn(topic) if callable(label_fn) else topic
+ lines = [f"\n## {label}"]
+ lines.append(df_to_markdown(outline.head(6 if compact else 10), max_rows=6 if compact else 10, compact=True))
+
+ topic_slices = _select_section_slices(context_slices, topic)
+ if isinstance(topic_slices, pl.DataFrame) and not topic_slices.is_empty():
+ lines.append("\n### ํต์ฌ ๊ทผ๊ฑฐ")
+ for row in topic_slices.head(2 if compact else 4).iter_rows(named=True):
+ period = row.get("period", "-")
+ source_topic = row.get("sourceTopic") or row.get("topic") or topic
+ block_type = "ํ" if row.get("isTable") or row.get("blockType") == "table" else "๋ฌธ์ฅ"
+ slice_text = _truncate_section_slice(str(row.get("sliceText") or ""), compact=compact)
+ if not slice_text:
+ continue
+ lines.append(f"#### ์์ : {period} | ์ถ์ฒ: {source_topic} | ์ ํ: {block_type}")
+ lines.append(slice_text)
+
+ if compact:
+ if ("preview" in outline.columns) and not (
+ isinstance(topic_slices, pl.DataFrame) and not topic_slices.is_empty()
+ ):
+ preview_lines: list[str] = []
+ for row in outline.head(2).iter_rows(named=True):
+ preview = row.get("preview")
+ if not isinstance(preview, str) or not preview.strip():
+ continue
+ period = row.get("period", "-")
+ title = row.get("title", "-")
+ preview_lines.append(
+ f"- period: {period} | source: docs | title: {title} | preview: {preview.strip()}"
+ )
+ if preview_lines:
+ lines.append("\n### ํต์ฌ preview")
+ lines.extend(preview_lines)
+ result[f"section_{topic}"] = "\n".join(lines)
+ continue
+
+ try:
+ raw_sections = sections.raw if hasattr(sections, "raw") else None
+ except _CONTEXT_ERRORS:
+ raw_sections = None
+
+ topic_rows = (
+ raw_sections.filter(pl.col("topic") == topic)
+ if isinstance(raw_sections, pl.DataFrame) and "topic" in raw_sections.columns
+ else None
+ )
+
+ block_builder = getattr(company, "_buildBlockIndex", None)
+ block_index = (
+ block_builder(topic_rows) if callable(block_builder) and isinstance(topic_rows, pl.DataFrame) else None
+ )
+
+ if isinstance(block_index, pl.DataFrame) and not block_index.is_empty():
+ lines.append("\n### block index")
+ lines.append(
+ df_to_markdown(block_index.head(4 if compact else 6), max_rows=4 if compact else 6, compact=True)
+ )
+
+ block_col = (
+ "block"
+ if "block" in block_index.columns
+ else "blockOrder"
+ if "blockOrder" in block_index.columns
+ else None
+ )
+ type_col = (
+ "type" if "type" in block_index.columns else "blockType" if "blockType" in block_index.columns else None
+ )
+ sample_block = None
+ if block_col:
+ for row in block_index.iter_rows(named=True):
+ block_no = row.get(block_col)
+ block_type = row.get(type_col)
+ if isinstance(block_no, int) and block_type in {"text", "table"}:
+ sample_block = block_no
+ break
+ if sample_block is not None:
+ show_section_block = getattr(company, "_showSectionBlock", None)
+ block_data = (
+ show_section_block(topic_rows, block=sample_block)
+ if callable(show_section_block) and isinstance(topic_rows, pl.DataFrame)
+ else None
+ )
+ section = _build_module_section(topic, block_data, compact=compact, max_rows=4 if compact else 6)
+ if section:
+ lines.append("\n### ๋ํ block")
+ lines.append(section.replace(f"\n## {label}", "", 1).strip())
+
+ result[f"section_{topic}"] = "\n".join(lines)
+
+ return result
+
+
+def _select_section_slices(context_slices: Any, topic: str) -> pl.DataFrame | None:
+ if not isinstance(context_slices, pl.DataFrame) or context_slices.is_empty():
+ return None
+
+ required_columns = {"topic", "periodOrder", "sliceText"}
+ if not required_columns <= set(context_slices.columns):
+ return None
+
+ detail_col = pl.col("detailTopic") if "detailTopic" in context_slices.columns else pl.lit(None)
+ semantic_col = pl.col("semanticTopic") if "semanticTopic" in context_slices.columns else pl.lit(None)
+ block_priority_col = pl.col("blockPriority") if "blockPriority" in context_slices.columns else pl.lit(0)
+ slice_idx_col = pl.col("sliceIdx") if "sliceIdx" in context_slices.columns else pl.lit(0)
+
+ matched = context_slices.filter((pl.col("topic") == topic) | (detail_col == topic) | (semantic_col == topic))
+ if matched.is_empty():
+ return None
+
+ return matched.with_columns(
+ pl.when(detail_col == topic)
+ .then(3)
+ .when(semantic_col == topic)
+ .then(2)
+ .when(pl.col("topic") == topic)
+ .then(1)
+ .otherwise(0)
+ .alias("matchPriority")
+ ).sort(
+ ["periodOrder", "matchPriority", "blockPriority", "sliceIdx"],
+ descending=[True, True, True, False],
+ )
+
+
+def _truncate_section_slice(text: str, *, compact: bool) -> str:
+ stripped = text.strip()
+ if not stripped:
+ return ""
+ max_chars = 500 if compact else 1200
+ if len(stripped) <= max_chars:
+ return stripped
+ return stripped[:max_chars].rstrip() + " ..."
+
+
+def build_context_by_module(
+ company: Any,
+ question: str,
+ include: list[str] | None = None,
+ exclude: list[str] | None = None,
+ compact: bool = False,
+) -> tuple[dict[str, str], list[str], str]:
+ """financeEngine ์ฐ์ compact ์ปจํ
์คํธ ๋น๋ (๋ชจ๋๋ณ ๋ถ๋ฆฌ).
+
+ 1์ฐจ: financeEngine annual + ratios (๋น ๋ฅด๊ณ ์ ๊ทํ๋ ์์น)
+ 2์ฐจ: docsParser ์ ์ฑ ๋ฐ์ดํฐ (๋ฐฐ๋น, ๊ฐ์ฌ, ์์ ๋ฑ โ ์ง๋ฌธ์ ๋ง๋ ๊ฒ๋ง)
+
+ Args:
+ compact: True๋ฉด ์ํ ๋ชจ๋ธ์ฉ์ผ๋ก ์ฐ๋/ํ์ ์ ํ (Ollama).
+
+ Returns:
+ (modules_dict, included_list, header_text)
+ - modules_dict: {"IS": "## ์์ต๊ณ์ฐ์\n...", "BS": "...", ...}
+ - included_list: ["IS", "BS", "CF", "ratios", ...]
+ - header_text: ๊ธฐ์
๋ช
+ ๋ฐ์ดํฐ ๊ธฐ์ค ๋ผ์ธ
+ """
+ from dartlab import config
+
+ orig_verbose = config.verbose
+ config.verbose = False
+ try:
+ return _build_compact_context_modules_inner(company, question, include, exclude, compact, orig_verbose)
+ finally:
+ config.verbose = orig_verbose
+
+
+def _build_compact_context_modules_inner(
+ company: Any,
+ question: str,
+ include: list[str] | None,
+ exclude: list[str] | None,
+ compact: bool,
+ orig_verbose: bool,
+) -> tuple[dict[str, str], list[str], str]:
+ n_years = _detect_year_hint(question)
+ if compact:
+ n_years = min(n_years, 4)
+ modules_dict: dict[str, str] = {}
+ included: list[str] = []
+
+ header_parts = [f"# {company.corpName} ({company.stockCode})"]
+
+ try:
+ detail = getattr(company, "companyOverviewDetail", None)
+ if detail and isinstance(detail, dict):
+ info_parts = []
+ if detail.get("ceo"):
+ info_parts.append(f"๋ํ: {detail['ceo']}")
+ if detail.get("mainBusiness"):
+ info_parts.append(f"์ฃผ์์ฌ์
: {detail['mainBusiness']}")
+ if info_parts:
+ header_parts.append("> " + " | ".join(info_parts))
+ except _CONTEXT_ERRORS:
+ pass
+
+ from dartlab.ai.conversation.prompts import _classify_question_multi
+
+ q_types = _classify_question_multi(question, max_types=2)
+ route = _resolve_context_route(question, include=include, q_types=q_types)
+ report_modules = _resolve_report_modules_for_question(question, include=include, exclude=exclude)
+ candidate_plan = _resolve_candidate_plan(company, question, route=route, include=include, exclude=exclude)
+ selected_finance_modules = _resolve_finance_modules_for_question(
+ question,
+ q_types=q_types,
+ route=route,
+ candidate_plan=candidate_plan,
+ )
+
+ acct_filters: dict[str, set[str]] = {}
+ if compact:
+ for qt in q_types:
+ for sj, ids in _QUESTION_ACCOUNT_FILTER.get(qt, {}).items():
+ acct_filters.setdefault(sj, set()).update(ids)
+
+ statement_modules = [name for name in selected_finance_modules if name in _FINANCE_STATEMENT_MODULES]
+ if statement_modules:
+ annual = getattr(company, "annual", None)
+ if annual is not None:
+ series, years = annual
+ quarter_counts = _get_quarter_counts(company)
+ if years:
+ yr_min = years[max(0, len(years) - n_years)]
+ yr_max = years[-1]
+ header = f"\n**๋ฐ์ดํฐ ๊ธฐ์ค: {yr_min}~{yr_max}๋
** (๊ฐ์ฅ ์ต๊ทผ: {yr_max}๋
, ๊ธ์ก: ์ต/์กฐ์)\n"
+
+ partial = [y for y in years[-n_years:] if quarter_counts.get(y, 4) < 4]
+ if partial:
+ notes = ", ".join(f"{y}๋
=Q1~Q{quarter_counts[y]}" for y in partial)
+ header += (
+ f"โ ๏ธ **๋ถ๋ถ ์ฐ๋ ์ฃผ์**: {notes} (ํด๋น ์ฐ๋๋ ๋ถ๊ธฐ ๋์ ์ด๋ฏ๋ก ์ ๋
์ฐ๊ฐ๊ณผ ์ง์ ๋น๊ต ๋ถ๊ฐ)\n"
+ )
+
+ header_parts.append(header)
+
+ for sj in statement_modules:
+ af = acct_filters.get(sj) if acct_filters and sj in {"IS", "BS", "CF"} else None
+ section = _build_finance_engine_section(
+ series,
+ years,
+ sj,
+ n_years,
+ af,
+ quarter_counts=quarter_counts,
+ )
+ if section:
+ modules_dict[sj] = section
+ included.append(sj)
+
+ if _detectGranularity(question) == "quarterly" and statement_modules:
+ ts = getattr(company, "timeseries", None)
+ if ts is not None:
+ tsSeries, tsPeriods = ts
+ for sj in statement_modules:
+ if sj in {"IS", "CF"}:
+ af = acct_filters.get(sj) if acct_filters else None
+ qSection = _buildQuarterlySection(
+ tsSeries,
+ tsPeriods,
+ sj,
+ nQuarters=8,
+ accountFilter=af,
+ )
+ if qSection:
+ qKey = f"{sj}_quarterly"
+ modules_dict[qKey] = qSection
+ included.append(qKey)
+
+ if "ratios" in selected_finance_modules:
+ ratios_section = _build_ratios_section(company, compact=compact, q_types=q_types or None)
+ if ratios_section:
+ modules_dict["ratios"] = ratios_section
+ if "ratios" not in included:
+ included.append("ratios")
+
+ requested_report_modules = report_modules or candidate_plan.get("report", [])
+ if route == "report":
+ requested_report_modules = requested_report_modules or [
+ "dividend",
+ "employee",
+ "majorHolder",
+ "executive",
+ "audit",
+ ]
+ report_sections = _build_report_sections(
+ company,
+ compact=compact,
+ q_types=q_types,
+ tier="focused" if compact else "full",
+ report_names=requested_report_modules,
+ )
+ for key, section in report_sections.items():
+ modules_dict[key] = section
+ included_name = _section_key_to_module_name(key)
+ if included_name not in included:
+ included.append(included_name)
+
+ if route == "hybrid" and requested_report_modules:
+ report_sections = _build_report_sections(
+ company,
+ compact=compact,
+ q_types=q_types,
+ tier="focused" if compact else "full",
+ report_names=requested_report_modules,
+ )
+ for key, section in report_sections.items():
+ modules_dict[key] = section
+ included_name = _section_key_to_module_name(key)
+ if included_name not in included:
+ included.append(included_name)
+
+ if route in {"sections", "hybrid"}:
+ topics = _resolve_sections_topics(
+ company,
+ question,
+ q_types=q_types,
+ candidates=candidate_plan.get("sections"),
+ include=include,
+ exclude=exclude,
+ limit=1 if route == "hybrid" else 2,
+ )
+ sections_context = _build_sections_context(company, topics, compact=compact)
+ for key, section in sections_context.items():
+ modules_dict[key] = section
+ included_name = _section_key_to_module_name(key)
+ if included_name not in included:
+ included.append(included_name)
+
+ if route == "finance":
+ _financeSectionsTopics = ["businessStatus", "businessOverview"]
+ availableTopicSet = _topic_name_set(company)
+ lightTopics = [t for t in _financeSectionsTopics if t in availableTopicSet]
+ if lightTopics:
+ lightContext = _build_sections_context(company, lightTopics[:1], compact=True)
+ for key, section in lightContext.items():
+ modules_dict[key] = section
+ included_name = _section_key_to_module_name(key)
+ if included_name not in included:
+ included.append(included_name)
+
+ direct_sections = _build_direct_module_context(
+ company,
+ candidate_plan.get("direct", []),
+ compact=compact,
+ question=question,
+ )
+ for key, section in direct_sections.items():
+ modules_dict[key] = section
+ if key not in included:
+ included.append(key)
+
+ response_contract = _build_response_contract(question, included_modules=included, route=route)
+ if response_contract:
+ modules_dict["_response_contract"] = response_contract
+
+ clarification_context = _build_clarification_context(company, question, candidate_plan=candidate_plan)
+ if clarification_context:
+ modules_dict["_clarify"] = clarification_context
+
+ if not modules_dict:
+ text, inc = build_context(company, question, include, exclude, compact=True)
+ return {"_full": text}, inc, ""
+
+ deduped_included: list[str] = []
+ for name in included:
+ if name not in deduped_included:
+ deduped_included.append(name)
+
+ return modules_dict, deduped_included, "\n".join(header_parts)
+
+
+def build_compact_context(
+ company: Any,
+ question: str,
+ include: list[str] | None = None,
+ exclude: list[str] | None = None,
+) -> tuple[str, list[str]]:
+ """financeEngine ์ฐ์ compact ์ปจํ
์คํธ ๋น๋ (ํ์ํธํ).
+
+ build_context_by_module ๊ฒฐ๊ณผ๋ฅผ ๋จ์ผ ๋ฌธ์์ด๋ก ํฉ์ณ ๋ฐํํ๋ค.
+ """
+ modules_dict, included, header = build_context_by_module(
+ company,
+ question,
+ include,
+ exclude,
+ compact=True,
+ )
+ if "_full" in modules_dict:
+ return modules_dict["_full"], included
+
+ parts = [header] if header else []
+ for name in included:
+ for key in _module_name_to_section_keys(name):
+ if key in modules_dict:
+ parts.append(modules_dict[key])
+ break
+ return "\n".join(parts), included
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# ์ง๋ฌธ ํค์๋ โ ์๋ ํฌํจ ๋ฐ์ดํฐ ๋งคํ
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+from dartlab.core.registry import buildKeywordMap
+
+# registry aiKeywords ์๋ ์ญ์ธ๋ฑ์ค (~55 ๋ชจ๋ ํค์๋)
+_KEYWORD_MAP = buildKeywordMap()
+
+# ์ฌ๋ฌด์ ํ ์ง์ ๋งคํ (registry ๋ฒ์ ๋ฐ โ BS/IS/CF ๋ฑ ์ฌ๋ฌด ์ฝ๋)
+_FINANCIAL_MAP: dict[str, list[str]] = {
+ "์ฌ๋ฌด": ["BS", "IS", "CF", "fsSummary", "costByNature"],
+ "๊ฑด์ ์ฑ": ["BS", "audit", "contingentLiability", "internalControl", "bond"],
+ "์์ต": ["IS", "segments", "productService", "costByNature"],
+ "์ค์ ": ["IS", "segments", "fsSummary", "productService", "salesOrder"],
+ "๋งค์ถ": ["IS", "segments", "productService", "salesOrder"],
+ "์์
์ด์ต": ["IS", "fsSummary", "segments"],
+ "์์ด์ต": ["IS", "fsSummary"],
+ "ํ๊ธ": ["CF", "BS"],
+ "์์ฐ": ["BS", "tangibleAsset", "investmentInOther"],
+ "์ฑ์ฅ": ["IS", "CF", "productService", "salesOrder", "rnd"],
+ "์๊ฐ": ["costByNature", "IS"],
+ "๋น์ฉ": ["costByNature", "IS"],
+ "๋ฐฐ๋น": ["dividend", "IS", "shareCapital"],
+ "์๋ณธ": ["BS", "capitalChange", "shareCapital", "fundraising"],
+ "ํฌ์": ["CF", "rnd", "subsidiary", "investmentInOther", "tangibleAsset"],
+ "๋ถ์ฑ": ["BS", "bond", "contingentLiability", "capitalChange"],
+ "๋ฆฌ์คํฌ": ["contingentLiability", "sanction", "riskDerivative", "audit", "internalControl"],
+ "์ง๋ฐฐ": ["majorHolder", "executive", "boardOfDirectors", "holderOverview"],
+}
+
+# ๋ณตํฉ ๋ถ์ (์ฌ๋ฌ ์ฌ๋ฌด์ ํ ์กฐํฉ)
+_COMPOSITE_MAP: dict[str, list[str]] = {
+ "ROE": ["IS", "BS", "fsSummary"],
+ "ROA": ["IS", "BS", "fsSummary"],
+ "PER": ["IS", "fsSummary", "dividend"],
+ "PBR": ["BS", "fsSummary"],
+ "EPS": ["IS", "fsSummary", "dividend"],
+ "EBITDA": ["IS", "CF", "fsSummary"],
+ "ESG": ["employee", "boardOfDirectors", "sanction", "internalControl"],
+ "๊ฑฐ๋ฒ๋์ค": ["majorHolder", "executive", "boardOfDirectors", "audit"],
+ "์ง๋ฐฐ๊ตฌ์กฐ": ["majorHolder", "executive", "boardOfDirectors", "audit"],
+ "์ธ๋ ฅํํฉ": ["employee", "executivePay"],
+ "์ฃผ์ฃผํ์": ["dividend", "shareCapital", "capitalChange"],
+ "๋ถ์ฑ์ํ": ["BS", "bond", "contingentLiability"],
+ "๋ถ์ฑ๊ตฌ์กฐ": ["BS", "bond", "contingentLiability"],
+ "์ข
ํฉ์ง๋จ": ["BS", "IS", "CF", "fsSummary", "dividend", "majorHolder", "audit", "employee"],
+ "์ค์บ": ["BS", "IS", "dividend", "majorHolder", "audit", "employee"],
+ "์ ๋ฐ": ["BS", "IS", "CF", "fsSummary", "audit", "majorHolder"],
+ "์ข
ํฉ": ["BS", "IS", "CF", "fsSummary", "audit", "majorHolder"],
+ # ์๋ฌธ
+ "revenue": ["IS", "segments", "productService"],
+ "profit": ["IS", "fsSummary"],
+ "debt": ["BS", "bond", "contingentLiability"],
+ "cash flow": ["CF"],
+ "cashflow": ["CF"],
+ "dividend": ["dividend", "IS", "shareCapital"],
+ "growth": ["IS", "CF", "productService", "rnd"],
+ "risk": ["contingentLiability", "sanction", "riskDerivative", "audit"],
+ "audit": ["audit", "auditSystem", "internalControl"],
+ "governance": ["majorHolder", "executive", "boardOfDirectors"],
+ "employee": ["employee", "executivePay"],
+ "subsidiary": ["subsidiary", "affiliateGroup", "investmentInOther"],
+ "capex": ["CF", "tangibleAsset"],
+ "operating": ["IS", "fsSummary", "segments"],
+}
+
+# ์์ฐ์ด ์ง๋ฌธ ํจํด
+_NATURAL_LANG_MAP: dict[str, list[str]] = {
+ "๋": ["BS", "CF"],
+ "๋ฒ": ["IS", "fsSummary"],
+ "์": ["IS", "fsSummary", "segments"],
+ "์ํ": ["contingentLiability", "sanction", "riskDerivative", "audit", "internalControl"],
+ "์์ ": ["BS", "audit", "contingentLiability", "internalControl"],
+ "๊ฑด๊ฐ": ["BS", "IS", "CF", "audit"],
+ "์ ๋ง": ["IS", "CF", "rnd", "segments", "mdna"],
+ "๋น๊ต": ["IS", "BS", "CF", "fsSummary"],
+ "์ถ์ธ": ["IS", "BS", "CF", "fsSummary"],
+ "ํธ๋ ๋": ["IS", "BS", "CF", "fsSummary"],
+ "๋ถ์": ["BS", "IS", "CF", "fsSummary"],
+ "์ด๋ค ํ์ฌ": ["companyOverviewDetail", "companyOverview", "business", "companyHistory"],
+ "๋ฌด์จ ์ฌ์
": ["business", "productService", "segments", "companyOverviewDetail"],
+ "๋ญํ๋": ["business", "productService", "segments", "companyOverviewDetail"],
+ "์ด๋ค ์ฌ์
": ["business", "productService", "segments", "companyOverviewDetail"],
+}
+
+# ๋ณํฉ: registry ํค์๋ โ ์ฌ๋ฌด์ ํ โ ๋ณตํฉ โ ์์ฐ์ด (ํ์์๊ฐ ์ค๋ฒ๋ผ์ด๋)
+_TOPIC_MAP: dict[str, list[str]] = {**_KEYWORD_MAP, **_FINANCIAL_MAP, **_COMPOSITE_MAP, **_NATURAL_LANG_MAP}
+
+# ํญ์ ํฌํจ๋๋ ๊ธฐ๋ณธ ์ปจํ
์คํธ
+_BASE_CONTEXT = ["fsSummary"]
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# ํ ํฝ ๋งคํ
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def _resolve_tables(question: str, include: list[str] | None, exclude: list[str] | None) -> list[str]:
+ """์ง๋ฌธ๊ณผ include/exclude๋ก ํฌํจํ ํ
์ด๋ธ ๋ชฉ๋ก ๊ฒฐ์ .
+
+ ๊ฐ์ : ๋์๋ฌธ์ ๋ฌด์, ๋ถ๋ถ๋งค์นญ, ๋ณตํฉ ํค์๋ ์ง์.
+ """
+ tables: list[str] = list(_BASE_CONTEXT)
+
+ if include:
+ tables.extend(include)
+ else:
+ q_lower = question.lower()
+ matched_count = 0
+
+ for keyword, table_names in _TOPIC_MAP.items():
+ # ๋์๋ฌธ์ ๋ฌด์ ๋งค์นญ
+ if keyword.lower() in q_lower:
+ matched_count += 1
+ for t in table_names:
+ if t not in tables:
+ tables.append(t)
+
+ # ๋งคํ ์ ๋์ผ๋ฉด ๊ธฐ๋ณธ ์ฌ๋ฌด์ ํ ํฌํจ
+ if matched_count == 0:
+ tables.extend(["BS", "IS", "CF"])
+
+ # ๋๋ฌด ๋ง์ ๋ชจ๋์ด ๋งค์นญ๋๋ฉด ์์ ์ฐ์ ์์๋ง (ํ ํฐ ์ ์ฝ)
+ # ํต์ฌ ๋ชจ๋(BS/IS/CF/fsSummary)์ ํญ์ ์ ์ง
+ _CORE = {"fsSummary", "BS", "IS", "CF"}
+ if len(tables) > 12:
+ core = [t for t in tables if t in _CORE]
+ non_core = [t for t in tables if t not in _CORE]
+ tables = core + non_core[:8]
+
+ if exclude:
+ tables = [t for t in tables if t not in exclude]
+
+ return tables
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# ์ปจํ
์คํธ ์กฐ๋ฆฝ
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def build_context(
+ company: Any,
+ question: str,
+ include: list[str] | None = None,
+ exclude: list[str] | None = None,
+ max_rows: int = 30,
+ compact: bool = False,
+) -> tuple[str, list[str]]:
+ """์ง๋ฌธ๊ณผ Company ์ธ์คํด์ค๋ก๋ถํฐ LLM context ํ
์คํธ ์กฐ๋ฆฝ.
+
+ Args:
+ compact: True๋ฉด ํต์ฌ ๊ณ์ ๋ง, ์ต/์กฐ ๋จ์, ๊ฐ๊ฒฐ ํฌ๋งท (์ํ ๋ชจ๋ธ์ฉ).
+
+ Returns:
+ (context_text, included_table_names)
+ """
+ from dartlab.ai.context.formatting import _KEY_ACCOUNTS_MAP
+
+ tables_to_include = _resolve_tables(question, include, exclude)
+
+ # fsSummary ์ค๋ณต ์ ๊ฑฐ: BS+IS ๋ ๋ค ์์ผ๋ฉด fsSummary ์คํต
+ if compact and "fsSummary" in tables_to_include:
+ has_bs = "BS" in tables_to_include
+ has_is = "IS" in tables_to_include
+ if has_bs and has_is:
+ tables_to_include = [t for t in tables_to_include if t != "fsSummary"]
+
+ from dartlab import config
+
+ orig_verbose = config.verbose
+ config.verbose = False
+
+ sections = []
+ included = []
+
+ sections.append(f"# {company.corpName} ({company.stockCode})")
+
+ try:
+ detail = getattr(company, "companyOverviewDetail", None)
+ if detail and isinstance(detail, dict):
+ info_parts = []
+ if detail.get("ceo"):
+ info_parts.append(f"๋ํ: {detail['ceo']}")
+ if detail.get("mainBusiness"):
+ info_parts.append(f"์ฃผ์์ฌ์
: {detail['mainBusiness']}")
+ if detail.get("foundedDate"):
+ info_parts.append(f"์ค๋ฆฝ: {detail['foundedDate']}")
+ if info_parts:
+ sections.append("> " + " | ".join(info_parts))
+ except _CONTEXT_ERRORS:
+ pass
+
+ year_range = detect_year_range(company, tables_to_include)
+ if year_range:
+ sections.append(
+ f"\n**๋ฐ์ดํฐ ๊ธฐ์ค: {year_range['min_year']}~{year_range['max_year']}๋
** (๊ฐ์ฅ ์ต๊ทผ: {year_range['max_year']}๋
)"
+ )
+ if not compact:
+ sections.append("์ดํ ๋ฐ์ดํฐ๋ ํฌํจ๋์ด ์์ง ์์ต๋๋ค.\n")
+
+ if compact:
+ sections.append("\n๊ธ์ก: ์ต/์กฐ์ ํ์ (์๋ณธ ๋ฐฑ๋ง์)\n")
+ else:
+ sections.append("")
+ sections.append("๋ชจ๋ ๊ธ์ก์ ๋ณ๋ ํ๊ธฐ ์์ผ๋ฉด ๋ฐฑ๋ง์(millions KRW) ๋จ์์
๋๋ค.")
+ sections.append("")
+
+ for name in tables_to_include:
+ try:
+ data = getattr(company, name, None)
+ if data is None:
+ continue
+
+ if callable(data) and not isinstance(data, type):
+ try:
+ result = data()
+ if hasattr(result, "FS") and isinstance(getattr(result, "FS", None), pl.DataFrame):
+ data = result.FS
+ elif isinstance(result, pl.DataFrame):
+ data = result
+ else:
+ data = result
+ except _CONTEXT_ERRORS:
+ continue
+
+ meta = MODULE_META.get(name)
+ label = meta.label if meta else name
+ desc = meta.description if meta else ""
+
+ section_parts = [f"\n## {label}"]
+ if not compact and desc:
+ section_parts.append(desc)
+
+ if isinstance(data, pl.DataFrame):
+ display_df = data
+ if compact and name in _KEY_ACCOUNTS_MAP:
+ display_df = _filter_key_accounts(data, name)
+
+ md = df_to_markdown(display_df, max_rows=max_rows, meta=meta, compact=compact)
+ section_parts.append(md)
+
+ derived = _compute_derived_metrics(name, data, company)
+ if derived:
+ section_parts.append(derived)
+
+ elif isinstance(data, dict):
+ dict_lines = []
+ for k, v in data.items():
+ dict_lines.append(f"- {k}: {v}")
+ section_parts.append("\n".join(dict_lines))
+
+ elif isinstance(data, list):
+ effective_max = meta.maxRows if meta else 20
+ if compact:
+ effective_max = min(effective_max, 10)
+ list_lines = []
+ for item in data[:effective_max]:
+ if hasattr(item, "title") and hasattr(item, "chars"):
+ list_lines.append(f"- **{item.title}** ({item.chars}์)")
+ else:
+ list_lines.append(f"- {item}")
+ if len(data) > effective_max:
+ list_lines.append(f"(... ์์ {effective_max}๊ฑด, ์ ์ฒด {len(data)}๊ฑด)")
+ section_parts.append("\n".join(list_lines))
+
+ else:
+ max_text = 1000 if compact else 2000
+ section_parts.append(str(data)[:max_text])
+
+ if not compact and meta and meta.analysisHints:
+ hints = " | ".join(meta.analysisHints)
+ section_parts.append(f"> ๋ถ์ ํฌ์ธํธ: {hints}")
+
+ sections.append("\n".join(section_parts))
+ included.append(name)
+
+ except _CONTEXT_ERRORS:
+ continue
+
+ from dartlab.ai.conversation.prompts import _classify_question_multi
+
+ _q_types = _classify_question_multi(question, max_types=2) if question else []
+ report_sections = _build_report_sections(company, q_types=_q_types)
+ for key, section in report_sections.items():
+ sections.append(section)
+ included.append(key)
+
+ if not compact:
+ available_modules = scan_available_modules(company)
+ available_names = {m["name"] for m in available_modules}
+ not_included = available_names - set(included)
+ if not_included:
+ available_list = []
+ for m in available_modules:
+ if m["name"] in not_included:
+ info = f"`{m['name']}` ({m['label']}"
+ if m.get("rows"):
+ info += f", {m['rows']}ํ"
+ info += ")"
+ available_list.append(info)
+ if available_list:
+ sections.append(
+ "\n---\n### ์ถ๊ฐ ์กฐํ ๊ฐ๋ฅํ ๋ฐ์ดํฐ\n"
+ "์๋ ๋ฐ์ดํฐ๋ ํ์ฌ ํฌํจ๋์ง ์์์ง๋ง `finance(action='data', module=...)` ๋๊ตฌ๋ก ์กฐํํ ์ ์์ต๋๋ค:\n"
+ + ", ".join(available_list[:15])
+ )
+
+ # โโ ์ ๋ณด ๋ฐฐ์น ์ต์ ํ: ํต์ฌ ์์น๋ฅผ context ๋์ ๋ฐ๋ณต (Lost-in-the-Middle ๋์) โโ
+ key_facts = _build_key_facts_recap(company, included)
+ if key_facts:
+ sections.append(key_facts)
+
+ config.verbose = orig_verbose
+
+ return "\n".join(sections), included
+
+
+def _build_key_facts_recap(company: Any, included: list[str]) -> str | None:
+ """context ๋์ ํต์ฌ ์์น๋ฅผ ๊ฐ๊ฒฐํ๊ฒ ๋ฐ๋ณต โ Lost-in-the-Middle ๋ฌธ์ ๋์."""
+ lines: list[str] = []
+
+ ratios = get_headline_ratios(company)
+ if ratios is not None and hasattr(ratios, "roe"):
+ facts = []
+ if ratios.roe is not None:
+ facts.append(f"ROE {ratios.roe:.1f}%")
+ if ratios.operatingMargin is not None:
+ facts.append(f"์์
์ด์ต๋ฅ {ratios.operatingMargin:.1f}%")
+ if ratios.debtRatio is not None:
+ facts.append(f"๋ถ์ฑ๋น์จ {ratios.debtRatio:.1f}%")
+ if ratios.currentRatio is not None:
+ facts.append(f"์ ๋๋น์จ {ratios.currentRatio:.1f}%")
+ if ratios.fcf is not None:
+ facts.append(f"FCF {_format_won(ratios.fcf)}")
+ if facts:
+ lines.append("---")
+ lines.append(f"**[ํต์ฌ ์งํ ์์ฝ] {' | '.join(facts)}**")
+
+ # insight ๋ฑ๊ธ ์์ฝ (์์ผ๋ฉด)
+ try:
+ from dartlab.analysis.financial.insight import analyze
+
+ stockCode = getattr(company, "stockCode", None)
+ if stockCode:
+ result = analyze(stockCode, company=company)
+ if result is not None:
+ grades = result.grades()
+ grade_parts = [f"{k}={v}" for k, v in grades.items() if v != "N"]
+ if grade_parts:
+ lines.append(f"**[์ธ์ฌ์ดํธ ๋ฑ๊ธ] {result.profile} โ {', '.join(grade_parts[:5])}**")
+ except (ImportError, AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError):
+ pass
+
+ if not lines:
+ return None
+ return "\n".join(lines)
+
+
+def _build_change_summary(company: Any, max_topics: int = 5) -> str | None:
+ """๊ธฐ๊ฐ๊ฐ ๋ณํ๊ฐ ํฐ topic top-N์ ์๋ ์์ฝํ์ฌ AI ์ปจํ
์คํธ์ ์ ๊ณต."""
+ try:
+ diff_df = company.diff()
+ except _CONTEXT_ERRORS:
+ return None
+
+ if diff_df is None or (isinstance(diff_df, pl.DataFrame) and diff_df.is_empty()):
+ return None
+
+ if not isinstance(diff_df, pl.DataFrame):
+ return None
+
+ # changeRate > 0 ์ธ topic๋ง ํํฐ, ์์ N๊ฐ
+ if "changeRate" not in diff_df.columns or "topic" not in diff_df.columns:
+ return None
+
+ changed = diff_df.filter(pl.col("changeRate") > 0).sort("changeRate", descending=True)
+ if changed.is_empty():
+ return None
+
+ top = changed.head(max_topics)
+ lines = [
+ "\n## ์ฃผ์ ๋ณํ (์ต๊ทผ ๊ณต์ vs ์ง์ )",
+ "| topic | ๋ณํ์จ | ๊ธฐ๊ฐ์ |",
+ "| --- | --- | --- |",
+ ]
+ for row in top.iter_rows(named=True):
+ rate_pct = round(row["changeRate"] * 100, 1)
+ periods = row.get("periods", "")
+ lines.append(f"| `{row['topic']}` | {rate_pct}% | {periods} |")
+
+ lines.append("")
+ lines.append(
+ "๊น์ด ๋ถ์์ด ํ์ํ๋ฉด `explore(action='show', topic=topic)`์ผ๋ก ์๋ฌธ์, `explore(action='diff', topic=topic)`์ผ๋ก ์์ธ ๋ณํ๋ฅผ ํ์ธํ์ธ์."
+ )
+ return "\n".join(lines)
+
+
+def _build_topics_section(company: Any, compact: bool = False) -> str | None:
+ """Company์ topics ๋ชฉ๋ก์ LLM์ด ์ฌ์ฉํ ์ ์๋ ๋งํฌ๋ค์ด์ผ๋ก ๋ณํ.
+
+ dartlab์ topic์ด ์ถ๊ฐ๋๋ฉด ์๋์ผ๋ก LLM ์ปจํ
์คํธ์ ํฌํจ๋๋ค.
+
+ Args:
+ compact: True๋ฉด ์์ 10๊ฐ + ์ด ๊ฐ์ ์์ฝ (93% ํ ํฐ ์ ๊ฐ)
+ """
+ topics = getattr(company, "topics", None)
+ if topics is None:
+ return None
+ if isinstance(topics, pl.DataFrame):
+ if "topic" not in topics.columns:
+ return None
+ topic_list = [topic for topic in topics["topic"].drop_nulls().to_list() if isinstance(topic, str) and topic]
+ elif isinstance(topics, pl.Series):
+ topic_list = [topic for topic in topics.drop_nulls().to_list() if isinstance(topic, str) and topic]
+ elif isinstance(topics, list):
+ topic_list = [topic for topic in topics if isinstance(topic, str) and topic]
+ else:
+ try:
+ topic_list = [topic for topic in list(topics) if isinstance(topic, str) and topic]
+ except TypeError:
+ return None
+ if not topic_list:
+ return None
+
+ if compact:
+ top10 = topic_list[:10]
+ return (
+ f"\n## ๊ณต์ topic ({len(topic_list)}๊ฐ)\n"
+ f"์ฃผ์: {', '.join(top10)}\n"
+ f"์ ์ฒด ๋ชฉ๋ก์ `explore(action='topics')` ๋๊ตฌ๋ก ์กฐํํ์ธ์."
+ )
+
+ lines = [
+ "\n## ์กฐํ ๊ฐ๋ฅํ ๊ณต์ topic ๋ชฉ๋ก",
+ "`explore(action='show', topic=...)` ๋๊ตฌ์ ์๋ topic์ ๋ฃ์ผ๋ฉด ์์ธ ๋ฐ์ดํฐ๋ฅผ ์กฐํํ ์ ์์ต๋๋ค.",
+ "",
+ ]
+
+ # index๊ฐ ์์ผ๋ฉด label ์ ๋ณด ํฌํจ
+ index_df = getattr(company, "index", None)
+ if isinstance(index_df, pl.DataFrame) and index_df.height > 0:
+ label_col = "label" if "label" in index_df.columns else None
+ source_col = "source" if "source" in index_df.columns else None
+ for row in index_df.head(60).iter_rows(named=True):
+ topic = row.get("topic", "")
+ label = row.get(label_col, topic) if label_col else topic
+ source = row.get(source_col, "") if source_col else ""
+ lines.append(f"- `{topic}` ({label}) [{source}]")
+ else:
+ for t in topic_list[:60]:
+ lines.append(f"- `{t}`")
+
+ return "\n".join(lines)
+
+
+def _build_insights_section(company: Any) -> str | None:
+ """Company์ 7์์ญ ์ธ์ฌ์ดํธ ๋ฑ๊ธ์ ์ปจํ
์คํธ์ ์๋ ํฌํจ."""
+ stockCode = getattr(company, "stockCode", None)
+ if not stockCode:
+ return None
+
+ try:
+ from dartlab.analysis.financial.insight.pipeline import analyze
+
+ result = analyze(stockCode, company=company)
+ except (ImportError, AttributeError, FileNotFoundError, OSError, RuntimeError, TypeError, ValueError):
+ return None
+ if result is None:
+ return None
+
+ area_labels = {
+ "performance": "์ค์ ",
+ "profitability": "์์ต์ฑ",
+ "health": "๊ฑด์ ์ฑ",
+ "cashflow": "ํ๊ธํ๋ฆ",
+ "governance": "์ง๋ฐฐ๊ตฌ์กฐ",
+ "risk": "๋ฆฌ์คํฌ",
+ "opportunity": "๊ธฐํ",
+ }
+
+ lines = [
+ "\n## ์ธ์ฌ์ดํธ ๋ฑ๊ธ (์๋ ๋ถ์)",
+ f"ํ๋กํ์ผ: **{result.profile}**",
+ "",
+ "| ์์ญ | ๋ฑ๊ธ | ์์ฝ |",
+ "| --- | --- | --- |",
+ ]
+ for key, label in area_labels.items():
+ ir = getattr(result, key, None)
+ grade = result.grades().get(key, "N")
+ summary = ir.summary if ir else "-"
+ lines.append(f"| {label} | {grade} | {summary} |")
+
+ if result.anomalies:
+ lines.append("")
+ lines.append("### ์ด์์น ๊ฒฝ๊ณ ")
+ for a in result.anomalies[:5]:
+ lines.append(f"- [{a.severity}] {a.text}")
+
+ if result.summary:
+ lines.append(f"\n{result.summary}")
+
+ return "\n".join(lines)
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# Tiered Context Pipeline
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+# skeleton tier์์ ์ฌ์ฉํ ํต์ฌ ratios ํค
+_SKELETON_RATIO_KEYS = ("roe", "debtRatio", "currentRatio", "operatingMargin", "fcf", "revenueGrowth3Y")
+
+# skeleton tier์์ ์ฌ์ฉํ ํต์ฌ ๊ณ์ (๋งค์ถ/์์
์ด์ต/์ด์์ฐ)
+_SKELETON_ACCOUNTS_KR: dict[str, list[tuple[str, str]]] = {
+ "IS": [("sales", "๋งค์ถ์ก"), ("operating_profit", "์์
์ด์ต")],
+ "BS": [("total_assets", "์์ฐ์ด๊ณ")],
+}
+_SKELETON_ACCOUNTS_EN: dict[str, list[tuple[str, str]]] = {
+ "IS": [("sales", "Revenue"), ("operating_profit", "Operating Income")],
+ "BS": [("total_assets", "Total Assets")],
+}
+
+
+def build_context_skeleton(company: Any) -> tuple[str, list[str]]:
+ """skeleton tier: ~500 ํ ํฐ. tool calling provider์ฉ ์ต์ ์ปจํ
์คํธ.
+
+ ํต์ฌ ๋น์จ 6๊ฐ + ๋งค์ถ/์์
์ด์ต/์ด์์ฐ 3๊ณ์ + insight ๋ฑ๊ธ 1์ค.
+ ์์ธ ๋ฐ์ดํฐ๋ ๋๊ตฌ๋ก ์กฐํํ๋๋ก ์๋ด.
+ EDGAR(US) / DART(KR) ์๋ ๊ฐ์ง.
+ """
+ market = getattr(company, "market", "KR")
+ is_us = market == "US"
+ fmt_val = _format_usd if is_us else _format_won
+ skel_accounts = _SKELETON_ACCOUNTS_EN if is_us else _SKELETON_ACCOUNTS_KR
+ unit_label = "USD" if is_us else "์ต/์กฐ์"
+
+ parts = [f"# {company.corpName} ({company.stockCode})"]
+ if is_us:
+ parts[0] += " | Market: US (SEC EDGAR) | Currency: USD"
+ parts.append("โ ๏ธ ์๋๋ ์ฐธ๊ณ ์ฉ ์์ฝ์
๋๋ค. ์ง๋ฌธ์ ๋ตํ๋ ค๋ฉด ๋ฐ๋์ ๋๊ตฌ(explore/finance)๋ก ์์ธ ๋ฐ์ดํฐ๋ฅผ ์กฐํํ์ธ์.")
+ included = []
+
+ # ํต์ฌ ๊ณ์ 3๊ฐ (์ต๊ทผ 3๋
)
+ annual = getattr(company, "annual", None)
+ if annual is not None:
+ series, years = annual
+ quarter_counts = _get_quarter_counts(company)
+ if years:
+ display_years = years[-3:]
+ display_labeled = []
+ for y in display_years:
+ qc = quarter_counts.get(y, 4)
+ if qc < 4:
+ display_labeled.append(f"{y}(~Q{qc})")
+ else:
+ display_labeled.append(y)
+ display_reversed = list(reversed(display_labeled))
+ year_offset = len(years) - 3
+
+ col_header = "Account" if is_us else "๊ณ์ "
+ header = f"| {col_header} | " + " | ".join(display_reversed) + " |"
+ sep = "| --- | " + " | ".join(["---"] * len(display_reversed)) + " |"
+ rows = []
+ for sj, accts in skel_accounts.items():
+ sj_data = series.get(sj, {})
+ for snake_id, label in accts:
+ vals = sj_data.get(snake_id)
+ if not vals:
+ continue
+ sliced = vals[max(0, year_offset) :]
+ cells = [fmt_val(v) if v is not None else "-" for v in reversed(sliced)]
+ rows.append(f"| {label} | " + " | ".join(cells) + " |")
+
+ if rows:
+ partial = [y for y in display_years if quarter_counts.get(y, 4) < 4]
+ partial_note = ""
+ if partial:
+ notes = ", ".join(f"{y}=Q1~Q{quarter_counts[y]}" for y in partial)
+ partial_note = f"\nโ ๏ธ {'Partial year' if is_us else '๋ถ๋ถ ์ฐ๋'}: {notes}"
+ section_title = f"Key Financials ({unit_label})" if is_us else f"ํต์ฌ ์์น ({unit_label})"
+ parts.extend(["", f"## {section_title}{partial_note}", header, sep, *rows])
+ included.extend(["IS", "BS"])
+
+ # ํต์ฌ ๋น์จ 6๊ฐ
+ ratios = get_headline_ratios(company)
+ if ratios is not None and hasattr(ratios, "roe"):
+ ratio_lines = []
+ for key in _SKELETON_RATIO_KEYS:
+ val = getattr(ratios, key, None)
+ if val is None:
+ continue
+ label_map_kr = {
+ "roe": "ROE",
+ "debtRatio": "๋ถ์ฑ๋น์จ",
+ "currentRatio": "์ ๋๋น์จ",
+ "operatingMargin": "์์
์ด์ต๋ฅ ",
+ "fcf": "FCF",
+ "revenueGrowth3Y": "๋งค์ถ3Y CAGR",
+ }
+ label_map_en = {
+ "roe": "ROE",
+ "debtRatio": "Debt Ratio",
+ "currentRatio": "Current Ratio",
+ "operatingMargin": "Op. Margin",
+ "fcf": "FCF",
+ "revenueGrowth3Y": "Rev. 3Y CAGR",
+ }
+ label = (label_map_en if is_us else label_map_kr).get(key, key)
+ if key == "fcf":
+ ratio_lines.append(f"- {label}: {fmt_val(val)}")
+ else:
+ ratio_lines.append(f"- {label}: {val:.1f}%")
+ if ratio_lines:
+ section_title = "Key Ratios" if is_us else "ํต์ฌ ๋น์จ"
+ parts.extend(["", f"## {section_title}", *ratio_lines])
+ included.append("ratios")
+
+ # ๋ถ์ ๊ฐ์ด๋
+ if is_us:
+ parts.extend(
+ [
+ "",
+ "## DartLab Analysis Guide",
+ "All filing data is structured as **sections** (topic ร period horizontalization).",
+ "- `explore(action='topics')` โ full topic list | `explore(action='show', topic=...)` โ block index โ data",
+ "- `explore(action='search', keyword=...)` โ original filing text for citations",
+ "- `explore(action='diff', topic=...)` โ period-over-period changes | `explore(action='trace', topic=...)` โ source provenance",
+ "- `finance(action='data', module='BS/IS/CF')` โ financials | `finance(action='ratios')` โ ratios",
+ "- `analyze(action='insight')` โ 7-area grades | `explore(action='coverage')` โ data availability",
+ "",
+ "**Note**: This is a US company (SEC EDGAR). No `report` namespace โ all narrative data via sections.",
+ "**Procedure**: Understand question โ explore topics โ retrieve data โ cross-verify โ synthesize answer",
+ ]
+ )
+ else:
+ parts.extend(
+ [
+ "",
+ "## DartLab ๋ถ์ ๊ฐ์ด๋",
+ "์ด ๊ธฐ์
์ ๋ชจ๋ ๊ณต์ ๋ฐ์ดํฐ๋ **sections** (topic ร ๊ธฐ๊ฐ ์ํํ)์ผ๋ก ๊ตฌ์กฐํ๋์ด ์์ต๋๋ค.",
+ "- `explore(action='topics')` โ ์ ์ฒด topic ๋ชฉ๋ก (ํ๊ท 120+๊ฐ)",
+ "- `explore(action='show', topic=...)` โ ๋ธ๋ก ๋ชฉ์ฐจ โ ์ค์ ๋ฐ์ดํฐ",
+ "- `explore(action='search', keyword=...)` โ ์๋ฌธ ์ฆ๊ฑฐ ๊ฒ์ (์ธ์ฉ์ฉ)",
+ "- `explore(action='diff', topic=...)` โ ๊ธฐ๊ฐ๊ฐ ๋ณํ | `explore(action='trace', topic=...)` โ ์ถ์ฒ ์ถ์ ",
+ "- `finance(action='data', module='BS/IS/CF')` โ ์ฌ๋ฌด์ ํ | `finance(action='ratios')` โ ์ฌ๋ฌด๋น์จ",
+ "- `analyze(action='insight')` โ 7์์ญ ์ข
ํฉ ๋ฑ๊ธ | `explore(action='report', apiType=...)` โ ์ ๊ธฐ๋ณด๊ณ ์",
+ "",
+ "**๋ถ์ ์ ์ฐจ**: ์ง๋ฌธ ์ดํด โ ๊ด๋ จ topic ํ์ โ ์๋ฌธ ๋ฐ์ดํฐ ์กฐํ โ ๊ต์ฐจ ๊ฒ์ฆ โ ์ข
ํฉ ๋ต๋ณ",
+ "**ํต์ฌ**: '๋ฐ์ดํฐ ์์'์ผ๋ก ๋ตํ๊ธฐ ์ ์ ๋ฐ๋์ ๋๊ตฌ๋ก ํ์ธ. sections์ ๊ฑฐ์ ๋ชจ๋ ๊ณต์ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค.",
+ ]
+ )
+
+ return "\n".join(parts), included
+
+
+def build_context_focused(
+ company: Any,
+ question: str,
+ include: list[str] | None = None,
+ exclude: list[str] | None = None,
+) -> tuple[dict[str, str], list[str], str]:
+ """focused tier: ~2,000 ํ ํฐ. tool calling ๋ฏธ์ง์ provider์ฉ.
+
+ skeleton + ์ง๋ฌธ ์ ํ๋ณ ๊ด๋ จ ๋ชจ๋๋ง ํฌํจ (compact ํ์).
+ """
+ return build_context_by_module(company, question, include, exclude, compact=True)
+
+
+ContextTier = str # "skeleton" | "focused" | "full"
+
+
+def build_context_tiered(
+ company: Any,
+ question: str,
+ tier: ContextTier,
+ include: list[str] | None = None,
+ exclude: list[str] | None = None,
+) -> tuple[dict[str, str], list[str], str]:
+ """tier๋ณ context ๋น๋. streaming.py์์ ํธ์ถ.
+
+ Args:
+ tier: "skeleton" | "focused" | "full"
+
+ Returns:
+ (modules_dict, included_list, header_text)
+ """
+ if tier == "skeleton":
+ text, included = build_context_skeleton(company)
+ return {"_skeleton": text}, included, ""
+ elif tier == "focused":
+ return build_context_focused(company, question, include, exclude)
+ else:
+ return build_context_by_module(company, question, include, exclude, compact=False)
diff --git a/src/dartlab/ai/context/company_adapter.py b/src/dartlab/ai/context/company_adapter.py
new file mode 100644
index 0000000000000000000000000000000000000000..fae2a11d881fe14d4819435aa4279c711eda6dd2
--- /dev/null
+++ b/src/dartlab/ai/context/company_adapter.py
@@ -0,0 +1,86 @@
+"""Facade adapter helpers for AI runtime.
+
+AI layer๋ `dartlab.Company` facade์ ์์ง ๋ด๋ถ ๊ตฌํ ์ฐจ์ด๋ฅผ ์ง์ ์์ง ์๋๋ค.
+์ด ๋ชจ๋์์ headline ratios / ratio series ๊ฐ์ surface ์ฐจ์ด๋ฅผ ํก์ํ๋ค.
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from typing import Any
+
+_ADAPTER_ERRORS = (
+ AttributeError,
+ KeyError,
+ OSError,
+ RuntimeError,
+ TypeError,
+ ValueError,
+)
+
+
+class _RatioProxy:
+ """๋๋ฝ ์์ฑ์ None์ผ๋ก ํก์ํ๋ lightweight ratio adapter."""
+
+ def __init__(self, inner: Any):
+ self._inner = inner
+
+ def __getattr__(self, name: str) -> Any:
+ return getattr(self._inner, name, None)
+
+
+def get_headline_ratios(company: Any) -> Any | None:
+ """Return RatioResult-like object regardless of facade surface."""
+ # ๋ด๋ถ์ฉ _getRatiosInternal ์ฐ์ (deprecation warning ์์)
+ internal = getattr(company, "_getRatiosInternal", None)
+ getter = internal if callable(internal) else getattr(company, "getRatios", None)
+ if callable(getter):
+ try:
+ result = getter()
+ if result is not None and hasattr(result, "roe"):
+ return _RatioProxy(result)
+ except _ADAPTER_ERRORS:
+ pass
+
+ finance = getattr(company, "finance", None)
+ finance_getter = getattr(finance, "getRatios", None)
+ if callable(finance_getter):
+ try:
+ result = finance_getter()
+ if result is not None and hasattr(result, "roe"):
+ return _RatioProxy(result)
+ except _ADAPTER_ERRORS:
+ pass
+
+ for candidate in (
+ getattr(company, "ratios", None),
+ getattr(finance, "ratios", None),
+ ):
+ if candidate is not None and hasattr(candidate, "roe"):
+ return _RatioProxy(candidate)
+
+ return None
+
+
+def get_ratio_series(company: Any) -> Any | None:
+ """Return attribute-style ratio series regardless of tuple/object surface."""
+ for candidate in (
+ getattr(company, "ratioSeries", None),
+ getattr(getattr(company, "finance", None), "ratioSeries", None),
+ ):
+ if candidate is None:
+ continue
+ if hasattr(candidate, "roe"):
+ return candidate
+ if isinstance(candidate, tuple) and len(candidate) == 2:
+ series, periods = candidate
+ if not isinstance(series, dict):
+ continue
+ ratio_series = series.get("RATIO", {})
+ if not isinstance(ratio_series, dict) or not ratio_series:
+ continue
+ adapted = SimpleNamespace(periods=periods)
+ for key, values in ratio_series.items():
+ setattr(adapted, key, values)
+ return adapted
+ return None
diff --git a/src/dartlab/ai/context/dartOpenapi.py b/src/dartlab/ai/context/dartOpenapi.py
new file mode 100644
index 0000000000000000000000000000000000000000..3d8dd4b137c565f3e9cd837826d871d060ead6f7
--- /dev/null
+++ b/src/dartlab/ai/context/dartOpenapi.py
@@ -0,0 +1,485 @@
+"""OpenDART ๊ณต์๋ชฉ๋ก retrieval helper.
+
+ํ์ฌ ๋ฏธ์ ํ ์ง๋ฌธ์์๋ ์ต๊ทผ ๊ณต์๋ชฉ๋ก/์์ฃผ๊ณต์/๊ณ์ฝ๊ณต์๋ฅผ
+deterministic prefetch๋ก ํ์ํด AI ์ปจํ
์คํธ๋ก ์ฃผ์
ํ๋ค.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from datetime import date, timedelta
+from html import unescape
+from typing import Any
+
+import polars as pl
+
+from dartlab.ai.context.formatting import df_to_markdown
+from dartlab.core.capabilities import UiAction
+from dartlab.providers.dart.openapi.dartKey import hasDartApiKey
+
+_FILING_TERMS = (
+ "๊ณต์",
+ "์ ์๊ณต์",
+ "๊ณต์๋ชฉ๋ก",
+ "๊ณต์ ๋ฆฌ์คํธ",
+ "์์ฃผ๊ณต์",
+ "๊ณ์ฝ๊ณต์",
+ "๋จ์ผํ๋งค๊ณต๊ธ๊ณ์ฝ",
+ "๊ณต๊ธ๊ณ์ฝ",
+ "ํ๋งค๊ณต๊ธ๊ณ์ฝ",
+ "์์ฃผ",
+)
+_REQUEST_TERMS = (
+ "์๋ ค",
+ "๋ณด์ฌ",
+ "์ฐพ์",
+ "์ ๋ฆฌ",
+ "์์ฝ",
+ "๋ถ์",
+ "๊ณจ๋ผ",
+ "์ถ์ฒ",
+ "๋ฌด์จ",
+ "๋ญ ์์",
+ "๋ฆฌ์คํธ",
+ "๋ชฉ๋ก",
+)
+_DETAIL_TERMS = (
+ "์์ฝ",
+ "๋ถ์",
+ "ํต์ฌ",
+ "์ค์",
+ "์ฝ์",
+ "๋ฆฌ์คํฌ",
+ "๋ด์ฉ",
+ "๋ฌด์จ ๋ด์ฉ",
+ "๊ผญ",
+)
+_READ_TERMS = (
+ "์ฝ์ด",
+ "๋ณธ๋ฌธ",
+ "์๋ฌธ",
+ "์ ๋ฌธ",
+ "์์ธํ ๋ณด์ฌ",
+ "๋ด์ฉ ๋ณด์ฌ",
+)
+_ANALYSIS_ONLY_TERMS = (
+ "๊ทผ๊ฑฐ",
+ "์",
+ "์ง์ ๊ฐ๋ฅ",
+ "์ง์๊ฐ๋ฅ",
+ "ํ๋จ",
+ "ํ๊ฐ",
+ "ํด์",
+ "์ฌ์
๊ตฌ์กฐ",
+ "๊ตฌ์กฐ",
+ "์ํฅ",
+ "๋ณํ",
+)
+_ORDER_KEYWORDS = (
+ "๋จ์ผํ๋งค๊ณต๊ธ๊ณ์ฝ",
+ "ํ๋งค๊ณต๊ธ๊ณ์ฝ",
+ "๊ณต๊ธ๊ณ์ฝ",
+ "์์ฃผ",
+)
+_DISCLOSURE_TYPE_HINTS = {
+ "์ ๊ธฐ๊ณต์": "A",
+ "์ฃผ์์ฌํญ": "B",
+ "์ฃผ์์ฌํญ๋ณด๊ณ ": "B",
+ "๋ฐํ๊ณต์": "C",
+ "์ง๋ถ๊ณต์": "D",
+ "๊ธฐํ๊ณต์": "E",
+ "์ธ๋ถ๊ฐ์ฌ": "F",
+ "ํ๋๊ณต์": "G",
+ "์์ฐ์ ๋ํ": "H",
+ "๊ฑฐ๋์๊ณต์": "I",
+ "๊ณต์ ์๊ณต์": "J",
+}
+_MARKET_HINTS = {
+ "์ฝ์คํผ": "Y",
+ "์ ๊ฐ์ฆ๊ถ": "Y",
+ "์ฝ์ค๋ฅ": "K",
+ "์ฝ๋ฅ์ค": "N",
+}
+_DEFAULT_LIMIT = 20
+_DEFAULT_DAYS = 7
+
+
+@dataclass(frozen=True)
+class DartFilingIntent:
+ matched: bool = False
+ corp: str | None = None
+ start: str = ""
+ end: str = ""
+ disclosureType: str | None = None
+ market: str | None = None
+ finalOnly: bool = False
+ limit: int = _DEFAULT_LIMIT
+ titleKeywords: tuple[str, ...] = ()
+ includeText: bool = False
+ textLimit: int = 0
+
+
+@dataclass(frozen=True)
+class DartFilingPrefetch:
+ matched: bool
+ needsKey: bool = False
+ message: str = ""
+ contextText: str = ""
+ uiAction: dict[str, Any] | None = None
+ filings: pl.DataFrame | None = None
+ intent: DartFilingIntent | None = None
+
+
+def buildMissingDartKeyMessage() -> str:
+ return (
+ "OpenDART API ํค๊ฐ ํ์ํฉ๋๋ค.\n"
+ "- ์ด ์ง๋ฌธ์ ์ค์๊ฐ ๊ณต์๋ชฉ๋ก ์กฐํ๊ฐ ํ์ํฉ๋๋ค.\n"
+ "- ์ค์ ์์ `OpenDART API ํค`๋ฅผ ์ ์ฅํ๋ฉด ์ต๊ทผ ๊ณต์, ์์ฃผ๊ณต์, ๊ณ์ฝ๊ณต์๋ฅผ ๋ฐ๋ก ๊ฒ์ํ ์ ์์ต๋๋ค.\n"
+ "- ํค๋ ํ๋ก์ ํธ ๋ฃจํธ `.env`์ `DART_API_KEY`๋ก ์ ์ฅ๋ฉ๋๋ค."
+ )
+
+
+def buildMissingDartKeyUiAction() -> dict[str, Any]:
+ return UiAction.update(
+ "settings",
+ {
+ "open": True,
+ "section": "openDart",
+ "message": "OpenDART API ํค๋ฅผ ์ค์ ํ๋ฉด ์ต๊ทผ ๊ณต์๋ชฉ๋ก์ ๋ฐ๋ก ๊ฒ์ํ ์ ์์ต๋๋ค.",
+ },
+ ).to_payload()
+
+
+def isDartFilingQuestion(question: str) -> bool:
+ q = (question or "").strip()
+ if not q:
+ return False
+ normalized = q.replace(" ", "")
+ if any(term in normalized for term in ("openapi", "opendart", "dartapi")) and not any(
+ term in q for term in _FILING_TERMS
+ ):
+ return False
+ has_filing_term = any(term in q for term in _FILING_TERMS)
+ has_request_term = any(term in q for term in _REQUEST_TERMS)
+ has_time_term = any(term in q for term in ("์ต๊ทผ", "์ค๋", "์ด์ ", "์ด๋ฒ ์ฃผ", "์ง๋ ์ฃผ", "์ด๋ฒ ๋ฌ", "๋ฉฐ์น ", "๋ช์ผ"))
+ has_read_term = any(term in q for term in _READ_TERMS)
+ has_analysis_only_term = any(term in q for term in _ANALYSIS_ONLY_TERMS)
+
+ if (
+ has_analysis_only_term
+ and not has_read_term
+ and not any(term in q for term in ("๋ชฉ๋ก", "๋ฆฌ์คํธ", "๋ญ ์์", "๋ฌด์จ ๊ณต์"))
+ ):
+ return False
+
+ return has_filing_term and (has_request_term or has_time_term or has_read_term or "?" not in q)
+
+
+def detectDartFilingIntent(question: str, company: Any | None = None) -> DartFilingIntent:
+ if not isDartFilingQuestion(question):
+ return DartFilingIntent()
+
+ today = date.today()
+ start_date, end_date = _resolve_date_window(question, today)
+ title_keywords = _resolve_title_keywords(question)
+ include_text = any(term in question for term in _DETAIL_TERMS) or any(term in question for term in _READ_TERMS)
+ limit = _resolve_limit(question)
+ corp = None
+ if company is not None:
+ corp = getattr(company, "stockCode", None) or getattr(company, "corpName", None)
+
+ disclosure_type = None
+ for hint, code in _DISCLOSURE_TYPE_HINTS.items():
+ if hint in question:
+ disclosure_type = code
+ break
+
+ market = None
+ for hint, code in _MARKET_HINTS.items():
+ if hint in question:
+ market = code
+ break
+
+ final_only = any(term in question for term in ("์ต์ข
", "์ ์ ์ ์ธ", "์ ์ ์๋", "์ ์ ์๋"))
+ text_limit = 3 if include_text and limit <= 5 else (2 if include_text else 0)
+
+ return DartFilingIntent(
+ matched=True,
+ corp=corp,
+ start=start_date.strftime("%Y%m%d"),
+ end=end_date.strftime("%Y%m%d"),
+ disclosureType=disclosure_type,
+ market=market,
+ finalOnly=final_only,
+ limit=limit,
+ titleKeywords=title_keywords,
+ includeText=include_text,
+ textLimit=text_limit,
+ )
+
+
+def searchDartFilings(
+ *,
+ corp: str | None = None,
+ start: str | None = None,
+ end: str | None = None,
+ days: int | None = None,
+ weeks: int | None = None,
+ disclosureType: str | None = None,
+ market: str | None = None,
+ finalOnly: bool = False,
+ titleKeywords: list[str] | tuple[str, ...] | None = None,
+ limit: int = _DEFAULT_LIMIT,
+) -> pl.DataFrame:
+ from dartlab import OpenDart
+
+ if not hasDartApiKey():
+ raise ValueError(buildMissingDartKeyMessage())
+
+ resolved_start, resolved_end = _coerce_search_window(start, end, days=days, weeks=weeks)
+ dart = OpenDart()
+ filings = dart.filings(
+ corp=corp,
+ start=resolved_start,
+ end=resolved_end,
+ type=disclosureType,
+ final=finalOnly,
+ market=market,
+ )
+ if filings is None or filings.height == 0:
+ return pl.DataFrame()
+
+ df = filings
+ if titleKeywords and "report_nm" in df.columns:
+ mask = pl.lit(False)
+ for keyword in titleKeywords:
+ mask = mask | pl.col("report_nm").str.contains(keyword, literal=True)
+ df = df.filter(mask)
+
+ if df.height == 0:
+ return pl.DataFrame()
+
+ sort_cols = [col for col in ("rcept_dt", "rcept_no") if col in df.columns]
+ if sort_cols:
+ descending = [True] * len(sort_cols)
+ df = df.sort(sort_cols, descending=descending)
+
+ return df.head(max(1, min(limit, 100)))
+
+
+def getDartFilingText(rceptNo: str, maxChars: int = 4000) -> str:
+ from dartlab import OpenDart
+
+ if not rceptNo:
+ raise ValueError("rcept_no๊ฐ ํ์ํฉ๋๋ค.")
+ if not hasDartApiKey():
+ raise ValueError(buildMissingDartKeyMessage())
+
+ raw_text = OpenDart().documentText(rceptNo)
+ return cleanDartFilingText(raw_text, maxChars=maxChars)
+
+
+def buildDartFilingPrefetch(question: str, company: Any | None = None) -> DartFilingPrefetch:
+ intent = detectDartFilingIntent(question, company=company)
+ if not intent.matched:
+ return DartFilingPrefetch(matched=False)
+ if not hasDartApiKey():
+ return DartFilingPrefetch(
+ matched=True,
+ needsKey=True,
+ message=buildMissingDartKeyMessage(),
+ uiAction=buildMissingDartKeyUiAction(),
+ intent=intent,
+ )
+
+ filings = searchDartFilings(
+ corp=intent.corp,
+ start=intent.start,
+ end=intent.end,
+ disclosureType=intent.disclosureType,
+ market=intent.market,
+ finalOnly=intent.finalOnly,
+ titleKeywords=intent.titleKeywords,
+ limit=intent.limit,
+ )
+ context_text = formatDartFilingContext(filings, intent, question=question)
+ if intent.includeText and filings.height > 0 and "rcept_no" in filings.columns:
+ detail_blocks = []
+ for rcept_no in filings["rcept_no"].head(intent.textLimit).to_list():
+ try:
+ excerpt = getDartFilingText(str(rcept_no), maxChars=1800)
+ except (OSError, RuntimeError, ValueError):
+ continue
+ detail_blocks.append(f"### ์ ์๋ฒํธ {rcept_no} ์๋ฌธ ๋ฐ์ท\n{excerpt}")
+ if detail_blocks:
+ context_text = "\n\n".join([context_text, *detail_blocks]) if context_text else "\n\n".join(detail_blocks)
+
+ return DartFilingPrefetch(
+ matched=True,
+ needsKey=False,
+ contextText=context_text,
+ filings=filings,
+ intent=intent,
+ )
+
+
+def formatDartFilingContext(
+ filings: pl.DataFrame,
+ intent: DartFilingIntent,
+ *,
+ question: str = "",
+) -> str:
+ if intent.start or intent.end:
+ window_label = f"{_format_date(intent.start or intent.end)} ~ {_format_date(intent.end or intent.start)}"
+ else:
+ window_label = "์๋ ๊ธฐ๋ณธ ๋ฒ์"
+ lines = ["## OpenDART ๊ณต์๋ชฉ๋ก ๊ฒ์ ๊ฒฐ๊ณผ", f"- ๊ธฐ๊ฐ: {window_label}"]
+ if intent.corp:
+ lines.append(f"- ํ์ฌ ํํฐ: {intent.corp}")
+ else:
+ lines.append("- ํ์ฌ ํํฐ: ์ ์ฒด ์์ฅ")
+ if intent.market:
+ lines.append(f"- ์์ฅ ํํฐ: {intent.market}")
+ if intent.disclosureType:
+ lines.append(f"- ๊ณต์์ ํ: {intent.disclosureType}")
+ if intent.finalOnly:
+ lines.append("- ์ต์ข
๋ณด๊ณ ์๋ง ํฌํจ")
+ if intent.titleKeywords:
+ lines.append(f"- ์ ๋ชฉ ํค์๋: {', '.join(intent.titleKeywords)}")
+ if question:
+ lines.append(f"- ์ฌ์ฉ์ ์ง๋ฌธ: {question}")
+
+ if filings is None or filings.height == 0:
+ lines.append("")
+ lines.append("ํด๋น ์กฐ๊ฑด์ ๋ง๋ ๊ณต์๊ฐ ์์ต๋๋ค.")
+ return "\n".join(lines)
+
+ display_df = _build_display_df(filings)
+ lines.extend(["", df_to_markdown(display_df, max_rows=min(intent.limit, 20), compact=False)])
+ return "\n".join(lines)
+
+
+def cleanDartFilingText(text: str, *, maxChars: int = 4000) -> str:
+ normalized = unescape(text or "")
+ normalized = re.sub(r"<[^>]+>", " ", normalized)
+ normalized = re.sub(r"\s+", " ", normalized).strip()
+ if len(normalized) <= maxChars:
+ return normalized
+ return normalized[:maxChars].rstrip() + " ... (truncated)"
+
+
+def _build_display_df(df: pl.DataFrame) -> pl.DataFrame:
+ display = df
+ if "rcept_dt" in display.columns:
+ display = display.with_columns(
+ pl.col("rcept_dt").cast(pl.Utf8).map_elements(_format_date, return_dtype=pl.Utf8).alias("rcept_dt")
+ )
+
+ preferred_cols = [
+ col
+ for col in ("rcept_dt", "corp_name", "stock_code", "corp_cls", "report_nm", "rcept_no")
+ if col in display.columns
+ ]
+ if preferred_cols:
+ display = display.select(preferred_cols)
+
+ rename_map = {
+ "rcept_dt": "์ ์์ผ",
+ "corp_name": "ํ์ฌ",
+ "stock_code": "์ข
๋ชฉ์ฝ๋",
+ "corp_cls": "์์ฅ",
+ "report_nm": "๊ณต์๋ช
",
+ "rcept_no": "์ ์๋ฒํธ",
+ }
+ actual_map = {src: dst for src, dst in rename_map.items() if src in display.columns}
+ return display.rename(actual_map)
+
+
+def _resolve_title_keywords(question: str) -> tuple[str, ...]:
+ if any(term in question for term in _ORDER_KEYWORDS) or "๊ณ์ฝ๊ณต์" in question:
+ return _ORDER_KEYWORDS
+ explicit = []
+ for phrase in ("๊ฐ์ฌ๋ณด๊ณ ์", "ํฉ๋ณ", "์ ์์ฆ์", "๋ฌด์์ฆ์", "๋ฐฐ๋น", "์๊ธฐ์ฃผ์", "์ต๋์ฃผ์ฃผ"):
+ if phrase in question:
+ explicit.append(phrase)
+ return tuple(explicit)
+
+
+def _resolve_limit(question: str) -> int:
+ match = re.search(r"(\d+)\s*๊ฑด", question)
+ if match:
+ return max(1, min(int(match.group(1)), 50))
+ if "์ซ" in question or "์ ๋ถ" in question or "์ ์ฒด" in question:
+ return 30
+ return _DEFAULT_LIMIT
+
+
+def _resolve_date_window(question: str, today: date) -> tuple[date, date]:
+ q = question.replace(" ", "")
+ if "์ค๋" in question:
+ return today, today
+ if "์ด์ " in question:
+ target = today - timedelta(days=1)
+ return target, target
+ if "์ด๋ฒ์ฃผ" in q:
+ start = today - timedelta(days=today.weekday())
+ return start, today
+ if "์ง๋์ฃผ" in q:
+ end = today - timedelta(days=today.weekday() + 1)
+ start = end - timedelta(days=6)
+ return start, end
+ if "์ด๋ฒ๋ฌ" in q:
+ start = today.replace(day=1)
+ return start, today
+
+ recent_match = re.search(r"์ต๊ทผ\s*(\d+)\s*(์ผ|์ฃผ|๊ฐ์|๋ฌ)", question)
+ if recent_match:
+ amount = int(recent_match.group(1))
+ unit = recent_match.group(2)
+ if unit == "์ผ":
+ return today - timedelta(days=max(amount - 1, 0)), today
+ if unit == "์ฃผ":
+ return today - timedelta(days=max(amount * 7 - 1, 0)), today
+ if unit in {"๊ฐ์", "๋ฌ"}:
+ return today - timedelta(days=max(amount * 30 - 1, 0)), today
+
+ if "์ต๊ทผ ๋ช์ผ" in q or "์ต๊ทผ๋ช์ผ" in q or "์ต๊ทผ ๋ฉฐ์น " in question or "์ต๊ทผ๋ฉฐ์น " in q:
+ return today - timedelta(days=_DEFAULT_DAYS - 1), today
+ if "์ต๊ทผ ๋ช์ฃผ" in q or "์ต๊ทผ๋ช์ฃผ" in q:
+ return today - timedelta(days=13), today
+
+ return today - timedelta(days=_DEFAULT_DAYS - 1), today
+
+
+def _coerce_search_window(
+ start: str | None,
+ end: str | None,
+ *,
+ days: int | None,
+ weeks: int | None,
+) -> tuple[str, str]:
+ today = date.today()
+ if start or end:
+ resolved_start = _strip_date_sep(start or (end or today.strftime("%Y%m%d")))
+ resolved_end = _strip_date_sep(end or today.strftime("%Y%m%d"))
+ return resolved_start, resolved_end
+ if days:
+ begin = today - timedelta(days=max(days - 1, 0))
+ return begin.strftime("%Y%m%d"), today.strftime("%Y%m%d")
+ if weeks:
+ begin = today - timedelta(days=max(weeks * 7 - 1, 0))
+ return begin.strftime("%Y%m%d"), today.strftime("%Y%m%d")
+ begin = today - timedelta(days=_DEFAULT_DAYS - 1)
+ return begin.strftime("%Y%m%d"), today.strftime("%Y%m%d")
+
+
+def _strip_date_sep(value: str) -> str:
+ return (value or "").replace("-", "").replace(".", "").replace("/", "")
+
+
+def _format_date(value: str) -> str:
+ digits = _strip_date_sep(str(value))
+ if len(digits) == 8 and digits.isdigit():
+ return f"{digits[:4]}-{digits[4:6]}-{digits[6:]}"
+ return str(value)
diff --git a/src/dartlab/ai/context/finance_context.py b/src/dartlab/ai/context/finance_context.py
new file mode 100644
index 0000000000000000000000000000000000000000..2a3b596c3d5d13d2c28c4f07f2a02b1327362f37
--- /dev/null
+++ b/src/dartlab/ai/context/finance_context.py
@@ -0,0 +1,945 @@
+"""Finance/report ๋ฐ์ดํฐ๋ฅผ LLM context ๋งํฌ๋ค์ด์ผ๋ก ๋ณํํ๋ ํจ์๋ค."""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+import polars as pl
+
+from dartlab.ai.context.company_adapter import get_headline_ratios, get_ratio_series
+from dartlab.ai.context.formatting import _format_won, df_to_markdown
+from dartlab.ai.metadata import MODULE_META
+
+_CONTEXT_ERRORS = (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError)
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# ์ง๋ฌธ ์ ํ๋ณ ๋ชจ๋ ๋งคํ (registry ์๋ ์์ฑ + override)
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+from dartlab.core.registry import buildQuestionModules
+
+# registry์ ์๋ ๋ชจ๋(sections topic ์ ์ฉ ๋ฑ)์ override๋ก ์ถ๊ฐ
+_QUESTION_MODULES_OVERRIDE: dict[str, list[str]] = {
+ "๊ณต์": [],
+ "๋ฐฐ๋น": ["treasuryStock"],
+ "์๋ณธ": ["treasuryStock"],
+ "์ฌ์
": ["businessOverview"],
+ "ESG": ["governanceOverview", "boardOfDirectors"],
+ "๊ณต๊ธ๋ง": ["segments", "rawMaterial"],
+ "๋ณํ": ["disclosureChanges", "businessStatus"],
+ "๋ฐธ๋ฅ์์ด์
": ["IS", "BS"],
+}
+
+_QUESTION_MODULES: dict[str, list[str]] = {}
+for _qt, _mods in buildQuestionModules().items():
+ _QUESTION_MODULES[_qt] = list(_mods)
+for _qt, _extra in _QUESTION_MODULES_OVERRIDE.items():
+ _QUESTION_MODULES.setdefault(_qt, []).extend(m for m in _extra if m not in _QUESTION_MODULES.get(_qt, []))
+
+_ALWAYS_INCLUDE_MODULES = {"employee"}
+
+_CONTEXT_MODULE_BUDGET = 10000 # ์ด ๋ชจ๋ context ๊ธ์ ์ ์ํ (focused tier ๊ธฐ๋ณธ๊ฐ)
+
+
+def _resolve_context_budget(tier: str = "focused") -> int:
+ """์ปจํ
์คํธ tier๋ณ ๋ชจ๋ ์์ฐ."""
+ return {
+ "skeleton": 2000, # tool-capable: ์ต์ ๋งฅ๋ฝ, ๋๊ตฌ๋ก ๋ณด์ถฉ
+ "focused": 10000, # ๋ถ๊ธฐ ๋ฐ์ดํฐ ์์ฉ
+ "full": 16000, # non-tool ๋ชจ๋ธ: ์ต๋ํ ํฌํจ
+ }.get(tier, 10000)
+
+
+def _topic_name_set(company: Any) -> set[str]:
+ """Company.topics์์ ์ค์ topic ์ด๋ฆ๋ง ์์ ํ๊ฒ ์ถ์ถ."""
+ try:
+ topics = getattr(company, "topics", None)
+ except _CONTEXT_ERRORS:
+ return set()
+
+ if topics is None:
+ return set()
+
+ if isinstance(topics, pl.DataFrame):
+ if "topic" not in topics.columns:
+ return set()
+ return {t for t in topics["topic"].drop_nulls().to_list() if isinstance(t, str) and t}
+
+ if isinstance(topics, pl.Series):
+ return {t for t in topics.drop_nulls().to_list() if isinstance(t, str) and t}
+
+ try:
+ return {str(t) for t in topics if isinstance(t, str) and t}
+ except TypeError:
+ return set()
+
+
+def _resolve_module_data(company: Any, module_name: str) -> Any:
+ """AI context์ฉ ๋ชจ๋ ํด์.
+
+ 1. Company property/direct attr
+ 2. registry ๊ธฐ๋ฐ lazy parser (_get_primary)
+ 3. ์ค์ ์กด์ฌํ๋ topic์ ํํด show()
+ """
+ data = getattr(company, module_name, None)
+ if data is not None:
+ return data
+
+ get_primary = getattr(company, "_get_primary", None)
+ if callable(get_primary):
+ try:
+ data = get_primary(module_name)
+ except _CONTEXT_ERRORS:
+ data = None
+ except (FileNotFoundError, ImportError, IndexError):
+ data = None
+ if data is not None:
+ return data
+
+ if hasattr(company, "show") and module_name in _topic_name_set(company):
+ try:
+ return company.show(module_name)
+ except _CONTEXT_ERRORS:
+ return None
+
+ return None
+
+
+def _extract_module_context(company: Any, module_name: str, max_rows: int = 10) -> str | None:
+ """registry ๋ชจ๋ โ ๋งํฌ๋ค์ด ์์ฝ. DataFrame/dict/list/text ๋ชจ๋ ์ฒ๋ฆฌ."""
+ try:
+ data = _resolve_module_data(company, module_name)
+ if data is None:
+ return None
+
+ if callable(data) and not isinstance(data, type):
+ try:
+ data = data()
+ except (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError):
+ return None
+
+ meta = MODULE_META.get(module_name)
+ label = meta.label if meta else module_name
+
+ if isinstance(data, pl.DataFrame):
+ if data.is_empty():
+ return None
+ md = df_to_markdown(data, max_rows=max_rows, meta=meta, compact=True)
+ return f"## {label}\n{md}"
+
+ if isinstance(data, dict):
+ items = list(data.items())[:max_rows]
+ lines = [f"## {label}"]
+ for k, v in items:
+ lines.append(f"- {k}: {v}")
+ return "\n".join(lines)
+
+ if isinstance(data, list):
+ if not data:
+ return None
+ lines = [f"## {label}"]
+ for item in data[:max_rows]:
+ if hasattr(item, "title") and hasattr(item, "chars"):
+ lines.append(f"- **{item.title}** ({item.chars}์)")
+ else:
+ lines.append(f"- {item}")
+ if len(data) > max_rows:
+ lines.append(f"(... ์์ {max_rows}๊ฑด, ์ ์ฒด {len(data)}๊ฑด)")
+ return "\n".join(lines)
+
+ text = str(data)
+ if len(text) > 300:
+ text = (
+ text[:300]
+ + f"... (์ ์ฒด {len(str(data))}์, explore(action='show', topic='{module_name}')์ผ๋ก ์ ๋ฌธ ํ์ธ)"
+ )
+ return f"## {label}\n{text}" if text.strip() else None
+
+ except (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError):
+ return None
+
+
+def _build_report_sections(
+ company: Any,
+ compact: bool = False,
+ q_types: list[str] | None = None,
+ tier: str = "focused",
+ report_names: list[str] | None = None,
+) -> dict[str, str]:
+ """reportEngine pivot ๊ฒฐ๊ณผ + ์ง๋ฌธ ์ ํ๋ณ ๋ชจ๋ ์๋ ์ฃผ์
โ LLM context ์น์
dict."""
+ report = getattr(company, "report", None)
+ sections: dict[str, str] = {}
+ budget = _resolve_context_budget(tier)
+ requested_reports = set(report_names or ["dividend", "employee", "majorHolder", "executive", "audit"])
+
+ # ์ง๋ฌธ ์ ํ๋ณ ์ถ๊ฐ ๋ชจ๋ ์ฃผ์
+ extra_modules: set[str] = set() if report_names is not None else set(_ALWAYS_INCLUDE_MODULES)
+ if q_types and report_names is None:
+ for qt in q_types:
+ for mod in _QUESTION_MODULES.get(qt, []):
+ extra_modules.add(mod)
+
+ # ํ๋์ฝ๋ฉ๋ ๊ธฐ์กด report ๋ชจ๋๋ค์ ์ด๋ฆ (์ค๋ณต ๋ฐฉ์ง์ฉ)
+ _HARDCODED_REPORT = {"dividend", "employee", "majorHolder", "executive", "audit"}
+ if report_names:
+ for mod in report_names:
+ if mod not in _HARDCODED_REPORT:
+ extra_modules.add(mod)
+
+ # ๋์ ๋ชจ๋ ์ฃผ์
(ํ๋์ฝ๋ฉ์ ์๋ ๊ฒ๋ง)
+ budget_used = 0
+ for mod in sorted(extra_modules - _HARDCODED_REPORT):
+ if budget_used >= budget:
+ break
+ content = _extract_module_context(company, mod, max_rows=8 if compact else 12)
+ if content:
+ budget_used += len(content)
+ sections[f"module_{mod}"] = content
+
+ if report is None:
+ return sections
+
+ max_years = 3 if compact else 99
+
+ div = getattr(report, "dividend", None) if "dividend" in requested_reports else None
+ if div is not None and div.years:
+ display_years = div.years[-max_years:]
+ offset = len(div.years) - len(display_years)
+ lines = ["## ๋ฐฐ๋น ์๊ณ์ด (์ ๊ธฐ๋ณด๊ณ ์)"]
+ header = "| ์ฐ๋ | " + " | ".join(str(y) for y in display_years) + " |"
+ sep = "| --- | " + " | ".join(["---"] * len(display_years)) + " |"
+ lines.append(header)
+ lines.append(sep)
+
+ def _fmtList(vals):
+ return [str(round(v)) if v is not None else "-" for v in vals]
+
+ lines.append("| DPS(์) | " + " | ".join(_fmtList(div.dps[offset:])) + " |")
+ lines.append(
+ "| ๋ฐฐ๋น์์ต๋ฅ (%) | "
+ + " | ".join([f"{v:.2f}" if v is not None else "-" for v in div.dividendYield[offset:]])
+ + " |"
+ )
+ latest_dps = div.dps[-1] if div.dps else None
+ latest_yield = div.dividendYield[-1] if div.dividendYield else None
+ if latest_dps is not None or latest_yield is not None:
+ lines.append("")
+ lines.append("### ๋ฐฐ๋น ํต์ฌ ์์ฝ")
+ if latest_dps is not None:
+ lines.append(f"- ์ต๊ทผ ์ฐ๋ DPS: {int(round(latest_dps))}์")
+ if latest_yield is not None:
+ lines.append(f"- ์ต๊ทผ ์ฐ๋ ๋ฐฐ๋น์์ต๋ฅ : {latest_yield:.2f}%")
+ if len(display_years) >= 3:
+ recent_dps = [
+ f"{year}:{int(round(value)) if value is not None else '-'}์"
+ for year, value in zip(display_years[-3:], div.dps[offset:][-3:], strict=False)
+ ]
+ lines.append("- ์ต๊ทผ 3๊ฐ๋
DPS ์ถ์ด: " + " โ ".join(recent_dps))
+ sections["report_dividend"] = "\n".join(lines)
+
+ emp = getattr(report, "employee", None) if "employee" in requested_reports else None
+ if emp is not None and emp.years:
+ display_years = emp.years[-max_years:]
+ offset = len(emp.years) - len(display_years)
+ lines = ["## ์ง์ํํฉ (์ ๊ธฐ๋ณด๊ณ ์)"]
+ header = "| ์ฐ๋ | " + " | ".join(str(y) for y in display_years) + " |"
+ sep = "| --- | " + " | ".join(["---"] * len(display_years)) + " |"
+ lines.append(header)
+ lines.append(sep)
+
+ def _fmtEmp(vals):
+ return [f"{int(v):,}" if v is not None else "-" for v in vals]
+
+ def _fmtSalary(vals):
+ return [f"{int(v):,}" if v is not None else "-" for v in vals]
+
+ lines.append("| ์ด ์ง์์(๋ช
) | " + " | ".join(_fmtEmp(emp.totalEmployee[offset:])) + " |")
+ lines.append("| ํ๊ท ์๊ธ(์ฒ์) | " + " | ".join(_fmtSalary(emp.avgMonthlySalary[offset:])) + " |")
+ sections["report_employee"] = "\n".join(lines)
+
+ mh = getattr(report, "majorHolder", None) if "majorHolder" in requested_reports else None
+ if mh is not None and mh.years:
+ lines = ["## ์ต๋์ฃผ์ฃผ (์ ๊ธฐ๋ณด๊ณ ์)"]
+ if compact:
+ latest_ratio = mh.totalShareRatio[-1] if mh.totalShareRatio else None
+ ratio_str = f"{latest_ratio:.2f}%" if latest_ratio is not None else "-"
+ lines.append(f"- {mh.years[-1]}๋
ํฉ์ฐ ์ง๋ถ์จ: {ratio_str}")
+ else:
+ header = "| ์ฐ๋ | " + " | ".join(str(y) for y in mh.years) + " |"
+ sep = "| --- | " + " | ".join(["---"] * len(mh.years)) + " |"
+ lines.append(header)
+ lines.append(sep)
+ lines.append(
+ "| ํฉ์ฐ ์ง๋ถ์จ(%) | "
+ + " | ".join([f"{v:.2f}" if v is not None else "-" for v in mh.totalShareRatio])
+ + " |"
+ )
+
+ if mh.latestHolders:
+ holder_limit = 3 if compact else 5
+ if not compact:
+ lines.append("")
+ lines.append(f"### ์ต๊ทผ ์ฃผ์์ฃผ์ฃผ ({mh.years[-1]}๋
)")
+ for h in mh.latestHolders[:holder_limit]:
+ ratio = f"{h['ratio']:.2f}%" if h.get("ratio") is not None else "-"
+ relate = f" ({h['relate']})" if h.get("relate") else ""
+ lines.append(f"- {h['name']}{relate}: {ratio}")
+ sections["report_majorHolder"] = "\n".join(lines)
+
+ exe = getattr(report, "executive", None) if "executive" in requested_reports else None
+ if exe is not None and exe.totalCount > 0:
+ lines = [
+ "## ์์ํํฉ (์ ๊ธฐ๋ณด๊ณ ์)",
+ f"- ์ด ์์์: {exe.totalCount}๋ช
",
+ f"- ์ฌ๋ด์ด์ฌ: {exe.registeredCount}๋ช
",
+ f"- ์ฌ์ธ์ด์ฌ: {exe.outsideCount}๋ช
",
+ ]
+ sections["report_executive"] = "\n".join(lines)
+
+ aud = getattr(report, "audit", None) if "audit" in requested_reports else None
+ if aud is not None and aud.years:
+ lines = ["## ๊ฐ์ฌ์๊ฒฌ (์ ๊ธฐ๋ณด๊ณ ์)"]
+ display_aud = list(zip(aud.years, aud.opinions, aud.auditors))
+ if compact:
+ display_aud = display_aud[-2:]
+ for y, opinion, auditor in display_aud:
+ opinion = opinion or "-"
+ auditor = auditor or "-"
+ lines.append(f"- {y}๋
: {opinion} ({auditor})")
+ sections["report_audit"] = "\n".join(lines)
+
+ return sections
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# financeEngine ๊ธฐ๋ฐ ์ปจํ
์คํธ (1์ฐจ ๋ฐ์ดํฐ ์์ค)
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+_YEAR_HINT_KEYWORDS: dict[str, int] = {
+ "์ต๊ทผ": 3,
+ "์ฌํด": 3,
+ "์๋
": 3,
+ "์ ๋
": 3,
+ "์ถ์ด": 5,
+ "ํธ๋ ๋": 5,
+ "์ถ์ธ": 5,
+ "๋ณํ": 5,
+ "์ฑ์ฅ": 5,
+ "ํ๋ฆ": 5,
+ "์ ์ฒด": 15,
+ "์ญ์ฌ": 15,
+ "์ฅ๊ธฐ": 10,
+}
+
+
+def _detect_year_hint(question: str) -> int:
+ """์ง๋ฌธ์์ ํ์ํ ์ฐ๋ ๋ฒ์ ์ถ์ถ."""
+ range_match = re.search(r"(\d+)\s*(?:๊ฐ๋
|๋
)", question)
+ if range_match:
+ value = int(range_match.group(1))
+ if 1 <= value <= 15:
+ return value
+
+ year_match = re.search(r"(20\d{2})", question)
+ if year_match:
+ return 3
+
+ for keyword, n in _YEAR_HINT_KEYWORDS.items():
+ if keyword in question:
+ return n
+
+ return 5
+
+
+_FE_DISPLAY_ACCOUNTS = {
+ "BS": [
+ ("total_assets", "์์ฐ์ด๊ณ"),
+ ("current_assets", "์ ๋์์ฐ"),
+ ("noncurrent_assets", "๋น์ ๋์์ฐ"),
+ ("total_liabilities", "๋ถ์ฑ์ด๊ณ"),
+ ("current_liabilities", "์ ๋๋ถ์ฑ"),
+ ("noncurrent_liabilities", "๋น์ ๋๋ถ์ฑ"),
+ ("owners_of_parent_equity", "์๋ณธ์ด๊ณ"),
+ ("cash_and_cash_equivalents", "ํ๊ธ์ฑ์์ฐ"),
+ ("trade_and_other_receivables", "๋งค์ถ์ฑ๊ถ"),
+ ("inventories", "์ฌ๊ณ ์์ฐ"),
+ ("tangible_assets", "์ ํ์์ฐ"),
+ ("intangible_assets", "๋ฌดํ์์ฐ"),
+ ("shortterm_borrowings", "๋จ๊ธฐ์ฐจ์
๊ธ"),
+ ("longterm_borrowings", "์ฅ๊ธฐ์ฐจ์
๊ธ"),
+ ],
+ "IS": [
+ ("sales", "๋งค์ถ์ก"),
+ ("cost_of_sales", "๋งค์ถ์๊ฐ"),
+ ("gross_profit", "๋งค์ถ์ด์ด์ต"),
+ ("selling_and_administrative_expenses", "ํ๊ด๋น"),
+ ("operating_profit", "์์
์ด์ต"),
+ ("finance_income", "๊ธ์ต์์ต"),
+ ("finance_costs", "๊ธ์ต๋น์ฉ"),
+ ("profit_before_tax", "๋ฒ์ธ์ธ์ฐจ๊ฐ์ ์ด์ต"),
+ ("income_taxes", "๋ฒ์ธ์ธ๋น์ฉ"),
+ ("net_profit", "๋น๊ธฐ์์ด์ต"),
+ ],
+ "CF": [
+ ("operating_cashflow", "์์
ํ๋CF"),
+ ("investing_cashflow", "ํฌ์ํ๋CF"),
+ ("cash_flows_from_financing_activities", "์ฌ๋ฌดํ๋CF"),
+ ("cash_and_cash_equivalents_end", "๊ธฐ๋งํ๊ธ"),
+ ],
+}
+
+
+# ํ๊ธ ๋ผ๋ฒจ โ snakeId ์ญ๋งคํ (Phase 5 validation์ฉ)
+ACCOUNT_LABEL_TO_SNAKE: dict[str, str] = {}
+for _sj_accounts in _FE_DISPLAY_ACCOUNTS.values():
+ for _snake_id, _label in _sj_accounts:
+ ACCOUNT_LABEL_TO_SNAKE[_label] = _snake_id
+
+_QUESTION_ACCOUNT_FILTER: dict[str, dict[str, set[str]]] = {
+ "๊ฑด์ ์ฑ": {
+ "BS": {
+ "total_assets",
+ "total_liabilities",
+ "owners_of_parent_equity",
+ "current_assets",
+ "current_liabilities",
+ "cash_and_cash_equivalents",
+ "shortterm_borrowings",
+ "longterm_borrowings",
+ },
+ "IS": {"operating_profit", "finance_costs", "net_profit"},
+ "CF": {"operating_cashflow", "investing_cashflow"},
+ },
+ "์์ต์ฑ": {
+ "IS": {
+ "sales",
+ "cost_of_sales",
+ "gross_profit",
+ "selling_and_administrative_expenses",
+ "operating_profit",
+ "net_profit",
+ },
+ "BS": {"owners_of_parent_equity", "total_assets"},
+ },
+ "์ฑ์ฅ์ฑ": {
+ "IS": {"sales", "operating_profit", "net_profit"},
+ "CF": {"operating_cashflow"},
+ },
+ "๋ฐฐ๋น": {
+ "IS": {"net_profit"},
+ "BS": {"owners_of_parent_equity"},
+ },
+ "ํ๊ธ": {
+ "CF": {
+ "operating_cashflow",
+ "investing_cashflow",
+ "cash_flows_from_financing_activities",
+ "cash_and_cash_equivalents_end",
+ },
+ "BS": {"cash_and_cash_equivalents"},
+ },
+}
+
+
+def _get_quarter_counts(company: Any) -> dict[str, int]:
+ """company.timeseries periods์์ ์ฐ๋๋ณ ๋ถ๊ธฐ ์ ๊ณ์ฐ."""
+ ts = getattr(company, "timeseries", None)
+ if ts is None:
+ return {}
+ _, periods = ts
+ counts: dict[str, int] = {}
+ for p in periods:
+ year = p.split("-")[0] if "-" in p else p[:4]
+ counts[year] = counts.get(year, 0) + 1
+ return counts
+
+
+def _build_finance_engine_section(
+ series: dict,
+ years: list[str],
+ sj_div: str,
+ n_years: int,
+ account_filter: set[str] | None = None,
+ quarter_counts: dict[str, int] | None = None,
+) -> str | None:
+ """financeEngine annual series โ compact ๋งํฌ๋ค์ด ํ
์ด๋ธ.
+
+ Args:
+ account_filter: ์ด set์ ์ํ snake_id๋ง ํ์. None์ด๋ฉด ์ ์ฒด.
+ """
+ accounts = _FE_DISPLAY_ACCOUNTS.get(sj_div, [])
+ if account_filter:
+ accounts = [(sid, label) for sid, label in accounts if sid in account_filter]
+ if not accounts:
+ return None
+
+ display_years = years[-n_years:]
+
+ # ๋ถ๋ถ ์ฐ๋ ํ์: IS/CF๋ 4๋ถ๊ธฐ ๋ฏธ๋ง์ด๋ฉด "(~Q3)" ๋ฑ ํ์, BS๋ ์์ ์์ก์ด๋ฏ๋ก ๋ถํ์
+ display_years_labeled = []
+ for y in display_years:
+ qc = (quarter_counts or {}).get(y, 4)
+ if sj_div != "BS" and qc < 4:
+ display_years_labeled.append(f"{y}(~Q{qc})")
+ else:
+ display_years_labeled.append(y)
+ display_years_reversed = list(reversed(display_years_labeled))
+
+ # ์ต์ ์ฐ๋๊ฐ ๋ถ๋ถ์ด๋ฉด YoY ๋น๊ต ๋ฌด์๋ฏธ
+ latest_year = display_years[-1]
+ latest_partial = sj_div != "BS" and (quarter_counts or {}).get(latest_year, 4) < 4
+
+ sj_data = series.get(sj_div, {})
+ if not sj_data:
+ return None
+
+ rows_data = []
+ for snake_id, label in accounts:
+ vals = sj_data.get(snake_id)
+ if not vals:
+ continue
+ year_offset = len(years) - n_years
+ sliced = vals[year_offset:] if year_offset >= 0 else vals
+ has_data = any(v is not None for v in sliced)
+ if has_data:
+ rows_data.append((label, list(reversed(sliced))))
+
+ if not rows_data:
+ return None
+
+ sj_labels = {"BS": "์ฌ๋ฌด์ํํ", "IS": "์์ต๊ณ์ฐ์", "CF": "ํ๊ธํ๋ฆํ"}
+ header = "| ๊ณ์ | " + " | ".join(display_years_reversed) + " | YoY |"
+ sep = "| --- | " + " | ".join(["---"] * len(display_years_reversed)) + " | --- |"
+
+ # ๊ธฐ๊ฐ ๋ฉํ๋ฐ์ดํฐ ๋ช
์
+ sj_meta = {"BS": "์์ ์์ก", "IS": "๊ธฐ๊ฐ flow (standalone)", "CF": "๊ธฐ๊ฐ flow (standalone)"}
+ meta_line = f"(๋จ์: ์ต/์กฐ์ | {sj_meta.get(sj_div, 'standalone')})"
+ if latest_partial:
+ meta_line += f" โ ๏ธ {display_years_labeled[-1]}์ ๋ถ๋ถ์ฐ๋ โ ์ฐ๊ฐ ์ง์ ๋น๊ต ๋ถ๊ฐ"
+
+ lines = [f"## {sj_labels.get(sj_div, sj_div)}", meta_line, header, sep]
+ for label, vals in rows_data:
+ cells = []
+ for v in vals:
+ cells.append(_format_won(v) if v is not None else "-")
+ # YoY: ๋ถ๋ถ ์ฐ๋๋ฉด ๋น๊ต ๋ถ๊ฐ
+ if latest_partial:
+ yoy_str = "-"
+ else:
+ yoy_str = _calc_yoy(vals[0], vals[1] if len(vals) > 1 else None)
+ lines.append(f"| {label} | " + " | ".join(cells) + f" | {yoy_str} |")
+
+ return "\n".join(lines)
+
+
+def _buildQuarterlySection(
+ series: dict,
+ periods: list[str],
+ sjDiv: str,
+ nQuarters: int = 8,
+ accountFilter: set[str] | None = None,
+) -> str | None:
+ """timeseries ๋ถ๊ธฐ๋ณ standalone โ compact ๋งํฌ๋ค์ด ํ
์ด๋ธ.
+
+ ์ต๊ทผ nQuarters ๋ถ๊ธฐ๋ง ํ์. QoQ/YoY ์ปฌ๋ผ ํฌํจ.
+ """
+ accounts = _FE_DISPLAY_ACCOUNTS.get(sjDiv, [])
+ if accountFilter:
+ accounts = [(sid, label) for sid, label in accounts if sid in accountFilter]
+ if not accounts:
+ return None
+
+ sjData = series.get(sjDiv, {})
+ if not sjData:
+ return None
+
+ displayPeriods = periods[-nQuarters:]
+ displayPeriodsReversed = list(reversed(displayPeriods))
+
+ rowsData = []
+ for snakeId, label in accounts:
+ vals = sjData.get(snakeId)
+ if not vals:
+ continue
+ offset = len(periods) - nQuarters
+ sliced = vals[offset:] if offset >= 0 else vals
+ hasData = any(v is not None for v in sliced)
+ if hasData:
+ rowsData.append((label, list(reversed(sliced))))
+
+ if not rowsData:
+ return None
+
+ sjLabels = {"BS": "์ฌ๋ฌด์ํํ(๋ถ๊ธฐ)", "IS": "์์ต๊ณ์ฐ์(๋ถ๊ธฐ)", "CF": "ํ๊ธํ๋ฆํ(๋ถ๊ธฐ)"}
+ sjMeta = {"BS": "์์ ์์ก", "IS": "๋ถ๊ธฐ standalone", "CF": "๋ถ๊ธฐ standalone"}
+
+ header = "| ๊ณ์ | " + " | ".join(displayPeriodsReversed) + " | QoQ | YoY |"
+ sep = "| --- | " + " | ".join(["---"] * len(displayPeriodsReversed)) + " | --- | --- |"
+ metaLine = f"(๋จ์: ์ต/์กฐ์ | {sjMeta.get(sjDiv, 'standalone')})"
+
+ lines = [f"## {sjLabels.get(sjDiv, sjDiv)}", metaLine, header, sep]
+ for label, vals in rowsData:
+ cells = [_format_won(v) if v is not None else "-" for v in vals]
+ qoq = _calc_yoy(vals[0], vals[1] if len(vals) > 1 else None)
+ yoyIdx = 4 if len(vals) > 4 else None
+ yoy = _calc_yoy(vals[0], vals[yoyIdx] if yoyIdx is not None else None)
+ lines.append(f"| {label} | " + " | ".join(cells) + f" | {qoq} | {yoy} |")
+
+ return "\n".join(lines)
+
+
+def _calc_yoy(current: float | None, previous: float | None) -> str:
+ """YoY ์ฆ๊ฐ๋ฅ ๊ณ์ฐ. ๋ถํธ ์ ํ ์ '-', |๋ณ๋๋ฅ |>50%๋ฉด ** ๊ฐ์กฐ."""
+ from dartlab.core.finance.ratios import yoy_pct
+
+ pct = yoy_pct(current, previous)
+ if pct is None:
+ return "-"
+ sign = "+" if pct >= 0 else ""
+ marker = "**" if abs(pct) > 50 else ""
+ return f"{marker}{sign}{pct:.1f}%{marker}"
+
+
+def _build_ratios_section(
+ company: Any,
+ compact: bool = False,
+ q_types: list[str] | None = None,
+) -> str | None:
+ """financeEngine RatioResult โ ๋งํฌ๋ค์ด (์ง๋ฌธ ์ ํ๋ณ ํํฐ๋ง).
+
+ q_types๊ฐ ์ฃผ์ด์ง๋ฉด ๊ด๋ จ ๋น์จ ๊ทธ๋ฃน๋ง ๋
ธ์ถํ์ฌ ํ ํฐ ์ ์ฝ.
+ None์ด๋ฉด ์ ์ฒด ๋
ธ์ถ.
+ """
+ ratios = get_headline_ratios(company)
+ if ratios is None:
+ return None
+ if not hasattr(ratios, "roe"):
+ return None
+
+ isFinancial = False
+ sectorInfo = getattr(company, "sector", None)
+ if sectorInfo is not None:
+ try:
+ from dartlab.analysis.comparative.sector.types import Sector
+
+ isFinancial = sectorInfo.sector == Sector.FINANCIALS
+ except (ImportError, AttributeError):
+ isFinancial = False
+
+ # โโ ํ๋จ ํฌํผ โโ
+ def _judge(val: float | None, good: float, caution: float) -> str:
+ if val is None:
+ return "-"
+ return "์ํธ" if val >= good else ("์ฃผ์" if val >= caution else "์ํ")
+
+ def _judge_inv(val: float | None, good: float, caution: float) -> str:
+ if val is None:
+ return "-"
+ return "์ํธ" if val <= good else ("์ฃผ์" if val <= caution else "์ํ")
+
+ # โโ ์ง๋ฌธ ์ ํ โ ๋
ธ์ถ ๊ทธ๋ฃน ๋งคํ โโ
+ _Q_TYPE_TO_GROUPS: dict[str, list[str]] = {
+ "๊ฑด์ ์ฑ": ["์์ต์ฑ_core", "์์ ์ฑ", "ํ๊ธํ๋ฆ", "๋ณตํฉ"],
+ "์์ต์ฑ": ["์์ต์ฑ", "ํจ์จ์ฑ", "๋ณตํฉ"],
+ "์ฑ์ฅ์ฑ": ["์์ต์ฑ_core", "์ฑ์ฅ"],
+ "๋ฐฐ๋น": ["์์ต์ฑ_core", "ํ๊ธํ๋ฆ"],
+ "๋ฆฌ์คํฌ": ["์์ ์ฑ", "ํ๊ธํ๋ฆ", "๋ณตํฉ"],
+ "ํฌ์": ["์์ต์ฑ_core", "์ฑ์ฅ", "ํ๊ธํ๋ฆ"],
+ "์ข
ํฉ": ["์์ต์ฑ", "์์ ์ฑ", "์ฑ์ฅ", "ํจ์จ์ฑ", "ํ๊ธํ๋ฆ", "๋ณตํฉ"],
+ }
+
+ active_groups: set[str] = set()
+ if q_types:
+ for qt in q_types:
+ active_groups.update(_Q_TYPE_TO_GROUPS.get(qt, []))
+ if not active_groups:
+ active_groups = {"์์ต์ฑ", "์์ ์ฑ", "์ฑ์ฅ", "ํจ์จ์ฑ", "ํ๊ธํ๋ฆ", "๋ณตํฉ"}
+
+ # "์์ต์ฑ_core"๋ ์์ต์ฑ์ ํต์ฌ๋ง (ROE, ROA, ์์
์ด์ต๋ฅ , ์์ด์ต๋ฅ )
+ show_profitability_full = "์์ต์ฑ" in active_groups
+ show_profitability_core = show_profitability_full or "์์ต์ฑ_core" in active_groups
+
+ roeGood, roeCaution = (8, 5) if isFinancial else (10, 5)
+ roaGood, roaCaution = (0.5, 0.2) if isFinancial else (5, 2)
+
+ lines = ["## ํต์ฌ ์ฌ๋ฌด๋น์จ (์๋๊ณ์ฐ)"]
+
+ # โโ ์์ต์ฑ โโ
+ if show_profitability_core:
+ prof_rows: list[str] = []
+ if ratios.roe is not None:
+ prof_rows.append(f"| ROE | {ratios.roe:.1f}% | {_judge(ratios.roe, roeGood, roeCaution)} |")
+ if ratios.roa is not None:
+ prof_rows.append(f"| ROA | {ratios.roa:.1f}% | {_judge(ratios.roa, roaGood, roaCaution)} |")
+ if ratios.operatingMargin is not None:
+ prof_rows.append(f"| ์์
์ด์ต๋ฅ | {ratios.operatingMargin:.1f}% | - |")
+ if not compact and ratios.netMargin is not None:
+ prof_rows.append(f"| ์์ด์ต๋ฅ | {ratios.netMargin:.1f}% | - |")
+ if show_profitability_full:
+ if ratios.grossMargin is not None:
+ prof_rows.append(f"| ๋งค์ถ์ด์ด์ต๋ฅ | {ratios.grossMargin:.1f}% | - |")
+ if ratios.ebitdaMargin is not None:
+ prof_rows.append(f"| EBITDA๋ง์ง | {ratios.ebitdaMargin:.1f}% | - |")
+ if not compact and ratios.roic is not None:
+ prof_rows.append(f"| ROIC | {ratios.roic:.1f}% | {_judge(ratios.roic, 15, 8)} |")
+ if prof_rows:
+ lines.append("\n### ์์ต์ฑ")
+ lines.append("| ์งํ | ๊ฐ | ํ๋จ |")
+ lines.append("| --- | --- | --- |")
+ lines.extend(prof_rows)
+
+ # โโ ์์ ์ฑ โโ
+ if "์์ ์ฑ" in active_groups:
+ stab_rows: list[str] = []
+ if ratios.debtRatio is not None:
+ stab_rows.append(f"| ๋ถ์ฑ๋น์จ | {ratios.debtRatio:.1f}% | {_judge_inv(ratios.debtRatio, 100, 200)} |")
+ if ratios.currentRatio is not None:
+ stab_rows.append(f"| ์ ๋๋น์จ | {ratios.currentRatio:.1f}% | {_judge(ratios.currentRatio, 150, 100)} |")
+ if not compact and ratios.quickRatio is not None:
+ stab_rows.append(f"| ๋น์ข๋น์จ | {ratios.quickRatio:.1f}% | {_judge(ratios.quickRatio, 100, 50)} |")
+ if not compact and ratios.equityRatio is not None:
+ stab_rows.append(f"| ์๊ธฐ์๋ณธ๋น์จ | {ratios.equityRatio:.1f}% | {_judge(ratios.equityRatio, 50, 30)} |")
+ if ratios.interestCoverage is not None:
+ stab_rows.append(
+ f"| ์ด์๋ณด์๋ฐฐ์จ | {ratios.interestCoverage:.1f}x | {_judge(ratios.interestCoverage, 5, 1)} |"
+ )
+ if not compact and ratios.debtToEbitda is not None:
+ stab_rows.append(f"| Debt/EBITDA | {ratios.debtToEbitda:.1f}x | {_judge_inv(ratios.debtToEbitda, 3, 5)} |")
+ if not compact and ratios.netDebt is not None:
+ stab_rows.append(
+ f"| ์์ฐจ์
๊ธ | {_format_won(ratios.netDebt)} | {'์ํธ' if ratios.netDebt <= 0 else '์ฃผ์'} |"
+ )
+ if not compact and ratios.netDebtRatio is not None:
+ stab_rows.append(
+ f"| ์์ฐจ์
๊ธ๋น์จ | {ratios.netDebtRatio:.1f}% | {_judge_inv(ratios.netDebtRatio, 30, 80)} |"
+ )
+ if stab_rows:
+ lines.append("\n### ์์ ์ฑ")
+ lines.append("| ์งํ | ๊ฐ | ํ๋จ |")
+ lines.append("| --- | --- | --- |")
+ lines.extend(stab_rows)
+
+ # โโ ์ฑ์ฅ์ฑ โโ
+ if "์ฑ์ฅ" in active_groups:
+ grow_rows: list[str] = []
+ if ratios.revenueGrowth is not None:
+ grow_rows.append(f"| ๋งค์ถ์ฑ์ฅ๋ฅ (YoY) | {ratios.revenueGrowth:.1f}% | - |")
+ if ratios.operatingProfitGrowth is not None:
+ grow_rows.append(f"| ์์
์ด์ต์ฑ์ฅ๋ฅ | {ratios.operatingProfitGrowth:.1f}% | - |")
+ if ratios.netProfitGrowth is not None:
+ grow_rows.append(f"| ์์ด์ต์ฑ์ฅ๋ฅ | {ratios.netProfitGrowth:.1f}% | - |")
+ if ratios.revenueGrowth3Y is not None:
+ grow_rows.append(f"| ๋งค์ถ 3Y CAGR | {ratios.revenueGrowth3Y:.1f}% | - |")
+ if not compact and ratios.assetGrowth is not None:
+ grow_rows.append(f"| ์์ฐ์ฑ์ฅ๋ฅ | {ratios.assetGrowth:.1f}% | - |")
+ if grow_rows:
+ lines.append("\n### ์ฑ์ฅ์ฑ")
+ lines.append("| ์งํ | ๊ฐ | ํ๋จ |")
+ lines.append("| --- | --- | --- |")
+ lines.extend(grow_rows)
+
+ # โโ ํจ์จ์ฑ โโ
+ if "ํจ์จ์ฑ" in active_groups and not compact:
+ eff_rows: list[str] = []
+ if ratios.totalAssetTurnover is not None:
+ eff_rows.append(f"| ์ด์์ฐํ์ ์จ | {ratios.totalAssetTurnover:.2f}x | - |")
+ if ratios.inventoryTurnover is not None:
+ eff_rows.append(f"| ์ฌ๊ณ ์์ฐํ์ ์จ | {ratios.inventoryTurnover:.1f}x | - |")
+ if ratios.receivablesTurnover is not None:
+ eff_rows.append(f"| ๋งค์ถ์ฑ๊ถํ์ ์จ | {ratios.receivablesTurnover:.1f}x | - |")
+ if eff_rows:
+ lines.append("\n### ํจ์จ์ฑ")
+ lines.append("| ์งํ | ๊ฐ | ํ๋จ |")
+ lines.append("| --- | --- | --- |")
+ lines.extend(eff_rows)
+
+ # โโ ํ๊ธํ๋ฆ โโ
+ if "ํ๊ธํ๋ฆ" in active_groups:
+ cf_rows: list[str] = []
+ if ratios.fcf is not None:
+ cf_rows.append(f"| FCF | {_format_won(ratios.fcf)} | {'์ํธ' if ratios.fcf > 0 else '์ฃผ์'} |")
+ if ratios.operatingCfToNetIncome is not None:
+ quality = _judge(ratios.operatingCfToNetIncome, 100, 50)
+ cf_rows.append(f"| ์์
CF/์์ด์ต | {ratios.operatingCfToNetIncome:.0f}% | {quality} |")
+ if not compact and ratios.capexRatio is not None:
+ cf_rows.append(f"| CAPEX๋น์จ | {ratios.capexRatio:.1f}% | - |")
+ if not compact and ratios.dividendPayoutRatio is not None:
+ cf_rows.append(f"| ๋ฐฐ๋น์ฑํฅ | {ratios.dividendPayoutRatio:.1f}% | - |")
+ if cf_rows:
+ lines.append("\n### ํ๊ธํ๋ฆ")
+ lines.append("| ์งํ | ๊ฐ | ํ๋จ |")
+ lines.append("| --- | --- | --- |")
+ lines.extend(cf_rows)
+
+ # โโ ๋ณตํฉ ์งํ โโ
+ if "๋ณตํฉ" in active_groups and not compact:
+ comp_lines: list[str] = []
+
+ # DuPont ๋ถํด
+ dm = getattr(ratios, "dupontMargin", None)
+ dt = getattr(ratios, "dupontTurnover", None)
+ dl = getattr(ratios, "dupontLeverage", None)
+ if dm is not None and dt is not None and dl is not None and ratios.roe is not None:
+ # ์ฃผ์ ๋์ธ ํ๋ณ
+ if dm >= dt and dm >= dl:
+ driver = "์์ต์ฑ ์ฃผ๋ํ"
+ elif dt >= dm and dt >= dl:
+ driver = "ํจ์จ์ฑ ์ฃผ๋ํ"
+ else:
+ driver = "๋ ๋ฒ๋ฆฌ์ง ์ฃผ๋ํ"
+ comp_lines.append("\n### DuPont ๋ถํด")
+ comp_lines.append(
+ f"ROE {ratios.roe:.1f}% = ์์ด์ต๋ฅ ({dm:.1f}%) ร ์์ฐํ์ ์จ({dt:.2f}x) ร ๋ ๋ฒ๋ฆฌ์ง({dl:.2f}x)"
+ )
+ comp_lines.append(f"โ **{driver}**")
+
+ # Piotroski F-Score
+ pf = getattr(ratios, "piotroskiFScore", None)
+ if pf is not None:
+ pf_label = "์ฐ์" if pf >= 7 else ("๋ณดํต" if pf >= 4 else "์ทจ์ฝ")
+ comp_lines.append("\n### ๋ณตํฉ ์ฌ๋ฌด ์งํ")
+ comp_lines.append(f"- **Piotroski F-Score**: {pf}/9 ({pf_label}) โ โฅ7 ์ฐ์, 4-6 ๋ณดํต, <4 ์ทจ์ฝ")
+
+ # Altman Z-Score
+ az = getattr(ratios, "altmanZScore", None)
+ if az is not None:
+ az_label = "์์ " if az > 2.99 else ("ํ์" if az >= 1.81 else "๋ถ์ค์ํ")
+ if pf is None:
+ comp_lines.append("\n### ๋ณตํฉ ์ฌ๋ฌด ์งํ")
+ comp_lines.append(f"- **Altman Z-Score**: {az:.2f} ({az_label}) โ >2.99 ์์ , 1.81-2.99 ํ์, <1.81 ๋ถ์ค")
+
+ # ROIC
+ if ratios.roic is not None:
+ roic_label = "์ฐ์" if ratios.roic >= 15 else ("์ ์ " if ratios.roic >= 8 else "๋ฏธํก")
+ comp_lines.append(f"- **ROIC**: {ratios.roic:.1f}% ({roic_label})")
+
+ # ์ด์ต์ ์ง โ CCC
+ ccc = getattr(ratios, "ccc", None)
+ dso = getattr(ratios, "dso", None)
+ dio = getattr(ratios, "dio", None)
+ dpo = getattr(ratios, "dpo", None)
+ cfni = ratios.operatingCfToNetIncome
+ has_quality = ccc is not None or cfni is not None
+ if has_quality:
+ comp_lines.append("\n### ์ด์ต์ ์ง")
+ if cfni is not None:
+ q = "์ํธ" if cfni >= 100 else ("๋ณดํต" if cfni >= 50 else "์ฃผ์")
+ comp_lines.append(f"- ์์
CF/์์ด์ต: {cfni:.0f}% ({q}) โ โฅ100% ์ํธ")
+ if ccc is not None:
+ ccc_parts = []
+ if dso is not None:
+ ccc_parts.append(f"DSO:{dso:.0f}")
+ if dio is not None:
+ ccc_parts.append(f"DIO:{dio:.0f}")
+ if dpo is not None:
+ ccc_parts.append(f"DPO:{dpo:.0f}")
+ detail = f" ({' + '.join(ccc_parts)})" if ccc_parts else ""
+ comp_lines.append(f"- CCC(ํ๊ธ์ ํ์ฃผ๊ธฐ): {ccc:.0f}์ผ{detail}")
+
+ if comp_lines:
+ lines.extend(comp_lines)
+
+ # โโ ratioSeries 3๋
์ถ์ธ โโ
+ ratio_series = get_ratio_series(company)
+ if ratio_series is not None and hasattr(ratio_series, "roe") and ratio_series.roe:
+ trend_keys = [("roe", "ROE"), ("operatingMargin", "์์
์ด์ต๋ฅ "), ("debtRatio", "๋ถ์ฑ๋น์จ")]
+ if not compact and "์ฑ์ฅ" in active_groups:
+ trend_keys.append(("revenueGrowth", "๋งค์ถ์ฑ์ฅ๋ฅ "))
+ trend_lines: list[str] = []
+ for key, label in trend_keys:
+ series_vals = getattr(ratio_series, key, None)
+ if series_vals and len(series_vals) >= 2:
+ recent = [f"{v:.1f}%" for v in series_vals[-3:] if v is not None]
+ if recent:
+ arrow = (
+ "โ" if series_vals[-1] > series_vals[-2] else "โ" if series_vals[-1] < series_vals[-2] else "โ"
+ )
+ trend_lines.append(f"- {label}: {' โ '.join(recent)} {arrow}")
+ if trend_lines:
+ lines.append("")
+ lines.append("### ์ถ์ธ (์ต๊ทผ 3๋
)")
+ lines.extend(trend_lines)
+
+ # โโ TTM โโ
+ ttm_lines: list[str] = []
+ if ratios.revenueTTM is not None:
+ ttm_lines.append(f"- TTM ๋งค์ถ: {_format_won(ratios.revenueTTM)}")
+ if ratios.operatingIncomeTTM is not None:
+ ttm_lines.append(f"- TTM ์์
์ด์ต: {_format_won(ratios.operatingIncomeTTM)}")
+ if ratios.netIncomeTTM is not None:
+ ttm_lines.append(f"- TTM ์์ด์ต: {_format_won(ratios.netIncomeTTM)}")
+ if ttm_lines:
+ lines.append("")
+ lines.append("### TTM (์ต๊ทผ 4๋ถ๊ธฐ ํฉ์ฐ)")
+ lines.extend(ttm_lines)
+
+ # โโ ๊ฒฝ๊ณ โโ
+ if ratios.warnings:
+ lines.append("")
+ lines.append("### ๊ฒฝ๊ณ ")
+ max_warnings = 2 if compact else len(ratios.warnings)
+ for w in ratios.warnings[:max_warnings]:
+ lines.append(f"- โ ๏ธ {w}")
+
+ return "\n".join(lines)
+
+
+def detect_year_range(company: Any, tables: list[str]) -> dict | None:
+ """ํฌํจ๋ ๋ฐ์ดํฐ์ ์ฐ๋ ๋ฒ์ ๊ฐ์ง."""
+ all_years: set[int] = set()
+ for name in tables:
+ try:
+ data = getattr(company, name, None)
+ if data is None:
+ continue
+ if isinstance(data, pl.DataFrame):
+ if "year" in data.columns:
+ years = data["year"].unique().to_list()
+ all_years.update(int(y) for y in years if y)
+ else:
+ year_cols = [c for c in data.columns if c.isdigit() and len(c) == 4]
+ all_years.update(int(c) for c in year_cols)
+ except _CONTEXT_ERRORS:
+ continue
+ if not all_years:
+ return None
+ sorted_years = sorted(all_years)
+ return {"min_year": sorted_years[0], "max_year": sorted_years[-1]}
+
+
+def scan_available_modules(company: Any) -> list[dict[str, str]]:
+ """Company ์ธ์คํด์ค์์ ์ค์ ๋ฐ์ดํฐ๊ฐ ์๋ ๋ชจ๋ ๋ชฉ๋ก์ ๋ฐํ.
+
+ Returns:
+ [{"name": "BS", "label": "์ฌ๋ฌด์ํํ", "type": "DataFrame", "rows": 25}, ...]
+ """
+ available = []
+ for name, meta in MODULE_META.items():
+ try:
+ data = getattr(company, name, None)
+ if data is None:
+ continue
+ # method์ธ ๊ฒฝ์ฐ ๊ฑด๋๋ (fsSummary ๋ฑ์ ํธ์ถ ๋น์ฉ์ด ํผ)
+ if callable(data) and not isinstance(data, type):
+ info: dict[str, Any] = {"name": name, "label": meta.label, "type": "method"}
+ available.append(info)
+ continue
+ if isinstance(data, pl.DataFrame):
+ info = {
+ "name": name,
+ "label": meta.label,
+ "type": "table",
+ "rows": data.height,
+ "cols": len(data.columns),
+ }
+ elif isinstance(data, dict):
+ info = {"name": name, "label": meta.label, "type": "dict", "rows": len(data)}
+ elif isinstance(data, list):
+ info = {"name": name, "label": meta.label, "type": "list", "rows": len(data)}
+ else:
+ info = {"name": name, "label": meta.label, "type": "text"}
+ available.append(info)
+ except _CONTEXT_ERRORS:
+ continue
+ return available
diff --git a/src/dartlab/ai/context/formatting.py b/src/dartlab/ai/context/formatting.py
new file mode 100644
index 0000000000000000000000000000000000000000..d0a63412d4aebd700d70ad37b760351ba87f99e8
--- /dev/null
+++ b/src/dartlab/ai/context/formatting.py
@@ -0,0 +1,439 @@
+"""ํฌ๋งทํ
ยท์ ํธ๋ฆฌํฐ ํจ์ โ builder.py์์ ๋ถ๋ฆฌ.
+
+์ ๋จ์ ๋ณํ, DataFrameโ๋งํฌ๋ค์ด, ํ์ ์งํ ์๋๊ณ์ฐ ๋ฑ
+builder / finance_context ์์ชฝ์์ ์ฌ์ฌ์ฉํ๋ ์์ ํจ์ ๋ชจ์.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import polars as pl
+
+from dartlab.ai.metadata import ModuleMeta
+
+_CONTEXT_ERRORS = (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError)
+
+# โโ ํต์ฌ ๊ณ์ ํํฐ์ฉ ์์ โโ
+
+_KEY_ACCOUNTS_BS = {
+ "์์ฐ์ด๊ณ",
+ "์ ๋์์ฐ",
+ "๋น์ ๋์์ฐ",
+ "๋ถ์ฑ์ด๊ณ",
+ "์ ๋๋ถ์ฑ",
+ "๋น์ ๋๋ถ์ฑ",
+ "์๋ณธ์ด๊ณ",
+ "์ง๋ฐฐ๊ธฐ์
์์ ์ฃผ์ง๋ถ",
+ "ํ๊ธ๋ฐํ๊ธ์ฑ์์ฐ",
+ "๋งค์ถ์ฑ๊ถ",
+ "์ฌ๊ณ ์์ฐ",
+ "์ ํ์์ฐ",
+ "๋ฌดํ์์ฐ",
+ "ํฌ์๋ถ๋์ฐ",
+ "๋จ๊ธฐ์ฐจ์
๊ธ",
+ "์ฅ๊ธฐ์ฐจ์
๊ธ",
+ "์ฌ์ฑ",
+}
+
+_KEY_ACCOUNTS_IS = {
+ "๋งค์ถ์ก",
+ "๋งค์ถ์๊ฐ",
+ "๋งค์ถ์ด์ด์ต",
+ "ํ๋งค๋น์๊ด๋ฆฌ๋น",
+ "์์
์ด์ต",
+ "์์
์์ค",
+ "๊ธ์ต์์ต",
+ "๊ธ์ต๋น์ฉ",
+ "์ด์๋น์ฉ",
+ "์ด์์์ต",
+ "๋ฒ์ธ์ธ๋น์ฉ์ฐจ๊ฐ์ ์์ด์ต",
+ "๋ฒ์ธ์ธ๋น์ฉ",
+ "๋น๊ธฐ์์ด์ต",
+ "๋น๊ธฐ์์์ค",
+ "์ง๋ฐฐ๊ธฐ์
์์ ์ฃผ์ง๋ถ์์ด์ต",
+}
+
+_KEY_ACCOUNTS_CF = {
+ "์์
ํ๋ํ๊ธํ๋ฆ",
+ "์์
ํ๋์ผ๋ก์ธํํ๊ธํ๋ฆ",
+ "ํฌ์ํ๋ํ๊ธํ๋ฆ",
+ "ํฌ์ํ๋์ผ๋ก์ธํํ๊ธํ๋ฆ",
+ "์ฌ๋ฌดํ๋ํ๊ธํ๋ฆ",
+ "์ฌ๋ฌดํ๋์ผ๋ก์ธํํ๊ธํ๋ฆ",
+ "ํ๊ธ๋ฐํ๊ธ์ฑ์์ฐ์์์ฆ๊ฐ",
+ "ํ๊ธ๋ฐํ๊ธ์ฑ์์ฐ์์ฆ๊ฐ",
+ "๊ธฐ์ดํ๊ธ๋ฐํ๊ธ์ฑ์์ฐ",
+ "๊ธฐ๋งํ๊ธ๋ฐํ๊ธ์ฑ์์ฐ",
+}
+
+_KEY_ACCOUNTS_MAP = {
+ "BS": _KEY_ACCOUNTS_BS,
+ "IS": _KEY_ACCOUNTS_IS,
+ "CF": _KEY_ACCOUNTS_CF,
+}
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# ์ซ์ ํฌ๋งทํ
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def _format_won(val: float) -> str:
+ """์ ๋จ์ ์ซ์๋ฅผ ์ฝ๊ธฐ ์ข์ ํ๊ตญ์ด ๋จ์๋ก ๋ณํ."""
+ abs_val = abs(val)
+ sign = "-" if val < 0 else ""
+ if abs_val >= 1e12:
+ return f"{sign}{abs_val / 1e12:,.1f}์กฐ"
+ if abs_val >= 1e8:
+ return f"{sign}{abs_val / 1e8:,.0f}์ต"
+ if abs_val >= 1e4:
+ return f"{sign}{abs_val / 1e4:,.0f}๋ง"
+ if abs_val >= 1:
+ return f"{sign}{abs_val:,.0f}"
+ return "0"
+
+
+def _format_krw(val: float) -> str:
+ """๋ฐฑ๋ง์ ๋จ์ ์ซ์๋ฅผ ์ฝ๊ธฐ ์ข์ ํ๊ตญ์ด ๋จ์๋ก ๋ณํ."""
+ abs_val = abs(val)
+ sign = "-" if val < 0 else ""
+ if abs_val >= 1_000_000:
+ return f"{sign}{abs_val / 1_000_000:,.1f}์กฐ"
+ if abs_val >= 10_000:
+ return f"{sign}{abs_val / 10_000:,.0f}์ต"
+ if abs_val >= 1:
+ return f"{sign}{abs_val:,.0f}"
+ if abs_val > 0:
+ return f"{sign}{abs_val:.4f}"
+ return "0"
+
+
+def _format_usd(val: float) -> str:
+ """USD ์ซ์๋ฅผ ์ฝ๊ธฐ ์ข์ ์๋ฌธ ๋จ์๋ก ๋ณํ."""
+ abs_val = abs(val)
+ sign = "-" if val < 0 else ""
+ if abs_val >= 1e12:
+ return f"{sign}${abs_val / 1e12:,.1f}T"
+ if abs_val >= 1e9:
+ return f"{sign}${abs_val / 1e9:,.1f}B"
+ if abs_val >= 1e6:
+ return f"{sign}${abs_val / 1e6:,.0f}M"
+ if abs_val >= 1e3:
+ return f"{sign}${abs_val / 1e3:,.0f}K"
+ if abs_val >= 1:
+ return f"{sign}${abs_val:,.0f}"
+ return "$0"
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# ๊ณ์ ํํฐ
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def _filter_key_accounts(df: pl.DataFrame, module_name: str) -> pl.DataFrame:
+ """์ฌ๋ฌด์ ํ์์ ํต์ฌ ๊ณ์ ๋ง ํํฐ๋ง."""
+ if "๊ณ์ ๋ช
" not in df.columns or module_name not in _KEY_ACCOUNTS_MAP:
+ return df
+
+ key_set = _KEY_ACCOUNTS_MAP[module_name]
+ mask = pl.lit(False)
+ for keyword in key_set:
+ mask = mask | pl.col("๊ณ์ ๋ช
").str.contains(keyword)
+
+ filtered = df.filter(mask)
+ if filtered.height < 5:
+ return df
+ return filtered
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# ์
์ข
๋ช
์ถ์ถ
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def _get_sector(company: Any) -> str | None:
+ """Company์์ ์
์ข
๋ช
์ถ์ถ."""
+ try:
+ overview = getattr(company, "companyOverview", None)
+ if isinstance(overview, dict):
+ sector = overview.get("indutyName") or overview.get("sector")
+ if sector:
+ return sector
+
+ detail = getattr(company, "companyOverviewDetail", None)
+ if isinstance(detail, dict):
+ sector = detail.get("sector") or detail.get("indutyName")
+ if sector:
+ return sector
+ except _CONTEXT_ERRORS:
+ pass
+
+ return None
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# DataFrame โ ๋งํฌ๋ค์ด ๋ณํ
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def df_to_markdown(
+ df: pl.DataFrame,
+ max_rows: int = 30,
+ meta: ModuleMeta | None = None,
+ compact: bool = False,
+ market: str = "KR",
+) -> str:
+ """Polars DataFrame โ ๋ฉํ๋ฐ์ดํฐ ์ฃผ์ ํฌํจ Markdown ํ
์ด๋ธ.
+
+ Args:
+ compact: True๋ฉด ์ซ์๋ฅผ ์ต/์กฐ ๋จ์๋ก ๋ณํ (LLM ์ปจํ
์คํธ์ฉ).
+ market: "KR"์ด๋ฉด ํ๊ธ ๋ผ๋ฒจ, "US"๋ฉด ์๋ฌธ ๋ผ๋ฒจ.
+ """
+ if df is None or df.height == 0:
+ return "(๋ฐ์ดํฐ ์์)"
+
+ # account ์ปฌ๋ผ์ snakeId โ ํ๊ธ/์๋ฌธ ๋ผ๋ฒจ ์๋ ๋ณํ
+ if "account" in df.columns:
+ try:
+ from dartlab.core.finance.labels import get_account_labels
+
+ locale = "kr" if market == "KR" else "en"
+ _labels = get_account_labels(locale)
+ df = df.with_columns(pl.col("account").replace(_labels).alias("account"))
+ except ImportError:
+ pass
+
+ effective_max = meta.maxRows if meta else max_rows
+ if compact:
+ effective_max = min(effective_max, 20)
+
+ if "year" in df.columns:
+ df = df.sort("year", descending=True)
+
+ if df.height > effective_max:
+ display_df = df.head(effective_max)
+ truncated = True
+ else:
+ display_df = df
+ truncated = False
+
+ parts = []
+
+ is_krw = not meta or meta.unit in ("๋ฐฑ๋ง์", "")
+ if meta and meta.unit and meta.unit != "๋ฐฑ๋ง์":
+ parts.append(f"(๋จ์: {meta.unit})")
+ elif compact and is_krw:
+ parts.append("(๋จ์: ์ต/์กฐ์, ์๋ณธ ๋ฐฑ๋ง์)")
+
+ if not compact and meta and meta.columns:
+ col_map = {c.name: c for c in meta.columns}
+ described = []
+ for col in display_df.columns:
+ if col in col_map:
+ c = col_map[col]
+ desc = f"`{col}`: {c.description}"
+ if c.unit:
+ desc += f" ({c.unit})"
+ described.append(desc)
+ if described:
+ parts.append(" | ".join(described))
+
+ cols = display_df.columns
+ if not compact and meta and meta.columns:
+ col_map = {c.name: c for c in meta.columns}
+ header_cells = []
+ for col in cols:
+ if col in col_map:
+ header_cells.append(f"{col} ({col_map[col].description})")
+ else:
+ header_cells.append(col)
+ header = "| " + " | ".join(header_cells) + " |"
+ else:
+ header = "| " + " | ".join(cols) + " |"
+
+ sep = "| " + " | ".join(["---"] * len(cols)) + " |"
+
+ rows = []
+ for row in display_df.iter_rows():
+ cells = []
+ for i, val in enumerate(row):
+ if val is None:
+ cells.append("-")
+ elif isinstance(val, (int, float)):
+ col_name = cols[i]
+ if compact and is_krw and col_name.isdigit() and len(col_name) == 4:
+ cells.append(_format_krw(float(val)))
+ elif isinstance(val, float):
+ if abs(val) >= 1:
+ cells.append(f"{val:,.0f}")
+ else:
+ cells.append(f"{val:.4f}")
+ elif col_name == "year" or (isinstance(val, int) and 1900 <= val <= 2100):
+ cells.append(str(val))
+ else:
+ cells.append(f"{val:,}")
+ else:
+ cells.append(str(val))
+ rows.append("| " + " | ".join(cells) + " |")
+
+ parts.append("\n".join([header, sep] + rows))
+
+ if truncated:
+ parts.append(f"(์์ {effective_max}ํ ํ์, ์ ์ฒด {df.height}ํ)")
+
+ return "\n".join(parts)
+
+
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+# ํ์ ์งํ ์๋๊ณ์ฐ
+# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+
+def _find_account_value(df: pl.DataFrame, keyword: str, year_col: str) -> float | None:
+ """๊ณ์ ๋ช
์์ ํค์๋๋ฅผ ํฌํจํ๋ ํ์ ๊ฐ ์ถ์ถ."""
+ if "๊ณ์ ๋ช
" not in df.columns or year_col not in df.columns:
+ return None
+ matched = df.filter(pl.col("๊ณ์ ๋ช
").str.contains(keyword))
+ if matched.height == 0:
+ return None
+ val = matched.row(0, named=True).get(year_col)
+ return val if isinstance(val, (int, float)) else None
+
+
+def _compute_derived_metrics(name: str, df: pl.DataFrame, company: Any = None) -> str | None:
+ """ํต์ฌ ์ฌ๋ฌด์ ํ์์ YoY ์ฑ์ฅ๋ฅ /๋น์จ ์๋๊ณ์ฐ.
+
+ ๊ฐ์ : ROE, ์ด์๋ณด์๋ฐฐ์จ, FCF, EBITDA ๋ฑ ์ถ๊ฐ.
+ """
+ if name not in ("BS", "IS", "CF") or df is None or df.height == 0:
+ return None
+
+ year_cols = sorted(
+ [c for c in df.columns if c.isdigit() and len(c) == 4],
+ reverse=True,
+ )
+ if len(year_cols) < 2:
+ return None
+
+ lines = []
+
+ if name == "IS":
+ targets = {
+ "๋งค์ถ์ก": "๋งค์ถ ์ฑ์ฅ๋ฅ ",
+ "์์
์ด์ต": "์์
์ด์ต ์ฑ์ฅ๋ฅ ",
+ "๋น๊ธฐ์์ด์ต": "์์ด์ต ์ฑ์ฅ๋ฅ ",
+ }
+ for acct, label in targets.items():
+ metrics = []
+ for i in range(min(len(year_cols) - 1, 3)):
+ cur = _find_account_value(df, acct, year_cols[i])
+ prev = _find_account_value(df, acct, year_cols[i + 1])
+ if cur is not None and prev is not None and prev != 0:
+ yoy = (cur - prev) / abs(prev) * 100
+ metrics.append(f"{year_cols[i]}/{year_cols[i + 1]}: {yoy:+.1f}%")
+ if metrics:
+ lines.append(f"- {label}: {', '.join(metrics)}")
+
+ # ์์
์ด์ต๋ฅ , ์์ด์ต๋ฅ
+ latest = year_cols[0]
+ rev = _find_account_value(df, "๋งค์ถ์ก", latest)
+ oi = _find_account_value(df, "์์
์ด์ต", latest)
+ ni = _find_account_value(df, "๋น๊ธฐ์์ด์ต", latest)
+ if rev and rev != 0:
+ if oi is not None:
+ lines.append(f"- {latest} ์์
์ด์ต๋ฅ : {oi / rev * 100:.1f}%")
+ if ni is not None:
+ lines.append(f"- {latest} ์์ด์ต๋ฅ : {ni / rev * 100:.1f}%")
+
+ # ์ด์๋ณด์๋ฐฐ์จ (์์
์ด์ต / ์ด์๋น์ฉ)
+ interest = _find_account_value(df, "์ด์๋น์ฉ", latest)
+ if interest is None:
+ interest = _find_account_value(df, "๊ธ์ต๋น์ฉ", latest)
+ if oi is not None and interest is not None and interest != 0:
+ icr = oi / abs(interest)
+ lines.append(f"- {latest} ์ด์๋ณด์๋ฐฐ์จ: {icr:.1f}x")
+
+ # ROE (์์ด์ต / ์๋ณธ์ด๊ณ) โ BS๊ฐ ์์ ๋
+ if company and ni is not None:
+ try:
+ bs = getattr(company, "BS", None)
+ if isinstance(bs, pl.DataFrame) and latest in bs.columns:
+ equity = _find_account_value(bs, "์๋ณธ์ด๊ณ", latest)
+ if equity and equity != 0:
+ roe = ni / equity * 100
+ lines.append(f"- {latest} ROE: {roe:.1f}%")
+ total_asset = _find_account_value(bs, "์์ฐ์ด๊ณ", latest)
+ if total_asset and total_asset != 0:
+ roa = ni / total_asset * 100
+ lines.append(f"- {latest} ROA: {roa:.1f}%")
+ except _CONTEXT_ERRORS:
+ pass
+
+ elif name == "BS":
+ latest = year_cols[0]
+ debt = _find_account_value(df, "๋ถ์ฑ์ด๊ณ", latest)
+ equity = _find_account_value(df, "์๋ณธ์ด๊ณ", latest)
+ ca = _find_account_value(df, "์ ๋์์ฐ", latest)
+ cl = _find_account_value(df, "์ ๋๋ถ์ฑ", latest)
+ ta = _find_account_value(df, "์์ฐ์ด๊ณ", latest)
+
+ if debt is not None and equity is not None and equity != 0:
+ lines.append(f"- {latest} ๋ถ์ฑ๋น์จ: {debt / equity * 100:.1f}%")
+ if ca is not None and cl is not None and cl != 0:
+ lines.append(f"- {latest} ์ ๋๋น์จ: {ca / cl * 100:.1f}%")
+ if debt is not None and ta is not None and ta != 0:
+ lines.append(f"- {latest} ๋ถ์ฑ์ด๊ณ/์์ฐ์ด๊ณ: {debt / ta * 100:.1f}%")
+
+ # ์ด์์ฐ ์ฆ๊ฐ์จ
+ for i in range(min(len(year_cols) - 1, 2)):
+ cur = _find_account_value(df, "์์ฐ์ด๊ณ", year_cols[i])
+ prev = _find_account_value(df, "์์ฐ์ด๊ณ", year_cols[i + 1])
+ if cur is not None and prev is not None and prev != 0:
+ yoy = (cur - prev) / abs(prev) * 100
+ lines.append(f"- ์ด์์ฐ ์ฆ๊ฐ์จ {year_cols[i]}/{year_cols[i + 1]}: {yoy:+.1f}%")
+
+ elif name == "CF":
+ latest = year_cols[0]
+ op_cf = _find_account_value(df, "์์
ํ๋", latest)
+ inv_cf = _find_account_value(df, "ํฌ์ํ๋", latest)
+ fin_cf = _find_account_value(df, "์ฌ๋ฌดํ๋", latest)
+
+ if op_cf is not None and inv_cf is not None:
+ fcf = op_cf + inv_cf
+ lines.append(f"- {latest} FCF(์์
CF+ํฌ์CF): {_format_krw(fcf)}")
+
+ # CF ํจํด ํด์
+ if op_cf is not None and inv_cf is not None and fin_cf is not None:
+ pattern = f"{'+' if op_cf >= 0 else '-'}/{'+' if inv_cf >= 0 else '-'}/{'+' if fin_cf >= 0 else '-'}"
+ pattern_desc = _interpret_cf_pattern(op_cf >= 0, inv_cf >= 0, fin_cf >= 0)
+ lines.append(f"- {latest} CF ํจํด(์์
/ํฌ์/์ฌ๋ฌด): {pattern} โ {pattern_desc}")
+
+ for i in range(min(len(year_cols) - 1, 2)):
+ cur = _find_account_value(df, "์์
ํ๋", year_cols[i])
+ prev = _find_account_value(df, "์์
ํ๋", year_cols[i + 1])
+ if cur is not None and prev is not None and prev != 0:
+ yoy = (cur - prev) / abs(prev) * 100
+ lines.append(f"- ์์
ํ๋CF ๋ณ๋ {year_cols[i]}/{year_cols[i + 1]}: {yoy:+.1f}%")
+
+ if not lines:
+ return None
+
+ return "### ์ฃผ์ ์งํ (์๋๊ณ์ฐ)\n" + "\n".join(lines)
+
+
+def _interpret_cf_pattern(op_pos: bool, inv_pos: bool, fin_pos: bool) -> str:
+ """ํ๊ธํ๋ฆ ํจํด ํด์."""
+ if op_pos and not inv_pos and not fin_pos:
+ return "์ฐ๋ ๊ธฐ์
ํ (์์
์ด์ต์ผ๋ก ํฌ์+์ํ)"
+ if op_pos and not inv_pos and fin_pos:
+ return "์ฑ์ฅ ํฌ์ํ (์์
+์ฐจ์
์ผ๋ก ์ ๊ทน ํฌ์)"
+ if op_pos and inv_pos and not fin_pos:
+ return "๊ตฌ์กฐ์กฐ์ ํ (์์ฐ ๋งค๊ฐ+๋ถ์ฑ ์ํ)"
+ if not op_pos and not inv_pos and fin_pos:
+ return "์ํ ์ ํธ (์์
์ ์์ธ๋ฐ ์ฐจ์
์ผ๋ก ํฌ์)"
+ if not op_pos and inv_pos and fin_pos:
+ return "์๊ธฐ ๊ด๋ฆฌํ (์์ฐ ๋งค๊ฐ+์ฐจ์
์ผ๋ก ์์
๋ณด์ )"
+ if not op_pos and inv_pos and not fin_pos:
+ return "์ถ์ํ (์์ฐ ๋งค๊ฐ์ผ๋ก ๋ถ์ฑ ์ํ)"
+ return "๊ธฐํ ํจํด"
diff --git a/src/dartlab/ai/context/snapshot.py b/src/dartlab/ai/context/snapshot.py
new file mode 100644
index 0000000000000000000000000000000000000000..b05d64673f3e5ef1cb85d14833cc297fff18f40b
--- /dev/null
+++ b/src/dartlab/ai/context/snapshot.py
@@ -0,0 +1,198 @@
+"""ํต์ฌ ์์น ์ค๋
์ท ๋น๋ โ server ์์กด์ฑ ์๋ ์์ ๋ก์ง.
+
+server/chat.py์ build_snapshot()์์ ์ถ์ถ.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from dartlab.ai.context.company_adapter import get_headline_ratios
+
+
+def _fmt(val: float | int | None, suffix: str = "") -> str | None:
+ if val is None:
+ return None
+ abs_v = abs(val)
+ sign = "-" if val < 0 else ""
+ if abs_v >= 1e12:
+ return f"{sign}{abs_v / 1e12:,.1f}์กฐ{suffix}"
+ if abs_v >= 1e8:
+ return f"{sign}{abs_v / 1e8:,.0f}์ต{suffix}"
+ if abs_v >= 1e4:
+ return f"{sign}{abs_v / 1e4:,.0f}๋ง{suffix}"
+ if abs_v >= 1:
+ return f"{sign}{abs_v:,.0f}{suffix}"
+ return f"0{suffix}"
+
+
+def _pct(val: float | None) -> str | None:
+ return f"{val:.1f}%" if val is not None else None
+
+
+def _judge_pct(val: float | None, good: float, caution: float) -> str | None:
+ if val is None:
+ return None
+ if val >= good:
+ return "good"
+ if val >= caution:
+ return "caution"
+ return "danger"
+
+
+def _judge_pct_inv(val: float | None, good: float, caution: float) -> str | None:
+ if val is None:
+ return None
+ if val <= good:
+ return "good"
+ if val <= caution:
+ return "caution"
+ return "danger"
+
+
+def build_snapshot(company: Any, *, includeInsights: bool = True) -> dict | None:
+ """ratios + ํต์ฌ ์๊ณ์ด์์ ์ฆ์ ํ์ํ ์ค๋
์ท ๋ฐ์ดํฐ ์ถ์ถ."""
+ ratios = get_headline_ratios(company)
+ if ratios is None:
+ return None
+ if not hasattr(ratios, "revenueTTM"):
+ return None
+
+ isFinancial = False
+ sectorInfo = getattr(company, "sector", None)
+ if sectorInfo is not None:
+ try:
+ from dartlab.analysis.comparative.sector.types import Sector
+
+ isFinancial = sectorInfo.sector == Sector.FINANCIALS
+ except (ImportError, AttributeError):
+ isFinancial = False
+
+ items: list[dict[str, Any]] = []
+ roeGood, roeCaution = (8, 5) if isFinancial else (10, 5)
+ roaGood, roaCaution = (0.5, 0.2) if isFinancial else (5, 2)
+
+ if ratios.revenueTTM is not None:
+ items.append({"label": "๋งค์ถ(TTM)", "value": _fmt(ratios.revenueTTM), "status": None})
+ if ratios.operatingIncomeTTM is not None:
+ items.append(
+ {
+ "label": "์์
์ด์ต(TTM)",
+ "value": _fmt(ratios.operatingIncomeTTM),
+ "status": "good" if ratios.operatingIncomeTTM > 0 else "danger",
+ }
+ )
+ if ratios.netIncomeTTM is not None:
+ items.append(
+ {
+ "label": "์์ด์ต(TTM)",
+ "value": _fmt(ratios.netIncomeTTM),
+ "status": "good" if ratios.netIncomeTTM > 0 else "danger",
+ }
+ )
+ if ratios.operatingMargin is not None:
+ items.append(
+ {
+ "label": "์์
์ด์ต๋ฅ ",
+ "value": _pct(ratios.operatingMargin),
+ "status": _judge_pct(ratios.operatingMargin, 10, 5),
+ }
+ )
+ if ratios.roe is not None:
+ items.append({"label": "ROE", "value": _pct(ratios.roe), "status": _judge_pct(ratios.roe, roeGood, roeCaution)})
+ if ratios.roa is not None:
+ items.append({"label": "ROA", "value": _pct(ratios.roa), "status": _judge_pct(ratios.roa, roaGood, roaCaution)})
+ if ratios.debtRatio is not None:
+ items.append(
+ {
+ "label": "๋ถ์ฑ๋น์จ",
+ "value": _pct(ratios.debtRatio),
+ "status": _judge_pct_inv(ratios.debtRatio, 100, 200),
+ }
+ )
+ if ratios.currentRatio is not None:
+ items.append(
+ {
+ "label": "์ ๋๋น์จ",
+ "value": _pct(ratios.currentRatio),
+ "status": _judge_pct(ratios.currentRatio, 150, 100),
+ }
+ )
+ if ratios.fcf is not None:
+ items.append({"label": "FCF", "value": _fmt(ratios.fcf), "status": "good" if ratios.fcf > 0 else "danger"})
+ if ratios.revenueGrowth3Y is not None:
+ items.append(
+ {
+ "label": "๋งค์ถ 3Y CAGR",
+ "value": _pct(ratios.revenueGrowth3Y),
+ "status": _judge_pct(ratios.revenueGrowth3Y, 5, 0),
+ }
+ )
+ if ratios.roic is not None:
+ items.append(
+ {
+ "label": "ROIC",
+ "value": _pct(ratios.roic),
+ "status": _judge_pct(ratios.roic, 15, 8),
+ }
+ )
+ if ratios.interestCoverage is not None:
+ items.append(
+ {
+ "label": "์ด์๋ณด์๋ฐฐ์จ",
+ "value": f"{ratios.interestCoverage:.1f}x",
+ "status": _judge_pct(ratios.interestCoverage, 5, 1),
+ }
+ )
+ pf = getattr(ratios, "piotroskiFScore", None)
+ if pf is not None:
+ items.append(
+ {
+ "label": "Piotroski F",
+ "value": f"{pf}/9",
+ "status": "good" if pf >= 7 else ("caution" if pf >= 4 else "danger"),
+ }
+ )
+ az = getattr(ratios, "altmanZScore", None)
+ if az is not None:
+ items.append(
+ {
+ "label": "Altman Z",
+ "value": f"{az:.2f}",
+ "status": "good" if az > 2.99 else ("caution" if az >= 1.81 else "danger"),
+ }
+ )
+
+ annual = getattr(company, "annual", None)
+ trend = None
+ if annual is not None:
+ series, years = annual
+ if years and len(years) >= 2:
+ rev_list = series.get("IS", {}).get("sales")
+ if rev_list:
+ n = min(5, len(rev_list))
+ recent_years = years[-n:]
+ recent_vals = rev_list[-n:]
+ trend = {"years": recent_years, "values": list(recent_vals)}
+
+ if not items:
+ return None
+
+ snapshot: dict[str, Any] = {"items": items}
+ if trend:
+ snapshot["trend"] = trend
+ if ratios.warnings:
+ snapshot["warnings"] = ratios.warnings[:3]
+
+ if includeInsights:
+ try:
+ from dartlab.analysis.financial.insight.pipeline import analyze as insight_analyze
+
+ insight_result = insight_analyze(company.stockCode, company=company)
+ if insight_result is not None:
+ snapshot["grades"] = insight_result.grades()
+ snapshot["anomalyCount"] = len(insight_result.anomalies)
+ except (ImportError, AttributeError, FileNotFoundError, OSError, RuntimeError, TypeError, ValueError):
+ pass
+
+ return snapshot
diff --git a/src/dartlab/ai/conversation/__init__.py b/src/dartlab/ai/conversation/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..56a6f02838efb48cafcff773fe811bedf8fa6f2c
--- /dev/null
+++ b/src/dartlab/ai/conversation/__init__.py
@@ -0,0 +1 @@
+"""AI conversation package."""
diff --git a/src/dartlab/ai/conversation/data_ready.py b/src/dartlab/ai/conversation/data_ready.py
new file mode 100644
index 0000000000000000000000000000000000000000..a6462c227d34c7e518bb076aca580814cc1a0a0b
--- /dev/null
+++ b/src/dartlab/ai/conversation/data_ready.py
@@ -0,0 +1,71 @@
+"""AI ๋ถ์ ์ ๋ฐ์ดํฐ ์ค๋น ์ํ๋ฅผ ์์ฝํ๋ ํฌํผ."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+_DATA_CATEGORIES = ("docs", "finance", "report")
+
+
+def getDataReadyStatus(stockCode: str) -> dict[str, Any]:
+ """์ข
๋ชฉ์ docs/finance/report ๋ก์ปฌ ์ค๋น ์ํ๋ฅผ ๋ฐํํ๋ค."""
+ from dartlab.core.dataLoader import _dataDir
+
+ categories: dict[str, dict[str, Any]] = {}
+ available: list[str] = []
+ missing: list[str] = []
+
+ for category in _DATA_CATEGORIES:
+ filePath = _dataDir(category) / f"{stockCode}.parquet"
+ ready = filePath.exists()
+ updatedAt = None
+ if ready:
+ updatedAt = datetime.fromtimestamp(filePath.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
+ available.append(category)
+ else:
+ missing.append(category)
+
+ categories[category] = {
+ "ready": ready,
+ "updatedAt": updatedAt,
+ }
+
+ return {
+ "stockCode": stockCode,
+ "allReady": not missing,
+ "available": available,
+ "missing": missing,
+ "categories": categories,
+ }
+
+
+def formatDataReadyStatus(stockCode: str, *, detailed: bool = False) -> str:
+ """๋ฐ์ดํฐ ์ค๋น ์ํ๋ฅผ LLM/UI์ฉ ํ
์คํธ๋ก ๋ ๋๋งํ๋ค."""
+ status = getDataReadyStatus(stockCode)
+
+ if not detailed:
+ readyText = ", ".join(status["available"]) if status["available"] else "์์"
+ missingText = ", ".join(status["missing"]) if status["missing"] else "์์"
+ if status["allReady"]:
+ return "- ๋ฐ์ดํฐ ์ํ: docs, finance, report๊ฐ ๋ชจ๋ ์ค๋น๋์ด ์์ต๋๋ค."
+ return (
+ f"- ๋ฐ์ดํฐ ์ํ: ์ค๋น๋จ={readyText}; ๋๋ฝ={missingText}. "
+ "๋๋ฝ๋ ๋ฐ์ดํฐ๊ฐ ์์ผ๋ฉด ๋ต๋ณ ๋ฒ์๊ฐ ์ ํ๋ ์ ์์ต๋๋ค."
+ )
+
+ lines = [f"## {stockCode} ๋ฐ์ดํฐ ์ํ", ""]
+ for category in _DATA_CATEGORIES:
+ info = status["categories"][category]
+ if info["ready"]:
+ lines.append(f"- **{category}**: โ
์์ (์ต์ข
๊ฐฑ์ : {info['updatedAt']})")
+ else:
+ lines.append(f"- **{category}**: โ ์์")
+
+ if status["allReady"]:
+ lines.append("\n๋ชจ๋ ๋ฐ์ดํฐ๊ฐ ์ค๋น๋์ด ์์ต๋๋ค. ๋ฐ๋ก ๋ถ์์ ์งํํ ์ ์์ต๋๋ค.")
+ else:
+ lines.append(
+ "\n์ผ๋ถ ๋ฐ์ดํฐ๊ฐ ์์ต๋๋ค. `download_data` ๋๊ตฌ๋ก ๋ค์ด๋ก๋ํ๊ฑฐ๋, ์ฌ์ฉ์์๊ฒ ๋ค์ด๋ก๋ ์ฌ๋ถ๋ฅผ ๋ฌผ์ด๋ณด์ธ์."
+ )
+ return "\n".join(lines)
diff --git a/src/dartlab/ai/conversation/dialogue.py b/src/dartlab/ai/conversation/dialogue.py
new file mode 100644
index 0000000000000000000000000000000000000000..b2e7f65a033be90cf893efdd83290b83585da3f3
--- /dev/null
+++ b/src/dartlab/ai/conversation/dialogue.py
@@ -0,0 +1,476 @@
+"""๋ํ ์ํ/๋ชจ๋ ๋ถ๋ฅ โ server ์์กด์ฑ ์๋ ์์ ๋ก์ง.
+
+server/dialogue.py์์ ์ถ์ถ. ๊ฒฝ๋ ํ์
(types.py) ๊ธฐ๋ฐ.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Any
+
+from ..types import HistoryItem, ViewContextInfo
+from .intent import has_analysis_intent, is_meta_question
+
+_LEGACY_VIEWER_RE = re.compile(
+ r"\[์ฌ์ฉ์๊ฐ ํ์ฌ\s+(?P.+?)\((?P[A-Za-z0-9]+)\)\s+๊ณต์๋ฅผ ๋ณด๊ณ ์์ต๋๋ค"
+ r"(?:\s+โ\s+ํ์ฌ ์น์
:\s+(?P