diff --git a/.streamlit/config.toml b/.streamlit/config.toml
new file mode 100644
index 0000000000000000000000000000000000000000..10097aabe303621b02f86a514561cf4390aefcbc
--- /dev/null
+++ b/.streamlit/config.toml
@@ -0,0 +1,7 @@
+[theme]
+base = "dark"
+primaryColor = "#ea4647"
+backgroundColor = "#050811"
+secondaryBackgroundColor = "#0f1219"
+textColor = "#f1f5f9"
+font = "sans serif"
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index fb657a3d2c730fb5fc9b3e6c77916b1e6a3db574..0000000000000000000000000000000000000000
--- a/Dockerfile
+++ /dev/null
@@ -1,38 +0,0 @@
-FROM python:3.12-slim
-
-WORKDIR /app
-
-RUN apt-get update && apt-get install -y --no-install-recommends \
- build-essential \
- libxml2-dev \
- libxslt1-dev \
- && rm -rf /var/lib/apt/lists/*
-
-# 핵심 의존성만 먼저 설치 (wheel 우선, 빌드 실패 방지)
-RUN pip install --no-cache-dir \
- polars \
- beautifulsoup4 lxml \
- httpx requests orjson \
- openpyxl rich plotly \
- prompt-toolkit \
- alive-progress \
- diff-match-patch \
- fastapi uvicorn[standard] sse-starlette msgpack
-
-COPY pyproject.toml ./
-COPY src/ src/
-RUN touch README.md
-
-# --no-deps: 위에서 이미 설치한 의존성 재설치 방지, marimo/mcp 건너뜀
-RUN pip install --no-cache-dir --no-deps -e .
-
-# HF Spaces user
-RUN useradd -m -u 1000 user
-USER user
-
-ENV SPACE_ID=1
-ENV HOME=/home/user
-
-EXPOSE 7860
-
-CMD ["python", "-m", "dartlab.server"]
diff --git a/README.md b/README.md
index 0eb3e1e14e4a775e1ad6788c97410ce3d83411a4..76f21d3b1d0758c1d4acb997d19474778ac8a064 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,9 @@ title: DartLab
emoji: 📊
colorFrom: red
colorTo: yellow
-sdk: docker
+sdk: streamlit
+sdk_version: "1.45.1"
+app_file: app.py
pinned: true
license: mit
short_description: DART + EDGAR disclosure analysis
diff --git a/app.py b/app.py
new file mode 100644
index 0000000000000000000000000000000000000000..be890b237f3ab7dcee7dca48626569c0c11a9b72
--- /dev/null
+++ b/app.py
@@ -0,0 +1,623 @@
+"""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
deleted file mode 100644
index aac2edab1f5685dbfdaafc2b14452e2394424873..0000000000000000000000000000000000000000
--- a/pyproject.toml
+++ /dev/null
@@ -1,241 +0,0 @@
-[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",
- "prompt-toolkit>=3.0,<4",
- "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
new file mode 100644
index 0000000000000000000000000000000000000000..e9e66e99b31fbbbba682987e32e4fc2a43a7aab7
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,4 @@
+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
deleted file mode 100644
index 0b155bba27538cd4f37927f35e268571628ef978..0000000000000000000000000000000000000000
--- a/src/dartlab/API_SPEC.md
+++ /dev/null
@@ -1,450 +0,0 @@
-# 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
deleted file mode 100644
index d11e1e048e7af4df57c7833b73d10e76d4ee843d..0000000000000000000000000000000000000000
--- a/src/dartlab/STATUS.md
+++ /dev/null
@@ -1,81 +0,0 @@
-# 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
deleted file mode 100644
index f38d0485a19eacff79437cbad88b027560bb3b8e..0000000000000000000000000000000000000000
--- a/src/dartlab/__init__.py
+++ /dev/null
@@ -1,1008 +0,0 @@
-"""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
deleted file mode 100644
index ef378213d44989674d71cb9e3ffe3bca30fc0deb..0000000000000000000000000000000000000000
--- a/src/dartlab/ai/DEV.md
+++ /dev/null
@@ -1,296 +0,0 @@
-# AI Engine Development Guide
-
-## 설계 사상
-
-### dartlab AI는 무엇인가
-
-dartlab의 핵심 자산은 데이터 엔진이다. 전자공시 원본을 정규화하여 **전기간 비교가능 + 기업간 비교가능**한 구조로 만든 것이 dartlab의 존재 이유다. AI는 이 데이터 위에서 동작하는 **소비자**이지, 데이터를 대체하지 않는다.
-
-**LLM은 해석자이지 분석가가 아니다.**
-- 계산은 엔진이 한다 (ratios, timeseries, insights, valuation)
-- 판단은 엔진이 한다 (anomaly detection, scoring, red flags)
-- LLM은 엔진 결과를 받아서 **"왜"를 설명하고, 인과 관계를 서술하고, 사용자 질문에 답한다**
-
-이것이 dexter와의 근본적 차이다:
-- dexter: 데이터 없음. LLM이 외부 API를 호출해서 데이터를 수집하고 분석. LLM이 전부.
-- dartlab: 데이터 엔진이 전부. LLM은 정규화된 데이터를 읽고 해석하는 마지막 계층.
-
-### 2-Tier 아키텍처
-
-- **Tier 1 (시스템 주도)**: 질문 분류 → 엔진 계산 → 결과를 컨텍스트로 조립 → LLM에 한 번 전달. 모든 provider에서 동작. tool calling 불필요.
-- **Tier 2 (LLM 주도)**: Tier 1 결과를 보고 LLM이 "부족하다" 판단 → 도구 호출로 추가 탐색. tool calling 가능한 provider에서만 동작.
-
-Tier 1이 충분하면 LLM roundtrip은 1회다. 이것이 속도의 핵심이다.
-
-### 속도 원칙
-
-**LLM roundtrip을 줄이는 것이 속도다.**
-- 더 많은 데이터를 미리 조립해서 1회에 끝내는 것이 빠르다 (Tier 1 강화)
-- 도구 호출을 병렬화하는 것보다, 애초에 호출이 필요 없게 만드는 것이 빠르다
-- changes(공시 변화분 23%)를 컨텍스트에 미리 넣으면 "뭐가 바뀌었지?" 탐색 호출이 사라진다
-
-### dexter에서 흡수한 것
-
-| 패턴 | dexter 원본 | dartlab 적용 |
-|------|------------|-------------|
-| Scratchpad | 도구 결과 누적/토큰 관리 | `runtime/scratchpad.py` — 중복 호출 방지, 토큰 예산 |
-| SOUL.md | 분석 철학 주입 | `templates/analysisPhilosophy.py` — Palepu-Healy + CFA 사고 프레임 |
-| stripFieldsDeep | 도구 결과 필드 제거 | `context/pruning.py` — XBRL 메타데이터 재귀 제거 |
-| SKILL.md | 워크플로우 가이드 | `skills/catalog.py` — 8개 분석 스킬 (도구 비의존) |
-| 자율 에이전트 | 충분할 때까지 탐색 | `agentLoopAutonomous()` — report_mode Tier 2 |
-| 세션 메모리 | SQLite + 시간 감쇠 | `memory/store.py` — 분석 기록 영속 |
-
-### 흡수하지 않은 것
-
-- **데이터 소유 구조**: dexter는 외부 API로 데이터 수집. dartlab은 이미 데이터 엔진을 소유.
-- **단일 모델 의존**: dexter는 모든 판단을 LLM에 위임. dartlab은 엔진이 계산/판단하고 LLM은 해석만.
-- **meta-tool 패턴**: 도구 안에 도구를 넣는 구조. dartlab은 Super Tool 7개로 이미 해결.
-
-### 사용자 원칙
-
-- **접근성**: 종목코드 하나면 끝. `dartlab ask "005930" "영업이익률 추세는?"` 또는 `dartlab chat`으로 인터랙티브.
-- **신뢰성**: 숫자는 엔진이 계산한 원본. LLM이 숫자를 만들어내면 검증 레이어가 잡는다.
-- **투명성**: 어떤 데이터를 봤는지(includedEvidence), 어떤 도구를 썼는지(tool_call) 항상 노출.
-
-### 품질 검증 기준선 (2026-03-27)
-
-ollama qwen3:4b 기준 critical+high 35건 배치 결과:
-
-| 지표 | 값 | 비고 |
-|------|-----|------|
-| avgOverall | 7.33 | gemini fallback 수정 후 재측정 (수정 전 5.98) |
-| routeMatch | 1.00 | intent 분류 + 라우팅 완벽 |
-| moduleUtilization | 0.75 | 일부 eval 케이스 정합성 문제 포함 |
-| falseUnavailable | 0/35 | "데이터 없다" 거짓 응답 없음 |
-
-production 모델(openai/gemini) 측정은 API 키 확보 후 진행 예정. factual accuracy는 production 모델에서만 유의미.
-
-주요 failure taxonomy:
-- **runtime_error**: provider 설정 정합성 (해결됨)
-- **retrieval_failure**: eval 케이스 expectedModules와 실제 컨텍스트 빌더 매핑 간극
-- **generation_failure**: 소형 모델 한계 (production 모델에서 재측정 필요)
-
----
-
-## 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
deleted file mode 100644
index b0d7785f851cb9f3778de3b8f328ed1e53963e6a..0000000000000000000000000000000000000000
--- a/src/dartlab/ai/STATUS.md
+++ /dev/null
@@ -1,200 +0,0 @@
-# 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
deleted file mode 100644
index d1c099e2c02b5bcc7ef15cba8ab3b0b506fe6485..0000000000000000000000000000000000000000
--- a/src/dartlab/ai/__init__.py
+++ /dev/null
@@ -1,119 +0,0 @@
-"""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
deleted file mode 100644
index 3b86faf41dc8683ac5e2fc5b309f74142c37e704..0000000000000000000000000000000000000000
--- a/src/dartlab/ai/agent.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""호환 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
deleted file mode 100644
index 41afc7400d3e5f97c711d33b0ba859faee63ca1a..0000000000000000000000000000000000000000
--- a/src/dartlab/ai/aiParser.py
+++ /dev/null
@@ -1,500 +0,0 @@
-"""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
deleted file mode 100644
index 0beaca641776ece7592e6173862356f0fcc22d54..0000000000000000000000000000000000000000
--- a/src/dartlab/ai/context/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-"""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
deleted file mode 100644
index 19b6a9e54b9e43af82c029b88c1c6d57e6f3c5e5..0000000000000000000000000000000000000000
--- a/src/dartlab/ai/context/builder.py
+++ /dev/null
@@ -1,2022 +0,0 @@
-"""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({"비용 구조", "원가 구조", "비용", "원가", "판관비", "매출원가"})
-_BUSINESS_CHANGE_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, _BUSINESS_CHANGE_HINTS)
- )
-
-
-def _has_recent_disclosure_business_pattern(question: str) -> bool:
- lowered = question.lower()
- return "최근 공시" in lowered and _question_has_any(question, _BUSINESS_CHANGE_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 _build_changes_context(company: Any, *, compact: bool = True) -> str:
- """sections 변화 요약을 LLM 컨텍스트용 마크다운으로 변환.
-
- 전체 sections(97MB) 대신 변화분(23%)만 요약하여 제공.
- LLM이 추가 도구 호출 없이 "무엇이 바뀌었는지" 즉시 파악 가능.
- """
- docs = getattr(company, "docs", None)
- sections = getattr(docs, "sections", None)
- if sections is None or not hasattr(sections, "changeSummary"):
- return ""
-
- try:
- summary = sections.changeSummary(topN=8 if compact else 15)
- except (AttributeError, TypeError, ValueError, pl.exceptions.PolarsError):
- return ""
-
- if summary is None or summary.is_empty():
- return ""
-
- lines = ["\n## 공시 변화 요약"]
- lines.append("| topic | 변화유형 | 건수 | 평균크기변화 |")
- lines.append("|-------|---------|------|------------|")
- for row in summary.iter_rows(named=True):
- topic = row.get("topic", "")
- changeType = row.get("changeType", "")
- count = row.get("count", 0)
- avgDelta = row.get("avgDelta", 0)
- sign = "+" if avgDelta and avgDelta > 0 else ""
- lines.append(f"| {topic} | {changeType} | {count} | {sign}{avgDelta} |")
-
- # 최근 기간 주요 변화 미리보기
- try:
- changes = sections.changes()
- except (AttributeError, TypeError, ValueError, pl.exceptions.PolarsError):
- changes = None
-
- if changes is not None and not changes.is_empty():
- # 가장 최근 기간 전환에서 structural/appeared 변화만 발췌
- latestPeriod = changes.get_column("toPeriod").max()
- recent = changes.filter(
- (pl.col("toPeriod") == latestPeriod) & pl.col("changeType").is_in(["structural", "appeared"])
- )
- if not recent.is_empty():
- lines.append(f"\n### 최근 주요 변화 ({latestPeriod})")
- for row in recent.head(5 if compact else 10).iter_rows(named=True):
- topic = row.get("topic", "")
- ct = row.get("changeType", "")
- preview = row.get("preview", "")
- if preview:
- preview = preview[:120] + "..." if len(preview) > 120 else preview
- lines.append(f"- **{topic}** [{ct}]: {preview}")
-
- return "\n".join(lines)
-
-
-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)
-
- # 변화 컨텍스트 — sections 변화분만 LLM에 전달 (roundtrip 감소)
- if route in {"sections", "hybrid"}:
- changes_context = _build_changes_context(company, compact=compact)
- if changes_context:
- modules_dict["_changes"] = changes_context
- if "_changes" not in included:
- included.append("_changes")
-
- 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
deleted file mode 100644
index fae2a11d881fe14d4819435aa4279c711eda6dd2..0000000000000000000000000000000000000000
--- a/src/dartlab/ai/context/company_adapter.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""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
deleted file mode 100644
index 3d8dd4b137c565f3e9cd837826d871d060ead6f7..0000000000000000000000000000000000000000
--- a/src/dartlab/ai/context/dartOpenapi.py
+++ /dev/null
@@ -1,485 +0,0 @@
-"""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
deleted file mode 100644
index 2a3b596c3d5d13d2c28c4f07f2a02b1327362f37..0000000000000000000000000000000000000000
--- a/src/dartlab/ai/context/finance_context.py
+++ /dev/null
@@ -1,945 +0,0 @@
-"""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
deleted file mode 100644
index d0a63412d4aebd700d70ad37b760351ba87f99e8..0000000000000000000000000000000000000000
--- a/src/dartlab/ai/context/formatting.py
+++ /dev/null
@@ -1,439 +0,0 @@
-"""포맷팅·유틸리티 함수 — 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/pruning.py b/src/dartlab/ai/context/pruning.py
deleted file mode 100644
index 0f03600331c01149ce49521f0022d1764149e6b3..0000000000000000000000000000000000000000
--- a/src/dartlab/ai/context/pruning.py
+++ /dev/null
@@ -1,95 +0,0 @@
-"""도구 결과 필드 pruning — LLM에 불필요한 컬럼/필드 재귀 제거.
-
-dexter의 stripFieldsDeep 패턴을 Python에 적용.
-토큰 절약 + 분석 관련성 향상.
-"""
-
-from __future__ import annotations
-
-import json
-from typing import Any
-
-# LLM 분석에 불필요한 필드 — 재귀적으로 제거
-_STRIP_FIELDS: frozenset[str] = frozenset(
- {
- # XBRL 메타데이터
- "concept_id",
- "xbrl_context_id",
- "instant",
- "member",
- "dimension",
- "label_ko_raw",
- # 공시 메타데이터
- "acceptance_number",
- "rcept_no",
- "filing_date",
- "report_code",
- "reprt_code",
- "corp_cls",
- "corp_code",
- # 기술적 식별자
- "sj_div",
- "ord",
- "data_rank",
- "source_file",
- "source_path",
- "sourceBlockOrder",
- # 중복/내부용
- "account_id_raw",
- "account_nm_raw",
- "currency",
- }
-)
-
-# 모듈별 추가 제거 필드
-_MODULE_STRIP: dict[str, frozenset[str]] = {
- "finance": frozenset({"bsns_year", "sj_nm", "stock_code", "fs_div", "fs_nm"}),
- "explore": frozenset({"blockHash", "rawHtml", "charCount"}),
- "report": frozenset({"rcept_no", "corp_code", "corp_cls"}),
-}
-
-
-def pruneToolResult(toolName: str, result: str, *, maxChars: int = 8000) -> str:
- """도구 결과 문자열에서 불필요 필드를 제거."""
- if not result or len(result) < 100:
- return result
-
- # JSON 파싱 시도
- try:
- data = json.loads(result)
- except (json.JSONDecodeError, ValueError):
- # JSON이 아니면 그대로 반환 (마크다운 테이블 등)
- return result[:maxChars] if len(result) > maxChars else result
-
- # 모듈별 추가 필드 결정
- category = _resolveCategory(toolName)
- extra = _MODULE_STRIP.get(category, frozenset())
- stripFields = _STRIP_FIELDS | extra
-
- pruned = _pruneValue(data, stripFields, depth=0)
- text = json.dumps(pruned, ensure_ascii=False, indent=2, default=str)
- if len(text) > maxChars:
- return text[:maxChars] + "\n... (pruned+truncated)"
- return text
-
-
-def _pruneValue(value: Any, stripFields: frozenset[str], depth: int) -> Any:
- """재귀적 필드 제거."""
- if depth > 8:
- return value
- if isinstance(value, dict):
- return {k: _pruneValue(v, stripFields, depth + 1) for k, v in value.items() if k not in stripFields}
- if isinstance(value, list):
- return [_pruneValue(item, stripFields, depth + 1) for item in value]
- return value
-
-
-def _resolveCategory(toolName: str) -> str:
- """도구 이름에서 카테고리 추출."""
- if toolName in ("finance", "get_data", "compute_ratios"):
- return "finance"
- if toolName in ("explore", "show", "search_data"):
- return "explore"
- if toolName in ("report", "get_report"):
- return "report"
- return ""
diff --git a/src/dartlab/ai/context/snapshot.py b/src/dartlab/ai/context/snapshot.py
deleted file mode 100644
index b05d64673f3e5ef1cb85d14833cc297fff18f40b..0000000000000000000000000000000000000000
--- a/src/dartlab/ai/context/snapshot.py
+++ /dev/null
@@ -1,198 +0,0 @@
-"""핵심 수치 스냅샷 빌드 — 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
deleted file mode 100644
index 56a6f02838efb48cafcff773fe811bedf8fa6f2c..0000000000000000000000000000000000000000
--- a/src/dartlab/ai/conversation/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""AI conversation package."""
diff --git a/src/dartlab/ai/conversation/data_ready.py b/src/dartlab/ai/conversation/data_ready.py
deleted file mode 100644
index a6462c227d34c7e518bb076aca580814cc1a0a0b..0000000000000000000000000000000000000000
--- a/src/dartlab/ai/conversation/data_ready.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""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
deleted file mode 100644
index b2e7f65a033be90cf893efdd83290b83585da3f3..0000000000000000000000000000000000000000
--- a/src/dartlab/ai/conversation/dialogue.py
+++ /dev/null
@@ -1,476 +0,0 @@
-"""대화 상태/모드 분류 — 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