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,270 @@
|
|
|
1
|
+
"""
|
|
2
|
+
adaptation/synthetic_generator.py — 합성 학습 데이터 자동 생성
|
|
3
|
+
|
|
4
|
+
변경 요약
|
|
5
|
+
- positive claim 생성뿐 아니라
|
|
6
|
+
candidate detection용 negative / weak negative 샘플도 함께 생성
|
|
7
|
+
- sentence_to_candidate 태스크 학습이 가능하도록 출력 구조 확장
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from structverify.utils.llm_client import LLMClient
|
|
16
|
+
from structverify.utils.logger import get_logger
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
CLAIM_GENERATION_PROMPT = """당신은 한국 뉴스 기자입니다.
|
|
21
|
+
아래 공식 통계표 정보를 보고, 이 통계표로 검증할 수 있는 뉴스 주장 {n}개를 생성하세요.
|
|
22
|
+
|
|
23
|
+
통계표 ID: {stat_id}
|
|
24
|
+
통계표명: {stat_name}
|
|
25
|
+
발행기관: {org_name}
|
|
26
|
+
분류: {category_path}
|
|
27
|
+
관련 키워드: {keywords}
|
|
28
|
+
|
|
29
|
+
규칙:
|
|
30
|
+
- 실제 뉴스에 나올법한 자연스러운 한국어 문장으로 작성
|
|
31
|
+
- 반드시 구체적인 수치(%, 만명, 억원, ha 등)를 포함
|
|
32
|
+
- 검증 가능한 사실 주장만 작성
|
|
33
|
+
|
|
34
|
+
JSON 배열로 답하세요:
|
|
35
|
+
[
|
|
36
|
+
{{
|
|
37
|
+
"claim": "뉴스에 나올법한 주장 문장",
|
|
38
|
+
"indicator": "핵심 지표명",
|
|
39
|
+
"claim_type": "increase|decrease|scale|comparison",
|
|
40
|
+
"expected_unit": "%, 만명, ha 등"
|
|
41
|
+
}}
|
|
42
|
+
]
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
NEGATIVE_CANDIDATE_PROMPT = """당신은 한국 뉴스 기자입니다.
|
|
46
|
+
아래 통계표와 관련된 기사 문맥에서, "숫자가 있거나 기사 문장처럼 보이지만 공식 통계 검증 후보로는 부적절한 문장" {n}개를 생성하세요.
|
|
47
|
+
|
|
48
|
+
통계표 ID: {stat_id}
|
|
49
|
+
통계표명: {stat_name}
|
|
50
|
+
발행기관: {org_name}
|
|
51
|
+
분류: {category_path}
|
|
52
|
+
관련 키워드: {keywords}
|
|
53
|
+
|
|
54
|
+
규칙:
|
|
55
|
+
- 뉴스 기사 문장처럼 자연스러운 한국어
|
|
56
|
+
- 단순 일정 소개, 행사 설명, 발언 소개, 맥락 설명, 애매한 표현 등을 사용
|
|
57
|
+
- 공식 통계와 바로 매핑하기 어렵게 작성
|
|
58
|
+
- 일부 문장은 숫자를 포함해도 됨
|
|
59
|
+
|
|
60
|
+
JSON 배열로 답하세요:
|
|
61
|
+
[
|
|
62
|
+
{{
|
|
63
|
+
"claim": "검증 후보로는 부적절한 문장"
|
|
64
|
+
}}
|
|
65
|
+
]
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
SCHEMA_GENERATION_PROMPT = """아래 뉴스 주장에서 검증에 필요한 핵심 정보를 추출하세요.
|
|
69
|
+
|
|
70
|
+
주장: "{claim}"
|
|
71
|
+
관련 통계표: {stat_name} ({stat_id})
|
|
72
|
+
|
|
73
|
+
JSON으로 답하세요:
|
|
74
|
+
{{
|
|
75
|
+
"indicator": "측정 지표",
|
|
76
|
+
"time_period": "기준 시점",
|
|
77
|
+
"unit": "단위",
|
|
78
|
+
"population": "대상 범위",
|
|
79
|
+
"value": 수치 또는 null,
|
|
80
|
+
"stat_id": "{stat_id}"
|
|
81
|
+
}}
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def generate_synthetic_pairs(
|
|
86
|
+
catalog: list[dict[str, Any]],
|
|
87
|
+
llm: LLMClient,
|
|
88
|
+
claims_per_table: int = 3,
|
|
89
|
+
max_tables: int | None = None,
|
|
90
|
+
) -> list[dict[str, Any]]:
|
|
91
|
+
tables = catalog[:max_tables] if max_tables else catalog
|
|
92
|
+
logger.info(f"합성 데이터 생성 시작: {len(tables)}개 통계표")
|
|
93
|
+
|
|
94
|
+
all_pairs: list[dict[str, Any]] = []
|
|
95
|
+
|
|
96
|
+
for idx, table in enumerate(tables):
|
|
97
|
+
try:
|
|
98
|
+
positive_claims = await _generate_claims(llm, table, claims_per_table)
|
|
99
|
+
negative_claims = await _generate_negative_candidates(llm, table, max(1, claims_per_table // 2))
|
|
100
|
+
|
|
101
|
+
# positive candidate
|
|
102
|
+
for claim_data in positive_claims:
|
|
103
|
+
claim_text = claim_data.get("claim", "").strip()
|
|
104
|
+
if not claim_text:
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
schema = await _generate_schema(llm, claim_data, table)
|
|
108
|
+
all_pairs.append({
|
|
109
|
+
"task": "sentence_to_candidate",
|
|
110
|
+
"claim": claim_text,
|
|
111
|
+
"stat_id": table.get("stat_id", ""),
|
|
112
|
+
"stat_name": table.get("stat_name", ""),
|
|
113
|
+
"indicator": claim_data.get("indicator", ""),
|
|
114
|
+
"claim_type": claim_data.get("claim_type", ""),
|
|
115
|
+
"schema": schema,
|
|
116
|
+
"candidate_label": True,
|
|
117
|
+
"candidate_score": 0.95,
|
|
118
|
+
"candidate_signals": {
|
|
119
|
+
"source": "synthetic_positive",
|
|
120
|
+
"verifiable_by_stat": True,
|
|
121
|
+
},
|
|
122
|
+
"source_table": table,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
# negative candidate
|
|
126
|
+
for claim_data in negative_claims:
|
|
127
|
+
claim_text = claim_data.get("claim", "").strip()
|
|
128
|
+
if not claim_text:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
all_pairs.append({
|
|
132
|
+
"task": "sentence_to_candidate",
|
|
133
|
+
"claim": claim_text,
|
|
134
|
+
"stat_id": "",
|
|
135
|
+
"stat_name": "",
|
|
136
|
+
"indicator": "",
|
|
137
|
+
"claim_type": "",
|
|
138
|
+
"schema": {},
|
|
139
|
+
"candidate_label": False,
|
|
140
|
+
"candidate_score": 0.05,
|
|
141
|
+
"candidate_signals": {
|
|
142
|
+
"source": "synthetic_negative",
|
|
143
|
+
"verifiable_by_stat": False,
|
|
144
|
+
},
|
|
145
|
+
"source_table": table,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
if (idx + 1) % 50 == 0:
|
|
149
|
+
logger.info(f"진행: {idx + 1}/{len(tables)}")
|
|
150
|
+
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.warning(f"통계표 {table.get('stat_id')} 처리 실패: {e}")
|
|
153
|
+
|
|
154
|
+
return _filter_quality(all_pairs)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _generate_claims(
|
|
158
|
+
llm: LLMClient,
|
|
159
|
+
table: dict[str, Any],
|
|
160
|
+
n: int,
|
|
161
|
+
) -> list[dict[str, Any]]:
|
|
162
|
+
prompt = CLAIM_GENERATION_PROMPT.format(
|
|
163
|
+
n=n,
|
|
164
|
+
stat_id=table.get("stat_id", ""),
|
|
165
|
+
stat_name=table.get("stat_name", ""),
|
|
166
|
+
org_name=table.get("org_name", ""),
|
|
167
|
+
category_path=table.get("category_path", ""),
|
|
168
|
+
keywords=", ".join(table.get("keywords", [])),
|
|
169
|
+
)
|
|
170
|
+
result = await llm.generate_json(
|
|
171
|
+
prompt=prompt,
|
|
172
|
+
system_prompt="한국 뉴스 기자. JSON 배열로만 답하세요.",
|
|
173
|
+
)
|
|
174
|
+
if isinstance(result, list):
|
|
175
|
+
return result
|
|
176
|
+
if isinstance(result, dict):
|
|
177
|
+
return [result]
|
|
178
|
+
return []
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def _generate_negative_candidates(
|
|
182
|
+
llm: LLMClient,
|
|
183
|
+
table: dict[str, Any],
|
|
184
|
+
n: int,
|
|
185
|
+
) -> list[dict[str, Any]]:
|
|
186
|
+
prompt = NEGATIVE_CANDIDATE_PROMPT.format(
|
|
187
|
+
n=n,
|
|
188
|
+
stat_id=table.get("stat_id", ""),
|
|
189
|
+
stat_name=table.get("stat_name", ""),
|
|
190
|
+
org_name=table.get("org_name", ""),
|
|
191
|
+
category_path=table.get("category_path", ""),
|
|
192
|
+
keywords=", ".join(table.get("keywords", [])),
|
|
193
|
+
)
|
|
194
|
+
result = await llm.generate_json(
|
|
195
|
+
prompt=prompt,
|
|
196
|
+
system_prompt="한국 뉴스 기자. JSON 배열로만 답하세요.",
|
|
197
|
+
)
|
|
198
|
+
if isinstance(result, list):
|
|
199
|
+
return result
|
|
200
|
+
if isinstance(result, dict):
|
|
201
|
+
return [result]
|
|
202
|
+
return []
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def _generate_schema(
|
|
206
|
+
llm: LLMClient,
|
|
207
|
+
claim_data: dict[str, Any],
|
|
208
|
+
table: dict[str, Any],
|
|
209
|
+
) -> dict[str, Any]:
|
|
210
|
+
prompt = SCHEMA_GENERATION_PROMPT.format(
|
|
211
|
+
claim=claim_data.get("claim", ""),
|
|
212
|
+
stat_name=table.get("stat_name", ""),
|
|
213
|
+
stat_id=table.get("stat_id", ""),
|
|
214
|
+
)
|
|
215
|
+
try:
|
|
216
|
+
return await llm.generate_json(
|
|
217
|
+
prompt=prompt,
|
|
218
|
+
system_prompt="통계 분석 전문가. JSON으로만 답하세요.",
|
|
219
|
+
)
|
|
220
|
+
except Exception:
|
|
221
|
+
return {}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _filter_quality(pairs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
225
|
+
"""
|
|
226
|
+
품질 필터링
|
|
227
|
+
|
|
228
|
+
positive는 수치가 없어도 schema가 있으면 통과 가능
|
|
229
|
+
negative는 너무 짧은 문장만 제거
|
|
230
|
+
"""
|
|
231
|
+
filtered: list[dict[str, Any]] = []
|
|
232
|
+
seen_claims: set[str] = set()
|
|
233
|
+
numeric_pattern = re.compile(r"\d")
|
|
234
|
+
|
|
235
|
+
for pair in pairs:
|
|
236
|
+
claim = pair.get("claim", "").strip()
|
|
237
|
+
if len(claim) < 8:
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
if claim in seen_claims:
|
|
241
|
+
continue
|
|
242
|
+
seen_claims.add(claim)
|
|
243
|
+
|
|
244
|
+
is_positive = bool(pair.get("candidate_label", False))
|
|
245
|
+
if is_positive:
|
|
246
|
+
has_schema = bool(pair.get("schema"))
|
|
247
|
+
has_numeric = bool(numeric_pattern.search(claim))
|
|
248
|
+
if not has_schema and not has_numeric:
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
filtered.append(pair)
|
|
252
|
+
|
|
253
|
+
return filtered
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
async def save_synthetic_data(
|
|
257
|
+
pairs: list[dict[str, Any]],
|
|
258
|
+
output_path: str = "ml/data/synthetic_pretrain.jsonl",
|
|
259
|
+
) -> None:
|
|
260
|
+
"""
|
|
261
|
+
기존 저장 함수 그대로 써도 되지만, JSONL 저장은 유지한다.
|
|
262
|
+
"""
|
|
263
|
+
import os
|
|
264
|
+
|
|
265
|
+
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
266
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
267
|
+
for item in pairs:
|
|
268
|
+
f.write(json.dumps(item, ensure_ascii=False) + "\n")
|
|
269
|
+
|
|
270
|
+
logger.info(f"합성 데이터 저장 완료: {output_path} ({len(pairs)}건)")
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""[리팩] Step 9 설정 로드 — explanation/config.yaml (default.yaml 미수정)"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
_EXPLANATION_CONFIG_PATH = Path(__file__).parent / "config.yaml"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_explanation_settings(config: dict | None) -> dict:
|
|
12
|
+
"""explanation 설정 병합: config.yaml 기본값 ← config['explanation'] override."""
|
|
13
|
+
merged: dict = {}
|
|
14
|
+
if _EXPLANATION_CONFIG_PATH.is_file():
|
|
15
|
+
with open(_EXPLANATION_CONFIG_PATH, encoding="utf-8") as f:
|
|
16
|
+
merged.update(yaml.safe_load(f) or {})
|
|
17
|
+
merged.update((config or {}).get("explanation") or {})
|
|
18
|
+
return merged
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""[리팩] explainer의 LLMClient 호출 → Step 9 전용 thin wrapper"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from structverify.utils.llm_client import LLMClient
|
|
5
|
+
|
|
6
|
+
from ._config import get_explanation_settings
|
|
7
|
+
|
|
8
|
+
_EXPLANATION_SYSTEM_PROMPT = (
|
|
9
|
+
"팩트체크 전문 작가. 명확하고 간결한 한국어로 작성하세요."
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def generate_explanation_text(
|
|
14
|
+
prompt: str,
|
|
15
|
+
config: dict | None = None,
|
|
16
|
+
) -> str:
|
|
17
|
+
"""검증 설명용 LLM 텍스트 생성."""
|
|
18
|
+
expl_cfg = get_explanation_settings(config)
|
|
19
|
+
model_tier = expl_cfg.get("model_tier", "heavy")
|
|
20
|
+
llm = LLMClient(config=(config or {}).get("llm", {}))
|
|
21
|
+
return await llm.generate(
|
|
22
|
+
prompt=prompt,
|
|
23
|
+
system_prompt=_EXPLANATION_SYSTEM_PROMPT,
|
|
24
|
+
model_tier=model_tier,
|
|
25
|
+
)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
explanation/explainer.py — LLM 기반 설명 생성 + Provenance 렌더링 (Step 9)
|
|
3
|
+
|
|
4
|
+
[김예슬 - 2026-04-22]
|
|
5
|
+
- 기존 단일 프롬프트 → verdict 유형별 전용 프롬프트 3종으로 분리
|
|
6
|
+
· MATCH_PROMPT : 일치 판정 — 어떤 통계가 근거인지 중심으로 설명
|
|
7
|
+
· MISMATCH_PROMPT : 불일치 판정 — 차이 수치, 원인 유형, 독자 주의 안내 포함
|
|
8
|
+
· UNVERIFIABLE_PROMPT : 검증 불가 — 왜 못 찾았는지, 다음 확인 방법 제시
|
|
9
|
+
- mismatch_type별 원인 설명 문구 자동 생성 (_mismatch_reason_text)
|
|
10
|
+
- _format_evidence(): Evidence 없을 때 안전하게 "N/A" 처리
|
|
11
|
+
- _format_schema(): ClaimSchema 요약 텍스트 생성
|
|
12
|
+
- generate_explanation() 반환값에 provenance_summary 자동 세팅
|
|
13
|
+
|
|
14
|
+
[참고] ReAct (Yao et al., ICLR 2023)
|
|
15
|
+
Agent의 최종 Observation 단계에서 판정 근거를 자연어로 설명하는 Step 9
|
|
16
|
+
|
|
17
|
+
[리팩 2026-06 / refactor/v1/js/explanation]
|
|
18
|
+
- verdict별 프롬프트 문자열 → prompts/ 로 분리 (import만 유지, 동작 동일)
|
|
19
|
+
- 수치·출처 포맷 헬퍼 → formatters.py 로 분리
|
|
20
|
+
- LLM 실패 fallback 문구 → fallback.py 로 분리
|
|
21
|
+
- LLM 호출 → _llm.py 로 분리
|
|
22
|
+
- model_tier 등 모듈 설정 → explanation/config.yaml (default.yaml 미수정)
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from structverify.core.schemas import (
|
|
27
|
+
Claim, VerdictType, VerificationResult,
|
|
28
|
+
)
|
|
29
|
+
# [리팩] explainer에 있던 포맷 헬퍼 → formatters.py (로직 동일)
|
|
30
|
+
from .formatters import (
|
|
31
|
+
_calc_diff,
|
|
32
|
+
_calc_diff_pct,
|
|
33
|
+
_format_search_hint,
|
|
34
|
+
_format_stat_source,
|
|
35
|
+
_mismatch_reason_text,
|
|
36
|
+
_unverifiable_reason,
|
|
37
|
+
)
|
|
38
|
+
# [리팩] verdict별 LLM 프롬프트 문자열 → prompts/ (동작 변경 없음)
|
|
39
|
+
from .prompts.match import MATCH_PROMPT
|
|
40
|
+
from .prompts.mismatch import MISMATCH_PROMPT
|
|
41
|
+
from .prompts.multihop import MULTIHOP_PROMPT
|
|
42
|
+
from .prompts.unverifiable import UNVERIFIABLE_PROMPT
|
|
43
|
+
# [리팩] LLM 실패 시 fallback 문구 → fallback.py (explainer에서 re-export)
|
|
44
|
+
from .fallback import _fallback_explanation
|
|
45
|
+
# [리팩] LLMClient 직접 호출 → _llm.py
|
|
46
|
+
from ._llm import generate_explanation_text
|
|
47
|
+
from structverify.graph.provenance import render_provenance_text
|
|
48
|
+
from structverify.utils.logger import get_logger
|
|
49
|
+
|
|
50
|
+
logger = get_logger(__name__)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── 메인 함수 ─────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
async def generate_explanation(
|
|
56
|
+
claim: Claim,
|
|
57
|
+
result: VerificationResult,
|
|
58
|
+
config: dict | None = None,
|
|
59
|
+
) -> str:
|
|
60
|
+
"""
|
|
61
|
+
검증 결과에 대한 자연어 설명을 생성한다.
|
|
62
|
+
|
|
63
|
+
verdict 유형에 따라 다른 프롬프트를 사용:
|
|
64
|
+
MATCH → MATCH_PROMPT (일치 근거 중심)
|
|
65
|
+
MISMATCH → MISMATCH_PROMPT (차이 원인 + 독자 주의)
|
|
66
|
+
UNVERIFIABLE → UNVERIFIABLE_PROMPT (왜 못 찾았는지 + 직접 확인 방법)
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
claim: 검증 대상 주장
|
|
70
|
+
result: 검증 결과 (verdict, evidence, mismatch_type 포함)
|
|
71
|
+
config: 설정 dict
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
자연어 설명 문자열
|
|
75
|
+
"""
|
|
76
|
+
config = config or {}
|
|
77
|
+
|
|
78
|
+
# Provenance 텍스트 렌더링
|
|
79
|
+
prov_text = "출처 정보 없음"
|
|
80
|
+
if result.evidence and result.evidence.provenance:
|
|
81
|
+
prov_text = render_provenance_text(result.evidence.provenance)
|
|
82
|
+
result.provenance_summary = prov_text
|
|
83
|
+
|
|
84
|
+
prompt = _build_prompt(claim, result, prov_text)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
explanation = await generate_explanation_text(prompt, config)
|
|
88
|
+
logger.info(f"[Step 9] 설명 생성 완료: {claim.sent_id} ({result.verdict.value})")
|
|
89
|
+
return explanation
|
|
90
|
+
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.error(f"설명 생성 실패: {e}")
|
|
93
|
+
# fallback — LLM 없이 기본 텍스트 생성
|
|
94
|
+
return _fallback_explanation(claim, result)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── 내부 헬퍼 ─────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def _build_prompt(
|
|
100
|
+
claim: Claim,
|
|
101
|
+
result: VerificationResult,
|
|
102
|
+
prov_text: str,
|
|
103
|
+
) -> str:
|
|
104
|
+
"""verdict 유형에 따라 적절한 프롬프트를 생성한다."""
|
|
105
|
+
|
|
106
|
+
ev = result.evidence
|
|
107
|
+
schema = claim.schema
|
|
108
|
+
|
|
109
|
+
claimed_value = schema.value if schema and schema.value is not None else "N/A"
|
|
110
|
+
unit = schema.unit or "" if schema else ""
|
|
111
|
+
official_value = ev.official_value if ev and ev.official_value is not None else "N/A"
|
|
112
|
+
stat_source = _format_stat_source(ev)
|
|
113
|
+
|
|
114
|
+
# [Multi-hop] 멀티홉으로 검증된 파생 주장은 전용 프롬프트
|
|
115
|
+
if getattr(result, "multihop_used", False) and result.multihop_detail:
|
|
116
|
+
d = result.multihop_detail
|
|
117
|
+
verdict_label = {
|
|
118
|
+
VerdictType.MATCH: "✅ 일치 (MATCH)",
|
|
119
|
+
VerdictType.MISMATCH: "❌ 불일치 (MISMATCH)",
|
|
120
|
+
VerdictType.UNVERIFIABLE: "❓ 검증 불가",
|
|
121
|
+
}.get(result.verdict, str(result.verdict))
|
|
122
|
+
return MULTIHOP_PROMPT.format(
|
|
123
|
+
verdict_label=verdict_label,
|
|
124
|
+
claim_text=claim.claim_text,
|
|
125
|
+
claimed_ratio=d.get("claimed_ratio", "N/A"),
|
|
126
|
+
computed_ratio=d.get("computed_ratio", "N/A"),
|
|
127
|
+
largest_value=f"{d.get('largest_value', 0):,.0f}",
|
|
128
|
+
smallest_value=f"{d.get('smallest_value', 0):,.0f}",
|
|
129
|
+
confidence=result.confidence,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if result.verdict == VerdictType.MATCH:
|
|
133
|
+
diff_pct = _calc_diff_pct(claimed_value, official_value)
|
|
134
|
+
# [수정] MATCH_PROMPT 템플릿이 쓰는 {indicator}/{claim_time}/
|
|
135
|
+
# {evidence_time} 플레이스홀더가 format 인자에서 누락돼 KeyError가
|
|
136
|
+
# 나던 버그 수정. claim.schema / evidence에서 값을 채운다.
|
|
137
|
+
_indicator = (schema.indicator if schema and schema.indicator
|
|
138
|
+
else "지표")
|
|
139
|
+
_claim_time = (schema.time_period if schema and schema.time_period
|
|
140
|
+
else "N/A")
|
|
141
|
+
_evidence_time = (ev.time_period if ev and ev.time_period
|
|
142
|
+
else "N/A")
|
|
143
|
+
return MATCH_PROMPT.format(
|
|
144
|
+
claim_text=claim.claim_text,
|
|
145
|
+
claimed_value=claimed_value,
|
|
146
|
+
official_value=official_value,
|
|
147
|
+
unit=unit,
|
|
148
|
+
diff_pct=diff_pct,
|
|
149
|
+
confidence=result.confidence,
|
|
150
|
+
stat_source=stat_source,
|
|
151
|
+
provenance=prov_text,
|
|
152
|
+
indicator=_indicator,
|
|
153
|
+
claim_time=_claim_time,
|
|
154
|
+
evidence_time=_evidence_time,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
elif result.verdict == VerdictType.MISMATCH:
|
|
158
|
+
diff_pct = _calc_diff_pct(claimed_value, official_value)
|
|
159
|
+
diff = _calc_diff(claimed_value, official_value)
|
|
160
|
+
mismatch_reason = _mismatch_reason_text(result.mismatch_type)
|
|
161
|
+
return MISMATCH_PROMPT.format(
|
|
162
|
+
claim_text=claim.claim_text,
|
|
163
|
+
claimed_value=claimed_value,
|
|
164
|
+
official_value=official_value,
|
|
165
|
+
unit=unit,
|
|
166
|
+
diff=diff,
|
|
167
|
+
diff_pct=diff_pct,
|
|
168
|
+
mismatch_reason=mismatch_reason,
|
|
169
|
+
confidence=result.confidence,
|
|
170
|
+
stat_source=stat_source,
|
|
171
|
+
provenance=prov_text,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
else: # UNVERIFIABLE
|
|
175
|
+
reason = _unverifiable_reason(claim, result)
|
|
176
|
+
search_hint = _format_search_hint(claim)
|
|
177
|
+
return UNVERIFIABLE_PROMPT.format(
|
|
178
|
+
claim_text=claim.claim_text,
|
|
179
|
+
reason=reason,
|
|
180
|
+
stat_source=stat_source,
|
|
181
|
+
search_hint=search_hint,
|
|
182
|
+
)
|
|
183
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""[리팩] explainer._fallback_explanation 분리 — LLM 실패 시 기본 문구 생성"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from structverify.core.schemas import Claim, VerdictType, VerificationResult
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _fallback_explanation(claim: Claim, result: VerificationResult) -> str:
|
|
8
|
+
"""LLM 실패 시 기본 텍스트로 fallback."""
|
|
9
|
+
verdict_kr = {
|
|
10
|
+
VerdictType.MATCH: "일치",
|
|
11
|
+
VerdictType.MISMATCH: "불일치",
|
|
12
|
+
VerdictType.UNVERIFIABLE: "검증 불가",
|
|
13
|
+
}.get(result.verdict, result.verdict.value)
|
|
14
|
+
|
|
15
|
+
base = f'"{claim.claim_text[:40]}..." — 판정: {verdict_kr}'
|
|
16
|
+
|
|
17
|
+
if result.verdict == VerdictType.MISMATCH and result.evidence:
|
|
18
|
+
ev = result.evidence
|
|
19
|
+
schema = claim.schema
|
|
20
|
+
if schema and schema.value and ev.official_value:
|
|
21
|
+
base += (
|
|
22
|
+
f" | 기사: {schema.value}{schema.unit or ''}"
|
|
23
|
+
f" / 공식: {ev.official_value}{ev.unit or ''}"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if result.provenance_summary:
|
|
27
|
+
base += f" | {result.provenance_summary}"
|
|
28
|
+
|
|
29
|
+
return base
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""[리팩] explainer에 있던 수치·출처 포맷 헬퍼 분리 — _build_prompt에서 사용"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from structverify.core.schemas import Claim, Evidence, MismatchType, VerificationResult
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _mismatch_reason_text(mismatch_type: MismatchType | None) -> str:
|
|
8
|
+
"""MismatchType을 독자가 이해할 수 있는 설명 문구로 변환한다."""
|
|
9
|
+
mapping = {
|
|
10
|
+
MismatchType.VALUE: "단순 수치 오류 — 기사가 공식 수치와 다른 값을 인용",
|
|
11
|
+
MismatchType.TIME_PERIOD: "시점 불일치 — 다른 연도의 통계를 현재 수치처럼 인용",
|
|
12
|
+
MismatchType.POPULATION: "대상 집단 불일치 — 다른 범위(전체 vs 일부)의 통계를 혼용",
|
|
13
|
+
MismatchType.EXAGGERATION:"과장/축소 — 실제 수치보다 크게 또는 작게 표현",
|
|
14
|
+
}
|
|
15
|
+
return mapping.get(mismatch_type, "수치 불일치")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _unverifiable_reason(claim: Claim, result: VerificationResult) -> str:
|
|
19
|
+
"""검증 불가 이유를 구체적으로 서술한다."""
|
|
20
|
+
if result.evidence is None:
|
|
21
|
+
return "기준 데이터에서 관련 항목을 찾지 못함"
|
|
22
|
+
if result.evidence.official_value is None:
|
|
23
|
+
return "통계표는 찾았으나 해당 시점/대상의 수치가 없음"
|
|
24
|
+
if claim.schema is None or claim.schema.value is None:
|
|
25
|
+
return "기사에서 구체적인 수치를 추출하지 못함"
|
|
26
|
+
return "검증에 필요한 정보가 불충분함"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _format_stat_source(ev: Evidence | None) -> str:
|
|
30
|
+
"""Evidence에서 통계 출처 텍스트를 생성한다."""
|
|
31
|
+
if not ev:
|
|
32
|
+
return "N/A"
|
|
33
|
+
parts = []
|
|
34
|
+
if ev.source_name:
|
|
35
|
+
parts.append(ev.source_name)
|
|
36
|
+
if ev.stat_table_id:
|
|
37
|
+
parts.append(f"표ID: {ev.stat_table_id}")
|
|
38
|
+
if ev.time_period:
|
|
39
|
+
parts.append(f"{ev.time_period} 기준")
|
|
40
|
+
return " | ".join(parts) if parts else "N/A"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _format_search_hint(claim: Claim) -> str:
|
|
44
|
+
"""독자가 직접 검색할 수 있는 키워드를 제안한다."""
|
|
45
|
+
if not claim.schema:
|
|
46
|
+
return claim.claim_text[:30]
|
|
47
|
+
parts = []
|
|
48
|
+
if claim.schema.indicator:
|
|
49
|
+
parts.append(claim.schema.indicator)
|
|
50
|
+
if claim.schema.population:
|
|
51
|
+
parts.append(claim.schema.population)
|
|
52
|
+
if claim.schema.time_period:
|
|
53
|
+
parts.append(claim.schema.time_period)
|
|
54
|
+
return " ".join(parts) if parts else claim.claim_text[:30]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _calc_diff_pct(claimed: float | str, official: float | str) -> float:
|
|
58
|
+
"""차이 비율(%) 계산. 수치가 없으면 0 반환."""
|
|
59
|
+
try:
|
|
60
|
+
c, o = float(claimed), float(official)
|
|
61
|
+
if o == 0:
|
|
62
|
+
return 0.0
|
|
63
|
+
return abs(c - o) / abs(o) * 100
|
|
64
|
+
except (TypeError, ValueError):
|
|
65
|
+
return 0.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _calc_diff(claimed: float | str, official: float | str) -> str:
|
|
69
|
+
"""실제 차이값 계산. 수치가 없으면 'N/A' 반환."""
|
|
70
|
+
try:
|
|
71
|
+
c, o = float(claimed), float(official)
|
|
72
|
+
diff = c - o
|
|
73
|
+
return f"{diff:+.1f}"
|
|
74
|
+
except (TypeError, ValueError):
|
|
75
|
+
return "N/A"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# [리팩] verdict별 프롬프트 모듈 (explainer.py에서 분리)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# [리팩] explainer.MATCH_PROMPT → prompts/match.py (문자열만 이동)
|
|
2
|
+
MATCH_PROMPT = """당신은 팩트체크 전문 작가입니다.
|
|
3
|
+
아래 검증 결과를 독자가 이해하기 쉽게 한국어로 설명하세요.
|
|
4
|
+
|
|
5
|
+
[판정: ✅ 일치 (MATCH) — 이 판정은 확정입니다. 절대 "사실이 아니다"라고 쓰지 마세요.]
|
|
6
|
+
주장: "{claim_text}"
|
|
7
|
+
기사 수치: {claimed_value} {unit}
|
|
8
|
+
공식 수치: {official_value} {unit}
|
|
9
|
+
오차: {diff_pct:.1f}%
|
|
10
|
+
신뢰도: {confidence:.0%}
|
|
11
|
+
근거 통계: {stat_source}
|
|
12
|
+
출처: {provenance}
|
|
13
|
+
|
|
14
|
+
[작성 규칙]
|
|
15
|
+
- 2~3문장으로 간결하게
|
|
16
|
+
- 판정이 "일치"이므로 "사실입니다", "확인됩니다" 등 긍정적 표현 사용
|
|
17
|
+
- "{{출처명}}에 따르면" 형식으로 출처 명시 (통계표/규정/기준 데이터 등)
|
|
18
|
+
- 기사 수치와 공식 수치를 나란히 비교
|
|
19
|
+
- 통계표 ID 포함
|
|
20
|
+
- ⚠️ "사실이 아닙니다", "틀렸습니다" 등 부정 표현 절대 금지
|
|
21
|
+
|
|
22
|
+
[⚠️ 주의 — 설명 전 반드시 확인]
|
|
23
|
+
- 공식 통계 출처({stat_source})가 indicator({indicator})와
|
|
24
|
+
*같은 국가/지역*의 통계인지 확인하세요.
|
|
25
|
+
- 시점({claim_time} vs {evidence_time})이 다르면
|
|
26
|
+
"같은 연도 데이터가 아님"을 반드시 명시하세요.
|
|
27
|
+
"""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# [리팩] explainer.MISMATCH_PROMPT → prompts/mismatch.py
|
|
2
|
+
MISMATCH_PROMPT = """당신은 팩트체크 전문 작가입니다.
|
|
3
|
+
아래 검증 결과를 독자가 이해하기 쉽게 한국어로 설명하세요.
|
|
4
|
+
|
|
5
|
+
[판정: 불일치 (MISMATCH) — 기사 수치와 공식 수치가 다릅니다.]
|
|
6
|
+
주장: "{claim_text}"
|
|
7
|
+
기사 수치: {claimed_value} {unit}
|
|
8
|
+
공식 수치: {official_value} {unit}
|
|
9
|
+
차이: {diff} {unit} ({diff_pct:.1f}%)
|
|
10
|
+
불일치 유형: {mismatch_reason}
|
|
11
|
+
신뢰도: {confidence:.0%}
|
|
12
|
+
근거 통계: {stat_source}
|
|
13
|
+
출처: {provenance}
|
|
14
|
+
|
|
15
|
+
[작성 규칙]
|
|
16
|
+
- 3~4문장으로 작성
|
|
17
|
+
- 기사 수치({claimed_value})와 공식 수치({official_value})를 반드시 정확히 인용
|
|
18
|
+
- 위에 적힌 수치만 사용하세요. 새로운 수치를 만들어내지 마세요.
|
|
19
|
+
- 불일치 유형({mismatch_reason})에 맞는 원인 설명 포함
|
|
20
|
+
- 출처 포함"""
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# [리팩] explainer.MULTIHOP_PROMPT → prompts/multihop.py
|
|
2
|
+
MULTIHOP_PROMPT = """당신은 팩트체크 전문 작가입니다.
|
|
3
|
+
아래 검증 결과를 독자가 이해하기 쉽게 한국어로 설명하세요.
|
|
4
|
+
|
|
5
|
+
[판정: {verdict_label} — 멀티홉 검증으로 판정했습니다.]
|
|
6
|
+
주장: "{claim_text}"
|
|
7
|
+
주장한 비율/배수: {claimed_ratio}배
|
|
8
|
+
계산된 비율/배수: {computed_ratio}배
|
|
9
|
+
근거: 원천 수치 {largest_value} / {smallest_value} = {computed_ratio}배
|
|
10
|
+
신뢰도: {confidence:.0%}
|
|
11
|
+
|
|
12
|
+
[작성 규칙]
|
|
13
|
+
- 이 주장은 기준 데이터에서 직접 찾을 수 없는 "파생 주장"(비율/배수)입니다
|
|
14
|
+
- 대신 같은 지표의 원천 수치 2개를 기준 데이터에서 찾아 비율을 직접 계산했습니다
|
|
15
|
+
- 2~3문장으로, 어떻게 계산했는지 설명: "원천 수치 {largest_value}와 {smallest_value}를 비교하면 약 {computed_ratio}배"
|
|
16
|
+
- 위에 적힌 수치만 사용하세요. 새 수치를 만들지 마세요."""
|