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,267 @@
1
+ """[리팩] 증가율/차이 자동 계산 — verifier._verify_growth_or_diff 분리 (fallback 프로필)"""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Callable
5
+
6
+ from structverify.core.schemas import Claim, Evidence, MismatchType, VerificationResult
7
+ from structverify.utils.logger import get_logger
8
+
9
+ from .row_match import (
10
+ aggregate_rows_from_fetches,
11
+ extract_criteria_from_row,
12
+ extract_numeric_values,
13
+ find_row_value_for_time,
14
+ find_value_for_time_with_criteria,
15
+ parse_row_dt,
16
+ period_is_annual,
17
+ period_matches_ym,
18
+ )
19
+ from .units import normalize_value
20
+ from .verdict_thresholds import verdict_from_error
21
+
22
+ logger = get_logger(__name__)
23
+
24
+
25
+ def verify_growth_or_diff(
26
+ claim: Claim,
27
+ evidence: Evidence,
28
+ claim_year: str | None,
29
+ claim_year_month: str | None,
30
+ prev_value: float,
31
+ is_ratio: bool,
32
+ config: dict,
33
+ classify_mismatch: Callable[[Claim, Evidence, float, dict], MismatchType],
34
+ ) -> VerificationResult | None:
35
+ """
36
+ 증가율/차이 schema의 자동 계산 검증 (fallback 프로필, verifier._verify_growth_or_diff).
37
+
38
+ 프로세스:
39
+ 1. KOSIS raw_response에서 *현재 시점의 절대값* row 찾기 (unit 검사 우회, 시점 우선)
40
+ 2. claim의 *prev_value*와 함께 계산:
41
+ - 증가율(%): (current - prev) / prev * 100
42
+ - 차이(절대): current - prev
43
+ 3. claim의 value와 비교 → verdict
44
+
45
+ KOSIS 현재 시점 row를 못 찾으면 None 반환 (호출자가 일반 분기로 fallthrough).
46
+ """
47
+ claimed = claim.schema.value
48
+ raw = evidence.raw_response if isinstance(evidence.raw_response, dict) else {}
49
+ rows = raw.get("row", [])
50
+ if not isinstance(rows, list) or not rows:
51
+ return None
52
+
53
+ kosis_values = extract_numeric_values(rows)
54
+ if not kosis_values:
55
+ return None
56
+
57
+ tier1, tier2a, tier2b, tier3 = [], [], [], []
58
+ for kv in kosis_values:
59
+ kv_period = kv.get("period") or ""
60
+ normalized = normalize_value(kv["value"], kv["unit"])
61
+ if normalized == 0:
62
+ continue
63
+ # 연도 ±2년만 필터 (unit 검사는 *우회*)
64
+ if claim_year and kv_period:
65
+ try:
66
+ if abs(int(claim_year) - int(kv_period[:4])) > 2:
67
+ continue
68
+ except (ValueError, TypeError):
69
+ pass
70
+ kv_norm = {**kv, "normalized": normalized}
71
+ # [v6.15] 정규화 헬퍼 사용
72
+ if claim_year_month and period_matches_ym(kv_period, claim_year_month):
73
+ tier1.append(kv_norm)
74
+ elif claim_year and kv_period.startswith(claim_year):
75
+ if claim_year_month and period_is_annual(kv_period):
76
+ # claim이 월값인데 row가 연간 누계 → 후순위
77
+ tier2b.append(kv_norm)
78
+ else:
79
+ tier2a.append(kv_norm)
80
+ else:
81
+ tier3.append(kv_norm)
82
+
83
+ pool = tier1 or tier2a or tier2b or tier3
84
+ if not pool:
85
+ logger.info("[verifier C2] 증가율 계산: KOSIS에서 현재 시점 row 못 찾음. fallthrough.")
86
+ return None
87
+
88
+ # tier 안에서 *prev_value 자릿수와 비슷한 row* 선택 (안전)
89
+ def _scale_match(kv):
90
+ v = abs(kv["normalized"])
91
+ p = abs(prev_value)
92
+ if v == 0 or p == 0:
93
+ return float("inf")
94
+ return abs(v / p - 1) if v >= p else abs(p / v - 1)
95
+
96
+ current_row = min(pool, key=_scale_match)
97
+ current_value = current_row["normalized"]
98
+
99
+ if is_ratio:
100
+ calculated = (current_value - prev_value) / prev_value * 100
101
+ calc_desc = (
102
+ f"증가율 ({current_value} - {prev_value}) / {prev_value} * 100 = {calculated:.2f}%"
103
+ )
104
+ else:
105
+ calculated = current_value - prev_value
106
+ calc_desc = f"차이 {current_value} - {prev_value} = {calculated:.4f}"
107
+
108
+ denom = max(abs(calculated), abs(claimed), 1e-9)
109
+ error_rate = abs(calculated - claimed) / denom
110
+
111
+ logger.info(
112
+ f"[verifier C2] {calc_desc} | claim={claimed} | error_rate={error_rate*100:.2f}% | "
113
+ f"current_row: period={current_row.get('period')!r} value={current_row['value']} "
114
+ f"unit={current_row.get('unit')!r}"
115
+ )
116
+
117
+ evidence = evidence.model_copy(update={
118
+ "official_value": current_row.get("value"),
119
+ "unit": current_row.get("unit") or evidence.unit,
120
+ "time_period": current_row.get("period") or evidence.time_period,
121
+ })
122
+
123
+ best_match_info = {
124
+ **current_row,
125
+ "error_rate": error_rate,
126
+ "calculated_from_prev": calculated,
127
+ "prev_value": prev_value,
128
+ }
129
+ return verdict_from_error(
130
+ claim, evidence, error_rate, best_match_info, config, classify_mismatch,
131
+ )
132
+
133
+
134
+ # ── agent 프로필 rows pool 계산 (loop._try_* 에서 추출) ─────────────────────
135
+
136
+ def try_growth_rate_from_rows(
137
+ evidence: dict,
138
+ schema,
139
+ claim_id: str,
140
+ all_fetch_observations: list | None = None,
141
+ ) -> tuple[float, float, float, str] | None:
142
+ """[v6.17] growth_rate claim — 표 rows에서 (current-prev)/prev*100 직접 계산.
143
+
144
+ KOSIS에 '증가율' 통계표가 따로 없어도, fetch한 표(현재값 표)의
145
+ rows에서 prev_time_period 시점 행을 찾아 증가율을 직접 계산한다.
146
+ [패치 H-3] aggregated pool + criteria 필터로 current/prev row를 올바르게 매칭.
147
+ """
148
+ prev_time = getattr(schema, "prev_time_period", None) if schema else None
149
+ if not prev_time:
150
+ return None
151
+
152
+ cur_time = getattr(schema, "time_period", None) if schema else None
153
+
154
+ rows = list(evidence.get("rows") or [])
155
+ pool_rows: list[dict] = []
156
+ if all_fetch_observations:
157
+ pool_rows = aggregate_rows_from_fetches(all_fetch_observations)
158
+ for r in rows:
159
+ if isinstance(r, dict) and r not in pool_rows:
160
+ pool_rows.append(r)
161
+ if not pool_rows:
162
+ return None
163
+
164
+ matched_row = evidence.get("matched_row") or {}
165
+ criteria = extract_criteria_from_row(matched_row)
166
+
167
+ current_val: float | None = None
168
+ if cur_time:
169
+ cur_hit = find_value_for_time_with_criteria(pool_rows, cur_time, criteria)
170
+ if cur_hit is not None:
171
+ current_val, _ = cur_hit
172
+ if current_val is None:
173
+ current_val = parse_row_dt(evidence.get("value"))
174
+ if current_val is None and matched_row:
175
+ current_val = parse_row_dt(matched_row.get("DT"))
176
+ if current_val is None:
177
+ return None
178
+
179
+ prev_hit = find_value_for_time_with_criteria(pool_rows, prev_time, criteria)
180
+ if prev_hit is None:
181
+ logger.info(
182
+ f"[loop] {claim_id}: growth_rate 직접계산 — criteria 매칭 prev row "
183
+ f"{prev_time!r} 못 찾음. 지표 무관 시점 매칭으로 fallback "
184
+ f"(pool={len(pool_rows)} rows, criteria={list(criteria.keys()) or '없음'})"
185
+ )
186
+ prev_val_legacy = find_row_value_for_time(pool_rows, prev_time)
187
+ if prev_val_legacy is None:
188
+ return None
189
+ prev_val = prev_val_legacy
190
+ else:
191
+ prev_val, _ = prev_hit
192
+
193
+ if prev_val == 0:
194
+ return None
195
+
196
+ calc_rate = (current_val - prev_val) / prev_val * 100.0
197
+ desc = (
198
+ f"표에서 직접 계산: 현재값({cur_time or '?'}) {current_val} - "
199
+ f"이전값({prev_time}) {prev_val} "
200
+ f"→ 증가율 ({current_val}-{prev_val})/{prev_val}×100 = {calc_rate:.2f}%"
201
+ )
202
+ logger.info(f"[loop] {claim_id}: growth_rate 직접계산 성공 — {desc}")
203
+ return (calc_rate, current_val, prev_val, desc)
204
+
205
+
206
+ def try_difference_from_rows(
207
+ evidence: dict,
208
+ schema,
209
+ claim_id: str,
210
+ all_fetch_observations: list | None = None,
211
+ ) -> tuple[float, float, float, str] | None:
212
+ """[v6.23] difference claim — 표 rows에서 current-prev 차이 직접 계산.
213
+
214
+ [패치 H-3] aggregated pool + criteria 필터로 current/prev row를 올바르게 매칭.
215
+ """
216
+ prev_time = getattr(schema, "prev_time_period", None) if schema else None
217
+ if not prev_time:
218
+ return None
219
+
220
+ cur_time = getattr(schema, "time_period", None) if schema else None
221
+
222
+ rows = list(evidence.get("rows") or [])
223
+ pool_rows: list[dict] = []
224
+ if all_fetch_observations:
225
+ pool_rows = aggregate_rows_from_fetches(all_fetch_observations)
226
+ for r in rows:
227
+ if isinstance(r, dict) and r not in pool_rows:
228
+ pool_rows.append(r)
229
+ if not pool_rows:
230
+ return None
231
+
232
+ matched_row = evidence.get("matched_row") or {}
233
+ criteria = extract_criteria_from_row(matched_row)
234
+
235
+ current_val: float | None = None
236
+ if cur_time:
237
+ cur_hit = find_value_for_time_with_criteria(pool_rows, cur_time, criteria)
238
+ if cur_hit is not None:
239
+ current_val, _ = cur_hit
240
+ if current_val is None:
241
+ current_val = parse_row_dt(evidence.get("value"))
242
+ if current_val is None and matched_row:
243
+ current_val = parse_row_dt(matched_row.get("DT"))
244
+ if current_val is None:
245
+ return None
246
+
247
+ prev_hit = find_value_for_time_with_criteria(pool_rows, prev_time, criteria)
248
+ if prev_hit is None:
249
+ logger.info(
250
+ f"[loop] {claim_id}: difference 직접계산 — criteria 매칭 prev row "
251
+ f"{prev_time!r} 못 찾음. 지표 무관 fallback "
252
+ f"(pool={len(pool_rows)} rows, criteria={list(criteria.keys()) or '없음'})"
253
+ )
254
+ prev_val_legacy = find_row_value_for_time(pool_rows, prev_time)
255
+ if prev_val_legacy is None:
256
+ return None
257
+ prev_val = prev_val_legacy
258
+ else:
259
+ prev_val, _ = prev_hit
260
+
261
+ calc_diff = current_val - prev_val
262
+ desc = (
263
+ f"표에서 직접 계산: 현재값({cur_time or '?'}) {current_val} - "
264
+ f"이전값({prev_time}) {prev_val} → 차이 {current_val}-{prev_val} = {calc_diff:.4f}"
265
+ )
266
+ logger.info(f"[loop] {claim_id}: difference 직접계산 성공 — {desc}")
267
+ return (calc_diff, current_val, prev_val, desc)
@@ -0,0 +1,345 @@
1
+ """[리팩] KOSIS row·시점 매칭 — verifier._find_best_match 등 분리 (fallback 프로필)"""
2
+ from __future__ import annotations
3
+
4
+ from structverify.utils.logger import get_logger
5
+
6
+ from .units import is_same_unit_type, normalize_value
7
+
8
+ logger = get_logger(__name__)
9
+
10
+
11
+ def extract_numeric_values(rows: list[dict]) -> list[dict]:
12
+ """raw_response["row"]에서 수치/단위/기간 추출"""
13
+ values = []
14
+ for row in rows:
15
+ dt = row.get("DT", "")
16
+ unit = row.get("UNIT_NM", "")
17
+ prd = row.get("PRD_DE", "")
18
+ try:
19
+ val = float(str(dt).replace(",", ""))
20
+ values.append({"value": val, "unit": unit, "period": prd, "raw": row})
21
+ except (ValueError, TypeError):
22
+ continue
23
+ return values
24
+
25
+
26
+ def normalize_period(period: str) -> str:
27
+ """KOSIS PRD_DE 다양한 형식을 *YYYYMM* 또는 *YYYY*로 정규화.
28
+
29
+ 지원 형식:
30
+ "202504" (6자 숫자) → "202504"
31
+ "2025-04" (7자 하이픈) → "202504"
32
+ "2025.04" (7자 점) → "202504"
33
+ "2025M04" (7자 M 구분) → "202504"
34
+ "2025/04" → "202504"
35
+ "2025" (4자 — 연간 누계) → "2025"
36
+ "2025Q1" (분기 — Q 포함) → "2025"
37
+ "202504XX" (8자+ — 일별 등) → "202504"
38
+ """
39
+ if not period:
40
+ return ""
41
+ p = str(period).strip()
42
+
43
+ # 분기 처리: "2025Q1", "20251Q" 등 → "2025"
44
+ if "Q" in p.upper():
45
+ return p[:4] if p[:4].isdigit() else ""
46
+
47
+ clean = "".join(c for c in p if c.isdigit())
48
+
49
+ if len(clean) >= 6:
50
+ return clean[:6]
51
+ if len(clean) == 4:
52
+ return clean
53
+ return clean
54
+
55
+
56
+ def period_is_monthly(period: str) -> bool:
57
+ """이 period가 *월 단위* row인지 (claim_year_month와 정확 비교 가능한 형식)."""
58
+ normalized = normalize_period(period)
59
+ return len(normalized) == 6 and normalized.isdigit()
60
+
61
+
62
+ def period_is_annual(period: str) -> bool:
63
+ """이 period가 *연간 누계 또는 연도 단위* row인지.
64
+
65
+ claim이 *월값*인데 (claim_year_month 있음) 매칭되면 *후순위*로 처리해야 함.
66
+ """
67
+ normalized = normalize_period(period)
68
+ return len(normalized) == 4 and normalized.isdigit()
69
+
70
+
71
+ def period_matches_ym(period: str, claim_year_month: str) -> bool:
72
+ """정규화 후 claim_year_month와 정확히 매칭되는지 (tier 1 후보)."""
73
+ if not period or not claim_year_month:
74
+ return False
75
+ normalized = normalize_period(period)
76
+ # claim_ym도 정규화 (혹시 "2025-04" 형식으로 들어올 수 있음)
77
+ claim_norm = normalize_period(claim_year_month)
78
+ if len(claim_norm) != 6 or len(normalized) < 6:
79
+ return False
80
+ return normalized[:6] == claim_norm[:6]
81
+
82
+
83
+ def find_best_match(
84
+ claimed: float,
85
+ claim_unit: str,
86
+ claim_year: str | None,
87
+ kosis_values: list[dict],
88
+ claim_year_month: str | None = None,
89
+ ) -> tuple[dict | None, float]:
90
+ """
91
+ KOSIS 전체 행에서 claim과 가장 가까운 값 탐색.
92
+ factcheck_test.py v7 numeric_check 로직 그대로 (fallback 프로필).
93
+
94
+ [v6.14 G fix] all_rows_empty — 지표명-단위 일체형 표면 row.unit 비어도 통과.
95
+ [v6.14 F1 fix] claim_year_month 있으면 동일 연-월 row를 *최우선* picking.
96
+ [v6.15] tier 2a (월 row) / 2b (연간 누계) 분리.
97
+ """
98
+ # [v6.14 G fix] 표 전체 row.unit 분포 분석
99
+ nonempty_units = [
100
+ kv["unit"] for kv in kosis_values
101
+ if kv.get("unit") and str(kv["unit"]).strip()
102
+ ]
103
+ all_rows_empty = len(nonempty_units) == 0
104
+
105
+ total_rows = len(kosis_values)
106
+ year_filtered = 0
107
+ unit_filtered = 0
108
+ zero_filtered = 0
109
+
110
+ tier1_candidates: list[dict] = []
111
+ tier2a_candidates: list[dict] = []
112
+ tier2b_candidates: list[dict] = []
113
+ tier3_candidates: list[dict] = []
114
+
115
+ for kv in kosis_values:
116
+ kv_year = None
117
+ kv_period = kv.get("period") or ""
118
+ if claim_year and kv_period:
119
+ kv_year = kv_period[:4]
120
+ try:
121
+ # 연도 정확 일치 필터 (박재윤 2026-05-14: ±2 → 0으로 변경)
122
+ if abs(int(claim_year) - int(kv_year)) > 0:
123
+ year_filtered += 1
124
+ continue
125
+ except (ValueError, TypeError):
126
+ pass
127
+
128
+ normalized = normalize_value(kv["value"], kv["unit"])
129
+ if normalized == 0:
130
+ zero_filtered += 1
131
+ continue
132
+
133
+ if not is_same_unit_type(claim_unit, kv["unit"], all_rows_empty=all_rows_empty):
134
+ unit_filtered += 1
135
+ continue
136
+
137
+ # [v6.14 H fix] 상대 오차 — 분모 1 버그 회피 (소수 지표 오판정 방지)
138
+ denom = max(abs(normalized), abs(claimed), 1e-9)
139
+ error_rate = abs(normalized - claimed) / denom
140
+ kv_with_meta = {**kv, "normalized": normalized, "error_rate": error_rate}
141
+
142
+ # [F1] 시점 tier 분류 — [v6.15] period 정규화 + 연간/월 row 분리
143
+ if claim_year_month and kv_period:
144
+ if period_matches_ym(kv_period, claim_year_month):
145
+ tier1_candidates.append(kv_with_meta)
146
+ elif claim_year and kv_period.startswith(claim_year):
147
+ if period_is_annual(kv_period):
148
+ # 연간 누계 — claim이 월값일 때 후순위
149
+ tier2b_candidates.append(kv_with_meta)
150
+ else:
151
+ tier2a_candidates.append(kv_with_meta)
152
+ else:
153
+ tier3_candidates.append(kv_with_meta)
154
+ elif claim_year and kv_period and kv_period.startswith(claim_year):
155
+ tier2a_candidates.append(kv_with_meta)
156
+ else:
157
+ tier3_candidates.append(kv_with_meta)
158
+
159
+ selected_tier = None
160
+ pool: list[dict] = []
161
+ if tier1_candidates:
162
+ pool = tier1_candidates
163
+ selected_tier = "1 (동일 연-월)"
164
+ elif tier2a_candidates:
165
+ pool = tier2a_candidates
166
+ selected_tier = "2a (동일 연도, 월 row)"
167
+ elif tier2b_candidates:
168
+ pool = tier2b_candidates
169
+ selected_tier = "2b (동일 연도, 연간 누계 — claim 월값과 시점 mismatch 가능)"
170
+ elif tier3_candidates:
171
+ pool = tier3_candidates
172
+ selected_tier = "3 (±2년)"
173
+
174
+ # [v6.15] 선택된 tier 번호를 각 후보에 기록 (verdict 가드용)
175
+ _tier_num = 1
176
+ if selected_tier:
177
+ if selected_tier.startswith("2"):
178
+ _tier_num = 2
179
+ elif selected_tier.startswith("3"):
180
+ _tier_num = 3
181
+ for kv in pool:
182
+ kv["_tier"] = _tier_num
183
+
184
+ best_match = None
185
+ best_error = float("inf")
186
+ for kv in pool:
187
+ if kv["error_rate"] < best_error:
188
+ best_error = kv["error_rate"]
189
+ best_match = kv
190
+
191
+ candidates = len(pool)
192
+
193
+ logger.info(
194
+ f"[verifier] match 탐색: claim={claimed}/{claim_unit!r} year={claim_year} "
195
+ f"ym={claim_year_month} | 전체 row={total_rows} (all_rows_empty={all_rows_empty}) → "
196
+ f"연도제외={year_filtered}, zero제외={zero_filtered}, 단위불일치제외={unit_filtered} | "
197
+ f"tier1(연-월)={len(tier1_candidates)}, tier2a(연도+월)={len(tier2a_candidates)}, "
198
+ f"tier2b(연간누계)={len(tier2b_candidates)}, tier3(±2년)={len(tier3_candidates)} → "
199
+ f"선택 tier={selected_tier}, 최종 후보={candidates}"
200
+ )
201
+ if best_match:
202
+ logger.info(
203
+ f"[verifier] best_match: period={best_match.get('period')!r} "
204
+ f"unit={best_match.get('unit')!r} value={best_match.get('value')} "
205
+ f"(normalized={best_match.get('normalized')}) "
206
+ f"error={best_match.get('error_rate'):.4f}"
207
+ )
208
+ else:
209
+ logger.info("[verifier] best_match: 없음 (단위/연도 필터 통과 row 없음)")
210
+
211
+ return best_match, best_error
212
+
213
+
214
+ # ── [패치 H-3] agent 프로필 row pool (loop.py에서 추출) ─────────────────────
215
+ # matched_row의 ITM_NM·C1_NM~C4_NM을 criteria로 추출해, aggregated rows
216
+ # 풀에서 같은 지표에 다른 시점(target_time)의 row를 찾는다.
217
+ # 시점만 보고 row를 잡으면 다른 지표 row(출생아 수 vs 혼인 건수 등)가
218
+ # 잘못 매칭되어 가짜 prev/current 비교를 만든다 — criteria 필터로 차단.
219
+
220
+ INDICATOR_CRITERIA_FIELDS = ("ITM_NM", "C1_NM", "C2_NM", "C3_NM", "C4_NM")
221
+
222
+
223
+ def parse_row_dt(raw) -> float | None:
224
+ """KOSIS row의 DT 필드를 float로 파싱 (콤마/공백 제거)."""
225
+ if raw is None:
226
+ return None
227
+ try:
228
+ return float(str(raw).replace(",", "").strip())
229
+ except (TypeError, ValueError):
230
+ return None
231
+
232
+
233
+ def find_row_value_for_time(rows: list, target_time: str) -> float | None:
234
+ """[v6.17] KOSIS 표 rows에서 특정 시점(PRD_DE) 행의 값(DT)을 찾는다.
235
+
236
+ growth_rate 직접 계산용 — 같은 표에서 prev 시점 값을 추출한다.
237
+ target_time: 'YYYY' 또는 'YYYY-MM'. PRD_DE는 'YYYY' 또는 'YYYYMM' 형식.
238
+ """
239
+ if not rows or not target_time:
240
+ return None
241
+ # 'YYYY-MM' → 'YYYYMM' 정규화
242
+ norm = str(target_time).replace("-", "").strip()
243
+ for row in rows:
244
+ if not isinstance(row, dict):
245
+ continue
246
+ prd = str(row.get("PRD_DE", "") or "").strip()
247
+ if prd == norm:
248
+ v = parse_row_dt(row.get("DT"))
249
+ if v is not None:
250
+ return v
251
+ # 연도만으로 재시도 (target이 'YYYY-MM'인데 표는 연 단위인 경우)
252
+ year = norm[:4]
253
+ if year and year != norm:
254
+ for row in rows:
255
+ if not isinstance(row, dict):
256
+ continue
257
+ prd = str(row.get("PRD_DE", "") or "").strip()
258
+ if prd == year:
259
+ v = parse_row_dt(row.get("DT"))
260
+ if v is not None:
261
+ return v
262
+ return None
263
+
264
+
265
+ def extract_criteria_from_row(row: dict) -> dict:
266
+ """matched_row에서 지표 식별 컬럼만 추출."""
267
+ if not isinstance(row, dict):
268
+ return {}
269
+ return {
270
+ k: row[k]
271
+ for k in INDICATOR_CRITERIA_FIELDS
272
+ if k in row and row[k] is not None and str(row[k]).strip() != ""
273
+ }
274
+
275
+
276
+ def find_value_for_time_with_criteria(
277
+ all_rows: list[dict],
278
+ target_time: str,
279
+ criteria: dict | None,
280
+ ) -> tuple[float, dict] | None:
281
+ """rows[]에서 target_time 매칭 + criteria 컬럼 값 일치하는 row 찾기.
282
+
283
+ criteria가 비면 find_row_value_for_time과 동일 동작.
284
+ 찾으면 (DT 값, 매칭한 row) 반환.
285
+ """
286
+ if not all_rows or not target_time:
287
+ return None
288
+ norm = str(target_time).replace("-", "").strip()
289
+
290
+ def _row_matches_criteria(row: dict) -> bool:
291
+ if not criteria:
292
+ return True
293
+ for k, v in criteria.items():
294
+ if str(row.get(k, "")).strip() != str(v).strip():
295
+ return False
296
+ return True
297
+
298
+ # 1차: PRD_DE 완전 일치 + criteria 일치
299
+ for row in all_rows:
300
+ if not isinstance(row, dict):
301
+ continue
302
+ prd = str(row.get("PRD_DE", "") or "").strip()
303
+ if prd != norm:
304
+ continue
305
+ if not _row_matches_criteria(row):
306
+ continue
307
+ v = parse_row_dt(row.get("DT"))
308
+ if v is not None:
309
+ return (v, row)
310
+
311
+ # 2차: 연 단위 fallback (PRD_DE='YYYY')
312
+ year = norm[:4]
313
+ if year and year != norm:
314
+ for row in all_rows:
315
+ if not isinstance(row, dict):
316
+ continue
317
+ prd = str(row.get("PRD_DE", "") or "").strip()
318
+ if prd != year:
319
+ continue
320
+ if not _row_matches_criteria(row):
321
+ continue
322
+ v = parse_row_dt(row.get("DT"))
323
+ if v is not None:
324
+ return (v, row)
325
+ return None
326
+
327
+
328
+ def aggregate_rows_from_fetches(fetch_observations: list) -> list[dict]:
329
+ """여러 fetch observation rows[] 평탄화 (loop._aggregate_rows_from_fetches)."""
330
+ out: list[dict] = []
331
+ if not fetch_observations:
332
+ return out
333
+ seen_ids: set[int] = set()
334
+ for obs in fetch_observations:
335
+ ev = (getattr(obs, "output", None) or {}).get("evidence") or {}
336
+ rs = ev.get("rows") or []
337
+ for r in rs:
338
+ if not isinstance(r, dict):
339
+ continue
340
+ rid = id(r)
341
+ if rid in seen_ids:
342
+ continue
343
+ seen_ids.add(rid)
344
+ out.append(r)
345
+ return out
@@ -0,0 +1,64 @@
1
+ """[리팩] 단위 변환·타입 비교 — verifier에서 분리 (로직 동일)"""
2
+
3
+
4
+ def normalize_value(value: float, kosis_unit: str) -> float:
5
+ """
6
+ KOSIS 단위 → 기본 단위 변환.
7
+ [v3] 천명개월은 실제로 개월 단위 (KOSIS 단위명 오류) → 변환 안 함.
8
+ """
9
+ u = (kosis_unit or "").lower()
10
+ # 천명개월은 KOSIS 단위명 오류 — 실제로는 개월 단위
11
+ if "천명개월" in u:
12
+ return value
13
+ if "천" in u:
14
+ return value * 1_000
15
+ if "백만" in u or "million" in u:
16
+ return value * 1_000_000
17
+ if "억" in u:
18
+ return value * 100_000_000
19
+ return value
20
+
21
+
22
+ def is_same_unit_type(
23
+ claim_unit: str,
24
+ kosis_unit: str,
25
+ all_rows_empty: bool = False,
26
+ ) -> bool:
27
+ """
28
+ 단위 타입이 같은지 확인 (명 ↔ 개월 혼용 방지).
29
+ [v3] 천명개월은 KOSIS 단위명 오류 → 통과.
30
+ [v6.14] 비대칭 처리:
31
+ - claim_unit 비어있으면 → True (claim 측 정보 부족, 책임은 claim에)
32
+ - kosis_unit 비어있으면 → False (KOSIS row 단위 없으면 안전 차단)
33
+ [v6.14 G fix] all_rows_empty: 표 전체 unit 빈 칸이면 지표명-단위 일체형 표로 통과.
34
+ """
35
+ c = (claim_unit or "").lower().strip()
36
+ k = (kosis_unit or "").lower().strip()
37
+
38
+ # [v6.14] claim 측 단위가 없는 경우 → 통과 (claim 책임)
39
+ if not c:
40
+ return True
41
+
42
+ # [v6.14] KOSIS row 단위가 없는 경우
43
+ if not k:
44
+ return all_rows_empty
45
+
46
+ # 천명개월은 KOSIS 단위명 오류 — 비교 자체를 통과
47
+ if "천명개월" in k:
48
+ return True
49
+
50
+ _TYPES = {
51
+ "people": ["명", "인구", "가구", "세대", "person"],
52
+ "time": ["개월", "월", "month", "년", "일", "주"],
53
+ "ratio": ["%", "퍼센트", "percent", "율", "비율"],
54
+ "money": ["원", "won", "달러", "dollar", "usd"],
55
+ }
56
+
57
+ def _get(u: str) -> str:
58
+ for t, kws in _TYPES.items():
59
+ if any(kw in u for kw in kws):
60
+ return t
61
+ return "unknown"
62
+
63
+ ct, kt = _get(c), _get(k)
64
+ return (ct == "unknown" or kt == "unknown") or (ct == kt)