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,817 @@
1
+ """structverify.agent.planner — Plan Agent (Phase C).
2
+
3
+ Pipeline 위치:
4
+ Claim (with schema) → **Planner** → Plan → Loop (Phase D)
5
+
6
+ Planner 책임:
7
+ 1. claim에서 *어떤 데이터가 필요한지* 결정 (LLM 호출)
8
+ 2. *claim type* 분류 (absolute / growth_rate / diff / ratio / other)
9
+ 3. 1차 시도용 *initial steps* 제안
10
+ 4. fallback 전략 (1차 실패 시)
11
+
12
+ Reflect Agent (Phase D)는 *이 Plan을 보면서* 실제 행동 결정.
13
+ Plan은 *제안*이지 *명령*이 아님 — Reflect는 plan 무시하고 다른 step 시도 가능.
14
+
15
+ LLM client는 *callable*로 주입 (의존성 주입).
16
+ 사용자 환경의 HCX/다른 LLM과 무관하게 wrap 가능.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from structverify.utils.logger import get_logger
22
+ import re
23
+ from dataclasses import dataclass
24
+ from typing import Any, Awaitable, Callable, Protocol
25
+
26
+ from pydantic import ValidationError
27
+
28
+ from .schemas import (
29
+ ActionType,
30
+ ClaimType,
31
+ DataPointSpec,
32
+ FallbackStrategy,
33
+ Plan,
34
+ PlanStep,
35
+ )
36
+ from .prompts import build_plan_prompt
37
+
38
+ logger = get_logger(__name__)
39
+
40
+
41
+ # ── LLM Client 인터페이스 ──────────────────────────────────────────
42
+
43
+ class LLMClient(Protocol):
44
+ """Planner가 사용하는 LLM 호출 인터페이스.
45
+
46
+ 사용자 환경의 HCX client (또는 다른 LLM)를 *이 형태로 wrap*하면 됨.
47
+ Phase F integration에서 실제 wiring.
48
+ """
49
+
50
+ async def complete(
51
+ self,
52
+ prompt: str,
53
+ model: str = "",
54
+ temperature: float = 0.1,
55
+ max_tokens: int = 4000,
56
+ **kwargs: Any,
57
+ ) -> str:
58
+ """prompt 보내고 응답 텍스트 받기. 동기든 비동기든 OK."""
59
+ ...
60
+
61
+
62
+ # Callable 형태도 지원 (가장 단순한 의존성 주입)
63
+ LLMCallable = Callable[[str], Awaitable[str]]
64
+
65
+
66
+ # ── 헬퍼: claim 정보 추출 ──────────────────────────────────────────
67
+
68
+ def _extract_schema_info(claim: Any) -> dict[str, Any]:
69
+ """Claim 객체에서 schema 정보를 *dict로* 추출.
70
+
71
+ Claim의 정확한 형태는 사용자 코드의 ClaimSchema에 의존하지만,
72
+ pydantic dump 또는 dict-like 접근으로 *어떤 형태든* 호환.
73
+ """
74
+ if not claim:
75
+ return {}
76
+
77
+ schema = getattr(claim, "schema", None) or getattr(claim, "claim_schema", None)
78
+ if schema is None:
79
+ return {}
80
+
81
+ # pydantic v2 model_dump
82
+ if hasattr(schema, "model_dump"):
83
+ try:
84
+ return schema.model_dump(mode="json", exclude_none=True)
85
+ except Exception:
86
+ pass
87
+ # pydantic v1 dict()
88
+ if hasattr(schema, "dict"):
89
+ try:
90
+ return schema.dict(exclude_none=True)
91
+ except Exception:
92
+ pass
93
+ # 평범한 dict
94
+ if isinstance(schema, dict):
95
+ return {k: v for k, v in schema.items() if v is not None}
96
+ return {}
97
+
98
+
99
+ def _extract_claim_id(claim: Any) -> str:
100
+ """Claim의 식별자 추출."""
101
+ for attr in ("claim_id", "id", "sent_id"):
102
+ v = getattr(claim, attr, None)
103
+ if v:
104
+ return str(v)
105
+ return "unknown"
106
+
107
+
108
+ def _extract_claim_text(claim: Any) -> str:
109
+ """Claim의 본문 텍스트 추출."""
110
+ for attr in ("claim_text", "text", "sentence"):
111
+ v = getattr(claim, attr, None)
112
+ if v:
113
+ return str(v)
114
+ return ""
115
+
116
+
117
+ # ── JSON 추출 ─────────────────────────────────────────────────────
118
+
119
+ _JSON_PATTERNS = [
120
+ # ```json ... ``` 또는 ``` ... ```
121
+ re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL),
122
+ # 그냥 { ... } (첫 번째 매칭)
123
+ re.compile(r"(\{.*\})", re.DOTALL),
124
+ ]
125
+
126
+
127
+ def _extract_balanced_json(text: str) -> str | None:
128
+ """첫 '{' 부터 매칭되는 '}'까지 추출 (brace counting).
129
+
130
+ 정규식과 달리 응답이 잘려서 닫는 ``` 가 없거나, 중첩된 ``` 가 있어도
131
+ 동작한다. 문자열 리터럴 내부의 brace는 무시.
132
+ """
133
+ if not text:
134
+ return None
135
+ start = text.find("{")
136
+ if start < 0:
137
+ return None
138
+ depth = 0
139
+ in_string = False
140
+ escape = False
141
+ for i in range(start, len(text)):
142
+ c = text[i]
143
+ if escape:
144
+ escape = False
145
+ continue
146
+ if c == "\\":
147
+ escape = True
148
+ continue
149
+ if c == '"':
150
+ in_string = not in_string
151
+ continue
152
+ if in_string:
153
+ continue
154
+ if c == "{":
155
+ depth += 1
156
+ elif c == "}":
157
+ depth -= 1
158
+ if depth == 0:
159
+ return text[start : i + 1]
160
+ return None
161
+
162
+
163
+ def _extract_json_from_response(text: str) -> dict[str, Any]:
164
+ """LLM 응답에서 JSON 부분 추출 + 파싱.
165
+
166
+ 응답이 ```json fenced``` 또는 plain JSON, 코드 펜스 잘림 모두 처리.
167
+
168
+ Returns:
169
+ dict. 파싱 실패 시 빈 dict + 로그.
170
+ """
171
+ if not text:
172
+ return {}
173
+
174
+ text = text.strip()
175
+
176
+ # 1차: 정규식 패턴들 시도
177
+ for pattern in _JSON_PATTERNS:
178
+ match = pattern.search(text)
179
+ if not match:
180
+ continue
181
+ candidate = match.group(1)
182
+ try:
183
+ return json.loads(candidate)
184
+ except json.JSONDecodeError as e:
185
+ logger.debug(f"[planner] JSON 파싱 실패 ({e}), 다음 패턴 시도")
186
+ continue
187
+
188
+ # 2차: brace-counting fallback (코드 펜스 없이/잘림 대응)
189
+ balanced = _extract_balanced_json(text)
190
+ if balanced:
191
+ try:
192
+ result = json.loads(balanced)
193
+ logger.info("[planner] JSON 추출: brace-counting fallback 성공")
194
+ return result
195
+ except json.JSONDecodeError as e:
196
+ logger.debug(f"[planner] brace-counting JSON 파싱 실패: {e}")
197
+
198
+ # 3차: 전체 text 시도
199
+ try:
200
+ return json.loads(text)
201
+ except json.JSONDecodeError:
202
+ logger.warning(f"[planner] JSON 추출 실패. 응답 일부: {text[:300]!r}")
203
+ return {}
204
+
205
+
206
+ # ── Plan 파싱 ─────────────────────────────────────────────────────
207
+
208
+ def _normalize_initial_steps(
209
+ initial_steps: list[PlanStep],
210
+ required_data: list[DataPointSpec],
211
+ claim_id: str,
212
+ fallback_query: str = "",
213
+ ) -> list[PlanStep]:
214
+ """Plan steps 정상화: catalog_search 누락 시 자동 보강.
215
+
216
+ LLM은 종종 다음과 같이 부실한 plan을 만든다:
217
+ A) initial_steps=[] (빈 배열)
218
+ B) [fetch_evidence(candidate_id='<...>')] (catalog_search 빼먹음)
219
+ 두 경우 모두 loop의 _interpolate_step_input이 candidate_id placeholder를
220
+ 보간할 수 없어서 placeholder string이 그대로 KOSIS source까지 흘러간다.
221
+
222
+ 여기서 정상화:
223
+ - case A → [catalog_search, fetch_evidence] 자동 추가
224
+ - case B → 첫 fetch_evidence 앞에 catalog_search prepend +
225
+ fetch의 candidate_id를 표준 placeholder로 통일
226
+
227
+ fallback_query: required_data가 비어있을 때 catalog_search query로 쓸 문자열
228
+ (보통 claim_text 또는 schema.indicator).
229
+ 비어있으면 catalog_search가 'query 비어있음' 실패 → 보간 불가.
230
+ """
231
+ # query: required_data의 첫 indicator 우선, 없으면 fallback_query
232
+ query = ""
233
+ if required_data:
234
+ query = getattr(required_data[0], "indicator", "") or ""
235
+ if not query:
236
+ query = (fallback_query or "").strip()
237
+
238
+ has_catalog = any(s.action == ActionType.CATALOG_SEARCH for s in initial_steps)
239
+ has_fetch = any(s.action == ActionType.FETCH_EVIDENCE for s in initial_steps)
240
+
241
+ # case A: 빈 plan
242
+ if not initial_steps:
243
+ logger.warning(
244
+ f"[planner] {claim_id}: LLM이 빈 initial_steps 반환 — "
245
+ f"[catalog_search, fetch_evidence] 자동 추가 (query={query!r})"
246
+ )
247
+ return [
248
+ PlanStep(
249
+ action=ActionType.CATALOG_SEARCH,
250
+ input={"query": query, "top_k": 5},
251
+ rationale="[auto-prepended] catalog_search 자동 추가",
252
+ ),
253
+ PlanStep(
254
+ action=ActionType.FETCH_EVIDENCE,
255
+ input={"candidate_id": "<catalog_search 결과의 top id>", "params": {}},
256
+ rationale="[auto-prepended] 후보 1번 표 데이터 가져오기",
257
+ ),
258
+ ]
259
+
260
+ # case B: fetch만 있고 catalog_search 없음
261
+ if has_fetch and not has_catalog:
262
+ logger.warning(
263
+ f"[planner] {claim_id}: catalog_search 누락 — "
264
+ f"fetch_evidence 앞에 자동 prepend (query={query!r})"
265
+ )
266
+ normalized: list[PlanStep] = []
267
+ for s in initial_steps:
268
+ if s.action == ActionType.FETCH_EVIDENCE:
269
+ inp = dict(s.input or {})
270
+ cid = inp.get("candidate_id", "")
271
+ # placeholder-like check
272
+ is_ph = (
273
+ not cid
274
+ or (isinstance(cid, str) and (
275
+ cid.startswith("<")
276
+ or "검색" in cid
277
+ or "search" in cid.lower()
278
+ or "결과" in cid
279
+ ))
280
+ )
281
+ if is_ph:
282
+ inp["candidate_id"] = "<catalog_search 결과의 top id>"
283
+ normalized.append(PlanStep(
284
+ action=s.action,
285
+ input=inp,
286
+ rationale=s.rationale,
287
+ ))
288
+ else:
289
+ normalized.append(s)
290
+ return [
291
+ PlanStep(
292
+ action=ActionType.CATALOG_SEARCH,
293
+ input={"query": query, "top_k": 5},
294
+ rationale="[auto-prepended] catalog_search 자동 추가",
295
+ ),
296
+ *normalized,
297
+ ]
298
+
299
+ # case C: 정상 (catalog_search + fetch_evidence) 또는 다른 패턴 — 그대로
300
+ return initial_steps
301
+
302
+
303
+ def _parse_plan(
304
+ response_text: str,
305
+ claim_id: str,
306
+ fallback_query: str = "",
307
+ ) -> Plan | None:
308
+ """LLM 응답을 Plan 객체로 변환.
309
+
310
+ 실패 시 None 반환 + 로그. 호출자가 fallback (heuristic plan 등) 처리.
311
+
312
+ fallback_query: required_data가 비었을 때 catalog_search query로 쓸 문자열.
313
+ """
314
+ data = _extract_json_from_response(response_text)
315
+ if not data:
316
+ return None
317
+
318
+ # ── claim_type 파싱 ──
319
+ raw_type = (data.get("claim_type") or "unknown").strip().lower()
320
+ type_map = {
321
+ "absolute": ClaimType.ABSOLUTE,
322
+ "growth_rate": ClaimType.GROWTH_RATE,
323
+ "difference": ClaimType.DIFFERENCE,
324
+ "diff": ClaimType.DIFFERENCE, # alias
325
+ "comparison": ClaimType.COMPARISON,
326
+ "ratio_comparison": ClaimType.COMPARISON, # alias (legacy)
327
+ "ranking": ClaimType.RANKING,
328
+ "aggregation": ClaimType.AGGREGATION, # [2026-05-21] 다년 평균/총합
329
+ "aggregate": ClaimType.AGGREGATION, # alias
330
+ "average": ClaimType.AGGREGATION, # alias (LLM이 average로 출력하는 경우)
331
+ "mean": ClaimType.AGGREGATION, # alias
332
+ "sum": ClaimType.AGGREGATION, # alias
333
+ "total": ClaimType.AGGREGATION, # alias
334
+ "unknown": ClaimType.UNKNOWN,
335
+ "other": ClaimType.UNKNOWN, # alias (legacy)
336
+ }
337
+ claim_type = type_map.get(raw_type, ClaimType.UNKNOWN)
338
+
339
+ # ── required_data 파싱 ──
340
+ raw_data = data.get("required_data") or []
341
+ required_data: list[DataPointSpec] = []
342
+ for item in raw_data:
343
+ if not isinstance(item, dict):
344
+ continue
345
+ try:
346
+ # role은 schema에 없을 수도 있으니 안전하게
347
+ spec_kwargs = {
348
+ "indicator": str(item.get("indicator", "")).strip(),
349
+ "time": str(item.get("time", "")).strip(),
350
+ "population": item.get("population") or None,
351
+ "unit_hint": item.get("unit_hint") or item.get("unit") or None,
352
+ }
353
+ # 비어있는 필수 필드면 스킵
354
+ if not spec_kwargs["indicator"] or not spec_kwargs["time"]:
355
+ logger.debug(f"[planner] data point 스킵 (필수 필드 누락): {item}")
356
+ continue
357
+ spec = DataPointSpec(**spec_kwargs)
358
+ required_data.append(spec)
359
+ except (ValidationError, TypeError) as e:
360
+ logger.debug(f"[planner] DataPointSpec 파싱 실패: {item} | {e}")
361
+
362
+ # ── initial_steps 파싱 ──
363
+ raw_steps = data.get("initial_steps") or []
364
+ initial_steps: list[PlanStep] = []
365
+ for item in raw_steps:
366
+ if not isinstance(item, dict):
367
+ continue
368
+ action_str = (item.get("action") or "").strip().lower()
369
+ try:
370
+ action = ActionType(action_str)
371
+ except ValueError:
372
+ logger.debug(f"[planner] 알 수 없는 action: {action_str!r}, 스킵")
373
+ continue
374
+ try:
375
+ step = PlanStep(
376
+ action=action,
377
+ input=item.get("input") or {},
378
+ rationale=str(item.get("rationale") or "").strip(),
379
+ )
380
+ initial_steps.append(step)
381
+ except (ValidationError, TypeError) as e:
382
+ logger.debug(f"[planner] PlanStep 파싱 실패: {item} | {e}")
383
+
384
+ # ── fallback 파싱 ──
385
+ raw_fallback = data.get("fallback") or {}
386
+ if not isinstance(raw_fallback, dict):
387
+ raw_fallback = {}
388
+ try:
389
+ fallback = FallbackStrategy(
390
+ use_original_text=bool(raw_fallback.get("use_original_text", False)),
391
+ alternative_keywords=list(raw_fallback.get("alternative_keywords") or []),
392
+ give_up_after_attempts=int(raw_fallback.get("give_up_after_attempts", 5)),
393
+ )
394
+ except (ValidationError, TypeError, ValueError) as e:
395
+ logger.debug(f"[planner] FallbackStrategy 파싱 실패: {e}")
396
+ fallback = FallbackStrategy()
397
+
398
+ # ── ★ initial_steps 정상화 (LLM의 부실한 plan 보강) ──
399
+ initial_steps = _normalize_initial_steps(
400
+ initial_steps, required_data, claim_id, fallback_query=fallback_query
401
+ )
402
+
403
+ # ── Plan 생성 ──
404
+ try:
405
+ plan = Plan(
406
+ claim_id=claim_id,
407
+ claim_type=claim_type,
408
+ required_data=required_data,
409
+ initial_steps=initial_steps,
410
+ fallback=fallback,
411
+ calculation_formula=(data.get("calculation_formula") or None) or None,
412
+ notes=str(data.get("notes") or "").strip() or None,
413
+ )
414
+ return plan
415
+ except ValidationError as e:
416
+ logger.warning(f"[planner] Plan 최종 validation 실패: {e}")
417
+ return None
418
+
419
+
420
+ # ── Heuristic Fallback Plan ──────────────────────────────────────
421
+
422
+ def _heuristic_plan(claim: Any, claim_id: str) -> Plan:
423
+ """LLM 호출 실패 시 *최소한의 Plan*을 만들어 loop이 멈추지 않게.
424
+
425
+ - claim_type=other
426
+ - schema가 있으면 데이터 점 1개 (current role)
427
+ - 첫 step: catalog_search (indicator 기반)
428
+ """
429
+ schema_info = _extract_schema_info(claim)
430
+ claim_text = _extract_claim_text(claim)
431
+
432
+ required_data: list[DataPointSpec] = []
433
+ if schema_info.get("indicator"):
434
+ try:
435
+ required_data.append(DataPointSpec(
436
+ indicator=str(schema_info["indicator"]),
437
+ time=str(schema_info.get("time_period") or ""),
438
+ population=schema_info.get("population") or None,
439
+ unit_hint=schema_info.get("unit") or None,
440
+ ))
441
+ except (ValidationError, TypeError):
442
+ pass
443
+
444
+ # 추측 검색어
445
+ query_terms = []
446
+ if schema_info.get("indicator"):
447
+ query_terms.append(str(schema_info["indicator"]))
448
+ if schema_info.get("parent_path"):
449
+ # parent_path가 "인구 > 출생 > 출생아 수" 같은 형식
450
+ parts = [p.strip() for p in str(schema_info["parent_path"]).split(">") if p.strip()]
451
+ if len(parts) >= 1:
452
+ query_terms.append(parts[0])
453
+
454
+ query = " ".join(query_terms) if query_terms else (claim_text[:30] if claim_text else "통계")
455
+
456
+ initial_steps = [
457
+ PlanStep(
458
+ action=ActionType.CATALOG_SEARCH,
459
+ input={"query": query, "top_k": 5},
460
+ rationale="(heuristic fallback) indicator 기반 1차 검색",
461
+ ),
462
+ PlanStep(
463
+ action=ActionType.FINISH,
464
+ input={
465
+ "verdict": "unverifiable",
466
+ "confidence": 0.3,
467
+ "explanation": "Plan Agent가 LLM 호출에 실패하여 휴리스틱 fallback. 자세한 검증 불가.",
468
+ },
469
+ rationale="(heuristic fallback) plan 부재로 최종 unverifiable",
470
+ ),
471
+ ]
472
+
473
+ return Plan(
474
+ claim_id=claim_id,
475
+ claim_type=ClaimType.UNKNOWN,
476
+ required_data=required_data,
477
+ initial_steps=initial_steps,
478
+ fallback=FallbackStrategy(),
479
+ notes="heuristic fallback plan (LLM 미사용 또는 실패)",
480
+ )
481
+
482
+
483
+ # ── Planner 본체 ──────────────────────────────────────────────────
484
+
485
+ @dataclass
486
+ class PlannerConfig:
487
+ """Planner 동작 설정."""
488
+
489
+ model: str = "HCX-007"
490
+ """LLM 모델 이름. config.agent.llm.plan_model 에서 가져오면 됨."""
491
+
492
+ temperature: float = 0.1
493
+ """낮을수록 결정적. plan은 결정적이 좋음 → 0.1 권장."""
494
+
495
+ max_tokens: int = 4000
496
+
497
+ max_retries: int = 1
498
+ """JSON 파싱 실패 시 LLM 재호출 횟수. 0이면 fallback 즉시."""
499
+
500
+
501
+ class Planner:
502
+ """Plan Agent.
503
+
504
+ Usage:
505
+ planner = Planner(llm_call=my_llm_call, config=PlannerConfig(model="HCX-007"))
506
+ plan = await planner.plan(claim, source_text=article_text, anchor_year=2025)
507
+ workspace.write_plan(claim.claim_id, plan.model_dump(mode="json"))
508
+ """
509
+
510
+ def __init__(
511
+ self,
512
+ llm_call: LLMCallable | None = None,
513
+ config: PlannerConfig | None = None,
514
+ ):
515
+ self.llm_call = llm_call
516
+ self.config = config or PlannerConfig()
517
+
518
+ async def plan(
519
+ self,
520
+ claim: Any,
521
+ source_text: str | None = None,
522
+ anchor_year: int | str | None = None,
523
+ ) -> Plan:
524
+ """Claim → Plan.
525
+
526
+ Args:
527
+ claim: structverify의 Claim 객체. .claim_text + .schema + .claim_id 속성 가정.
528
+ source_text: 원문 기사 전체 (옵션 — prompt에 일부 삽입).
529
+ anchor_year: 문서 anchor_year (옵션 — 시점 해소용).
530
+
531
+ Returns:
532
+ Plan. LLM 호출 실패 시 *heuristic fallback Plan*.
533
+ """
534
+ claim_id = _extract_claim_id(claim)
535
+ claim_text = _extract_claim_text(claim)
536
+ schema_info = _extract_schema_info(claim)
537
+
538
+ if not claim_text:
539
+ logger.warning(f"[planner] {claim_id}: claim_text 비어있음, heuristic fallback")
540
+ return _heuristic_plan(claim, claim_id)
541
+
542
+ if self.llm_call is None:
543
+ logger.warning(f"[planner] {claim_id}: llm_call 미주입, heuristic fallback")
544
+ return _heuristic_plan(claim, claim_id)
545
+
546
+ prompt = build_plan_prompt(
547
+ claim_text=claim_text,
548
+ schema_info=schema_info or None,
549
+ source_excerpt=source_text,
550
+ anchor_year=anchor_year,
551
+ )
552
+ logger.info(
553
+ f"[planner] {claim_id}: prompt 구성 완료 ({len(prompt)}자). "
554
+ f"schema={'있음' if schema_info else '없음'} source={'있음' if source_text else '없음'}"
555
+ )
556
+
557
+ # LLM 호출 (재시도 포함)
558
+ last_response = ""
559
+ for attempt in range(self.config.max_retries + 1):
560
+ try:
561
+ response = await self.llm_call(prompt)
562
+ last_response = response or ""
563
+ logger.info(
564
+ f"[planner] {claim_id}: LLM 응답 받음 ({len(last_response)}자) "
565
+ f"[시도 {attempt + 1}/{self.config.max_retries + 1}]"
566
+ )
567
+ # [2026-05-25] LLM thought 디버깅용 — 응답 본문을 INFO에 펼침.
568
+ # 화면 UI에서 plan 결정 사유 추적하기 어려운 케이스 대응.
569
+ logger.info(
570
+ f"[planner] {claim_id}: LLM 응답 본문 ↓\n"
571
+ f"────── PLAN RESPONSE START ──────\n"
572
+ f"{last_response}\n"
573
+ f"────── PLAN RESPONSE END ──────"
574
+ )
575
+ except Exception as e:
576
+ logger.warning(
577
+ f"[planner] {claim_id}: LLM 호출 실패 [시도 {attempt + 1}]: "
578
+ f"{type(e).__name__}: {e}"
579
+ )
580
+ continue
581
+
582
+ # fallback query: schema.indicator > claim_text 앞부분
583
+ fallback_query = ""
584
+ if isinstance(schema_info, dict):
585
+ fallback_query = (schema_info.get("indicator") or "").strip()
586
+ if not fallback_query and claim_text:
587
+ # claim_text 앞 40자 정도까지 (너무 길면 search 품질 떨어짐)
588
+ fallback_query = claim_text.strip()[:40]
589
+
590
+ plan = _parse_plan(last_response, claim_id, fallback_query=fallback_query)
591
+ if plan is not None:
592
+ # [2026-05-21] value_role 후처리 — schema_inductor가 분기한 *역할*과
593
+ # LLM이 만든 claim_type이 불일치하면 *value_role을 신뢰*하고 정정.
594
+ # LLM이 같은 claim_text의 sub-claim들을 동일 plan_type으로 잘못
595
+ # 분류하던 버그(2026-05-21 진단: 출생아 수 base + 증가율 둘 다
596
+ # growth_rate)를 결정론적으로 차단.
597
+ _role = (schema_info or {}).get("value_role") if isinstance(schema_info, dict) else None
598
+ _role_to_type = {
599
+ "base": ClaimType.ABSOLUTE,
600
+ "derived_rate": ClaimType.GROWTH_RATE,
601
+ "derived_difference": ClaimType.DIFFERENCE,
602
+ # [2026-05-21] 다년 집계 — 도메인 무관, schema_inductor가 분기
603
+ "aggregation": ClaimType.AGGREGATION,
604
+ }
605
+ _expected_type = _role_to_type.get(_role)
606
+ if _expected_type and plan.claim_type != _expected_type:
607
+ logger.info(
608
+ f"[planner] {claim_id}: value_role={_role!r} 기반 정정 — "
609
+ f"LLM type={plan.claim_type.value} → {_expected_type.value}"
610
+ )
611
+ plan = plan.model_copy(update={"claim_type": _expected_type})
612
+ logger.info(
613
+ f"[planner] {claim_id}: Plan 생성 완료. "
614
+ f"type={plan.claim_type.value}, data_points={len(plan.required_data)}, "
615
+ f"steps={len(plan.initial_steps)}, formula={plan.calculation_formula!r}"
616
+ )
617
+ return plan
618
+
619
+ logger.warning(
620
+ f"[planner] {claim_id}: Plan 파싱 실패 [시도 {attempt + 1}]. "
621
+ f"응답 일부: {last_response[:200]!r}"
622
+ )
623
+
624
+ # 모든 시도 실패 → fallback
625
+ logger.warning(f"[planner] {claim_id}: 모든 시도 실패. heuristic fallback 사용.")
626
+ return _heuristic_plan(claim, claim_id)
627
+
628
+ # ── [2026-05-26] regenerate_plan ─────────────────────────────────
629
+ # 실행 도중 plan 자체가 틀렸음이 드러났을 때 (예: claim 값이 표에 row로 없는
630
+ # delta/derived 지표인데 plan이 absolute로 잡힘) 새 plan을 생성.
631
+ # 기존 fallback(try_ids, catalog retry, row_matcher 등)이 모두 같은 plan 내에서
632
+ # 답 찾기였다면, regenerate_plan은 *plan 자체*를 갈아끼움.
633
+ async def regenerate_plan(
634
+ self,
635
+ claim: Any,
636
+ original_plan: Any | None,
637
+ observations: list[dict],
638
+ reason: str,
639
+ ) -> Plan | None:
640
+ """원래 plan + 실행 observation을 보고 *수정된 plan*을 생성.
641
+
642
+ Args:
643
+ claim: Claim 객체.
644
+ original_plan: 첫 실행에 사용된 Plan (있으면 LLM에 참고로 보여줌).
645
+ observations: workspace의 observation 요약 리스트
646
+ (각 항목 {action, success, summary, fetched_value/stat_id, ...}).
647
+ reason: replan이 필요한 이유 (LLM이 입력으로 받음).
648
+
649
+ Returns:
650
+ 새 Plan. 실패 시 None.
651
+ """
652
+ claim_id = _extract_claim_id(claim)
653
+ claim_text = _extract_claim_text(claim)
654
+ schema_info = _extract_schema_info(claim)
655
+
656
+ if not claim_text:
657
+ logger.warning(f"[planner.regenerate] {claim_id}: claim_text 비어있음, fallback X")
658
+ return None
659
+ if self.llm_call is None:
660
+ logger.warning(f"[planner.regenerate] {claim_id}: llm_call 미주입")
661
+ return None
662
+
663
+ # original plan 직렬화 (LLM 입력용)
664
+ orig_plan_str = ""
665
+ if original_plan is not None:
666
+ try:
667
+ orig_plan_str = json.dumps(
668
+ original_plan.model_dump(mode="json") if hasattr(original_plan, "model_dump")
669
+ else dict(original_plan),
670
+ ensure_ascii=False, indent=2, default=str,
671
+ )
672
+ except Exception:
673
+ orig_plan_str = str(original_plan)
674
+
675
+ # observation 요약 직렬화
676
+ try:
677
+ obs_str = json.dumps(
678
+ observations or [], ensure_ascii=False, indent=2, default=str,
679
+ )
680
+ except Exception:
681
+ obs_str = str(observations)
682
+
683
+ prompt = _build_regenerate_prompt(
684
+ claim_text=claim_text,
685
+ schema_info=schema_info or {},
686
+ original_plan_json=orig_plan_str,
687
+ observations_json=obs_str,
688
+ reason=reason,
689
+ )
690
+ logger.info(
691
+ f"[planner.regenerate] {claim_id}: prompt 구성 완료 ({len(prompt)}자). "
692
+ f"obs_count={len(observations or [])}, reason={reason[:80]!r}"
693
+ )
694
+
695
+ last_response = ""
696
+ for attempt in range(self.config.max_retries + 1):
697
+ try:
698
+ response = await self.llm_call(prompt)
699
+ last_response = response or ""
700
+ logger.info(
701
+ f"[planner.regenerate] {claim_id}: LLM 응답 ({len(last_response)}자) "
702
+ f"[시도 {attempt + 1}/{self.config.max_retries + 1}]"
703
+ )
704
+ logger.info(
705
+ f"[planner.regenerate] {claim_id}: LLM 응답 본문 ↓\n"
706
+ f"────── REPLAN RESPONSE START ──────\n"
707
+ f"{last_response}\n"
708
+ f"────── REPLAN RESPONSE END ──────"
709
+ )
710
+ except Exception as e:
711
+ logger.warning(
712
+ f"[planner.regenerate] {claim_id}: LLM 호출 실패 [시도 {attempt + 1}]: "
713
+ f"{type(e).__name__}: {e}"
714
+ )
715
+ continue
716
+
717
+ # fallback query: schema.indicator
718
+ fallback_query = ""
719
+ if isinstance(schema_info, dict):
720
+ fallback_query = (schema_info.get("indicator") or "").strip()
721
+ if not fallback_query and claim_text:
722
+ fallback_query = claim_text.strip()[:40]
723
+
724
+ plan = _parse_plan(last_response, claim_id, fallback_query=fallback_query)
725
+ if plan is not None:
726
+ logger.info(
727
+ f"[planner.regenerate] {claim_id}: 새 Plan 생성 완료. "
728
+ f"type={plan.claim_type.value}, data_points={len(plan.required_data)}, "
729
+ f"steps={len(plan.initial_steps)}, formula={plan.calculation_formula!r}"
730
+ )
731
+ return plan
732
+ logger.warning(
733
+ f"[planner.regenerate] {claim_id}: 파싱 실패 [시도 {attempt + 1}]. "
734
+ f"응답 일부: {last_response[:200]!r}"
735
+ )
736
+
737
+ logger.warning(f"[planner.regenerate] {claim_id}: 모든 시도 실패")
738
+ return None
739
+
740
+
741
+ def _build_regenerate_prompt(
742
+ *,
743
+ claim_text: str,
744
+ schema_info: dict,
745
+ original_plan_json: str,
746
+ observations_json: str,
747
+ reason: str,
748
+ ) -> str:
749
+ """regenerate_plan용 LLM 프롬프트.
750
+
751
+ 원래 plan + 실행 결과 + 이유를 보여주고 *수정된 plan*을 받는다.
752
+ Plan JSON 형식은 build_plan_prompt와 동일 (정상 parse 가능하게).
753
+ """
754
+ return f"""당신은 통계 검증 plan 수정자입니다. 아래 정보를 보고 *새 plan*을 만드세요.
755
+
756
+ [원래 claim]
757
+ {claim_text}
758
+
759
+ [claim 스키마]
760
+ {json.dumps(schema_info, ensure_ascii=False, indent=2, default=str)}
761
+
762
+ [원래 plan (실패함)]
763
+ {original_plan_json or '(없음)'}
764
+
765
+ [실행 결과 요약 — 무엇을 시도했고 어떤 데이터를 받았는지]
766
+ {observations_json or '(없음)'}
767
+
768
+ [replan 이유]
769
+ {reason or '(없음)'}
770
+
771
+ [plan 수정 가이드]
772
+ 1. 위 실행 결과에서 *어떤 표/시점/지표의 데이터가 실제로 존재했는지* 먼저 파악.
773
+ 2. claim의 값이 그 표에 *직접 row로* 들어있나? → 들어있으면 claim_type='absolute'.
774
+ 3. row로 *없는데* 표에 *원시 절대값*이 있다면 → 계산 필요:
775
+ - "<지표> 증가 수/감소 수/증감/변화량" → claim_type='difference', formula='current - prev'
776
+ - "<지표> 증가율/감소율/증감률" → claim_type='growth_rate', formula='(current-prev)/prev*100'
777
+ 4. prev_time_period는 schema에 명시되었거나, time_period의 직전 단위(연→전년, 월→전월)로 추정.
778
+ 5. initial_steps는 *현재까지 부족한 데이터만* 채우도록 구성:
779
+ - 이미 fetch 성공한 시점이 있으면 그 시점 fetch는 *반복하지 마세요*.
780
+ - 부족한 시점만 fetch_evidence → calculate → finish.
781
+
782
+ [출력 형식 — JSON만, 다른 텍스트 금지]
783
+ {{
784
+ "claim_type": "absolute" | "growth_rate" | "difference" | "comparison" | "ranking" | "aggregation",
785
+ "required_data": [
786
+ {{"indicator": "...", "time": "...", "population": "...", "unit_hint": "...", "role": "current/prev/..."}}
787
+ ],
788
+ "calculation_formula": "current - prev" | "(current - prev) / prev * 100" | null,
789
+ "expected_result": <claim.value>,
790
+ "expected_unit": "...",
791
+ "verdict_logic": "계산된 값이 expected_result와 일치하면 match",
792
+ "initial_steps": [
793
+ {{"action": "fetch_evidence", "input": {{"candidate_id": "<직전 성공한 stat_id>", "params": {{"time_period": "<부족한 시점>"}}}}, "rationale": "..."}},
794
+ {{"action": "calculate", "input": {{"formula": "...", "vars": {{"current": ..., "prev": ...}}}}, "rationale": "..."}},
795
+ {{"action": "finish", "input": {{}}, "rationale": "..."}}
796
+ ],
797
+ "fallback": {{"use_original_text": false, "alternative_keywords": [], "give_up_after_attempts": 3}},
798
+ "notes": "replan 사유 한 줄 메모"
799
+ }}
800
+ """
801
+
802
+
803
+ # ── 편의 함수 ──────────────────────────────────────────────────────
804
+
805
+ async def build_plan(
806
+ claim: Any,
807
+ llm_call: LLMCallable | None = None,
808
+ source_text: str | None = None,
809
+ anchor_year: int | str | None = None,
810
+ config: PlannerConfig | None = None,
811
+ ) -> Plan:
812
+ """일회성 Plan 생성 (Planner 인스턴스 안 만들고).
813
+
814
+ Phase D Loop에서 한 번씩만 호출하면 되니 충분.
815
+ """
816
+ planner = Planner(llm_call=llm_call, config=config)
817
+ return await planner.plan(claim, source_text=source_text, anchor_year=anchor_year)