structverify 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. structverify/__init__.py +83 -0
  2. structverify/adaptation/__init__.py +0 -0
  3. structverify/adaptation/adapter_trainer.py +341 -0
  4. structverify/adaptation/feedback_store.py +31 -0
  5. structverify/adaptation/kosis_crawler.py +317 -0
  6. structverify/adaptation/sample_builder.py +149 -0
  7. structverify/adaptation/synthetic_generator.py +320 -0
  8. structverify/adaptation/update_embeddings.py +178 -0
  9. structverify/agent/__init__.py +21 -0
  10. structverify/agent/builder_agent.py +226 -0
  11. structverify/agent/conformance_agent.py +171 -0
  12. structverify/agent/dependency_planner.py +151 -0
  13. structverify/agent/indexing_agent.py +153 -0
  14. structverify/agent/indexing_planner.py +169 -0
  15. structverify/agent/integration_example.py +182 -0
  16. structverify/agent/loop.py +1165 -0
  17. structverify/agent/memory.py +207 -0
  18. structverify/agent/planner.py +817 -0
  19. structverify/agent/prompts/__init__.py +15 -0
  20. structverify/agent/prompts/planner_prompts.py +219 -0
  21. structverify/agent/prompts/reflect_prompts.py +387 -0
  22. structverify/agent/reflect.py +227 -0
  23. structverify/agent/runtime_agent.py +1272 -0
  24. structverify/agent/schemas.py +262 -0
  25. structverify/agent/source_profiler.py +229 -0
  26. structverify/agent/tools/__init__.py +64 -0
  27. structverify/agent/tools/base.py +222 -0
  28. structverify/agent/tools/calculate.py +244 -0
  29. structverify/agent/tools/catalog_search.py +859 -0
  30. structverify/agent/tools/deep_explore.py +293 -0
  31. structverify/agent/tools/explore_catalog.py +423 -0
  32. structverify/agent/tools/fetch_evidence.py +922 -0
  33. structverify/agent/tools/finish.py +423 -0
  34. structverify/agent/tools/meta_explore.py +267 -0
  35. structverify/agent/tools/query_rewriter.py +134 -0
  36. structverify/agent/tools/read_original.py +144 -0
  37. structverify/agent/tools/replan.py +365 -0
  38. structverify/agent/workspace.py +958 -0
  39. structverify/api.py +804 -0
  40. structverify/config/default.yaml +350 -0
  41. structverify/core/__init__.py +0 -0
  42. structverify/core/config_loader.py +30 -0
  43. structverify/core/pipeline.py +280 -0
  44. structverify/core/schemas.py +362 -0
  45. structverify/detection/__init__.py +26 -0
  46. structverify/detection/_config.py +163 -0
  47. structverify/detection/_llm.py +24 -0
  48. structverify/detection/candidate/__init__.py +1 -0
  49. structverify/detection/candidate/heuristic.py +60 -0
  50. structverify/detection/candidate/llm.py +51 -0
  51. structverify/detection/candidate_scorer.py +81 -0
  52. structverify/detection/claim_detector.py +164 -0
  53. structverify/detection/claims/__init__.py +1 -0
  54. structverify/detection/claims/worthiness.py +142 -0
  55. structverify/detection/domain/__init__.py +1 -0
  56. structverify/detection/domain/classify.py +84 -0
  57. structverify/detection/domain/preview.py +36 -0
  58. structverify/detection/domain/registry.py +99 -0
  59. structverify/detection/domain_classifier.py +75 -0
  60. structverify/detection/prompts/__init__.py +1 -0
  61. structverify/detection/prompts/candidate.py +38 -0
  62. structverify/detection/prompts/claim_worthiness.py +48 -0
  63. structverify/detection/prompts/domain.py +41 -0
  64. structverify/detection/prompts/schema.py +508 -0
  65. structverify/detection/prompts_loader.py +167 -0
  66. structverify/detection/schema/__init__.py +1 -0
  67. structverify/detection/schema/expand.py +83 -0
  68. structverify/detection/schema/induce.py +441 -0
  69. structverify/detection/schema/regenerate.py +162 -0
  70. structverify/detection/schema/temporal_hints.py +130 -0
  71. structverify/detection/schema/validate.py +193 -0
  72. structverify/detection/schema_inductor.py +112 -0
  73. structverify/detection/synthetic_generator.py +270 -0
  74. structverify/explanation/__init__.py +0 -0
  75. structverify/explanation/_config.py +18 -0
  76. structverify/explanation/_llm.py +25 -0
  77. structverify/explanation/explainer.py +183 -0
  78. structverify/explanation/fallback.py +29 -0
  79. structverify/explanation/formatters.py +75 -0
  80. structverify/explanation/prompts/__init__.py +1 -0
  81. structverify/explanation/prompts/match.py +27 -0
  82. structverify/explanation/prompts/mismatch.py +20 -0
  83. structverify/explanation/prompts/multihop.py +16 -0
  84. structverify/explanation/prompts/unverifiable.py +17 -0
  85. structverify/graph/__init__.py +0 -0
  86. structverify/graph/claim_graph.py +226 -0
  87. structverify/graph/document_graph.py +487 -0
  88. structverify/graph/graph_builder.py +238 -0
  89. structverify/graph/graph_multihop.py +335 -0
  90. structverify/graph/graph_store.py +281 -0
  91. structverify/graph/provenance.py +52 -0
  92. structverify/memory/__init__.py +44 -0
  93. structverify/memory/agent_memory.py +142 -0
  94. structverify/memory/embedder.py +69 -0
  95. structverify/memory/exemplar_store.py +241 -0
  96. structverify/memory/normalizer.py +91 -0
  97. structverify/memory/schema.py +119 -0
  98. structverify/memory/storage/__init__.py +29 -0
  99. structverify/memory/storage/jsonl_store.py +117 -0
  100. structverify/memory/working_memory.py +370 -0
  101. structverify/preprocessing/Dockerfile.scraper +27 -0
  102. structverify/preprocessing/__init__.py +0 -0
  103. structverify/preprocessing/extractor.py +574 -0
  104. structverify/preprocessing/pdf/__init__.py +16 -0
  105. structverify/preprocessing/pdf/fields.py +95 -0
  106. structverify/preprocessing/pdf/markdown.py +107 -0
  107. structverify/preprocessing/pdf/models.py +34 -0
  108. structverify/preprocessing/pdf/ocr.py +172 -0
  109. structverify/preprocessing/pdf/pipeline.py +74 -0
  110. structverify/preprocessing/pdf/reader.py +119 -0
  111. structverify/preprocessing/pdf/scoring.py +61 -0
  112. structverify/preprocessing/scraper_sandbox.py +561 -0
  113. structverify/preprocessing/segmenter.py +48 -0
  114. structverify/preprocessing/sir_builder.py +240 -0
  115. structverify/progress.py +591 -0
  116. structverify/retrieval/__init__.py +0 -0
  117. structverify/retrieval/base.py +208 -0
  118. structverify/retrieval/base_connector.py +85 -0
  119. structverify/retrieval/catalog_ranker.py +300 -0
  120. structverify/retrieval/catalog_search.py +583 -0
  121. structverify/retrieval/chunking.py +92 -0
  122. structverify/retrieval/custom_csv_source.py +386 -0
  123. structverify/retrieval/custom_db_source.py +396 -0
  124. structverify/retrieval/custom_docs_source.py +152 -0
  125. structverify/retrieval/dimension_resolver.py +281 -0
  126. structverify/retrieval/evidence_subgraph.py +63 -0
  127. structverify/retrieval/kosis_connector.py +1192 -0
  128. structverify/retrieval/kosis_relevance.py +142 -0
  129. structverify/retrieval/kosis_source.py +1541 -0
  130. structverify/retrieval/query_builder.py +72 -0
  131. structverify/retrieval/registry.py +133 -0
  132. structverify/retrieval/relevance_judge.py +141 -0
  133. structverify/retrieval/row_matcher.py +267 -0
  134. structverify/storage/__init__.py +0 -0
  135. structverify/storage/db_manager.py +157 -0
  136. structverify/storage/dwh_manager.py +92 -0
  137. structverify/storage/init_db.py +99 -0
  138. structverify/storage/raw_storage.py +29 -0
  139. structverify/training/__init__.py +26 -0
  140. structverify/training/curator.py +124 -0
  141. structverify/training/dataset.py +134 -0
  142. structverify/training/doctor.py +99 -0
  143. structverify/training/evalgate.py +96 -0
  144. structverify/training/generate.py +101 -0
  145. structverify/training/loop.py +116 -0
  146. structverify/training/recipe/train_mlx.py +99 -0
  147. structverify/training/recipe/train_qlora.py +104 -0
  148. structverify/training/tasks.py +79 -0
  149. structverify/utils/__init__.py +0 -0
  150. structverify/utils/embedding_client.py +248 -0
  151. structverify/utils/llm_client.py +809 -0
  152. structverify/utils/logger.py +81 -0
  153. structverify/verification/__init__.py +0 -0
  154. structverify/verification/_config.py +45 -0
  155. structverify/verification/adapters.py +405 -0
  156. structverify/verification/conformance.py +117 -0
  157. structverify/verification/decide_verdict.py +216 -0
  158. structverify/verification/decide_verdict_agent.py +454 -0
  159. structverify/verification/growth_diff.py +267 -0
  160. structverify/verification/row_match.py +345 -0
  161. structverify/verification/units.py +64 -0
  162. structverify/verification/verdict_thresholds.py +232 -0
  163. structverify/verification/verifier.py +84 -0
  164. structverify-0.3.0.dist-info/METADATA +903 -0
  165. structverify-0.3.0.dist-info/RECORD +168 -0
  166. structverify-0.3.0.dist-info/WHEEL +5 -0
  167. structverify-0.3.0.dist-info/licenses/LICENSE +21 -0
  168. structverify-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,262 @@
