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,15 @@
1
+ """structverify.agent.prompts — Agent 시스템에서 사용하는 LLM 프롬프트 모음.
2
+
3
+ 각 모듈:
4
+ - planner_prompts: Plan Agent (Phase C) — claim → Plan JSON
5
+ - reflect_prompts: Reflect Agent (Phase D) — 현 상태 → 다음 ActionType
6
+ - 향후: explainer_prompts (기존 explainer.py 통합), verifier_prompts (필요 시)
7
+
8
+ 분리 이유:
9
+ - 프롬프트는 *자주 수정됨* — 로직 코드와 섞이면 PR 리뷰가 어렵다
10
+ - 한국어/영어 버전 분기 시 깔끔
11
+ - 다른 LLM 시도 시 (HCX → Claude → GPT) prompt만 갈아끼우면 됨
12
+ """
13
+ from .planner_prompts import PLAN_PROMPT_TEMPLATE, build_plan_prompt
14
+
15
+ __all__ = ["PLAN_PROMPT_TEMPLATE", "build_plan_prompt"]
@@ -0,0 +1,219 @@
1
+ """Plan Agent 프롬프트 (Phase C).
2
+
3
+ 핵심 설계 원칙:
4
+ 1. **claim type 자동 분류** — LLM이 absolute / growth_rate / diff / ratio_comparison 판단
5
+ 2. **N data point 추출** — claim type에 따라 1-3개 데이터 점
6
+ 3. **시점 명확화** — "올 4월"/"지난해 같은 달" → 구체적 YYYY-MM
7
+ 4. **fallback 전략** — 1차 검색 실패 시 대안 키워드/접근법
8
+
9
+ LLM 응답 형식: 단일 JSON. 다른 텍스트 없이.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+
16
+ # ── Plan Prompt 본체 ──────────────────────────────────────────────────
17
+
18
+ PLAN_PROMPT_TEMPLATE = """당신은 한국 통계 팩트체크 시스템의 *Plan Agent*입니다.
19
+ 주어진 *claim (주장)*을 검증하기 위해 *어떤 데이터가 필요한지* 계획을 세우세요.
20
+
21
+ ## Claim 정보
22
+
23
+ 원문 문장: {claim_text}
24
+ {schema_block}
25
+
26
+ {source_context_block}
27
+
28
+ ## 임무
29
+
30
+ 이 claim을 검증하기 위해:
31
+ 1. claim의 *유형*을 분류하세요 (absolute / difference / growth_rate / comparison / ranking / unknown).
32
+ 2. 검증에 *필요한 데이터 점*을 결정하세요.
33
+ 3. 1차 시도가 실패할 경우의 *fallback 전략*을 세우세요.
34
+
35
+ ### Claim 유형별 데이터 점 패턴
36
+
37
+ ★ 분류 우선순위 — schema가 단일 value(prev_value 없음)이면 **absolute**.
38
+ claim_text에 비교 문맥("~보다 적다", "~에 비해")이 있어도 *이 sub-claim*은
39
+ 자기 단일 값만 검증하면 됨. 비교 자체는 별도 layer에서 sub-claim verdict들을
40
+ 모아 도출. 단일 값 sub-claim을 comparison으로 잘못 분류하면 검증 시퀀스가
41
+ 망가져 calculate가 잘못 호출됨.
42
+
43
+ **absolute** — 단일 값 검증
44
+ → 데이터 점 1개 (role=current)
45
+ → calculation_formula 없음
46
+ → 시퀀스: catalog_search → fetch_evidence → finish
47
+
48
+ **growth_rate** — 증가율/감소율 (schema.prev_value 있고 claim에 "%·증가율" 같은 비율 표현)
49
+ → 데이터 점 2개 (current + prev)
50
+ → calculation_formula: "(current - prev) / prev * 100"
51
+ → 시퀀스: catalog_search → fetch_evidence(prev) → fetch_evidence(current) → calculate → finish
52
+
53
+ **difference** — 차이/변화량 (schema.prev_value 있고 차이가 *절대 단위*로 표현)
54
+ → 데이터 점 2개 (current + prev)
55
+ → calculation_formula: "current - prev"
56
+ → 시퀀스: catalog_search → fetch_evidence(prev) → fetch_evidence(current) → calculate → finish
57
+
58
+ **comparison** — 두 *시점의 직접 값* 둘 다 명시 (예: "73.6% → 70.3%")
59
+ → schema에 current+prev 두 값이 *모두 명시*된 경우만 해당
60
+ → 데이터 점 2개 — 각각 *독립 매칭*
61
+ → calculation_formula 없음 (calculate 호출하지 *말 것* — 비교는 *부등호*이지 수식이 아님)
62
+ → 시퀀스: catalog_search → fetch_evidence × 2 → finish
63
+
64
+ **ranking** — 순위 (예: "1위", "하락폭이 가장 컸다")
65
+ → 데이터 점 여러 개
66
+ → calculate 호출 *금지*
67
+ → 시퀀스: catalog_search → fetch_evidence × N → finish
68
+
69
+ **unknown** — 분류 불가. 데이터 점은 *최소한*만.
70
+ → 시퀀스: catalog_search → fetch_evidence → finish
71
+
72
+ ★ calculate 액션은 **growth_rate/difference에만** 사용. Python eval로 수식을
73
+ 계산하는 도구라 *부등호 비교에 쓸 수 없음*. absolute/comparison/ranking에서
74
+ calculate를 시퀀스에 넣지 *말 것*.
75
+
76
+ ★ **initial_steps에 반드시 finish step을 포함**할 것. plan이 finish로 끝나야
77
+ 검증이 자동 종료됨. finish 안 박으면 loop이 자율 결정으로 잘못된 액션을
78
+ 추가할 위험.
79
+
80
+ ### 시점(time) 표기 규칙
81
+
82
+ - *YYYY-MM* 형식 권장 (예: "2025-04")
83
+ - 연간 데이터면 *YYYY* (예: "2025")
84
+ - *상대 표현 금지*: "지난해", "전년", "올해" 등을 그대로 쓰지 말고 *anchor_year를 보고* 구체적 연-월로 변환.
85
+ - anchor_year={anchor_year}, 추출된 schema.time_period={schema_time}
86
+
87
+ ### Fallback 전략
88
+
89
+ 1차 검색이 실패하기 쉬운 경우:
90
+ - *월별 데이터가 없는 표*만 매칭됨 → `alternative_keywords`에 "월별" 추가
91
+ - *합계출산율*처럼 일부 표에 *시계열만* 있음 → 다른 표명 시도
92
+
93
+ ## 출력 형식
94
+
95
+ **반드시 아래 JSON만** 출력하세요. 다른 설명, 주석, 코드 펜스 절대 없음.
96
+
97
+ ```json
98
+ {{
99
+ "claim_type": "absolute | difference | growth_rate | comparison | ranking | unknown",
100
+ "required_data": [
101
+ {{
102
+ "indicator": "출생아 수",
103
+ "time": "2025-04",
104
+ "population": "전체",
105
+ "unit_hint": "명",
106
+ "role": "current"
107
+ }}
108
+ ],
109
+ "calculation_formula": "(current - prev) / prev * 100",
110
+ "expected_result": 8.7,
111
+ "expected_unit": "%",
112
+ "verdict_logic": "calculation 결과가 expected_result와 ±5% 안에 있으면 match",
113
+ "initial_steps": [
114
+ {{
115
+ "action": "catalog_search",
116
+ "input": {{"query": "<검색 키워드>", "category": ["<분류>"], "top_k": 5}},
117
+ "rationale": "데이터 소스에서 적합한 표 찾기"
118
+ }},
119
+ {{
120
+ "action": "fetch_evidence",
121
+ "input": {{"candidate_id": "<catalog_search 결과의 top id>", "params": {{}}}},
122
+ "rationale": "후보 표에서 데이터 가져와서 row 매칭"
123
+ }},
124
+ {{
125
+ "action": "finish",
126
+ "input": {{}},
127
+ "rationale": "검증 종료"
128
+ }}
129
+ ],
130
+ "fallback": {{
131
+ "use_original_text": false,
132
+ "alternative_keywords": ["월별 인구동향", "출생사망혼인이혼"],
133
+ "give_up_after_attempts": 5
134
+ }},
135
+ "notes": "이 claim은 ... (debugging용 자유 메모)"
136
+ }}
137
+ ```
138
+
139
+ ### role 값 (data point당)
140
+ - `current`: 비교의 *현재 시점* 값 (또는 단일 시점 값)
141
+ - `prev`: 비교의 *기준* 값 (이전 시점)
142
+ - `other`: 위에 안 맞는 경우 (ranking의 비교 대상들 등)
143
+
144
+ ### action 값 (initial_steps의 단계)
145
+ - `catalog_search`: 데이터 소스 표 검색
146
+ - `fetch_evidence`: 후보 ID로 실제 수치 조회
147
+ - `read_original`: 원문 기사 일부 다시 읽기
148
+ - `calculate`: 모은 값으로 *수식* 계산 (growth_rate/difference 전용 — Python eval).
149
+ *비교는 부등호이지 수식이 아니므로 calculate 호출 금지*.
150
+ - `finish`: 검증 종료 + verdict 결정. 모든 plan의 *마지막 step*으로 반드시 포함.
151
+
152
+ 이제 위 형식대로 *JSON 한 개*만 출력하세요.
153
+ """
154
+
155
+
156
+ def build_plan_prompt(
157
+ claim_text: str,
158
+ schema_info: dict[str, Any] | None = None,
159
+ source_excerpt: str | None = None,
160
+ anchor_year: str | int | None = None,
161
+ ) -> str:
162
+ """Plan prompt 인스턴스 생성.
163
+
164
+ Args:
165
+ claim_text: claim의 원문 문장. 필수.
166
+ schema_info: schema_inductor 결과 (있으면). dict 키: indicator, value, unit, time_period, population, prev_value, prev_time_period, ...
167
+ source_excerpt: 원문 기사의 일부 (있으면 — 맥락 보강).
168
+ anchor_year: 문서의 anchor_year (있으면 — 시점 해소용).
169
+
170
+ Returns:
171
+ LLM에 그대로 전달할 prompt 문자열.
172
+ """
173
+ # schema block 구성
174
+ if schema_info:
175
+ lines = ["추출된 schema 정보 (이 sub-claim 전용 — claim_text 전체 해석보다 *이쪽을* 우선):"]
176
+ # value_role을 최상단에 노출 — schema_inductor가 분기한 *역할*을 명시
177
+ _role = schema_info.get("value_role")
178
+ if _role:
179
+ _role_to_plantype = {
180
+ "base": "absolute (단일 값 검증, calculate 호출 금지)",
181
+ "derived_rate": "growth_rate (비율 직접 계산)",
182
+ "derived_difference": "difference (절대 차이 계산)",
183
+ }
184
+ mapping_hint = _role_to_plantype.get(_role, _role)
185
+ lines.append(
186
+ f" ★ value_role: {_role!r} → claim_type을 *{mapping_hint}*로 설정할 것. "
187
+ f"같은 문장의 다른 sub-claim과 헷갈리지 마세요."
188
+ )
189
+ for k in ("indicator", "value", "unit", "time_period", "population",
190
+ "prev_value", "prev_time_period", "prev_phrase",
191
+ "is_approximate", "modifier", "parent_path"):
192
+ v = schema_info.get(k)
193
+ if v is not None and v != "":
194
+ lines.append(f" - {k}: {v!r}")
195
+ schema_block = "\n".join(lines)
196
+ schema_time = schema_info.get("time_period") or "(없음)"
197
+ else:
198
+ schema_block = "추출된 schema 정보: (없음 — claim 원문에서만 추론)"
199
+ schema_time = "(없음)"
200
+
201
+ # source context block (옵션)
202
+ if source_excerpt:
203
+ # 너무 길면 자름 (1000자)
204
+ excerpt = source_excerpt.strip()
205
+ if len(excerpt) > 1000:
206
+ excerpt = excerpt[:1000] + "...[잘림]"
207
+ source_context_block = f"## 원문 기사 일부 (맥락)\n\n{excerpt}\n"
208
+ else:
209
+ source_context_block = ""
210
+
211
+ anchor_year_str = str(anchor_year) if anchor_year else "(없음)"
212
+
213
+ return PLAN_PROMPT_TEMPLATE.format(
214
+ claim_text=claim_text,
215
+ schema_block=schema_block,
216
+ source_context_block=source_context_block,
217
+ anchor_year=anchor_year_str,
218
+ schema_time=schema_time,
219
+ )
@@ -0,0 +1,387 @@
1
+ """Reflect Agent 프롬프트 (Phase E).
2
+
3
+ ReAct 패턴의 *Reflect* 단계 — 매 iter 시작 시 LLM이 다음 action을 동적 결정.
4
+
5
+ 설계:
6
+ - 매 iter에 *모든 컨텍스트* 제공: claim, plan, memory, last_observation
7
+ - LLM이 *5개 action 중 하나* 선택 + 그 input을 *직접 채움* (KOSIS prdSe까지)
8
+ - JSON 응답 형식 강제 → ReflectDecision으로 파싱
9
+
10
+ 룰베이스 fallback과 차이:
11
+ - 룰베이스: prdSe="M" 한 가지만 시도. 한국어 indicator를 substring 매칭.
12
+ - LLM Reflect: 사용 가능한 모든 candidate 보고 *최적 fetch params* 직접 생성.
13
+ row sample 보고 정확한 indicator/카테고리 추론.
14
+ growth_rate면 두 시점 fetch 동적으로 계획.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from typing import Any
20
+
21
+
22
+ # ── Reflect Prompt 본체 ──────────────────────────────────────────────
23
+
24
+ REFLECT_PROMPT_TEMPLATE = """당신은 한국 통계 팩트체크 시스템의 *Reflect Agent*입니다.
25
+ 지금까지 수행한 action 결과를 보고 *다음에 무엇을 할지* 결정하세요.
26
+
27
+ ## 현재 검증 대상 Claim
28
+
29
+ 원문: {claim_text}
30
+
31
+ 검증 schema:
32
+ - indicator: {indicator}
33
+ - value (주장값): {claim_value} {unit}
34
+ - time_period: {time_period}
35
+ - population: {population}
36
+ - prev_value (이전 시점 값, 있으면): {prev_value}
37
+ - prev_time_period: {prev_time_period}
38
+
39
+ ## Plan 정보 (이전에 만든 계획)
40
+
41
+ - claim_type: {claim_type} (absolute / growth_rate / difference / comparison / ranking)
42
+ - required_data: {required_data_json}
43
+ - calculation_formula: {calculation_formula}
44
+
45
+ ## 지금까지의 진행 상황
46
+
47
+ 현재 iteration: {iter_num} / {max_iterations}
48
+
49
+ ### 이전 observation 요약 (memory)
50
+ {memory_text}
51
+
52
+ ### 직전 observation (가장 중요)
53
+ {last_observation_block}
54
+
55
+ ## 사용 가능한 Action
56
+
57
+ ### `catalog_search` — KOSIS 표 후보 검색
58
+ input: {{"query": "<검색 키워드>", "category": ["<분류>"], "top_k": 5}}
59
+
60
+ ### `fetch_evidence` — 후보 표에서 실제 수치 조회
61
+ input: {{
62
+ "candidate_id": "<catalog_search 결과의 stat_id>",
63
+ "params": {{
64
+ "indicator": "<지표명, claim과 일치>",
65
+ "time_period": "<YYYY-MM 또는 YYYY>",
66
+ "prdSe": "<M / Q / Y>",
67
+ "startPrdDe": "<YYYYMM 또는 YYYY>",
68
+ "endPrdDe": "<startPrdDe와 같게>",
69
+ "match_criteria": {{"<column_name>": "<expected_substring>"}}
70
+ }}
71
+ }}
72
+
73
+ ★ `match_criteria` (선택, 강력 권장 — 두 번째 fetch부터):
74
+ 직전 fetch의 *row sample*에 노출된 컬럼명을 보고 어떤 컬럼이 어떤 값과
75
+ 매칭돼야 하는지를 dict로 명시하면, 모든 criteria를 만족하는 row만 채택된다.
76
+ *컬럼명은 row sample에 실제 등장한 키를 그대로 사용* — 도메인 무관.
77
+ 매칭 row가 한 개도 없으면 fetch 실패 처리 → 다음 fallback 표로 자동 진행.
78
+
79
+ ### `calculate` — 확보된 데이터로 수식 계산 (growth_rate, difference에 필수)
80
+ input: {{
81
+ "expression": "(current - prev) / prev * 100",
82
+ "variables": {{"current": <number>, "prev": <number>}}
83
+ }}
84
+ **중요**: 키 이름은 반드시 `expression` (formula 아님). 주석(`//...`) 절대 금지 — 순수 JSON만.
85
+
86
+ ### `read_original` — 원문 기사 더 읽기 (claim 외 정보 필요할 때)
87
+ input: {{"context_chars": 500}}
88
+
89
+ ### `finish` — 검증 종료 + 최종 verdict 확정
90
+ input: {{
91
+ "verdict": "match / mismatch / partial / unverifiable",
92
+ "confidence": 0.0~1.0,
93
+ "explanation": "<독자에게 보일 자연어 설명, KOSIS 출처 포함>",
94
+ "data_points": [{{"indicator": "...", "time": "...", "resolved_value": ..., "source": "kosis:DT_..."}}]
95
+ }}
96
+
97
+ ### `replan` — *plan 자체 갈아끼우기* (최후의 수단)
98
+ input: {{"reason": "<왜 replan이 필요한지 한 줄>"}}
99
+
100
+ ★ **호출 조건 (엄격)** — 아래 조건이 *모두* 만족될 때만 호출:
101
+ 1. 여러 catalog 후보를 fetch 시도했는데 *모두* 실패 (관련성 거부 또는 row 매칭 0건)
102
+ 2. catalog_search 재호출(query_rewrite/force_explore)도 시도했는데 추가 후보 없음
103
+ 3. observation에서 받은 표들의 row sample을 확인했지만, *claim의 정확한 값*이
104
+ row로 *직접 존재하지 않음*. 예: claim="증가 수 52"인데 표에는 "절대값 N대"만 있음.
105
+
106
+ ★ **호출 효과**: planner LLM이 observation을 보고 *새 plan*을 만든다.
107
+ - claim_type을 더 적절하게 변경 가능 (예: absolute → difference)
108
+ - calculation_formula 추가 (예: 'current - prev')
109
+ - 부족한 시점만 fetch하도록 새 steps 생성
110
+ - 이후 iter는 *완전히 새 plan*으로 진행
111
+
112
+ ★ **호출 금지**:
113
+ - 단순히 fetch 한두 번 실패했다고 호출 X (catalog retry 먼저)
114
+ - claim 값이 row로 *직접 있는* 케이스(absolute) X
115
+ - per-claim **최대 2회** (tool 내부에서 강제). 그 이상은 거부됨.
116
+
117
+ ★ **호출 후**: 새 plan으로 *다시* fetch_evidence/calculate를 진행. replan 호출 자체로
118
+ 검증이 끝나지 않음 — 새 plan을 *따르는 것*이 핵심.
119
+
120
+ ★★ verdict 판정 기준 (sub-claim 단위로만, 매우 중요) ★★
121
+
122
+ 이 검증은 **하나의 sub-claim 단위**입니다. 즉 위에 적힌 schema.value(기사 주장값)와
123
+ fetch_evidence가 가져온 official value 둘 사이의 *수치 일치 여부*만 판단하세요.
124
+
125
+ claim_text 원문엔 *비교 명제*("A가 B보다 적다", "전체 평균과 차이가 크다" 등)가
126
+ 포함될 수 있지만, **그건 별개의 상위 검증 단계**입니다. 이 sub-claim 단위에선
127
+ *무시*하고, *오직 schema.value vs evidence.value 의 객관적 수치 일치*만 보세요.
128
+
129
+ - **match**: |schema.value - evidence.value| / |schema.value| < 0.05 (5% 이내)
130
+ 단위가 의미상 같으면 통과 (예: schema "개" vs evidence "대" — 둘 다
131
+ 수량 단위라 같음). 예: schema=11573, evidence=11573 → 100% 일치 → match.
132
+ - **mismatch**: 오차 5% 초과. 예: schema=20717, evidence=4165 → 큰 차이 → mismatch.
133
+ - **partial**: 시점/단위 부분 일치 등 매우 드문 경우만.
134
+ - **unverifiable**: evidence 0건 또는 매칭 row 없음.
135
+
136
+ ★ explanation 작성 시 주의:
137
+ - "이 sub-claim의 schema.value=X vs official=Y → 일치/불일치" 처럼 *수치 단위*로 단순 작성.
138
+ - "주장 전체가 사실이다" 같은 *거시 진위 판단 금지*.
139
+ - 다른 sub-claim의 값을 끌어와 비교하지 마세요 (예: 경기 claim에서 강원 1336과 비교 X).
140
+
141
+ ## 결정 가이드
142
+
143
+ **iter 1 (시작)**: 보통 catalog_search 먼저.
144
+
145
+ **catalog_search 직후**: last_observation.output["candidates"]에서 *가장 적합한 표*의 id를 골라
146
+ fetch_evidence 호출. params는 claim의 indicator/time_period 그대로 넣되,
147
+ prdSe는 time_period 형식에 맞춰 (YYYY-MM이면 "M", YYYY-Q1이면 "Q", YYYY면 "Y").
148
+
149
+ **fetch_evidence 직후 (claim_type별로 다름 — plan을 따르세요)**:
150
+
151
+ - **claim_type=absolute**: 단일 값 검증. evidence value가 claim의 time_period와 매칭되면 → finish.
152
+ *calculate 호출 금지* — 절대값 검증에 수식이 필요 없음.
153
+ ★★ **prev_time_period 또는 다른 시점 fetch 절대 금지**. absolute claim은 *오직
154
+ claim.time_period* 한 시점만 필요. "지난 달", "전년", "지난해 같은 달", "이전 시점"
155
+ 같은 *derived 의도*를 가지지 말 것. 다른 sub-claim(증가율)이 같은 sent에 있어도
156
+ 이 claim과 무관. 같은 sent의 다른 claim은 별도 처리됨.
157
+ ★★ claim.time_period의 evidence가 이미 fetch 됐다면 *그 자리에서 finish*.
158
+ 같은 indicator 다른 시점을 또 fetch하지 말 것 (헛돌이).
159
+
160
+ - **claim_type=growth_rate / difference**: prev + current 두 값 다 받은 후에만 calculate
161
+ → finish. prev 아직 없으면 또 fetch_evidence (prev_time_period로).
162
+ ★ fetch 시점은 *오직* claim.time_period + claim.prev_time_period 두 개만.
163
+ 인접 월/분기 같은 *임의 시점*은 fetch 금지.
164
+
165
+ - **claim_type=comparison / ranking**: N개 비교 대상을 *각각 fetch*만 하고 → finish.
166
+ 비교 자체는 *부등호*이지 수식이 아니므로 **calculate 호출 절대 금지**. 차이값을
167
+ 구하지 마세요 — 사용자 claim은 "A < B"의 boolean이지 "B - A"의 차이가 아닙니다.
168
+
169
+ - **값/시점/단위가 안 맞음**: match_criteria로 row 좁히기 또는 다른 표로 catalog_search 다시.
170
+
171
+ **시도 횟수 거의 다 씀 (iter >= max-2)** 또는 *데이터 도저히 못 찾음*: finish (unverifiable).
172
+
173
+ ★ **plan의 initial_steps를 우선 따르세요**. plan에 finish가 박혀있으면 그 시점에
174
+ finish 호출. plan을 *넘어선* 액션(특히 plan.claim_type과 무관한 calculate)은
175
+ 부르지 마세요.
176
+
177
+ ★★ **finish 조기 종료 기준 (latency 최적화)**:
178
+ - absolute claim: claim.time_period 매칭 evidence를 *1개라도 success*로 받으면 즉시 finish.
179
+ - growth_rate / difference: prev + current 두 값 다 받으면 calculate → finish.
180
+ - 더 받아도 결과 안 바뀜. 추가 fetch는 헛돌이.
181
+
182
+ ## ★ 중복 action 방지 (매우 중요)
183
+
184
+ memory를 보고 **이미 같은 action을 같은 input으로 호출한 기록이 있으면 다른 시도를 하세요**:
185
+ - 같은 catalog_search query 반복 X → 검색어 바꾸거나 fetch로 넘어가기
186
+ - 같은 candidate에 같은 params로 fetch_evidence 반복 X → params 바꾸거나 (prdSe/startPrdDe 다르게)
187
+ 다른 candidate 시도하거나 finish로 넘어가기
188
+ - calculate가 "expression 비어있음"으로 실패하면 → **다음 시도엔 반드시 input.expression 채워서 보내기**
189
+ (formula 아님!)
190
+
191
+ ## ★ 한국어/KOSIS 특수성
192
+
193
+ - KOSIS 표 column에서 indicator는 ITM_NM, C1_NM, C2_NM 등 여러 column에 분산.
194
+ - 정확한 indicator를 input.params.indicator에 넣으면 시스템이 자동으로 모든 column 탐색.
195
+ - KOSIS 표는 종종 출생/사망/혼인/이혼 통합 — indicator를 정확히 명시해야 정확한 row 매칭.
196
+ - catalog_search 후보 이름이 너무 광범위(예: "월·분기·연간 인구동향(출생,사망,혼인,이혼)")해도
197
+ fetch params의 indicator로 좁힐 수 있음.
198
+
199
+ ## 출력 형식
200
+
201
+ 다른 텍스트 없이 **JSON 한 개**만:
202
+
203
+ ```json
204
+ {{
205
+ "thought": "현재 상태 + 다음 단계 추론 (1-3문장)",
206
+ "action": "catalog_search | explore_catalog | fetch_evidence | calculate | read_original | replan | finish",
207
+ "input": {{...}},
208
+ "confidence_so_far": 0.0,
209
+ "proposed_verdict": null,
210
+ "proposed_explanation": null
211
+ }}
212
+ ```
213
+
214
+ **JSON 규칙 (엄격)**:
215
+ - 주석(`//`, `/* */`) 절대 금지 — JSON 파싱 실패함
216
+ - trailing comma 금지
217
+ - 모든 string은 큰따옴표 `"..."` 사용
218
+ - 추측값 사용 금지 — 모르면 catalog_search나 fetch_evidence로 진짜 데이터 확보 후 사용
219
+
220
+ `action == "finish"`인 경우에만 `proposed_verdict` + `proposed_explanation` 채움
221
+ (input.verdict / input.explanation과 동일해도 됨).
222
+ """
223
+
224
+
225
+ def _format_last_observation(last_observation: Any) -> str:
226
+ """직전 observation을 prompt용 텍스트로 정리."""
227
+ if last_observation is None:
228
+ return "(없음 — 첫 iteration)"
229
+
230
+ action = getattr(last_observation, "action", None)
231
+ action_str = action.value if hasattr(action, "value") else str(action)
232
+ success = getattr(last_observation, "success", True)
233
+ summary = getattr(last_observation, "summary", "") or ""
234
+ output = getattr(last_observation, "output", {}) or {}
235
+
236
+ parts = [
237
+ f"- action: {action_str}",
238
+ f"- success: {success}",
239
+ f"- summary: {summary[:300]}",
240
+ ]
241
+
242
+ # 핵심 output 발췌 (token 절약)
243
+ if action_str == "catalog_search":
244
+ cands = output.get("candidates") or []
245
+ if cands:
246
+ top_lines = []
247
+ for i, c in enumerate(cands[:5]):
248
+ if not isinstance(c, dict):
249
+ continue
250
+ cid = c.get("id", "")
251
+ name = (c.get("name") or "")[:80]
252
+ score = c.get("score", 0)
253
+ top_lines.append(f" [{i+1}] id={cid!r} name={name!r} score={score:.3f}")
254
+ parts.append("- candidates (top 5):")
255
+ parts.extend(top_lines)
256
+
257
+ elif action_str == "fetch_evidence":
258
+ ev = output.get("evidence") or {}
259
+ if ev:
260
+ parts.append(
261
+ f"- evidence: value={ev.get('value')!r} unit={ev.get('unit')!r} "
262
+ f"time_period={ev.get('time_period')!r} "
263
+ f"stat_table_id={ev.get('stat_table_id')!r}"
264
+ )
265
+ matched = ev.get("matched_row")
266
+ if matched and isinstance(matched, dict):
267
+ key_fields = {
268
+ k: matched.get(k) for k in
269
+ ("ITM_NM", "C1_NM", "C2_NM", "PRD_DE", "DT", "UNIT_NM")
270
+ if k in matched
271
+ }
272
+ parts.append(f"- matched_row: {key_fields}")
273
+ # match_criteria 박을 때 활용할 *전체 컬럼명* 노출 — 도메인 무관
274
+ parts.append(
275
+ f"- available columns: {list(matched.keys())} "
276
+ f"# match_criteria에 사용 가능"
277
+ )
278
+ rows = ev.get("rows") or []
279
+ # row sample은 매칭 성공/실패 둘 다 노출. 성공 시에도 LLM이 *다른
280
+ # 매칭 후보가 있나* 보고 다음 fetch에 정밀한 match_criteria를 박을 수 있음.
281
+ if rows:
282
+ sample_keys = list((rows[0] or {}).keys())[:10]
283
+ sample = [
284
+ {k: r.get(k) for k in sample_keys if k in r}
285
+ for r in rows[:3]
286
+ ]
287
+ parts.append(f"- row sample (first 3 of {len(rows)}): {sample}")
288
+
289
+ elif action_str == "calculate":
290
+ parts.append(f"- result: {output.get('result')!r}")
291
+
292
+ return "\n".join(parts)
293
+
294
+
295
+ def _format_memory(memory_text: str, max_chars: int = 1500) -> str:
296
+ """memory를 prompt에 넣을 만큼 truncate."""
297
+ if not memory_text:
298
+ return "(아직 기록 없음)"
299
+ if len(memory_text) <= max_chars:
300
+ return memory_text
301
+ # 앞 1/3 + 뒤 2/3 (최근이 중요)
302
+ head = memory_text[: max_chars // 3]
303
+ tail = memory_text[-(max_chars - max_chars // 3):]
304
+ return f"{head}\n\n... (중간 생략) ...\n\n{tail}"
305
+
306
+
307
+ def build_reflect_prompt(
308
+ claim: Any,
309
+ plan: Any,
310
+ memory_text: str,
311
+ last_observation: Any,
312
+ iter_num: int,
313
+ max_iterations: int = 10,
314
+ ) -> str:
315
+ """Reflect Agent에 보낼 prompt 조립.
316
+
317
+ Args:
318
+ claim: structverify Claim (schema 포함)
319
+ plan: Plan 객체 (claim_type, required_data, formula)
320
+ memory_text: workspace.read_memory(claim_id) 결과
321
+ last_observation: 직전 Observation 또는 None
322
+ iter_num: 현재 iteration (1-based)
323
+ max_iterations: 최대 iter
324
+ """
325
+ schema = getattr(claim, "schema", None)
326
+ claim_text = getattr(claim, "claim_text", "") or ""
327
+
328
+ indicator = (getattr(schema, "indicator", None) if schema else None) or "(미지정)"
329
+ claim_value = getattr(schema, "value", None) if schema else None
330
+ unit = (getattr(schema, "unit", None) if schema else None) or ""
331
+ time_period = (getattr(schema, "time_period", None) if schema else None) or "(미지정)"
332
+ population = (getattr(schema, "population", None) if schema else None) or "(미지정)"
333
+ prev_value = getattr(schema, "prev_value", None) if schema else None
334
+ prev_time = (getattr(schema, "prev_time_period", None) if schema else None) or "(없음)"
335
+
336
+ claim_type = "unknown"
337
+ required_data_json = "[]"
338
+ formula = "(없음)"
339
+ if plan is not None:
340
+ ct = getattr(plan, "claim_type", None)
341
+ claim_type = ct.value if hasattr(ct, "value") else str(ct or "unknown")
342
+
343
+ # [2026-05-25] ABSOLUTE claim에선 prev_value/prev_time을 prompt에서 *제거*.
344
+ # 이유: LLM이 prev 정보를 보고 "증가율 검증"이라 잘못 판단해 작년 동월 fetch를
345
+ # 시도함 (실제 케이스: claim_type=ABSOLUTE인데 LLM이 2024-04 fetch 자행).
346
+ # 같은 sent 안 derived sub-claim의 메타가 잔재로 남은 것이라, absolute에선 안 봐도 됨.
347
+ if claim_type == "absolute":
348
+ prev_value = "(absolute claim — 사용 안 함)"
349
+ prev_time = "(absolute claim — 사용 안 함)"
350
+ req = getattr(plan, "required_data", []) or []
351
+ req_simplified = []
352
+ for d in req:
353
+ if hasattr(d, "model_dump"):
354
+ d_dict = d.model_dump()
355
+ elif isinstance(d, dict):
356
+ d_dict = d
357
+ else:
358
+ continue
359
+ # 필요한 필드만
360
+ req_simplified.append({
361
+ k: d_dict.get(k) for k in
362
+ ("indicator", "time", "population", "unit_hint", "resolved_value")
363
+ if d_dict.get(k) is not None
364
+ })
365
+ try:
366
+ required_data_json = json.dumps(req_simplified, ensure_ascii=False)
367
+ except Exception:
368
+ required_data_json = str(req_simplified)
369
+ formula = getattr(plan, "calculation_formula", None) or "(없음)"
370
+
371
+ return REFLECT_PROMPT_TEMPLATE.format(
372
+ claim_text=claim_text,
373
+ indicator=indicator,
374
+ claim_value=claim_value if claim_value is not None else "(미지정)",
375
+ unit=unit,
376
+ time_period=time_period,
377
+ population=population,
378
+ prev_value=prev_value if prev_value is not None else "(없음)",
379
+ prev_time_period=prev_time,
380
+ claim_type=claim_type,
381
+ required_data_json=required_data_json,
382
+ calculation_formula=formula,
383
+ iter_num=iter_num,
384
+ max_iterations=max_iterations,
385
+ memory_text=_format_memory(memory_text or ""),
386
+ last_observation_block=_format_last_observation(last_observation),
387
+ )