eddmpython commited on
Commit
3b7138e
·
verified ·
1 Parent(s): deda9f9

deploy: dartlab API + MCP server

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +6 -0
  2. Dockerfile +24 -0
  3. README.md +33 -6
  4. pyproject.toml +228 -0
  5. src/dartlab/STATUS.md +81 -0
  6. src/dartlab/__init__.py +1032 -0
  7. src/dartlab/__main__.py +5 -0
  8. src/dartlab/__pycache__/__init__.cpython-312.pyc +0 -0
  9. src/dartlab/__pycache__/__init__.cpython-313.pyc +0 -0
  10. src/dartlab/__pycache__/__main__.cpython-312.pyc +0 -0
  11. src/dartlab/__pycache__/company.cpython-312.pyc +0 -0
  12. src/dartlab/__pycache__/company.cpython-313.pyc +0 -0
  13. src/dartlab/__pycache__/config.cpython-312.pyc +0 -0
  14. src/dartlab/__pycache__/config.cpython-313.pyc +0 -0
  15. src/dartlab/__pycache__/listing.cpython-312.pyc +0 -0
  16. src/dartlab/__pycache__/listing.cpython-313.pyc +0 -0
  17. src/dartlab/__pycache__/topdown.cpython-312.pyc +0 -0
  18. src/dartlab/__pycache__/topdown.cpython-313.pyc +0 -0
  19. src/dartlab/ai/STATUS.md +200 -0
  20. src/dartlab/ai/__init__.py +161 -0
  21. src/dartlab/ai/__pycache__/__init__.cpython-312.pyc +0 -0
  22. src/dartlab/ai/__pycache__/__init__.cpython-313.pyc +0 -0
  23. src/dartlab/ai/__pycache__/types.cpython-312.pyc +0 -0
  24. src/dartlab/ai/__pycache__/types.cpython-313.pyc +0 -0
  25. src/dartlab/ai/context/__init__.py +38 -0
  26. src/dartlab/ai/context/__pycache__/__init__.cpython-312.pyc +0 -0
  27. src/dartlab/ai/context/__pycache__/__init__.cpython-313.pyc +0 -0
  28. src/dartlab/ai/context/__pycache__/aiview.cpython-312.pyc +0 -0
  29. src/dartlab/ai/context/__pycache__/budget.cpython-312.pyc +0 -0
  30. src/dartlab/ai/context/__pycache__/budget.cpython-313.pyc +0 -0
  31. src/dartlab/ai/context/__pycache__/builder.cpython-312.pyc +0 -0
  32. src/dartlab/ai/context/__pycache__/builder.cpython-313.pyc +0 -0
  33. src/dartlab/ai/context/__pycache__/bundle.cpython-312.pyc +0 -0
  34. src/dartlab/ai/context/__pycache__/bundle.cpython-313.pyc +0 -0
  35. src/dartlab/ai/context/__pycache__/encoder.cpython-312.pyc +0 -0
  36. src/dartlab/ai/context/__pycache__/encoder.cpython-313.pyc +0 -0
  37. src/dartlab/ai/context/__pycache__/intent.cpython-312.pyc +0 -0
  38. src/dartlab/ai/context/__pycache__/intent.cpython-313.pyc +0 -0
  39. src/dartlab/ai/context/__pycache__/playbook.cpython-312.pyc +0 -0
  40. src/dartlab/ai/context/__pycache__/playbook.cpython-313.pyc +0 -0
  41. src/dartlab/ai/context/aiview.py +360 -0
  42. src/dartlab/ai/context/budget.py +68 -0
  43. src/dartlab/ai/context/builder.py +195 -0
  44. src/dartlab/ai/context/bundle.py +66 -0
  45. src/dartlab/ai/context/encoder.py +115 -0
  46. src/dartlab/ai/context/intent.py +236 -0
  47. src/dartlab/ai/context/playbook.py +220 -0
  48. src/dartlab/ai/context/selectors/__init__.py +32 -0
  49. src/dartlab/ai/context/selectors/__pycache__/__init__.cpython-312.pyc +0 -0
  50. 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: Dartlab
3
- emoji: 🦀
4
- colorFrom: red
5
- colorTo: red
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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