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,423 @@
1
+ """
2
+ structverify.agent.tools.finish — 종료 Tool.
3
+
4
+ Agent가 *충분히 검증했거나 더 시도해도 안 된다고 판단*했을 때 호출.
5
+
6
+ 이 Tool을 호출하면:
7
+ - workspace.write_verdict()로 verdict.json 저장
8
+ - memory.md에 "## Final Verdict" 섹션 추가
9
+ - Loop은 *이 Tool 호출 후 즉시 종료*
10
+
11
+ 호출 시 LLM이 결정해야 할 것:
12
+ - verdict: "match" | "mismatch" | "partial" | "unverifiable"
13
+ - confidence: 0.0 ~ 1.0
14
+ - explanation: 사람-읽기 자연어 (최종 출력)
15
+ - data_points: 모은 데이터 점들 (검산용)
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from structverify.utils.logger import get_logger
20
+ from datetime import datetime, timezone
21
+ from typing import Any
22
+
23
+ from ..schemas import ActionType, AgentVerdict, DataPointSpec, StopReason, VerdictType
24
+ from ..memory import append_final
25
+ from .base import ToolBase, ToolContext, ToolResult, register_tool
26
+
27
+ logger = get_logger(__name__)
28
+
29
+
30
+ _VALID_VERDICTS = {v.value for v in VerdictType}
31
+
32
+
33
+ def _has_successful_fetch_evidence(workspace, claim_id) -> bool:
34
+ """이 claim에 대해 fetch_evidence가 한 번이라도 success로 끝났는지.
35
+
36
+ LLM이 evidence 한 번도 못 받았는데 match/mismatch로 finish 호출하는
37
+ hallucination을 차단하기 위한 가드. (1-2 A안)
38
+ """
39
+ try:
40
+ names = workspace.list_observations(claim_id)
41
+ except Exception as e:
42
+ logger.debug(f"[finish] list_observations 실패: {e}")
43
+ return False
44
+ for name in names:
45
+ if "fetch" not in name.lower():
46
+ continue
47
+ data = workspace.read_observation(claim_id, name)
48
+ if not isinstance(data, dict):
49
+ continue
50
+ # 표준 observation 형식
51
+ if data.get("action") == "fetch_evidence" and data.get("success") is True:
52
+ return True
53
+ # 일부 raw 저장 형식 — evidence.value가 있으면 성공으로 간주
54
+ ev = (data.get("output") or {}).get("evidence") or data.get("evidence") or {}
55
+ if isinstance(ev, dict) and ev.get("value") is not None:
56
+ return True
57
+ return False
58
+
59
+
60
+ def _collect_fetch_evidences(workspace, claim_id) -> list[dict]:
61
+ """[2026-05-25] 이 claim의 모든 successful fetch_evidence observation을
62
+ {indicator, time_period, value, unit, stat_id} dict로 평탄화해 모음.
63
+
64
+ FinishTool이 LLM이 채운 data_points의 resolved_value를 *실제 fetched value*로
65
+ 덮어쓰기 위한 ground truth. LLM이 claim value를 그대로 박는 hallucination 방지.
66
+
67
+ indicator 우선순위: evidence dict의 indicator → observation의 params.indicator.
68
+ (EvidenceData는 indicator 필드가 없는 경우가 있어 fallback으로 params 사용)
69
+ """
70
+ out: list[dict] = []
71
+ try:
72
+ names = workspace.list_observations(claim_id)
73
+ except Exception:
74
+ return out
75
+ for name in names:
76
+ if "fetch" not in name.lower():
77
+ continue
78
+ data = workspace.read_observation(claim_id, name)
79
+ if not isinstance(data, dict):
80
+ continue
81
+ ev = (data.get("output") or {}).get("evidence") or data.get("evidence") or {}
82
+ if not isinstance(ev, dict) or ev.get("value") is None:
83
+ continue
84
+ # indicator는 evidence에 보통 비어있어 params에서 보강
85
+ _ind = str(ev.get("indicator") or "").strip()
86
+ if not _ind:
87
+ _params = data.get("params") or (data.get("output") or {}).get("params") or {}
88
+ if isinstance(_params, dict):
89
+ _ind = str(_params.get("indicator") or "").strip()
90
+ out.append({
91
+ "indicator": _ind,
92
+ "time_period": str(ev.get("time_period") or ""),
93
+ "value": ev.get("value"),
94
+ "unit": str(ev.get("unit") or ""),
95
+ "stat_id": str(ev.get("stat_table_id") or ""),
96
+ "obs_name": name, # 디버깅용 trace
97
+ })
98
+ return out
99
+
100
+
101
+ def _match_evidence_for_data_point(dp_dict: dict, evidences: list[dict]) -> dict | None:
102
+ """data_point의 (indicator, time)으로 가장 가까운 fetch evidence 찾기.
103
+
104
+ 매칭 룰:
105
+ 1) indicator + time 정규화 후 완전 일치
106
+ 2) time만 일치 (indicator는 LLM이 다르게 표기 가능)
107
+ 3) indicator만 일치
108
+ 매칭 안 되면 None — LLM이 채운 값 그대로 유지 (calculate 결과 등).
109
+ """
110
+ def _norm_time(t: str) -> str:
111
+ if not t:
112
+ return ""
113
+ s = str(t).strip().replace("-", "").replace(".", "").replace("/", "")
114
+ return s
115
+ def _norm_ind(s: str) -> str:
116
+ if not s:
117
+ return ""
118
+ return str(s).strip().replace(" ", "").lower()
119
+
120
+ dp_ind = _norm_ind(dp_dict.get("indicator", ""))
121
+ dp_time = _norm_time(dp_dict.get("time") or dp_dict.get("source_time") or "")
122
+
123
+ # 1차: indicator + time 둘 다 일치
124
+ for ev in evidences:
125
+ if _norm_ind(ev["indicator"]) == dp_ind and _norm_time(ev["time_period"]).startswith(dp_time):
126
+ return ev
127
+ if _norm_ind(ev["indicator"]) == dp_ind and dp_time.startswith(_norm_time(ev["time_period"])):
128
+ return ev
129
+ # 2차: time만 일치
130
+ if dp_time:
131
+ for ev in evidences:
132
+ ev_t = _norm_time(ev["time_period"])
133
+ if ev_t == dp_time or ev_t.startswith(dp_time) or dp_time.startswith(ev_t):
134
+ return ev
135
+ # 3차: indicator만 일치 (time 없는 경우 등)
136
+ if dp_ind:
137
+ for ev in evidences:
138
+ if _norm_ind(ev["indicator"]) == dp_ind:
139
+ return ev
140
+ return None
141
+
142
+
143
+ @register_tool(ActionType.FINISH)
144
+ class FinishTool(ToolBase):
145
+ """검증 종료 + Verdict 확정.
146
+
147
+ 이 Tool 호출 후 Loop은 *즉시 종료*. 다음 iteration 없음.
148
+
149
+ 호출 시점:
150
+ - 모든 데이터 점 확보 + 계산 완료 → MATCH / MISMATCH
151
+ - 일부만 확보됐는데 충분히 정황 파악 → PARTIAL
152
+ - 시도했지만 데이터 못 찾음 → UNVERIFIABLE
153
+ """
154
+
155
+ name = ActionType.FINISH
156
+ description = (
157
+ "검증 종료. 모든 데이터 모았거나 더 시도해도 안 될 때 호출. "
158
+ "verdict 결정 + 사용자에게 보일 explanation 작성. "
159
+ "이 Tool 호출 후 loop이 종료되므로 *마지막 결정* 신중히."
160
+ )
161
+ input_schema = {
162
+ "verdict": (
163
+ "판정. 'match' (일치) | 'mismatch' (불일치, 시점/단위 같지만 값 다름) | "
164
+ "'partial' (일부 검증) | 'unverifiable' (검증 불가능)"
165
+ ),
166
+ "confidence": "신뢰도 (0.0~1.0). match면 보통 0.9+, partial은 0.5~0.8, unverifiable은 0.2~0.5",
167
+ "explanation": "사용자에게 보일 설명 (자연어 2-4문장, KOSIS 출처 포함)",
168
+ "data_points": "확보한 데이터 점들 (선택). [{indicator, time, resolved_value, source}, ...]",
169
+ }
170
+
171
+ async def execute(
172
+ self,
173
+ input_data: dict[str, Any],
174
+ context: ToolContext,
175
+ ) -> ToolResult:
176
+ # 입력 파싱
177
+ verdict_raw = (input_data.get("verdict") or "").strip().lower()
178
+ # [2026-05-21] reflect LLM이 verdict 자리에 claim_type(comparison/absolute/...)을
179
+ # 잘못 박는 헛돌이 차단 — reject(success=False)하면 LLM이 같은 실수 반복.
180
+ # 대신 unverifiable로 강등하고 finish는 *성공*시켜 loop을 종료 → 직후
181
+ # N 패치가 schema.value vs evidence 객관 비교해 MATCH/MISMATCH로 정정 가능.
182
+ # 22:54~22:55 로그: 661202e8 claim이 iter 4, 6 두 번 verdict='comparison' 실패
183
+ # → iter 7 unverifiable로 끝난 케이스 해결.
184
+ _claim_type_misvalues = {
185
+ "comparison", "absolute", "growth_rate", "difference",
186
+ "ranking", "diff", "absolute_value", "compare",
187
+ }
188
+ if verdict_raw not in _VALID_VERDICTS:
189
+ if verdict_raw in _claim_type_misvalues:
190
+ logger.warning(
191
+ f"[finish] {context.claim_id}: verdict={verdict_raw!r}는 "
192
+ f"claim_type 값 — verdict 필드에 잘못 박힘. unverifiable로 강등 "
193
+ f"(loop 종료 후 합성 verdict가 객관 비교로 정정 가능)"
194
+ )
195
+ verdict_raw = "unverifiable"
196
+ else:
197
+ return ToolResult(
198
+ output={},
199
+ summary=f"실패: verdict={verdict_raw!r} 유효하지 않음",
200
+ success=False,
201
+ error=f"verdict는 {sorted(_VALID_VERDICTS)} 중 하나여야 합니다.",
202
+ )
203
+ verdict = VerdictType(verdict_raw)
204
+
205
+ try:
206
+ confidence = float(input_data.get("confidence", 0.0))
207
+ except (TypeError, ValueError):
208
+ confidence = 0.0
209
+ confidence = max(0.0, min(1.0, confidence))
210
+
211
+ explanation = (input_data.get("explanation") or "").strip()
212
+ if not explanation:
213
+ return ToolResult(
214
+ output={},
215
+ summary="실패: explanation 비어있음",
216
+ success=False,
217
+ error="explanation은 비울 수 없습니다.",
218
+ )
219
+
220
+ # ── 가드 (1-2 A안): evidence 한 번도 fetch 못 했는데 match/mismatch면 강등 ──
221
+ # LLM이 fetch_evidence success 없이 finish(match, conf=1.0)을 호출하는
222
+ # hallucination 차단. fetch가 한 번도 success로 끝난 적 없다면 어떤
223
+ # 결론도 안전하지 않으므로 unverifiable로 강제 변환.
224
+ if verdict in (VerdictType.MATCH, VerdictType.MISMATCH):
225
+ if not _has_successful_fetch_evidence(context.workspace, context.claim_id):
226
+ logger.warning(
227
+ f"[finish] {context.claim_id}: LLM verdict={verdict.value} "
228
+ f"호출했으나 fetch_evidence success 이력 0건 → unverifiable로 강등"
229
+ )
230
+ verdict = VerdictType.UNVERIFIABLE
231
+ confidence = min(confidence, 0.3)
232
+ explanation = (
233
+ "[자동 강등] LLM이 검증 완료로 보고했으나, 이 claim에 대해 "
234
+ "외부 데이터(fetch_evidence)를 한 번도 성공적으로 조회하지 못했습니다. "
235
+ "근거 없는 판정이므로 검증 불가로 처리합니다.\n\n"
236
+ f"원래 LLM 설명: {explanation[:300]}"
237
+ )
238
+
239
+ # data_points 파싱 (옵션)
240
+ # [2026-05-25] LLM이 채운 resolved_value가 claim 값을 그대로 박는 경우가 있음
241
+ # (예: claim "20717명" → LLM이 evidence value도 20717이라고 보고 → 실제 KOSIS
242
+ # 값은 20787인데 20717로 저장됨 → verified_facts 캐시도 오염).
243
+ # 대응: workspace의 실제 fetch_evidence observation에서 indicator/time이
244
+ # 매칭되는 값을 찾아 resolved_value를 *evidence의 값으로 덮음*.
245
+ # 매칭 안 되는 data_point는 LLM 값 유지 (계산 결과 등).
246
+ evidences = _collect_fetch_evidences(context.workspace, context.claim_id)
247
+
248
+ data_points_raw = input_data.get("data_points") or []
249
+
250
+ # 진단: evidence pool과 LLM이 채운 data_points를 한 번에 로그
251
+ logger.info(
252
+ f"[finish] {context.claim_id}: evidence pool ({len(evidences)}건) ↓\n"
253
+ + "\n".join(
254
+ f" - ev[{i}] indicator={e['indicator']!r} time={e['time_period']!r} "
255
+ f"value={e['value']!r} unit={e['unit']!r} (obs={e['obs_name']})"
256
+ for i, e in enumerate(evidences)
257
+ )
258
+ + f"\n LLM data_points_raw ({len(data_points_raw)}건): {data_points_raw}"
259
+ )
260
+
261
+ data_points: list[DataPointSpec] = []
262
+ # 이미 data_point로 덮인 (indicator,time)을 추적 — 중복 추가 방지
263
+ _covered_keys: set[tuple[str, str]] = set()
264
+
265
+ def _norm_t(t: str) -> str:
266
+ return str(t or "").strip().replace("-", "").replace(".", "")
267
+ def _norm_i(s: str) -> str:
268
+ return str(s or "").strip().replace(" ", "").lower()
269
+
270
+ for dp in data_points_raw:
271
+ if not isinstance(dp, dict):
272
+ continue
273
+ matched_ev = _match_evidence_for_data_point(dp, evidences)
274
+ if matched_ev is None:
275
+ logger.info(
276
+ f"[finish] {context.claim_id}: data_point 매칭 evidence 없음 — "
277
+ f"LLM 값 유지 (indicator={dp.get('indicator')!r}, "
278
+ f"time={dp.get('time')!r}, value={dp.get('resolved_value')!r}). "
279
+ f"이유: evidence pool에 해당 (indicator,time) 없음 또는 정규화 mismatch."
280
+ )
281
+ else:
282
+ _llm_val = dp.get("resolved_value")
283
+ _ev_val = matched_ev["value"]
284
+ if _llm_val == _ev_val:
285
+ logger.info(
286
+ f"[finish] {context.claim_id}: data_point resolved_value 일치 "
287
+ f"(LLM=evidence={_ev_val}) — 보정 불필요 "
288
+ f"(indicator={dp.get('indicator')!r}, time={dp.get('time')!r})"
289
+ )
290
+ else:
291
+ logger.info(
292
+ f"[finish] {context.claim_id}: data_point resolved_value 보정 — "
293
+ f"LLM={_llm_val} → evidence={_ev_val} "
294
+ f"(indicator={dp.get('indicator')!r}, time={dp.get('time')!r}, "
295
+ f"src_obs={matched_ev['obs_name']})"
296
+ )
297
+ # evidence ground-truth로 덮어씀 (resolved_value + resolved_unit + source)
298
+ dp = {
299
+ **dp,
300
+ "resolved_value": _ev_val,
301
+ "resolved_unit": matched_ev["unit"] or dp.get("resolved_unit"),
302
+ "source": (
303
+ f"KOSIS:{matched_ev['stat_id']}"
304
+ if matched_ev["stat_id"] else dp.get("source") or "KOSIS"
305
+ ),
306
+ "source_time": matched_ev["time_period"] or dp.get("source_time"),
307
+ }
308
+ try:
309
+ _spec = DataPointSpec(**dp)
310
+ data_points.append(_spec)
311
+ _covered_keys.add((_norm_i(_spec.indicator), _norm_t(_spec.source_time or _spec.time)))
312
+ except Exception as e:
313
+ logger.debug(f"[finish] data_point 파싱 실패: {dp} | {e}")
314
+
315
+ # [2026-05-25] 빠진 evidence 자동 보강 — LLM이 data_points에 안 박았어도
316
+ # workspace에 fetch_evidence success가 있으면 시스템이 보장해서 data_points에
317
+ # 추가. 이렇게 해야 verified_facts 캐시에 *모든 fetched 값*이 저장되어
318
+ # 다음 claim(예: 증가율)이 prev/current 둘 다 재검색 없이 즉시 가져옴.
319
+ # LLM이 1개만 박아도 시스템이 나머지를 보강하므로 derived claim 효율 보장.
320
+ for ev in evidences:
321
+ _ind = ev["indicator"]
322
+ if not _ind or ev["value"] is None:
323
+ continue
324
+ _key = (_norm_i(_ind), _norm_t(ev["time_period"]))
325
+ if _key in _covered_keys:
326
+ continue
327
+ try:
328
+ _spec = DataPointSpec(
329
+ indicator=_ind,
330
+ time=ev["time_period"],
331
+ resolved_value=ev["value"],
332
+ resolved_unit=ev["unit"] or None,
333
+ source=(
334
+ f"{ev.get('source') or 'kosis'}:{ev['stat_id']}"
335
+ if ev["stat_id"] else (ev.get("source") or "kosis")
336
+ ),
337
+ source_time=ev["time_period"] or None,
338
+ )
339
+ data_points.append(_spec)
340
+ _covered_keys.add(_key)
341
+ logger.info(
342
+ f"[finish] {context.claim_id}: evidence 자동 보강 — "
343
+ f"indicator={_ind!r} time={ev['time_period']!r} "
344
+ f"value={ev['value']!r} (LLM이 data_points에 안 넣음, "
345
+ f"src_obs={ev['obs_name']}) — verified_facts 캐시 보존용."
346
+ )
347
+ except Exception as e:
348
+ logger.debug(f"[finish] evidence 자동 보강 실패: {ev} | {e}")
349
+
350
+ # AgentVerdict 생성
351
+ agent_verdict = AgentVerdict(
352
+ claim_id=str(context.claim_id),
353
+ verdict=verdict,
354
+ confidence=confidence,
355
+ explanation=explanation,
356
+ data_points=data_points,
357
+ iterations_used=context.iter_num,
358
+ stop_reason=StopReason.COMPLETED,
359
+ )
360
+
361
+ # workspace에 저장
362
+ try:
363
+ context.workspace.write_verdict(
364
+ context.claim_id,
365
+ agent_verdict.model_dump(mode="json"),
366
+ )
367
+ except Exception as e:
368
+ logger.warning(f"[finish] verdict 저장 실패: {e}")
369
+
370
+ # [2026-05-25] 디버깅/UI 추적용 — claim 디렉토리에 최종 data_points 덤프.
371
+ # 어떤 LLM raw vs evidence-corrected 값으로 결론이 났는지 추적 가능.
372
+ try:
373
+ dp_dump = {
374
+ "claim_id": str(context.claim_id),
375
+ "verdict": verdict.value,
376
+ "confidence": confidence,
377
+ "iter_num": context.iter_num,
378
+ "evidences_collected": [
379
+ {
380
+ "indicator": e["indicator"], "time_period": e["time_period"],
381
+ "value": e["value"], "unit": e["unit"],
382
+ "stat_id": e["stat_id"], "obs": e["obs_name"],
383
+ } for e in evidences
384
+ ],
385
+ "llm_data_points_raw": data_points_raw,
386
+ "final_data_points": [dp.model_dump(mode="json") for dp in data_points],
387
+ }
388
+ context.workspace.write_observation(
389
+ context.claim_id, "_final_data_points", dp_dump,
390
+ )
391
+ context.workspace.write_data_points(
392
+ context.claim_id, [dp.model_dump(mode="json") for dp in data_points],
393
+ )
394
+ except Exception as e:
395
+ logger.debug(f"[finish] data_points dump 실패 (무시): {e}")
396
+
397
+ # memory에 final 섹션 추가
398
+ try:
399
+ append_final(
400
+ context.workspace,
401
+ context.claim_id,
402
+ verdict=verdict.value,
403
+ confidence=confidence,
404
+ reason=explanation[:200], # 너무 길면 잘라서 memory에는 요약만
405
+ iterations_used=context.iter_num,
406
+ )
407
+ except Exception as e:
408
+ logger.debug(f"[finish] memory append 실패: {e}")
409
+
410
+ return ToolResult(
411
+ output={
412
+ "verdict": verdict.value,
413
+ "confidence": confidence,
414
+ "iterations_used": context.iter_num,
415
+ "data_points_count": len(data_points),
416
+ "_finish": True, # Loop에게 *종료 신호*
417
+ },
418
+ summary=(
419
+ f"FINISH: verdict={verdict.value} confidence={confidence:.2f} "
420
+ f"data_points={len(data_points)}"
421
+ ),
422
+ success=True,
423
+ )
@@ -0,0 +1,267 @@
1
+ """
2
+ structverify.agent.tools.meta_explore — P30: KOSIS getMeta 기반 LLM reasoning.
3
+
4
+ 배경:
5
+ P28 deep_explore (row preview)는 표마다 전체 데이터(8천~1.3만 row)를 받아
6
+ 표당 22~26초 소요. KOSIS의 *메타 API* (getMeta type=ITM/OBJL)는 같은 표의
7
+ *항목/분류 list* 만 반환해 표당 ~1초로 훨씬 가벼움. 게다가:
8
+
9
+ - ITM_NM list에 "체외 충격파 쇄석술기" 같은 *세부 row keyword*가 직접 포함됨
10
+ - C1_NM/C2_NM 분류에 "강원도" 같은 지역 코드가 명시됨
11
+ → LLM이 *확실한 증거 기반*으로 best 표 식별 가능 (외삽 reasoning 불필요)
12
+
13
+ deep_explore와 인터페이스 동일 (ExplorationResult 반환). catalog_search Tool이
14
+ config.catalog_search.deep_explore.explore_mode = "meta"일 때 이 함수를 호출.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import re
21
+ from typing import Any
22
+
23
+ from structverify.utils.logger import get_logger
24
+
25
+ from .deep_explore import ExplorationResult
26
+
27
+ logger = get_logger(__name__)
28
+
29
+
30
+ async def _fetch_meta(
31
+ candidate_id: str,
32
+ source: Any,
33
+ include_obj: bool,
34
+ ) -> dict | None:
35
+ """한 표의 getMeta(ITM) + (옵션) getMeta(OBJL01) 호출.
36
+
37
+ Returns:
38
+ {"itm": [...], "obj": [...]} dict. 실패 시 None.
39
+ """
40
+ try:
41
+ itm_task = source.get_table_meta(candidate_id=candidate_id, meta_type="ITM")
42
+ if include_obj:
43
+ obj_task = source.get_table_meta(candidate_id=candidate_id, meta_type="OBJL01")
44
+ itm, obj = await asyncio.gather(itm_task, obj_task)
45
+ else:
46
+ itm = await itm_task
47
+ obj = None
48
+ if itm is None and obj is None:
49
+ return None
50
+ return {"itm": itm or [], "obj": obj or []}
51
+ except Exception as e:
52
+ logger.debug(f"[meta_explore] meta fetch {candidate_id} 실패: {e}")
53
+ return None
54
+
55
+
56
+ def _extract_names(meta_rows: Any, name_keys: tuple[str, ...]) -> list[str]:
57
+ """KOSIS getMeta 응답에서 *이름* list 추출.
58
+
59
+ 응답은 보통 [{"ITM_ID": "...", "ITM_NM": "...", ...}, ...] 또는
60
+ [{"C1_NM": "...", "C1": "...", ...}] 형식. name_keys 순서대로 첫 hit 사용.
61
+ """
62
+ if not isinstance(meta_rows, list):
63
+ return []
64
+ out: list[str] = []
65
+ seen: set[str] = set()
66
+ for r in meta_rows:
67
+ if not isinstance(r, dict):
68
+ continue
69
+ for k in name_keys:
70
+ v = r.get(k)
71
+ if v and isinstance(v, str):
72
+ s = v.strip()
73
+ if s and s not in seen:
74
+ seen.add(s)
75
+ out.append(s)
76
+ break
77
+ return out
78
+
79
+
80
+ def _build_prompt(
81
+ query: str,
82
+ claim_info: dict,
83
+ candidates_with_meta: list[dict],
84
+ max_items_per_table: int = 50,
85
+ ) -> str:
86
+ """LLM prompt — ITM list + OBJ list 보고 best 표 선택."""
87
+ lines: list[str] = []
88
+ for i, c in enumerate(candidates_with_meta, start=1):
89
+ cid = c.get("id", "")
90
+ cname = (c.get("name", "") or "").strip()
91
+ score = c.get("score")
92
+ head = f"{i}. [{cid}] {cname}"
93
+ if isinstance(score, (int, float)):
94
+ head += f" (catalog_score={score:.3f})"
95
+ lines.append(head)
96
+ meta = c.get("_meta")
97
+ if meta:
98
+ itm_names = _extract_names(meta.get("itm"), ("ITM_NM",))
99
+ obj_names = _extract_names(meta.get("obj"), ("C1_NM", "OBJL_NM"))
100
+ if itm_names:
101
+ shown = itm_names[:max_items_per_table]
102
+ more = f" (외 {len(itm_names)-len(shown)}개)" if len(itm_names) > len(shown) else ""
103
+ lines.append(f" - 통계항목 ITM_NM: {', '.join(shown)}{more}")
104
+ if obj_names:
105
+ shown = obj_names[:max_items_per_table]
106
+ more = f" (외 {len(obj_names)-len(shown)}개)" if len(obj_names) > len(shown) else ""
107
+ lines.append(f" - 분류 OBJ_NM: {', '.join(shown)}{more}")
108
+ if not itm_names and not obj_names:
109
+ lines.append(" - (메타 비어있음)")
110
+ else:
111
+ lines.append(" - (메타 fetch 실패 — catalog score만으로 추정)")
112
+
113
+ return f"""당신은 통계표 *식별 reviewer*입니다. 사용자가 찾는 *구체 항목*이
114
+ 어느 표의 통계항목(ITM_NM) 또는 분류(OBJ_NM)에 포함되어 있는지 판단하세요.
115
+
116
+ [사용자 검색 의도]
117
+ - query: {query!r}
118
+ - indicator (찾는 지표): {claim_info.get('indicator')!r}
119
+ - population (대상 집단/지역): {claim_info.get('population')!r}
120
+ - time_period: {claim_info.get('time_period')!r}
121
+ - unit: {claim_info.get('unit')!r}
122
+
123
+ [후보 표 + 메타 항목/분류]
124
+ {chr(10).join(lines)}
125
+
126
+ [판단 기준]
127
+ 1. 어느 표의 ITM_NM list에 indicator의 *핵심 키워드*가 직접/유사 매칭되는가? (예: indicator="체외 충격파 쇄석술 장비" → ITM_NM에 "체외 충격파 쇄석술기" 또는 "ESWL" 같은 항목이 있으면 매칭).
128
+ 2. OBJ_NM list가 population(지역/집단)을 포함하는가? (예: population="강원도" → OBJ에 "강원" 또는 시도 분류 있으면 매칭).
129
+ 3. ITM+OBJ 둘 다 매칭되는 표가 정답일 가능성 최상.
130
+ 4. 어느 표에도 매칭 단서가 없으면 best_stat_id를 "none"으로 응답. *억지로 고르지 말 것*.
131
+
132
+ [응답 형식 — JSON only, 다른 텍스트 금지]
133
+ {{
134
+ "best_stat_id": "DT_XXX" or "none",
135
+ "reasoning": "ITM/OBJ 매칭 근거 한 줄 (어떤 항목이 어디 있는지 명시)",
136
+ "confidence": 0.0~1.0
137
+ }}
138
+ """
139
+
140
+
141
+ def _parse_response(raw: str, candidate_ids: list[str]) -> tuple[str | None, str, bool]:
142
+ """LLM 응답 파싱 (deep_explore와 동일 로직)."""
143
+ try:
144
+ m = re.search(r"\{[^{}]*\}", raw, re.DOTALL)
145
+ if not m:
146
+ return None, "", False
147
+ data = json.loads(m.group(0))
148
+ except Exception as e:
149
+ logger.debug(f"[meta_explore] JSON 파싱 실패: {e}")
150
+ return None, "", False
151
+
152
+ raw_best = (data.get("best_stat_id") or "").strip()
153
+ reasoning = str(data.get("reasoning") or "").strip()
154
+
155
+ if raw_best.lower() in ("none", "null", "", "n/a"):
156
+ return None, reasoning, True
157
+
158
+ best = raw_best.strip().strip("[]").strip("'\"").strip()
159
+ if best in candidate_ids:
160
+ return best, reasoning, False
161
+
162
+ for cid in candidate_ids:
163
+ if cid and (best in cid or cid in best):
164
+ logger.info(f"[meta_explore] best={best!r} → substring 매칭 {cid!r}")
165
+ return cid, reasoning, False
166
+
167
+ logger.info(f"[meta_explore] best={best!r}가 후보 list에 없음 — 무효 처리")
168
+ return None, reasoning, False
169
+
170
+
171
+ async def meta_explore(
172
+ *,
173
+ query: str,
174
+ candidates: list[dict[str, Any]],
175
+ claim: Any,
176
+ source: Any,
177
+ workspace: Any,
178
+ config: dict | None,
179
+ ) -> ExplorationResult:
180
+ """top N 표의 getMeta(ITM/OBJ) → LLM이 *항목 list* 보고 best 표 식별.
181
+
182
+ deep_explore와 동일 인터페이스. config.catalog_search.deep_explore의
183
+ top_n / model_tier / include_obj 사용.
184
+
185
+ Args:
186
+ query: catalog 검색 쿼리.
187
+ candidates: catalog 후보 list (이미 점수순). 각 dict는 {"id", "name", "score"}.
188
+ claim: Claim 객체.
189
+ source: BaseDataSource (get_table_meta 지원해야).
190
+ workspace: (현재 미사용, 인터페이스 호환용).
191
+ config: 전체 config dict.
192
+
193
+ Returns:
194
+ ExplorationResult.
195
+ """
196
+ _cfg = (config or {}).get("catalog_search") or {}
197
+ _dx = _cfg.get("deep_explore") or {}
198
+ top_n = int(_dx.get("top_n") or 5)
199
+ include_obj = bool(_dx.get("include_obj", True))
200
+
201
+ if not candidates:
202
+ return ExplorationResult(None, "", False, [], used=False)
203
+
204
+ top_candidates = candidates[:top_n]
205
+ candidate_ids = [c.get("id", "") for c in top_candidates if c.get("id")]
206
+ if not candidate_ids:
207
+ return ExplorationResult(None, "", False, [], used=False)
208
+
209
+ # 1) meta 병렬 fetch
210
+ meta_tasks = [_fetch_meta(cid, source, include_obj) for cid in candidate_ids]
211
+ meta_results = await asyncio.gather(*meta_tasks, return_exceptions=False)
212
+
213
+ previewed_ids: list[str] = []
214
+ enriched: list[dict[str, Any]] = []
215
+ for c, meta in zip(top_candidates, meta_results):
216
+ d = dict(c)
217
+ if meta is not None:
218
+ d["_meta"] = meta
219
+ previewed_ids.append(c.get("id", ""))
220
+ enriched.append(d)
221
+
222
+ if not previewed_ids:
223
+ logger.info("[meta_explore] 메타 fetch 0건 — LLM 호출 skip")
224
+ return ExplorationResult(None, "", False, [], used=False)
225
+
226
+ # 2) claim info
227
+ _schema = getattr(claim, "schema", None) if claim is not None else None
228
+ claim_info = {
229
+ "indicator": (getattr(_schema, "indicator", None) or "") if _schema else "",
230
+ "population": (getattr(_schema, "population", None) or "") if _schema else "",
231
+ "time_period": (getattr(_schema, "time_period", None) or "") if _schema else "",
232
+ "unit": (getattr(_schema, "unit", None) or "") if _schema else "",
233
+ }
234
+
235
+ # 3) LLM 호출
236
+ prompt = _build_prompt(query, claim_info, enriched)
237
+ model_tier = str(_dx.get("model_tier") or "light").strip().lower()
238
+
239
+ from structverify.utils.llm_client import LLMClient
240
+ llm = LLMClient(config=(config or {}).get("llm") or {})
241
+ try:
242
+ raw = await llm.generate(
243
+ prompt=prompt,
244
+ system_prompt=(
245
+ "KOSIS 통계표 식별 reviewer. ITM/OBJ 메타 기반 reasoning. JSON만 응답."
246
+ ),
247
+ model_tier=model_tier,
248
+ )
249
+ except Exception as e:
250
+ logger.warning(f"[meta_explore] LLM 호출 실패 (model_tier={model_tier}): {e}")
251
+ return ExplorationResult(None, "", False, previewed_ids, used=False)
252
+
253
+ best, reasoning, none_signal = _parse_response(raw, candidate_ids)
254
+ if none_signal:
255
+ logger.info(f"[meta_explore] LLM none_signal — reasoning={reasoning[:120]!r}")
256
+ elif best:
257
+ logger.info(f"[meta_explore] LLM 추천 best={best!r} reasoning={reasoning[:120]!r}")
258
+ else:
259
+ logger.info(f"[meta_explore] LLM 응답 파싱 결과 best=None — 무효 응답으로 처리")
260
+
261
+ return ExplorationResult(
262
+ best_table_id=best,
263
+ reasoning=reasoning,
264
+ none_signal=none_signal,
265
+ previewed_ids=previewed_ids,
266
+ used=True,
267
+ )