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,216 @@
1
+ """[리팩] Step 8 판정 메인 진입 — profile별 분기 (fallback 우선)"""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from structverify.core.schemas import (
7
+ Claim,
8
+ Evidence,
9
+ MismatchType,
10
+ VerificationResult,
11
+ VerdictType,
12
+ )
13
+ from structverify.utils.logger import get_logger
14
+
15
+ from ._config import VerificationProfile
16
+ from .adapters import (
17
+ AgentCalculateInput,
18
+ AgentFetchInput,
19
+ NormalizedInput,
20
+ VerdictDecision,
21
+ )
22
+ from .decide_verdict_agent import (
23
+ decide_verdict_agent_calculate,
24
+ decide_verdict_agent_fetch,
25
+ )
26
+ from .growth_diff import verify_growth_or_diff
27
+ from .row_match import extract_numeric_values, find_best_match
28
+ from .units import is_same_unit_type, normalize_value
29
+ from .verdict_thresholds import verdict_from_error
30
+
31
+ logger = get_logger(__name__)
32
+
33
+
34
+ def decide_verdict(
35
+ claim: Claim,
36
+ normalized: NormalizedInput | AgentFetchInput | AgentCalculateInput,
37
+ config: dict | None = None,
38
+ profile: VerificationProfile = "fallback",
39
+ ) -> VerificationResult | VerdictDecision:
40
+ """claim + 정규화된 입력 → VerificationResult(fallback) 또는 VerdictDecision(agent)."""
41
+ config = config or {}
42
+ if profile == "fallback":
43
+ if not isinstance(normalized, NormalizedInput):
44
+ raise TypeError("profile='fallback' requires NormalizedInput")
45
+ return _decide_verdict_fallback(claim, normalized, config)
46
+ if profile == "agent":
47
+ if isinstance(normalized, AgentFetchInput):
48
+ return decide_verdict_agent_fetch(claim, normalized, config)
49
+ if isinstance(normalized, AgentCalculateInput):
50
+ return decide_verdict_agent_calculate(claim, normalized, config)
51
+ raise TypeError("profile='agent' requires AgentFetchInput or AgentCalculateInput")
52
+ raise NotImplementedError(f"profile={profile!r}")
53
+
54
+
55
+ def _decide_verdict_fallback(
56
+ claim: Claim,
57
+ normalized: NormalizedInput,
58
+ config: dict,
59
+ ) -> VerificationResult:
60
+ evidence = normalized.evidence
61
+ claim_year = normalized.claim_year
62
+ claim_year_month = normalized.claim_year_month
63
+
64
+ claimed = claim.schema.value if claim.schema else None
65
+ claim_unit = (claim.schema.unit or "") if claim.schema else ""
66
+
67
+ prev_value = getattr(claim.schema, "prev_value", None) if claim.schema else None
68
+ # [v6.14 C2] 증가율/차이 자동 계산 분기
69
+ if prev_value is not None and prev_value != 0:
70
+ indicator = (claim.schema.indicator or "") if claim.schema else ""
71
+ is_ratio_schema = claim_unit and (
72
+ "%" in claim_unit
73
+ or "퍼센트" in claim_unit
74
+ or "율" in claim_unit
75
+ or "비율" in claim_unit
76
+ )
77
+ is_diff_schema = (
78
+ "차이" in indicator or "증감" in indicator or "변화량" in indicator
79
+ )
80
+ if is_ratio_schema or is_diff_schema:
81
+ calc_result = verify_growth_or_diff(
82
+ claim,
83
+ evidence,
84
+ claim_year,
85
+ claim_year_month,
86
+ prev_value,
87
+ is_ratio_schema,
88
+ config,
89
+ classify_mismatch=_classify_mismatch,
90
+ )
91
+ if calc_result is not None:
92
+ return calc_result
93
+
94
+ raw = evidence.raw_response if isinstance(evidence.raw_response, dict) else {}
95
+ rows = raw.get("row", [])
96
+ if isinstance(rows, list) and rows:
97
+ kosis_values = extract_numeric_values(rows)
98
+ if kosis_values:
99
+ best_match, best_error = find_best_match(
100
+ claimed,
101
+ claim_unit,
102
+ claim_year,
103
+ kosis_values,
104
+ claim_year_month=claim_year_month,
105
+ )
106
+
107
+ if best_match is None:
108
+ return VerificationResult(
109
+ claim_id=claim.claim_id,
110
+ verdict=VerdictType.UNVERIFIABLE,
111
+ confidence=0.3,
112
+ evidence=evidence,
113
+ )
114
+
115
+ evidence = evidence.model_copy(update={
116
+ "official_value": best_match.get("value"),
117
+ "unit": best_match.get("unit") or evidence.unit,
118
+ "time_period": best_match.get("period") or evidence.time_period,
119
+ })
120
+ logger.info(
121
+ f"[verifier] evidence 동기화 (F2): official_value={evidence.official_value} "
122
+ f"unit={evidence.unit!r} time_period={evidence.time_period!r}"
123
+ )
124
+
125
+ return verdict_from_error(
126
+ claim,
127
+ evidence,
128
+ best_error,
129
+ best_match,
130
+ config,
131
+ _classify_mismatch,
132
+ )
133
+
134
+ kosis_unit = evidence.unit or ""
135
+
136
+ if not is_same_unit_type(claim_unit, kosis_unit):
137
+ logger.info(f"단위 타입 불일치: claim={claim_unit!r} kosis={kosis_unit!r}")
138
+ return VerificationResult(
139
+ claim_id=claim.claim_id,
140
+ verdict=VerdictType.UNVERIFIABLE,
141
+ confidence=0.3,
142
+ evidence=evidence,
143
+ )
144
+
145
+ official = normalize_value(evidence.official_value, kosis_unit)
146
+ denom = max(abs(official), abs(claimed), 1e-9)
147
+ diff_pct = abs(claimed - official) / denom * 100
148
+
149
+ return verdict_from_error(
150
+ claim, evidence, diff_pct / 100, None, config, _classify_mismatch,
151
+ )
152
+
153
+
154
+ def _primary_year_from_period(text: str | None) -> str | None:
155
+ if not text or not str(text).strip():
156
+ return None
157
+ m = re.search(r"(?:19|20)\d{2}", str(text))
158
+ return m.group(0) if m else None
159
+
160
+
161
+ def _norm_token(s: str | None) -> str:
162
+ if not s:
163
+ return ""
164
+ return " ".join(str(s).split()).lower()
165
+
166
+
167
+ def _population_incompatible(claim_pop: str | None, ev_pop: str | None) -> bool:
168
+ c = _norm_token(claim_pop)
169
+ e = _norm_token(ev_pop)
170
+ if not c or not e:
171
+ return False
172
+ if c in e or e in c:
173
+ return False
174
+ return True
175
+
176
+
177
+ def _classify_mismatch(
178
+ claim: Claim,
179
+ evidence: Evidence,
180
+ diff_pct: float,
181
+ config: dict,
182
+ ) -> MismatchType:
183
+ """
184
+ MISMATCH 세부 유형 분류 (fallback 프로필, LLM 미사용).
185
+ 우선순위: TIME_PERIOD → POPULATION → EXAGGERATION → VALUE
186
+ """
187
+ vconf = config.get("verification", {}) if config else {}
188
+ exaggeration_pct = float(vconf.get("exaggeration_diff_percent", 20.0))
189
+
190
+ schema = claim.schema
191
+ if schema is None:
192
+ return (
193
+ MismatchType.EXAGGERATION
194
+ if diff_pct > exaggeration_pct
195
+ else MismatchType.VALUE
196
+ )
197
+
198
+ # 시점
199
+ cy = _primary_year_from_period(schema.time_period)
200
+ ey = _primary_year_from_period(evidence.time_period)
201
+ if cy and ey and cy != ey:
202
+ return MismatchType.TIME_PERIOD
203
+
204
+ # 집단
205
+ raw = evidence.raw_response if isinstance(evidence.raw_response, dict) else {}
206
+ ev_pop = raw.get("population") or raw.get("population_label")
207
+ if isinstance(ev_pop, (list, tuple)):
208
+ ev_pop = " ".join(str(x) for x in ev_pop)
209
+ if schema.population and _population_incompatible(schema.population, ev_pop):
210
+ return MismatchType.POPULATION
211
+
212
+ # 과장
213
+ if diff_pct > exaggeration_pct:
214
+ return MismatchType.EXAGGERATION
215
+
216
+ return MismatchType.VALUE
@@ -0,0 +1,454 @@
1
+ """[리팩] agent 프로필 판정 — loop._synthesize_verdict_* 로직 추출 (이동만)"""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import Any
6
+
7
+ from structverify.agent.schemas import ClaimType
8
+ from structverify.core.schemas import Claim, VerdictType
9
+ from structverify.utils.logger import get_logger
10
+
11
+ from .adapters import AgentCalculateInput, AgentFetchInput, VerdictDecision
12
+ from .growth_diff import try_difference_from_rows, try_growth_rate_from_rows
13
+ from .verdict_thresholds import (
14
+ classify_atomic_ratio_agent,
15
+ classify_calculate_simple_agent,
16
+ classify_difference_gap_agent,
17
+ classify_growth_rate_pp_agent,
18
+ detect_threshold_direction,
19
+ growth_rate_direction_mismatch,
20
+ )
21
+
22
+ logger = get_logger(__name__)
23
+
24
+ _COMPLEX_TYPES = {ClaimType.GROWTH_RATE, ClaimType.DIFFERENCE, ClaimType.RANKING}
25
+
26
+ # 한국어 큰 수 단위 배수 (선두 접두어). "만 건"→10⁴·"건", "억달러"→10⁸·"달러".
27
+ _KOR_MULT = {"천": 1e3, "만": 1e4, "억": 1e8, "조": 1e12}
28
+
29
+
30
+ def _kor_unit_scale(unit: str | None) -> tuple[float, str]:
31
+ """단위 문자열 선두의 만/억/조/천 배수와 기본단위를 분리.
32
+
33
+ 스키마 유도가 "228만 건"을 (228, "만 건")처럼 *값은 접두어 앞 숫자, 단위엔 접두어*
34
+ 로 뽑을 때가 있다("150만 명"은 (1500000,"명")으로 뽑기도 함 — LLM 비일관). 어느
35
+ 쪽이든 '값이 단위 표기와 일치'하므로, 비교 직전 단위 배수를 값에 반영하면 일관돼진다.
36
+
37
+ Returns: (배수, 기본단위) 예: "만 건"→(10000.0,"건"), "건"→(1.0,"건").
38
+ """
39
+ u = (unit or "").strip()
40
+ m = re.match(r"^\s*(천|만|억|조)\s*(.*)$", u)
41
+ if m:
42
+ return _KOR_MULT[m.group(1)], m.group(2).strip()
43
+ return 1.0, u
44
+
45
+
46
+ def decide_verdict_agent_fetch(
47
+ claim: Claim,
48
+ normalized: AgentFetchInput,
49
+ config: dict,
50
+ ) -> VerdictDecision:
51
+ """fetch observation 기반 agent 판정 (loop._synthesize_verdict_from_observation).
52
+
53
+ 합성 규칙:
54
+ - fetch 성공 + claim에 값 있음 → 값 비교 (tolerance, 기본 5%)
55
+ - growth_rate/difference/ranking → 두 시점 비교 필요인데 단일 fetch 뿐 → UNVERIFIABLE
56
+ (단, GROWTH_RATE/DIFFERENCE는 rows pool에서 직접 계산 시도)
57
+ - fetch 실패 또는 값 없음 → UNVERIFIABLE
58
+ """
59
+ claim_id = normalized.claim_id
60
+ evidence = normalized.evidence
61
+ tolerance = normalized.tolerance
62
+ claim_actual_type = normalized.claim_actual_type
63
+
64
+ fetched_value = evidence.get("value")
65
+ fetched_unit = evidence.get("unit", "") or ""
66
+ fetched_time = evidence.get("time_period", "") or ""
67
+ stat_table_id = evidence.get("stat_table_id", "") or ""
68
+ stat_name = evidence.get("stat_name", "") or ""
69
+
70
+ # 소스 인식 라벨 (KOSIS 전용 하드코딩 제거 — custom_csv 등도 지원). 모든 branch에서 사용.
71
+ _src = str(evidence.get("source") or "").strip()
72
+ _src_disp = {"kosis": "KOSIS", "custom_csv": "기준 데이터", "custom_db": "기준 데이터"}.get(
73
+ _src, _src or "기준 데이터"
74
+ )
75
+ src_label = _src_disp + (f"({stat_table_id})" if stat_table_id else "")
76
+ if stat_name:
77
+ src_label += f" {stat_name}"
78
+
79
+ schema = claim.schema
80
+ claim_value = schema.value if schema is not None else None
81
+ claim_unit = (schema.unit or "") if schema is not None else ""
82
+ claim_time = (schema.time_period or "") if schema is not None else ""
83
+ claim_indicator = (schema.indicator or "") if schema is not None else ""
84
+
85
+ # 복합 claim type: 두 시점 비교 필요인데 plan은 단일 fetch
86
+ # ★ plan.claim_type은 Planner LLM이 source_text 의미로 일괄 분류해서 부정확함
87
+ # → claim.schema에서 직접 추론한 type을 더 신뢰
88
+ if isinstance(claim_actual_type, ClaimType) and claim_actual_type in _COMPLEX_TYPES:
89
+ # ── [v6.17] GROWTH_RATE 직접 계산 시도 ──────────────────────────
90
+ if claim_actual_type == ClaimType.GROWTH_RATE:
91
+ result = _try_growth_rate_verdict(
92
+ claim, claim_id, evidence, schema, claim_value, claim_indicator,
93
+ stat_table_id, normalized.all_fetch_observations, config,
94
+ )
95
+ if result is not None:
96
+ return result
97
+
98
+ if claim_actual_type == ClaimType.DIFFERENCE:
99
+ result = _try_difference_verdict(
100
+ claim, claim_id, evidence, schema, claim_value,
101
+ stat_table_id, normalized.all_fetch_observations, config,
102
+ )
103
+ if result is not None:
104
+ return result
105
+
106
+ logger.info(
107
+ f"[loop] {claim_id}: claim type={claim_actual_type.value} "
108
+ f"(planner type={normalized.plan_claim_type.value}) — 단일 fetch로 검증 불가"
109
+ )
110
+ return VerdictDecision(
111
+ claim_id=claim_id,
112
+ verdict=VerdictType.UNVERIFIABLE,
113
+ confidence=0.3,
114
+ explanation=(
115
+ f"{claim_actual_type.value} 유형은 두 시점 비교 필요. "
116
+ f"{src_label} 현재값 {fetched_value!r}{fetched_unit} "
117
+ f"(시점 {fetched_time}) 확보. "
118
+ f"이전 시점 데이터 부재로 검증 불가."
119
+ ),
120
+ )
121
+
122
+ if fetched_value is None or claim_value is None:
123
+ return VerdictDecision(
124
+ claim_id=claim_id,
125
+ verdict=VerdictType.UNVERIFIABLE,
126
+ confidence=0.3,
127
+ explanation=(
128
+ f"비교 불가: 주장값={claim_value!r}{claim_unit}, "
129
+ f"{src_label} 조회값={fetched_value!r}{fetched_unit}."
130
+ ),
131
+ )
132
+
133
+ try:
134
+ fv = float(fetched_value)
135
+ cv = float(claim_value)
136
+ except (TypeError, ValueError):
137
+ return VerdictDecision(
138
+ claim_id=claim_id,
139
+ verdict=VerdictType.UNVERIFIABLE,
140
+ confidence=0.3,
141
+ explanation=(
142
+ f"값 숫자 변환 실패 — 주장값={claim_value!r}, 조회값={fetched_value!r}."
143
+ ),
144
+ )
145
+
146
+ # 한국어 큰 수 단위(만/억/조) 스케일 정규화 — "228만 건" vs "건" 같은 단위 스케일
147
+ # 불일치 구제. 단, *스케일링이 두 값을 더 가깝게 만들 때만* 적용한다.
148
+ # - 오히려 멀어지면: 단위 표기가 어긋났거나(예: 스키마가 "억"을 떨궈 잘못된 단위)
149
+ # 값이 이미 완전 환산된 것(이중스케일) → 건드리면 오판. 그대로 둔다.
150
+ # 이 가드로 [만 건 vs 건] 구제와 [잘못된 단위·이중스케일] 회귀 방지를 동시에.
151
+ _cs, _cbase = _kor_unit_scale(claim_unit)
152
+ _fs, _fbase = _kor_unit_scale(fetched_unit)
153
+ if (_cs != 1.0 or _fs != 1.0) and (_cbase == _fbase or not _cbase or not _fbase):
154
+ def _dr(a: float, b: float) -> float:
155
+ return abs(b - a) / abs(a) if abs(a) > 1e-9 else (0.0 if abs(b) < 1e-9 else 1.0)
156
+ if _dr(cv * _cs, fv * _fs) < _dr(cv, fv):
157
+ _cv0, _fv0 = cv, fv
158
+ cv, fv = cv * _cs, fv * _fs
159
+ claim_unit, fetched_unit = (_cbase or claim_unit), (_fbase or fetched_unit)
160
+ logger.info(
161
+ f"[loop] {claim_id}: 단위 스케일 정규화(값이 가까워짐) "
162
+ f"주장 {_cv0:.4g}×{_cs:g}={cv:.4g}{claim_unit}, "
163
+ f"조회 {_fv0:.4g}×{_fs:g}={fv:.4g}{fetched_unit}"
164
+ )
165
+
166
+ if abs(cv) < 1e-9:
167
+ diff_ratio = 0.0 if abs(fv) < 1e-9 else 1.0
168
+ else:
169
+ diff_ratio = abs(fv - cv) / abs(cv)
170
+
171
+ time_aligned = True
172
+ if claim_time and fetched_time:
173
+ ct_norm = str(claim_time).replace("-", "").replace(".", "")
174
+ ft_norm = str(fetched_time).replace("-", "").replace(".", "")
175
+ time_aligned = (ct_norm in ft_norm) or (ft_norm in ct_norm)
176
+
177
+ # [규정/예산] evidence에 규칙 operator가 실려오면 *규칙 기준 준수* 판정.
178
+ # fv=기준값(한도/최소), cv=신청/주장값. 예: operator '<=' → 신청 cv ≤ 한도 fv 이면 승인(MATCH).
179
+ # (detect_threshold_direction은 '주장 자체가 부등식'인 경우 — 여긴 규칙이 부등식이라 별도)
180
+ _rule_op = str(evidence.get("operator") or "").strip()
181
+ if _rule_op and time_aligned:
182
+ import operator as _opmod
183
+ _OPS = {"<=": _opmod.le, "≤": _opmod.le, "<": _opmod.lt,
184
+ ">=": _opmod.ge, "≥": _opmod.ge, ">": _opmod.gt,
185
+ "==": _opmod.eq, "=": _opmod.eq}
186
+ _fn = _OPS.get(_rule_op)
187
+ if _fn is not None:
188
+ _rel = {"<=": "이하", "≤": "이하", "<": "미만", ">=": "이상",
189
+ "≥": "이상", ">": "초과", "==": "일치", "=": "일치"}[_rule_op]
190
+ # 단위 스케일 정규화 — 신청/기준 단위가 둘 다 통화 단위면 '원' 기준으로 맞춤.
191
+ # (스키마 유도가 "6000만원"→6000/만원, 규칙은 50000000/원 처럼 스케일이 달라 오판 방지)
192
+ _WON = {"원": 1, "천원": 1000, "만원": 10000, "십만원": 100000,
193
+ "백만원": 1_000_000, "천만원": 10_000_000, "억원": 100_000_000}
194
+ _cu, _fu = _WON.get((claim_unit or "").strip()), _WON.get((fetched_unit or "").strip())
195
+ _cvn, _fvn = (cv * _cu, fv * _fu) if (_cu and _fu) else (cv, fv)
196
+ _ok = _fn(_cvn, _fvn)
197
+ logger.info(
198
+ f"[loop] {claim_id}: 규칙 operator 판정 신청={cv:.4g} {_rule_op} "
199
+ f"기준={fv:.4g} → {'승인' if _ok else '반려'}"
200
+ )
201
+ return VerdictDecision(
202
+ claim_id=claim_id,
203
+ verdict=VerdictType.MATCH if _ok else VerdictType.MISMATCH,
204
+ confidence=0.9,
205
+ explanation=(
206
+ f"신청/주장값 {cv:.4g}{claim_unit}이(가) 기준 {fv:.4g}{fetched_unit} "
207
+ f"{_rel} 규칙을 {'충족 → 승인' if _ok else '위반 → 반려'} "
208
+ f"({src_label}, 시점 {fetched_time or claim_time})."
209
+ ),
210
+ )
211
+
212
+ # [v6.20] 부등식 주장 — threshold 방향 감지 후 실측값 vs 기준값 비교
213
+ thr_dir = detect_threshold_direction(claim)
214
+ if thr_dir is not None and time_aligned:
215
+ if thr_dir == "gte":
216
+ satisfied = fv >= cv
217
+ rel_txt = "이상"
218
+ else:
219
+ satisfied = fv <= cv
220
+ rel_txt = "이하"
221
+ logger.info(
222
+ f"[loop] {claim_id}: threshold 판정 dir={thr_dir} "
223
+ f"기준값={cv:.4g} 실측={fv:.4g} → {'충족' if satisfied else '미충족'}"
224
+ )
225
+ if satisfied:
226
+ return VerdictDecision(
227
+ claim_id=claim_id,
228
+ verdict=VerdictType.MATCH,
229
+ confidence=0.8,
230
+ explanation=(
231
+ f"주장은 '{cv:.4g}{claim_unit} {rel_txt}'(부등식)이고, "
232
+ f"{src_label} 조회값은 {fv:.4g}{fetched_unit} "
233
+ f"(시점 {fetched_time or claim_time})이므로 주장이 성립합니다."
234
+ ),
235
+ )
236
+ return VerdictDecision(
237
+ claim_id=claim_id,
238
+ verdict=VerdictType.MISMATCH,
239
+ confidence=0.7,
240
+ explanation=(
241
+ f"주장은 '{cv:.4g}{claim_unit} {rel_txt}'(부등식)이지만, "
242
+ f"{src_label} 조회값은 {fv:.4g}{fetched_unit} "
243
+ f"(시점 {fetched_time or claim_time})이므로 주장이 성립하지 않습니다."
244
+ ),
245
+ )
246
+
247
+ if diff_ratio < tolerance and time_aligned:
248
+ return VerdictDecision(
249
+ claim_id=claim_id,
250
+ verdict=VerdictType.MATCH,
251
+ confidence=0.85,
252
+ explanation=(
253
+ f"주장값 {cv:.4g}{claim_unit}과 {src_label} 조회값 "
254
+ f"{fv:.4g}{fetched_unit}이 일치 (오차 {diff_ratio*100:.2f}%, "
255
+ f"시점 주장={claim_time or '?'}, 조회={fetched_time or '?'})."
256
+ ),
257
+ )
258
+
259
+ if not time_aligned:
260
+ return VerdictDecision(
261
+ claim_id=claim_id,
262
+ verdict=VerdictType.UNVERIFIABLE,
263
+ confidence=0.35,
264
+ explanation=(
265
+ f"시점 불일치 — 주장은 {claim_time}, {src_label} 조회는 {fetched_time}. "
266
+ f"동일 시점 데이터 미확보로 검증 불가 "
267
+ f"(조회값 {fv:.4g}{fetched_unit}, 주장값 {cv:.4g}{claim_unit})."
268
+ ),
269
+ )
270
+
271
+ verdict_t, conf = classify_atomic_ratio_agent(diff_ratio, config)
272
+ return VerdictDecision(
273
+ claim_id=claim_id,
274
+ verdict=verdict_t,
275
+ confidence=conf,
276
+ explanation=(
277
+ f"주장값 {cv:.4g}{claim_unit}과 {src_label} 조회값 "
278
+ f"{fv:.4g}{fetched_unit}이 {diff_ratio*100:.1f}% 차이 "
279
+ f"(시점 {fetched_time or claim_time})."
280
+ ),
281
+ )
282
+
283
+
284
+ def decide_verdict_agent_calculate(
285
+ claim: Claim,
286
+ normalized: AgentCalculateInput,
287
+ config: dict,
288
+ ) -> VerdictDecision:
289
+ """calculate observation 기반 agent 판정 (loop._synthesize_verdict_from_calculate).
290
+
291
+ LLM이 prev/current를 계산했지만 finish를 안 부르고 다시 같은 액션 반복
292
+ → 중복차단 → 강제 unverifiable로 죽는 케이스 회복. calculate output의
293
+ result 값을 claim.schema.value와 비교해 자동 verdict 생성한다.
294
+ """
295
+ claim_id = normalized.claim_id
296
+ calc_value = normalized.calc_value
297
+ claim_actual_type = normalized.claim_actual_type
298
+ calc_summary = normalized.calc_summary
299
+
300
+ schema = claim.schema
301
+ claim_value = schema.value if schema is not None else None
302
+ claim_unit = (schema.unit or "") if schema is not None else ""
303
+ cv = float(claim_value)
304
+
305
+ if isinstance(claim_actual_type, ClaimType) and claim_actual_type == ClaimType.GROWTH_RATE:
306
+ diff = abs(abs(calc_value) - abs(cv))
307
+ verdict_t, conf = classify_growth_rate_pp_agent(diff, config)
308
+ label = "일치" if verdict_t == VerdictType.MATCH else (
309
+ "오차 큼" if verdict_t == VerdictType.UNVERIFIABLE else "불일치"
310
+ )
311
+ diff_desc = f"차이 {diff:.2f}%p"
312
+ elif isinstance(claim_actual_type, ClaimType) and claim_actual_type == ClaimType.DIFFERENCE:
313
+ gap = abs(abs(calc_value) - abs(cv))
314
+ verdict_t, conf = classify_difference_gap_agent(gap, cv, config)
315
+ tol = max(abs(cv) * 0.10, 0.02)
316
+ label = "일치" if verdict_t == VerdictType.MATCH else (
317
+ "오차 큼" if verdict_t == VerdictType.UNVERIFIABLE else "불일치"
318
+ )
319
+ diff_desc = f"차이 {gap:.4f}, 허용 {tol:.4f}"
320
+ else:
321
+ if abs(cv) < 1e-9:
322
+ diff_ratio = 0.0 if abs(calc_value) < 1e-9 else 1.0
323
+ else:
324
+ diff_ratio = abs(calc_value - cv) / abs(cv)
325
+ verdict_t, conf = classify_calculate_simple_agent(diff_ratio, config)
326
+ label = "일치" if verdict_t == VerdictType.MATCH else "불일치"
327
+ diff_desc = f"오차 {diff_ratio*100:.2f}%"
328
+
329
+ logger.info(
330
+ f"[loop] {claim_id}: calculate 합성 판정={label} "
331
+ f"(기사 {cv}{claim_unit} vs 계산 {calc_value:.4g}, {diff_desc})"
332
+ )
333
+ return VerdictDecision(
334
+ claim_id=claim_id,
335
+ verdict=verdict_t,
336
+ confidence=conf,
337
+ explanation=(
338
+ f"Agent가 직접 계산한 결과로 검증: 기사 주장 {cv}{claim_unit}, "
339
+ f"산출된 값 {calc_value:.4g} ({diff_desc}). "
340
+ f"계산식: {calc_summary[:200]}"
341
+ ),
342
+ )
343
+
344
+
345
+ def _try_growth_rate_verdict(
346
+ claim: Claim,
347
+ claim_id: str,
348
+ evidence: dict,
349
+ schema: Any,
350
+ claim_value: Any,
351
+ claim_indicator: str,
352
+ stat_table_id: str,
353
+ all_fetch_observations: list | None,
354
+ config: dict,
355
+ ) -> VerdictDecision | None:
356
+ calc = try_growth_rate_from_rows(
357
+ evidence, schema, claim_id, all_fetch_observations=all_fetch_observations,
358
+ )
359
+ if calc is None or claim_value is None:
360
+ return None
361
+ calc_rate, _cur_v, _prev_v, calc_desc = calc
362
+ try:
363
+ claimed_rate = float(claim_value)
364
+ except (TypeError, ValueError):
365
+ return None
366
+
367
+ if growth_rate_direction_mismatch(claim_indicator, calc_rate):
368
+ # [패치 J] 증가/감소 방향 불일치 — 부호 가드
369
+ diff = abs(abs(calc_rate) - abs(claimed_rate))
370
+ logger.warning(
371
+ f"[loop] {claim_id}: growth_rate 부호 방향 불일치 "
372
+ f"(indicator={claim_indicator!r}, 기사 {claimed_rate:+.2f}% 방향, "
373
+ f"계산 {calc_rate:+.2f}% 반대 방향) → MISMATCH 강제"
374
+ )
375
+ return VerdictDecision(
376
+ claim_id=claim_id,
377
+ verdict=VerdictType.MISMATCH,
378
+ confidence=0.75,
379
+ explanation=(
380
+ f"증가율 방향 불일치: 기사는 '{claim_indicator}' "
381
+ f"{claimed_rate}% (양의 방향), "
382
+ f"KOSIS({stat_table_id}) 표 계산값 "
383
+ f"{calc_rate:.2f}% ({'감소' if calc_rate < 0 else '증가'} 방향). "
384
+ f"{calc_desc}"
385
+ ),
386
+ )
387
+
388
+ diff = abs(abs(calc_rate) - abs(claimed_rate))
389
+ verdict_t, conf = classify_growth_rate_pp_agent(diff, config)
390
+ v_label = {
391
+ VerdictType.MATCH: "일치",
392
+ VerdictType.UNVERIFIABLE: "오차 큼",
393
+ VerdictType.MISMATCH: "불일치",
394
+ }[verdict_t]
395
+ logger.info(
396
+ f"[loop] {claim_id}: growth_rate 직접계산 판정={v_label} "
397
+ f"(기사 {claimed_rate}% vs 계산 {calc_rate:.2f}%, 차이 {diff:.2f}%p)"
398
+ )
399
+ return VerdictDecision(
400
+ claim_id=claim_id,
401
+ verdict=verdict_t,
402
+ confidence=conf,
403
+ explanation=(
404
+ f"증가율 직접 검증: 기사 주장 {claimed_rate}%, "
405
+ f"KOSIS({stat_table_id}) 표에서 계산한 값 "
406
+ f"{calc_rate:.2f}% (차이 {diff:.2f}%p). {calc_desc}"
407
+ ),
408
+ )
409
+
410
+
411
+ def _try_difference_verdict(
412
+ claim: Claim,
413
+ claim_id: str,
414
+ evidence: dict,
415
+ schema: Any,
416
+ claim_value: Any,
417
+ stat_table_id: str,
418
+ all_fetch_observations: list | None,
419
+ config: dict,
420
+ ) -> VerdictDecision | None:
421
+ calc = try_difference_from_rows(
422
+ evidence, schema, claim_id, all_fetch_observations=all_fetch_observations,
423
+ )
424
+ if calc is None or claim_value is None:
425
+ return None
426
+ calc_diff, _cur_v, _prev_v, calc_desc = calc
427
+ try:
428
+ claimed_diff = float(claim_value)
429
+ except (TypeError, ValueError):
430
+ return None
431
+
432
+ gap = abs(abs(calc_diff) - abs(claimed_diff))
433
+ verdict_t, conf = classify_difference_gap_agent(gap, claimed_diff, config)
434
+ tol = max(abs(claimed_diff) * 0.10, 0.02)
435
+ v_label = {
436
+ VerdictType.MATCH: "일치",
437
+ VerdictType.UNVERIFIABLE: "오차 큼",
438
+ VerdictType.MISMATCH: "불일치",
439
+ }[verdict_t]
440
+ logger.info(
441
+ f"[loop] {claim_id}: difference 직접계산 판정={v_label} "
442
+ f"(기사 {claimed_diff} vs 계산 {calc_diff:.4f}, "
443
+ f"차이 {gap:.4f}, 허용 {tol:.4f})"
444
+ )
445
+ return VerdictDecision(
446
+ claim_id=claim_id,
447
+ verdict=verdict_t,
448
+ confidence=conf,
449
+ explanation=(
450
+ f"차이값 직접 검증: 기사 주장 {claimed_diff}, "
451
+ f"KOSIS({stat_table_id}) 표에서 계산한 값 "
452
+ f"{calc_diff:.4f} (차이 {gap:.4f}). {calc_desc}"
453
+ ),
454
+ )