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""" +
+

{c.corpName}

+
+
+ 종목코드 + {c.stockCode} +
+
+ 시장 + {c.market} +
+ {currencyHtml} +
+
+ """, 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""" +
+
+ DartLab +

DartLab

+

종목코드 하나. 기업의 전체 이야기.

+

DART / EDGAR 공시 데이터를 구조화하여 제공합니다

+
+""", 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