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,238 @@
1
+ """
2
+ graph/graph_builder.py — Claim/Evidence Graph 조립 (Step 6)
3
+
4
+ 유도된 스키마와 클레임 정보를 바탕으로 그래프 노드/엣지를 생성한다.
5
+
6
+ [참고] 설계 근거 논문:
7
+ - GraphRAG (arXiv 2501.00309) — 텍스트→그래프 구성·검색 패턴
8
+ - AutoSchemaKG (arXiv 2505.23628) — LLM schema → 그래프 노드/엣지 변환
9
+ - Fact Verification on KG (EMNLP Findings 2025) — KG 위 프로그래밍적 사실 검증
10
+ - FEVER (Thorne et al., NAACL 2018) — Claim 단위 검증, VerdictType 3-class 근거
11
+ - HOVER (Jiang et al., EMNLP 2020) — 멀티홉 서브그래프 추론 (Claim 중심 구조)
12
+ - TabFact (Chen et al., ICLR 2020) — 통계표 수치 검증 (KOSIS Evidence 모델)
13
+
14
+ [Step 6 — 담당: 이수민]
15
+ Claim + ClaimSchema
16
+ → 노드 생성:
17
+ - ClaimNode (claim_id, claim_text)
18
+ - MetricNode (indicator명)
19
+ - TimeNode (time_period)
20
+ - EntityNode (population/대상)
21
+ → 엣지 생성:
22
+ - claim → MEASURED_AT → time
23
+ - claim → BELONGS_TO → metric
24
+ - claim → BELONGS_TO → entity
25
+ → GraphNode[], GraphEdge[] 반환 (Neo4j 저장은 별도)
26
+
27
+ [pipeline 수정 - 담당 : 김예슬]
28
+ - build_claim_graph() 시그니처 변경: sir_doc 파라미터 추가
29
+ · sir_doc이 있으면 extract_context_edges() 호출 → NEXT_SENT/IN_BLOCK/IN_DOC 추가
30
+ · GraphRAG 2-hop 탐색의 핵심 — 같은 문단 내 연관 수치 자동 발견
31
+
32
+ - COMPARE 엣지 추가 (핵심 변경):
33
+ · 같은 MetricNode(indicator)를 공유하는 Claim 쌍에 COMPARE 엣지 생성
34
+ · 예: C1("21만7천") ↔ C2("8만4천") → 둘 다 indicator="쉬었음인구"
35
+ · → C3("2.6배") 같은 파생 주장 검증 시 C1+C2를 함께 KOSIS 조회 가능
36
+ · 같은 지표 여러 시점: C7(12.77개월/2024) ↔ C8(10.71개월/2004)
37
+ → "첫취업소요기간이 2.06개월 늘었다"는 주장도 C7+C8 동시 검증
38
+
39
+ - 문맥 엣지를 GraphEdge 객체로 변환하여 반환값에 포함
40
+ · graph_store.py(박재윤)에서 Neo4j MERGE 시 일괄 처리 가능
41
+
42
+ """
43
+ from __future__ import annotations
44
+
45
+ from collections import defaultdict
46
+
47
+ from structverify.core.schemas import (
48
+ Claim, GraphEdge, GraphEdgeType, GraphNode, GraphNodeType, SIRDocument,ClaimType
49
+ )
50
+ from structverify.utils.logger import get_logger
51
+
52
+ logger = get_logger(__name__)
53
+
54
+ # 문맥 엣지 타입 문자열 → GraphEdgeType 매핑
55
+ _CONTEXT_EDGE_MAP = {
56
+ "NEXT_SENT": GraphEdgeType.NEXT_SENT,
57
+ "IN_BLOCK": GraphEdgeType.IN_BLOCK,
58
+ "IN_DOC": GraphEdgeType.IN_DOC,
59
+ }
60
+
61
+
62
+ def build_claim_graph(
63
+ claims: list[Claim],
64
+ sir_doc: SIRDocument | None = None,
65
+ ) -> tuple[list[GraphNode], list[GraphEdge]]:
66
+ """
67
+ Claim 리스트 → Knowledge Graph (노드 + 엣지) 조립.
68
+
69
+ Args:
70
+ claims: Claim 객체 리스트 (schema 포함)
71
+ sir_doc: SIRDocument (있으면 NEXT_SENT/IN_BLOCK/IN_DOC 문맥 엣지 추가)
72
+
73
+ 생성되는 노드/엣지:
74
+ ClaimNode → BELONGS_TO → MetricNode (indicator)
75
+ ClaimNode → MEASURED_AT → TimeNode (time_period)
76
+ ClaimNode → BELONGS_TO → EntityNode (population)
77
+ ClaimNode ↔ COMPARE ↔ ClaimNode (같은 indicator 공유 시)
78
+ SentNode → NEXT_SENT → SentNode (문맥 순서)
79
+ SentNode → IN_BLOCK → BlockNode (문단 소속)
80
+ BlockNode → IN_DOC → DocNode (문서 소속)
81
+ """
82
+ nodes: list[GraphNode] = []
83
+ edges: list[GraphEdge] = []
84
+ seen_node_ids: set[str] = set()
85
+
86
+ def _add_node(node: GraphNode) -> None:
87
+ if node.node_id not in seen_node_ids:
88
+ nodes.append(node)
89
+ seen_node_ids.add(node.node_id)
90
+
91
+ # ── 1) 기본 Claim 노드/엣지 (기존 로직) ───────────────────────────
92
+ # indicator별 claim_id 추적 (COMPARE 엣지 생성용)
93
+ metric_to_claims: dict[str, list[str]] = defaultdict(list)
94
+
95
+ for claim in claims:
96
+ claim_node_id = f"claim:{claim.claim_id.hex[:8]}"
97
+ _add_node(GraphNode(
98
+ node_id=claim_node_id,
99
+ node_type=GraphNodeType.CLAIM,
100
+ label=claim.claim_text[:60],
101
+ properties={
102
+ "claim_text": claim.claim_text,
103
+ "value": claim.schema.value if claim.schema else None,
104
+ "claim_type": claim.claim_type,
105
+ "canonical_type": (
106
+ claim.canonical_type.value
107
+ if isinstance(claim.canonical_type, ClaimType)
108
+ else claim.canonical_type
109
+ ),
110
+ },
111
+ ))
112
+
113
+ if not claim.schema:
114
+ continue
115
+
116
+ schema = claim.schema
117
+
118
+ # MetricNode + BELONGS_TO
119
+ if schema.indicator:
120
+ metric_node_id = f"metric:{schema.indicator}"
121
+ _add_node(GraphNode(
122
+ node_id=metric_node_id,
123
+ node_type=GraphNodeType.METRIC,
124
+ label=schema.indicator,
125
+ properties={"unit": schema.unit},
126
+ ))
127
+ edges.append(GraphEdge(
128
+ from_node=claim_node_id,
129
+ to_node=metric_node_id,
130
+ edge_type=GraphEdgeType.BELONGS_TO,
131
+ ))
132
+ # COMPARE 엣지 대상 추적
133
+ metric_to_claims[schema.indicator].append(claim_node_id)
134
+
135
+ # TimeNode + MEASURED_AT
136
+ if schema.time_period:
137
+ time_node_id = f"time:{schema.time_period}"
138
+ _add_node(GraphNode(
139
+ node_id=time_node_id,
140
+ node_type=GraphNodeType.TIME,
141
+ label=schema.time_period,
142
+ ))
143
+ edges.append(GraphEdge(
144
+ from_node=claim_node_id,
145
+ to_node=time_node_id,
146
+ edge_type=GraphEdgeType.MEASURED_AT,
147
+ ))
148
+
149
+ # EntityNode + BELONGS_TO
150
+ if schema.population:
151
+ entity_node_id = f"entity:{schema.population}"
152
+ _add_node(GraphNode(
153
+ node_id=entity_node_id,
154
+ node_type=GraphNodeType.ENTITY,
155
+ label=schema.population,
156
+ ))
157
+ edges.append(GraphEdge(
158
+ from_node=claim_node_id,
159
+ to_node=entity_node_id,
160
+ edge_type=GraphEdgeType.BELONGS_TO,
161
+ ))
162
+
163
+ # ── 2) COMPARE 엣지 — 같은 indicator 공유 Claim 쌍 ────────────────
164
+ #
165
+ # 왜 필요한가:
166
+ # "쉬었음 청년이 20년 새 2.6배 늘었다" → 이건 C1(21만7천)+C2(8만4천) 비율
167
+ # 두 Claim이 indicator="쉬었음인구"를 공유 → COMPARE 엣지로 연결
168
+ # 검증 시 MetricNode 2-hop으로 C1+C2를 함께 KOSIS 조회 → 비율 계산
169
+ #
170
+ compare_added: set[tuple[str, str]] = set()
171
+ for indicator, claim_ids in metric_to_claims.items():
172
+ if len(claim_ids) < 2:
173
+ continue
174
+ for i in range(len(claim_ids)):
175
+ for j in range(i + 1, len(claim_ids)):
176
+ pair = (claim_ids[i], claim_ids[j])
177
+ if pair not in compare_added:
178
+ edges.append(GraphEdge(
179
+ from_node=claim_ids[i],
180
+ to_node=claim_ids[j],
181
+ edge_type=GraphEdgeType.COMPARE,
182
+ properties={"shared_indicator": indicator},
183
+ ))
184
+ compare_added.add(pair)
185
+
186
+ # ── 3) 문맥 엣지 — NEXT_SENT / IN_BLOCK / IN_DOC ──────────────────
187
+ #
188
+ # sir_doc이 있으면 extract_context_edges()로 문장-문단-문서 관계를 추가.
189
+ # 이 엣지들이 없으면:
190
+ # - 같은 문단에 있는 수치들이 서로 연결되지 않음
191
+ # - "이 기사에서 취업 관련 수치 모두 검증" 쿼리 시
192
+ # Block B3 → IN_BLOCK → C7+C8+C9 탐색 불가
193
+ # - C1 다음 문장이 C2, 그 다음이 C3(2.6배) 관계를 모름
194
+ # → 파생 주장 자동 탐지 불가
195
+ #
196
+ if sir_doc is not None:
197
+ from structverify.preprocessing.sir_builder import extract_context_edges
198
+ context_edges = extract_context_edges(sir_doc)
199
+
200
+ for ce in context_edges:
201
+ edge_type = _CONTEXT_EDGE_MAP.get(ce["edge_type"])
202
+ if edge_type is None:
203
+ continue
204
+ edges.append(GraphEdge(
205
+ from_node=ce["from_node"],
206
+ to_node=ce["to_node"],
207
+ edge_type=edge_type,
208
+ ))
209
+
210
+ # 문맥 엣지에 등장하는 노드(블록/문장)도 등록
211
+ # (Claim이 아닌 SentNode, BlockNode, DocNode)
212
+ context_node_ids = set()
213
+ for ce in context_edges:
214
+ context_node_ids.add(ce["from_node"])
215
+ context_node_ids.add(ce["to_node"])
216
+ for nid in context_node_ids:
217
+ if nid not in seen_node_ids:
218
+ # node_type은 node_id prefix로 구분
219
+ if nid.startswith("node:doc:"):
220
+ ntype = GraphNodeType.SOURCE
221
+ elif nid.startswith("node:b"):
222
+ ntype = GraphNodeType.SOURCE # Block → SOURCE로 임시 매핑
223
+ else:
224
+ ntype = GraphNodeType.ENTITY
225
+ _add_node(GraphNode(
226
+ node_id=nid,
227
+ node_type=ntype,
228
+ label=nid,
229
+ ))
230
+
231
+ logger.info(
232
+ f"Graph 조립: {len(nodes)} nodes, {len(edges)} edges "
233
+ f"(COMPARE: {len(compare_added)}쌍, 문맥엣지: {len(context_edges)}개)"
234
+ )
235
+ else:
236
+ logger.info(f"Graph 조립: {len(nodes)} nodes, {len(edges)} edges (문맥엣지 없음 — sir_doc 미전달)")
237
+
238
+ return nodes, edges
@@ -0,0 +1,335 @@
1
+ """
2
+ graph/graph_multihop.py — Multi-hop GraphRAG 파생 주장 검증 (Step 7.5)
3
+
4
+ [김예슬 - 2026-05-08 / v1]
5
+ KOSIS에서 직접 검증 불가능한 "파생 주장"을 Graph 2-hop 탐색으로 검증.
6
+
7
+ [문제]
8
+ "쉬었음 청년이 20년 새 2.6배 늘었다" 같은 비율/배수 주장은
9
+ KOSIS에 "2.6배"라는 수치가 직접 존재하지 않음 → 항상 unverifiable.
10
+
11
+ [해결 — Multi-hop GraphRAG]
12
+ COMPARE 엣지로 같은 indicator를 공유하는 Claim들을 연결한 뒤,
13
+ 파생 주장(C3: "2.6배")을 그 원천 주장(C1, C2)들의 검증된 수치로 재계산.
14
+
15
+ 탐색 흐름 (2-hop):
16
+ C3("2.6배") -[BELONGS_TO]-> MetricNode("쉬었음인구")
17
+ ^
18
+ | [BELONGS_TO]
19
+ C1(21만7천, 2024), C2(8만4천, 2004)
20
+ → C1, C2가 각각 KOSIS로 MATCH 판정됨
21
+ → 217000 / 84000 = 2.58 ≈ 2.6 → C3도 MATCH
22
+
23
+ [설계 원칙]
24
+ - LLM 미사용 — 비율/배수/차이 계산은 deterministic
25
+ - C1, C2가 먼저 검증(MATCH/MISMATCH)되어 있어야 함 → Step 8 이후 실행
26
+ - 원천 주장이 검증 안 됐으면 multi-hop도 unverifiable
27
+
28
+ [참고] GraphRAG (Edge et al., 2024) — 엔티티 그래프 기반 멀티홉 추론
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import re
33
+
34
+ from structverify.core.schemas import (
35
+ Claim, ClaimType, GraphEdge, VerdictType, VerificationResult,
36
+ )
37
+ from structverify.utils.logger import get_logger
38
+
39
+ logger = get_logger(__name__)
40
+
41
+
42
+ # ── 파생 주장 판별 ──────────────────────────────────────────────────────────
43
+
44
+ # 배수/비율 표현 패턴 — "2.6배", "두 배", "3분의 1" 등
45
+ _RATIO_PATTERNS = [
46
+ re.compile(r"(\d+\.?\d*)\s*배"), # 2.6배
47
+ re.compile(r"(\d+\.?\d*)\s*퍼센트|%"), # 30%
48
+ ]
49
+ # 차이 표현 — "8만→22만", "8만에서 22만으로"
50
+ _DIFF_PATTERNS = [
51
+ re.compile(r"(\d[\d,]*)\s*[만천]?\s*[→~]\s*(\d[\d,]*)"),
52
+ ]
53
+
54
+
55
+ def is_derived_claim(claim: Claim) -> bool:
56
+ """
57
+ 파생 주장(비율/배수/차이)인지 판별.
58
+
59
+ 파생 주장:
60
+ - canonical_type이 SCALE / COMPARISON
61
+ - claim_text에 "배", "배로", "분의" 등 비율 표현
62
+ - schema.value가 비율값으로 보임 (0 < value < 100, unit이 "배"/"%")
63
+ """
64
+ text = claim.claim_text or ""
65
+
66
+ # 1) canonical_type 기반
67
+ if claim.canonical_type in (ClaimType.SCALE, ClaimType.COMPARISON):
68
+ return True
69
+
70
+ # 2) 텍스트 패턴 — "N배"
71
+ if re.search(r"\d+\.?\d*\s*배", text):
72
+ return True
73
+
74
+ # 3) unit이 "배"
75
+ if claim.schema and claim.schema.unit:
76
+ if "배" in claim.schema.unit:
77
+ return True
78
+
79
+ return False
80
+
81
+
82
+ def extract_ratio_value(claim: Claim) -> float | None:
83
+ """
84
+ 파생 주장에서 비율/배수 값을 추출.
85
+ "2.6배 늘었다" → 2.6
86
+ """
87
+ if claim.schema and claim.schema.value is not None:
88
+ v = claim.schema.value
89
+ if 0 < v < 1000: # 비율로 보이는 범위
90
+ return v
91
+
92
+ text = claim.claim_text or ""
93
+ m = re.search(r"(\d+\.?\d*)\s*배", text)
94
+ if m:
95
+ try:
96
+ return float(m.group(1))
97
+ except ValueError:
98
+ pass
99
+ return None
100
+
101
+
102
+ # ── COMPARE 엣지 2-hop 탐색 ─────────────────────────────────────────────────
103
+
104
+ def find_compare_neighbors(
105
+ target_claim: Claim,
106
+ all_claims: list[Claim],
107
+ edges: list[GraphEdge],
108
+ ) -> list[Claim]:
109
+ """
110
+ target_claim과 COMPARE 엣지로 연결된 Claim들을 반환.
111
+
112
+ Graph 2-hop:
113
+ target -[BELONGS_TO]-> MetricNode <-[BELONGS_TO]- neighbor
114
+ (graph_builder가 이미 COMPARE 엣지로 표현해둠)
115
+
116
+ Args:
117
+ target_claim: 파생 주장
118
+ all_claims: 전체 claim 목록
119
+ edges: graph_builder가 만든 엣지 목록
120
+
121
+ Returns:
122
+ 같은 indicator를 공유하는 다른 Claim들
123
+ """
124
+ target_nid = f"claim:{target_claim.claim_id.hex[:8]}"
125
+
126
+ # COMPARE 엣지에서 target과 연결된 노드 id 수집
127
+ neighbor_nids: set[str] = set()
128
+ for edge in edges:
129
+ etype = edge.edge_type
130
+ etype_val = etype.value if hasattr(etype, "value") else etype
131
+ if etype_val != "compare":
132
+ continue
133
+ if edge.from_node == target_nid:
134
+ neighbor_nids.add(edge.to_node)
135
+ elif edge.to_node == target_nid:
136
+ neighbor_nids.add(edge.from_node)
137
+
138
+ if not neighbor_nids:
139
+ return []
140
+
141
+ # node_id → Claim 매핑
142
+ neighbors = []
143
+ for claim in all_claims:
144
+ nid = f"claim:{claim.claim_id.hex[:8]}"
145
+ if nid in neighbor_nids:
146
+ neighbors.append(claim)
147
+
148
+ return neighbors
149
+
150
+
151
+ # ── Multi-hop 검증 ──────────────────────────────────────────────────────────
152
+
153
+ def verify_via_multihop(
154
+ target_claim: Claim,
155
+ all_claims: list[Claim],
156
+ edges: list[GraphEdge],
157
+ results_by_claim: dict[str, VerificationResult],
158
+ config: dict | None = None,
159
+ ) -> VerificationResult | None:
160
+ """
161
+ 파생 주장을 COMPARE 이웃들의 검증된 수치로 재검증.
162
+
163
+ [핵심 로직]
164
+ C3: "2.6배 늘었다" (파생 주장)
165
+ → COMPARE 이웃: C1(217000, 2024), C2(84000, 2004)
166
+ → C1, C2가 모두 검증됨 (official_value 존재)
167
+ → 큰값 / 작은값 = 217000 / 84000 = 2.58
168
+ → claimed 2.6 vs computed 2.58 → 오차 0.8% → MATCH
169
+
170
+ Args:
171
+ target_claim: 검증할 파생 주장
172
+ all_claims: 전체 claim
173
+ edges: graph 엣지
174
+ results_by_claim: {claim_id_hex: VerificationResult} — Step 8 결과
175
+ config: 설정 (tolerance 등)
176
+
177
+ Returns:
178
+ 재검증된 VerificationResult, 또는 multi-hop 불가 시 None
179
+ """
180
+ config = config or {}
181
+ tolerance = config.get("verification", {}).get("multihop_tolerance_pct", 10.0)
182
+
183
+ # 1) 파생 주장인지 확인
184
+ if not is_derived_claim(target_claim):
185
+ return None
186
+
187
+ claimed_ratio = extract_ratio_value(target_claim)
188
+ if claimed_ratio is None:
189
+ logger.debug(f"파생 주장이지만 비율값 추출 실패: {target_claim.claim_text[:40]}")
190
+ return None
191
+
192
+ # 2) COMPARE 이웃 탐색 (2-hop)
193
+ neighbors = find_compare_neighbors(target_claim, all_claims, edges)
194
+ if len(neighbors) < 2:
195
+ logger.debug(
196
+ f"Multi-hop 불가 — COMPARE 이웃 부족 "
197
+ f"({len(neighbors)}개): {target_claim.claim_text[:40]}"
198
+ )
199
+ return None
200
+
201
+ # 3) 이웃들 중 검증된(official_value 있는) 수치 수집
202
+ verified_values: list[tuple[float, str]] = [] # (official_value, time_period)
203
+ for nb in neighbors:
204
+ nb_hex = nb.claim_id.hex
205
+ nb_result = results_by_claim.get(nb_hex)
206
+ if not nb_result or not nb_result.evidence:
207
+ continue
208
+ official = nb_result.evidence.official_value
209
+ if official is None or official == 0:
210
+ continue
211
+ # 원천 주장이 MATCH/MISMATCH(=수치 확인됨)인 것만 사용
212
+ if nb_result.verdict == VerdictType.UNVERIFIABLE:
213
+ continue
214
+ period = ""
215
+ if nb.schema and nb.schema.time_period:
216
+ period = nb.schema.time_period
217
+ verified_values.append((official, period))
218
+
219
+ if len(verified_values) < 2:
220
+ logger.debug(
221
+ f"Multi-hop 불가 — 검증된 이웃 수치 부족 "
222
+ f"({len(verified_values)}개): {target_claim.claim_text[:40]}"
223
+ )
224
+ return None
225
+
226
+ # 4) 비율 계산 — 큰 값 / 작은 값
227
+ values_only = sorted([v for v, _ in verified_values], reverse=True)
228
+ largest = values_only[0]
229
+ smallest = values_only[-1]
230
+
231
+ if smallest == 0:
232
+ return None
233
+
234
+ computed_ratio = largest / smallest
235
+
236
+ # 5) 판정 — claimed vs computed
237
+ diff_pct = abs(claimed_ratio - computed_ratio) / max(computed_ratio, 1e-9) * 100
238
+
239
+ if diff_pct <= tolerance:
240
+ verdict = VerdictType.MATCH
241
+ conf = min(0.9, 1.0 - diff_pct / 100)
242
+ elif diff_pct <= 30:
243
+ verdict = VerdictType.UNVERIFIABLE
244
+ conf = 0.4
245
+ else:
246
+ verdict = VerdictType.MISMATCH
247
+ conf = min(0.85, diff_pct / 100)
248
+
249
+ logger.info(
250
+ f"[Multi-hop] {target_claim.claim_text[:40]} → "
251
+ f"claimed={claimed_ratio:.2f}배 vs computed={computed_ratio:.2f}배 "
252
+ f"({largest:.0f}/{smallest:.0f}) → {verdict.value} (오차 {diff_pct:.1f}%)"
253
+ )
254
+
255
+ # 원천 주장 evidence를 멀티홉 결과에 첨부 (가장 큰 값 쪽)
256
+ source_evidence = None
257
+ for nb in neighbors:
258
+ nb_result = results_by_claim.get(nb.claim_id.hex)
259
+ if nb_result and nb_result.evidence:
260
+ if nb_result.evidence.official_value == largest:
261
+ source_evidence = nb_result.evidence
262
+ break
263
+
264
+ result = VerificationResult(
265
+ claim_id=target_claim.claim_id,
266
+ verdict=verdict,
267
+ confidence=conf,
268
+ evidence=source_evidence,
269
+ )
270
+ # multi-hop 사용 표시 (explainer가 참고)
271
+ result.multihop_used = True
272
+ result.multihop_detail = {
273
+ "claimed_ratio": claimed_ratio,
274
+ "computed_ratio": round(computed_ratio, 3),
275
+ "largest_value": largest,
276
+ "smallest_value": smallest,
277
+ "neighbor_count": len(verified_values),
278
+ }
279
+ return result
280
+
281
+
282
+ def apply_multihop_verification(
283
+ claims: list[Claim],
284
+ results: list[VerificationResult],
285
+ edges: list[GraphEdge],
286
+ config: dict | None = None,
287
+ ) -> list[VerificationResult]:
288
+ """
289
+ Step 8 이후 호출 — UNVERIFIABLE인 파생 주장들을 multi-hop으로 재검증.
290
+
291
+ runtime_agent에서:
292
+ results = [verify_claim(c, ev) for c in claims] # Step 8
293
+ results = apply_multihop_verification(claims, results, edges) # Step 8.5
294
+
295
+ Args:
296
+ claims: 전체 claim
297
+ results: Step 8 검증 결과 (claims와 같은 순서)
298
+ edges: graph 엣지 (COMPARE 포함)
299
+ config: 설정
300
+
301
+ Returns:
302
+ multi-hop 재검증이 반영된 results (UNVERIFIABLE → MATCH/MISMATCH 가능)
303
+ """
304
+ # claim_id_hex → result 매핑
305
+ results_by_claim = {
306
+ str(r.claim_id.hex if hasattr(r.claim_id, "hex") else r.claim_id): r
307
+ for r in results
308
+ }
309
+ # claim 순서 유지용
310
+ result_by_idx = {i: r for i, r in enumerate(results)}
311
+
312
+ multihop_count = 0
313
+ for idx, claim in enumerate(claims):
314
+ current = result_by_idx[idx]
315
+
316
+ # 이미 MATCH/MISMATCH면 multi-hop 불필요
317
+ if current.verdict != VerdictType.UNVERIFIABLE:
318
+ continue
319
+
320
+ # 파생 주장만 시도
321
+ if not is_derived_claim(claim):
322
+ continue
323
+
324
+ mh_result = verify_via_multihop(
325
+ claim, claims, edges, results_by_claim, config
326
+ )
327
+ if mh_result is not None:
328
+ result_by_idx[idx] = mh_result
329
+ results_by_claim[claim.claim_id.hex] = mh_result
330
+ multihop_count += 1
331
+
332
+ if multihop_count:
333
+ logger.info(f"[Multi-hop] {multihop_count}건 재검증 완료")
334
+
335
+ return [result_by_idx[i] for i in range(len(results))]