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.
- structverify/__init__.py +83 -0
- structverify/adaptation/__init__.py +0 -0
- structverify/adaptation/adapter_trainer.py +341 -0
- structverify/adaptation/feedback_store.py +31 -0
- structverify/adaptation/kosis_crawler.py +317 -0
- structverify/adaptation/sample_builder.py +149 -0
- structverify/adaptation/synthetic_generator.py +320 -0
- structverify/adaptation/update_embeddings.py +178 -0
- structverify/agent/__init__.py +21 -0
- structverify/agent/builder_agent.py +226 -0
- structverify/agent/conformance_agent.py +171 -0
- structverify/agent/dependency_planner.py +151 -0
- structverify/agent/indexing_agent.py +153 -0
- structverify/agent/indexing_planner.py +169 -0
- structverify/agent/integration_example.py +182 -0
- structverify/agent/loop.py +1165 -0
- structverify/agent/memory.py +207 -0
- structverify/agent/planner.py +817 -0
- structverify/agent/prompts/__init__.py +15 -0
- structverify/agent/prompts/planner_prompts.py +219 -0
- structverify/agent/prompts/reflect_prompts.py +387 -0
- structverify/agent/reflect.py +227 -0
- structverify/agent/runtime_agent.py +1272 -0
- structverify/agent/schemas.py +262 -0
- structverify/agent/source_profiler.py +229 -0
- structverify/agent/tools/__init__.py +64 -0
- structverify/agent/tools/base.py +222 -0
- structverify/agent/tools/calculate.py +244 -0
- structverify/agent/tools/catalog_search.py +859 -0
- structverify/agent/tools/deep_explore.py +293 -0
- structverify/agent/tools/explore_catalog.py +423 -0
- structverify/agent/tools/fetch_evidence.py +922 -0
- structverify/agent/tools/finish.py +423 -0
- structverify/agent/tools/meta_explore.py +267 -0
- structverify/agent/tools/query_rewriter.py +134 -0
- structverify/agent/tools/read_original.py +144 -0
- structverify/agent/tools/replan.py +365 -0
- structverify/agent/workspace.py +958 -0
- structverify/api.py +804 -0
- structverify/config/default.yaml +350 -0
- structverify/core/__init__.py +0 -0
- structverify/core/config_loader.py +30 -0
- structverify/core/pipeline.py +280 -0
- structverify/core/schemas.py +362 -0
- structverify/detection/__init__.py +26 -0
- structverify/detection/_config.py +163 -0
- structverify/detection/_llm.py +24 -0
- structverify/detection/candidate/__init__.py +1 -0
- structverify/detection/candidate/heuristic.py +60 -0
- structverify/detection/candidate/llm.py +51 -0
- structverify/detection/candidate_scorer.py +81 -0
- structverify/detection/claim_detector.py +164 -0
- structverify/detection/claims/__init__.py +1 -0
- structverify/detection/claims/worthiness.py +142 -0
- structverify/detection/domain/__init__.py +1 -0
- structverify/detection/domain/classify.py +84 -0
- structverify/detection/domain/preview.py +36 -0
- structverify/detection/domain/registry.py +99 -0
- structverify/detection/domain_classifier.py +75 -0
- structverify/detection/prompts/__init__.py +1 -0
- structverify/detection/prompts/candidate.py +38 -0
- structverify/detection/prompts/claim_worthiness.py +48 -0
- structverify/detection/prompts/domain.py +41 -0
- structverify/detection/prompts/schema.py +508 -0
- structverify/detection/prompts_loader.py +167 -0
- structverify/detection/schema/__init__.py +1 -0
- structverify/detection/schema/expand.py +83 -0
- structverify/detection/schema/induce.py +441 -0
- structverify/detection/schema/regenerate.py +162 -0
- structverify/detection/schema/temporal_hints.py +130 -0
- structverify/detection/schema/validate.py +193 -0
- structverify/detection/schema_inductor.py +112 -0
- structverify/detection/synthetic_generator.py +270 -0
- structverify/explanation/__init__.py +0 -0
- structverify/explanation/_config.py +18 -0
- structverify/explanation/_llm.py +25 -0
- structverify/explanation/explainer.py +183 -0
- structverify/explanation/fallback.py +29 -0
- structverify/explanation/formatters.py +75 -0
- structverify/explanation/prompts/__init__.py +1 -0
- structverify/explanation/prompts/match.py +27 -0
- structverify/explanation/prompts/mismatch.py +20 -0
- structverify/explanation/prompts/multihop.py +16 -0
- structverify/explanation/prompts/unverifiable.py +17 -0
- structverify/graph/__init__.py +0 -0
- structverify/graph/claim_graph.py +226 -0
- structverify/graph/document_graph.py +487 -0
- structverify/graph/graph_builder.py +238 -0
- structverify/graph/graph_multihop.py +335 -0
- structverify/graph/graph_store.py +281 -0
- structverify/graph/provenance.py +52 -0
- structverify/memory/__init__.py +44 -0
- structverify/memory/agent_memory.py +142 -0
- structverify/memory/embedder.py +69 -0
- structverify/memory/exemplar_store.py +241 -0
- structverify/memory/normalizer.py +91 -0
- structverify/memory/schema.py +119 -0
- structverify/memory/storage/__init__.py +29 -0
- structverify/memory/storage/jsonl_store.py +117 -0
- structverify/memory/working_memory.py +370 -0
- structverify/preprocessing/Dockerfile.scraper +27 -0
- structverify/preprocessing/__init__.py +0 -0
- structverify/preprocessing/extractor.py +574 -0
- structverify/preprocessing/pdf/__init__.py +16 -0
- structverify/preprocessing/pdf/fields.py +95 -0
- structverify/preprocessing/pdf/markdown.py +107 -0
- structverify/preprocessing/pdf/models.py +34 -0
- structverify/preprocessing/pdf/ocr.py +172 -0
- structverify/preprocessing/pdf/pipeline.py +74 -0
- structverify/preprocessing/pdf/reader.py +119 -0
- structverify/preprocessing/pdf/scoring.py +61 -0
- structverify/preprocessing/scraper_sandbox.py +561 -0
- structverify/preprocessing/segmenter.py +48 -0
- structverify/preprocessing/sir_builder.py +240 -0
- structverify/progress.py +591 -0
- structverify/retrieval/__init__.py +0 -0
- structverify/retrieval/base.py +208 -0
- structverify/retrieval/base_connector.py +85 -0
- structverify/retrieval/catalog_ranker.py +300 -0
- structverify/retrieval/catalog_search.py +583 -0
- structverify/retrieval/chunking.py +92 -0
- structverify/retrieval/custom_csv_source.py +386 -0
- structverify/retrieval/custom_db_source.py +396 -0
- structverify/retrieval/custom_docs_source.py +152 -0
- structverify/retrieval/dimension_resolver.py +281 -0
- structverify/retrieval/evidence_subgraph.py +63 -0
- structverify/retrieval/kosis_connector.py +1192 -0
- structverify/retrieval/kosis_relevance.py +142 -0
- structverify/retrieval/kosis_source.py +1541 -0
- structverify/retrieval/query_builder.py +72 -0
- structverify/retrieval/registry.py +133 -0
- structverify/retrieval/relevance_judge.py +141 -0
- structverify/retrieval/row_matcher.py +267 -0
- structverify/storage/__init__.py +0 -0
- structverify/storage/db_manager.py +157 -0
- structverify/storage/dwh_manager.py +92 -0
- structverify/storage/init_db.py +99 -0
- structverify/storage/raw_storage.py +29 -0
- structverify/training/__init__.py +26 -0
- structverify/training/curator.py +124 -0
- structverify/training/dataset.py +134 -0
- structverify/training/doctor.py +99 -0
- structverify/training/evalgate.py +96 -0
- structverify/training/generate.py +101 -0
- structverify/training/loop.py +116 -0
- structverify/training/recipe/train_mlx.py +99 -0
- structverify/training/recipe/train_qlora.py +104 -0
- structverify/training/tasks.py +79 -0
- structverify/utils/__init__.py +0 -0
- structverify/utils/embedding_client.py +248 -0
- structverify/utils/llm_client.py +809 -0
- structverify/utils/logger.py +81 -0
- structverify/verification/__init__.py +0 -0
- structverify/verification/_config.py +45 -0
- structverify/verification/adapters.py +405 -0
- structverify/verification/conformance.py +117 -0
- structverify/verification/decide_verdict.py +216 -0
- structverify/verification/decide_verdict_agent.py +454 -0
- structverify/verification/growth_diff.py +267 -0
- structverify/verification/row_match.py +345 -0
- structverify/verification/units.py +64 -0
- structverify/verification/verdict_thresholds.py +232 -0
- structverify/verification/verifier.py +84 -0
- structverify-0.3.0.dist-info/METADATA +903 -0
- structverify-0.3.0.dist-info/RECORD +168 -0
- structverify-0.3.0.dist-info/WHEEL +5 -0
- structverify-0.3.0.dist-info/licenses/LICENSE +21 -0
- 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))]
|