Spaces:
Running
Running
deploy: dartlab API + MCP server
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +6 -0
- Dockerfile +24 -0
- README.md +33 -6
- pyproject.toml +228 -0
- src/dartlab/STATUS.md +81 -0
- src/dartlab/__init__.py +1032 -0
- src/dartlab/__main__.py +5 -0
- src/dartlab/__pycache__/__init__.cpython-312.pyc +0 -0
- src/dartlab/__pycache__/__init__.cpython-313.pyc +0 -0
- src/dartlab/__pycache__/__main__.cpython-312.pyc +0 -0
- src/dartlab/__pycache__/company.cpython-312.pyc +0 -0
- src/dartlab/__pycache__/company.cpython-313.pyc +0 -0
- src/dartlab/__pycache__/config.cpython-312.pyc +0 -0
- src/dartlab/__pycache__/config.cpython-313.pyc +0 -0
- src/dartlab/__pycache__/listing.cpython-312.pyc +0 -0
- src/dartlab/__pycache__/listing.cpython-313.pyc +0 -0
- src/dartlab/__pycache__/topdown.cpython-312.pyc +0 -0
- src/dartlab/__pycache__/topdown.cpython-313.pyc +0 -0
- src/dartlab/ai/STATUS.md +200 -0
- src/dartlab/ai/__init__.py +161 -0
- src/dartlab/ai/__pycache__/__init__.cpython-312.pyc +0 -0
- src/dartlab/ai/__pycache__/__init__.cpython-313.pyc +0 -0
- src/dartlab/ai/__pycache__/types.cpython-312.pyc +0 -0
- src/dartlab/ai/__pycache__/types.cpython-313.pyc +0 -0
- src/dartlab/ai/context/__init__.py +38 -0
- src/dartlab/ai/context/__pycache__/__init__.cpython-312.pyc +0 -0
- src/dartlab/ai/context/__pycache__/__init__.cpython-313.pyc +0 -0
- src/dartlab/ai/context/__pycache__/aiview.cpython-312.pyc +0 -0
- src/dartlab/ai/context/__pycache__/budget.cpython-312.pyc +0 -0
- src/dartlab/ai/context/__pycache__/budget.cpython-313.pyc +0 -0
- src/dartlab/ai/context/__pycache__/builder.cpython-312.pyc +0 -0
- src/dartlab/ai/context/__pycache__/builder.cpython-313.pyc +0 -0
- src/dartlab/ai/context/__pycache__/bundle.cpython-312.pyc +0 -0
- src/dartlab/ai/context/__pycache__/bundle.cpython-313.pyc +0 -0
- src/dartlab/ai/context/__pycache__/encoder.cpython-312.pyc +0 -0
- src/dartlab/ai/context/__pycache__/encoder.cpython-313.pyc +0 -0
- src/dartlab/ai/context/__pycache__/intent.cpython-312.pyc +0 -0
- src/dartlab/ai/context/__pycache__/intent.cpython-313.pyc +0 -0
- src/dartlab/ai/context/__pycache__/playbook.cpython-312.pyc +0 -0
- src/dartlab/ai/context/__pycache__/playbook.cpython-313.pyc +0 -0
- src/dartlab/ai/context/aiview.py +360 -0
- src/dartlab/ai/context/budget.py +68 -0
- src/dartlab/ai/context/builder.py +195 -0
- src/dartlab/ai/context/bundle.py +66 -0
- src/dartlab/ai/context/encoder.py +115 -0
- src/dartlab/ai/context/intent.py +236 -0
- src/dartlab/ai/context/playbook.py +220 -0
- src/dartlab/ai/context/selectors/__init__.py +32 -0
- src/dartlab/ai/context/selectors/__pycache__/__init__.cpython-312.pyc +0 -0
- src/dartlab/ai/context/selectors/__pycache__/__init__.cpython-313.pyc +0 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
src/dartlab/analysis/financial/research/__pycache__/narrative.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
src/dartlab/providers/dart/__pycache__/company.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
src/dartlab/providers/dart/__pycache__/company.cpython-313.pyc filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
src/dartlab/providers/edgar/__pycache__/company.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
src/dartlab/providers/edgar/__pycache__/company.cpython-313.pyc filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
src/dartlab/review/__pycache__/builders.cpython-312.pyc filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# uv 설치
|
| 6 |
+
RUN pip install --no-cache-dir uv
|
| 7 |
+
|
| 8 |
+
# 의존성 먼저 (캐시 레이어)
|
| 9 |
+
COPY pyproject.toml uv.lock ./
|
| 10 |
+
RUN uv pip install --system .
|
| 11 |
+
|
| 12 |
+
# 소스 복사
|
| 13 |
+
COPY src/ src/
|
| 14 |
+
|
| 15 |
+
# HF Spaces 환경변수
|
| 16 |
+
ENV SPACE_ID=eddmpython/dartlab
|
| 17 |
+
ENV DARTLAB_MCP_HTTP=1
|
| 18 |
+
ENV DARTLAB_CORS_ORIGINS=*
|
| 19 |
+
ENV DARTLAB_HOST=0.0.0.0
|
| 20 |
+
ENV DARTLAB_PORT=7860
|
| 21 |
+
|
| 22 |
+
EXPOSE 7860
|
| 23 |
+
|
| 24 |
+
CMD ["python", "-m", "dartlab.server"]
|
README.md
CHANGED
|
@@ -1,10 +1,37 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: dartlab
|
| 3 |
+
emoji: 📊
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# dartlab — 한국 전자공시 분석 API + MCP 서버
|
| 11 |
+
|
| 12 |
+
설치 없이 사용:
|
| 13 |
+
- **REST API**: `https://eddmpython-dartlab.hf.space/api/*`
|
| 14 |
+
- **MCP (Claude Desktop)**: `https://eddmpython-dartlab.hf.space/mcp/sse`
|
| 15 |
+
|
| 16 |
+
## MCP 설정
|
| 17 |
+
|
| 18 |
+
`claude_desktop_config.json`:
|
| 19 |
+
```json
|
| 20 |
+
{
|
| 21 |
+
"mcpServers": {
|
| 22 |
+
"dartlab": {
|
| 23 |
+
"url": "https://eddmpython-dartlab.hf.space/mcp/sse"
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
## API 예시
|
| 30 |
+
|
| 31 |
+
```bash
|
| 32 |
+
# 공시 목록
|
| 33 |
+
curl "https://eddmpython-dartlab.hf.space/api/dart/filings?corp=005930"
|
| 34 |
+
|
| 35 |
+
# 재무제표
|
| 36 |
+
curl "https://eddmpython-dartlab.hf.space/api/dart/finance/005930?year=2024"
|
| 37 |
+
```
|
pyproject.toml
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "dartlab"
|
| 3 |
+
version = "0.9.6"
|
| 4 |
+
description = "DART 전자공시 + EDGAR 공시를 하나의 회사 맵으로 — Python 재무 분석 라이브러리"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
license = {file = "LICENSE"}
|
| 7 |
+
requires-python = ">=3.12"
|
| 8 |
+
authors = [
|
| 9 |
+
{name = "eddmpython"}
|
| 10 |
+
]
|
| 11 |
+
keywords = [
|
| 12 |
+
"dart",
|
| 13 |
+
"edgar",
|
| 14 |
+
"sec",
|
| 15 |
+
"financial-statements",
|
| 16 |
+
"korea",
|
| 17 |
+
"disclosure",
|
| 18 |
+
"accounting",
|
| 19 |
+
"polars",
|
| 20 |
+
"sections",
|
| 21 |
+
"mcp",
|
| 22 |
+
"ai-analysis",
|
| 23 |
+
"annual-report",
|
| 24 |
+
"10-k",
|
| 25 |
+
"xbrl",
|
| 26 |
+
"전자공시",
|
| 27 |
+
"재무제표",
|
| 28 |
+
"사업보고서",
|
| 29 |
+
"공시분석",
|
| 30 |
+
"다트",
|
| 31 |
+
]
|
| 32 |
+
classifiers = [
|
| 33 |
+
"Development Status :: 4 - Beta",
|
| 34 |
+
"Intended Audience :: Developers",
|
| 35 |
+
"Intended Audience :: Science/Research",
|
| 36 |
+
"Intended Audience :: Financial and Insurance Industry",
|
| 37 |
+
"Intended Audience :: End Users/Desktop",
|
| 38 |
+
"License :: OSI Approved :: MIT License",
|
| 39 |
+
"Operating System :: OS Independent",
|
| 40 |
+
"Programming Language :: Python :: 3",
|
| 41 |
+
"Programming Language :: Python :: 3.12",
|
| 42 |
+
"Programming Language :: Python :: 3.13",
|
| 43 |
+
"Topic :: Office/Business :: Financial",
|
| 44 |
+
"Topic :: Office/Business :: Financial :: Accounting",
|
| 45 |
+
"Topic :: Office/Business :: Financial :: Investment",
|
| 46 |
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
| 47 |
+
"Natural Language :: Korean",
|
| 48 |
+
"Natural Language :: English",
|
| 49 |
+
"Typing :: Typed",
|
| 50 |
+
]
|
| 51 |
+
dependencies = [
|
| 52 |
+
# core
|
| 53 |
+
"beautifulsoup4>=4.14.3,<5",
|
| 54 |
+
"lxml>=6.0.2,<7",
|
| 55 |
+
"httpx>=0.28.1,<1",
|
| 56 |
+
"polars>=1.0.0,<2",
|
| 57 |
+
"rich>=14.3.3,<15",
|
| 58 |
+
"huggingface-hub>=0.20.0,<1",
|
| 59 |
+
"openpyxl>=3.1.5,<4",
|
| 60 |
+
"diff-match-patch>=20230430",
|
| 61 |
+
"numpy>=1.26.0,<3",
|
| 62 |
+
"marimo>=0.22.0",
|
| 63 |
+
# ai providers
|
| 64 |
+
"openai>=1.0.0,<3",
|
| 65 |
+
"google-genai>=1.0.0,<2",
|
| 66 |
+
"anthropic>=0.30.0,<2",
|
| 67 |
+
# server (dartlab ai)
|
| 68 |
+
"fastapi>=0.135.1,<1",
|
| 69 |
+
"uvicorn[standard]>=0.30.0,<1",
|
| 70 |
+
"sse-starlette>=2.0.0,<3",
|
| 71 |
+
"mcp[cli]>=1.0",
|
| 72 |
+
"qrcode>=7.0,<9",
|
| 73 |
+
# viz
|
| 74 |
+
"plotly>=5.0.0,<6",
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
[project.scripts]
|
| 78 |
+
dartlab = "dartlab.cli.main:main"
|
| 79 |
+
|
| 80 |
+
[project.entry-points."dartlab.plugins"]
|
| 81 |
+
|
| 82 |
+
[project.urls]
|
| 83 |
+
Homepage = "https://eddmpython.github.io/dartlab/"
|
| 84 |
+
Repository = "https://github.com/eddmpython/dartlab"
|
| 85 |
+
Documentation = "https://eddmpython.github.io/dartlab/docs/"
|
| 86 |
+
Issues = "https://github.com/eddmpython/dartlab/issues"
|
| 87 |
+
Changelog = "https://eddmpython.github.io/dartlab/docs/changelog"
|
| 88 |
+
Demo = "https://huggingface.co/spaces/eddmpython/dartlab"
|
| 89 |
+
|
| 90 |
+
[build-system]
|
| 91 |
+
requires = ["hatchling"]
|
| 92 |
+
build-backend = "hatchling.build"
|
| 93 |
+
|
| 94 |
+
[tool.hatch.build.targets.wheel]
|
| 95 |
+
packages = ["src/dartlab"]
|
| 96 |
+
artifacts = [
|
| 97 |
+
"src/dartlab/ui/build/**",
|
| 98 |
+
]
|
| 99 |
+
exclude = [
|
| 100 |
+
"**/_reference/**",
|
| 101 |
+
"src/dartlab/engines/edinet/**",
|
| 102 |
+
"src/dartlab/engines/esg/**",
|
| 103 |
+
"src/dartlab/engines/event/**",
|
| 104 |
+
"src/dartlab/engines/supply/**",
|
| 105 |
+
"src/dartlab/engines/watch/**",
|
| 106 |
+
]
|
| 107 |
+
|
| 108 |
+
[tool.hatch.build.targets.sdist]
|
| 109 |
+
include = [
|
| 110 |
+
"src/dartlab/**/*.py",
|
| 111 |
+
"src/dartlab/**/*.json",
|
| 112 |
+
"src/dartlab/**/*.parquet",
|
| 113 |
+
"src/dartlab/ui/build/**",
|
| 114 |
+
"README.md",
|
| 115 |
+
"LICENSE",
|
| 116 |
+
]
|
| 117 |
+
exclude = [
|
| 118 |
+
"**/_reference/**",
|
| 119 |
+
"src/dartlab/engines/edinet/**",
|
| 120 |
+
"src/dartlab/engines/esg/**",
|
| 121 |
+
"src/dartlab/engines/event/**",
|
| 122 |
+
"src/dartlab/engines/supply/**",
|
| 123 |
+
"src/dartlab/engines/watch/**",
|
| 124 |
+
]
|
| 125 |
+
|
| 126 |
+
[tool.ruff]
|
| 127 |
+
target-version = "py312"
|
| 128 |
+
line-length = 120
|
| 129 |
+
exclude = ["experiments", "*/_reference"]
|
| 130 |
+
|
| 131 |
+
[tool.ruff.lint]
|
| 132 |
+
select = ["E", "F", "I"]
|
| 133 |
+
ignore = ["E402", "E501", "E741", "F841"]
|
| 134 |
+
|
| 135 |
+
[tool.pytest.ini_options]
|
| 136 |
+
testpaths = ["tests"]
|
| 137 |
+
python_files = ["test_*.py", "bench_*.py"]
|
| 138 |
+
addopts = "-v --tb=short"
|
| 139 |
+
asyncio_mode = "auto"
|
| 140 |
+
markers = [
|
| 141 |
+
"requires_data: 로컬 parquet 데이터 필요 (CI에서 skip)",
|
| 142 |
+
"unit: 순수 로직/mock만 — 데이터 로드 없음, 병렬 안전",
|
| 143 |
+
"integration: Company 1개 로딩 필요 — 중간 무게",
|
| 144 |
+
"heavy: 대량 데이터 로드 — 단독 실행 필수",
|
| 145 |
+
]
|
| 146 |
+
|
| 147 |
+
[tool.coverage.run]
|
| 148 |
+
source = ["dartlab"]
|
| 149 |
+
omit = [
|
| 150 |
+
"src/dartlab/engines/ai/providers/*",
|
| 151 |
+
"src/dartlab/review/*",
|
| 152 |
+
]
|
| 153 |
+
|
| 154 |
+
[tool.coverage.report]
|
| 155 |
+
show_missing = true
|
| 156 |
+
skip_empty = true
|
| 157 |
+
exclude_lines = [
|
| 158 |
+
"pragma: no cover",
|
| 159 |
+
"if __name__",
|
| 160 |
+
"raise NotImplementedError",
|
| 161 |
+
]
|
| 162 |
+
fail_under = 30
|
| 163 |
+
|
| 164 |
+
[tool.pyright]
|
| 165 |
+
pythonVersion = "3.12"
|
| 166 |
+
typeCheckingMode = "basic"
|
| 167 |
+
include = ["src/dartlab"]
|
| 168 |
+
exclude = [
|
| 169 |
+
"src/dartlab/engines/ai/providers/**",
|
| 170 |
+
"ui/**",
|
| 171 |
+
"experiments/**",
|
| 172 |
+
]
|
| 173 |
+
reportMissingTypeStubs = false
|
| 174 |
+
reportUnknownParameterType = false
|
| 175 |
+
reportUnknownMemberType = false
|
| 176 |
+
reportUnknownVariableType = false
|
| 177 |
+
|
| 178 |
+
[tool.bandit]
|
| 179 |
+
exclude_dirs = ["experiments", "tests"]
|
| 180 |
+
skips = ["B101"]
|
| 181 |
+
|
| 182 |
+
[tool.deptry]
|
| 183 |
+
# 옵셔널 통합 의존성 — 사용자가 별도 설치할 때만 동작 (런타임 ImportError 가드 있음)
|
| 184 |
+
extend_exclude = [
|
| 185 |
+
"src/dartlab/.*/_reference/.*", # 학습/실험 코드, 런타임 미사용
|
| 186 |
+
]
|
| 187 |
+
[tool.deptry.per_rule_ignores]
|
| 188 |
+
DEP001 = [
|
| 189 |
+
# ── channel 어댑터 (외부 메신저 옵셔널) ──
|
| 190 |
+
"discord", "slack_bolt", "telegram",
|
| 191 |
+
# ── CLI 인터랙티브 옵셔널 ──
|
| 192 |
+
"prompt_toolkit",
|
| 193 |
+
# ── display 옵셔널 ──
|
| 194 |
+
"great_tables", "itables", "IPython",
|
| 195 |
+
# ── gather 옵셔널 ──
|
| 196 |
+
"FinanceDataReader", "tavily",
|
| 197 |
+
# ── _reference 학습/실험 ──
|
| 198 |
+
"agents", "owlready2", "rapidfuzz", "edgar",
|
| 199 |
+
# ── transitive deps (다른 패키지가 끌어옴) ──
|
| 200 |
+
"dotenv", # python-dotenv
|
| 201 |
+
"google", # google-genai
|
| 202 |
+
"yaml", # pyyaml
|
| 203 |
+
"bs4", # beautifulsoup4
|
| 204 |
+
"starlette", # fastapi가 끌어옴
|
| 205 |
+
"pydantic", # fastapi가 끌어옴
|
| 206 |
+
]
|
| 207 |
+
DEP002 = [
|
| 208 |
+
"beautifulsoup4", # bs4 직접 import
|
| 209 |
+
"google-genai", # google.genai 사용 (gemini provider)
|
| 210 |
+
"marimo", # 노트북 컴파일/배포 도구
|
| 211 |
+
]
|
| 212 |
+
|
| 213 |
+
[dependency-groups]
|
| 214 |
+
dev = [
|
| 215 |
+
"build>=1.4.0",
|
| 216 |
+
"dartlab[all]",
|
| 217 |
+
"hatchling>=1.29.0",
|
| 218 |
+
"hypothesis>=6.100.0",
|
| 219 |
+
"pillow>=12.1.1",
|
| 220 |
+
"pre-commit>=4.0.0",
|
| 221 |
+
"pyright>=1.1.0",
|
| 222 |
+
"pytest>=9.0.2",
|
| 223 |
+
"pytest-asyncio>=0.24.0",
|
| 224 |
+
"pytest-benchmark>=5.0.0",
|
| 225 |
+
"pytest-cov>=6.0.0",
|
| 226 |
+
"radon>=6.0.0",
|
| 227 |
+
"vulture>=2.0",
|
| 228 |
+
]
|
src/dartlab/STATUS.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/dartlab
|
| 2 |
+
|
| 3 |
+
## 개요
|
| 4 |
+
DART 공시 데이터 활용 라이브러리. 종목코드 기반 API.
|
| 5 |
+
|
| 6 |
+
## 구조
|
| 7 |
+
```
|
| 8 |
+
dartlab/
|
| 9 |
+
├── core/ # 공통 기반 (데이터 로딩, 보고서 선택, 테이블 파싱, 주석 추출)
|
| 10 |
+
├── finance/ # 재무 데이터 (36개 모듈)
|
| 11 |
+
│ ├── summary/ # 요약재무정보 시계열
|
| 12 |
+
│ ├── statements/ # 연결재무제표 (BS, IS, CF)
|
| 13 |
+
│ ├── segment/ # 부문별 보고 (주석)
|
| 14 |
+
│ ├── affiliate/ # 관계기업·공동기업 (주석)
|
| 15 |
+
│ ├── costByNature/ # 비용의 성격별 분류 (주석)
|
| 16 |
+
│ ├── tangibleAsset/ # 유형자산 (주석)
|
| 17 |
+
│ ├── notesDetail/ # 주석 상세 (23개 키워드)
|
| 18 |
+
│ ├── dividend/ # 배당
|
| 19 |
+
│ ├── majorHolder/ # 최대주주·주주현황
|
| 20 |
+
│ ├── shareCapital/ # 주식 현황
|
| 21 |
+
│ ├── employee/ # 직원 현황
|
| 22 |
+
│ ├── subsidiary/ # 자회사 투자
|
| 23 |
+
│ ├── bond/ # 채무증권
|
| 24 |
+
│ ├── audit/ # 감사의견·보수
|
| 25 |
+
│ ├── executive/ # 임원 현황
|
| 26 |
+
│ ├── executivePay/ # 임원 보수
|
| 27 |
+
│ ├── boardOfDirectors/ # 이사회
|
| 28 |
+
│ ├── capitalChange/ # 자본금 변동
|
| 29 |
+
│ ├── contingentLiability/ # 우발부채
|
| 30 |
+
│ ├── internalControl/ # 내부통제
|
| 31 |
+
│ ├── relatedPartyTx/ # 관계자 거래
|
| 32 |
+
│ ├── rnd/ # R&D 비용
|
| 33 |
+
│ ├── sanction/ # 제재 현황
|
| 34 |
+
│ ├── affiliateGroup/ # 계열사 목록
|
| 35 |
+
│ ├── fundraising/ # 증자/감자
|
| 36 |
+
│ ├── productService/ # 주요 제품/서비스
|
| 37 |
+
│ ├── salesOrder/ # 매출/수주
|
| 38 |
+
│ ├── riskDerivative/ # 위험관리/파생거래
|
| 39 |
+
│ ├── articlesOfIncorporation/ # 정관
|
| 40 |
+
│ ├── otherFinance/ # 기타 재무
|
| 41 |
+
│ ├── companyHistory/ # 회사 연혁
|
| 42 |
+
│ ├── shareholderMeeting/ # 주주총회
|
| 43 |
+
│ ├── auditSystem/ # 감사제도
|
| 44 |
+
│ ├── investmentInOther/ # 타법인출자
|
| 45 |
+
│ └── companyOverviewDetail/ # 회사개요 상세
|
| 46 |
+
├── disclosure/ # 공시 서술형 (4개 모듈)
|
| 47 |
+
│ ├── business/ # 사업의 내용
|
| 48 |
+
│ ├── companyOverview/ # 회사의 개요 (정량)
|
| 49 |
+
│ ├── mdna/ # MD&A
|
| 50 |
+
│ └── rawMaterial/ # 원재료·설비
|
| 51 |
+
├── company.py # 통합 접근 (property 기반, lazy + cache)
|
| 52 |
+
├── notes.py # K-IFRS 주석 통합 접근
|
| 53 |
+
└── config.py # 전역 설정 (verbose)
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
## API 요약
|
| 57 |
+
```python
|
| 58 |
+
import dartlab
|
| 59 |
+
|
| 60 |
+
c = dartlab.Company("005930")
|
| 61 |
+
c.index # 회사 구조 인덱스
|
| 62 |
+
c.show("BS") # topic payload
|
| 63 |
+
c.trace("dividend") # source trace
|
| 64 |
+
c.BS # 재무상태표 DataFrame
|
| 65 |
+
c.dividend # 배당 시계열 DataFrame
|
| 66 |
+
|
| 67 |
+
import dartlab
|
| 68 |
+
dartlab.verbose = False # 진행 표시 끄기
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
## 현황
|
| 72 |
+
- 2026-03-06: core/ + finance/summary/ 초기 구축
|
| 73 |
+
- 2026-03-06: finance/statements/, segment/, affiliate/ 추가
|
| 74 |
+
- 2026-03-06: 전체 패키지 개선 — stockCode 시그니처, 핫라인 설계, API_SPEC.md
|
| 75 |
+
- 2026-03-07: finance/ 11개 모듈 추가 (dividend~bond, costByNature)
|
| 76 |
+
- 2026-03-07: disclosure/ 4개 모듈 추가 (business, companyOverview, mdna, rawMaterial)
|
| 77 |
+
- 2026-03-07: finance/ 주석 모듈 추가 (notesDetail, tangibleAsset)
|
| 78 |
+
- 2026-03-07: finance/ 7개 모듈 추가 (audit~internalControl, rnd, sanction)
|
| 79 |
+
- 2026-03-07: finance/ 7개 모듈 추가 (affiliateGroup~companyHistory, shareholderMeeting~investmentInOther, companyOverviewDetail)
|
| 80 |
+
- 2026-03-08: analyze → fsSummary 리네이밍, 계정명 특수문자 정리
|
| 81 |
+
- 2026-03-08: Company 재설계 — property 기반 접근, Notes 통합, all(), verbose 설정
|
src/dartlab/__init__.py
ADDED
|
@@ -0,0 +1,1032 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DART 공시 데이터 활용 라이브러리."""
|
| 2 |
+
|
| 3 |
+
import sys
|
| 4 |
+
from importlib.metadata import PackageNotFoundError
|
| 5 |
+
from importlib.metadata import version as _pkg_version
|
| 6 |
+
|
| 7 |
+
from dartlab import ai as llm # noqa: F401 — 하위호환
|
| 8 |
+
from dartlab import config, core # noqa: F401 — 하위호환
|
| 9 |
+
from dartlab.audit import queryAudit, runAudit # noqa: F401 — 하위호환
|
| 10 |
+
from dartlab.company import Company
|
| 11 |
+
from dartlab.core.env import loadEnv as _loadEnv
|
| 12 |
+
from dartlab.core.select import ChartResult, SelectResult
|
| 13 |
+
from dartlab.gather.fred import Fred
|
| 14 |
+
from dartlab.gather.listing import codeToName, fuzzySearch, getKindList, nameToCode # noqa: F401
|
| 15 |
+
from dartlab.listing import listing # noqa: F401 — 목록 조회 단일 진입점
|
| 16 |
+
from dartlab.providers.dart.company import Company as _DartEngineCompany
|
| 17 |
+
from dartlab.providers.dart.openapi.dart import OpenDart
|
| 18 |
+
from dartlab.providers.edgar.openapi.edgar import OpenEdgar
|
| 19 |
+
from dartlab.review import Review
|
| 20 |
+
|
| 21 |
+
# .env 자동 로드 — API 키 등 환경변수
|
| 22 |
+
_loadEnv()
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
__version__ = _pkg_version("dartlab")
|
| 26 |
+
except PackageNotFoundError:
|
| 27 |
+
__version__ = "0.0.0"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def search(
|
| 31 |
+
query: str,
|
| 32 |
+
*,
|
| 33 |
+
corp: str | None = None,
|
| 34 |
+
start: str | None = None,
|
| 35 |
+
end: str | None = None,
|
| 36 |
+
topK: int = 10,
|
| 37 |
+
):
|
| 38 |
+
"""공시 원문 검색. *(alpha)*
|
| 39 |
+
|
| 40 |
+
Ngram+Synonym 기반 검색. 모델 불필요, cold start 0ms.
|
| 41 |
+
DART 공시 뷰어 링크(dartUrl) 포함.
|
| 42 |
+
|
| 43 |
+
Capabilities:
|
| 44 |
+
- 전체 공시 원문 검색 (수시공시 포함)
|
| 45 |
+
- 자연어 동의어 확장 ("돈을 빌렸다" → 사채/차입/전환사채)
|
| 46 |
+
- 종목/기간 필터 지원
|
| 47 |
+
- DART 공시 뷰어 링크 포함 (dartUrl 컬럼)
|
| 48 |
+
|
| 49 |
+
Requires:
|
| 50 |
+
데이터: allFilings (수집 + buildIndex 필요)
|
| 51 |
+
|
| 52 |
+
AIContext:
|
| 53 |
+
공시 내용을 자연어로 찾을 때 사용. 결과의 dartUrl로 원문 확인 가능.
|
| 54 |
+
종목 찾기는 Company("삼성전자")를 사용.
|
| 55 |
+
|
| 56 |
+
Guide:
|
| 57 |
+
- "유상증자 한 회사?" -> search("유상증자 결정")
|
| 58 |
+
- "삼성전자 최근 공시?" -> search("공시", corp="005930")
|
| 59 |
+
|
| 60 |
+
SeeAlso:
|
| 61 |
+
- Company: 종목코드/회사명으로 Company 생성
|
| 62 |
+
- listing: 전체 상장법인 목록
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
query: 검색어 (한국어). "유상증자 결정", "대표이사 변경" 등.
|
| 66 |
+
corp: 종목 필터 (종목코드 "005930" 또는 회사명 "삼성전자").
|
| 67 |
+
start: 시작일 (YYYYMMDD).
|
| 68 |
+
end: 종료일 (YYYYMMDD).
|
| 69 |
+
topK: 반환 건수 (기본 10).
|
| 70 |
+
|
| 71 |
+
Returns
|
| 72 |
+
-------
|
| 73 |
+
pl.DataFrame
|
| 74 |
+
score : float — 매칭 점수 (BM25F 가중)
|
| 75 |
+
rcept_no : str — 접수번호 (DART 고유 ID)
|
| 76 |
+
corp_name : str — 회사명
|
| 77 |
+
rcept_dt : str — 접수일 (YYYYMMDD)
|
| 78 |
+
report_nm : str — 공시 유형명
|
| 79 |
+
section_title : str — 섹션 제목
|
| 80 |
+
text : str — 본문 텍스트 (최대 2000자)
|
| 81 |
+
dartUrl : str — DART 공시 뷰어 URL
|
| 82 |
+
|
| 83 |
+
Example::
|
| 84 |
+
|
| 85 |
+
import dartlab
|
| 86 |
+
dartlab.search("유상증자 결정")
|
| 87 |
+
dartlab.search("대표이사 변경", corp="005930")
|
| 88 |
+
dartlab.search("전환사채", start="20240101", topK=5)
|
| 89 |
+
"""
|
| 90 |
+
# R33-1: 빈 query 거부
|
| 91 |
+
if not query or not query.strip():
|
| 92 |
+
raise ValueError(
|
| 93 |
+
"search 의 query 가 비어 있습니다. 검색어를 1자 이상 전달하세요. 예: dartlab.search('유상증자')"
|
| 94 |
+
)
|
| 95 |
+
from dartlab.core.search import search as _search
|
| 96 |
+
|
| 97 |
+
return _search(query, corp=corp, start=start, end=end, topK=topK)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def searchName(keyword: str):
|
| 101 |
+
"""종목명/코드로 종목 찾기 (KR + US).
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
keyword: 종목명, 종목코드, 또는 ticker.
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
pl.DataFrame — 종목 검색 결과.
|
| 108 |
+
|
| 109 |
+
Example::
|
| 110 |
+
|
| 111 |
+
dartlab.searchName("삼성전자")
|
| 112 |
+
dartlab.searchName("AAPL")
|
| 113 |
+
"""
|
| 114 |
+
# R33-2: 빈 keyword 거부
|
| 115 |
+
if not keyword or not keyword.strip():
|
| 116 |
+
raise ValueError(
|
| 117 |
+
"searchName 의 keyword 가 비어 있습니다. 종목명/코드를 1자 이상 전달하세요. "
|
| 118 |
+
"예: dartlab.searchName('삼성전자') 또는 dartlab.searchName('AAPL')"
|
| 119 |
+
)
|
| 120 |
+
if any("\uac00" <= ch <= "\ud7a3" for ch in keyword):
|
| 121 |
+
return _DartEngineCompany.search(keyword)
|
| 122 |
+
if keyword.isascii() and keyword.isalpha():
|
| 123 |
+
try:
|
| 124 |
+
from dartlab.providers.edgar.company import Company as _US
|
| 125 |
+
|
| 126 |
+
return _US.search(keyword)
|
| 127 |
+
except (ImportError, AttributeError, NotImplementedError):
|
| 128 |
+
pass
|
| 129 |
+
return _DartEngineCompany.search(keyword)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def collect(
|
| 133 |
+
*codes: str,
|
| 134 |
+
categories: list[str] | None = None,
|
| 135 |
+
incremental: bool = True,
|
| 136 |
+
) -> dict[str, dict[str, int]]:
|
| 137 |
+
"""지정 종목 DART 데이터 수집 (OpenAPI).
|
| 138 |
+
|
| 139 |
+
Capabilities:
|
| 140 |
+
- 종목별 DART 공시 데��터 직접 수집 (finance, docs, report)
|
| 141 |
+
- 멀티키 병렬 수집 (DART_API_KEYS 쉼표 구분)
|
| 142 |
+
- 증분 수집 — 이미 있는 데이터는 건너뜀
|
| 143 |
+
- 카테고리별 선택 수집
|
| 144 |
+
|
| 145 |
+
Requires:
|
| 146 |
+
API 키: DART_API_KEY
|
| 147 |
+
|
| 148 |
+
AIContext:
|
| 149 |
+
사용자가 특정 종목의 최신 데이터를 직접 수집할 때 사용.
|
| 150 |
+
|
| 151 |
+
Guide:
|
| 152 |
+
- "데이터 수집해줘" -> DART_API_KEY 필요. dartlab.setup("dart-key", "YOUR_KEY")로 설정 안내
|
| 153 |
+
- "삼성전자 재무 데이터 수집" -> collect("005930", categories=["finance"])
|
| 154 |
+
- 보안: 키는 로컬 .env에만 저장, 외부 전송 절대 없음
|
| 155 |
+
|
| 156 |
+
SeeAlso:
|
| 157 |
+
- Company: 수집된 데이터로 Company 생성하여 분석
|
| 158 |
+
- search: 종목코드 모를 때 먼저 검색
|
| 159 |
+
|
| 160 |
+
Args:
|
| 161 |
+
*codes: 종목코드 1개 이상 ("005930", "000660").
|
| 162 |
+
categories: 수집 카테고리 ["finance", "docs", "report"]. None이면 전체.
|
| 163 |
+
incremental: True면 증분 수집 (기본). False면 전체 재수집.
|
| 164 |
+
|
| 165 |
+
Returns:
|
| 166 |
+
dict — 종목코드별 카테고리별 수집 건수.
|
| 167 |
+
|
| 168 |
+
Example::
|
| 169 |
+
|
| 170 |
+
import dartlab
|
| 171 |
+
dartlab.collect("005930") # 삼성전자 전체
|
| 172 |
+
dartlab.collect("005930", "000660", categories=["finance"]) # 재무만
|
| 173 |
+
"""
|
| 174 |
+
from dartlab.providers.dart.openapi.batch import batchCollect
|
| 175 |
+
|
| 176 |
+
return batchCollect(list(codes), categories=categories, incremental=incremental)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def collectAll(
|
| 180 |
+
*,
|
| 181 |
+
categories: list[str] | None = None,
|
| 182 |
+
mode: str = "new",
|
| 183 |
+
maxWorkers: int | None = None,
|
| 184 |
+
incremental: bool = True,
|
| 185 |
+
) -> dict[str, dict[str, int]]:
|
| 186 |
+
"""전체 상장종목 DART 데이터 일괄 수집.
|
| 187 |
+
|
| 188 |
+
Capabilities:
|
| 189 |
+
- 전체 상장종목 DART 공시 데이터 일괄 수집
|
| 190 |
+
- 미수집 종목만 선별 수집 (mode="new") 또는 전체 재수집 (mode="all")
|
| 191 |
+
- 멀티키 병렬 수집 (DART_API_KEYS 쉼표 구분)
|
| 192 |
+
- 카테고리별 선택 (finance, docs, report)
|
| 193 |
+
|
| 194 |
+
Requires:
|
| 195 |
+
API 키: DART_API_KEY
|
| 196 |
+
|
| 197 |
+
Guide:
|
| 198 |
+
- "전종목 데이터 수집" -> collectAll() 안내. DART_API_KEY 필요
|
| 199 |
+
- "재무 데이터만 수집" -> collectAll(categories=["finance"])
|
| 200 |
+
- 보안: 키는 로컬 .env에만 저장, 외부 전송 절대 없음
|
| 201 |
+
|
| 202 |
+
SeeAlso:
|
| 203 |
+
- collect: 특정 종목만 수집
|
| 204 |
+
- downloadAll: HuggingFace 사전구축 데이터 (API 키 불필요, 더 빠름)
|
| 205 |
+
|
| 206 |
+
Args:
|
| 207 |
+
categories: 수집 카테고리 ["finance", "docs", "report"]. None이면 전체.
|
| 208 |
+
mode: "new" (미수집만, 기본) 또는 "all" (전체 재수집).
|
| 209 |
+
maxWorkers: 병렬 워커 수. None이면 키 수에 따라 자동.
|
| 210 |
+
incremental: True면 증분 수집. False면 전체 재수집.
|
| 211 |
+
|
| 212 |
+
Returns:
|
| 213 |
+
dict — 종목코드별 카테고리별 수집 건수.
|
| 214 |
+
|
| 215 |
+
Example::
|
| 216 |
+
|
| 217 |
+
import dartlab
|
| 218 |
+
dartlab.collectAll() # 전체 미수집 종목
|
| 219 |
+
dartlab.collectAll(categories=["finance"]) # 재무만
|
| 220 |
+
dartlab.collectAll(mode="all") # 기수집 포함 전체
|
| 221 |
+
"""
|
| 222 |
+
from dartlab.providers.dart.openapi.batch import batchCollectAll
|
| 223 |
+
|
| 224 |
+
return batchCollectAll(
|
| 225 |
+
categories=categories,
|
| 226 |
+
mode=mode,
|
| 227 |
+
maxWorkers=maxWorkers,
|
| 228 |
+
incremental=incremental,
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def downloadAll(category: str = "finance", *, forceUpdate: bool = False) -> None:
|
| 233 |
+
"""HuggingFace에서 전체 시장 데이터 다운로드.
|
| 234 |
+
|
| 235 |
+
Capabilities:
|
| 236 |
+
- HuggingFace 사전 구축 데이터 일괄 다운로드
|
| 237 |
+
- finance (~600MB, 2700+종목), docs (~8GB, 2500+종목), report (~320MB, 2700+종목)
|
| 238 |
+
- 이어받기/병렬 다운로드 지원 (huggingface_hub)
|
| 239 |
+
- 전사 분석(scanAccount, governance, digest 등)에 필요한 데이터 사전 준비
|
| 240 |
+
|
| 241 |
+
Requires:
|
| 242 |
+
없음 (HuggingFace 공개 데이터셋)
|
| 243 |
+
|
| 244 |
+
Guide:
|
| 245 |
+
- "데이터 어떻게 받아?" -> downloadAll("finance") 안내. API 키 불필요
|
| 246 |
+
- "scan 쓰려면?" -> downloadAll("finance") + downloadAll("report") 필요
|
| 247 |
+
- finance 먼저 (600MB), report 다음 (320MB), docs는 대용량 주의 (8GB)
|
| 248 |
+
|
| 249 |
+
SeeAlso:
|
| 250 |
+
- scan: 다운로드된 데이터로 전종목 비교
|
| 251 |
+
- collect: DART API로 직접 수집 (최신 데이터, API 키 필요)
|
| 252 |
+
|
| 253 |
+
Args:
|
| 254 |
+
category: "finance" (재무 ~600MB), "docs" (공시 ~8GB), "report" (보고서 ~320MB).
|
| 255 |
+
forceUpdate: True면 이미 있는 파일도 최신으로 갱신.
|
| 256 |
+
|
| 257 |
+
Returns:
|
| 258 |
+
None.
|
| 259 |
+
|
| 260 |
+
Example::
|
| 261 |
+
|
| 262 |
+
import dartlab
|
| 263 |
+
dartlab.downloadAll("finance") # 재무 전체 — scanAccount/scanRatio 등에 필요
|
| 264 |
+
dartlab.downloadAll("report") # 보고서 전체 — governance/workforce/capital/debt에 필요
|
| 265 |
+
dartlab.downloadAll("docs") # 공시 전체 — digest에 필요 (대용량 ~8GB)
|
| 266 |
+
"""
|
| 267 |
+
from dartlab.core.dataLoader import downloadAll as _downloadAll
|
| 268 |
+
|
| 269 |
+
_downloadAll(category, forceUpdate=forceUpdate)
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def checkFreshness(stockCode: str, *, forceCheck: bool = False):
|
| 273 |
+
"""종목의 로컬 데이터가 최신인지 DART API로 확인.
|
| 274 |
+
|
| 275 |
+
Capabilities:
|
| 276 |
+
- 로컬 데이터와 DART 서버의 최신 공시 비교
|
| 277 |
+
- 누락 공시 수 + 최신 여부 판정
|
| 278 |
+
- 캐시된 결과 재사용 (forceCheck=False)
|
| 279 |
+
|
| 280 |
+
Requires:
|
| 281 |
+
API 키: DART_API_KEY
|
| 282 |
+
|
| 283 |
+
AIContext:
|
| 284 |
+
- 분석 전 데이터 최신성 확인에 사용
|
| 285 |
+
- isFresh=False이면 collect()로 갱신 권장
|
| 286 |
+
- missingCount로 누락 규모 파악 후 수집 우선순위 판단
|
| 287 |
+
|
| 288 |
+
Guide:
|
| 289 |
+
- "내 데이터 최신이야?" -> checkFreshness("005930")
|
| 290 |
+
- "공시 누락 있어?" -> checkFreshness로 missingCount 확인
|
| 291 |
+
- "데이터 업데이트 필요해?" -> checkFreshness 후 collect 안내
|
| 292 |
+
|
| 293 |
+
SeeAlso:
|
| 294 |
+
- collect: 누락 공시 실제 수집 (checkFreshness에서 발견한 gap 채우기)
|
| 295 |
+
- Company: 종목 데이터 접근 (최신 데이터 기반 분석)
|
| 296 |
+
|
| 297 |
+
Args:
|
| 298 |
+
stockCode: 종목코드 ("005930").
|
| 299 |
+
forceCheck: True면 캐시 무시, DART API 강제 조회.
|
| 300 |
+
|
| 301 |
+
Returns:
|
| 302 |
+
FreshnessResult — isFresh (bool), missingCount (int), lastLocalDate, lastRemoteDate.
|
| 303 |
+
|
| 304 |
+
Example::
|
| 305 |
+
|
| 306 |
+
import dartlab
|
| 307 |
+
result = dartlab.checkFreshness("005930")
|
| 308 |
+
result.isFresh # True/False
|
| 309 |
+
result.missingCount # 누락 공시 수
|
| 310 |
+
"""
|
| 311 |
+
from dartlab.providers.dart.openapi.freshness import (
|
| 312 |
+
checkFreshness as _check,
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
return _check(stockCode, forceCheck=forceCheck)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def setup(provider: str | None = None):
|
| 319 |
+
"""AI provider 설정 안내 + 인터랙티브 설정.
|
| 320 |
+
|
| 321 |
+
Capabilities:
|
| 322 |
+
- 전체 AI provider 설정 현황 테이블 표시
|
| 323 |
+
- provider별 대화형 설정 (키 입력 → .env 저장)
|
| 324 |
+
- ChatGPT OAuth 브라우저 로그인
|
| 325 |
+
- OpenAI/Gemini/Groq/Cerebras/Mistral API 키 설정
|
| 326 |
+
- Ollama 로컬 LLM 설치 안내
|
| 327 |
+
|
| 328 |
+
Requires:
|
| 329 |
+
없음
|
| 330 |
+
|
| 331 |
+
AIContext:
|
| 332 |
+
- AI 분석 기능 사용 전 provider 설정 상태 확인
|
| 333 |
+
- 미설정 provider 감지 시 setup() 안내로 연결
|
| 334 |
+
- 설정 완료 여부를 프로그래밍 방식으로 체크 가능
|
| 335 |
+
|
| 336 |
+
Guide:
|
| 337 |
+
- "AI 설정 어떻게 해?" -> setup()으로 전체 현황 확인
|
| 338 |
+
- "ChatGPT 연결하고 싶어" -> setup("chatgpt")
|
| 339 |
+
- "OpenAI 키 등록" -> setup("openai")
|
| 340 |
+
- "Ollama 어떻게 써?" -> setup("ollama")
|
| 341 |
+
|
| 342 |
+
SeeAlso:
|
| 343 |
+
- ask: AI 질문 (setup 완료 후 사용)
|
| 344 |
+
- chat: AI 대화 (setup 완료 후 사용)
|
| 345 |
+
- llm.configure: 프로그래밍 방식 provider 설정
|
| 346 |
+
|
| 347 |
+
Args:
|
| 348 |
+
provider: provider명 또는 alias. None이면 전체 현황 표시.
|
| 349 |
+
지원: "chatgpt", "openai", "gemini", "groq", "cerebras",
|
| 350 |
+
"mistral", "ollama", "codex", "custom".
|
| 351 |
+
|
| 352 |
+
Returns:
|
| 353 |
+
None (터미널/노트북에 안내 출력).
|
| 354 |
+
|
| 355 |
+
Example::
|
| 356 |
+
|
| 357 |
+
import dartlab
|
| 358 |
+
dartlab.setup() # 전체 provider 현황
|
| 359 |
+
dartlab.setup("chatgpt") # ChatGPT OAuth 브라우저 로그인
|
| 360 |
+
dartlab.setup("openai") # OpenAI API 키 설정
|
| 361 |
+
dartlab.setup("ollama") # Ollama 설치 안내
|
| 362 |
+
"""
|
| 363 |
+
from dartlab.core.ai.guide import (
|
| 364 |
+
providers_status,
|
| 365 |
+
resolve_alias,
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
if provider is None:
|
| 369 |
+
print(providers_status())
|
| 370 |
+
return
|
| 371 |
+
|
| 372 |
+
provider = resolve_alias(provider)
|
| 373 |
+
|
| 374 |
+
if provider == "oauth-codex":
|
| 375 |
+
_setup_oauth_interactive()
|
| 376 |
+
else:
|
| 377 |
+
_setup_apikey_interactive(provider)
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def _setup_oauth_interactive():
|
| 381 |
+
"""노트북/CLI에서 ChatGPT OAuth 브라우저 로그인."""
|
| 382 |
+
try:
|
| 383 |
+
from dartlab.ai.providers.support.oauth_token import is_authenticated
|
| 384 |
+
|
| 385 |
+
if is_authenticated():
|
| 386 |
+
print("\n ✓ ChatGPT OAuth 이미 인증되어 있습니다.")
|
| 387 |
+
print(' 재인증: dartlab.setup("chatgpt") # 재실행하면 갱신\n')
|
| 388 |
+
return
|
| 389 |
+
except ImportError:
|
| 390 |
+
pass
|
| 391 |
+
|
| 392 |
+
try:
|
| 393 |
+
from dartlab.cli.commands.setup import _do_oauth_login
|
| 394 |
+
|
| 395 |
+
_do_oauth_login()
|
| 396 |
+
except ImportError:
|
| 397 |
+
print("\n ChatGPT OAuth 브라우저 로그인:")
|
| 398 |
+
print(" CLI에서 실행: dartlab setup oauth-codex\n")
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def _setup_apikey_interactive(provider: str):
|
| 402 |
+
"""API 키 기반 provider 인터랙티브 설정."""
|
| 403 |
+
from dartlab.guide.providers import _PROVIDERS
|
| 404 |
+
|
| 405 |
+
spec = _PROVIDERS.get(provider)
|
| 406 |
+
if spec is None or not spec.env_key:
|
| 407 |
+
from dartlab.core.ai.guide import provider_guide
|
| 408 |
+
|
| 409 |
+
print(provider_guide(provider))
|
| 410 |
+
return
|
| 411 |
+
|
| 412 |
+
from dartlab.guide.env import promptAndSave
|
| 413 |
+
|
| 414 |
+
promptAndSave(
|
| 415 |
+
spec.env_key,
|
| 416 |
+
label=spec.label,
|
| 417 |
+
guide=spec.signupUrl or spec.description,
|
| 418 |
+
)
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def _auto_stream(gen) -> str:
|
| 422 |
+
"""Generator를 소비하면서 stdout에 스트리밍 출력, 전체 텍스트 반환."""
|
| 423 |
+
import sys
|
| 424 |
+
|
| 425 |
+
chunks: list[str] = []
|
| 426 |
+
for chunk in gen:
|
| 427 |
+
chunks.append(chunk)
|
| 428 |
+
sys.stdout.write(chunk)
|
| 429 |
+
sys.stdout.flush()
|
| 430 |
+
sys.stdout.write("\n")
|
| 431 |
+
sys.stdout.flush()
|
| 432 |
+
return "".join(chunks)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def ask(
|
| 436 |
+
*args: str,
|
| 437 |
+
include: list[str] | None = None,
|
| 438 |
+
exclude: list[str] | None = None,
|
| 439 |
+
provider: str | None = None,
|
| 440 |
+
model: str | None = None,
|
| 441 |
+
stream: bool = True,
|
| 442 |
+
raw: bool = False,
|
| 443 |
+
reflect: bool = False,
|
| 444 |
+
pattern: str | None = None,
|
| 445 |
+
template: str | None = None,
|
| 446 |
+
modules: list[str] | None = None,
|
| 447 |
+
**kwargs,
|
| 448 |
+
):
|
| 449 |
+
"""LLM에게 기업에 대해 질문.
|
| 450 |
+
|
| 451 |
+
Capabilities:
|
| 452 |
+
- 자연어로 기업 분석 질문 (종목 자동 감지)
|
| 453 |
+
- 스트리밍 출력 (기본) / 배치 반환 / Generator 직접 제어
|
| 454 |
+
- 엔진 자동 계산 → LLM 해석 (Engine-First)
|
| 455 |
+
- 데이터 모듈 include/exclude로 분석 범위 제어
|
| 456 |
+
- 자체 검증 (reflect=True)
|
| 457 |
+
|
| 458 |
+
Requires:
|
| 459 |
+
AI: provider 설정 (dartlab.setup() 참조)
|
| 460 |
+
|
| 461 |
+
AIContext:
|
| 462 |
+
- 재무비율, 추세, 동종업계 비교를 자동 계산하여 LLM에 제공
|
| 463 |
+
- sections 서술형 데이터 + finance 숫자 데이터 동시 주입
|
| 464 |
+
- tool calling provider에서는 LLM이 추가 데이터 자율 탐색
|
| 465 |
+
|
| 466 |
+
Guide:
|
| 467 |
+
- "삼성전자 분석해줘" -> ask("삼성전자 재무건전성 분석해줘")
|
| 468 |
+
- "이 회사 괜찮아?" -> ask("종목코드", "이 회사 투자해도 괜찮아?")
|
| 469 |
+
- "AI 설정 어떻게 해?" -> dartlab.setup()으로 provider/키 설정 안내
|
| 470 |
+
- provider 미설정 시 자동 감지. 설정 방법: dartlab.llm.configure(provider="openai", api_key="sk-...")
|
| 471 |
+
- 보안: API 키는 로컬 .env에만 저장, 외부 전송 절대 없음
|
| 472 |
+
|
| 473 |
+
SeeAlso:
|
| 474 |
+
- chat: 대화형 연속 분석 (멀티턴)
|
| 475 |
+
- Company: 프로그래밍 방식 데이터 접근
|
| 476 |
+
- scan: 전종목 비교 (ask보다 직접적)
|
| 477 |
+
|
| 478 |
+
Args:
|
| 479 |
+
*args: 자연어 질문 (1개) 또는 (종목, 질문) 2개.
|
| 480 |
+
provider: LLM provider ("openai", "codex", "oauth-codex", "ollama").
|
| 481 |
+
model: 모델 override.
|
| 482 |
+
stream: True면 스트리밍 출력 (기본값). False면 조용히 전체 텍스트 반환.
|
| 483 |
+
raw: True면 Generator를 직접 반환 (커스텀 UI용).
|
| 484 |
+
include: 포함할 데이터 모듈.
|
| 485 |
+
exclude: 제외할 데이터 모듈.
|
| 486 |
+
reflect: True면 답변 자체 검증 (1회 reflection).
|
| 487 |
+
|
| 488 |
+
Returns:
|
| 489 |
+
str | None: 전체 답변 텍스트. 설정 오류 시 None. (raw=True일 때만 Generator[str])
|
| 490 |
+
|
| 491 |
+
Example::
|
| 492 |
+
|
| 493 |
+
import dartlab
|
| 494 |
+
dartlab.llm.configure(provider="openai", api_key="sk-...")
|
| 495 |
+
|
| 496 |
+
# 호출하면 스트리밍 출력 + 전체 텍스트 반환
|
| 497 |
+
answer = dartlab.ask("삼성전자 재무건전성 분석해줘")
|
| 498 |
+
|
| 499 |
+
# provider + model 지정
|
| 500 |
+
answer = dartlab.ask("삼성전자 분석", provider="openai", model="gpt-4o")
|
| 501 |
+
|
| 502 |
+
# (종목, 질문) 분리
|
| 503 |
+
answer = dartlab.ask("005930", "영업이익률 추세는?")
|
| 504 |
+
|
| 505 |
+
# 조용히 전체 텍스트만 (배치용)
|
| 506 |
+
answer = dartlab.ask("삼성전자 분석", stream=False)
|
| 507 |
+
|
| 508 |
+
# Generator 직접 제어 (커스텀 UI용)
|
| 509 |
+
for chunk in dartlab.ask("삼성전자 분석", raw=True):
|
| 510 |
+
custom_process(chunk)
|
| 511 |
+
"""
|
| 512 |
+
from dartlab.ai.runtime.standalone import ask as _ask
|
| 513 |
+
|
| 514 |
+
# provider 미지정 시 auto-detect
|
| 515 |
+
if provider is None:
|
| 516 |
+
from dartlab.core.ai.detect import auto_detect_provider
|
| 517 |
+
|
| 518 |
+
detected = auto_detect_provider()
|
| 519 |
+
if detected is None:
|
| 520 |
+
from dartlab.core.ai.guide import no_provider_message
|
| 521 |
+
|
| 522 |
+
print(no_provider_message())
|
| 523 |
+
return None
|
| 524 |
+
provider = detected
|
| 525 |
+
|
| 526 |
+
if len(args) == 2:
|
| 527 |
+
import warnings
|
| 528 |
+
|
| 529 |
+
warnings.warn(
|
| 530 |
+
"dartlab.ask(stock, question) is deprecated. Use dartlab.ask('삼성전자 분석해줘') instead.",
|
| 531 |
+
DeprecationWarning,
|
| 532 |
+
stacklevel=2,
|
| 533 |
+
)
|
| 534 |
+
company = Company(args[0])
|
| 535 |
+
question = args[1]
|
| 536 |
+
elif len(args) == 1:
|
| 537 |
+
company = None
|
| 538 |
+
question = args[0]
|
| 539 |
+
elif len(args) == 0:
|
| 540 |
+
print("\n 질문을 입력해 주세요.")
|
| 541 |
+
print(" 예: dartlab.ask('삼성전자 재무건전성 분석해줘')")
|
| 542 |
+
print(" 예: dartlab.ask('005930', '영업이익률 추세는?')\n")
|
| 543 |
+
return None
|
| 544 |
+
else:
|
| 545 |
+
print(f"\n 인자는 1~2개만 허용됩니다 (받은 수: {len(args)})")
|
| 546 |
+
print(" 예: dartlab.ask('삼성전자 분석해줘')")
|
| 547 |
+
print(" 예: dartlab.ask('005930', '영업이익률 추세는?')\n")
|
| 548 |
+
return None
|
| 549 |
+
|
| 550 |
+
# kwargs에서 company 제거 (내부에서 직접 전달)
|
| 551 |
+
kwargs.pop("company", None)
|
| 552 |
+
_call_kwargs = dict(
|
| 553 |
+
company=company,
|
| 554 |
+
include=include,
|
| 555 |
+
exclude=exclude,
|
| 556 |
+
provider=provider,
|
| 557 |
+
model=model,
|
| 558 |
+
reflect=reflect,
|
| 559 |
+
pattern=pattern,
|
| 560 |
+
template=template,
|
| 561 |
+
modules=modules,
|
| 562 |
+
**kwargs,
|
| 563 |
+
)
|
| 564 |
+
|
| 565 |
+
if raw:
|
| 566 |
+
return _ask(question, stream=stream, **_call_kwargs)
|
| 567 |
+
|
| 568 |
+
if not stream:
|
| 569 |
+
return _ask(question, stream=False, **_call_kwargs)
|
| 570 |
+
|
| 571 |
+
gen = _ask(question, stream=True, **_call_kwargs)
|
| 572 |
+
return _auto_stream(gen)
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
def templates(name: str | None = None):
|
| 576 |
+
"""분석 템플릿 목록 또는 특정 템플릿 내용.
|
| 577 |
+
|
| 578 |
+
Example::
|
| 579 |
+
|
| 580 |
+
dartlab.templates() # 전체 목록
|
| 581 |
+
dartlab.templates("가치투자") # 특정 템플릿 내용
|
| 582 |
+
"""
|
| 583 |
+
from dartlab.ai import templates as _templates
|
| 584 |
+
|
| 585 |
+
return _templates(name)
|
| 586 |
+
|
| 587 |
+
|
| 588 |
+
def saveTemplate(name: str, *, content: str | None = None, file: str | None = None):
|
| 589 |
+
"""사용자 분석 템플릿 저장. ~/.dartlab/templates/{name}.md
|
| 590 |
+
|
| 591 |
+
Example::
|
| 592 |
+
|
| 593 |
+
dartlab.saveTemplate("my_style", content="## 내 기준\\n- ROE > 15%")
|
| 594 |
+
"""
|
| 595 |
+
from dartlab.ai import saveTemplate as _save
|
| 596 |
+
|
| 597 |
+
return _save(name, content=content, file=file)
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
def chat(
|
| 601 |
+
*args: str,
|
| 602 |
+
provider: str | None = None,
|
| 603 |
+
model: str | None = None,
|
| 604 |
+
max_turns: int = 5,
|
| 605 |
+
on_tool_call=None,
|
| 606 |
+
on_tool_result=None,
|
| 607 |
+
**kwargs,
|
| 608 |
+
) -> str:
|
| 609 |
+
"""에이전트 모드: LLM이 도구를 선택하여 심화 분석.
|
| 610 |
+
|
| 611 |
+
Capabilities:
|
| 612 |
+
- LLM이 dartlab 도구를 자율적으로 선택/실행
|
| 613 |
+
- 원본 공시 탐색, 계정 시계열 비교, 섹터 통계 등 심화 분석
|
| 614 |
+
- 최대 N회 도구 호출 반복 (multi-turn)
|
| 615 |
+
- 도구 호출/결과 콜백으로 UI 연동
|
| 616 |
+
- 종목 없이도 동작 (시장 전체 질문, 메타 질문 등)
|
| 617 |
+
|
| 618 |
+
Requires:
|
| 619 |
+
AI: provider 설정 (tool calling 지원 provider 권장)
|
| 620 |
+
|
| 621 |
+
AIContext:
|
| 622 |
+
- ask()와 동일한 기본 컨텍스트 + 저수준 도구 접근
|
| 623 |
+
- LLM이 부족하다 판단하면 추가 데이터 자율 수집
|
| 624 |
+
- company=None이면 scan/gather/system 도구만 활성화
|
| 625 |
+
|
| 626 |
+
Guide:
|
| 627 |
+
- "깊게 분석해줘" -> chat("005930", "배당 추세를 분석하고 이상 징후를 찾아줘")
|
| 628 |
+
- "시장 전체 거버넌스 비교" -> chat("코스피 거버넌스 좋은 회사 찾아줘")
|
| 629 |
+
- "dartlab 뭐 할 수 있어?" -> chat("dartlab 기능 알려줘")
|
| 630 |
+
- ask()보다 심화 분석이 필요할 때 사용. LLM이 자율적으로 도구 호출
|
| 631 |
+
|
| 632 |
+
SeeAlso:
|
| 633 |
+
- ask: 단일 질문 (간단한 분석)
|
| 634 |
+
- Company: 프로그래밍 방식 직접 접근
|
| 635 |
+
- scan: 전종목 횡단분석
|
| 636 |
+
|
| 637 |
+
Args:
|
| 638 |
+
*args: (종목, 질문) 2개 또는 질문만 1개.
|
| 639 |
+
provider: LLM provider.
|
| 640 |
+
model: 모델 override.
|
| 641 |
+
max_turns: 최대 도구 호출 반복 횟수.
|
| 642 |
+
|
| 643 |
+
Returns:
|
| 644 |
+
str: 최종 답변 텍스트.
|
| 645 |
+
|
| 646 |
+
Example::
|
| 647 |
+
|
| 648 |
+
import dartlab
|
| 649 |
+
dartlab.chat("005930", "배당 추세를 분석하고 이상 징후를 찾아줘")
|
| 650 |
+
dartlab.chat("코스피 ROE 높은 회사 알려줘") # 종목 없이 시장 질문
|
| 651 |
+
"""
|
| 652 |
+
from dartlab.ai.runtime.standalone import chat as _chat
|
| 653 |
+
|
| 654 |
+
if len(args) == 2:
|
| 655 |
+
company = Company(args[0])
|
| 656 |
+
question = args[1]
|
| 657 |
+
elif len(args) == 1:
|
| 658 |
+
from dartlab.core.resolve import resolve_from_text
|
| 659 |
+
|
| 660 |
+
company, question = resolve_from_text(args[0])
|
| 661 |
+
if company is None:
|
| 662 |
+
question = args[0]
|
| 663 |
+
elif len(args) == 0:
|
| 664 |
+
print("\n 질문을 입력해 주세요.")
|
| 665 |
+
print(" 예: dartlab.chat('005930', '배당 추세 분석해줘')")
|
| 666 |
+
print(" 예: dartlab.chat('코스피 ROE 높은 회사 알려줘')\n")
|
| 667 |
+
return ""
|
| 668 |
+
else:
|
| 669 |
+
print(f"\n 인자는 1~2개만 허용됩니다 (받은 수: {len(args)})")
|
| 670 |
+
return ""
|
| 671 |
+
|
| 672 |
+
return _chat(
|
| 673 |
+
company,
|
| 674 |
+
question,
|
| 675 |
+
provider=provider,
|
| 676 |
+
model=model,
|
| 677 |
+
max_turns=max_turns,
|
| 678 |
+
on_tool_call=on_tool_call,
|
| 679 |
+
on_tool_result=on_tool_result,
|
| 680 |
+
**kwargs,
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
def plugins():
|
| 685 |
+
"""로드된 플러그인 목록 반환.
|
| 686 |
+
|
| 687 |
+
Capabilities:
|
| 688 |
+
- 설치된 dartlab 플러그인 자동 탐색
|
| 689 |
+
- 플러그인 메타데이터 (이름, 버전, 제공 topic) 조회
|
| 690 |
+
|
| 691 |
+
Requires:
|
| 692 |
+
없음
|
| 693 |
+
|
| 694 |
+
AIContext:
|
| 695 |
+
- 확장 기능 탐색 시 설치된 플러그인 목록 확인
|
| 696 |
+
- 플러그인이 제공하는 topic을 show()에서 사용 가능
|
| 697 |
+
- 플러그인 유무에 따라 분석 범위 동적 결정
|
| 698 |
+
|
| 699 |
+
Guide:
|
| 700 |
+
- "플러그인 뭐 있어?" -> plugins()
|
| 701 |
+
- "확장 기능 목록" -> plugins()로 설치된 플러그인 확인
|
| 702 |
+
- "ESG 플러그인 있어?" -> plugins()에서 검색
|
| 703 |
+
|
| 704 |
+
SeeAlso:
|
| 705 |
+
- reload_plugins: 새 플러그인 설치 후 재스캔
|
| 706 |
+
- Company.show: 플러그인 topic 조회 (plugins가 제공한 topic 사용)
|
| 707 |
+
|
| 708 |
+
Args:
|
| 709 |
+
없음.
|
| 710 |
+
|
| 711 |
+
Returns:
|
| 712 |
+
list[PluginMeta] — 로드된 플러그인 목록.
|
| 713 |
+
|
| 714 |
+
Example::
|
| 715 |
+
|
| 716 |
+
import dartlab
|
| 717 |
+
dartlab.plugins() # [PluginMeta(name="esg-scores", ...)]
|
| 718 |
+
"""
|
| 719 |
+
from dartlab.core.plugins import discover, get_loaded_plugins
|
| 720 |
+
|
| 721 |
+
discover()
|
| 722 |
+
return get_loaded_plugins()
|
| 723 |
+
|
| 724 |
+
|
| 725 |
+
def reload_plugins():
|
| 726 |
+
"""플러그인 재스캔 — pip install 후 재시작 없이 즉시 인식.
|
| 727 |
+
|
| 728 |
+
Capabilities:
|
| 729 |
+
- 새로 설치한 플러그인 즉시 인식 (세션 재시작 불필요)
|
| 730 |
+
- entry_points 재스캔
|
| 731 |
+
|
| 732 |
+
Requires:
|
| 733 |
+
없음
|
| 734 |
+
|
| 735 |
+
AIContext:
|
| 736 |
+
- pip install 후 세션 재시작 없이 플러그인 즉시 활성화
|
| 737 |
+
- 새로 인식된 topic이 Company.show()에서 바로 사용 가능
|
| 738 |
+
|
| 739 |
+
Guide:
|
| 740 |
+
- "새 플러그인 설치했는데 안 보여" -> reload_plugins()
|
| 741 |
+
- "플러그인 재스캔" -> reload_plugins()
|
| 742 |
+
|
| 743 |
+
SeeAlso:
|
| 744 |
+
- plugins: 현재 로드된 플러그인 확인 (reload 전후 비교)
|
| 745 |
+
- Company.show: 플러그인 topic 조회
|
| 746 |
+
|
| 747 |
+
Args:
|
| 748 |
+
없음.
|
| 749 |
+
|
| 750 |
+
Returns:
|
| 751 |
+
list[PluginMeta] — 재스캔 후 플러그인 목록.
|
| 752 |
+
|
| 753 |
+
Example::
|
| 754 |
+
|
| 755 |
+
# 1. 새 플러그인 설치
|
| 756 |
+
# !uv pip install dartlab-plugin-esg
|
| 757 |
+
|
| 758 |
+
# 2. 재스캔
|
| 759 |
+
dartlab.reload_plugins()
|
| 760 |
+
|
| 761 |
+
# 3. 즉시 사용
|
| 762 |
+
dartlab.Company("005930").show("esgScore")
|
| 763 |
+
"""
|
| 764 |
+
from dartlab.core.plugins import rediscover
|
| 765 |
+
|
| 766 |
+
return rediscover()
|
| 767 |
+
|
| 768 |
+
|
| 769 |
+
class _Module(sys.modules[__name__].__class__):
|
| 770 |
+
"""dartlab.verbose / dartlab.dataDir / dartlab.chart|table|text 프록시."""
|
| 771 |
+
|
| 772 |
+
@property
|
| 773 |
+
def verbose(self):
|
| 774 |
+
"""전역 verbose 설정 조회."""
|
| 775 |
+
return config.verbose
|
| 776 |
+
|
| 777 |
+
@verbose.setter
|
| 778 |
+
def verbose(self, value):
|
| 779 |
+
config.verbose = value
|
| 780 |
+
|
| 781 |
+
@property
|
| 782 |
+
def askLog(self):
|
| 783 |
+
"""ask/chat 로그 활성화 조회."""
|
| 784 |
+
return config.askLog
|
| 785 |
+
|
| 786 |
+
@askLog.setter
|
| 787 |
+
def askLog(self, value):
|
| 788 |
+
config.askLog = bool(value)
|
| 789 |
+
|
| 790 |
+
@property
|
| 791 |
+
def dataDir(self):
|
| 792 |
+
"""데이터 저장 디렉토리 경로 조회."""
|
| 793 |
+
return config.dataDir
|
| 794 |
+
|
| 795 |
+
@dataDir.setter
|
| 796 |
+
def dataDir(self, value):
|
| 797 |
+
config.dataDir = str(value)
|
| 798 |
+
|
| 799 |
+
def __getattr__(self, name):
|
| 800 |
+
if name == "scan":
|
| 801 |
+
from dartlab.scan import Scan
|
| 802 |
+
|
| 803 |
+
instance = Scan()
|
| 804 |
+
setattr(self, name, instance)
|
| 805 |
+
return instance
|
| 806 |
+
if name == "analysis":
|
| 807 |
+
from dartlab.analysis.financial import Analysis
|
| 808 |
+
|
| 809 |
+
instance = Analysis()
|
| 810 |
+
setattr(self, name, instance)
|
| 811 |
+
return instance
|
| 812 |
+
if name == "credit":
|
| 813 |
+
from dartlab.credit import credit
|
| 814 |
+
|
| 815 |
+
setattr(self, name, credit)
|
| 816 |
+
return credit
|
| 817 |
+
if name == "quant":
|
| 818 |
+
from dartlab.quant import Quant
|
| 819 |
+
|
| 820 |
+
instance = Quant()
|
| 821 |
+
setattr(self, name, instance)
|
| 822 |
+
return instance
|
| 823 |
+
if name == "macro":
|
| 824 |
+
from dartlab.macro import Macro
|
| 825 |
+
|
| 826 |
+
instance = Macro()
|
| 827 |
+
setattr(self, name, instance)
|
| 828 |
+
return instance
|
| 829 |
+
if name == "topdown":
|
| 830 |
+
from dartlab.topdown import _TopdownEntry
|
| 831 |
+
|
| 832 |
+
instance = _TopdownEntry()
|
| 833 |
+
setattr(self, name, instance)
|
| 834 |
+
return instance
|
| 835 |
+
if name == "viz":
|
| 836 |
+
import dartlab.viz as _viz
|
| 837 |
+
|
| 838 |
+
setattr(self, name, _viz)
|
| 839 |
+
return _viz
|
| 840 |
+
if name == "chart":
|
| 841 |
+
# 하위호환: dartlab.chart → dartlab.viz
|
| 842 |
+
import dartlab.viz as _viz
|
| 843 |
+
|
| 844 |
+
setattr(self, name, _viz)
|
| 845 |
+
return _viz
|
| 846 |
+
if name == "table":
|
| 847 |
+
from dartlab.table import Table
|
| 848 |
+
|
| 849 |
+
instance = Table()
|
| 850 |
+
setattr(self, name, instance)
|
| 851 |
+
return instance
|
| 852 |
+
if name == "text":
|
| 853 |
+
import importlib
|
| 854 |
+
|
| 855 |
+
mod = importlib.import_module("dartlab.tools.text")
|
| 856 |
+
setattr(self, name, mod)
|
| 857 |
+
return mod
|
| 858 |
+
raise AttributeError(f"module 'dartlab' has no attribute {name!r}")
|
| 859 |
+
|
| 860 |
+
|
| 861 |
+
sys.modules[__name__].__class__ = _Module
|
| 862 |
+
|
| 863 |
+
# gather 모듈을 GatherEntry callable로 덮어쓰기
|
| 864 |
+
# (gather 서브모듈이 top-level import로 이미 로드되므로 __getattr__ lazy 불가)
|
| 865 |
+
from dartlab.gather.entry import GatherEntry as _GatherEntry
|
| 866 |
+
|
| 867 |
+
sys.modules[__name__].gather = _GatherEntry()
|
| 868 |
+
|
| 869 |
+
# topdown도 같은 문제 — 모듈 import가 __getattr__보다 우선이라 callable로 덮어쓴다
|
| 870 |
+
from dartlab.topdown import _TopdownEntry as _TopdownEntry
|
| 871 |
+
|
| 872 |
+
sys.modules[__name__].topdown = _TopdownEntry()
|
| 873 |
+
|
| 874 |
+
# scan/analysis/credit/quant — 어떤 import 체인이 모듈을 먼저 로드하면
|
| 875 |
+
# 모듈 클래스의 __getattr__이 동작 안 함 (CI에서 발견된 회귀).
|
| 876 |
+
# 해결: 모듈 자체를 callable로 패치 — 모듈 객체에 __call__을 직접 부여.
|
| 877 |
+
import types as _types
|
| 878 |
+
|
| 879 |
+
|
| 880 |
+
def _makeCallableModule(modName: str, instanceFactory):
|
| 881 |
+
"""이미 로드된 서브모듈에 __call__을 부여하여 callable하게 만든다.
|
| 882 |
+
|
| 883 |
+
서브모듈(rank, _helpers 등)도 그대로 import 가능. instance 메소드는 lazy 호출.
|
| 884 |
+
"""
|
| 885 |
+
mod = sys.modules.get(modName)
|
| 886 |
+
if mod is None:
|
| 887 |
+
return
|
| 888 |
+
|
| 889 |
+
class _CallableModule(_types.ModuleType):
|
| 890 |
+
_instance = None
|
| 891 |
+
|
| 892 |
+
def __call__(self, *args, **kwargs):
|
| 893 |
+
if self._instance is None:
|
| 894 |
+
self._instance = instanceFactory()
|
| 895 |
+
return self._instance(*args, **kwargs)
|
| 896 |
+
|
| 897 |
+
def __getattr__(self, name):
|
| 898 |
+
if self._instance is None:
|
| 899 |
+
self._instance = instanceFactory()
|
| 900 |
+
try:
|
| 901 |
+
return getattr(self._instance, name)
|
| 902 |
+
except AttributeError:
|
| 903 |
+
raise AttributeError(f"module '{modName}' has no attribute '{name}'") from None
|
| 904 |
+
|
| 905 |
+
mod.__class__ = _CallableModule
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
def _scanFactory():
|
| 909 |
+
from dartlab.scan import Scan
|
| 910 |
+
|
| 911 |
+
return Scan()
|
| 912 |
+
|
| 913 |
+
|
| 914 |
+
def _analysisFactory():
|
| 915 |
+
from dartlab.analysis.financial import Analysis
|
| 916 |
+
|
| 917 |
+
return Analysis()
|
| 918 |
+
|
| 919 |
+
|
| 920 |
+
def _quantFactory():
|
| 921 |
+
from dartlab.quant import Quant
|
| 922 |
+
|
| 923 |
+
return Quant()
|
| 924 |
+
|
| 925 |
+
|
| 926 |
+
# scan/analysis/quant — 모듈 자체를 callable로 변환
|
| 927 |
+
import dartlab.analysis.financial as _analysis_mod # noqa: F401
|
| 928 |
+
import dartlab.quant as _quant_mod # noqa: F401
|
| 929 |
+
import dartlab.scan as _scan_mod # noqa: F401
|
| 930 |
+
|
| 931 |
+
_makeCallableModule("dartlab.scan", _scanFactory)
|
| 932 |
+
_makeCallableModule("dartlab.analysis.financial", _analysisFactory)
|
| 933 |
+
_makeCallableModule("dartlab.quant", _quantFactory)
|
| 934 |
+
|
| 935 |
+
# credit은 함수형 (이미 callable)
|
| 936 |
+
from dartlab.credit import credit as _credit_callable
|
| 937 |
+
|
| 938 |
+
sys.modules[__name__].credit = _credit_callable
|
| 939 |
+
|
| 940 |
+
|
| 941 |
+
__all__ = [
|
| 942 |
+
"Company",
|
| 943 |
+
"Fred",
|
| 944 |
+
"OpenDart",
|
| 945 |
+
"OpenEdgar",
|
| 946 |
+
"config",
|
| 947 |
+
"ask",
|
| 948 |
+
"chat",
|
| 949 |
+
"setup",
|
| 950 |
+
"search",
|
| 951 |
+
"listing",
|
| 952 |
+
"collect",
|
| 953 |
+
"collectAll",
|
| 954 |
+
"downloadAll",
|
| 955 |
+
"scan",
|
| 956 |
+
"analysis",
|
| 957 |
+
"gather",
|
| 958 |
+
"quant",
|
| 959 |
+
"credit",
|
| 960 |
+
"macro",
|
| 961 |
+
"topdown",
|
| 962 |
+
"verbose",
|
| 963 |
+
"dataDir",
|
| 964 |
+
"codeToName",
|
| 965 |
+
"nameToCode",
|
| 966 |
+
"searchName",
|
| 967 |
+
"Review",
|
| 968 |
+
"SelectResult",
|
| 969 |
+
"ChartResult",
|
| 970 |
+
"capabilities",
|
| 971 |
+
]
|
| 972 |
+
|
| 973 |
+
|
| 974 |
+
def capabilities(key: str | None = None, *, search: str | None = None) -> dict | list[str]:
|
| 975 |
+
"""dartlab 전체 기능 카탈로그 조회.
|
| 976 |
+
|
| 977 |
+
Capabilities:
|
| 978 |
+
CAPABILITIES dict에서 부분 조회 가능.
|
| 979 |
+
key 없이 호출 시 전체 키 목록(summary 포함) 반환.
|
| 980 |
+
key 지정 시 해당 항목의 상세(guide, capabilities, seeAlso 등) 반환.
|
| 981 |
+
search 지정 시 자연어 질문 기반 관련 API 검색 (상위 10개).
|
| 982 |
+
|
| 983 |
+
Requires:
|
| 984 |
+
없음
|
| 985 |
+
|
| 986 |
+
AIContext:
|
| 987 |
+
AI가 "dartlab에 뭐가 있는지" 모를 때 탐색용.
|
| 988 |
+
capabilities() → 목차 확인 → capabilities("analysis") → 상세 확인 → execute_code.
|
| 989 |
+
capabilities(search="재무건전성") → 질문 관련 API 검색 → 코드 생성.
|
| 990 |
+
|
| 991 |
+
Guide:
|
| 992 |
+
- "dartlab 뭐 할 수 있어?" -> capabilities()
|
| 993 |
+
- "분석 기능 뭐 있어?" -> capabilities("analysis")
|
| 994 |
+
- "scan 어떻게 써?" -> capabilities("scan")
|
| 995 |
+
- "재무건전성 관련 API?" -> capabilities(search="재무건전성")
|
| 996 |
+
|
| 997 |
+
SeeAlso:
|
| 998 |
+
- ask: AI 질문 (capabilities로 기능 파악 후 ask로 분석)
|
| 999 |
+
- setup: AI provider 설정 (capabilities 확인 후 설정)
|
| 1000 |
+
|
| 1001 |
+
Args:
|
| 1002 |
+
key: 조회할 기능 키. None이면 전체 목차.
|
| 1003 |
+
search: 자연어 질문 기반 검색. key와 동시 사용 불가.
|
| 1004 |
+
|
| 1005 |
+
Returns:
|
| 1006 |
+
dict | list[str] — key 있으면 해당 항목 dict, 없으면 키+summary 목록.
|
| 1007 |
+
|
| 1008 |
+
Example::
|
| 1009 |
+
|
| 1010 |
+
dartlab.capabilities() # 전체 목차
|
| 1011 |
+
dartlab.capabilities("analysis") # analysis 상세 (guide, capabilities)
|
| 1012 |
+
dartlab.capabilities("Company.analysis") # Company.analysis 상세
|
| 1013 |
+
dartlab.capabilities("scan") # scan 상세
|
| 1014 |
+
dartlab.capabilities(search="재무건전성") # 질문 기반 검색 → 상위 10개
|
| 1015 |
+
"""
|
| 1016 |
+
if search is not None:
|
| 1017 |
+
from dartlab.core._capabilitySearch import searchCapabilities
|
| 1018 |
+
|
| 1019 |
+
results = searchCapabilities(search)
|
| 1020 |
+
return {key: entry for key, entry, _score in results}
|
| 1021 |
+
|
| 1022 |
+
from dartlab.core._generatedCapabilities import CAPABILITIES
|
| 1023 |
+
|
| 1024 |
+
if key is None:
|
| 1025 |
+
return {k: v.get("summary", "") for k, v in CAPABILITIES.items()}
|
| 1026 |
+
if key in CAPABILITIES:
|
| 1027 |
+
return CAPABILITIES[key]
|
| 1028 |
+
# 부분 매칭: "analysis" → "Company.analysis" 등도 포함
|
| 1029 |
+
matched = {k: v for k, v in CAPABILITIES.items() if key.lower() in k.lower()}
|
| 1030 |
+
if matched:
|
| 1031 |
+
return matched
|
| 1032 |
+
return {}
|
src/dartlab/__main__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Allow `python -m dartlab` to invoke the CLI."""
|
| 2 |
+
|
| 3 |
+
from dartlab.cli.main import main
|
| 4 |
+
|
| 5 |
+
raise SystemExit(main())
|
src/dartlab/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (37.5 kB). View file
|
|
|
src/dartlab/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (36 kB). View file
|
|
|
src/dartlab/__pycache__/__main__.cpython-312.pyc
ADDED
|
Binary file (328 Bytes). View file
|
|
|
src/dartlab/__pycache__/company.cpython-312.pyc
ADDED
|
Binary file (5.55 kB). View file
|
|
|
src/dartlab/__pycache__/company.cpython-313.pyc
ADDED
|
Binary file (5.49 kB). View file
|
|
|
src/dartlab/__pycache__/config.cpython-312.pyc
ADDED
|
Binary file (3.91 kB). View file
|
|
|
src/dartlab/__pycache__/config.cpython-313.pyc
ADDED
|
Binary file (3.99 kB). View file
|
|
|
src/dartlab/__pycache__/listing.cpython-312.pyc
ADDED
|
Binary file (9.07 kB). View file
|
|
|
src/dartlab/__pycache__/listing.cpython-313.pyc
ADDED
|
Binary file (7.71 kB). View file
|
|
|
src/dartlab/__pycache__/topdown.cpython-312.pyc
ADDED
|
Binary file (10.5 kB). View file
|
|
|
src/dartlab/__pycache__/topdown.cpython-313.pyc
ADDED
|
Binary file (10.6 kB). View file
|
|
|
src/dartlab/ai/STATUS.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AI Engine — Provider 현황 및 유지보수 체크리스트
|
| 2 |
+
|
| 3 |
+
## Provider 목록 (7개)
|
| 4 |
+
|
| 5 |
+
| Provider | 파일 | 인증 | 기본 모델 | 안정성 |
|
| 6 |
+
|----------|------|------|----------|--------|
|
| 7 |
+
| `openai` | openai_compat.py | API Key | gpt-4o | **안정** — 공식 SDK |
|
| 8 |
+
| `ollama` | ollama.py | 없음 (localhost) | llama3.1 | **안정** — 로컬 |
|
| 9 |
+
| `custom` | openai_compat.py | API Key | gpt-4o | **안정** — OpenAI 호환 |
|
| 10 |
+
| `chatgpt` | providers/__init__.py alias | `codex`로 정규화 | codex mirror | **호환용 alias** — 공개 surface 비노출 |
|
| 11 |
+
| `codex` | codex.py | CLI 세션 | CLI config 또는 gpt-4.1 | **공식 경로 우선** — Codex CLI 의존 |
|
| 12 |
+
| `oauth-codex` | oauthCodex.py | ChatGPT OAuth | gpt-5.4 | **공개 경로** — 비공식 backend API 의존 |
|
| 13 |
+
| `claude-code` | claude_code.py | CLI 세션 | sonnet | **보류중** — OAuth 지원 전 비공개 |
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## 현재 공개 경로
|
| 18 |
+
|
| 19 |
+
- ChatGPT 구독 계정 경로는 2개다.
|
| 20 |
+
- `codex`: Codex CLI 로그인 기반
|
| 21 |
+
- `oauth-codex`: ChatGPT OAuth 직접 연결 기반
|
| 22 |
+
- 공개 provider surface는 `codex`, `oauth-codex`, `openai`, `ollama`, `custom`만 유지한다.
|
| 23 |
+
- `claude` provider는 public surface에서 제거되었고 legacy/internal 코드로만 남아 있다.
|
| 24 |
+
- `chatgpt`는 기존 설정/호환성 때문에 내부 alias로만 남아 있으며 실제 구현은 `codex`로 정규화된다.
|
| 25 |
+
- `chatgpt-oauth`는 내부/호환 alias로만 남아 있으며 실제 구현은 `oauth-codex`로 정규화된다.
|
| 26 |
+
|
| 27 |
+
## Tool Runtime 기반
|
| 28 |
+
|
| 29 |
+
- 도구 등록/실행은 `tool_runtime.py`의 `ToolRuntime`으로 분리되기 시작했다.
|
| 30 |
+
- `tools_registry.py`는 현재 호환 래퍼 역할을 하며, 세션별/에이전트별 isolated runtime 생성이 가능하다.
|
| 31 |
+
- coding executor는 `coding_runtime.py`로 분리되기 시작했고, backend registry를 통해 관리한다.
|
| 32 |
+
- 표준 코드 작업 진입점은 `run_coding_task`이며 `run_codex_task`는 Codex compatibility alias로 유지한다.
|
| 33 |
+
- 다음 단계는 Codex 외 backend를 이 runtime 뒤에 추가하되, 공개 provider surface와는 분리하는 것이다.
|
| 34 |
+
|
| 35 |
+
## ChatGPT OAuth Provider — 핵심 리스크
|
| 36 |
+
|
| 37 |
+
### 왜 취약한가
|
| 38 |
+
|
| 39 |
+
`oauth-codex` provider는 **OpenAI 비공식 내부 API** (`chatgpt.com/backend-api/codex/responses`)를 사용한다.
|
| 40 |
+
공식 OpenAI API (`api.openai.com`)가 아니므로 **예고 없이 변경/차단될 수 있다**.
|
| 41 |
+
|
| 42 |
+
### 정기 체크 항목
|
| 43 |
+
|
| 44 |
+
**1. 엔드포인트 변경**
|
| 45 |
+
- 현재: `https://chatgpt.com/backend-api/codex/responses`
|
| 46 |
+
- 파일: [oauthCodex.py](providers/oauthCodex.py) `CODEX_API_BASE`, `CODEX_RESPONSES_PATH`
|
| 47 |
+
- OpenAI가 URL 경로를 변경하면 즉시 404/403 발생
|
| 48 |
+
- 확인법: `dartlab status` 실행 → chatgpt available 확인
|
| 49 |
+
|
| 50 |
+
**2. OAuth 인증 파라미터**
|
| 51 |
+
- Client ID: `app_EMoamEEZ73f0CkXaXp7hrann` (Codex CLI에서 추출)
|
| 52 |
+
- 파일: [oauthToken.py](../oauthToken.py) `CHATGPT_CLIENT_ID`
|
| 53 |
+
- OpenAI가 client_id를 갱신하거나 revoke하면 로그인 불가
|
| 54 |
+
- 확인법: OAuth 로그인 시도 → "invalid_client" 에러 여부
|
| 55 |
+
|
| 56 |
+
**3. SSE 이벤트 타입**
|
| 57 |
+
- 현재 파싱하는 타입 3개:
|
| 58 |
+
- `response.output_text.delta` — 텍스트 청크
|
| 59 |
+
- `response.content_part.delta` — 컨텐츠 청크
|
| 60 |
+
- `response.output_item.done` — 아이템 완료
|
| 61 |
+
- 파일: [oauthCodex.py](providers/oauthCodex.py) `stream()`, `_parse_sse_response()`
|
| 62 |
+
- OpenAI가 이벤트 스키마를 변경하면 응답이 빈 문자열로 돌아옴
|
| 63 |
+
- 확인법: 스트리밍 응답이 도착하는데 텍스트가 비어있으면 이벤트 타입 변경 의심
|
| 64 |
+
|
| 65 |
+
**4. 요청 헤더**
|
| 66 |
+
- `originator: codex_cli_rs` — Codex CLI 사칭
|
| 67 |
+
- `OpenAI-Beta: responses=experimental` — 실험 API 플래그
|
| 68 |
+
- 파일: [oauthCodex.py](providers/oauthCodex.py) `_build_headers()`
|
| 69 |
+
- 이 헤더 없이는 403 반환됨
|
| 70 |
+
- OpenAI가 originator 검증을 강화하면 차단됨
|
| 71 |
+
|
| 72 |
+
**5. 모델 목록**
|
| 73 |
+
- `AVAILABLE_MODELS` 리스트는 수동 관리
|
| 74 |
+
- 파일: [oauthCodex.py](providers/oauthCodex.py) `AVAILABLE_MODELS`
|
| 75 |
+
- 새 모델 출시/폐기 시 수동 업데이트 필요
|
| 76 |
+
- GPT-4 시리즈 (gpt-4, gpt-4-turbo 등)는 이미 제거됨
|
| 77 |
+
|
| 78 |
+
**6. 토큰 만료 정책**
|
| 79 |
+
- access_token: expires_in 기준 (현재 ~1시간)
|
| 80 |
+
- refresh_token: 만료 정책 불명 (OpenAI 미공개)
|
| 81 |
+
- 파일: [oauthToken.py](../oauthToken.py) `get_valid_token()`, `refresh_access_token()`
|
| 82 |
+
- refresh_token이 만료되면 재로그인 필요
|
| 83 |
+
- 확인법: 며칠 방치 후 요청 → 401 + refresh 실패 여부
|
| 84 |
+
|
| 85 |
+
### 브레이킹 체인지 대응 순서
|
| 86 |
+
|
| 87 |
+
1. 사용자가 "ChatGPT 안됨" 보고
|
| 88 |
+
2. `dartlab status` 로 available 확인
|
| 89 |
+
3. available=False → OAuth 로그인 재시도
|
| 90 |
+
4. 로그인 실패 → client_id 변경 확인 (opencode-openai-codex-auth 참조)
|
| 91 |
+
5. 로그인 성공인데 API 호출 실패 → 엔드포인트/헤더 변경 확인
|
| 92 |
+
6. API 호출 성공인데 응답 비어있음 → SSE 이벤트 타입 변경 확인
|
| 93 |
+
|
| 94 |
+
### 생태계 비교 — 누가 같은 API를 쓰는가
|
| 95 |
+
|
| 96 |
+
ChatGPT OAuth(`chatgpt.com/backend-api`)를 사용하는 프로젝트는 **전부 openai/codex CLI 역공학** 기반이다.
|
| 97 |
+
|
| 98 |
+
| 프로젝트 | 언어 | Client ID | 모델 목록 | refresh 실패 처리 | 토큰 저장 |
|
| 99 |
+
|----------|------|-----------|----------|------------------|----------|
|
| 100 |
+
| **openai/codex** (공식) | Rust | 하드코딩 | `/models` 동적 + 5분 캐시 | 4가지 분류 | 파일/키링/메모리 3중 |
|
| 101 |
+
| **opencode plugin** | TS | 동일 복제 | 사용자 설정 의존 | 단순 throw | 프레임워크 위임 |
|
| 102 |
+
| **ai-sdk-provider** | TS | 동일 복제 | 3개 하드코딩 | 단순 throw | codex auth.json 재사용 |
|
| 103 |
+
| **dartlab** (현재) | Python | 동일 복제 | 13개 하드코딩 | None 반환 | `~/.dartlab/oauth_token.json` |
|
| 104 |
+
|
| 105 |
+
**공통 특징:**
|
| 106 |
+
- Client ID `app_EMoamEEZ73f0CkXaXp7hrann` 전원 동일 (OpenAI public OAuth client)
|
| 107 |
+
- `originator: codex_cli_rs` 헤더 전원 동일
|
| 108 |
+
- OpenAI가 이 값들을 바꾸면 **전부 동시에 깨짐**
|
| 109 |
+
|
| 110 |
+
**openai/codex만의 차별점 (dartlab에 없는 것):**
|
| 111 |
+
1. Token Exchange — OAuth 토큰 → `api.openai.com` 호환 API Key 변환
|
| 112 |
+
2. Device Code Flow — headless 환경 (서버, SSH) 인증 지원
|
| 113 |
+
3. 모델 목록 동적 조회 — `/models` 엔드포인트 + 캐시 + bundled fallback
|
| 114 |
+
4. Keyring 저장 — OS 키체인 (macOS Keychain, Windows Credential Manager)
|
| 115 |
+
5. refresh 실패 4단계 분류 — expired / reused / revoked / other
|
| 116 |
+
6. WebSocket SSE 이중 지원
|
| 117 |
+
|
| 118 |
+
**참고: opencode와 oh-my-opencode(현 oh-my-openagent)는 ChatGPT OAuth를 사용하지 않는다.**
|
| 119 |
+
- opencode: GitHub Copilot API 인증 (다른 시스템)
|
| 120 |
+
- oh-my-openagent: MCP 서버 표준 OAuth 2.0 + PKCE (플러그인)
|
| 121 |
+
|
| 122 |
+
### 추적 대상 레포지토리
|
| 123 |
+
|
| 124 |
+
변경사항 감지를 위해 다음 레포를 추적한다.
|
| 125 |
+
|
| 126 |
+
| 레포 | 추적 이유 | Watch 대상 |
|
| 127 |
+
|------|----------|-----------|
|
| 128 |
+
| **openai/codex** | canonical 구현. Client ID, 엔드포인트, 헤더의 원본 | `codex-rs/core/src/auth.rs`, `model_provider_info.rs` |
|
| 129 |
+
| **numman-ali/opencode-openai-codex-auth** | 빠른 변경 반영 (TS라 읽기 쉬움) | `lib/auth/`, `lib/constants.ts` |
|
| 130 |
+
| **ben-vargas/ai-sdk-provider-chatgpt-oauth** | Vercel AI SDK 호환 참조 | `src/auth/` |
|
| 131 |
+
|
| 132 |
+
### 향후 개선 후보 (codex에서 가져올 수 있는 것)
|
| 133 |
+
|
| 134 |
+
1. **모델 목록 동적 조회** — `chatgpt.com/backend-api/codex/models` 호출 + JSON 캐시
|
| 135 |
+
2. **refresh 실패 분류** — expired/reused/revoked 구분하여 사용자에게 구체적 안내
|
| 136 |
+
3. **Token Exchange** — OAuth → API Key 변환으로 `api.openai.com` 호환 (듀얼 엔드포인트)
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
## Codex CLI Provider — 리스크
|
| 141 |
+
|
| 142 |
+
### 왜 취약한가
|
| 143 |
+
|
| 144 |
+
`codex` provider는 OpenAI `codex` CLI 바이너리를 subprocess로 호출한다.
|
| 145 |
+
CLI의 JSONL 출력 포맷이 변경되면 파싱 실패.
|
| 146 |
+
|
| 147 |
+
### 현재 동작
|
| 148 |
+
|
| 149 |
+
- `~/.codex/config.toml`의 model 설정을 우선 흡수
|
| 150 |
+
- `codex --help`, `codex exec --help`를 읽어 command/sandbox capability를 동적 감지
|
| 151 |
+
- 일반 질의는 `read-only`, 코드 수정 의도는 `workspace-write` sandbox 우선
|
| 152 |
+
- 별도 `run_codex_task` tool로 다른 provider에서도 Codex CLI 코드 작업 위임 가능
|
| 153 |
+
|
| 154 |
+
### 체크 항목
|
| 155 |
+
|
| 156 |
+
- CLI 출력 포맷: `item.completed.item.agent_message.text` 경로
|
| 157 |
+
- CLI 플래그: `--json`, `--sandbox ...`, `--model ...`, `--skip-git-repo-check`
|
| 158 |
+
- CLI 설치: `npm install -g @openai/codex`
|
| 159 |
+
- 파일: [codex.py](providers/codex.py)
|
| 160 |
+
|
| 161 |
+
---
|
| 162 |
+
|
| 163 |
+
## Claude Code CLI Provider — 보류중
|
| 164 |
+
|
| 165 |
+
### 현재 상태
|
| 166 |
+
|
| 167 |
+
VSCode 환경에서 `CLAUDECODE` 환경변수가 설정되어 SDK fallback 모드로 진입하지만,
|
| 168 |
+
SDK fallback에서 API key 추출(`claude auth status --json`)이 또 subprocess를 호출하는 순환 문제.
|
| 169 |
+
|
| 170 |
+
### 알려진 이슈
|
| 171 |
+
|
| 172 |
+
- 테스트 31/32 pass, `test_complete_timeout` 1개 fail
|
| 173 |
+
- VSCode 내에서 CLI 호출이 hang되는 케이스 (중첩 세션)
|
| 174 |
+
- `_probe_cli()` 8초 타임아웃으로 hang 감지 후 SDK 전환
|
| 175 |
+
- 파일: [claude_code.py](providers/claude_code.py)
|
| 176 |
+
|
| 177 |
+
---
|
| 178 |
+
|
| 179 |
+
## 안정 Provider — 특이사항 없음
|
| 180 |
+
|
| 181 |
+
### openai / custom (openai_compat.py)
|
| 182 |
+
- 공식 `openai` Python SDK 사용
|
| 183 |
+
- 버전 업데이트 시 SDK breaking change만 주의
|
| 184 |
+
- tool calling 지원
|
| 185 |
+
|
| 186 |
+
### claude (claude.py)
|
| 187 |
+
- 공식 `anthropic` Python SDK + OpenAI 프록시 이중 모드
|
| 188 |
+
- base_url 있으면 OpenAI 호환, 없으면 Anthropic 네이티브
|
| 189 |
+
|
| 190 |
+
### ollama (ollama.py)
|
| 191 |
+
- localhost:11434 OpenAI 호환 엔드포인트
|
| 192 |
+
- `preload()`, `get_installed_models()`, `complete_json()` 추가 기능
|
| 193 |
+
- tool calling 지원 (v0.3.0+)
|
| 194 |
+
|
| 195 |
+
---
|
| 196 |
+
|
| 197 |
+
## 마지막 점검일
|
| 198 |
+
|
| 199 |
+
- 2026-03-10: ChatGPT OAuth 정상 동작 확인 (gpt-5.4)
|
| 200 |
+
- 2026-03-10: Claude Code 보류 (VSCode 환경이슈)
|
src/dartlab/ai/__init__.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM 기반 적극적 분석가. dartlab을 도구로 삼아 주체적으로 분석하고, 사용자의 분석 학습을 돕는다."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dartlab.ai.types import LLMConfig, LLMResponse
|
| 6 |
+
from dartlab.core.ai import (
|
| 7 |
+
AI_ROLES,
|
| 8 |
+
DEFAULT_ROLE,
|
| 9 |
+
get_profile_manager,
|
| 10 |
+
get_provider_spec,
|
| 11 |
+
normalize_provider,
|
| 12 |
+
normalize_role,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def configure(
|
| 17 |
+
provider: str = "codex",
|
| 18 |
+
model: str | None = None,
|
| 19 |
+
api_key: str | None = None,
|
| 20 |
+
base_url: str | None = None,
|
| 21 |
+
role: str | None = None,
|
| 22 |
+
temperature: float = 0.3,
|
| 23 |
+
max_tokens: int = 4096,
|
| 24 |
+
system_prompt: str | None = None,
|
| 25 |
+
) -> None:
|
| 26 |
+
"""공통 AI profile을 갱신한다."""
|
| 27 |
+
normalized = normalize_provider(provider) or provider
|
| 28 |
+
if get_provider_spec(normalized) is None:
|
| 29 |
+
raise ValueError(f"지원하지 않는 provider: {provider}")
|
| 30 |
+
normalized_role = normalize_role(role)
|
| 31 |
+
if role is not None and normalized_role is None:
|
| 32 |
+
raise ValueError(f"지원하지 않는 role: {role}. 지원: {AI_ROLES}")
|
| 33 |
+
manager = get_profile_manager()
|
| 34 |
+
manager.update(
|
| 35 |
+
provider=normalized,
|
| 36 |
+
model=model,
|
| 37 |
+
role=normalized_role,
|
| 38 |
+
base_url=base_url,
|
| 39 |
+
temperature=temperature,
|
| 40 |
+
max_tokens=max_tokens,
|
| 41 |
+
system_prompt=system_prompt,
|
| 42 |
+
updated_by="code",
|
| 43 |
+
)
|
| 44 |
+
if api_key:
|
| 45 |
+
spec = get_provider_spec(normalized)
|
| 46 |
+
if spec and spec.auth_kind == "api_key":
|
| 47 |
+
manager.save_api_key(normalized, api_key, updated_by="code")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def get_config(provider: str | None = None, *, role: str | None = None) -> LLMConfig:
|
| 51 |
+
"""현재 글로벌 LLM 설정 반환."""
|
| 52 |
+
normalized_role = normalize_role(role)
|
| 53 |
+
resolved = get_profile_manager().resolve(provider=provider, role=normalized_role)
|
| 54 |
+
return LLMConfig(**resolved)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def status(provider: str | None = None, *, role: str | None = None) -> dict:
|
| 58 |
+
"""LLM 설정 및 provider 상태 확인."""
|
| 59 |
+
from dartlab.ai.providers import create_provider
|
| 60 |
+
|
| 61 |
+
normalized_role = normalize_role(role)
|
| 62 |
+
config = get_config(provider, role=normalized_role)
|
| 63 |
+
selected_provider = config.provider
|
| 64 |
+
llm = create_provider(config)
|
| 65 |
+
available = llm.check_available()
|
| 66 |
+
|
| 67 |
+
result = {
|
| 68 |
+
"provider": selected_provider,
|
| 69 |
+
"role": normalized_role or DEFAULT_ROLE,
|
| 70 |
+
"model": llm.resolved_model,
|
| 71 |
+
"available": available,
|
| 72 |
+
"defaultProvider": get_profile_manager().load().default_provider,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
if selected_provider == "ollama":
|
| 76 |
+
from dartlab.ai.providers.support.ollama_setup import detect_ollama
|
| 77 |
+
|
| 78 |
+
result["ollama"] = detect_ollama()
|
| 79 |
+
|
| 80 |
+
if selected_provider == "codex":
|
| 81 |
+
from dartlab.ai.providers.support.cli_setup import detect_codex
|
| 82 |
+
|
| 83 |
+
result["codex"] = detect_codex()
|
| 84 |
+
|
| 85 |
+
if selected_provider == "oauth-codex":
|
| 86 |
+
from dartlab.ai.providers.support import oauth_token as oauthToken
|
| 87 |
+
|
| 88 |
+
token_stored = False
|
| 89 |
+
try:
|
| 90 |
+
token_stored = oauthToken.load_token() is not None
|
| 91 |
+
except (OSError, ValueError):
|
| 92 |
+
token_stored = False
|
| 93 |
+
|
| 94 |
+
try:
|
| 95 |
+
authenticated = oauthToken.is_authenticated()
|
| 96 |
+
account_id = oauthToken.get_account_id() if authenticated else None
|
| 97 |
+
except (
|
| 98 |
+
AttributeError,
|
| 99 |
+
OSError,
|
| 100 |
+
RuntimeError,
|
| 101 |
+
ValueError,
|
| 102 |
+
oauthToken.TokenRefreshError,
|
| 103 |
+
):
|
| 104 |
+
authenticated = False
|
| 105 |
+
account_id = None
|
| 106 |
+
|
| 107 |
+
result["oauth-codex"] = {
|
| 108 |
+
"authenticated": authenticated,
|
| 109 |
+
"tokenStored": token_stored,
|
| 110 |
+
"accountId": account_id,
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
return result
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
from dartlab.ai.tools.plugin import get_plugin_registry, tool
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def templates(name: str | None = None):
|
| 120 |
+
"""분석 템플릿 목록 또는 특정 템플릿 내용 반환.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
name: None이면 전체 목록, 지정하면 해당 템플릿 내용.
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
list[dict] (목록) 또는 str (내용) 또는 None.
|
| 127 |
+
"""
|
| 128 |
+
from dartlab.ai.patterns import get_template, list_templates
|
| 129 |
+
|
| 130 |
+
if name is None:
|
| 131 |
+
return list_templates()
|
| 132 |
+
return get_template(name)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def saveTemplate(name: str, *, content: str | None = None, file: str | None = None):
|
| 136 |
+
"""사용자 분석 템플릿 저장. ~/.dartlab/templates/{name}.md
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
name: 템플릿 이름.
|
| 140 |
+
content: 마크다운 내용.
|
| 141 |
+
file: 파일 경로 (content 대신).
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
Path — 저장된 파일 경로.
|
| 145 |
+
"""
|
| 146 |
+
from dartlab.ai.patterns import save_template
|
| 147 |
+
|
| 148 |
+
return save_template(name, content=content, file=file)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
__all__ = [
|
| 152 |
+
"configure",
|
| 153 |
+
"get_config",
|
| 154 |
+
"status",
|
| 155 |
+
"LLMConfig",
|
| 156 |
+
"LLMResponse",
|
| 157 |
+
"tool",
|
| 158 |
+
"get_plugin_registry",
|
| 159 |
+
"templates",
|
| 160 |
+
"saveTemplate",
|
| 161 |
+
]
|
src/dartlab/ai/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (5.4 kB). View file
|
|
|
src/dartlab/ai/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (5.46 kB). View file
|
|
|
src/dartlab/ai/__pycache__/types.cpython-312.pyc
ADDED
|
Binary file (7.85 kB). View file
|
|
|
src/dartlab/ai/__pycache__/types.cpython-313.pyc
ADDED
|
Binary file (8.26 kB). View file
|
|
|
src/dartlab/ai/context/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ai/context — Context Engineering 레이어 (Phase 1).
|
| 2 |
+
|
| 3 |
+
Anthropic / DSPy / Manus 계열의 context engineering 패턴을 dartlab에 적용.
|
| 4 |
+
prompt engineering 단계의 고정 텍스트 블록 주입을 동적 컨텍스트 빌더로 대체.
|
| 5 |
+
|
| 6 |
+
핵심 사상:
|
| 7 |
+
- intent 분류 → selector 동적 호출 → ContextBundle 조립
|
| 8 |
+
- 토큰 예산 우선순위 트리밍
|
| 9 |
+
- TOON 인코딩으로 같은 데이터를 30~60% 적은 토큰으로 주입
|
| 10 |
+
- selfai 폐기 학습 적용: 자동 최적화 X. 모든 선택은 명시적 결정론.
|
| 11 |
+
|
| 12 |
+
진입점:
|
| 13 |
+
from dartlab.ai.context import ContextBuilder
|
| 14 |
+
bundle = ContextBuilder(question=q, company=c, provider="gemini").build()
|
| 15 |
+
|
| 16 |
+
레이아웃:
|
| 17 |
+
intent.py — 질문 → Intent (6막 + compare + concept)
|
| 18 |
+
selectors/ — Intent별 컨텍스트 선택자
|
| 19 |
+
budget.py — provider별 토큰 한도 + 우선순위 트리밍
|
| 20 |
+
encoder.py — TOON 인코딩
|
| 21 |
+
builder.py — ContextBuilder 메인 진입점
|
| 22 |
+
bundle.py — ContextBundle dataclass
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
from dartlab.ai.context.builder import ContextBuilder
|
| 28 |
+
from dartlab.ai.context.bundle import ContextBundle, ContextPart, PartPriority
|
| 29 |
+
from dartlab.ai.context.intent import Intent, classifyIntent
|
| 30 |
+
|
| 31 |
+
__all__ = [
|
| 32 |
+
"ContextBuilder",
|
| 33 |
+
"ContextBundle",
|
| 34 |
+
"ContextPart",
|
| 35 |
+
"Intent",
|
| 36 |
+
"PartPriority",
|
| 37 |
+
"classifyIntent",
|
| 38 |
+
]
|
src/dartlab/ai/context/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (1.53 kB). View file
|
|
|
src/dartlab/ai/context/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (1.58 kB). View file
|
|
|
src/dartlab/ai/context/__pycache__/aiview.cpython-312.pyc
ADDED
|
Binary file (14.8 kB). View file
|
|
|
src/dartlab/ai/context/__pycache__/budget.cpython-312.pyc
ADDED
|
Binary file (2.33 kB). View file
|
|
|
src/dartlab/ai/context/__pycache__/budget.cpython-313.pyc
ADDED
|
Binary file (2.36 kB). View file
|
|
|
src/dartlab/ai/context/__pycache__/builder.cpython-312.pyc
ADDED
|
Binary file (8.58 kB). View file
|
|
|
src/dartlab/ai/context/__pycache__/builder.cpython-313.pyc
ADDED
|
Binary file (8.69 kB). View file
|
|
|
src/dartlab/ai/context/__pycache__/bundle.cpython-312.pyc
ADDED
|
Binary file (3.33 kB). View file
|
|
|
src/dartlab/ai/context/__pycache__/bundle.cpython-313.pyc
ADDED
|
Binary file (3.47 kB). View file
|
|
|
src/dartlab/ai/context/__pycache__/encoder.cpython-312.pyc
ADDED
|
Binary file (6.01 kB). View file
|
|
|
src/dartlab/ai/context/__pycache__/encoder.cpython-313.pyc
ADDED
|
Binary file (6.16 kB). View file
|
|
|
src/dartlab/ai/context/__pycache__/intent.cpython-312.pyc
ADDED
|
Binary file (6.3 kB). View file
|
|
|
src/dartlab/ai/context/__pycache__/intent.cpython-313.pyc
ADDED
|
Binary file (6.49 kB). View file
|
|
|
src/dartlab/ai/context/__pycache__/playbook.cpython-312.pyc
ADDED
|
Binary file (7.17 kB). View file
|
|
|
src/dartlab/ai/context/__pycache__/playbook.cpython-313.pyc
ADDED
|
Binary file (7.37 kB). View file
|
|
|
src/dartlab/ai/context/aiview.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AI용 데이터 맥락 보강 — 엔진 반환값을 AI가 이해하기 좋은 형태로 변환.
|
| 2 |
+
|
| 3 |
+
모든 엔진의 dict/DataFrame을 자동 감지해서 맥락을 보강한다.
|
| 4 |
+
엔진별 수작업 0 — 구조(history + period + 숫자)만 보고 판단.
|
| 5 |
+
|
| 6 |
+
삽입 위치: _calcToContextPart()에서 encodeAuto() 직전.
|
| 7 |
+
calc result → **autoEnrich()** → encodeAuto(TOON) → ContextPart
|
| 8 |
+
|
| 9 |
+
근거:
|
| 10 |
+
- Kim et al. (시카고대, 2024): 재무제표 + 맥락 → 이익 방향 60% 정확도
|
| 11 |
+
- TAP4LLM (EMNLP 2024): 서브테이블 + 보강 → +7.93%p
|
| 12 |
+
- 실험 110 A/B: enriched가 raw 대비 코드 0라운드, 해석 명확성 압도
|
| 13 |
+
|
| 14 |
+
Examples::
|
| 15 |
+
|
| 16 |
+
# analysis calc 결과
|
| 17 |
+
raw = calcMarginTrend(company)
|
| 18 |
+
# {"history": [{"period": "2025", "operatingMargin": 13.07, ...}, ...]}
|
| 19 |
+
|
| 20 |
+
enriched = autoEnrich(raw)
|
| 21 |
+
# {"_summary": "영업이익률 13.1% · 전기비 +2.2pp(소폭 개선) · 5년평균 위 1.2pp",
|
| 22 |
+
# "history": [...], ← 원본 유지
|
| 23 |
+
# "_context": {"marginTrend": {"avg5y": 11.86, "yoy_pp": +2.19, ...}}}
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
from typing import Any
|
| 29 |
+
|
| 30 |
+
# ── 비율 필드 감지 키워드 ─────────────────────────────────
|
| 31 |
+
|
| 32 |
+
_RATIO_KEYWORDS = frozenset({
|
| 33 |
+
"margin", "ratio", "rate", "roe", "roa", "roic", "turnover",
|
| 34 |
+
"pct", "yield", "percent", "coverage", "leverage", "yoy",
|
| 35 |
+
"dso", "dio", "dpo", "ccc", "dol", "payout",
|
| 36 |
+
})
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _isRatioField(field: str, value: Any) -> bool:
|
| 40 |
+
"""비율 필드인지 판단 (이름 + 값 범위)."""
|
| 41 |
+
lower = field.lower()
|
| 42 |
+
if any(kw in lower for kw in _RATIO_KEYWORDS):
|
| 43 |
+
return True
|
| 44 |
+
# 값이 -200~500 범위이고 float이면 비율일 가능성
|
| 45 |
+
if isinstance(value, (int, float)) and -200 <= value <= 500:
|
| 46 |
+
# 금액은 보통 1e6 이상
|
| 47 |
+
return abs(value) < 1e6
|
| 48 |
+
return False
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ── 변화 판단 ─────────────────────────────────────────────
|
| 52 |
+
|
| 53 |
+
def _judgeChange(delta: float | None, isRatio: bool) -> str:
|
| 54 |
+
if delta is None:
|
| 55 |
+
return ""
|
| 56 |
+
t = 1.0 if isRatio else 5.0
|
| 57 |
+
if abs(delta) < t * 0.5:
|
| 58 |
+
return "보합"
|
| 59 |
+
elif abs(delta) < t * 2:
|
| 60 |
+
return "소폭 개선" if delta > 0 else "소폭 악화"
|
| 61 |
+
elif abs(delta) < t * 5:
|
| 62 |
+
return "개선" if delta > 0 else "악화"
|
| 63 |
+
else:
|
| 64 |
+
return "대폭 개선" if delta > 0 else "대폭 악화"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ── 한글 필드명 ───────────────────────────────────────────
|
| 68 |
+
|
| 69 |
+
_KOREAN = {
|
| 70 |
+
"operatingMargin": "영업이익률", "netMargin": "순이익률",
|
| 71 |
+
"grossMargin": "매출총이익률", "roe": "ROE", "roa": "ROA",
|
| 72 |
+
"roic": "ROIC", "revenue": "매출", "operatingIncome": "영업이익",
|
| 73 |
+
"netIncome": "순이익", "debtRatio": "부채비율",
|
| 74 |
+
"equityRatio": "자기자본비율", "ocf": "영업CF", "fcf": "FCF",
|
| 75 |
+
"capex": "CAPEX", "ccc": "CCC", "dso": "매출채권회수일",
|
| 76 |
+
"dio": "재고회전일", "dpo": "매입채무회전일",
|
| 77 |
+
"totalAssetTurnover": "총자산회전율", "revenueYoy": "매출YoY",
|
| 78 |
+
"operatingIncomeYoy": "영업이익YoY", "netIncomeYoy": "순이익YoY",
|
| 79 |
+
"costOfSalesRatio": "매출원가율", "sgaRatio": "판관비율",
|
| 80 |
+
"ocfToNi": "영업CF/순이익", "ocfMargin": "영업CF마진",
|
| 81 |
+
"interestCoverage": "이자보상배율", "pattern": "CF패턴",
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _koreanName(field: str) -> str:
|
| 86 |
+
return _KOREAN.get(field, field)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _formatNum(value: Any, field: str = "") -> str:
|
| 90 |
+
if value is None:
|
| 91 |
+
return "-"
|
| 92 |
+
if _isRatioField(field, value):
|
| 93 |
+
return f"{value:.1f}%"
|
| 94 |
+
if isinstance(value, (int, float)) and abs(value) > 1e12:
|
| 95 |
+
return f"{value / 1e12:.1f}조"
|
| 96 |
+
if isinstance(value, (int, float)) and abs(value) > 1e8:
|
| 97 |
+
return f"{value / 1e8:,.0f}억"
|
| 98 |
+
if isinstance(value, float):
|
| 99 |
+
return f"{value:,.1f}"
|
| 100 |
+
return str(value)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ── 핵심: autoEnrich ─────────────────────────────────────
|
| 104 |
+
|
| 105 |
+
def autoEnrich(data: dict | list | None, *, company: Any = None, calc_fn: Any = None) -> dict | list | None:
|
| 106 |
+
"""엔진 반환값을 자동 감지해서 AI용 맥락 보강.
|
| 107 |
+
|
| 108 |
+
3가지 패턴 자동 감지:
|
| 109 |
+
- dict with history[] → 시계열 보강 (5년 평균, YoY, 판단)
|
| 110 |
+
- list[dict] → history 배열로 취급
|
| 111 |
+
- flat dict → 핵심 필드 요약
|
| 112 |
+
|
| 113 |
+
엔진이 새 축을 추가해도 history + period + 숫자 패턴만 유지하면 자동 적용.
|
| 114 |
+
"""
|
| 115 |
+
if data is None:
|
| 116 |
+
return None
|
| 117 |
+
|
| 118 |
+
# list[dict] — history 배열 직접 전달된 경우
|
| 119 |
+
if isinstance(data, list) and data and isinstance(data[0], dict):
|
| 120 |
+
return _enrichHistory(data)
|
| 121 |
+
|
| 122 |
+
if not isinstance(data, dict):
|
| 123 |
+
return data
|
| 124 |
+
|
| 125 |
+
# 독스트링 스키마 추출 (있으면 확정 기반, 없으면 자동 감지 fallback)
|
| 126 |
+
_schema = parseReturnsSchema(calc_fn) if callable(calc_fn) else None
|
| 127 |
+
|
| 128 |
+
# 최상위에 바로 history[]가 있는 경우 (개별 calc 결과: {"history": [...], "displayHints": {...}})
|
| 129 |
+
if "history" in data and isinstance(data["history"], list) and data["history"]:
|
| 130 |
+
summary = _summarizeHistory(data["history"], "data", schema=_schema)
|
| 131 |
+
if summary:
|
| 132 |
+
enriched = dict(data)
|
| 133 |
+
enriched["_summary"] = summary
|
| 134 |
+
return enriched
|
| 135 |
+
return data
|
| 136 |
+
|
| 137 |
+
# 중첩 history — 전체 analysis dict: {"marginTrend": {"history": [...]}, ...}
|
| 138 |
+
tsKeys = [
|
| 139 |
+
k for k, v in data.items()
|
| 140 |
+
if isinstance(v, dict)
|
| 141 |
+
and "history" in v
|
| 142 |
+
and isinstance(v["history"], list)
|
| 143 |
+
and v["history"]
|
| 144 |
+
]
|
| 145 |
+
if tsKeys:
|
| 146 |
+
return _enrichDictWithHistory(data, tsKeys, company=company)
|
| 147 |
+
|
| 148 |
+
# flat dict (숫자 키가 있는) — credit, quant
|
| 149 |
+
numericKeys = [k for k, v in data.items() if isinstance(v, (int, float))]
|
| 150 |
+
if numericKeys:
|
| 151 |
+
return _enrichFlat(data)
|
| 152 |
+
|
| 153 |
+
return data
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ── 패턴 1: dict with history[] ──────────────────────────
|
| 157 |
+
|
| 158 |
+
def _enrichDictWithHistory(
|
| 159 |
+
data: dict, tsKeys: list[str], *, company: Any = None,
|
| 160 |
+
) -> dict:
|
| 161 |
+
"""history[] 시계열을 자동 보강. 모든 analysis 축에 범용 적용."""
|
| 162 |
+
summaries: list[str] = []
|
| 163 |
+
|
| 164 |
+
for tsKey in tsKeys:
|
| 165 |
+
hist = data[tsKey]["history"]
|
| 166 |
+
if not hist:
|
| 167 |
+
continue
|
| 168 |
+
summary = _summarizeHistory(hist, tsKey)
|
| 169 |
+
if summary:
|
| 170 |
+
summaries.append(summary)
|
| 171 |
+
|
| 172 |
+
# _summary 필드에 전체 요약 삽입 (원본 data에 추가)
|
| 173 |
+
enriched = dict(data)
|
| 174 |
+
if summaries:
|
| 175 |
+
enriched["_summary"] = " / ".join(summaries[:4])
|
| 176 |
+
|
| 177 |
+
return enriched
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _enrichHistory(rows: list[dict]) -> dict:
|
| 181 |
+
"""history 배열 직접 전달 시."""
|
| 182 |
+
summary = _summarizeHistory(rows, "data")
|
| 183 |
+
return {"_summary": summary, "history": rows} if summary else {"history": rows}
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _summarizeHistory(hist: list[dict], label: str, *, schema: dict | None = None) -> str:
|
| 187 |
+
"""history 배열에서 비율 필드를 자동 감지, 핵심 3개의 요약문 생성."""
|
| 188 |
+
if not hist or len(hist) < 2:
|
| 189 |
+
return ""
|
| 190 |
+
|
| 191 |
+
latest = hist[0]
|
| 192 |
+
prev = hist[1]
|
| 193 |
+
|
| 194 |
+
# 모든 숫자 필드 감지
|
| 195 |
+
numericFields = [
|
| 196 |
+
k for k, v in latest.items()
|
| 197 |
+
if isinstance(v, (int, float)) and k != "period"
|
| 198 |
+
]
|
| 199 |
+
if not numericFields:
|
| 200 |
+
return ""
|
| 201 |
+
|
| 202 |
+
fieldInfos: list[dict] = []
|
| 203 |
+
for field in numericFields:
|
| 204 |
+
values = [h.get(field) for h in hist[:5] if h.get(field) is not None]
|
| 205 |
+
if not values:
|
| 206 |
+
continue
|
| 207 |
+
|
| 208 |
+
current = values[0]
|
| 209 |
+
# 독스트링 스키마 우선, 없으면 자동 감지 fallback
|
| 210 |
+
schemaResult = isRatioBySchema(field, schema) if schema else None
|
| 211 |
+
isRatio = schemaResult if schemaResult is not None else _isRatioField(field, current)
|
| 212 |
+
prevVal = values[1] if len(values) >= 2 else None
|
| 213 |
+
avg5 = sum(values) / len(values)
|
| 214 |
+
|
| 215 |
+
# YoY — 비율은 pp 차이, 금액은 변화율(%)
|
| 216 |
+
yoy = None
|
| 217 |
+
if prevVal is not None:
|
| 218 |
+
if isRatio:
|
| 219 |
+
yoy = current - prevVal
|
| 220 |
+
elif prevVal != 0:
|
| 221 |
+
yoy = (current - prevVal) / abs(prevVal) * 100
|
| 222 |
+
|
| 223 |
+
# 5년 평균 대비
|
| 224 |
+
vsAvg = None
|
| 225 |
+
if isRatio:
|
| 226 |
+
vsAvg = current - avg5
|
| 227 |
+
elif avg5 != 0:
|
| 228 |
+
vsAvg = (current - avg5) / abs(avg5) * 100
|
| 229 |
+
|
| 230 |
+
fieldInfos.append({
|
| 231 |
+
"field": field,
|
| 232 |
+
"current": current,
|
| 233 |
+
"isRatio": isRatio,
|
| 234 |
+
"yoy": round(yoy, 2) if yoy is not None else None,
|
| 235 |
+
"vsAvg": round(vsAvg, 2) if vsAvg is not None else None,
|
| 236 |
+
"judgment": _judgeChange(yoy, isRatio),
|
| 237 |
+
"avg5": round(avg5, 2),
|
| 238 |
+
})
|
| 239 |
+
|
| 240 |
+
# 비율 필드 우선, 변화가 큰 순
|
| 241 |
+
ratios = [f for f in fieldInfos if f["isRatio"]]
|
| 242 |
+
amounts = [f for f in fieldInfos if not f["isRatio"]]
|
| 243 |
+
picked = sorted(ratios, key=lambda x: abs(x["yoy"] or 0), reverse=True)[:3]
|
| 244 |
+
if not picked:
|
| 245 |
+
picked = sorted(amounts, key=lambda x: abs(x["yoy"] or 0), reverse=True)[:2]
|
| 246 |
+
|
| 247 |
+
# 요약 문장 생성
|
| 248 |
+
parts = []
|
| 249 |
+
for fi in picked:
|
| 250 |
+
unit = "pp" if fi["isRatio"] else "%"
|
| 251 |
+
segs = [f"{_koreanName(fi['field'])} {_formatNum(fi['current'], fi['field'])}"]
|
| 252 |
+
if fi["yoy"] is not None:
|
| 253 |
+
segs.append(f"전기비 {fi['yoy']:+.1f}{unit}({fi['judgment']})")
|
| 254 |
+
if fi["vsAvg"] is not None:
|
| 255 |
+
pos = "위" if fi["vsAvg"] > 0 else "아래"
|
| 256 |
+
segs.append(f"5년평균 {pos} {abs(fi['vsAvg']):.1f}{unit}")
|
| 257 |
+
parts.append(" · ".join(segs))
|
| 258 |
+
|
| 259 |
+
return f"[{label}] {' | '.join(parts)}" if parts else ""
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
# ── 패턴 2: flat dict ────────────────────────────────────
|
| 263 |
+
|
| 264 |
+
def _enrichFlat(data: dict) -> dict:
|
| 265 |
+
"""flat dict 보강 — credit, quant 결과."""
|
| 266 |
+
summaryParts = []
|
| 267 |
+
for k, v in data.items():
|
| 268 |
+
if isinstance(v, str) and len(v) < 50:
|
| 269 |
+
summaryParts.append(f"{_koreanName(k)}={v}")
|
| 270 |
+
elif isinstance(v, (int, float)):
|
| 271 |
+
summaryParts.append(f"{_koreanName(k)}={_formatNum(v, k)}")
|
| 272 |
+
if not summaryParts:
|
| 273 |
+
return data
|
| 274 |
+
enriched = dict(data)
|
| 275 |
+
enriched["_summary"] = " · ".join(summaryParts[:6])
|
| 276 |
+
return enriched
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
# ── 독스트링 기반 스키마 파싱 ──────────────────────────────
|
| 280 |
+
|
| 281 |
+
import re
|
| 282 |
+
from functools import lru_cache
|
| 283 |
+
from typing import Callable
|
| 284 |
+
|
| 285 |
+
_UNIT_PATTERN = re.compile(r"\((%|원|일|배|점)\)")
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
@lru_cache(maxsize=256)
|
| 289 |
+
def parseReturnsSchema(fn: Callable) -> dict[str, dict] | None:
|
| 290 |
+
"""함수의 docstring에서 Returns 스키마를 파싱.
|
| 291 |
+
|
| 292 |
+
Returns dict 예시::
|
| 293 |
+
|
| 294 |
+
{
|
| 295 |
+
"operatingMargin": {"type": "float", "unit": "%", "desc": "영업이익률"},
|
| 296 |
+
"revenue": {"type": "float", "unit": "원", "desc": "매출"},
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
독스트링에 Returns 섹션이 없으면 None.
|
| 300 |
+
"""
|
| 301 |
+
doc = getattr(fn, "__doc__", None)
|
| 302 |
+
if not doc:
|
| 303 |
+
return None
|
| 304 |
+
|
| 305 |
+
# Returns 섹션 추출
|
| 306 |
+
lines = doc.split("\n")
|
| 307 |
+
inReturns = False
|
| 308 |
+
returnsLines: list[str] = []
|
| 309 |
+
for line in lines:
|
| 310 |
+
stripped = line.strip()
|
| 311 |
+
if stripped == "Returns":
|
| 312 |
+
inReturns = True
|
| 313 |
+
continue
|
| 314 |
+
if inReturns and stripped.startswith("-------"):
|
| 315 |
+
continue
|
| 316 |
+
if inReturns:
|
| 317 |
+
# 다른 섹션 시작 감지 (Raises, Examples, Notes, Guide, See Also)
|
| 318 |
+
if stripped and not stripped[0].isspace() and stripped[0] != " " and ":" not in stripped and stripped in (
|
| 319 |
+
"Raises", "Examples", "Notes", "Guide", "See Also", "Parameters",
|
| 320 |
+
):
|
| 321 |
+
break
|
| 322 |
+
# 빈 줄 다음에 섹션 헤더가 올 수 있음
|
| 323 |
+
if stripped and re.match(r"^[A-Z][a-z]", stripped) and not any(c in stripped for c in (":", "—", "-")):
|
| 324 |
+
break
|
| 325 |
+
returnsLines.append(line)
|
| 326 |
+
|
| 327 |
+
if not returnsLines:
|
| 328 |
+
return None
|
| 329 |
+
|
| 330 |
+
# 키 : 타입 — 설명 (단위) 패턴 파싱
|
| 331 |
+
schema: dict[str, dict] = {}
|
| 332 |
+
for line in returnsLines:
|
| 333 |
+
# " operatingMargin : float — 영업이익률 (%)" 패턴
|
| 334 |
+
m = re.match(r"\s+(\w+)\s*:\s*(\w[\w\[\]]*)\s*[—-]\s*(.+)", line)
|
| 335 |
+
if not m:
|
| 336 |
+
continue
|
| 337 |
+
key, typ, desc = m.group(1), m.group(2), m.group(3).strip()
|
| 338 |
+
|
| 339 |
+
# 단위 추출
|
| 340 |
+
unit_match = _UNIT_PATTERN.search(desc)
|
| 341 |
+
unit = unit_match.group(1) if unit_match else None
|
| 342 |
+
|
| 343 |
+
schema[key] = {"type": typ, "desc": desc, "unit": unit}
|
| 344 |
+
|
| 345 |
+
return schema if schema else None
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def isRatioBySchema(field: str, schema: dict[str, dict] | None) -> bool | None:
|
| 349 |
+
"""스키마에서 필드의 단위를 확인해서 비율인지 확정.
|
| 350 |
+
|
| 351 |
+
Returns True(비율)/False(금액)/None(스키마에 없음 → fallback 필요).
|
| 352 |
+
"""
|
| 353 |
+
if schema is None or field not in schema:
|
| 354 |
+
return None
|
| 355 |
+
unit = schema[field].get("unit")
|
| 356 |
+
if unit == "%":
|
| 357 |
+
return True
|
| 358 |
+
if unit in ("원", "일"):
|
| 359 |
+
return False
|
| 360 |
+
return None
|
src/dartlab/ai/context/budget.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""토큰 예산 + 우선순위 트리밍.
|
| 2 |
+
|
| 3 |
+
provider별 컨텍스트 한도를 기준으로 ContextPart 리스트를 정리.
|
| 4 |
+
CRITICAL은 절대 제거하지 않고, OPTIONAL부터 자른다.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from dartlab.ai.context.bundle import ContextPart, PartPriority
|
| 10 |
+
|
| 11 |
+
# provider별 안전 컨텍스트 예산 (system + user 합계 기준 권장치)
|
| 12 |
+
# 보수적으로 설정 — 응답 토큰 여유 확보.
|
| 13 |
+
_PROVIDER_BUDGETS: dict[str, int] = {
|
| 14 |
+
"gemini": 30000,
|
| 15 |
+
"openai": 12000,
|
| 16 |
+
"groq": 6000,
|
| 17 |
+
"cerebras": 6000,
|
| 18 |
+
"mistral": 8000,
|
| 19 |
+
"ollama": 4000,
|
| 20 |
+
"claude": 30000,
|
| 21 |
+
"claude_code": 30000,
|
| 22 |
+
"codex": 12000,
|
| 23 |
+
"oauth_codex": 12000,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
_DEFAULT_BUDGET = 8000
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def budgetFor(provider: str | None) -> int:
|
| 30 |
+
"""provider 이름 → 권장 컨텍스트 예산 토큰."""
|
| 31 |
+
if not provider:
|
| 32 |
+
return _DEFAULT_BUDGET
|
| 33 |
+
return _PROVIDER_BUDGETS.get(provider.lower(), _DEFAULT_BUDGET)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def trim(
|
| 37 |
+
parts: list[ContextPart],
|
| 38 |
+
*,
|
| 39 |
+
budgetTokens: int,
|
| 40 |
+
) -> tuple[list[ContextPart], list[str]]:
|
| 41 |
+
"""우선순위 기반 트리밍.
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
(kept, droppedKeys)
|
| 45 |
+
- kept: 예산 안에 들어간 parts (priority 내림차순)
|
| 46 |
+
- droppedKeys: 잘려나간 part key 리스트
|
| 47 |
+
"""
|
| 48 |
+
# priority 내림차순 정렬 (높은 우선순위 먼저)
|
| 49 |
+
sorted_parts = sorted(parts, key=lambda p: p.priority, reverse=True)
|
| 50 |
+
|
| 51 |
+
kept: list[ContextPart] = []
|
| 52 |
+
dropped: list[str] = []
|
| 53 |
+
used = 0
|
| 54 |
+
|
| 55 |
+
for part in sorted_parts:
|
| 56 |
+
# CRITICAL은 예산 초과해도 무조건 포함 (안전장치)
|
| 57 |
+
if part.priority == PartPriority.CRITICAL:
|
| 58 |
+
kept.append(part)
|
| 59 |
+
used += part.estimatedTokens
|
| 60 |
+
continue
|
| 61 |
+
|
| 62 |
+
if used + part.estimatedTokens <= budgetTokens:
|
| 63 |
+
kept.append(part)
|
| 64 |
+
used += part.estimatedTokens
|
| 65 |
+
else:
|
| 66 |
+
dropped.append(part.key)
|
| 67 |
+
|
| 68 |
+
return kept, dropped
|
src/dartlab/ai/context/builder.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ContextBuilder — Phase 1 메인 진입점.
|
| 2 |
+
|
| 3 |
+
질문 + Company + provider → ContextBundle.
|
| 4 |
+
|
| 5 |
+
설계:
|
| 6 |
+
1. classifyIntent() 로 질문 의도 파악
|
| 7 |
+
2. legacy selectors 호출 → 기존 5개 pre-grounding을 ContextPart로
|
| 8 |
+
3. (Phase 1.5) intent별 act selector 호출
|
| 9 |
+
4. budget.trim() 으로 토큰 예산 적용
|
| 10 |
+
5. ContextBundle 반환
|
| 11 |
+
|
| 12 |
+
Phase 1 보장: 기존 _analyze_inner 동작과 동일 (legacy selectors만 사용).
|
| 13 |
+
DARTLAB_CONTEXT_V2=1 환경 변수로 활성화.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from dataclasses import dataclass
|
| 19 |
+
from typing import Any
|
| 20 |
+
|
| 21 |
+
from dartlab.ai.context.budget import budgetFor, trim
|
| 22 |
+
from dartlab.ai.context.bundle import ContextBundle, ContextPart
|
| 23 |
+
from dartlab.ai.context.intent import Intent, classifyIntent
|
| 24 |
+
from dartlab.ai.context.selectors import (
|
| 25 |
+
selectCompanySearch,
|
| 26 |
+
selectDisclosureBrief,
|
| 27 |
+
selectExternalSearch,
|
| 28 |
+
selectInsightHints,
|
| 29 |
+
selectMemoryHints,
|
| 30 |
+
selectPlaybookBullets,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class ContextBuilder:
|
| 36 |
+
"""질문 → ContextBundle 빌더.
|
| 37 |
+
|
| 38 |
+
사용::
|
| 39 |
+
|
| 40 |
+
bundle = ContextBuilder(
|
| 41 |
+
question="삼성전자 마진 추세는?",
|
| 42 |
+
company=c,
|
| 43 |
+
provider="gemini",
|
| 44 |
+
).build()
|
| 45 |
+
|
| 46 |
+
userParts = bundle.toUserParts() # 기존 _analyze_inner 호환
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
question: str
|
| 50 |
+
company: Any | None = None
|
| 51 |
+
provider: str | None = None
|
| 52 |
+
budgetTokens: int | None = None # None → provider별 기본값
|
| 53 |
+
|
| 54 |
+
def build(self) -> ContextBundle:
|
| 55 |
+
if not self.question or not self.question.strip():
|
| 56 |
+
return ContextBundle(intent=Intent.ACT_ALL.value)
|
| 57 |
+
|
| 58 |
+
# Company 메타 추출
|
| 59 |
+
stockCode = (
|
| 60 |
+
getattr(self.company, "stockCode", None) or getattr(self.company, "ticker", None)
|
| 61 |
+
if self.company is not None
|
| 62 |
+
else None
|
| 63 |
+
)
|
| 64 |
+
corpName = getattr(self.company, "corpName", None) if self.company is not None else None
|
| 65 |
+
|
| 66 |
+
# 1. Intent 분류
|
| 67 |
+
intentResult = classifyIntent(self.question, hasCompany=self.company is not None)
|
| 68 |
+
|
| 69 |
+
# 2. selector 호출 (legacy + ACE playbook + analysis calc)
|
| 70 |
+
parts: list[ContextPart] = []
|
| 71 |
+
parts.extend(selectCompanySearch(self.question, self.company))
|
| 72 |
+
parts.extend(selectDisclosureBrief(stockCode))
|
| 73 |
+
parts.extend(selectExternalSearch(self.question, stockCode, corpName))
|
| 74 |
+
parts.extend(selectMemoryHints(stockCode))
|
| 75 |
+
parts.extend(selectInsightHints(stockCode, self.company))
|
| 76 |
+
# ACE evolving playbook — intent별 학습된 분석 지침 주입
|
| 77 |
+
parts.extend(selectPlaybookBullets(intentResult.intent.value, self.company))
|
| 78 |
+
# intent → analysis calc selector 라우팅
|
| 79 |
+
parts.extend(self._selectCalcForIntent(intentResult.intent))
|
| 80 |
+
# Phase 2: 인과 질문("왜") → graph traversal
|
| 81 |
+
try:
|
| 82 |
+
from dartlab.ai.context.selectors.graph import selectGraphCauses
|
| 83 |
+
|
| 84 |
+
parts.extend(selectGraphCauses(self.question, self.company))
|
| 85 |
+
except ImportError:
|
| 86 |
+
pass
|
| 87 |
+
|
| 88 |
+
# 3. 분석 대상 라벨 (CRITICAL — 항상 포함)
|
| 89 |
+
if corpName and stockCode:
|
| 90 |
+
from dartlab.ai.context.bundle import PartPriority
|
| 91 |
+
from dartlab.ai.context.encoder import estimateTokens
|
| 92 |
+
|
| 93 |
+
label = f"분석 대상: {corpName} (종목코드: {stockCode})"
|
| 94 |
+
parts.insert(
|
| 95 |
+
0,
|
| 96 |
+
ContextPart(
|
| 97 |
+
key="company.label",
|
| 98 |
+
text=label,
|
| 99 |
+
priority=PartPriority.CRITICAL,
|
| 100 |
+
estimatedTokens=estimateTokens(label),
|
| 101 |
+
source="company.meta",
|
| 102 |
+
),
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
# 4. concept selector (Company 불필요)
|
| 106 |
+
if intentResult.intent == Intent.CONCEPT:
|
| 107 |
+
try:
|
| 108 |
+
from dartlab.ai.context.selectors.concept import selectConcept
|
| 109 |
+
|
| 110 |
+
parts.extend(selectConcept(self.question))
|
| 111 |
+
except ImportError:
|
| 112 |
+
pass
|
| 113 |
+
|
| 114 |
+
# 5. 예산 트리밍
|
| 115 |
+
budget = self.budgetTokens or budgetFor(self.provider)
|
| 116 |
+
kept, dropped = trim(parts, budgetTokens=budget)
|
| 117 |
+
|
| 118 |
+
totalTokens = sum(p.estimatedTokens for p in kept)
|
| 119 |
+
return ContextBundle(
|
| 120 |
+
parts=kept,
|
| 121 |
+
intent=intentResult.intent.value,
|
| 122 |
+
totalTokens=totalTokens,
|
| 123 |
+
droppedKeys=dropped,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
def _selectCalcForIntent(self, intent: Intent) -> list[ContextPart]:
|
| 127 |
+
"""intent → analysis calc selector 라우팅.
|
| 128 |
+
|
| 129 |
+
Company 없으면 빈 리스트. calc 실패 시 빈 리스트 (graceful).
|
| 130 |
+
ACT_ALL → 핵심 3개(margin + cashflow + distress)만.
|
| 131 |
+
"""
|
| 132 |
+
if self.company is None:
|
| 133 |
+
return []
|
| 134 |
+
try:
|
| 135 |
+
_ROUTER = {
|
| 136 |
+
Intent.ACT1_BUSINESS: "dartlab.ai.context.selectors.act1",
|
| 137 |
+
Intent.ACT2_PROFIT: "dartlab.ai.context.selectors.act2",
|
| 138 |
+
Intent.ACT3_CASH: "dartlab.ai.context.selectors.act3",
|
| 139 |
+
Intent.ACT4_STABILITY: "dartlab.ai.context.selectors.act4",
|
| 140 |
+
Intent.ACT5_CAPITAL: "dartlab.ai.context.selectors.act5",
|
| 141 |
+
Intent.ACT6_OUTLOOK: "dartlab.ai.context.selectors.act6",
|
| 142 |
+
Intent.COMPARE: "dartlab.ai.context.selectors.compare",
|
| 143 |
+
}
|
| 144 |
+
if intent == Intent.ACT_ALL:
|
| 145 |
+
# 핵심 3축만 주입 (마진 + 현금흐름 + 안정성)
|
| 146 |
+
parts: list[ContextPart] = []
|
| 147 |
+
try:
|
| 148 |
+
from dartlab.ai.context.selectors.act2 import selectAct2
|
| 149 |
+
|
| 150 |
+
parts.extend(selectAct2(self.company))
|
| 151 |
+
except (ImportError, Exception):
|
| 152 |
+
pass
|
| 153 |
+
try:
|
| 154 |
+
from dartlab.ai.context.selectors.act3 import selectAct3
|
| 155 |
+
|
| 156 |
+
parts.extend(selectAct3(self.company))
|
| 157 |
+
except (ImportError, Exception):
|
| 158 |
+
pass
|
| 159 |
+
try:
|
| 160 |
+
from dartlab.ai.context.selectors.act4 import selectAct4
|
| 161 |
+
|
| 162 |
+
parts.extend(selectAct4(self.company))
|
| 163 |
+
except (ImportError, Exception):
|
| 164 |
+
pass
|
| 165 |
+
return parts
|
| 166 |
+
|
| 167 |
+
module_path = _ROUTER.get(intent)
|
| 168 |
+
if not module_path:
|
| 169 |
+
return []
|
| 170 |
+
|
| 171 |
+
import importlib
|
| 172 |
+
|
| 173 |
+
mod = importlib.import_module(module_path)
|
| 174 |
+
# 함수 이름 규칙: selectAct{N}, selectCompare
|
| 175 |
+
fn_name = (
|
| 176 |
+
f"select{intent.value.split('_')[0].title()}"
|
| 177 |
+
if "_" in intent.value
|
| 178 |
+
else f"select{intent.value.title()}"
|
| 179 |
+
)
|
| 180 |
+
# 실제 함수명 매핑
|
| 181 |
+
_FN_NAMES = {
|
| 182 |
+
Intent.ACT1_BUSINESS: "selectAct1",
|
| 183 |
+
Intent.ACT2_PROFIT: "selectAct2",
|
| 184 |
+
Intent.ACT3_CASH: "selectAct3",
|
| 185 |
+
Intent.ACT4_STABILITY: "selectAct4",
|
| 186 |
+
Intent.ACT5_CAPITAL: "selectAct5",
|
| 187 |
+
Intent.ACT6_OUTLOOK: "selectAct6",
|
| 188 |
+
Intent.COMPARE: "selectCompare",
|
| 189 |
+
}
|
| 190 |
+
fn = getattr(mod, _FN_NAMES[intent])
|
| 191 |
+
if intent == Intent.COMPARE:
|
| 192 |
+
return fn(self.company)
|
| 193 |
+
return fn(self.company)
|
| 194 |
+
except (ImportError, AttributeError, KeyError, Exception):
|
| 195 |
+
return []
|
src/dartlab/ai/context/bundle.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ContextBundle — ContextBuilder 출력 자료구조.
|
| 2 |
+
|
| 3 |
+
builder는 selector들이 만든 ContextPart 리스트를 priority + budget에 따라
|
| 4 |
+
트리밍하여 최종 ContextBundle을 만든다. 소비자(_analyze_inner)는
|
| 5 |
+
bundle.toUserParts() 로 기존 userParts 리스트와 호환되는 형태를 얻는다.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from enum import IntEnum
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class PartPriority(IntEnum):
|
| 15 |
+
"""우선순위 — 낮을수록 먼저 트리밍된다.
|
| 16 |
+
|
| 17 |
+
예산 부족 시 LOW부터 제거하고 CRITICAL은 절대 제거하지 않는다.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
CRITICAL = 100 # 분석 대상 종목/회사명 — 절대 트리밍 금지
|
| 21 |
+
HIGH = 80 # analysis calc 결과 (intent 매칭)
|
| 22 |
+
MEDIUM = 60 # 인사이트, 그래프 traversal
|
| 23 |
+
LOW = 40 # 외부 검색, 메모리 힌트
|
| 24 |
+
OPTIONAL = 20 # few-shot 예시
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass(frozen=True)
|
| 28 |
+
class ContextPart:
|
| 29 |
+
"""단일 컨텍스트 블록.
|
| 30 |
+
|
| 31 |
+
selector가 생성하고 builder가 budget에 따라 취사선택한다.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
key: str # selector 식별자 (예: "act2.marginTrend")
|
| 35 |
+
text: str # 사람이 읽는 텍스트 (TOON 또는 마크다운)
|
| 36 |
+
priority: PartPriority
|
| 37 |
+
estimatedTokens: int # rough — len(text) // 3 정도면 충분
|
| 38 |
+
source: str = "" # 출처 (예: "calc:profitability", "knowledgedb:insight")
|
| 39 |
+
|
| 40 |
+
def __post_init__(self) -> None:
|
| 41 |
+
if not self.text:
|
| 42 |
+
raise ValueError(f"ContextPart.text empty: key={self.key}")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class ContextBundle:
|
| 47 |
+
"""ContextBuilder 최종 출력.
|
| 48 |
+
|
| 49 |
+
소비자는 toUserParts() 로 기존 코드 (_analyze_inner) 와 호환되는 리스트를 얻는다.
|
| 50 |
+
parts 는 priority 내림차순 정렬되어 있다.
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
parts: list[ContextPart] = field(default_factory=list)
|
| 54 |
+
intent: str = ""
|
| 55 |
+
totalTokens: int = 0
|
| 56 |
+
droppedKeys: list[str] = field(default_factory=list) # budget으로 잘린 part keys
|
| 57 |
+
|
| 58 |
+
def toUserParts(self) -> list[str]:
|
| 59 |
+
"""기존 _analyze_inner userParts 호환 — text 리스트만."""
|
| 60 |
+
return [p.text for p in self.parts]
|
| 61 |
+
|
| 62 |
+
def keys(self) -> list[str]:
|
| 63 |
+
return [p.key for p in self.parts]
|
| 64 |
+
|
| 65 |
+
def __len__(self) -> int:
|
| 66 |
+
return len(self.parts)
|
src/dartlab/ai/context/encoder.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TOON (Token-Oriented Object Notation) 인코더.
|
| 2 |
+
|
| 3 |
+
LLM 입력용 압축 표현. 같은 데이터를 JSON 대비 30~60% 적은 토큰으로 주입.
|
| 4 |
+
일부 케이스(작은 dict)에는 효과 없음 — encodeAuto가 작은 입력은 JSON 유지.
|
| 5 |
+
|
| 6 |
+
참조: TOON 사양 (2026, llm-data 압축 포맷)
|
| 7 |
+
- 키: 한 번만 등장 (헤더 행)
|
| 8 |
+
- 값: 행 단위 정렬
|
| 9 |
+
- 깊은 중첩 최소화 (LLM 어텐션이 가장 잘 처리하는 형태)
|
| 10 |
+
|
| 11 |
+
dartlab은 외부 의존성 추가 없이 자체 구현 — 단순 직렬화.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _isFlatList(value: Any) -> bool:
|
| 21 |
+
"""list[dict] 형태이고 모든 dict가 같은 키 집합인지."""
|
| 22 |
+
if not isinstance(value, list) or not value:
|
| 23 |
+
return False
|
| 24 |
+
if not all(isinstance(x, dict) for x in value):
|
| 25 |
+
return False
|
| 26 |
+
first_keys = tuple(value[0].keys())
|
| 27 |
+
return all(tuple(x.keys()) == first_keys for x in value)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _encodeFlatList(rows: list[dict[str, Any]]) -> str:
|
| 31 |
+
"""list[dict] → TOON 표 형식.
|
| 32 |
+
|
| 33 |
+
예::
|
| 34 |
+
|
| 35 |
+
[{"a": 1, "b": 2}, {"a": 3, "b": 4}]
|
| 36 |
+
→
|
| 37 |
+
a|b
|
| 38 |
+
1|2
|
| 39 |
+
3|4
|
| 40 |
+
"""
|
| 41 |
+
if not rows:
|
| 42 |
+
return ""
|
| 43 |
+
keys = list(rows[0].keys())
|
| 44 |
+
header = "|".join(keys)
|
| 45 |
+
lines = [header]
|
| 46 |
+
for row in rows:
|
| 47 |
+
cells = []
|
| 48 |
+
for k in keys:
|
| 49 |
+
v = row.get(k)
|
| 50 |
+
if v is None:
|
| 51 |
+
cells.append("")
|
| 52 |
+
elif isinstance(v, (int, float, str, bool)):
|
| 53 |
+
cells.append(str(v))
|
| 54 |
+
else:
|
| 55 |
+
cells.append(json.dumps(v, ensure_ascii=False, default=str))
|
| 56 |
+
lines.append("|".join(cells))
|
| 57 |
+
return "\n".join(lines)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _encodeDict(d: dict[str, Any], depth: int = 0) -> str:
|
| 61 |
+
"""dict → TOON key:value 행 형식. 중첩 list[dict]는 표로 변환."""
|
| 62 |
+
if not d:
|
| 63 |
+
return ""
|
| 64 |
+
lines = []
|
| 65 |
+
indent = " " * depth
|
| 66 |
+
for k, v in d.items():
|
| 67 |
+
if _isFlatList(v):
|
| 68 |
+
lines.append(f"{indent}{k}:")
|
| 69 |
+
table = _encodeFlatList(v)
|
| 70 |
+
lines.extend(f"{indent} {ln}" for ln in table.split("\n"))
|
| 71 |
+
elif isinstance(v, dict):
|
| 72 |
+
lines.append(f"{indent}{k}:")
|
| 73 |
+
lines.append(_encodeDict(v, depth + 1))
|
| 74 |
+
elif isinstance(v, list):
|
| 75 |
+
# 단순 list[scalar] — 한 줄에 ,로
|
| 76 |
+
lines.append(f"{indent}{k}: " + ", ".join(str(x) for x in v))
|
| 77 |
+
elif v is None:
|
| 78 |
+
lines.append(f"{indent}{k}: -")
|
| 79 |
+
else:
|
| 80 |
+
lines.append(f"{indent}{k}: {v}")
|
| 81 |
+
return "\n".join(lines)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def encodeTOON(data: Any) -> str:
|
| 85 |
+
"""임의 데이터 → TOON 텍스트.
|
| 86 |
+
|
| 87 |
+
list[dict] (균질) → 표 형식
|
| 88 |
+
dict → key:value (중첩 처리)
|
| 89 |
+
그 외 → JSON fallback
|
| 90 |
+
"""
|
| 91 |
+
if _isFlatList(data):
|
| 92 |
+
return _encodeFlatList(data)
|
| 93 |
+
if isinstance(data, dict):
|
| 94 |
+
return _encodeDict(data)
|
| 95 |
+
return json.dumps(data, ensure_ascii=False, default=str)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def encodeAuto(data: Any, *, jsonThresholdChars: int = 200) -> str:
|
| 99 |
+
"""작은 입력은 JSON, 큰 입력은 TOON.
|
| 100 |
+
|
| 101 |
+
작은 dict는 JSON이 더 짧을 수 있음 (헤더 오버헤드 없음).
|
| 102 |
+
"""
|
| 103 |
+
js = json.dumps(data, ensure_ascii=False, default=str)
|
| 104 |
+
if len(js) < jsonThresholdChars:
|
| 105 |
+
return js
|
| 106 |
+
toon = encodeTOON(data)
|
| 107 |
+
# TOON이 더 길면 JSON 사용 (안전장치)
|
| 108 |
+
return toon if len(toon) < len(js) else js
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def estimateTokens(text: str) -> int:
|
| 112 |
+
"""rough 토큰 추정 — 한국어 + 영문 혼합 기준 평균 1토큰 ≈ 2.5 chars."""
|
| 113 |
+
if not text:
|
| 114 |
+
return 0
|
| 115 |
+
return max(1, len(text) // 3)
|
src/dartlab/ai/context/intent.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Intent 분류 — 질문 → 6막 + compare + concept.
|
| 2 |
+
|
| 3 |
+
LLM 호출 없이 키워드 매칭 + Company 상태 + 패턴으로 결정론적 분류.
|
| 4 |
+
selfai 폐기 학습 적용: ML 없음, 모든 규칙은 명시적 코드.
|
| 5 |
+
|
| 6 |
+
8개 Intent:
|
| 7 |
+
act1_business — 사업이해 (수익구조, 성장성)
|
| 8 |
+
act2_profit — 수익성 (마진, 비용구조)
|
| 9 |
+
act3_cash — 현금흐름 (CF, 이익품질)
|
| 10 |
+
act4_stability — 안정성 (부채, 신용)
|
| 11 |
+
act5_capital — 자본배분 (배당, ROIC)
|
| 12 |
+
act6_outlook — 전망 (가치평가, 매크로)
|
| 13 |
+
compare — 시장 비교 (scan)
|
| 14 |
+
concept — 개념질문 (capabilities, docs)
|
| 15 |
+
|
| 16 |
+
오분류 fallback: act_all (핵심 축 요약 주입)
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
from dataclasses import dataclass
|
| 22 |
+
from enum import Enum
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class Intent(str, Enum):
|
| 26 |
+
ACT1_BUSINESS = "act1_business"
|
| 27 |
+
ACT2_PROFIT = "act2_profit"
|
| 28 |
+
ACT3_CASH = "act3_cash"
|
| 29 |
+
ACT4_STABILITY = "act4_stability"
|
| 30 |
+
ACT5_CAPITAL = "act5_capital"
|
| 31 |
+
ACT6_OUTLOOK = "act6_outlook"
|
| 32 |
+
COMPARE = "compare"
|
| 33 |
+
CONCEPT = "concept"
|
| 34 |
+
ACT_ALL = "act_all" # fallback — 의도가 명확하지 않거나 종합 질문
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ── 키워드 사전 ────────────────────────────────────────────
|
| 38 |
+
# 각 막에 배타적으로 강한 신호만 등록. 약한 키워드는 act_all로 떨어져도 OK.
|
| 39 |
+
|
| 40 |
+
_KEYWORDS: dict[Intent, tuple[str, ...]] = {
|
| 41 |
+
Intent.ACT1_BUSINESS: (
|
| 42 |
+
"사업",
|
| 43 |
+
"비즈니스",
|
| 44 |
+
"매출구성",
|
| 45 |
+
"사업부",
|
| 46 |
+
"세그먼트",
|
| 47 |
+
"segment",
|
| 48 |
+
"제품",
|
| 49 |
+
"서비스",
|
| 50 |
+
"고객",
|
| 51 |
+
"시장점유",
|
| 52 |
+
"시장 점유",
|
| 53 |
+
"성장",
|
| 54 |
+
"뭐하는",
|
| 55 |
+
"뭘 하는",
|
| 56 |
+
"어떤 회사",
|
| 57 |
+
"뭐 해서",
|
| 58 |
+
"뭐해서",
|
| 59 |
+
"돈 벌",
|
| 60 |
+
"돈벌",
|
| 61 |
+
"수익원",
|
| 62 |
+
),
|
| 63 |
+
Intent.ACT2_PROFIT: (
|
| 64 |
+
"수익성",
|
| 65 |
+
"마진",
|
| 66 |
+
"영업이익률",
|
| 67 |
+
"순이익률",
|
| 68 |
+
"ROIC",
|
| 69 |
+
"ROE",
|
| 70 |
+
"ROA",
|
| 71 |
+
"비용구조",
|
| 72 |
+
"원가",
|
| 73 |
+
"판관비",
|
| 74 |
+
"이익률",
|
| 75 |
+
"수익",
|
| 76 |
+
"벌고",
|
| 77 |
+
),
|
| 78 |
+
Intent.ACT3_CASH: (
|
| 79 |
+
"현금",
|
| 80 |
+
"현금흐름",
|
| 81 |
+
"OCF",
|
| 82 |
+
"FCF",
|
| 83 |
+
"이익품질",
|
| 84 |
+
"운전자본",
|
| 85 |
+
"감가상각",
|
| 86 |
+
"발생액",
|
| 87 |
+
"현금전환",
|
| 88 |
+
),
|
| 89 |
+
Intent.ACT4_STABILITY: (
|
| 90 |
+
"부채",
|
| 91 |
+
"안정성",
|
| 92 |
+
"재무건전",
|
| 93 |
+
"이자보상",
|
| 94 |
+
"유동",
|
| 95 |
+
"차입",
|
| 96 |
+
"신용",
|
| 97 |
+
"부실",
|
| 98 |
+
"Z-Score",
|
| 99 |
+
"ICR",
|
| 100 |
+
"디폴트",
|
| 101 |
+
"default",
|
| 102 |
+
),
|
| 103 |
+
Intent.ACT5_CAPITAL: (
|
| 104 |
+
"배당",
|
| 105 |
+
"자사주",
|
| 106 |
+
"자본배분",
|
| 107 |
+
"주주환원",
|
| 108 |
+
"유보",
|
| 109 |
+
"재투자",
|
| 110 |
+
"CAPEX",
|
| 111 |
+
"WACC",
|
| 112 |
+
),
|
| 113 |
+
Intent.ACT6_OUTLOOK: (
|
| 114 |
+
"전망",
|
| 115 |
+
"예측",
|
| 116 |
+
"추정",
|
| 117 |
+
"valuation",
|
| 118 |
+
"DCF",
|
| 119 |
+
"PER",
|
| 120 |
+
"PBR",
|
| 121 |
+
"목표가",
|
| 122 |
+
"적정가",
|
| 123 |
+
"고평가",
|
| 124 |
+
"저평가",
|
| 125 |
+
"안전마진",
|
| 126 |
+
"매크로",
|
| 127 |
+
"환율",
|
| 128 |
+
"금리",
|
| 129 |
+
"유가",
|
| 130 |
+
),
|
| 131 |
+
Intent.COMPARE: (
|
| 132 |
+
"비교",
|
| 133 |
+
"랭킹",
|
| 134 |
+
"순위",
|
| 135 |
+
"상위",
|
| 136 |
+
"하위",
|
| 137 |
+
"대비",
|
| 138 |
+
"vs",
|
| 139 |
+
"VS",
|
| 140 |
+
"동종",
|
| 141 |
+
"동종업계",
|
| 142 |
+
"peer",
|
| 143 |
+
"scan",
|
| 144 |
+
"스캔",
|
| 145 |
+
"전종목",
|
| 146 |
+
"업종 평균",
|
| 147 |
+
"업종평균",
|
| 148 |
+
# NOTE: "보다 큰/작은/높/낮" 은 두 지표 간 비교에도 자주 쓰여 제외.
|
| 149 |
+
# COMPARE 는 회사 간 비교일 때만 매칭되도록 명시적 키워드만 둔다.
|
| 150 |
+
),
|
| 151 |
+
Intent.CONCEPT: (
|
| 152 |
+
"사용법",
|
| 153 |
+
"어떻게 쓰",
|
| 154 |
+
"어떻게 사용",
|
| 155 |
+
"어떻게 호출",
|
| 156 |
+
"방법 알려",
|
| 157 |
+
"예시",
|
| 158 |
+
"예제",
|
| 159 |
+
"튜토리얼",
|
| 160 |
+
"dartlab",
|
| 161 |
+
"ask(",
|
| 162 |
+
"show(",
|
| 163 |
+
"select(",
|
| 164 |
+
"analysis(",
|
| 165 |
+
"review(",
|
| 166 |
+
"공시 어디",
|
| 167 |
+
"어디서 찾",
|
| 168 |
+
),
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@dataclass(frozen=True)
|
| 173 |
+
class IntentResult:
|
| 174 |
+
intent: Intent
|
| 175 |
+
confidence: float # 0.0~1.0 — 매칭된 키워드 / 후보 키워드 비율
|
| 176 |
+
matchedKeywords: tuple[str, ...]
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _scoreIntent(question: str, intent: Intent) -> tuple[float, tuple[str, ...]]:
|
| 180 |
+
"""단일 intent 점수 + 매칭된 키워드 반환."""
|
| 181 |
+
q = question.lower()
|
| 182 |
+
keywords = _KEYWORDS[intent]
|
| 183 |
+
matched = tuple(kw for kw in keywords if kw.lower() in q)
|
| 184 |
+
if not matched:
|
| 185 |
+
return 0.0, ()
|
| 186 |
+
# 매칭 키워드 수 / 후보 수 — 정규화. 단순 카운트 우선.
|
| 187 |
+
score = len(matched) / max(len(keywords), 1)
|
| 188 |
+
# 매칭 1개라도 있으면 최소 0.2 보장 (희소 키워드 보호)
|
| 189 |
+
return max(score, 0.2), matched
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def classifyIntent(
|
| 193 |
+
question: str,
|
| 194 |
+
*,
|
| 195 |
+
hasCompany: bool = False,
|
| 196 |
+
) -> IntentResult:
|
| 197 |
+
"""질문 → IntentResult.
|
| 198 |
+
|
| 199 |
+
Args:
|
| 200 |
+
question: 사용자 질문
|
| 201 |
+
hasCompany: Company 객체 존재 여부 (없으면 CONCEPT/COMPARE 가중치)
|
| 202 |
+
|
| 203 |
+
Returns:
|
| 204 |
+
IntentResult — 가장 높은 점수의 intent. 동점은 정의 순서.
|
| 205 |
+
"""
|
| 206 |
+
if not question or not question.strip():
|
| 207 |
+
return IntentResult(Intent.ACT_ALL, 0.0, ())
|
| 208 |
+
|
| 209 |
+
scores: list[tuple[Intent, float, tuple[str, ...]]] = []
|
| 210 |
+
for intent in (
|
| 211 |
+
Intent.COMPARE, # compare 먼저 — "비교" 키워드가 다른 막과 섞일 때 우선
|
| 212 |
+
Intent.CONCEPT,
|
| 213 |
+
Intent.ACT2_PROFIT,
|
| 214 |
+
Intent.ACT3_CASH,
|
| 215 |
+
Intent.ACT4_STABILITY,
|
| 216 |
+
Intent.ACT5_CAPITAL,
|
| 217 |
+
Intent.ACT6_OUTLOOK,
|
| 218 |
+
Intent.ACT1_BUSINESS,
|
| 219 |
+
):
|
| 220 |
+
score, matched = _scoreIntent(question, intent)
|
| 221 |
+
if score > 0:
|
| 222 |
+
scores.append((intent, score, matched))
|
| 223 |
+
|
| 224 |
+
if not scores:
|
| 225 |
+
return IntentResult(Intent.ACT_ALL, 0.0, ())
|
| 226 |
+
|
| 227 |
+
# Company 없으면 막 관련 intent는 의미 없음 → CONCEPT/COMPARE 우대
|
| 228 |
+
if not hasCompany:
|
| 229 |
+
prioritized = [s for s in scores if s[0] in (Intent.CONCEPT, Intent.COMPARE)]
|
| 230 |
+
if prioritized:
|
| 231 |
+
scores = prioritized
|
| 232 |
+
|
| 233 |
+
# 최고 점수 선택 (동점은 위 순서 유지)
|
| 234 |
+
scores.sort(key=lambda s: s[1], reverse=True)
|
| 235 |
+
best = scores[0]
|
| 236 |
+
return IntentResult(best[0], best[1], best[2])
|
src/dartlab/ai/context/playbook.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ACE Curator/Reflector — dartlab 결정론 구현.
|
| 2 |
+
|
| 3 |
+
논문: arxiv.org/abs/2510.04618 (ICLR 2026, Stanford+UCB+SambaNova)
|
| 4 |
+
|
| 5 |
+
ACE 3 컴포넌트 매핑:
|
| 6 |
+
Generator = ai/runtime/core.py::_streamWithCodeExecution (이미 있음)
|
| 7 |
+
Reflector = extractBullets() — 응답 텍스트에서 bullet 추출 (결정론)
|
| 8 |
+
Curator = curate() — KnowledgeDB.upsert_bullet 위임 (delta merge)
|
| 9 |
+
|
| 10 |
+
핵심 규칙 (논문):
|
| 11 |
+
1. delta merge — 기존 bullet 절대 삭제 X. context collapse 방지.
|
| 12 |
+
2. bullet은 한 줄 (200자 cap), 중첩 금지.
|
| 13 |
+
3. success/fail 카운트 → quality (Beta posterior 근사).
|
| 14 |
+
4. retrieval은 quality desc, 섹터 우선 매칭.
|
| 15 |
+
|
| 16 |
+
selfai 폐기 학습 적용:
|
| 17 |
+
- LLM Reflector 안 씀 (페이퍼는 LLM Reflector 사용).
|
| 18 |
+
- dartlab은 결정론 regex/패턴 추출만 — 디버깅 가능, 토큰 비용 0.
|
| 19 |
+
- 효과 검증 후 LLM Reflector 단계 도입 검토.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import re
|
| 25 |
+
from dataclasses import dataclass
|
| 26 |
+
|
| 27 |
+
# ── Reflector: 응답 → bullet 결정론 추출 ───────────────────
|
| 28 |
+
|
| 29 |
+
# 의미 있는 한 줄 패턴 (한국어 분석 응답 기준)
|
| 30 |
+
_BULLET_HEADERS = (
|
| 31 |
+
"결론",
|
| 32 |
+
"핵심",
|
| 33 |
+
"요약",
|
| 34 |
+
"판단",
|
| 35 |
+
"주의",
|
| 36 |
+
"리스크",
|
| 37 |
+
"강점",
|
| 38 |
+
"약점",
|
| 39 |
+
"관전",
|
| 40 |
+
"관찰",
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
_BULLET_LINE_RE = re.compile(
|
| 44 |
+
r"^\s*[-*•]\s*(.+?)$",
|
| 45 |
+
re.MULTILINE,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
_HEADER_LINE_RE = re.compile(
|
| 49 |
+
rf"(?:{'|'.join(_BULLET_HEADERS)})[::]\s*([^\n]{{8,180}})",
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# 너무 짧거나 무의미한 패턴 차단
|
| 53 |
+
_NOISE_RE = re.compile(r"^(있다|없다|확인|분석|참고|참조)\.?$")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _cleanBullet(text: str) -> str | None:
|
| 57 |
+
"""bullet 정제 — 길이/노이즈 필터."""
|
| 58 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 59 |
+
text = text.strip("-*•·#> .").strip()
|
| 60 |
+
if not text:
|
| 61 |
+
return None
|
| 62 |
+
if len(text) < 8 or len(text) > 200:
|
| 63 |
+
return None
|
| 64 |
+
if _NOISE_RE.match(text):
|
| 65 |
+
return None
|
| 66 |
+
# 코드/표 라인 제외
|
| 67 |
+
if "|" in text and text.count("|") >= 3:
|
| 68 |
+
return None
|
| 69 |
+
if text.startswith("```"):
|
| 70 |
+
return None
|
| 71 |
+
return text
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def extractBullets(response_text: str, *, max_bullets: int = 8) -> list[str]:
|
| 75 |
+
"""응답 텍스트 → 핵심 bullet 리스트.
|
| 76 |
+
|
| 77 |
+
추출 우선순위:
|
| 78 |
+
1. "결론:", "핵심:", "주의:" 등 헤더 매칭 (가장 신뢰)
|
| 79 |
+
2. 마크다운 리스트 항목 (- / * / •)
|
| 80 |
+
3. 위 둘 다 없으면 빈 리스트 (조용히 실패)
|
| 81 |
+
"""
|
| 82 |
+
if not response_text:
|
| 83 |
+
return []
|
| 84 |
+
bullets: list[str] = []
|
| 85 |
+
seen: set[str] = set()
|
| 86 |
+
|
| 87 |
+
# 1. 헤더 매칭
|
| 88 |
+
for m in _HEADER_LINE_RE.finditer(response_text):
|
| 89 |
+
cleaned = _cleanBullet(m.group(1))
|
| 90 |
+
if cleaned and cleaned not in seen:
|
| 91 |
+
bullets.append(cleaned)
|
| 92 |
+
seen.add(cleaned)
|
| 93 |
+
if len(bullets) >= max_bullets:
|
| 94 |
+
return bullets
|
| 95 |
+
|
| 96 |
+
# 2. 마크다운 리스트
|
| 97 |
+
for m in _BULLET_LINE_RE.finditer(response_text):
|
| 98 |
+
cleaned = _cleanBullet(m.group(1))
|
| 99 |
+
if cleaned and cleaned not in seen:
|
| 100 |
+
bullets.append(cleaned)
|
| 101 |
+
seen.add(cleaned)
|
| 102 |
+
if len(bullets) >= max_bullets:
|
| 103 |
+
return bullets
|
| 104 |
+
|
| 105 |
+
return bullets
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ── grade → outcome 매핑 ──────────────────────────────────
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def gradeToOutcome(grade: str | None) -> str:
|
| 112 |
+
"""KnowledgeDB executions.grade → upsert_bullet outcome.
|
| 113 |
+
|
| 114 |
+
dartlab grade 체계:
|
| 115 |
+
G — Good (성공)
|
| 116 |
+
T — Trivial (보통, neutral)
|
| 117 |
+
C — Crash (실패)
|
| 118 |
+
V — Vague (실패 — 모호한 답변)
|
| 119 |
+
P — Partial (성공 — 부분적이지만 가치 있음)
|
| 120 |
+
"""
|
| 121 |
+
g = (grade or "").upper().strip()
|
| 122 |
+
if g in ("G", "P"):
|
| 123 |
+
return "success"
|
| 124 |
+
if g in ("C", "V"):
|
| 125 |
+
return "fail"
|
| 126 |
+
return "neutral"
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ── Curator: bullet 묶음을 KnowledgeDB로 영속 ────────────
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@dataclass
|
| 133 |
+
class CurateResult:
|
| 134 |
+
intent: str
|
| 135 |
+
sector: str
|
| 136 |
+
inserted: int
|
| 137 |
+
skipped: int
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def curate(
|
| 141 |
+
*,
|
| 142 |
+
intent: str,
|
| 143 |
+
response_text: str,
|
| 144 |
+
grade: str | None,
|
| 145 |
+
sector: str = "",
|
| 146 |
+
source: str = "reflection",
|
| 147 |
+
) -> CurateResult:
|
| 148 |
+
"""Reflector + Curator 한 번에 호출.
|
| 149 |
+
|
| 150 |
+
1. extractBullets — 결정론 추출
|
| 151 |
+
2. gradeToOutcome — success/fail/neutral 결정
|
| 152 |
+
3. KnowledgeDB.upsert_bullet — delta merge
|
| 153 |
+
|
| 154 |
+
실패 (DB 없음/import 실패) 시 빈 결과 반환, 예외 전파 X.
|
| 155 |
+
"""
|
| 156 |
+
if not intent or not response_text:
|
| 157 |
+
return CurateResult(intent or "", sector, 0, 0)
|
| 158 |
+
|
| 159 |
+
bullets = extractBullets(response_text)
|
| 160 |
+
if not bullets:
|
| 161 |
+
return CurateResult(intent, sector, 0, 0)
|
| 162 |
+
|
| 163 |
+
outcome = gradeToOutcome(grade)
|
| 164 |
+
inserted = 0
|
| 165 |
+
skipped = 0
|
| 166 |
+
try:
|
| 167 |
+
from dartlab.ai.persistence import KnowledgeDB
|
| 168 |
+
|
| 169 |
+
db = KnowledgeDB.get()
|
| 170 |
+
except ImportError:
|
| 171 |
+
return CurateResult(intent, sector, 0, len(bullets))
|
| 172 |
+
|
| 173 |
+
for b in bullets:
|
| 174 |
+
try:
|
| 175 |
+
db.upsert_bullet(
|
| 176 |
+
intent=intent,
|
| 177 |
+
bullet=b,
|
| 178 |
+
sector=sector,
|
| 179 |
+
outcome=outcome,
|
| 180 |
+
source=source,
|
| 181 |
+
)
|
| 182 |
+
inserted += 1
|
| 183 |
+
except (OSError, RuntimeError):
|
| 184 |
+
skipped += 1
|
| 185 |
+
|
| 186 |
+
return CurateResult(intent, sector, inserted, skipped)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# ── Generator 측: bullet retrieval ─────────────────────────
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def retrieveBullets(
|
| 193 |
+
intent: str,
|
| 194 |
+
*,
|
| 195 |
+
sector: str = "",
|
| 196 |
+
limit: int = 6,
|
| 197 |
+
min_quality: float = 0.4,
|
| 198 |
+
) -> list[str]:
|
| 199 |
+
"""intent별 playbook bullet retrieval.
|
| 200 |
+
|
| 201 |
+
ContextBuilder 의 selector 가 호출. KnowledgeDB 없거나 비어있으면 빈 리스트.
|
| 202 |
+
"""
|
| 203 |
+
if not intent:
|
| 204 |
+
return []
|
| 205 |
+
try:
|
| 206 |
+
from dartlab.ai.persistence import KnowledgeDB
|
| 207 |
+
|
| 208 |
+
db = KnowledgeDB.get()
|
| 209 |
+
except ImportError:
|
| 210 |
+
return []
|
| 211 |
+
try:
|
| 212 |
+
rows = db.get_bullets(
|
| 213 |
+
intent=intent,
|
| 214 |
+
sector=sector,
|
| 215 |
+
limit=limit,
|
| 216 |
+
min_quality=min_quality,
|
| 217 |
+
)
|
| 218 |
+
except (OSError, RuntimeError):
|
| 219 |
+
return []
|
| 220 |
+
return [r[0] for r in rows]
|
src/dartlab/ai/context/selectors/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ContextBuilder selectors — Intent별 컨텍스트 생산자.
|
| 2 |
+
|
| 3 |
+
각 selector는 (question, company, intent) → list[ContextPart] 형태.
|
| 4 |
+
순수 함수, 부수효과 없음. 실패 시 빈 리스트 반환 (에러 전파 금지).
|
| 5 |
+
|
| 6 |
+
Phase 1 (현재):
|
| 7 |
+
legacy.py — 기존 ai/runtime/core.py의 pre-grounding 5개 헬퍼 래핑
|
| 8 |
+
(손실 없는 이주, A/B 비교 가능)
|
| 9 |
+
|
| 10 |
+
Phase 1.5 (다음):
|
| 11 |
+
act1~6.py, compare.py, concept.py — analysis calc 결과를 intent별로 선택 주입
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from dartlab.ai.context.selectors.legacy import (
|
| 17 |
+
selectCompanySearch,
|
| 18 |
+
selectDisclosureBrief,
|
| 19 |
+
selectExternalSearch,
|
| 20 |
+
selectInsightHints,
|
| 21 |
+
selectMemoryHints,
|
| 22 |
+
)
|
| 23 |
+
from dartlab.ai.context.selectors.playbook import selectPlaybookBullets
|
| 24 |
+
|
| 25 |
+
__all__ = [
|
| 26 |
+
"selectCompanySearch",
|
| 27 |
+
"selectDisclosureBrief",
|
| 28 |
+
"selectExternalSearch",
|
| 29 |
+
"selectInsightHints",
|
| 30 |
+
"selectMemoryHints",
|
| 31 |
+
"selectPlaybookBullets",
|
| 32 |
+
]
|
src/dartlab/ai/context/selectors/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (1.07 kB). View file
|
|
|
src/dartlab/ai/context/selectors/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (1.12 kB). View file
|
|
|