1
+ """
2
+ structverify.agent.schemas — Agent 시스템의 데이터 모델.
3
+
4
+ 핵심 개념:
5
+ - DataPointSpec : claim 검증에 필요한 *하나의 데이터 점* (지표 + 시점 + 인구)
6
+ - Plan : 검증 계획 — 어떤 데이터 점들이 필요한지 + 어떻게 찾을지
7
+ - PlanStep : 다음 행동 한 단위 (action + input + rationale)
8
+ - Observation : action 실행 결과
9
+ - ReflectDecision : Reflect Agent가 결정한 다음 행동
10
+ - AgentVerdict : Agent loop 최종 결과
11
+
12
+ Phase A에서는 *모델 정의*만. 실제 사용은:
13
+ - Plan → Phase C (Plan Agent)
14
+ - Observation → Phase D (Loop)
15
+ - ReflectDecision → Phase D (Reflect Agent)
16
+ - AgentVerdict → Phase E (Verifier 확장)
17
+
18
+ 사용 패턴:
19
+ from structverify.agent.schemas import Plan, DataPointSpec, ClaimType
20
+
21
+ plan = Plan(
22
+ claim_id="abc-123",
23
+ claim_type=ClaimType.COMPARISON,
24
+ required_data=[
25
+ DataPointSpec(indicator="평균소비성향", time="2014"),
26
+ DataPointSpec(indicator="평균소비성향", time="2024"),
27
+ ],
28
+ initial_steps=[...],
29
+ )
30
+ workspace.write_plan(claim_id, plan.model_dump())
31
+ """
32
+ from __future__ import annotations
33
+
34
+ from datetime import datetime, timezone
35
+ from enum import Enum
36
+ from typing import Any
37
+ from pydantic import BaseModel, Field
38
+
39
+
40
+ # ── Enums ─────────────────────────────────────────────────────────
41
+
42
+ class ClaimType(str, Enum):
43
+ """Claim의 검증 유형 — Plan Agent가 분류한다."""
44
+
45
+ ABSOLUTE = "absolute"
46
+ """단일 시점 절대값. 예: '출생아 수 20,171명' (1개 데이터 점)."""
47
+
48
+ DIFFERENCE = "difference"
49
+ """두 시점 사이 차이값. 예: '0.04명 증가' (2개 데이터 점 + 차이 계산)."""
50
+
51
+ GROWTH_RATE = "growth_rate"
52
+ """두 시점 사이 증가율(%). 예: '6.7% 증가' (2개 데이터 점 + 비율 계산)."""
53
+
54
+ COMPARISON = "comparison"
55
+ """두 시점 직접 비교 (X→Y 형식). 예: '73.6% → 70.3%' (2개 데이터 점)."""
56
+
57
+ RANKING = "ranking"
58
+ """순위/상대 비교. 예: '1위', '하락폭이 가장 컸다' (여러 데이터 점)."""
59
+
60
+ AGGREGATION = "aggregation"
61
+ """[2026-05-21] 다년/다기간 집계 — 평균/총합/최대/최소 등.
62
+ 예: '최근 3년 평균 …', '2022~2024년 총합 …' (N개 데이터 점 + 집계 계산).
63
+ 도메인 무관, schema_inductor가 LLM으로 신호 감지. 시퀀스:
64
+ catalog_search → fetch_evidence × N → calculate(agg) → finish."""
65
+
66
+ UNKNOWN = "unknown"
67
+ """분류 불가."""
68
+
69
+
70
+ class ActionType(str, Enum):
71
+ """Agent가 실행 가능한 action."""
72
+
73
+ CATALOG_SEARCH = "catalog_search"
74
+ """지정된 데이터 소스의 카탈로그(표 목록)에서 검색."""
75
+
76
+ EXPLORE_CATALOG = "explore_catalog"
77
+ """[패치 Q] 카탈로그의 카테고리 분포 + 대표 표를 탐색.
78
+
79
+ LLM이 어떤 카테고리 어휘를 써야 할지 모를 때(예: '기상관측통계' vs '기후 변화')
80
+ 먼저 이 도구를 호출해 카탈로그가 실제로 어떤 분류·어휘를 쓰는지 파악한 뒤,
81
+ 그 정보를 바탕으로 정확한 catalog_search query/category를 만든다.
82
+ 룰베이스 도메인 매핑 없이도 DataSource 어휘를 자가학습."""
83
+
84
+ FETCH_EVIDENCE = "fetch_evidence"
85
+ """카탈로그에서 찾은 후보의 실제 데이터 조회."""
86
+
87
+ READ_ORIGINAL = "read_original"
88
+ """원문 기사 읽기 (claim 외 정보 필요할 때)."""
89
+
90
+ CALCULATE = "calculate"
91
+ """확보한 데이터로 수식 계산 (증가율, 차이 등)."""
92
+
93
+ REPLAN = "replan"
94
+ """[2026-05-26] plan 자체가 틀린 경우 — claim 값이 표에 직접 row로 없고
95
+ *계산해야 하는* 경우 (예: '증가 수 52'는 row가 아니라 current-prev delta).
96
+ 기존 fallback(try_ids/catalog retry/row_matcher 등)이 모두 같은 plan 안에서
97
+ 답 찾는 거라면, replan은 *plan을 통째로 새로 만든다*.
98
+
99
+ 호출 조건:
100
+ - 모든 catalog 후보 fetch 실패 + catalog retry도 소진
101
+ - 표 sample을 보고 LLM이 'claim 값이 row로 없는 *계산 대상*'이라 판단할 때
102
+ - per-claim 최대 2회 (무한 replan 방지)
103
+ """
104
+
105
+ FINISH = "finish"
106
+ """판정 확정 후 종료."""
107
+
108
+
109
+ class VerdictType(str, Enum):
110
+ """Agent loop 결과 verdict."""
111
+ MATCH = "match"
112
+ MISMATCH = "mismatch"
113
+ PARTIAL = "partial"
114
+ UNVERIFIABLE = "unverifiable"
115
+
116
+
117
+ class StopReason(str, Enum):
118
+ """Agent loop 종료 이유."""
119
+ COMPLETED = "completed"
120
+ """모든 데이터 확보 + 계산 + finish 결정."""
121
+
122
+ MAX_ITERATIONS = "max_iterations"
123
+ """max_iter 초과 — 미완료 상태로 종료."""
124
+
125
+ GIVE_UP = "give_up"
126
+ """agent가 명시적으로 포기 (충분히 시도했으나 데이터 없음)."""
127
+
128
+ TOKEN_BUDGET = "token_budget"
129
+ """LLM 토큰 예산 초과."""
130
+
131
+ ERROR = "error"
132
+ """예외 발생."""
133
+
134
+
135
+ # ── Data point ────────────────────────────────────────────────────
136
+
137
+ class DataPointSpec(BaseModel):
138
+ """
139
+ 검증에 필요한 *하나의 데이터 점* 명세.
140
+
141
+ Plan Agent가 claim에서 추출하고, agent loop이 *resolved_value*를 채움.
142
+ """
143
+ indicator: str
144
+ """지표명. '출생아 수', '평균소비성향' 등."""
145
+
146
+ time: str
147
+ """시점. 'YYYY' 또는 'YYYY-MM' 형식 권장."""
148
+
149
+ population: str | None = None
150
+ """대상 인구. '전체', '30대 이하' 등."""
151
+
152
+ unit_hint: str | None = None
153
+ """단위 힌트. '%', '명', '원' (LLM이 추론 — 절대 강제 X)."""
154
+
155
+ # ── 채워지는 필드 (agent loop 진행 중) ────────────────────────
156
+ resolved_value: float | None = None
157
+ """확보된 수치값. None이면 *아직 못 찾음*."""
158
+
159
+ resolved_unit: str | None = None
160
+ """실제 fetch된 데이터의 단위."""
161
+
162
+ source: str | None = None
163
+ """데이터 출처. 'KOSIS:DT_1L9U108', 'CSV:my_sales.csv' 등."""
164
+
165
+ source_time: str | None = None
166
+ """실제 fetch된 시점 (요청 시점과 다를 수 있음 — 가까운 row 매칭)."""
167
+
168
+
169
+ # ── Plan ──────────────────────────────────────────────────────────
170
+
171
+ class PlanStep(BaseModel):
172
+ """Plan에 포함된 *예상 행동 단계*. Reflect Agent가 변경/건너뛸 수 있다."""
173
+
174
+ action: ActionType
175
+ input: dict[str, Any]
176
+ rationale: str
177
+
178
+
179
+ class FallbackStrategy(BaseModel):
180
+ """1차 탐색 실패 시 대안."""
181
+
182
+ use_original_text: bool = False
183
+ """원문에서 직접 수치 추출 시도."""
184
+
185
+ alternative_keywords: list[str] = Field(default_factory=list)
186
+ """다른 검색어 후보."""
187
+
188
+ give_up_after_attempts: int = 5
189
+ """이 횟수 시도 후에도 데이터 못 찾으면 unverifiable."""
190
+
191
+
192
+ class Plan(BaseModel):
193
+ """Plan Agent가 생성하는 검증 계획."""
194
+
195
+ claim_id: str
196
+ claim_type: ClaimType
197
+ required_data: list[DataPointSpec]
198
+ initial_steps: list[PlanStep] = Field(default_factory=list)
199
+ fallback: FallbackStrategy = Field(default_factory=FallbackStrategy)
200
+ calculation_formula: str | None = None
201
+ """예: '(current - prev) / prev * 100' (증가율), 'X - Y' (차이)."""
202
+
203
+ notes: str | None = None
204
+ """Plan Agent의 자유 형식 메모 (디버깅용)."""
205
+
206
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
207
+
208
+
209
+ # ── Observation + Reflect ────────────────────────────────────────
210
+
211
+ class Observation(BaseModel):
212
+ """Action 실행 결과."""
213
+
214
+ iter_num: int
215
+ action: ActionType
216
+ input: dict[str, Any]
217
+ output: dict[str, Any]
218
+ """Tool 호출 raw 결과. observations/*.json에도 저장됨."""
219
+
220
+ summary: str
221
+ """memory.md에 들어갈 한 줄 요약. agent가 다음 턴에 참고."""
222
+
223
+ success: bool = True
224
+ error: str | None = None
225
+ timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
226
+
227
+
228
+ class ReflectDecision(BaseModel):
229
+ """Reflect Agent의 다음 행동 결정."""
230
+
231
+ thought: str
232
+ """추론 과정 (디버깅 + 사용자에게 보일 수도)."""
233
+
234
+ action: ActionType
235
+ input: dict[str, Any]
236
+
237
+ confidence_so_far: float = 0.0
238
+ """0~1. finish 결정에 영향."""
239
+
240
+ # finish action일 때만 채워짐
241
+ proposed_verdict: VerdictType | None = None
242
+ proposed_explanation: str | None = None
243
+
244
+
245
+ # ── Verdict ──────────────────────────────────────────────────────
246
+
247
+ class AgentVerdict(BaseModel):
248
+ """Agent loop 최종 결과."""
249
+
250
+ claim_id: str
251
+ verdict: VerdictType
252
+ confidence: float
253
+ explanation: str
254
+
255
+ data_points: list[DataPointSpec] = Field(default_factory=list)
256
+ """확보된 데이터 점들. verifier.py가 이걸로 *최종 검산*."""
257
+
258
+ iterations_used: int = 0
259
+ total_tokens: int = 0
260
+ stop_reason: StopReason = StopReason.COMPLETED
261
+
262
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@@ -0,0 +1,229 @@
1
+ """structverify.agent.source_profiler — 연결된 데이터소스의 '검증 프로파일' 생성.
2
+
3
+ 목적: 탐지·스키마 파이프라인이 KOSIS를 가정하지 않고, *연결된 소스*로 검증
4
+ 가능한 것을 기준으로 삼게 한다.
5
+
6
+ 에이전트 관점:
7
+ · inspect — 소스를 조회해 지표/단위/샘플을 수집 (도구)
8
+ · reason — LLM이 도메인·검증 범위를 요약
9
+ · remember — SourceProfile(의미기억)로 캐시 → 탐지 프롬프트에 주입
10
+
11
+ 메모리 구조: SourceProfile 은 소스당 1회 만들어 캐시하는 *semantic memory*.
12
+ (같은 소스로 여러 문서를 검증해도 재생성 안 함.)
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field, asdict
17
+ from typing import Any
18
+
19
+ from structverify.utils.logger import get_logger
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ # 지표가 이보다 많으면 "방대"로 보고 임베딩+랭커 전략을 쓴다. (KOSIS 26만 표 = 방대)
25
+ _LARGE_THRESHOLD = 200
26
+
27
+
28
+ @dataclass
29
+ class SourceProfile:
30
+ """연결된 데이터소스가 '무엇을 검증할 수 있는지'에 대한 의미기억."""
31
+ source_name: str
32
+ domain: str = "" # 추론된 도메인 (예: 'corporate_finance')
33
+ description: str = "" # 이 데이터가 다루는 것 한 문장
34
+ indicators: list[str] = field(default_factory=list) # 검증 가능한 지표들
35
+ units: list[str] = field(default_factory=list)
36
+ samples: list[str] = field(default_factory=list) # 예시 사실 몇 개
37
+ # ★ 검색 전략 — 프로파일러가 데이터 규모를 보고 결정 (소스 이름 분기 대체).
38
+ # {scale, indicator_count, method: keyword|embedding, use_ranker: bool, ranker_context}
39
+ retrieval_plan: dict[str, Any] = field(default_factory=dict)
40
+
41
+ def to_dict(self) -> dict[str, Any]:
42
+ return asdict(self)
43
+
44
+ def is_empty(self) -> bool:
45
+ return not self.indicators
46
+
47
+
48
+ def _decide_plan(indicators: list[str], domain: str, description: str) -> dict[str, Any]:
49
+ """데이터 특성을 보고 검색 전략을 정한다.
50
+
51
+ 규모-적응은 *검색 방식*에만 적용:
52
+ 작음 → 키워드 매칭 (후보가 적어 임베딩 비용 회피).
53
+ 방대 → 임베딩 의미검색 (KOSIS 26만 표처럼 키워드로 못 좁힘).
54
+
55
+ 판별(랭커)은 *규모와 무관하게 항상 ON* — KOSIS 수준의 자동 disambiguation.
56
+ 소규모라도 "총 주문건수 vs 연간 주문건수", "특수의료장비 vs 의료장비"처럼
57
+ claim에 맞는 표를 고르는 모호성은 늘 존재한다. 후보가 2개 이상이면 LLM 랭커가
58
+ claim(지표·시점·대상)에 가장 맞는 표를 고른다. (후보 1개면 호출자가 스킵.)
59
+ """
60
+ n = len(indicators)
61
+ large = n > _LARGE_THRESHOLD
62
+ ctx = f"{domain}: {description}".strip(" :") if (domain or description) else ""
63
+ return {
64
+ "indicator_count": n,
65
+ "scale": "large" if large else "small",
66
+ "method": "embedding" if large else "keyword",
67
+ "use_ranker": n > 1, # 후보 판별이 필요할 수 있으면 항상 (KOSIS 파리티)
68
+ "ranker_context": ctx[:200],
69
+ }
70
+
71
+
72
+ # 소스명 → SourceProfile (프로세스 캐시). 같은 소스 재프로파일 방지.
73
+ _CACHE: dict[str, SourceProfile] = {}
74
+
75
+
76
+ def _inspect_tabular(ds: Any) -> tuple[list[str], list[str], list[str]]:
77
+ """csv/db 소스에서 (지표목록, 단위목록, 샘플문장). _read_rows 를 쓰는 소스용."""
78
+ rows = ds._read_rows()
79
+ cm = getattr(ds, "column_mapping", {})
80
+ ind_c, unit_c, val_c = cm.get("indicator", "indicator"), cm.get("unit", "unit"), cm.get("value", "value")
81
+ inds, units, samples = [], [], []
82
+ seen_i, seen_u = set(), set()
83
+ for r in rows:
84
+ i = (r.get(ind_c) or "").strip()
85
+ if i and i not in seen_i:
86
+ seen_i.add(i); inds.append(i)
87
+ u = (r.get(unit_c) or "").strip()
88
+ if u and u not in seen_u:
89
+ seen_u.add(u); units.append(u)
90
+ for r in rows[:6]:
91
+ i, v, u = (r.get(ind_c) or ""), (r.get(val_c) or ""), (r.get(unit_c) or "")
92
+ if i:
93
+ samples.append(f"{i} = {v}{u}")
94
+ # 지표가 많은 소스(예: 부품유형 150+)에서 프로파일 상한에 잘려 'headline' 지표
95
+ # (총/지역/연간/평균 등)가 빠지는 문제 방지 — 짧고 일반적인 이름을 앞으로 정렬.
96
+ # (짧은 지표명 = 대개 종합/대표 지표; 긴 이름 = 세부 카테고리.)
97
+ inds.sort(key=lambda s: (len(s), s))
98
+ return inds, units, samples
99
+
100
+
101
+ def _inspect_docs(ds: Any) -> tuple[list[str], list[str], list[str]]:
102
+ """custom_docs 소스에서 조각 미리보기 → 주제(지표 대용)."""
103
+ chunks = getattr(ds, "_chunks", None) or getattr(ds, "chunks", None) or []
104
+ previews = []
105
+ for ch in list(chunks)[:8]:
106
+ txt = ch if isinstance(ch, str) else (ch.get("text", "") if isinstance(ch, dict) else "")
107
+ txt = " ".join(str(txt).split())[:60]
108
+ if txt:
109
+ previews.append(txt)
110
+ return previews, [], previews
111
+
112
+
113
+ _PROFILE_PROMPT = """아래는 사실 검증의 '정답 기준'으로 쓸 데이터 소스의 내용이다.
114
+ 이 소스로 어떤 주장을 검증할 수 있는지 파악하라.
115
+
116
+ [소스에 있는 지표/항목]
117
+ {indicators}
118
+
119
+ [단위]
120
+ {units}
121
+
122
+ [샘플]
123
+ {samples}
124
+
125
+ JSON으로만 답하라:
126
+ {{"domain": "이 데이터의 도메인 한 단어(영문 스네이크, 예: corporate_finance)",
127
+ "description": "이 데이터가 다루는 내용을 한 문장으로"}}"""
128
+
129
+
130
+ async def profile_source(
131
+ ds: Any,
132
+ source_name: str,
133
+ config: dict | None = None,
134
+ *,
135
+ max_indicators: int = 60,
136
+ ) -> SourceProfile:
137
+ """데이터소스를 프로파일링해 SourceProfile 반환 (캐시)."""
138
+ # 캐시 키: 소스명 + 실제 데이터 식별자(테이블/경로) — 같은 custom_db라도 테이블이
139
+ # 다르면 다른 프로파일이어야 한다.
140
+ _ident = (
141
+ getattr(ds, "table", None) or getattr(ds, "query", None)
142
+ or getattr(ds, "csv_path", None) or getattr(ds, "docs_path", None)
143
+ or (",".join(getattr(ds, "tables", []) or []) if getattr(ds, "agentic", False) else "")
144
+ or ""
145
+ )
146
+ cache_key = f"{source_name}:{_ident}"
147
+ if cache_key in _CACHE:
148
+ return _CACHE[cache_key]
149
+
150
+ # ── 에이전틱(text-to-SQL) 소스: 스키마 조사 → LLM이 지표 후보 제안 ──
151
+ if getattr(ds, "agentic", False) and hasattr(ds, "propose_metrics"):
152
+ try:
153
+ meta = await ds.propose_metrics(config)
154
+ except Exception as e: # noqa: BLE001
155
+ logger.warning(f"[source-profiler] propose_metrics 실패({source_name}): {e}")
156
+ meta = {}
157
+ profile = SourceProfile(
158
+ source_name=source_name,
159
+ domain=str(meta.get("domain", "") or ""),
160
+ description=str(meta.get("description", "") or ""),
161
+ indicators=list(meta.get("indicators", []) or [])[:max_indicators],
162
+ units=list(meta.get("units", []) or [])[:20],
163
+ samples=list(meta.get("samples", []) or [])[:6],
164
+ )
165
+ if profile.indicators:
166
+ profile.retrieval_plan = _decide_plan(
167
+ profile.indicators, profile.domain, profile.description,
168
+ )
169
+ profile.retrieval_plan["agentic"] = True # SQL로 임의 집계 가능 → worthiness 관대
170
+ logger.info(
171
+ f"[source-profiler] {source_name}(에이전틱): domain={profile.domain!r} "
172
+ f"제안지표 {len(profile.indicators)}개 · text-to-SQL 모드"
173
+ )
174
+ else:
175
+ logger.info(f"[source-profiler] {source_name}(에이전틱): 지표 제안 없음")
176
+ _CACHE[cache_key] = profile
177
+ return profile
178
+
179
+ # ── inspect ──
180
+ try:
181
+ if hasattr(ds, "_read_rows"):
182
+ inds, units, samples = _inspect_tabular(ds)
183
+ else:
184
+ inds, units, samples = _inspect_docs(ds)
185
+ except Exception as e: # noqa: BLE001 — 프로파일 실패는 빈 프로파일로(파이프라인 보호)
186
+ logger.warning(f"[source-profiler] inspect 실패({source_name}): {e}")
187
+ inds = units = samples = []
188
+
189
+ profile = SourceProfile(
190
+ source_name=source_name,
191
+ indicators=inds[:max_indicators],
192
+ units=units[:20],
193
+ samples=samples[:6],
194
+ )
195
+ if not inds:
196
+ logger.info(f"[source-profiler] {source_name}: 지표 없음 — 빈 프로파일")
197
+ _CACHE[cache_key] = profile
198
+ return profile
199
+
200
+ # ── reason (LLM 요약) ──
201
+ try:
202
+ from structverify.utils.llm_client import LLMClient
203
+ llm = LLMClient(config=(config or {}).get("llm", {}))
204
+ out = await llm.generate_json_light(_PROFILE_PROMPT.format(
205
+ indicators="\n".join(f"- {i}" for i in inds[:40]),
206
+ units=", ".join(units[:20]) or "(미상)",
207
+ samples="\n".join(samples) or "(없음)",
208
+ ))
209
+ profile.domain = str((out or {}).get("domain", "") or "").strip()
210
+ profile.description = str((out or {}).get("description", "") or "").strip()
211
+ except Exception as e: # noqa: BLE001
212
+ logger.warning(f"[source-profiler] reason 실패({source_name}): {e}")
213
+
214
+ # ★ 규모 보고 검색 전략 결정 (키워드 vs 임베딩+랭커)
215
+ profile.retrieval_plan = _decide_plan(
216
+ profile.indicators, profile.domain, profile.description,
217
+ )
218
+
219
+ logger.info(
220
+ f"[source-profiler] {source_name} 프로파일: domain={profile.domain!r} "
221
+ f"지표 {len(profile.indicators)}개 · 전략={profile.retrieval_plan['method']}"
222
+ f"(랭커 {'ON' if profile.retrieval_plan['use_ranker'] else 'OFF'})"
223
+ )
224
+ _CACHE[cache_key] = profile
225
+ return profile
226
+
227
+
228
+ def clear_cache() -> None:
229
+ _CACHE.clear()
@@ -0,0 +1,64 @@
1
+ """
2
+ structverify.agent.tools — Agent가 호출하는 Tool 모음.
3
+
4
+ 각 Tool은 *@register_tool(ActionType.X)* 데코레이터로 자동 등록됨.
5
+ 이 패키지를 import하면 *모든 Tool이 registry에 등록됨*.
6
+
7
+ Phase B Tool 목록:
8
+ - catalog_search : DataSource 카탈로그(표) 검색
9
+ - fetch_evidence : 후보의 실제 수치 조회
10
+ - read_original : workspace의 원문 기사 읽기
11
+ - calculate : 안전한 수식 계산
12
+ - finish : 검증 종료 + Verdict 생성
13
+
14
+ 회사 자체 Tool 추가:
15
+ from structverify.agent.tools import register_tool, ToolBase
16
+ from structverify.agent.schemas import ActionType
17
+
18
+ # ActionType에 새 항목 추가 후 (또는 기존 사용):
19
+ @register_tool(ActionType.YOUR_ACTION)
20
+ class YourTool(ToolBase):
21
+ ...
22
+
23
+ Usage (Phase D Loop에서):
24
+ from structverify.agent.tools import get_tool_class, list_tools, render_all_help
25
+
26
+ # LLM prompt에 모든 Tool 설명 삽입
27
+ prompt = f"... Available tools:\n{render_all_help()} ..."
28
+
29
+ # LLM 응답 (decision)에서 action 받아서 실행
30
+ tool_cls = get_tool_class(decision.action)
31
+ tool = tool_cls()
32
+ result = await tool.execute(decision.input, context)
33
+ """
34
+ from .base import (
35
+ ToolBase,
36
+ ToolContext,
37
+ ToolResult,
38
+ register_tool,
39
+ get_tool_class,
40
+ list_tools,
41
+ build_tool,
42
+ render_all_help,
43
+ )
44
+
45
+ # 모든 Tool 모듈 import — register_tool 데코레이터 실행 트리거.
46
+ # (import 순서 무관 — registry는 ActionType 키 기반)
47
+ from . import calculate # noqa: F401
48
+ from . import finish # noqa: F401
49
+ from . import read_original # noqa: F401
50
+ from . import catalog_search # noqa: F401
51
+ from . import fetch_evidence # noqa: F401
52
+ from . import explore_catalog # noqa: F401
53
+ from . import replan # noqa: F401
54
+
55
+ __all__ = [
56
+ "ToolBase",
57
+ "ToolContext",
58
+ "ToolResult",
59
+ "register_tool",
60
+ "get_tool_class",
61
+ "list_tools",
62
+ "build_tool",
63
+ "render_all_help",
64
+ ]