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,1165 @@
1
+ """structverify.agent.loop — Agent Loop (Phase D).
2
+
3
+ Pipeline:
4
+ Plan (Phase C) → **Loop** → AgentVerdict
5
+
6
+ Loop 책임:
7
+ 1. Plan의 initial_steps을 *순서대로 실행* (deterministic mode)
8
+ 2. 각 step의 결과를 Observation으로 wrap + memory/log 기록
9
+ 3. FINISH action 또는 max_iter 도달 시 종료
10
+ 4. **Reflect hook** — 매 iteration 전에 *결정 함수* 호출 가능 (Phase E에서 진짜 Reflect Agent)
11
+ 5. **★ Auto verdict synthesis** — plan steps 소진 시 마지막 fetch observation 기반
12
+ deterministic verdict 자동 합성 (Phase E에서 LLM verdict로 대체)
13
+
14
+ 이번 Phase D = **deterministic mode만**. Plan의 initial_steps 그대로 실행.
15
+ Phase E에서 reflect_fn으로 *LLM이 다음 step 결정* 가능.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from dataclasses import dataclass
21
+ from typing import Any, Awaitable, Callable, Protocol
22
+
23
+ from structverify.utils.logger import get_logger
24
+ # [리팩] Step 8 판정 → verification.decide_verdict(agent)
25
+ from structverify.verification.adapters import (
26
+ VerdictDecision,
27
+ from_agent_calculate,
28
+ from_agent_fetch,
29
+ )
30
+ from structverify.verification.decide_verdict import decide_verdict
31
+ from .schemas import (
32
+ ActionType,
33
+ AgentVerdict,
34
+ ClaimType,
35
+ DataPointSpec,
36
+ Observation,
37
+ Plan,
38
+ PlanStep,
39
+ ReflectDecision,
40
+ StopReason,
41
+ VerdictType,
42
+ )
43
+ from .tools import get_tool_class, ToolContext, ToolResult
44
+ from .memory import append_iteration, append_plan_summary
45
+ from .workspace import Workspace
46
+
47
+ logger = get_logger(__name__)
48
+
49
+ # ── Reflect Hook (Phase E에서 LLM 기반으로 대체 가능) ────────────
50
+
51
+ class ReflectFn(Protocol):
52
+ """매 iteration 전에 *다음 행동 결정* 함수.
53
+
54
+ Args:
55
+ plan: 현재 plan
56
+ memory_text: 지금까지의 memory.md 내용 (LLM이 본 컨텍스트)
57
+ last_observation: 직전 iteration 결과 (None이면 첫 iter)
58
+ iter_num: 현재 iteration 번호 (1-based)
59
+
60
+ Returns:
61
+ ReflectDecision (다음 action + input + rationale)
62
+ 또는 None (Loop이 기본 동작 — plan의 다음 step 그대로 실행)
63
+ """
64
+
65
+ async def __call__(
66
+ self,
67
+ plan: Plan,
68
+ memory_text: str,
69
+ last_observation: Observation | None,
70
+ iter_num: int,
71
+ ) -> ReflectDecision | None:
72
+ ...
73
+
74
+
75
+ # ── Loop 설정 ────────────────────────────────────────────────────
76
+
77
+ @dataclass
78
+ class LoopConfig:
79
+ """Loop 동작 설정."""
80
+
81
+ max_iterations: int = 10
82
+ """최대 iteration 수. 도달 시 강제 unverifiable 종료."""
83
+
84
+ mode: str = "deterministic"
85
+ """'deterministic' (plan 그대로) | 'reflect' (reflect_fn 호출, Phase E)."""
86
+
87
+ fail_fast: bool = False
88
+ """True면 첫 Tool 실패 시 즉시 unverifiable. False면 계속 다음 step 시도."""
89
+
90
+ value_match_tolerance: float = 0.05
91
+ """[2026-05-21 완화] auto verdict 합성 시 값 매칭 허용 오차 (5% 기본).
92
+ 기존 1%는 너무 strict해서 schema=0.79 vs fetch=0.8 (1.3% 차이) 같은
93
+ 실질적 일치도 mismatch로 떨어졌음. KOSIS 데이터의 통계적 변동/시점 차이를
94
+ 감안해 5%로 완화. 더 strict한 매칭이 필요하면 호출자가 인자로 override."""
95
+
96
+
97
+ # ── Step input 보간 (deterministic mode 보조) ────────────────────
98
+
99
+ def _interpolate_step_input(
100
+ step: PlanStep,
101
+ last_observation: Observation | None,
102
+ ) -> PlanStep:
103
+ """deterministic mode에서 step.input의 placeholder를 직전 observation 결과로 치환.
104
+
105
+ Planner가 plan 만들 때 *catalog_search 결과를 아직 모르므로* fetch_evidence의
106
+ candidate_id에 placeholder 문자열을 넣음 (예: '<catalog_search 결과의 top id>').
107
+ Loop이 deterministic mode에서 그걸 그대로 넘기면 fetch 실패 → 여기서 보간.
108
+ """
109
+ if step.action != ActionType.FETCH_EVIDENCE:
110
+ return step
111
+ if last_observation is None or last_observation.action != ActionType.CATALOG_SEARCH:
112
+ return step
113
+
114
+ cid = (step.input or {}).get("candidate_id", "")
115
+ is_placeholder = (
116
+ not cid
117
+ or (isinstance(cid, str) and (
118
+ cid.startswith("<")
119
+ or cid.startswith("{")
120
+ or cid.strip().upper() in {"TBD", "TODO", "FILL_ME", "N/A"}
121
+ ))
122
+ )
123
+ if not is_placeholder:
124
+ return step
125
+
126
+ candidates = (last_observation.output or {}).get("candidates") or []
127
+ if not candidates or not isinstance(candidates[0], dict):
128
+ logger.warning(
129
+ f"[loop] 보간 skip: last_observation.output에 candidates 없음 "
130
+ f"(action={last_observation.action.value}, output keys={list((last_observation.output or {}).keys())})"
131
+ )
132
+ return step
133
+ top_id = candidates[0].get("id")
134
+ if not top_id:
135
+ return step
136
+
137
+ new_input = dict(step.input or {})
138
+ new_input["candidate_id"] = top_id
139
+ # ── [v6.18] 후보 순회용 fallback 리스트 ──────────────────────────
140
+ # catalog top 1개가 무관한 표일 수 있으므로(예: "연평균기온"에
141
+ # "[해양기상] 등표 관측값"이 1등), 나머지 후보 id들도 같이 넘겨서
142
+ # fetch_evidence가 관련성 체크 실패 시 다음 후보로 재시도하게 함.
143
+ fallback_ids = [
144
+ c.get("id") for c in candidates[1:]
145
+ if isinstance(c, dict) and c.get("id")
146
+ ]
147
+ if fallback_ids:
148
+ new_input["_candidate_fallbacks"] = fallback_ids
149
+ logger.info(
150
+ f"[loop] candidate_id placeholder 보간: {cid!r} → {top_id!r} "
151
+ f"(fallback 후보 {len(fallback_ids)}개)"
152
+ )
153
+ return PlanStep(action=step.action, input=new_input, rationale=step.rationale)
154
+
155
+
156
+ # ── Auto verdict synthesis → verification.decide_verdict(agent) ─────────
157
+
158
+ def _evidence_to_data_points(evidence: dict, claim: Any) -> list[DataPointSpec]:
159
+ """fetch observation evidence dict → DataPointSpec 리스트.
160
+
161
+ [v6.17] agent 경로가 검증에 쓴 KOSIS 데이터를 verdict에 담아야
162
+ runtime_agent가 그걸 VerificationResult.evidence로 복원해서 UI에 표시함.
163
+ 이전엔 data_points=[] 로 비워 → UI에 '공식 통계 출처'가 안 떴음.
164
+ """
165
+ if not evidence:
166
+ return []
167
+ fetched_value = evidence.get("value")
168
+ if fetched_value is None:
169
+ # 값 없으면 출처 표시 무의미 — 빈 리스트
170
+ return []
171
+ schema = getattr(claim, "schema", None)
172
+ indicator = (getattr(schema, "indicator", "") or "") if schema else ""
173
+ population = getattr(schema, "population", None) if schema else None
174
+ stat_id = evidence.get("stat_table_id", "") or ""
175
+ try:
176
+ rv = float(fetched_value)
177
+ except (TypeError, ValueError):
178
+ rv = None
179
+ return [
180
+ DataPointSpec(
181
+ indicator=indicator or (evidence.get("stat_name", "") or "KOSIS"),
182
+ time=str(evidence.get("time_period", "") or ""),
183
+ population=population,
184
+ unit_hint=evidence.get("unit", "") or None,
185
+ resolved_value=rv,
186
+ resolved_unit=evidence.get("unit", "") or None,
187
+ source=(f"KOSIS:{stat_id}" if stat_id else "KOSIS"),
188
+ source_time=str(evidence.get("time_period", "") or "") or None,
189
+ )
190
+ ]
191
+
192
+
193
+ def _save_verified_facts(
194
+ workspace: Any, verdict: Any, claim_id: str, claim: Any | None = None,
195
+ ) -> None:
196
+ """[v6.21] verdict의 data_points에서 검증된 수치를 job 공유 저장소에 기록.
197
+
198
+ MATCH/MISMATCH verdict는 KOSIS 공식 수치를 data_points에 담는다.
199
+ 그 (indicator, time_period, value, unit)을 verified_facts에 저장하면,
200
+ 다음 claim이 같은 수치를 catalog_search 없이 재사용할 수 있다.
201
+
202
+ UNVERIFIABLE은 공식 수치가 없으므로 저장하지 않는다.
203
+
204
+ [S 패치 2026-05-21] claim이 전달되면 sent_id 기반 sibling_evidence에도 같이
205
+ 기록해 같은 sent_id의 형제 sub-claim들이 활용할 수 있도록 한다.
206
+ """
207
+ try:
208
+ v_type = getattr(verdict.verdict, "value", str(verdict.verdict))
209
+ if v_type not in ("match", "mismatch"):
210
+ return # 검증 실패 — 신뢰할 수치 없음
211
+ dps = getattr(verdict, "data_points", None) or []
212
+
213
+ # sibling_evidence용 sent_id / value_role 추출
214
+ sent_id = ""
215
+ role = ""
216
+ if claim is not None:
217
+ sent_id = str(getattr(claim, "sent_id", "") or "").strip()
218
+ schema = getattr(claim, "schema", None)
219
+ role = (getattr(schema, "value_role", None) or "") if schema else ""
220
+
221
+ for dp in dps:
222
+ val = getattr(dp, "resolved_value", None)
223
+ if val is None:
224
+ continue
225
+ fact = {
226
+ "indicator": getattr(dp, "indicator", "") or "",
227
+ "time_period": (
228
+ getattr(dp, "source_time", None) or getattr(dp, "time", "") or ""
229
+ ),
230
+ # [2026-05-21] population 추가 — sub-claim별 격리 위해 캐시 키에 포함
231
+ "population": (getattr(dp, "population", None) or ""),
232
+ "value": val,
233
+ "unit": getattr(dp, "resolved_unit", None) or "",
234
+ "source": getattr(dp, "source", None) or "KOSIS",
235
+ "claim_id": str(claim_id),
236
+ "verdict": v_type,
237
+ }
238
+ workspace.append_verified_fact(fact)
239
+ if sent_id and role:
240
+ workspace.record_sibling_evidence(
241
+ sent_id=sent_id, role=role, evidence=fact,
242
+ )
243
+ except Exception as e:
244
+ logger.debug(f"[loop] verified_fact 저장 실패 (무시): {e}")
245
+
246
+
247
+ def _verdict_decision_to_agent_verdict(
248
+ decision: VerdictDecision,
249
+ evidence: dict,
250
+ claim: Any,
251
+ iter_num: int,
252
+ stop_reason: StopReason = StopReason.COMPLETED,
253
+ ) -> AgentVerdict:
254
+ """VerdictDecision → AgentVerdict (data_points·iter 메타 포장)."""
255
+ return AgentVerdict(
256
+ claim_id=decision.claim_id,
257
+ verdict=decision.verdict,
258
+ confidence=decision.confidence,
259
+ explanation=decision.explanation,
260
+ data_points=_evidence_to_data_points(evidence, claim),
261
+ iterations_used=iter_num,
262
+ stop_reason=stop_reason,
263
+ )
264
+
265
+
266
+ def _synthesize_verdict_from_observation(
267
+ plan: Plan,
268
+ claim: Any,
269
+ claim_id: str,
270
+ last_observation: Observation | None,
271
+ iter_num: int,
272
+ tolerance: float,
273
+ all_fetch_observations: list | None = None,
274
+ config: dict | None = None,
275
+ ) -> AgentVerdict | None:
276
+ """Plan steps 소진 시 fetch observation 보고 deterministic verdict 합성.
277
+
278
+ Phase D의 임시 verdict 결정 로직. Phase E에서 LLM 기반 verdict로 교체 예정.
279
+ [리팩] 판정 본문은 verification.decide_verdict(profile=agent)에 위임.
280
+ """
281
+ if last_observation is None:
282
+ return None
283
+ if last_observation.action != ActionType.FETCH_EVIDENCE:
284
+ return None
285
+
286
+ normalized, early = from_agent_fetch(
287
+ claim,
288
+ last_observation,
289
+ plan,
290
+ tolerance=tolerance,
291
+ all_fetch_observations=all_fetch_observations,
292
+ )
293
+ if early is not None:
294
+ return _verdict_decision_to_agent_verdict(early, {}, claim, iter_num)
295
+ if normalized is None:
296
+ return None
297
+
298
+ decision = decide_verdict(claim, normalized, config or {}, profile="agent")
299
+ return _verdict_decision_to_agent_verdict(
300
+ decision, normalized.evidence, claim, iter_num,
301
+ )
302
+
303
+
304
+ def _synthesize_verdict_from_calculate(
305
+ plan: Plan,
306
+ claim: Any,
307
+ claim_id: str,
308
+ last_calc_observation: Observation | None,
309
+ iter_num: int,
310
+ last_fetch_observation: Observation | None = None,
311
+ workspace: Any = None,
312
+ config: dict | None = None,
313
+ ) -> AgentVerdict | None:
314
+ """[패치] Plan 소진 + 마지막 성공한 관측이 CALCULATE인 경우 verdict 합성.
315
+
316
+ LLM이 prev/current를 계산했지만 finish를 안 부르고 다시 같은 액션 반복
317
+ → 중복차단 → 강제 unverifiable로 죽는 케이스 회복.
318
+ [리팩] 판정 본문은 verification.decide_verdict(profile=agent)에 위임.
319
+ """
320
+ normalized, _early = from_agent_calculate(
321
+ claim,
322
+ last_calc_observation,
323
+ plan,
324
+ last_fetch_observation=last_fetch_observation,
325
+ workspace=workspace,
326
+ )
327
+ if normalized is None:
328
+ return None
329
+
330
+ decision = decide_verdict(claim, normalized, config or {}, profile="agent")
331
+ return _verdict_decision_to_agent_verdict(decision, {}, claim, iter_num)
332
+
333
+
334
+ # ── Agent Loop 본체 ──────────────────────────────────────────────
335
+
336
+ async def agent_loop(
337
+ plan: Plan,
338
+ claim: Any,
339
+ workspace: Workspace,
340
+ datasources: dict[str, Any],
341
+ config: dict[str, Any] | None = None,
342
+ reflect_fn: ReflectFn | None = None,
343
+ loop_config: LoopConfig | None = None,
344
+ ) -> AgentVerdict:
345
+ """Agent Loop 실행.
346
+
347
+ Args:
348
+ plan: Phase C에서 만든 Plan
349
+ claim: structverify Claim (logging + claim_id + schema용)
350
+ workspace: 이 job의 workspace
351
+ datasources: {name: BaseDataSource} 등록된 source들
352
+ config: 전체 config dict (Tool들이 사용)
353
+ reflect_fn: 옵션. Phase E에서 LLM 기반 Reflect Agent.
354
+ None이면 *deterministic mode* (plan.initial_steps 그대로 실행)
355
+ loop_config: max_iter, mode 등
356
+
357
+ Returns:
358
+ AgentVerdict — workspace에도 자동 저장됨 (FinishTool 호출 시 또는 auto-synthesize 시)
359
+ """
360
+ loop_config = loop_config or LoopConfig()
361
+ config = config or {}
362
+ claim_id = str(getattr(claim, "claim_id", "") or getattr(claim, "id", "unknown"))
363
+
364
+ # ── 초기화 ──
365
+ logger.info(
366
+ f"[loop] {claim_id}: 시작. plan.type={plan.claim_type.value}, "
367
+ f"steps={len(plan.initial_steps)}, mode={loop_config.mode}, "
368
+ f"max_iter={loop_config.max_iterations}"
369
+ )
370
+
371
+ # [2026-05-21] 사전 가드 — claim에 검증 가능한 value가 *없으면* 즉시 unverifiable.
372
+ # LLM(schema_inductor)이 한 문장에서 정상 schema + 빈(value=null) schema를 함께
373
+ # 만들어 별도 sub-claim으로 분기되던 케이스 회귀 방지. 빈 schema는 fetch를
374
+ # 아무리 해도 비교 불가 → max_iter까지 reflect 헛돌이만 발생.
375
+ # aggregation은 별도 흐름(N개 시점 fetch → calc)이라 value 없어도 OK.
376
+ _claim_schema = getattr(claim, "schema", None)
377
+ _claim_role = getattr(_claim_schema, "value_role", None) if _claim_schema else None
378
+ _claim_value = getattr(_claim_schema, "value", None) if _claim_schema else None
379
+ if (
380
+ _claim_schema is not None
381
+ and _claim_value is None
382
+ and _claim_role != "aggregation"
383
+ ):
384
+ logger.warning(
385
+ f"[loop] {claim_id}: schema.value=None (role={_claim_role!r}) — "
386
+ f"검증 대상 수치가 없어 즉시 unverifiable. "
387
+ f"indicator={getattr(_claim_schema, 'indicator', None)!r}, "
388
+ f"time={getattr(_claim_schema, 'time_period', None)!r}, "
389
+ f"population={getattr(_claim_schema, 'population', None)!r}"
390
+ )
391
+ return AgentVerdict(
392
+ claim_id=claim_id,
393
+ verdict=VerdictType.UNVERIFIABLE,
394
+ confidence=0.2,
395
+ explanation=(
396
+ "이 sub-claim의 schema에 비교할 수치(value)가 없어 검증 불가. "
397
+ "원문에서 수치 추출이 실패했거나, 같은 문장의 다른 sub-claim에서 "
398
+ "수치가 모두 표현된 경우."
399
+ ),
400
+ data_points=[],
401
+ iterations_used=0,
402
+ stop_reason=StopReason.COMPLETED,
403
+ )
404
+
405
+ # plan summary를 memory에 기록
406
+ try:
407
+ plan_summary = (
408
+ f"Plan type: {plan.claim_type.value}\n"
409
+ f"Required data points: {len(plan.required_data)}\n"
410
+ f"Formula: {plan.calculation_formula}\n"
411
+ f"Initial steps: {[s.action.value for s in plan.initial_steps]}\n"
412
+ f"Fallback keywords: {plan.fallback.alternative_keywords}"
413
+ )
414
+ append_plan_summary(workspace, claim_id, plan_summary)
415
+ except Exception as e:
416
+ logger.debug(f"[loop] plan summary memory 기록 실패: {e}")
417
+
418
+ # ── 실행 루프 ──
419
+ last_observation: Observation | None = None
420
+ # B2 sanity check용 — finish 이후의 verdict 검증에 쓰임.
421
+ # last_observation은 finish 자체로 덮어쓰여 사라지므로 별도 추적.
422
+ last_fetch_observation: Observation | None = None
423
+ # [패치 H-3] 같은 claim의 모든 성공 fetch observation을 모음.
424
+ # 마지막 fetch가 prev_time만 받았을 때, 이전 fetch의 rows[]에서 claim_time
425
+ # 시점 row를 찾아 비교/계산할 수 있도록 한다. 또 prev/current row가 서로
426
+ # 다른 fetch에서 와도 같은 지표(matched_row criteria)로 묶어 짝지을 수 있음.
427
+ all_fetch_observations: list[Observation] = []
428
+ # [패치] LLM이 계산까지 했는데 finish를 안 부르고 중복차단으로 죽는
429
+ # 케이스 대응 — 마지막으로 성공한 calculate observation을 별도 추적.
430
+ last_calc_observation: Observation | None = None
431
+ last_result: ToolResult | None = None
432
+ finished = False
433
+ stop_reason = StopReason.MAX_ITERATIONS
434
+ plan_step_idx = 0
435
+ plan_exhausted = False
436
+
437
+ # ── [중복 action 대응] reflect 모드 전용 ─────────────────────────
438
+ # reflect(HCX)가 thought엔 "다른 검색어"라 쓰면서 action.input.query는
439
+ # 동일하게 두는 일이 잦다. 이전엔 *강제 종료*했으나, 이는 fetch fail
440
+ # 후 retry 기회를 박탈해 진짜 정답에 도달 못 하는 부작용이 컸음
441
+ # (project_fetch_lockup, 2026-05-26).
442
+ #
443
+ # 새 정책: 중복 감지 시 *이전 observation을 그대로 반환 + 다음 행동 hint*.
444
+ # 연속 N회 이상 지속되면 강제 다음 action 또는 종료.
445
+ # 1회 중복: cached observation + soft hint
446
+ # 2회 중복: cached observation + strong hint (다음 action 명시)
447
+ # 3회 이상: 헛돌이 — 종료
448
+ _seen_action_keys: set[str] = set()
449
+ _action_key_to_observation: dict[str, Observation] = {}
450
+ _consecutive_dup = 0
451
+ _DUP_HARD_LIMIT = 3 # 연속 N회 도달 시 종료
452
+
453
+ def _action_key(step: PlanStep) -> str:
454
+ """action + 입력으로 중복 판별 키. 문자열 값은 공백 제거 정규화."""
455
+ inp = step.input or {}
456
+ norm = {
457
+ k: (str(v).strip().replace(" ", "") if isinstance(v, str) else v)
458
+ for k, v in inp.items()
459
+ }
460
+ return (
461
+ f"{step.action.value}::"
462
+ f"{json.dumps(norm, sort_keys=True, ensure_ascii=False)}"
463
+ )
464
+
465
+ for iter_num in range(1, loop_config.max_iterations + 1):
466
+ # ── 다음 step 결정 ──
467
+ next_step: PlanStep | None = None
468
+
469
+ if reflect_fn is not None and loop_config.mode == "reflect":
470
+ # Phase E: LLM Reflect Agent
471
+ try:
472
+ memory_text = workspace.read_memory(claim_id)
473
+ # [S 패치 2026-05-21] 같은 sent_id의 sibling base evidence를
474
+ # memory_text 상단에 inject → derived claim이 추가 fetch 없이
475
+ # base의 KOSIS 값을 활용해 즉시 calculate 가능.
476
+ try:
477
+ _schema = getattr(claim, "schema", None)
478
+ _role = (getattr(_schema, "value_role", None) or "") if _schema else ""
479
+ _sent_id = str(getattr(claim, "sent_id", "") or "").strip()
480
+ if _role in ("derived_rate", "derived_difference") and _sent_id:
481
+ _sibs = workspace.read_sibling_evidence(_sent_id) or []
482
+ _base_sibs = [s for s in _sibs if s.get("role") == "base"]
483
+ if _base_sibs and iter_num == 1:
484
+ # 첫 iter에만 inject (이후엔 last_observation으로 전달됨)
485
+ _sib_lines = []
486
+ for _s in _base_sibs:
487
+ _sib_lines.append(
488
+ f" - role={_s.get('role')!r} "
489
+ f"indicator={_s.get('indicator')!r} "
490
+ f"value={_s.get('value')} "
491
+ f"unit={_s.get('unit')!r} "
492
+ f"time_period={_s.get('time_period')!r} "
493
+ f"source={_s.get('source')!r}"
494
+ )
495
+ _sib_block = (
496
+ "## 같은 sent_id의 sibling base 검증 결과 (S 패치)\n"
497
+ "이 derived claim과 같은 문장에서 분기된 *base sub-claim*이\n"
498
+ "KOSIS에서 이미 검증한 값:\n"
499
+ + "\n".join(_sib_lines) + "\n\n"
500
+ "★ 활용 방법:\n"
501
+ " - 이 base value가 derived의 *current 시점* 값입니다.\n"
502
+ " - claim.schema.prev_value가 원문에 있으면 그 값과 함께\n"
503
+ " *추가 fetch 없이* calculate (또는 finish) 가능합니다.\n"
504
+ " - prev fetch가 필요하면 같은 stat_id로 한 번만.\n\n"
505
+ "---\n\n"
506
+ )
507
+ memory_text = _sib_block + (memory_text or "")
508
+ logger.info(
509
+ f"[loop] {claim_id}: sibling base evidence "
510
+ f"{len(_base_sibs)}건 inject (sent_id={_sent_id!r})"
511
+ )
512
+ except Exception as _e:
513
+ logger.debug(f"[loop] sibling inject 실패 (무시): {_e}")
514
+ decision = await reflect_fn(plan, memory_text, last_observation, iter_num)
515
+ except Exception as e:
516
+ logger.warning(f"[loop] reflect_fn 실패: {e}, deterministic fallback")
517
+ decision = None
518
+
519
+ if decision is not None:
520
+ next_step = PlanStep(
521
+ action=decision.action,
522
+ input=decision.input,
523
+ # ReflectDecision은 'thought' 필드를 씀 (rationale 아님).
524
+ # PlanStep.rationale에 thought를 매핑.
525
+ rationale=decision.thought,
526
+ )
527
+
528
+ # reflect_fn 없거나 None 반환 → plan의 다음 step 사용
529
+ if next_step is None:
530
+ if plan_step_idx < len(plan.initial_steps):
531
+ next_step = plan.initial_steps[plan_step_idx]
532
+ plan_step_idx += 1
533
+ # Planner가 넣은 placeholder를 직전 observation 결과로 보간
534
+ next_step = _interpolate_step_input(next_step, last_observation)
535
+ else:
536
+ # plan steps 다 썼는데 finish 안 함 → auto verdict 합성 시도
537
+ logger.info(
538
+ f"[loop] {claim_id}: plan steps 모두 소진, iter {iter_num}에서 종료"
539
+ )
540
+ plan_exhausted = True
541
+ stop_reason = StopReason.MAX_ITERATIONS
542
+ break
543
+
544
+ # ── [J 패치 2026-05-21] absolute claim에서 calculate 액션 차단 ──
545
+ # plan.claim_type=ABSOLUTE인데 reflect LLM이 자율적으로 calculate를
546
+ # 부르는 케이스 (출생아 수 base 20717명: fetch 후 자율 calc → unverifiable).
547
+ # absolute는 단일 값 비교라 수식 계산이 필요 없음. tool 실행 스킵 +
548
+ # "absolute니 finish 하라" observation 전달해 다음 iter에 finish 유도.
549
+ # G 패치(prompt 가이드)로 LLM 자제 시도했으나 무시되는 케이스가 잦아
550
+ # loop 단에서 결정론적 차단.
551
+ if (
552
+ loop_config.mode == "reflect"
553
+ and plan.claim_type == ClaimType.ABSOLUTE
554
+ and next_step.action == ActionType.CALCULATE
555
+ ):
556
+ logger.warning(
557
+ f"[loop] {claim_id} iter {iter_num}: "
558
+ f"plan.claim_type=ABSOLUTE인데 calculate 호출 — 스킵하고 finish 유도"
559
+ )
560
+ last_observation = Observation(
561
+ iter_num=iter_num,
562
+ action=next_step.action,
563
+ input=next_step.input,
564
+ output={},
565
+ summary=(
566
+ "[absolute 가드] 이 claim은 plan_type=absolute (단일 값 검증)"
567
+ "이므로 calculate 호출이 불필요합니다. 직전 fetch_evidence "
568
+ "값이 claim.value와 일치하면 finish(match)로 종료하세요."
569
+ ),
570
+ success=False,
571
+ error="absolute_calc_blocked",
572
+ )
573
+ try:
574
+ append_iteration(
575
+ workspace, claim_id,
576
+ iteration_num=last_observation.iter_num,
577
+ action=getattr(last_observation.action, "value",
578
+ str(last_observation.action)),
579
+ action_input=last_observation.input,
580
+ observation_summary=last_observation.summary,
581
+ success=last_observation.success,
582
+ )
583
+ except Exception as e:
584
+ logger.warning(
585
+ f"[loop] absolute 가드 observation 기록 실패: {e}"
586
+ )
587
+ continue # 다음 iter — reflect가 finish 결정하도록
588
+
589
+ # ── [중복 action 대응] reflect 모드에서만 ──────────────────
590
+ # 같은 (action, input)이 이미 실행됐으면 *cached observation 반환* +
591
+ # 다음 행동 hint. 연속 N회면 종료.
592
+ if loop_config.mode == "reflect":
593
+ _akey = _action_key(next_step)
594
+ if _akey in _seen_action_keys:
595
+ _consecutive_dup += 1
596
+ _cached_obs = _action_key_to_observation.get(_akey)
597
+ logger.info(
598
+ f"[loop] {claim_id} iter {iter_num}: 중복 action 감지 "
599
+ f"(action={next_step.action.value}, 연속 {_consecutive_dup}회) "
600
+ f"— cached observation 재사용"
601
+ )
602
+
603
+ # cached observation summary 그대로 + hint 추가
604
+ _base_summary = (
605
+ _cached_obs.summary if _cached_obs is not None
606
+ else f"이전 iter에서 같은 입력으로 실행된 적이 있습니다."
607
+ )
608
+ if _consecutive_dup == 1:
609
+ _hint = (
610
+ " | [hint] 이 입력은 직전에 이미 시도했고 동일한 결과가 나왔습니다. "
611
+ "결과를 다시 검토하고 *다음에 할 action을 결정*하세요. "
612
+ "(예: catalog_search 결과를 보고 fetch_evidence로 후보 시도, "
613
+ "또는 다른 query/category로 catalog_search 재호출)"
614
+ )
615
+ elif _consecutive_dup == 2:
616
+ _hint = (
617
+ " | [strong hint] 동일 입력 2회 반복입니다. *반드시 다른 action* "
618
+ "또는 *다른 query/input*을 선택하세요. catalog_search 반복 금지 — "
619
+ "fetch_evidence(다른 candidate_id) 또는 explore_catalog를 쓰거나 "
620
+ "이미 충분한 정보가 있으면 finish(unverifiable)로 종료."
621
+ )
622
+ else:
623
+ _hint = (
624
+ " | [final hint] 동일 입력 3회 이상 — 헛돌이로 판단합니다."
625
+ )
626
+
627
+ last_observation = Observation(
628
+ iter_num=iter_num,
629
+ action=next_step.action,
630
+ input=next_step.input,
631
+ output=(_cached_obs.output if _cached_obs is not None else {}),
632
+ summary=_base_summary + _hint,
633
+ success=(_cached_obs.success if _cached_obs is not None else False),
634
+ error="duplicate_action_cached",
635
+ )
636
+ try:
637
+ append_iteration(
638
+ workspace, claim_id,
639
+ iteration_num=last_observation.iter_num,
640
+ action=getattr(last_observation.action, "value",
641
+ str(last_observation.action)),
642
+ action_input=last_observation.input,
643
+ observation_summary=last_observation.summary,
644
+ success=last_observation.success,
645
+ )
646
+ except Exception as e:
647
+ logger.warning(f"[loop] 중복 observation 기록 실패: {e}")
648
+ if _consecutive_dup >= _DUP_HARD_LIMIT:
649
+ logger.warning(
650
+ f"[loop] {claim_id}: 중복 action {_consecutive_dup}회 연속 "
651
+ f"→ 헛돌이로 판단, iter {iter_num}에서 종료"
652
+ )
653
+ plan_exhausted = True
654
+ stop_reason = StopReason.MAX_ITERATIONS
655
+ break
656
+ continue # 다음 iter — reflect가 cached + hint 보고 결정
657
+ # 중복 아님 — 키 등록, 연속 카운터 리셋
658
+ _seen_action_keys.add(_akey)
659
+ _consecutive_dup = 0
660
+
661
+ # ── Tool 실행 ──
662
+ logger.info(
663
+ f"[loop] {claim_id} iter {iter_num}: action={next_step.action.value} "
664
+ f"rationale={next_step.rationale!r}"
665
+ )
666
+
667
+ ctx = ToolContext(
668
+ workspace=workspace,
669
+ claim_id=claim_id,
670
+ config=config,
671
+ datasources=datasources,
672
+ iter_num=iter_num,
673
+ claim=claim, # fetch_evidence가 claim.schema 사용
674
+ current_plan=plan, # [2026-05-26] replan tool이 원래 plan 참고용
675
+ )
676
+
677
+ try:
678
+ tool_cls = get_tool_class(next_step.action)
679
+ tool = tool_cls()
680
+ except KeyError as e:
681
+ logger.warning(f"[loop] Tool 미등록: {next_step.action.value}, skip")
682
+ last_result = ToolResult(
683
+ output={}, summary=f"Tool not registered: {next_step.action.value}",
684
+ success=False, error=str(e),
685
+ )
686
+ else:
687
+ # 입력 검증
688
+ valid, err_msg = tool.validate_input(next_step.input)
689
+ if not valid:
690
+ logger.warning(f"[loop] Tool 입력 검증 실패: {err_msg}")
691
+ last_result = ToolResult(
692
+ output={}, summary=f"입력 검증 실패: {err_msg}",
693
+ success=False, error=err_msg,
694
+ )
695
+ else:
696
+ try:
697
+ last_result = await tool.execute(next_step.input, ctx)
698
+ except Exception as e:
699
+ logger.exception(f"[loop] Tool 실행 예외: {next_step.action.value}")
700
+ last_result = ToolResult(
701
+ output={}, summary=f"실행 예외: {type(e).__name__}: {e}",
702
+ success=False, error=f"{type(e).__name__}: {e}",
703
+ )
704
+
705
+ # ── [2026-05-26] replan tool 결과 처리 — plan 통째 교체 ──
706
+ # ReplanTool이 성공하면 output에 new_plan(dict)이 들어있음.
707
+ # 이걸 받아 *plan 객체 자체*를 새로 만들고 plan_step_idx, _seen_action_keys
708
+ # 등 plan-관련 상태를 리셋. 이후 iter는 새 plan으로 진행.
709
+ if (
710
+ next_step.action == ActionType.REPLAN
711
+ and last_result.success
712
+ and isinstance(last_result.output, dict)
713
+ and last_result.output.get("new_plan")
714
+ ):
715
+ try:
716
+ _new_plan_dict = last_result.output["new_plan"]
717
+ # 직렬화 안전성을 위해 claim_id는 기존 plan것 유지
718
+ _new_plan_dict.setdefault("claim_id", str(plan.claim_id))
719
+ _new_plan = Plan.model_validate(_new_plan_dict)
720
+ _old_type = plan.claim_type.value
721
+ _new_type = _new_plan.claim_type.value
722
+ plan = _new_plan
723
+ # plan-관련 상태 리셋
724
+ plan_step_idx = 0
725
+ plan_exhausted = False
726
+ # 중복 차단 카운터 리셋 (새 plan이라 처음부터 다시)
727
+ _seen_action_keys.clear()
728
+ _consecutive_dup = 0
729
+ logger.info(
730
+ f"[loop] {claim_id}: ★ plan 교체 완료 (replan) — "
731
+ f"claim_type: {_old_type} → {_new_type}, "
732
+ f"new steps: {len(plan.initial_steps)}"
733
+ )
734
+ except Exception as _e:
735
+ logger.warning(f"[loop] {claim_id}: replan 결과 plan 파싱 실패: {_e}")
736
+
737
+ # ── Observation 생성 + memory 기록 ──
738
+ # [P28 2026-05-22] T2 — fetch_evidence 실패(no row matched/not relevant 등) 시
739
+ # observation summary에 fallback hint 주입 → 다음 reflect turn이 catalog_search를
740
+ # 2단계 fallback으로 재호출하도록 유도.
741
+ # [P30 2026-05-22] 2단계 fallback hint:
742
+ # 1. query_rewrite=true — LLM이 표 이름 친화 어휘로 query 변형 후 재검색.
743
+ # catalog 후보에 정답 표가 진입조차 못 한 경우 회복.
744
+ # 2. force_explore=true (explore_mode=meta 기본) — top 후보 표의 항목/분류
745
+ # 메타(ITM/OBJ)를 받아 LLM이 정답 표 식별. 정답이 후보에 있으나 cosine
746
+ # 점수가 낮아 묻힌 경우 회복.
747
+ _summary = last_result.summary
748
+ if (
749
+ next_step.action == ActionType.FETCH_EVIDENCE
750
+ and not last_result.success
751
+ and loop_config.mode == "reflect"
752
+ ):
753
+ _summary = (
754
+ _summary
755
+ + " | [hint] 이 표에서 row를 찾지 못했습니다. 다음 turn에 catalog_search "
756
+ "재호출 시 *두 옵션 중 하나*를 추가하세요:"
757
+ "\n (a) input에 \"query_rewrite\": true — LLM이 검색어를 표 이름 친화 "
758
+ "어휘로 변형(예: '체외 충격파 쇄석술 장비' → '시군구별 의료장비'). "
759
+ "원본 query가 row 항목 키워드일 때 정답 표가 후보에 진입하도록 도움."
760
+ "\n (b) input에 \"force_explore\": true — top 표들의 *항목 메타*(ITM/OBJ)를 "
761
+ "가져와 LLM이 '이 표에 indicator가 직접 있는가' 판단. 정답이 후보에는 "
762
+ "있으나 점수가 낮아 묻힌 경우 회복."
763
+ "\n 먼저 (a)를 시도하고 그래도 부족하면 (b). 둘 다 한 번에 켜도 OK."
764
+ "\n ★ (a)/(b)도 *전부* 시도했는데 또 fail이고, 받은 표 sample을 봐도 "
765
+ "claim의 정확한 값이 row로 없으면 → *replan* action 호출. "
766
+ "(예: claim '증가 수 52'인데 표엔 절대값만 — 계산 필요)"
767
+ )
768
+
769
+ last_observation = Observation(
770
+ iter_num=iter_num,
771
+ action=next_step.action,
772
+ input=next_step.input,
773
+ output=last_result.output,
774
+ summary=_summary,
775
+ success=last_result.success,
776
+ error=last_result.error,
777
+ )
778
+ # [중복 action 대응] 같은 (action, input) 재호출 시 cache 재사용
779
+ if loop_config.mode == "reflect":
780
+ try:
781
+ _action_key_to_observation[_action_key(next_step)] = last_observation
782
+ except Exception:
783
+ pass
784
+ # B2 sanity check용 — fetch가 성공할 때마다 갱신 (finish가 덮어쓰지 못하게)
785
+ if next_step.action == ActionType.FETCH_EVIDENCE and last_result.success:
786
+ last_fetch_observation = last_observation
787
+ # [패치 H-3] aggregate pool에도 추가
788
+ all_fetch_observations.append(last_observation)
789
+ # [패치] calculate가 성공할 때마다 갱신 — finish 미호출 시 합성 verdict용
790
+ if next_step.action == ActionType.CALCULATE and last_result.success:
791
+ last_calc_observation = last_observation
792
+ logger.info(
793
+ f"[loop] {claim_id} iter {iter_num} done: "
794
+ f"success={last_result.success} summary={last_result.summary[:200]}"
795
+ )
796
+
797
+ try:
798
+ append_iteration(
799
+ workspace, claim_id,
800
+ iteration_num=last_observation.iter_num,
801
+ action=getattr(last_observation.action, "value",
802
+ str(last_observation.action)),
803
+ action_input=last_observation.input,
804
+ observation_summary=last_observation.summary,
805
+ success=last_observation.success,
806
+ )
807
+ except Exception as e:
808
+ logger.warning(f"[loop] memory 기록 실패: {e}")
809
+
810
+ # ★ Phase E: observation을 workspace에 저장 (runtime_agent가 Evidence 빌드용으로 read)
811
+ # [2026-05-27] reflect LLM의 thought(자연어 사고)·confidence·proposed_verdict를
812
+ # observation에 함께 보존 → 프론트 실시간 카드/콘솔이 "왜 이 action을 골랐는지"를
813
+ # 표시할 수 있게. decision은 reflect 모드에서 LLM이 반환한 ReflectDecision이고,
814
+ # deterministic 모드(plan 그대로)에선 None — 그 경우 next_step.rationale(=planner가
815
+ # 적은 rationale)이라도 노출.
816
+ try:
817
+ _thought = None
818
+ _conf_so_far = None
819
+ _proposed_verdict = None
820
+ if 'decision' in locals() and decision is not None:
821
+ _thought = getattr(decision, "thought", None) or None
822
+ _conf_raw = getattr(decision, "confidence_so_far", None)
823
+ if isinstance(_conf_raw, (int, float)):
824
+ _conf_so_far = float(_conf_raw)
825
+ _pv = getattr(decision, "proposed_verdict", None)
826
+ if _pv is not None:
827
+ _proposed_verdict = getattr(_pv, "value", str(_pv))
828
+ # reflect가 thought를 안 줬으면 planner가 짠 step.rationale을 fallback으로
829
+ _thought = _thought or (next_step.rationale or None)
830
+ obs_dict = {
831
+ "iter_num": iter_num,
832
+ "action": next_step.action.value,
833
+ "input": next_step.input,
834
+ "output": last_result.output,
835
+ "summary": last_result.summary,
836
+ "success": last_result.success,
837
+ "error": last_result.error,
838
+ # ↓ LLM 자연어 사고 (프론트가 "LLM 생각" 라인으로 노출)
839
+ "thought": _thought,
840
+ "confidence_so_far": _conf_so_far,
841
+ "proposed_verdict": _proposed_verdict,
842
+ }
843
+ workspace.write_observation(
844
+ claim_id,
845
+ name=f"iter_{iter_num:02d}_{next_step.action.value}",
846
+ data=obs_dict,
847
+ )
848
+ except Exception as e:
849
+ logger.debug(f"[loop] observation 저장 실패: {e}")
850
+
851
+ # ── FINISH 신호 감지 ──
852
+ if last_result.output.get("_finish"):
853
+ finished = True
854
+ stop_reason = StopReason.COMPLETED
855
+ logger.info(f"[loop] {claim_id}: FINISH 신호 감지, iter {iter_num}에서 종료")
856
+ break
857
+
858
+ # ── [패치 2026-05-21] calculate 성공 후 자동 finish 트리거 ──
859
+ # growth_rate/difference 같은 derived claim에서 prev/current를 fetch로
860
+ # 다 모은 다음 calculate까지 성공시켰는데 reflect LLM이 finish는 안
861
+ # 부르고 다음 iter에 *또* fetch_evidence를 시도하는 헛돌이가 잦다
862
+ # (2026-05-21 진단: 출생아 수 증가율 claim이 iter 3에서 9.19% 계산
863
+ # 끝났는데 iter 4·5·6에서 또 fetch → 다른 시점 row 끌어와 결과 변동).
864
+ # _synthesize_verdict_from_calculate의 가드(derived suffix + fetch
865
+ # 1건 이상)를 통과해 match/mismatch가 명확하면 그 자리에서 verdict
866
+ # 확정. UNVERIFIABLE이면 통과시키지 않아 LLM이 더 fetch할 기회 유지.
867
+ # [2026-05-21] sibling base cache가 current를 공급하는 경우 fetch는 prev 1건만
868
+ # 들어와도 calculate에 필요한 두 값이 다 모인 것. >=2 가드를 그대로 두면
869
+ # 2번째 fetch가 중복으로 차단된 케이스(연속 dup)에서 auto-finish가 미발화 →
870
+ # LLM이 엉뚱한 표(예: 혼인건수)로 추가 fetch를 시도해 최종 verdict 오염.
871
+ # sent_id의 sibling base evidence가 있으면 fetch 1건도 충분으로 간주.
872
+ _has_sibling_base = False
873
+ try:
874
+ _schema = getattr(claim, "schema", None)
875
+ _role = (getattr(_schema, "value_role", None) or "") if _schema else ""
876
+ _sent_id = str(getattr(claim, "sent_id", "") or "").strip()
877
+ if (
878
+ _role in ("derived_rate", "derived_difference")
879
+ and _sent_id
880
+ and hasattr(workspace, "read_sibling_evidence")
881
+ ):
882
+ _sibs = workspace.read_sibling_evidence(_sent_id) or []
883
+ _has_sibling_base = any(s.get("role") == "base" for s in _sibs)
884
+ except Exception:
885
+ _has_sibling_base = False
886
+ _fetch_threshold = 1 if _has_sibling_base else 2
887
+
888
+ # [P22 2026-05-22] sibling base가 있으면 fetch 0번도 OK.
889
+ # LLM이 verified_facts/sibling 캐시만으로 calculate(current=sibling, prev=캐시)를
890
+ # 호출하는 케이스 — fetch_observation이 None이라 기존 gate가 막아 unverifiable로
891
+ # 떨어지던 회귀(혼인 건수 증가율 4.9% claim 케이스). sibling이 있으면 last_fetch
892
+ # 없어도 calculate 결과를 신뢰.
893
+ _gate_pass = (
894
+ not finished
895
+ and last_result.success
896
+ and next_step.action == ActionType.CALCULATE
897
+ and last_calc_observation is not None
898
+ )
899
+ if _has_sibling_base:
900
+ # sibling 있으면 fetch threshold/observation 요구 없음
901
+ _gate_pass = _gate_pass
902
+ else:
903
+ _gate_pass = _gate_pass and (
904
+ last_fetch_observation is not None
905
+ and len(all_fetch_observations) >= _fetch_threshold
906
+ )
907
+ if _gate_pass:
908
+ early_verdict = _synthesize_verdict_from_calculate(
909
+ plan=plan,
910
+ claim=claim,
911
+ claim_id=claim_id,
912
+ last_calc_observation=last_calc_observation,
913
+ iter_num=iter_num,
914
+ last_fetch_observation=last_fetch_observation,
915
+ workspace=workspace,
916
+ config=config,
917
+ )
918
+ if (
919
+ early_verdict is not None
920
+ and early_verdict.verdict != VerdictType.UNVERIFIABLE
921
+ ):
922
+ try:
923
+ workspace.write_verdict(
924
+ claim_id, early_verdict.model_dump(mode="json"),
925
+ )
926
+ except Exception as e:
927
+ logger.debug(f"[loop] early calculate verdict 저장 실패: {e}")
928
+ _save_verified_facts(workspace, early_verdict, claim_id, claim=claim)
929
+ v_str = getattr(
930
+ early_verdict.verdict, "value", str(early_verdict.verdict),
931
+ )
932
+ logger.info(
933
+ f"[loop] {claim_id}: iter {iter_num} calculate 성공 후 "
934
+ f"finish 자동 트리거 (verdict={v_str} "
935
+ f"conf={early_verdict.confidence:.2f}) — "
936
+ f"reflect의 finish 미호출 헛돌이 차단"
937
+ )
938
+ return early_verdict
939
+
940
+ # ── fail_fast 모드 ──
941
+ if loop_config.fail_fast and not last_result.success:
942
+ logger.info(f"[loop] {claim_id}: fail_fast 모드, iter {iter_num}에서 실패 종료")
943
+ stop_reason = StopReason.ERROR
944
+ break
945
+
946
+ # ── ★ Auto verdict synthesis ──
947
+ # [패치 K] plan_exhausted 뿐 아니라 max_iter 자연 종료 시에도 합성 시도.
948
+ # reflect 모드는 plan_step_idx를 안 쓰므로 plan_exhausted=False인 채로
949
+ # max_iterations에 도달하면, fetch는 성공했는데 LLM이 finish를 안 부르고
950
+ # 헛돌이로 iter을 다 써버린 경우 synthesis 자체가 안 발화해 unverifiable
951
+ # 기본값으로 떨어지던 버그 (혼인 건수 base 케이스). last_fetch_observation
952
+ # 이 살아있으면 그걸로 합성 시도.
953
+ if not finished and (plan_exhausted or last_fetch_observation is not None):
954
+ # [패치 G] last_observation 대신 last_fetch_observation(마지막 *성공한*
955
+ # fetch)을 사용한다. 중복 차단으로 plan_exhausted가 끝나는 경우
956
+ # last_observation은 success=False 더미이고, 그걸 그대로 합성에 넣으면
957
+ # _synthesize_verdict_from_observation의 "fetch 실패" 분기로 떨어져
958
+ # UNVERIFIABLE conf=0.25가 박힌다. 실제로는 직전 iter에 성공한 fetch
959
+ # 값(20787, 0.8, 18919 등)이 claim과 거의 일치하는데도 그 비교가
960
+ # 발화하지 않아 unverifiable로 끝나는 버그.
961
+ synth_target = (
962
+ last_fetch_observation if last_fetch_observation is not None
963
+ else last_observation
964
+ )
965
+ auto_verdict = _synthesize_verdict_from_observation(
966
+ plan=plan,
967
+ claim=claim,
968
+ claim_id=claim_id,
969
+ last_observation=synth_target,
970
+ iter_num=iter_num,
971
+ tolerance=loop_config.value_match_tolerance,
972
+ all_fetch_observations=all_fetch_observations,
973
+ config=config,
974
+ )
975
+ # [패치] fetch 기반 합성이 None이거나 UNVERIFIABLE인데 LLM이 계산을
976
+ # 끝낸 케이스면 calculate 결과로 다시 합성 시도. calculate 결과가
977
+ # 더 명확한(match/mismatch) verdict면 그쪽을 우선한다. fetch 합성이
978
+ # "prev row 없어 직접 계산 불가 → unverifiable"로 떨어질 때, agent가
979
+ # 따로 calculate로 9.193% 같은 결과를 내놨으면 그걸 살려야 함.
980
+ if last_calc_observation is not None and (
981
+ auto_verdict is None
982
+ or auto_verdict.verdict == VerdictType.UNVERIFIABLE
983
+ ):
984
+ calc_verdict = _synthesize_verdict_from_calculate(
985
+ plan=plan,
986
+ claim=claim,
987
+ claim_id=claim_id,
988
+ last_calc_observation=last_calc_observation,
989
+ iter_num=iter_num,
990
+ last_fetch_observation=last_fetch_observation,
991
+ workspace=workspace,
992
+ config=config,
993
+ )
994
+ if calc_verdict is not None and (
995
+ calc_verdict.verdict != VerdictType.UNVERIFIABLE
996
+ or auto_verdict is None
997
+ ):
998
+ auto_verdict = calc_verdict
999
+ if auto_verdict is not None:
1000
+ try:
1001
+ workspace.write_verdict(claim_id, auto_verdict.model_dump(mode="json"))
1002
+ except Exception as e:
1003
+ logger.debug(f"[loop] auto verdict 저장 실패: {e}")
1004
+ # [v6.21] 검증된 수치를 job 공유 저장소에 기록 — 다음 claim이 재사용.
1005
+ _save_verified_facts(workspace, auto_verdict, claim_id, claim=claim)
1006
+ v_str = getattr(auto_verdict.verdict, "value", str(auto_verdict.verdict))
1007
+ logger.info(
1008
+ f"[loop] {claim_id}: auto-synthesized verdict={v_str} "
1009
+ f"confidence={auto_verdict.confidence:.2f} (Phase D deterministic)"
1010
+ )
1011
+ return auto_verdict
1012
+
1013
+ # ── 종료 처리 (auto synthesis 실패 또는 다른 종료) ──
1014
+ if not finished:
1015
+ # FINISH 호출 안 됨, auto synthesis도 실패 → 강제 unverifiable
1016
+ verdict = AgentVerdict(
1017
+ claim_id=claim_id,
1018
+ verdict=VerdictType.UNVERIFIABLE,
1019
+ confidence=0.2,
1020
+ explanation=(
1021
+ f"Agent loop이 verdict 결정 없이 종료됨 (stop_reason={stop_reason.value}, "
1022
+ f"iter={iter_num}/{loop_config.max_iterations}). "
1023
+ f"마지막 observation: {last_observation.summary if last_observation else '(없음)'}"
1024
+ ),
1025
+ data_points=[], # agent_loop 본문 — fetch evidence 미정의 구간
1026
+ iterations_used=iter_num,
1027
+ stop_reason=stop_reason,
1028
+ )
1029
+ try:
1030
+ workspace.write_verdict(claim_id, verdict.model_dump(mode="json"))
1031
+ except Exception as e:
1032
+ logger.debug(f"[loop] 강제 verdict 저장 실패: {e}")
1033
+ logger.info(
1034
+ f"[loop] {claim_id}: 강제 unverifiable 종료. stop_reason={stop_reason.value}"
1035
+ )
1036
+ return verdict
1037
+
1038
+ # ── 정상 종료: workspace에서 verdict 읽기 (FinishTool이 저장한 것) ──
1039
+ try:
1040
+ verdict_data = workspace.read_verdict(claim_id)
1041
+ verdict = AgentVerdict(**verdict_data)
1042
+ except Exception as e:
1043
+ logger.warning(f"[loop] verdict 읽기 실패: {e}, 마지막 result에서 복원")
1044
+ verdict = AgentVerdict(
1045
+ claim_id=claim_id,
1046
+ verdict=VerdictType.UNVERIFIABLE,
1047
+ confidence=0.3,
1048
+ explanation="verdict.json 읽기 실패. 마지막 result에서 복원.",
1049
+ data_points=[], # agent_loop 본문 — fetch evidence 미정의 구간
1050
+ iterations_used=iter_num,
1051
+ stop_reason=stop_reason,
1052
+ )
1053
+
1054
+ # ── B2 sanity check (1-2 패치): LLM의 MATCH를 합성 verdict로 검증 ──
1055
+ # LLM이 fetch한 값을 무시하고 article 값을 그대로 답으로 박는 hallucination
1056
+ # 차단. fetch_evidence success가 있었으면 거기서 본 value vs claim value를
1057
+ # 객관적으로 비교(_synthesize_verdict_from_observation)해서, LLM의 MATCH가
1058
+ # 합성 결과 MISMATCH이면 합성으로 덮어쓴다.
1059
+ # [패치 2026-05-20] 합성이 UNVERIFIABLE인 경우엔 정정하지 않는다. fetch가
1060
+ # 단일 시점만 받아 합성이 prev/cur 비교 불가로 UNVERIFIABLE 떨어지는
1061
+ # 케이스에서, LLM이 (KOSIS prev + article current) 같이 섞어 계산한 8.82%
1062
+ # vs article 8.7%처럼 합리적인 결론을 잘못 강등시키는 걸 방지. evidence
1063
+ # 0건 자체는 A안 가드(FinishTool)에서 이미 차단됨.
1064
+ if (
1065
+ verdict.verdict == VerdictType.MATCH
1066
+ and last_fetch_observation is not None
1067
+ ):
1068
+ synth = _synthesize_verdict_from_observation(
1069
+ plan=plan,
1070
+ claim=claim,
1071
+ claim_id=claim_id,
1072
+ last_observation=last_fetch_observation,
1073
+ iter_num=iter_num,
1074
+ tolerance=loop_config.value_match_tolerance,
1075
+ all_fetch_observations=all_fetch_observations,
1076
+ config=config,
1077
+ )
1078
+ if synth is not None and synth.verdict == VerdictType.MISMATCH:
1079
+ logger.warning(
1080
+ f"[loop] {claim_id}: LLM finish verdict=match vs 합성 verdict="
1081
+ f"{synth.verdict.value} (MISMATCH) → 합성으로 정정. "
1082
+ f"(LLM explanation: {(verdict.explanation or '')[:120]!r})"
1083
+ )
1084
+ corrected = AgentVerdict(
1085
+ claim_id=verdict.claim_id,
1086
+ verdict=synth.verdict,
1087
+ confidence=synth.confidence,
1088
+ explanation=(
1089
+ f"[자동 정정] LLM은 '일치'로 보고했으나, 조회된 KOSIS 값으로 "
1090
+ f"객관 비교 시 결론이 다릅니다.\n\n"
1091
+ f"합성 판정 근거: {synth.explanation}\n\n"
1092
+ f"원래 LLM 설명(참고): {(verdict.explanation or '')[:200]}"
1093
+ ),
1094
+ data_points=synth.data_points or verdict.data_points,
1095
+ iterations_used=iter_num,
1096
+ stop_reason=verdict.stop_reason,
1097
+ )
1098
+ try:
1099
+ workspace.write_verdict(claim_id, corrected.model_dump(mode="json"))
1100
+ except Exception as e:
1101
+ logger.debug(f"[loop] 정정 verdict 저장 실패: {e}")
1102
+ verdict = corrected
1103
+
1104
+ # ── [N 패치 2026-05-21] LLM의 MISMATCH / UNVERIFIABLE도 합성 sanity check ──
1105
+ # LLM이 sub-claim 단위 검증해야 하는데 (1) claim_text 전체의 비교 문맥까지
1106
+ # 따져 mismatch 박거나 (2) fetch evidence 충분히 있는데도 unverifiable 박는
1107
+ # 케이스가 잦다.
1108
+ # (1) 경기 11573 vs fetch 11573 완벽 매치인데 mismatch 박힘
1109
+ # (2) 혼인 base 18921 vs fetch 18919 (0.01% 차이)인데 unverifiable 박힘
1110
+ # 합성 verdict가 MATCH이면 LLM 결정이 잘못 — 합성으로 정정.
1111
+ if (
1112
+ verdict.verdict in (VerdictType.MISMATCH, VerdictType.UNVERIFIABLE)
1113
+ and last_fetch_observation is not None
1114
+ ):
1115
+ synth = _synthesize_verdict_from_observation(
1116
+ plan=plan,
1117
+ claim=claim,
1118
+ claim_id=claim_id,
1119
+ last_observation=last_fetch_observation,
1120
+ iter_num=iter_num,
1121
+ tolerance=loop_config.value_match_tolerance,
1122
+ all_fetch_observations=all_fetch_observations,
1123
+ config=config,
1124
+ )
1125
+ if synth is not None and synth.verdict == VerdictType.MATCH:
1126
+ _orig_v = verdict.verdict.value
1127
+ logger.warning(
1128
+ f"[loop] {claim_id}: LLM finish verdict={_orig_v} vs 합성 "
1129
+ f"verdict=MATCH → 합성으로 정정 (sub-claim의 schema.value와 "
1130
+ f"KOSIS 조회값이 객관 일치). "
1131
+ f"(LLM explanation: {(verdict.explanation or '')[:120]!r})"
1132
+ )
1133
+ corrected = AgentVerdict(
1134
+ claim_id=verdict.claim_id,
1135
+ verdict=synth.verdict,
1136
+ confidence=synth.confidence,
1137
+ explanation=(
1138
+ f"[자동 정정] LLM은 '{_orig_v}'(으)로 보고했으나, 조회된 "
1139
+ f"KOSIS 값이 claim.value와 객관 일치합니다.\n\n"
1140
+ f"합성 판정 근거: {synth.explanation}\n\n"
1141
+ f"원래 LLM 설명(참고): {(verdict.explanation or '')[:200]}"
1142
+ ),
1143
+ data_points=synth.data_points or verdict.data_points,
1144
+ iterations_used=iter_num,
1145
+ stop_reason=verdict.stop_reason,
1146
+ )
1147
+ try:
1148
+ workspace.write_verdict(claim_id, corrected.model_dump(mode="json"))
1149
+ except Exception as e:
1150
+ logger.debug(f"[loop] 정정 verdict 저장 실패: {e}")
1151
+ verdict = corrected
1152
+
1153
+ # [S 패치] FinishTool 정상 경로의 verdict도 sibling_evidence에 기록 →
1154
+ # 같은 sent_id의 derived sub-claim들이 활용할 수 있도록.
1155
+ # auto-synthesis 경로는 위쪽 _save_verified_facts에서 이미 기록되므로
1156
+ # 여기선 *finished + match/mismatch* 케이스만 추가 처리.
1157
+ if finished:
1158
+ _save_verified_facts(workspace, verdict, claim_id, claim=claim)
1159
+
1160
+ v_str = getattr(verdict.verdict, "ovalue", str(verdict.verdict))
1161
+ logger.info(
1162
+ f"[loop] {claim_id}: 완료. verdict={v_str} "
1163
+ f"confidence={verdict.confidence:.2f} iterations={iter_num}"
1164
+ )
1165
+ return verdict