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,1272 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent/runtime_agent.py — Runtime Verification Agent (Agent A)
|
|
3
|
+
|
|
4
|
+
실시간 검증 요청을 처리하는 메인 Agent. ReAct 패턴 기반.
|
|
5
|
+
Thought → Action(Tool Call) → Observation 순환을 통해 파이프라인 제어.
|
|
6
|
+
|
|
7
|
+
[김예슬 - 2026-04-22]
|
|
8
|
+
- Step 3~9 오케스트레이션 전체 담당
|
|
9
|
+
- ReAct 패턴으로 각 스텝을 Action으로 정의하고 순차 실행
|
|
10
|
+
|
|
11
|
+
[김예슬 - 2026-04-23]
|
|
12
|
+
- classify_domain() 반환값 튜플 대응: str → (domain, domain_desc)
|
|
13
|
+
- config["detected_domain"] → self.config["detected_domain"] 버그 수정
|
|
14
|
+
- domain_desc를 self.config에 저장하여 schema_inductor 힌트로 활용
|
|
15
|
+
|
|
16
|
+
[김예슬 - 2026-04-24]
|
|
17
|
+
- induce_schemas Action 설명 업데이트:
|
|
18
|
+
· 기존: HCX-003 generate_json() → JSON 파싱 (실패 가능)
|
|
19
|
+
· 변경: HCX-007 Structured Outputs → JSON Schema 보장 (파싱 실패 없음)
|
|
20
|
+
- Action별 사용 모델/API 업데이트:
|
|
21
|
+
· classify_domain → HCX-DASH-002 (v3 API)
|
|
22
|
+
· score_candidate → HCX-DASH-002 (v3 API)
|
|
23
|
+
· check_worthiness → HCX-003 (v1 API)
|
|
24
|
+
· induce_schemas → HCX-007 Structured Outputs (v3 API)
|
|
25
|
+
· generate_explain → HCX-003 (v1 API)
|
|
26
|
+
|
|
27
|
+
[ReAct 패턴 설명]
|
|
28
|
+
LLM이 단순히 답변을 생성하는 것이 아니라, 매 스텝마다 다음을 반복합니다:
|
|
29
|
+
Thought : "현재 상태에서 무엇을 해야 하는가?" (LLM 내부 추론)
|
|
30
|
+
Action : 구체적인 도구(함수) 호출
|
|
31
|
+
Observation: 도구 호출 결과를 관찰하고 다음 Thought 수행
|
|
32
|
+
|
|
33
|
+
Action → 사용 모델/API 매핑:
|
|
34
|
+
classify_domain → HCX-DASH-002 (v3, 경량)
|
|
35
|
+
score_candidate → HCX-DASH-002 (v3, 경량)
|
|
36
|
+
check_worthiness → HCX-003 (v1, 중량)
|
|
37
|
+
induce_schemas → HCX-007 Structured Outputs (v3, JSON 보장)
|
|
38
|
+
build_graph → 내부 로직 (LLM 미사용)
|
|
39
|
+
retrieve_evidence → KOSIS Open API (LLM 미사용)
|
|
40
|
+
verify_claim → Deterministic 수치 비교 (LLM 미사용)
|
|
41
|
+
generate_explain → HCX-003 (v1, 중량)
|
|
42
|
+
|
|
43
|
+
[참고] ReAct (Yao et al., ICLR 2023) — https://github.com/ysymyth/ReAct
|
|
44
|
+
"""
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from datetime import datetime, timezone
|
|
48
|
+
|
|
49
|
+
from structverify.core.schemas import (
|
|
50
|
+
Claim, SIRDocument, VerificationResult, GraphNode, GraphEdge, Evidence,
|
|
51
|
+
GraphNodeType, GraphEdgeType,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _cap_text(s: str, max_chars: int) -> str:
|
|
56
|
+
"""긴 텍스트를 head/tail로 잘라 안전한 크기로 반환.
|
|
57
|
+
|
|
58
|
+
LLM prompt가 수십 KB 넘는 경우가 흔해 raw 저장 시 디스크 폭발 방지.
|
|
59
|
+
중간을 잘라 head 70% + truncation 표시 + tail 30%로 유지 (디버깅엔
|
|
60
|
+
양끝이 가장 유용).
|
|
61
|
+
"""
|
|
62
|
+
if not s or len(s) <= max_chars:
|
|
63
|
+
return s or ""
|
|
64
|
+
head_len = int(max_chars * 0.7)
|
|
65
|
+
tail_len = max_chars - head_len - 80
|
|
66
|
+
if tail_len <= 0:
|
|
67
|
+
return s[:max_chars]
|
|
68
|
+
return (
|
|
69
|
+
s[:head_len]
|
|
70
|
+
+ f"\n\n... [{len(s) - max_chars}자 truncated] ...\n\n"
|
|
71
|
+
+ s[-tail_len:]
|
|
72
|
+
)
|
|
73
|
+
from structverify.detection.domain_classifier import classify_domain
|
|
74
|
+
from structverify.detection.claim_detector import detect_claims
|
|
75
|
+
from structverify.detection.schema_inductor import induce_schemas
|
|
76
|
+
from structverify.graph.graph_builder import build_claim_graph
|
|
77
|
+
from structverify.graph.graph_multihop import apply_multihop_verification
|
|
78
|
+
from structverify.graph.document_graph import build_document_temporal_graph
|
|
79
|
+
from structverify.graph.claim_graph import ClaimGraph
|
|
80
|
+
from structverify.retrieval.query_builder import build_query
|
|
81
|
+
from structverify.retrieval.evidence_subgraph import build_evidence_subgraph
|
|
82
|
+
from structverify.retrieval.kosis_connector import KOSISConnector
|
|
83
|
+
from structverify.verification.verifier import verify_claim
|
|
84
|
+
from structverify.explanation.explainer import generate_explanation
|
|
85
|
+
from structverify.memory import DocumentWorkingMemory # [머지 이수민 main]
|
|
86
|
+
from structverify.utils.logger import get_logger
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _claims_from_all_sentences(sir_doc: "SIRDocument") -> list["Claim"]:
|
|
90
|
+
"""[2026-05-27 oracle mode] detect_claims 우회 — 모든 문장을 Claim 객체로.
|
|
91
|
+
|
|
92
|
+
FEVER/SciFact 스타일의 oracle setting (claim_text를 직접 input으로 받음)
|
|
93
|
+
평가 시 사용. config.eval.bypass_detection=true일 때만 호출됨.
|
|
94
|
+
"""
|
|
95
|
+
out: list[Claim] = []
|
|
96
|
+
for block in (sir_doc.blocks or []):
|
|
97
|
+
for sent in (block.sentences or []):
|
|
98
|
+
txt = (sent.text or "").strip()
|
|
99
|
+
if not txt:
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
out.append(Claim(
|
|
103
|
+
doc_id=sir_doc.doc_id,
|
|
104
|
+
block_id=block.block_id,
|
|
105
|
+
sent_id=sent.sent_id,
|
|
106
|
+
claim_text=txt,
|
|
107
|
+
check_worthy_score=1.0, # oracle이므로 만점
|
|
108
|
+
))
|
|
109
|
+
except Exception:
|
|
110
|
+
continue
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
logger = get_logger(__name__)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class RuntimeAgent:
|
|
117
|
+
"""
|
|
118
|
+
Agent A: 실시간 검증 처리.
|
|
119
|
+
Step 3~9를 순차 실행하며 ReAct 패턴으로 파이프라인을 오케스트레이션한다.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, config: dict | None = None):
|
|
123
|
+
self.config = config or {}
|
|
124
|
+
# [v3 김예슬] kosis config에 llm 포함 → CatalogSearchTool LLM Agent가 llm 설정 사용
|
|
125
|
+
kosis_cfg = {
|
|
126
|
+
**self.config.get("kosis", {}),
|
|
127
|
+
"llm": self.config.get("llm", {}),
|
|
128
|
+
# [#67-D A-2] config.embedding 을 CatalogSearchTool까지 전달 (provider 선택)
|
|
129
|
+
"embedding": self.config.get("embedding", {}),
|
|
130
|
+
}
|
|
131
|
+
self.kosis = KOSISConnector(config=kosis_cfg)
|
|
132
|
+
|
|
133
|
+
# [v6.19] Graph Store 초기화 (Neo4j — 옵셔널)
|
|
134
|
+
# default.yaml graph.store.enabled=true 일 때만 실제 연결.
|
|
135
|
+
# 미설정/미설치/연결실패 시 GraphStore 내부에서 안전하게 비활성화됨.
|
|
136
|
+
from structverify.graph.graph_store import GraphStore
|
|
137
|
+
graph_store_cfg = (self.config.get("graph") or {}).get("store") or {}
|
|
138
|
+
self.graph_store = GraphStore(config=graph_store_cfg)
|
|
139
|
+
|
|
140
|
+
def _build_datasources(self) -> dict:
|
|
141
|
+
"""config.data_sources.enabled 기반 {name: DataSource} 구성 (#66).
|
|
142
|
+
|
|
143
|
+
enabled 미설정이면 ["kosis"] (동작 보존). 소스별 config는 data_sources.<name>.
|
|
144
|
+
kosis는 embedding 주입 유지(#67-D A-2). custom_csv 등록 트리거 import 포함.
|
|
145
|
+
"""
|
|
146
|
+
import structverify.retrieval.kosis_source # noqa: F401 — @register_datasource
|
|
147
|
+
import structverify.retrieval.custom_csv_source # noqa: F401 — @register_datasource
|
|
148
|
+
import structverify.retrieval.custom_db_source # noqa: F401 — @register_datasource
|
|
149
|
+
from structverify.retrieval.registry import build_all_enabled
|
|
150
|
+
|
|
151
|
+
ds_cfg = self.config.get("data_sources") or {}
|
|
152
|
+
enabled = list(ds_cfg.get("enabled", ["kosis"]))
|
|
153
|
+
|
|
154
|
+
kosis_ds_cfg = dict(ds_cfg.get("kosis") or self.config.get("kosis") or {})
|
|
155
|
+
kosis_ds_cfg.setdefault("embedding", self.config.get("embedding", {}))
|
|
156
|
+
|
|
157
|
+
build_cfg = {"enabled": enabled}
|
|
158
|
+
for name in enabled:
|
|
159
|
+
build_cfg[name] = kosis_ds_cfg if name == "kosis" else (ds_cfg.get(name) or {})
|
|
160
|
+
|
|
161
|
+
return {ds.name: ds for ds in build_all_enabled(build_cfg)}
|
|
162
|
+
|
|
163
|
+
async def _ensure_source_profile(self) -> None:
|
|
164
|
+
"""연결된 custom 소스(custom_db/csv/docs)를 프로파일링해 config에 주입한다.
|
|
165
|
+
|
|
166
|
+
탐지·스키마가 'KOSIS 통계인가?' 대신 '이 소스로 검증되나?'를 기준으로 삼게 한다.
|
|
167
|
+
공공소스(kosis)만 연결됐으면 스킵 → 기존 동작 보존.
|
|
168
|
+
"""
|
|
169
|
+
if self.config.get("_source_profile"):
|
|
170
|
+
return
|
|
171
|
+
ds_cfg = self.config.get("data_sources", {}) or {}
|
|
172
|
+
enabled = list(ds_cfg.get("enabled", []) or [])
|
|
173
|
+
custom = [n for n in enabled if n in ("custom_db", "custom_csv", "custom_docs")]
|
|
174
|
+
if not custom:
|
|
175
|
+
return
|
|
176
|
+
try:
|
|
177
|
+
from structverify.agent.source_profiler import profile_source
|
|
178
|
+
datasources = self._build_datasources()
|
|
179
|
+
name = custom[0]
|
|
180
|
+
ds = datasources.get(name)
|
|
181
|
+
if ds is None:
|
|
182
|
+
return
|
|
183
|
+
profile = await profile_source(ds, name, self.config)
|
|
184
|
+
if not profile.is_empty():
|
|
185
|
+
self.config["_source_profile"] = profile.to_dict()
|
|
186
|
+
logger.info(
|
|
187
|
+
f"[Agent A] 소스 프로파일 주입: {name} — "
|
|
188
|
+
f"domain={profile.domain!r}, 지표 {len(profile.indicators)}개"
|
|
189
|
+
)
|
|
190
|
+
except Exception as e: # noqa: BLE001 — 프로파일 실패는 조용히(기존 탐지로 폴백)
|
|
191
|
+
logger.warning(f"[source-profiler] 프로파일 생성 실패: {e}")
|
|
192
|
+
|
|
193
|
+
async def process(self, sir_doc: SIRDocument) -> tuple[
|
|
194
|
+
list[Claim], list[VerificationResult], list[GraphNode], list[GraphEdge]
|
|
195
|
+
]:
|
|
196
|
+
"""
|
|
197
|
+
SIR 문서 → 전체 검증 파이프라인 실행 (Step 3~9).
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
(claims, results, graph_nodes, graph_edges)
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
# ── [머지: main의 DocumentWorkingMemory] ──────────────────────
|
|
204
|
+
# 이 doc 처리 동안만 살아있는 in-memory 컨텍스트.
|
|
205
|
+
# v2의 verified_facts(검증값 캐시)와 역할이 다름:
|
|
206
|
+
# - memory : doc 단위 — 도메인 가드 / stat_id 캐시 / claim 인덱스
|
|
207
|
+
# - verified_facts : claim 간 검증값 재사용 (agent loop 내부)
|
|
208
|
+
# 둘은 충돌하지 않으며 상호 보완적이다.
|
|
209
|
+
from uuid import uuid4
|
|
210
|
+
memory = DocumentWorkingMemory(
|
|
211
|
+
doc_id=str(sir_doc.doc_id),
|
|
212
|
+
run_id=str(uuid4())[:8],
|
|
213
|
+
source_uri=getattr(sir_doc, "source_uri", None),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# ── Action: classify_domain ──────────────────────────────────
|
|
217
|
+
# Tool: HCX-DASH-002 (v3 API, 경량)
|
|
218
|
+
# Thought: "이 문서의 도메인이 무엇인가?"
|
|
219
|
+
# Observation: domain 문자열 + 설명
|
|
220
|
+
import structverify.progress as _progress
|
|
221
|
+
domain, domain_desc = await classify_domain(sir_doc, self.config)
|
|
222
|
+
self.config["detected_domain"] = domain
|
|
223
|
+
self.config["detected_domain_desc"] = domain_desc
|
|
224
|
+
memory.record_domain(domain, domain_desc) # [머지 이수민 main]
|
|
225
|
+
logger.info(f"[Agent A] Step 3 classify_domain → {domain} ({domain_desc})")
|
|
226
|
+
_progress.emit("step", stage="domain", name=f"도메인 분류 → {domain}", pct=12)
|
|
227
|
+
|
|
228
|
+
# ── [source-aware] 연결된 custom 소스를 프로파일링해 탐지 기준을 소스에 맞춤 ──
|
|
229
|
+
# KOSIS 등 공공소스만이면 스킵(기존 동작 유지). custom_db/csv/docs면 프로파일 주입.
|
|
230
|
+
_progress.emit("step", stage="profile", name="소스 프로파일링…", pct=22)
|
|
231
|
+
await self._ensure_source_profile()
|
|
232
|
+
|
|
233
|
+
# ── Action: detect_claims ────────────────────────────────────
|
|
234
|
+
# [4-1] candidate_scorer: HCX-DASH-002 (v3, 경량) → 0~1 점수
|
|
235
|
+
# [4-2] claim_detector: HCX-003 (v1, 중량) → check-worthiness
|
|
236
|
+
# Thought: "검증 가능한 주장 문장을 찾아야 한다"
|
|
237
|
+
# Observation: Claim 객체 리스트
|
|
238
|
+
# TODO [김예슬]: domain-packs 기반 도메인별 few-shot 예시 주입
|
|
239
|
+
#
|
|
240
|
+
# [2026-05-27] Oracle mode (evaluation용) — config.eval.bypass_detection=true이면
|
|
241
|
+
# detect_claims LLM 필터링을 건너뛰고 *모든 문장*을 claim으로 변환. FEVER/SciFact
|
|
242
|
+
# style의 oracle claim setting에 사용. 일반 검증 흐름엔 영향 X (config 기본 false).
|
|
243
|
+
_bypass_det = bool(
|
|
244
|
+
(self.config.get("eval", {}) or {}).get("bypass_detection", False)
|
|
245
|
+
)
|
|
246
|
+
if _bypass_det:
|
|
247
|
+
claims = _claims_from_all_sentences(sir_doc)
|
|
248
|
+
logger.info(
|
|
249
|
+
f"[Agent A] Step 4 detect_claims (BYPASSED — oracle mode) → "
|
|
250
|
+
f"{len(claims)}건 (모든 문장 claim 변환)"
|
|
251
|
+
)
|
|
252
|
+
else:
|
|
253
|
+
claims = await detect_claims(sir_doc, self.config)
|
|
254
|
+
logger.info(f"[Agent A] Step 4 detect_claims → {len(claims)}건")
|
|
255
|
+
|
|
256
|
+
_progress.emit(
|
|
257
|
+
"claims", count=len(claims),
|
|
258
|
+
claims=[
|
|
259
|
+
{"id": str(c.claim_id), "text": (c.claim_text or "")[:140]}
|
|
260
|
+
for c in claims
|
|
261
|
+
],
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if not claims:
|
|
265
|
+
logger.info("[Agent A] 검증 가능한 주장 없음 — 파이프라인 종료")
|
|
266
|
+
_progress.emit("done", count=0)
|
|
267
|
+
return [], [], [], []
|
|
268
|
+
|
|
269
|
+
# ── [v4 김예슬] Context Window 부착 ────────────────────────────
|
|
270
|
+
# 각 claim에 앞뒤 문장 context를 붙여서 LLM이 맥락을 이해할 수 있게 함
|
|
271
|
+
# 예: "이는 20년 새 2.6배 증가한 것이다" → 앞 문장 "쉬었음 청년이 21만7천명"도 함께 전달
|
|
272
|
+
# schema_inductor + query_builder에서 context_text 활용
|
|
273
|
+
for claim in claims:
|
|
274
|
+
claim.context_text = _get_context_window(claim, sir_doc, window=2)
|
|
275
|
+
|
|
276
|
+
# ── Action: build_document_temporal_graph ─────────────────────
|
|
277
|
+
# Tool: HCX 문서 시간 분석 agent (LLM 1회 호출)
|
|
278
|
+
# Thought: "기사 작성일/anchor_year를 추출해서 '내년/올해' 같은
|
|
279
|
+
# 상대 시점을 절대 연도로 풀 수 있게 해야 한다"
|
|
280
|
+
# Observation: DocumentNode(anchor_year=...) + TemporalExpr 노드
|
|
281
|
+
# [v6.16] 이 단계가 빠져 있어서 anchor_year가 항상 None이던 버그 수정
|
|
282
|
+
temporal_graph = None
|
|
283
|
+
try:
|
|
284
|
+
t_nodes, t_edges = await build_document_temporal_graph(
|
|
285
|
+
sir_doc, self.config
|
|
286
|
+
)
|
|
287
|
+
if t_nodes:
|
|
288
|
+
temporal_graph = ClaimGraph(t_nodes, t_edges)
|
|
289
|
+
# [머지 이수민 main] anchor_year + 시간 표현을 memory에 기록
|
|
290
|
+
for _n in t_nodes:
|
|
291
|
+
_nt = getattr(getattr(_n, "node_type", None), "value", "")
|
|
292
|
+
if _nt == "document":
|
|
293
|
+
_ay = (_n.properties or {}).get("anchor_year")
|
|
294
|
+
if _ay:
|
|
295
|
+
try:
|
|
296
|
+
memory.record_anchor_year(int(_ay))
|
|
297
|
+
except (ValueError, TypeError):
|
|
298
|
+
pass
|
|
299
|
+
elif _nt == "temporal_expr":
|
|
300
|
+
_expr = (_n.properties or {}).get("expression")
|
|
301
|
+
_resolved = (_n.properties or {}).get("resolved_value")
|
|
302
|
+
if _expr and _resolved:
|
|
303
|
+
memory.record_temporal(str(_expr), str(_resolved))
|
|
304
|
+
logger.info(
|
|
305
|
+
f"[Agent A] Step 4.5 temporal graph → "
|
|
306
|
+
f"anchor_year={temporal_graph.get_anchor_year()}"
|
|
307
|
+
)
|
|
308
|
+
except Exception as e:
|
|
309
|
+
logger.warning(f"[Agent A] temporal graph 빌드 실패 (계속 진행): {e}")
|
|
310
|
+
|
|
311
|
+
# ── Action: induce_schemas ───────────────────────────────────
|
|
312
|
+
# Tool: HCX-007 Structured Outputs (v3 API)
|
|
313
|
+
# Thought: "각 주장을 indicator/value/unit/population으로 구조화해야 한다"
|
|
314
|
+
# Observation: claim.schema = ClaimSchema({indicator, value, ...})
|
|
315
|
+
# [v4] context_text 포함 → "이는" 같은 대명사 참조 해소
|
|
316
|
+
# [v6.16] temporal_graph 전달 → 상대 시점 해소 + anchor_year fallback
|
|
317
|
+
claims = await induce_schemas(claims, self.config, graph=temporal_graph)
|
|
318
|
+
memory.record_claims(claims) # [머지 이수민 main] metric_to_claims 인덱싱
|
|
319
|
+
logger.info(
|
|
320
|
+
f"[Agent A] Step 5 induce_schemas → schemas attached "
|
|
321
|
+
f"(memory: {len(memory.metric_to_claims)} metrics)"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# ── Action: build_claim_graph ────────────────────────────────
|
|
325
|
+
# Tool: 내부 로직 (LLM 미사용)
|
|
326
|
+
# Thought: "ClaimSchema → Knowledge Graph 노드/엣지를 구성해야 한다"
|
|
327
|
+
# Observation: GraphNode[], GraphEdge[]
|
|
328
|
+
# TODO [신준수]: graph_builder.py 노드/엣지 타입 완성
|
|
329
|
+
all_nodes, all_edges = build_claim_graph(claims, sir_doc=sir_doc) # 호출부 로직 변경 [pipeline v3] 김예슬
|
|
330
|
+
logger.info(f"[Agent A] Step 6 build_claim_graph → {len(all_nodes)} nodes")
|
|
331
|
+
|
|
332
|
+
# ── Step 7~8: 각 주장별 Evidence 조회 + 검증 ────────────────
|
|
333
|
+
# [Multi-hop v1] Step 9(설명)는 multi-hop 재검증 후로 분리
|
|
334
|
+
# 이유: 파생 주장 검증은 다른 claim들의 Step 8 결과가 모두 필요함
|
|
335
|
+
# [Phase D] config.agent.enabled=true 면 planner+loop 경로 사용:
|
|
336
|
+
# - planner가 claim마다 Plan 수립 (ReAct: Thought)
|
|
337
|
+
# - agent_loop가 Plan대로 catalog_search → fetch_evidence → verify 순회
|
|
338
|
+
# 기존 경로(고정 retrieve→verify)는 enabled=false 시 그대로 사용 → 안전 롤백
|
|
339
|
+
# [머지 박재윤 main] asyncio.gather + Semaphore(3) 병렬화.
|
|
340
|
+
# claim끼리 독립적이므로 agent 경로(planner+loop)도 병렬 안전.
|
|
341
|
+
# claim 1건당 14~40초 → 8건 직렬이면 ~7분. 병렬 3이면 ~1/3.
|
|
342
|
+
agent_enabled = bool(
|
|
343
|
+
(self.config.get("agent") or {}).get("enabled", False)
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# agent 경로에서 쓸 문서 원문 (planner가 source_text로 사용 + workspace의
|
|
347
|
+
# source.txt 저장).
|
|
348
|
+
# [2026-05-21] sir_doc.raw_text(P10에서 추가, 원본 markdown/줄바꿈 포함)
|
|
349
|
+
# 우선. 없으면 _get_source_text(sir_doc)가 sentence들을 공백 join한 결과.
|
|
350
|
+
# 이유: workspace.initialize가 source.txt에 이 값을 저장하는데, sv_platform이
|
|
351
|
+
# /v1/jobs 폴링 시 Job.source_data(=raw_text)와 source.txt를 _normalize_ws
|
|
352
|
+
# 비교로 매칭. sentence join은 markdown 단락/리스트 구조 손실로 URL 추출본과
|
|
353
|
+
# 형태가 달라 매칭 실패 → 프론트 실시간 partial claim 안 뜸. text 입력은
|
|
354
|
+
# 단순해서 우연히 비슷했을 뿐.
|
|
355
|
+
source_text = (
|
|
356
|
+
getattr(sir_doc, "raw_text", None)
|
|
357
|
+
or self._get_source_text(sir_doc)
|
|
358
|
+
)
|
|
359
|
+
anchor_year = (
|
|
360
|
+
temporal_graph.get_anchor_year() if temporal_graph else None
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
import asyncio
|
|
364
|
+
sem = asyncio.Semaphore(3)
|
|
365
|
+
# 병렬 claim들이 memory에 동시 기록 → race 방지용 Lock.
|
|
366
|
+
# DocumentWorkingMemory의 record_* 는 dict 갱신이라 짧지만,
|
|
367
|
+
# record_stat_id_used 같은 복합 갱신을 원자적으로 보호한다.
|
|
368
|
+
mem_lock = asyncio.Lock()
|
|
369
|
+
|
|
370
|
+
async def process_one_claim(claim):
|
|
371
|
+
"""claim 1건 Step 7~8. 그래프 노드/엣지는 반환만 하고
|
|
372
|
+
병렬 종료 후 메인이 모은다 (extend가 thread-safe하지 않으므로)."""
|
|
373
|
+
claim_nid = f"claim:{claim.claim_id.hex[:8]}"
|
|
374
|
+
async with sem:
|
|
375
|
+
if agent_enabled:
|
|
376
|
+
# ── [Phase D] Agent Loop 경로 ──────────────────────
|
|
377
|
+
result, ev_nodes, ev_edges = await self._verify_with_agent(
|
|
378
|
+
claim, source_text, anchor_year, temporal_graph,
|
|
379
|
+
claim_nid=claim_nid, memory=memory, mem_lock=mem_lock,
|
|
380
|
+
)
|
|
381
|
+
logger.info(
|
|
382
|
+
f"[Agent A] Step 7~8 agent_loop → {result.verdict.value} "
|
|
383
|
+
f"(evidence nodes={len(ev_nodes)})"
|
|
384
|
+
)
|
|
385
|
+
return result, ev_nodes, ev_edges
|
|
386
|
+
|
|
387
|
+
# ── 기존 경로 (고정 retrieve_evidence + verify_claim) ──
|
|
388
|
+
query = build_query(claim)
|
|
389
|
+
evidence, ev_nodes, ev_edges = await build_evidence_subgraph(
|
|
390
|
+
self.kosis, query, claim_nid,
|
|
391
|
+
)
|
|
392
|
+
logger.info(
|
|
393
|
+
f"[Agent A] Step 7 retrieve_evidence → "
|
|
394
|
+
f"{str(evidence)[:80] if evidence else None}"
|
|
395
|
+
)
|
|
396
|
+
# Action: verify_claim (Deterministic, LLM 미개입)
|
|
397
|
+
# 주의: sv2 verify_claim은 memory 파라미터를 받지 않음.
|
|
398
|
+
# 도메인 가드는 호출 후 evidence를 보고 별도로 적용한다.
|
|
399
|
+
result = verify_claim(
|
|
400
|
+
claim, evidence, self.config, graph=temporal_graph,
|
|
401
|
+
)
|
|
402
|
+
# [머지 이수민 main] 도메인 가드 — evidence가 doc 도메인과
|
|
403
|
+
# 어긋나면 UNVERIFIABLE 강등. evidence.raw_response 또는
|
|
404
|
+
# stat 메타에서 category_path를 찾는다(없으면 가드 통과).
|
|
405
|
+
_ev_cat = None
|
|
406
|
+
if evidence is not None:
|
|
407
|
+
_ev_cat = (evidence.raw_response or {}).get("category_path")
|
|
408
|
+
if (_ev_cat and not memory.domain_matches_category(_ev_cat)):
|
|
409
|
+
from structverify.core.schemas import VerdictType
|
|
410
|
+
logger.warning(
|
|
411
|
+
f"[Agent A] 도메인 가드 거절: category={_ev_cat!r} "
|
|
412
|
+
f"vs domain={memory.domain!r} → UNVERIFIABLE"
|
|
413
|
+
)
|
|
414
|
+
async with mem_lock:
|
|
415
|
+
memory.record_stat_id_rejected(
|
|
416
|
+
str(getattr(evidence, "stat_table_id", "") or "?"),
|
|
417
|
+
f"도메인 불일치: {memory.domain}",
|
|
418
|
+
)
|
|
419
|
+
result.verdict = VerdictType.UNVERIFIABLE
|
|
420
|
+
result.confidence = min(result.confidence or 0.3, 0.3)
|
|
421
|
+
# [머지 이수민 main] 성공 stat_id를 memory에 캐시
|
|
422
|
+
if (result.verdict.value == "match"
|
|
423
|
+
and evidence and getattr(evidence, "stat_table_id", None)
|
|
424
|
+
and claim.schema and claim.schema.indicator):
|
|
425
|
+
async with mem_lock:
|
|
426
|
+
memory.record_stat_id_used(
|
|
427
|
+
indicator=claim.schema.indicator,
|
|
428
|
+
stat_id=evidence.stat_table_id,
|
|
429
|
+
category_path=_ev_cat,
|
|
430
|
+
time_period=getattr(evidence, "time_period", None),
|
|
431
|
+
)
|
|
432
|
+
logger.info(f"[Agent A] Step 8 verify_claim → {result.verdict.value}")
|
|
433
|
+
return result, ev_nodes, ev_edges
|
|
434
|
+
|
|
435
|
+
# ── [Dependency Planning 2026-05-21] level 기반 실행 ──
|
|
436
|
+
# 한 문장에서 분기된 base/derived sub-claim, 또는 같은 indicator를
|
|
437
|
+
# 공유하는 claim들을 *순차 레벨*로 묶어 evidence 재활용.
|
|
438
|
+
# Level 1 (병렬): base claims
|
|
439
|
+
# Level 2 (병렬): derived_rate / derived_difference claims
|
|
440
|
+
# Level 간 verified_facts / successful_stat_ids 캐시가 살아 있어 derived가
|
|
441
|
+
# base의 fetch 결과를 자동 재활용. 같은 level 안에선 기존대로 Semaphore(3)
|
|
442
|
+
# 병렬 유지.
|
|
443
|
+
from structverify.agent.dependency_planner import build_execution_levels
|
|
444
|
+
_exec_levels = build_execution_levels(claims)
|
|
445
|
+
logger.info(
|
|
446
|
+
f"[Agent A] dependency planning: {len(_exec_levels)} levels, "
|
|
447
|
+
f"sizes={[len(lvl) for lvl in _exec_levels]}"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# claim_id → 결과 매핑 (원래 claim 순서대로 정렬 위해)
|
|
451
|
+
_results_by_id: dict[Any, tuple] = {}
|
|
452
|
+
_claim_disp_idx = {c.claim_id: i + 1 for i, c in enumerate(claims)} # 탐지 순서 표시 index
|
|
453
|
+
|
|
454
|
+
async def _run_and_emit(_c):
|
|
455
|
+
# claim 컨텍스트를 이 태스크에 심는다 → 처리 중 발생하는 모든 로그가
|
|
456
|
+
# 이 claim에 태깅됨(병렬로 섞여도 정확히 분류). 각 claim이 끝나는 순간
|
|
457
|
+
# 결과 이벤트를 쏴 카드를 실시간 갱신.
|
|
458
|
+
_idx = _claim_disp_idx.get(_c.claim_id, 0)
|
|
459
|
+
_cid = str(_c.claim_id)
|
|
460
|
+
_tok = _progress.claim_var.set({"id": _cid, "idx": _idx})
|
|
461
|
+
try:
|
|
462
|
+
_out = await process_one_claim(_c)
|
|
463
|
+
finally:
|
|
464
|
+
_progress.claim_var.reset(_tok)
|
|
465
|
+
_r = _out[0]
|
|
466
|
+
_progress.emit(
|
|
467
|
+
"claim",
|
|
468
|
+
index=_idx,
|
|
469
|
+
claim_id=_cid,
|
|
470
|
+
claim=(_c.claim_text or ""),
|
|
471
|
+
verdict=getattr(getattr(_r, "verdict", None), "value", "") or "",
|
|
472
|
+
value=getattr(_r, "fetched_value", None),
|
|
473
|
+
unit=getattr(_r, "unit", "") or "",
|
|
474
|
+
)
|
|
475
|
+
return _out
|
|
476
|
+
|
|
477
|
+
for _lvl_idx, _level_claims in enumerate(_exec_levels):
|
|
478
|
+
if not _level_claims:
|
|
479
|
+
continue
|
|
480
|
+
logger.info(
|
|
481
|
+
f"[Agent A] Level {_lvl_idx + 1}/{len(_exec_levels)}: "
|
|
482
|
+
f"{len(_level_claims)}개 claim 병렬 시작"
|
|
483
|
+
)
|
|
484
|
+
_parallel = await asyncio.gather(
|
|
485
|
+
*[_run_and_emit(c) for c in _level_claims]
|
|
486
|
+
)
|
|
487
|
+
for _c, _out in zip(_level_claims, _parallel):
|
|
488
|
+
_results_by_id[_c.claim_id] = _out
|
|
489
|
+
logger.info(
|
|
490
|
+
f"[Agent A] Level {_lvl_idx + 1} 완료 — verified_facts/"
|
|
491
|
+
f"successful_stat_ids 캐시가 다음 level로 전파됨"
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
# ── [2026-05-25 패치 X] derived prev_time prefetch ──
|
|
495
|
+
# Level 1(base) 끝난 후 Level 2+(derived)가 필요로 하는 prev_time_period
|
|
496
|
+
# 시점들을 *시스템이 미리 fetch*해 verified_facts에 저장. derived loop이
|
|
497
|
+
# 그 자리에서 캐시 적중하므로 재fetch 안 함.
|
|
498
|
+
#
|
|
499
|
+
# 배경: A 패치(reflect prompt에서 absolute claim 시 prev 시점 fetch 금지)로
|
|
500
|
+
# base claim이 prev 시점을 fetch하지 않게 됐는데, 그러면 derived가 또 직접
|
|
501
|
+
# fetch해야 함. 이전엔 LLM 헛돌이로 우연히 prev도 fetch되어 cache 풍부했음.
|
|
502
|
+
# 이제 *시스템이 명시적으로* prev 시점만 미리 잡아 둠.
|
|
503
|
+
try:
|
|
504
|
+
await self._prefetch_derived_prev_times(
|
|
505
|
+
base_claims=_level_claims,
|
|
506
|
+
future_levels=_exec_levels[_lvl_idx + 1:],
|
|
507
|
+
workspace=workspace,
|
|
508
|
+
memory=memory,
|
|
509
|
+
)
|
|
510
|
+
except Exception as _e:
|
|
511
|
+
logger.debug(f"[Agent A] derived prev prefetch 실패 (무시): {_e}")
|
|
512
|
+
|
|
513
|
+
# 원래 claim 순서대로 정렬 (results 인덱스 보존)
|
|
514
|
+
results: list[VerificationResult] = []
|
|
515
|
+
for _c in claims:
|
|
516
|
+
_result, _ev_nodes, _ev_edges = _results_by_id[_c.claim_id]
|
|
517
|
+
results.append(_result)
|
|
518
|
+
all_nodes.extend(_ev_nodes)
|
|
519
|
+
all_edges.extend(_ev_edges)
|
|
520
|
+
|
|
521
|
+
# [머지 이수민 main] working memory 통계 로깅
|
|
522
|
+
logger.info(f"[Agent A] working_memory stats: {memory.stats()}")
|
|
523
|
+
if memory.rejected_stat_ids:
|
|
524
|
+
logger.info(
|
|
525
|
+
f"[Agent A] 도메인 가드 거절 stat_id: "
|
|
526
|
+
f"{len(memory.rejected_stat_ids)}건"
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
# ── Step 8.5: Multi-hop GraphRAG 파생 주장 재검증 ──────────────
|
|
530
|
+
# Tool: graph_multihop (LLM 미사용 — 비율/배수 계산은 deterministic)
|
|
531
|
+
# Thought: "KOSIS로 직접 검증 못한 파생 주장(2.6배 등)을
|
|
532
|
+
# COMPARE 엣지 이웃들의 검증된 수치로 재계산할 수 있다"
|
|
533
|
+
# Observation: UNVERIFIABLE 파생 주장 → MATCH/MISMATCH 가능
|
|
534
|
+
results = apply_multihop_verification(
|
|
535
|
+
claims, results, all_edges, self.config
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# ── Step 9: 각 주장별 설명 생성 ────────────────────────────────
|
|
539
|
+
for claim, result in zip(claims, results):
|
|
540
|
+
result.explanation = await generate_explanation(claim, result, self.config)
|
|
541
|
+
logger.info(f"[Agent A] Step 9 generate_explanation → {len(result.explanation or '')}자")
|
|
542
|
+
|
|
543
|
+
# ── [v6.19] Step 9.5: Graph Store 영속화 (Neo4j — 옵셔널) ──────
|
|
544
|
+
# graph.store.enabled=true 면 노드/엣지를 Neo4j에 MERGE.
|
|
545
|
+
# 비활성/실패해도 save_graph 내부에서 흡수 — 검증 결과는 그대로 반환.
|
|
546
|
+
if self.graph_store.is_active():
|
|
547
|
+
try:
|
|
548
|
+
n_saved, e_saved = await self.graph_store.save_graph(
|
|
549
|
+
all_nodes, all_edges
|
|
550
|
+
)
|
|
551
|
+
logger.info(
|
|
552
|
+
f"[Agent A] Step 9.5 graph_store → "
|
|
553
|
+
f"Neo4j 저장 노드 {n_saved} / 엣지 {e_saved}"
|
|
554
|
+
)
|
|
555
|
+
except Exception as e:
|
|
556
|
+
logger.warning(f"[Agent A] graph_store 저장 실패 (무시): {e}")
|
|
557
|
+
|
|
558
|
+
logger.info(f"[Agent A] 완료: claims={len(claims)}, results={len(results)}, "
|
|
559
|
+
f"nodes={len(all_nodes)}, edges={len(all_edges)}")
|
|
560
|
+
|
|
561
|
+
# [v6.19] job 완료 후 agent_workspace 임시 디렉토리 정리.
|
|
562
|
+
# workspace.cleanup()은 정의돼 있었지만 어디서도 호출되지 않아
|
|
563
|
+
# job마다 agent_workspace/job_* 디렉토리가 무한 누적 →
|
|
564
|
+
# "No space left on device"로 agent_loop 전체가 폴백되는 장애 발생.
|
|
565
|
+
# agent.workspace.persist_after_job=true면 디버깅 위해 보존.
|
|
566
|
+
agent_cfg = self.config.get("agent") or {}
|
|
567
|
+
ws_cfg = dict(agent_cfg.get("workspace") or {})
|
|
568
|
+
if not ws_cfg.get("persist_after_job", False):
|
|
569
|
+
try:
|
|
570
|
+
from structverify.agent.workspace import build_workspace
|
|
571
|
+
# _verify_with_agent와 동일하게 claim.doc_id를 job_id로 사용
|
|
572
|
+
_job_id = ""
|
|
573
|
+
if claims:
|
|
574
|
+
_job_id = str(getattr(claims[0], "doc_id", "") or "")
|
|
575
|
+
_ws = build_workspace(job_id=_job_id or "job", config=ws_cfg)
|
|
576
|
+
_ws.cleanup()
|
|
577
|
+
except Exception as e:
|
|
578
|
+
logger.warning(f"[Agent A] workspace 정리 실패 (무시): {e}")
|
|
579
|
+
|
|
580
|
+
return claims, results, all_nodes, all_edges
|
|
581
|
+
|
|
582
|
+
# ── [Phase D] Agent Loop 경로 헬퍼 ──────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
async def _prefetch_derived_prev_times(
|
|
585
|
+
self,
|
|
586
|
+
base_claims: list,
|
|
587
|
+
future_levels: list[list],
|
|
588
|
+
workspace: Any,
|
|
589
|
+
memory: Any,
|
|
590
|
+
) -> None:
|
|
591
|
+
"""[2026-05-25 패치 X] base 레벨이 끝난 후, 후속 derived 레벨의
|
|
592
|
+
prev_time_period 시점들을 시스템이 미리 fetch해 verified_facts에 저장.
|
|
593
|
+
|
|
594
|
+
목적: derived(증가율/차이) claim의 loop이 prev 시점을 캐시에서 적중하도록.
|
|
595
|
+
이전엔 LLM 헛돌이로 base 처리 중 prev도 fetch됐었지만, A 패치 이후
|
|
596
|
+
base는 자기 시점만 정확히 fetch 함. 그래서 derived가 prev 재fetch
|
|
597
|
+
필요해진 부작용을 시스템이 명시적으로 보완.
|
|
598
|
+
|
|
599
|
+
흐름:
|
|
600
|
+
1) future_levels의 모든 derived claim에서 (indicator_base, prev_time_period,
|
|
601
|
+
population, unit) 추출 (indicator는 derived suffix strip).
|
|
602
|
+
2) workspace.lookup_verified_fact로 이미 있는지 확인.
|
|
603
|
+
3) 없으면 base claim의 successful_stat_id를 1순위로 KOSISDataSource.fetch_evidence
|
|
604
|
+
직접 호출. 결과를 workspace.append_verified_fact + sibling_evidence 저장.
|
|
605
|
+
"""
|
|
606
|
+
if not future_levels:
|
|
607
|
+
return
|
|
608
|
+
from structverify.agent.workspace import _strip_derived_suffix
|
|
609
|
+
|
|
610
|
+
# 1) prefetch 대상 수집 — 중복 제거
|
|
611
|
+
_targets: dict[tuple[str, str, str, str], dict] = {}
|
|
612
|
+
for _next_lvl in future_levels:
|
|
613
|
+
for _dc in _next_lvl:
|
|
614
|
+
_sch = getattr(_dc, "schema", None)
|
|
615
|
+
if _sch is None:
|
|
616
|
+
continue
|
|
617
|
+
_ind = (getattr(_sch, "indicator", "") or "").strip()
|
|
618
|
+
_prev_t = (getattr(_sch, "prev_time_period", "") or "").strip()
|
|
619
|
+
if not _ind or not _prev_t:
|
|
620
|
+
continue
|
|
621
|
+
_pop = (getattr(_sch, "population", "") or "").strip()
|
|
622
|
+
_unit = (getattr(_sch, "unit", "") or "").strip()
|
|
623
|
+
_ind_base = _strip_derived_suffix(_ind)
|
|
624
|
+
_key = (_ind_base, _prev_t, _pop, _unit)
|
|
625
|
+
if _key not in _targets:
|
|
626
|
+
_targets[_key] = {
|
|
627
|
+
"indicator": _ind_base,
|
|
628
|
+
"time_period": _prev_t,
|
|
629
|
+
"population": _pop,
|
|
630
|
+
"unit": _unit,
|
|
631
|
+
"sent_id": str(getattr(_dc, "sent_id", "") or ""),
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if not _targets:
|
|
635
|
+
return
|
|
636
|
+
|
|
637
|
+
# 2) 이미 캐시된 건 skip
|
|
638
|
+
_to_fetch: list[dict] = []
|
|
639
|
+
for _t in _targets.values():
|
|
640
|
+
try:
|
|
641
|
+
_hit = workspace.lookup_verified_fact(
|
|
642
|
+
_t["indicator"], _t["time_period"],
|
|
643
|
+
unit_hint=_t["unit"] or None,
|
|
644
|
+
population=_t["population"] or None,
|
|
645
|
+
)
|
|
646
|
+
except Exception:
|
|
647
|
+
_hit = None
|
|
648
|
+
if _hit is None:
|
|
649
|
+
_to_fetch.append(_t)
|
|
650
|
+
|
|
651
|
+
if not _to_fetch:
|
|
652
|
+
logger.info(
|
|
653
|
+
f"[Agent A] derived prev prefetch: {len(_targets)}개 시점 — "
|
|
654
|
+
f"모두 이미 verified_facts에 있음. skip."
|
|
655
|
+
)
|
|
656
|
+
return
|
|
657
|
+
|
|
658
|
+
logger.info(
|
|
659
|
+
f"[Agent A] derived prev prefetch 시작: {len(_to_fetch)}개 (indicator, prev_time) "
|
|
660
|
+
f"미리 fetch (예: {_to_fetch[0]['indicator']!r} {_to_fetch[0]['time_period']!r})"
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
# 3) base의 successful stat_id 1순위로 fetch
|
|
664
|
+
try:
|
|
665
|
+
_prior_stat_ids = workspace.read_successful_stat_ids() or []
|
|
666
|
+
except Exception:
|
|
667
|
+
_prior_stat_ids = []
|
|
668
|
+
if not _prior_stat_ids:
|
|
669
|
+
logger.info(
|
|
670
|
+
f"[Agent A] derived prev prefetch skip: base에서 successful_stat_id 없음."
|
|
671
|
+
)
|
|
672
|
+
return
|
|
673
|
+
|
|
674
|
+
_stat_id = _prior_stat_ids[0]
|
|
675
|
+
|
|
676
|
+
# KOSISDataSource를 *직접* 호출 (loop tool wrap 없이) — verified_facts 저장만 목적
|
|
677
|
+
_kosis_source = None
|
|
678
|
+
try:
|
|
679
|
+
from structverify.retrieval.registry import build_datasource
|
|
680
|
+
import structverify.retrieval.kosis_source # noqa: F401 — @register_datasource 트리거
|
|
681
|
+
_ds_cfg = (self.config.get("data_sources") or {}).get("kosis", {}) or {}
|
|
682
|
+
_kosis_source = build_datasource("kosis", config=_ds_cfg)
|
|
683
|
+
except Exception as _e:
|
|
684
|
+
logger.info(f"[Agent A] derived prev prefetch skip: KOSISDataSource 인스턴스 실패 — {_e}")
|
|
685
|
+
return
|
|
686
|
+
if _kosis_source is None:
|
|
687
|
+
return
|
|
688
|
+
|
|
689
|
+
for _t in _to_fetch:
|
|
690
|
+
_params = {
|
|
691
|
+
"indicator": _t["indicator"],
|
|
692
|
+
"time_period": _t["time_period"],
|
|
693
|
+
"population": _t["population"] or None,
|
|
694
|
+
"unit_hint": _t["unit"] or None,
|
|
695
|
+
}
|
|
696
|
+
try:
|
|
697
|
+
_ev = await _kosis_source.fetch_evidence(
|
|
698
|
+
candidate_id=_stat_id, params=_params, workspace=workspace,
|
|
699
|
+
)
|
|
700
|
+
except Exception as _e:
|
|
701
|
+
logger.debug(
|
|
702
|
+
f"[Agent A] derived prev prefetch fetch 실패: {_t} stat={_stat_id} — {_e}"
|
|
703
|
+
)
|
|
704
|
+
continue
|
|
705
|
+
if _ev is None:
|
|
706
|
+
continue
|
|
707
|
+
_val = _ev.get("value") if hasattr(_ev, "get") else getattr(_ev, "value", None)
|
|
708
|
+
if _val is None:
|
|
709
|
+
continue
|
|
710
|
+
|
|
711
|
+
# verified_facts에 저장
|
|
712
|
+
_fact = {
|
|
713
|
+
"indicator": _t["indicator"],
|
|
714
|
+
"time_period": _t["time_period"],
|
|
715
|
+
"population": _t["population"] or "",
|
|
716
|
+
"value": _val,
|
|
717
|
+
"unit": (
|
|
718
|
+
_ev.get("unit") if hasattr(_ev, "get") else getattr(_ev, "unit", "")
|
|
719
|
+
) or "",
|
|
720
|
+
"source": f"KOSIS:{_stat_id}",
|
|
721
|
+
"claim_id": "prefetch",
|
|
722
|
+
"verdict": "prefetch",
|
|
723
|
+
}
|
|
724
|
+
try:
|
|
725
|
+
workspace.append_verified_fact(_fact)
|
|
726
|
+
# sibling_evidence에도 같은 sent_id로 박아 두면 derived loop이 즉시 활용
|
|
727
|
+
if _t["sent_id"]:
|
|
728
|
+
workspace.record_sibling_evidence(
|
|
729
|
+
sent_id=_t["sent_id"], role="base", evidence=_fact,
|
|
730
|
+
)
|
|
731
|
+
logger.info(
|
|
732
|
+
f"[Agent A] derived prev prefetch 성공: "
|
|
733
|
+
f"indicator={_t['indicator']!r} time={_t['time_period']!r} "
|
|
734
|
+
f"value={_val} (stat_id={_stat_id})"
|
|
735
|
+
)
|
|
736
|
+
except Exception as _e:
|
|
737
|
+
logger.debug(f"[Agent A] derived prev prefetch verified_fact 저장 실패: {_e}")
|
|
738
|
+
|
|
739
|
+
def _get_source_text(self, sir_doc: "SIRDocument") -> str:
|
|
740
|
+
"""SIR 문서에서 원문 텍스트 복원 — planner의 source_text로 사용."""
|
|
741
|
+
parts: list[str] = []
|
|
742
|
+
for block in getattr(sir_doc, "blocks", []) or []:
|
|
743
|
+
for sent in getattr(block, "sentences", []) or []:
|
|
744
|
+
t = getattr(sent, "text", None)
|
|
745
|
+
if t:
|
|
746
|
+
parts.append(t)
|
|
747
|
+
return " ".join(parts)
|
|
748
|
+
|
|
749
|
+
async def _verify_with_agent(
|
|
750
|
+
self,
|
|
751
|
+
claim: "Claim",
|
|
752
|
+
source_text: str,
|
|
753
|
+
anchor_year: "int | None",
|
|
754
|
+
temporal_graph: "ClaimGraph | None",
|
|
755
|
+
claim_nid: str,
|
|
756
|
+
memory: "DocumentWorkingMemory | None" = None,
|
|
757
|
+
mem_lock=None,
|
|
758
|
+
) -> "tuple[VerificationResult, list[GraphNode], list[GraphEdge]]":
|
|
759
|
+
"""
|
|
760
|
+
[Phase D] planner + agent_loop 으로 claim 1건 검증.
|
|
761
|
+
|
|
762
|
+
ReAct:
|
|
763
|
+
Thought → planner.plan() 이 Plan(검증 전략 + 단계) 수립
|
|
764
|
+
Action → agent_loop 이 catalog_search → fetch_evidence 순회
|
|
765
|
+
Observation → 각 step 결과 누적
|
|
766
|
+
→ AgentVerdict 산출 → VerificationResult 변환
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
(VerificationResult, evidence GraphNode 리스트, GraphEdge 리스트)
|
|
770
|
+
— [v6.19] evidence를 그래프에 박아 multihop 재검증이 agent 경로에서도
|
|
771
|
+
동작하게 함. 검증 실패해도 노드는 빈 리스트로 반환(에러 아님).
|
|
772
|
+
|
|
773
|
+
실패 시 기존 경로(retrieve_evidence + verify_claim)로 폴백.
|
|
774
|
+
"""
|
|
775
|
+
from structverify.agent.planner import Planner, PlannerConfig
|
|
776
|
+
from structverify.agent.loop import agent_loop, LoopConfig
|
|
777
|
+
from structverify.agent.reflect import ReflectAgent, ReflectConfig
|
|
778
|
+
from structverify.agent.workspace import build_workspace
|
|
779
|
+
|
|
780
|
+
agent_cfg = self.config.get("agent") or {}
|
|
781
|
+
llm_cfg = self.config.get("llm") or {}
|
|
782
|
+
|
|
783
|
+
try:
|
|
784
|
+
# 1) workspace 준비
|
|
785
|
+
# [2026-05-21] scope에 따라 workspace 격리 단위 결정:
|
|
786
|
+
# - "doc_hash" (default): claim.doc_id (= md5(raw_text))
|
|
787
|
+
# 같은 본문이면 캐시 공유. KOSIS fetch 재사용으로 빠름.
|
|
788
|
+
# - "job_id" : ws_cfg.external_job_id (sv_platform이 set) 또는 fresh UUID.
|
|
789
|
+
# 매 요청 cold start로 정확성↑.
|
|
790
|
+
ws_cfg = dict(agent_cfg.get("workspace") or {})
|
|
791
|
+
_ws_scope = str(ws_cfg.get("scope") or "doc_hash").strip().lower()
|
|
792
|
+
if _ws_scope == "job_id":
|
|
793
|
+
_external = ws_cfg.get("external_job_id")
|
|
794
|
+
if _external:
|
|
795
|
+
_ws_job_id = str(_external)
|
|
796
|
+
else:
|
|
797
|
+
from uuid import uuid4 as _uuid4
|
|
798
|
+
_ws_job_id = str(_uuid4())
|
|
799
|
+
logger.info(
|
|
800
|
+
f"[Agent A] workspace scope='job_id'인데 external_job_id 미제공 "
|
|
801
|
+
f"→ fresh UUID 생성: {_ws_job_id}"
|
|
802
|
+
)
|
|
803
|
+
else:
|
|
804
|
+
# "doc_hash" — 기존 동작 (text-hash 기반 캐시 재사용)
|
|
805
|
+
_ws_job_id = str(getattr(claim, "doc_id", "") or "job")
|
|
806
|
+
workspace = build_workspace(
|
|
807
|
+
job_id=_ws_job_id,
|
|
808
|
+
config=ws_cfg,
|
|
809
|
+
)
|
|
810
|
+
# [P23 2026-05-22] is_initialized 체크 제거 — initialize가 idempotent.
|
|
811
|
+
# source.txt를 매번 raw_text로 덮어씀 (stale 방지). meta는 변경 없음.
|
|
812
|
+
workspace.initialize(source_text=source_text or "")
|
|
813
|
+
workspace.create_claim_dir(
|
|
814
|
+
claim.claim_id, claim_data=claim.model_dump(mode="json")
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
# 2) DataSource 등록 — config.data_sources.enabled 기반 (#66)
|
|
818
|
+
datasources = self._build_datasources()
|
|
819
|
+
|
|
820
|
+
# 3) Planner LLM wiring — 기존 LLMClient 재사용
|
|
821
|
+
from structverify.utils.llm_client import LLMClient
|
|
822
|
+
plan_llm = LLMClient(config=llm_cfg)
|
|
823
|
+
|
|
824
|
+
# [2026-05-27] LLM 원본 입출력을 workspace에 영구 저장 → 프론트가
|
|
825
|
+
# "AI 콘솔" 탭에서 lazy fetch. 한 LLM 호출당 1 JSON 파일.
|
|
826
|
+
# 사이즈 안전망: prompt/response 각각 60KB로 head/tail cap.
|
|
827
|
+
def _save_llm_trace(name: str, prompt: str, response: str) -> None:
|
|
828
|
+
try:
|
|
829
|
+
payload = {
|
|
830
|
+
"name": name,
|
|
831
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
832
|
+
"prompt": _cap_text(prompt, 60_000),
|
|
833
|
+
"response": _cap_text(response, 60_000),
|
|
834
|
+
"prompt_chars": len(prompt or ""),
|
|
835
|
+
"response_chars": len(response or ""),
|
|
836
|
+
}
|
|
837
|
+
workspace.write_observation(
|
|
838
|
+
claim.claim_id,
|
|
839
|
+
name=f"llm_traces/{name}",
|
|
840
|
+
data=payload,
|
|
841
|
+
)
|
|
842
|
+
except Exception as _e:
|
|
843
|
+
logger.debug(f"[runtime_agent] llm_trace 저장 실패 ({name}): {_e}")
|
|
844
|
+
|
|
845
|
+
async def llm_call_for_plan(prompt: str) -> str:
|
|
846
|
+
_resp = await plan_llm.generate(
|
|
847
|
+
prompt=prompt,
|
|
848
|
+
system_prompt="검증 계획 수립 전문가. JSON으로만 답하세요.",
|
|
849
|
+
)
|
|
850
|
+
_save_llm_trace("planner", prompt, _resp or "")
|
|
851
|
+
return _resp
|
|
852
|
+
|
|
853
|
+
planner = Planner(
|
|
854
|
+
llm_call=llm_call_for_plan,
|
|
855
|
+
config=PlannerConfig(
|
|
856
|
+
model=(llm_cfg.get("plan_model") or "HCX-007"),
|
|
857
|
+
temperature=0.1,
|
|
858
|
+
),
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
# 4) Plan 생성 (ReAct Thought)
|
|
862
|
+
plan = await planner.plan(
|
|
863
|
+
claim, source_text=source_text, anchor_year=anchor_year
|
|
864
|
+
)
|
|
865
|
+
workspace.write_plan(claim.claim_id, plan.model_dump(mode="json"))
|
|
866
|
+
logger.info(
|
|
867
|
+
f"[planner] {claim.claim_id}: Plan 수립 "
|
|
868
|
+
f"type={getattr(plan, 'claim_type', None)} "
|
|
869
|
+
f"steps={len(getattr(plan, 'initial_steps', []) or [])}"
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
# 5) Agent Loop 실행 (ReAct Action/Observation)
|
|
873
|
+
loop_cfg = agent_cfg.get("loop") or {}
|
|
874
|
+
loop_mode = str(loop_cfg.get("mode", "deterministic")).strip()
|
|
875
|
+
max_iter = int(loop_cfg.get("max_iterations", 10))
|
|
876
|
+
|
|
877
|
+
# [reflect 활성화] mode='reflect'면 ReflectAgent를 loop에 주입.
|
|
878
|
+
# 매 iter LLM이 last_observation을 보고 다음 action을 동적
|
|
879
|
+
# 결정(catalog 결과 부적합 시 검색어 바꿔 재검색, 원문 재독 등).
|
|
880
|
+
# ReflectAgent는 파싱 실패 시 None을 반환하고, loop은 그 경우
|
|
881
|
+
# plan의 다음 step으로 deterministic fallback → 안전.
|
|
882
|
+
# mode='deterministic'이면 reflect_fn=None (기존 동작 유지).
|
|
883
|
+
reflect_fn = None
|
|
884
|
+
if loop_mode == "reflect":
|
|
885
|
+
# iter별로 파일명이 겹치지 않게 counter — Reflect는 매 iter 호출됨.
|
|
886
|
+
_reflect_call_counter = {"n": 0}
|
|
887
|
+
|
|
888
|
+
async def llm_call_for_reflect(prompt: str) -> str:
|
|
889
|
+
_reflect_call_counter["n"] += 1
|
|
890
|
+
_n = _reflect_call_counter["n"]
|
|
891
|
+
_resp = await plan_llm.generate(
|
|
892
|
+
prompt=prompt,
|
|
893
|
+
system_prompt=(
|
|
894
|
+
"당신은 사실검증 ReAct 에이전트입니다. "
|
|
895
|
+
"지금까지의 관찰 결과를 보고 다음 action을 "
|
|
896
|
+
"JSON으로만 답하세요."
|
|
897
|
+
),
|
|
898
|
+
)
|
|
899
|
+
_save_llm_trace(f"reflect_call_{_n:02d}", prompt, _resp or "")
|
|
900
|
+
return _resp
|
|
901
|
+
|
|
902
|
+
reflect_fn = ReflectAgent(
|
|
903
|
+
llm_call=llm_call_for_reflect,
|
|
904
|
+
claim=claim,
|
|
905
|
+
config=ReflectConfig(),
|
|
906
|
+
max_iterations=max_iter,
|
|
907
|
+
)
|
|
908
|
+
logger.info(
|
|
909
|
+
f"[planner] {claim.claim_id}: reflect 모드 활성화 "
|
|
910
|
+
f"(max_iter={max_iter}) — 매 iter LLM 재계획"
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
verdict = await agent_loop(
|
|
914
|
+
plan=plan,
|
|
915
|
+
claim=claim,
|
|
916
|
+
workspace=workspace,
|
|
917
|
+
datasources=datasources,
|
|
918
|
+
config=self.config,
|
|
919
|
+
reflect_fn=reflect_fn,
|
|
920
|
+
loop_config=LoopConfig(
|
|
921
|
+
max_iterations=max_iter,
|
|
922
|
+
mode=loop_mode,
|
|
923
|
+
),
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
# 6) AgentVerdict → VerificationResult 변환
|
|
927
|
+
# [v6.17] agent_loop이 검증에 쓴 KOSIS 데이터(data_points)를
|
|
928
|
+
# Evidence로 복원 → UI '공식 통계 출처' 박스에 표시됨.
|
|
929
|
+
# [2026-05-21 P7] primary/supporting 분리:
|
|
930
|
+
# - primary: claim.schema.time_period와 매칭되는 시점의 fetch
|
|
931
|
+
# - supporting: derived claim에서 함께 쓰인 *다른 시점* fetch
|
|
932
|
+
# (예: 차이/증가율 검증의 prev 시점)
|
|
933
|
+
# base claim은 supporting 비움 (헛돌이 prev fetch 노이즈 제거).
|
|
934
|
+
agent_evidence = None
|
|
935
|
+
supporting_evidence: list[Evidence] = []
|
|
936
|
+
_ev_category = None # 도메인 가드용 — Evidence 스키마엔 없는 필드
|
|
937
|
+
|
|
938
|
+
def _parse_stat_id(src: str | None) -> str | None:
|
|
939
|
+
if not src:
|
|
940
|
+
return None
|
|
941
|
+
s = str(src).strip()
|
|
942
|
+
if ":" in s:
|
|
943
|
+
return s.split(":", 1)[1].strip() or None
|
|
944
|
+
return s or None
|
|
945
|
+
|
|
946
|
+
def _norm_time(t: str | None) -> str:
|
|
947
|
+
return str(t or "").replace("-", "").replace(".", "").strip()
|
|
948
|
+
|
|
949
|
+
# ── 1) 후보 evidence 수집 (data_points + workspace fetch obs + sibling_evidence) ──
|
|
950
|
+
_claim_target_time = (
|
|
951
|
+
getattr(claim.schema, "time_period", None) if claim.schema else None
|
|
952
|
+
)
|
|
953
|
+
_claim_role = (
|
|
954
|
+
getattr(claim.schema, "value_role", None) if claim.schema else None
|
|
955
|
+
) or ""
|
|
956
|
+
_is_derived = _claim_role in ("derived_rate", "derived_difference")
|
|
957
|
+
_claim_sent_id = str(getattr(claim, "sent_id", "") or "").strip()
|
|
958
|
+
|
|
959
|
+
candidates: list[dict] = []
|
|
960
|
+
dps = getattr(verdict, "data_points", None) or []
|
|
961
|
+
for _dp in dps:
|
|
962
|
+
_val = getattr(_dp, "resolved_value", None)
|
|
963
|
+
if _val is None:
|
|
964
|
+
continue
|
|
965
|
+
_sid = _parse_stat_id(getattr(_dp, "source", None))
|
|
966
|
+
_t = (getattr(_dp, "source_time", None) or getattr(_dp, "time", "") or "")
|
|
967
|
+
candidates.append({
|
|
968
|
+
"stat_id": _sid,
|
|
969
|
+
"value": _val,
|
|
970
|
+
"unit": getattr(_dp, "resolved_unit", None),
|
|
971
|
+
"time": str(_t),
|
|
972
|
+
"category": getattr(_dp, "category_path", None),
|
|
973
|
+
"origin": "data_point",
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
# workspace의 모든 fetch observation에서 evidence 수집 (헛돌이 fetch도 포함됨)
|
|
977
|
+
try:
|
|
978
|
+
for _obs_name in workspace.list_observations(claim.claim_id):
|
|
979
|
+
if "fetch" not in _obs_name.lower():
|
|
980
|
+
continue
|
|
981
|
+
_obs = workspace.read_observation(claim.claim_id, _obs_name)
|
|
982
|
+
if not isinstance(_obs, dict):
|
|
983
|
+
continue
|
|
984
|
+
_ev = (_obs.get("output") or {}).get("evidence") or _obs.get("evidence") or {}
|
|
985
|
+
if not isinstance(_ev, dict):
|
|
986
|
+
continue
|
|
987
|
+
_val = _ev.get("value")
|
|
988
|
+
if _val is None:
|
|
989
|
+
continue
|
|
990
|
+
candidates.append({
|
|
991
|
+
"stat_id": _ev.get("stat_table_id") or None,
|
|
992
|
+
"value": _val,
|
|
993
|
+
"unit": _ev.get("unit") or None,
|
|
994
|
+
"time": str(_ev.get("time_period") or ""),
|
|
995
|
+
"category": _ev.get("category_path") or None,
|
|
996
|
+
"origin": "fetch_obs",
|
|
997
|
+
})
|
|
998
|
+
except Exception as _e:
|
|
999
|
+
logger.debug(f"[Agent A] fetch observation 수집 실패 (무시): {_e}")
|
|
1000
|
+
|
|
1001
|
+
# derived claim은 sibling_evidence의 base 결과(=current 값)도 후보로 합침
|
|
1002
|
+
if _is_derived and _claim_sent_id and hasattr(workspace, "read_sibling_evidence"):
|
|
1003
|
+
try:
|
|
1004
|
+
for _s in (workspace.read_sibling_evidence(_claim_sent_id) or []):
|
|
1005
|
+
if not isinstance(_s, dict):
|
|
1006
|
+
continue
|
|
1007
|
+
if _s.get("role") != "base":
|
|
1008
|
+
continue
|
|
1009
|
+
_val = _s.get("value")
|
|
1010
|
+
if _val is None:
|
|
1011
|
+
continue
|
|
1012
|
+
candidates.append({
|
|
1013
|
+
"stat_id": _parse_stat_id(_s.get("source")),
|
|
1014
|
+
"value": _val,
|
|
1015
|
+
"unit": _s.get("unit") or None,
|
|
1016
|
+
"time": str(_s.get("time_period") or ""),
|
|
1017
|
+
"category": None,
|
|
1018
|
+
"origin": "sibling_base",
|
|
1019
|
+
})
|
|
1020
|
+
except Exception as _e:
|
|
1021
|
+
logger.debug(f"[Agent A] sibling_evidence 수집 실패 (무시): {_e}")
|
|
1022
|
+
|
|
1023
|
+
# (stat_id, time) 기준 중복 제거 — 같은 fetch 여러 번 박힌 경우
|
|
1024
|
+
_seen = set()
|
|
1025
|
+
_deduped: list[dict] = []
|
|
1026
|
+
for _c in candidates:
|
|
1027
|
+
_key = (str(_c.get("stat_id") or ""), _norm_time(_c.get("time")))
|
|
1028
|
+
if _key in _seen:
|
|
1029
|
+
continue
|
|
1030
|
+
_seen.add(_key)
|
|
1031
|
+
_deduped.append(_c)
|
|
1032
|
+
|
|
1033
|
+
# ── 2) primary 선택: claim.schema.time_period 매칭 우선 ──
|
|
1034
|
+
_primary: dict | None = None
|
|
1035
|
+
if _claim_target_time and _deduped:
|
|
1036
|
+
_tnorm = _norm_time(_claim_target_time)
|
|
1037
|
+
for _c in _deduped:
|
|
1038
|
+
if _norm_time(_c.get("time")) == _tnorm:
|
|
1039
|
+
_primary = _c
|
|
1040
|
+
break
|
|
1041
|
+
if _primary is None and _deduped:
|
|
1042
|
+
_primary = _deduped[0] # 시점 매칭 실패 시 첫 후보로 폴백
|
|
1043
|
+
|
|
1044
|
+
if _primary is not None:
|
|
1045
|
+
_ev_category = _primary.get("category")
|
|
1046
|
+
agent_evidence = Evidence(
|
|
1047
|
+
source_name="KOSIS",
|
|
1048
|
+
stat_table_id=_primary.get("stat_id"),
|
|
1049
|
+
official_value=_primary.get("value"),
|
|
1050
|
+
unit=_primary.get("unit"),
|
|
1051
|
+
time_period=_primary.get("time") or None,
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
# ── 3) supporting: derived claim에만 — primary 외 후보 ──
|
|
1055
|
+
# [2026-05-25] supporting은 *claim이 의도한 시점*에만 한정.
|
|
1056
|
+
# 기존엔 workspace의 모든 fetch_obs를 supporting에 덤프해서, 4월 증가율
|
|
1057
|
+
# claim 검증 중 시도된 3월/5월 fetch가 "함께 참조한 데이터"로 노출됨
|
|
1058
|
+
# → UI 노이즈. claim.time_period + claim.prev_time_period 두 시점에
|
|
1059
|
+
# 매칭되는 evidence만 인정.
|
|
1060
|
+
_relevant_times: set[str] = set()
|
|
1061
|
+
if _claim_target_time:
|
|
1062
|
+
_relevant_times.add(_norm_time(_claim_target_time))
|
|
1063
|
+
if claim.schema and getattr(claim.schema, "prev_time_period", None):
|
|
1064
|
+
_relevant_times.add(_norm_time(claim.schema.prev_time_period))
|
|
1065
|
+
|
|
1066
|
+
def _time_is_relevant(t: str) -> bool:
|
|
1067
|
+
"""_relevant_times에 정확/prefix 매칭되면 True. 빈 set면 모두 허용 (보수)."""
|
|
1068
|
+
if not _relevant_times:
|
|
1069
|
+
return True
|
|
1070
|
+
tn = _norm_time(t)
|
|
1071
|
+
if not tn:
|
|
1072
|
+
return False
|
|
1073
|
+
for rt in _relevant_times:
|
|
1074
|
+
if not rt:
|
|
1075
|
+
continue
|
|
1076
|
+
if tn == rt or tn.startswith(rt) or rt.startswith(tn):
|
|
1077
|
+
return True
|
|
1078
|
+
return False
|
|
1079
|
+
|
|
1080
|
+
if _is_derived and _primary is not None:
|
|
1081
|
+
for _c in _deduped:
|
|
1082
|
+
if _c is _primary:
|
|
1083
|
+
continue
|
|
1084
|
+
if not _time_is_relevant(_c.get("time", "")):
|
|
1085
|
+
logger.debug(
|
|
1086
|
+
f"[Agent A] supporting 제외 (claim 의도 시점 불일치): "
|
|
1087
|
+
f"time={_c.get('time')!r} not in {_relevant_times}"
|
|
1088
|
+
)
|
|
1089
|
+
continue
|
|
1090
|
+
supporting_evidence.append(Evidence(
|
|
1091
|
+
source_name="KOSIS",
|
|
1092
|
+
stat_table_id=_c.get("stat_id"),
|
|
1093
|
+
official_value=_c.get("value"),
|
|
1094
|
+
unit=_c.get("unit"),
|
|
1095
|
+
time_period=_c.get("time") or None,
|
|
1096
|
+
))
|
|
1097
|
+
|
|
1098
|
+
if agent_evidence is not None:
|
|
1099
|
+
logger.info(
|
|
1100
|
+
f"[Agent A] {claim.claim_id}: evidence 분리 — "
|
|
1101
|
+
f"primary(time={agent_evidence.time_period}, "
|
|
1102
|
+
f"value={agent_evidence.official_value}), "
|
|
1103
|
+
f"supporting={len(supporting_evidence)}건 "
|
|
1104
|
+
f"(role={_claim_role!r}, claim_target_time={_claim_target_time!r})"
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
# [머지 이수민 main] 도메인 가드 — agent 경로에도 적용.
|
|
1108
|
+
# data_point의 category_path가 doc 도메인과 어긋나면(예: 인구
|
|
1109
|
+
# 기사인데 환경 통계표) verdict를 UNVERIFIABLE로 강등.
|
|
1110
|
+
# agent_loop의 표 관련성 체크와 별개의 doc-레벨 안전망.
|
|
1111
|
+
if (memory is not None and agent_evidence is not None
|
|
1112
|
+
and _ev_category):
|
|
1113
|
+
if not memory.domain_matches_category(_ev_category):
|
|
1114
|
+
from structverify.core.schemas import VerdictType
|
|
1115
|
+
logger.warning(
|
|
1116
|
+
f"[Agent A] 도메인 가드 거절: claim={claim.claim_id} "
|
|
1117
|
+
f"category={_ev_category!r} "
|
|
1118
|
+
f"vs domain={memory.domain!r} → UNVERIFIABLE 강등"
|
|
1119
|
+
)
|
|
1120
|
+
if mem_lock is not None:
|
|
1121
|
+
async with mem_lock:
|
|
1122
|
+
memory.record_stat_id_rejected(
|
|
1123
|
+
str(agent_evidence.stat_table_id or "?"),
|
|
1124
|
+
f"도메인 불일치: {memory.domain}",
|
|
1125
|
+
)
|
|
1126
|
+
verdict.verdict = VerdictType.UNVERIFIABLE
|
|
1127
|
+
verdict.confidence = min(
|
|
1128
|
+
getattr(verdict, "confidence", 0.3) or 0.3, 0.3
|
|
1129
|
+
)
|
|
1130
|
+
agent_evidence = None # 그래프에도 박지 않음
|
|
1131
|
+
|
|
1132
|
+
# [머지 이수민 main] MATCH면 성공 stat_id를 memory에 캐시
|
|
1133
|
+
if (memory is not None and agent_evidence is not None
|
|
1134
|
+
and getattr(verdict.verdict, "value", "") == "match"
|
|
1135
|
+
and getattr(agent_evidence, "stat_table_id", None)
|
|
1136
|
+
and claim.schema and claim.schema.indicator):
|
|
1137
|
+
if mem_lock is not None:
|
|
1138
|
+
async with mem_lock:
|
|
1139
|
+
memory.record_stat_id_used(
|
|
1140
|
+
indicator=claim.schema.indicator,
|
|
1141
|
+
stat_id=agent_evidence.stat_table_id,
|
|
1142
|
+
category_path=_ev_category,
|
|
1143
|
+
time_period=getattr(
|
|
1144
|
+
agent_evidence, "time_period", None
|
|
1145
|
+
),
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
# [v6.19] Evidence → 그래프 노드/엣지 (multihop 재검증 입력)
|
|
1149
|
+
ev_nodes, ev_edges = _evidence_to_graph(agent_evidence, claim_nid)
|
|
1150
|
+
|
|
1151
|
+
result = VerificationResult(
|
|
1152
|
+
claim_id=claim.claim_id,
|
|
1153
|
+
verdict=verdict.verdict,
|
|
1154
|
+
confidence=verdict.confidence,
|
|
1155
|
+
explanation=verdict.explanation,
|
|
1156
|
+
evidence=agent_evidence,
|
|
1157
|
+
supporting_evidence=supporting_evidence,
|
|
1158
|
+
)
|
|
1159
|
+
return result, ev_nodes, ev_edges
|
|
1160
|
+
|
|
1161
|
+
except Exception as e:
|
|
1162
|
+
# Agent 경로 실패 → 기존 경로로 안전 폴백
|
|
1163
|
+
logger.warning(
|
|
1164
|
+
f"[Agent A] agent_loop 실패 → 기존 경로 폴백: {e}"
|
|
1165
|
+
)
|
|
1166
|
+
query = build_query(claim)
|
|
1167
|
+
evidence, fb_nodes, fb_edges = await build_evidence_subgraph(
|
|
1168
|
+
self.kosis, query, claim_nid,
|
|
1169
|
+
)
|
|
1170
|
+
fb_result = verify_claim(
|
|
1171
|
+
claim, evidence, self.config, graph=temporal_graph
|
|
1172
|
+
)
|
|
1173
|
+
return fb_result, fb_nodes, fb_edges
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
# ── [v6.19] Evidence → 그래프 노드/엣지 변환 ────────────────────────────────
|
|
1177
|
+
|
|
1178
|
+
def _evidence_to_graph(
|
|
1179
|
+
evidence: "Evidence | None", claim_nid: str,
|
|
1180
|
+
) -> "tuple[list[GraphNode], list[GraphEdge]]":
|
|
1181
|
+
"""agent 경로가 얻은 Evidence를 그래프 노드/엣지로 변환.
|
|
1182
|
+
|
|
1183
|
+
기존 경로의 build_evidence_subgraph와 동일한 모양:
|
|
1184
|
+
EVIDENCE 노드 1개 + (claim ─VERIFIED_BY→ evidence) 엣지 1개
|
|
1185
|
+
이렇게 박아야 Step 8.5 multihop이 COMPARE 이웃의 검증 수치를
|
|
1186
|
+
그래프에서 찾아 파생 주장을 재검증할 수 있다.
|
|
1187
|
+
|
|
1188
|
+
evidence가 None이거나 official_value가 없으면 빈 리스트
|
|
1189
|
+
(검증 근거가 없으므로 그래프에 박을 것도 없음).
|
|
1190
|
+
"""
|
|
1191
|
+
if evidence is None or getattr(evidence, "official_value", None) is None:
|
|
1192
|
+
return [], []
|
|
1193
|
+
|
|
1194
|
+
stat_id = getattr(evidence, "stat_table_id", None) or "unknown"
|
|
1195
|
+
ev_node_id = f"evidence:{claim_nid}:{stat_id}"
|
|
1196
|
+
ev_node = GraphNode(
|
|
1197
|
+
node_id=ev_node_id,
|
|
1198
|
+
node_type=GraphNodeType.EVIDENCE,
|
|
1199
|
+
label=f"{evidence.source_name or 'KOSIS'}({stat_id})",
|
|
1200
|
+
properties={
|
|
1201
|
+
"official_value": evidence.official_value,
|
|
1202
|
+
"unit": getattr(evidence, "unit", None),
|
|
1203
|
+
"time_period": getattr(evidence, "time_period", None),
|
|
1204
|
+
"stat_table_id": stat_id,
|
|
1205
|
+
"source_name": evidence.source_name,
|
|
1206
|
+
},
|
|
1207
|
+
)
|
|
1208
|
+
ev_edge = GraphEdge(
|
|
1209
|
+
from_node=claim_nid,
|
|
1210
|
+
to_node=ev_node_id,
|
|
1211
|
+
edge_type=GraphEdgeType.VERIFIED_BY,
|
|
1212
|
+
)
|
|
1213
|
+
return [ev_node], [ev_edge]
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
# ── [v4 김예슬] Context Window 헬퍼 ─────────────────────────────────────────
|
|
1217
|
+
|
|
1218
|
+
def _get_context_window(
|
|
1219
|
+
claim: "Claim",
|
|
1220
|
+
sir_doc: "SIRDocument",
|
|
1221
|
+
window: int = 2,
|
|
1222
|
+
) -> str:
|
|
1223
|
+
"""
|
|
1224
|
+
claim의 앞 문장 window개를 SIR Tree에서 가져와서 context 문자열로 반환.
|
|
1225
|
+
|
|
1226
|
+
[v4 김예슬 - 2026-05-07]
|
|
1227
|
+
"이는 20년 새 2.6배 증가한 것이다" 같은 문장은
|
|
1228
|
+
앞 문장 "2024년 쉬었음 청년이 21만7천명이다"가 있어야 정확한 schema 추출 가능.
|
|
1229
|
+
|
|
1230
|
+
SIR Tree의 block_id/sent_id를 기반으로 같은 블록 내 앞 문장들을 찾음.
|
|
1231
|
+
NEXT_SENT 엣지를 graph에서 탐색하지 않고 sir_doc에서 직접 조회 (더 효율적).
|
|
1232
|
+
|
|
1233
|
+
Args:
|
|
1234
|
+
claim: 대상 주장
|
|
1235
|
+
sir_doc: SIR 문서 (blocks → sentences 계층)
|
|
1236
|
+
window: 앞에서 가져올 문장 수 (기본 2)
|
|
1237
|
+
|
|
1238
|
+
Returns:
|
|
1239
|
+
"앞문장1. 앞문장2. 현재문장" 형태의 context 문자열
|
|
1240
|
+
"""
|
|
1241
|
+
target_block = claim.block_id
|
|
1242
|
+
target_sent = claim.sent_id
|
|
1243
|
+
|
|
1244
|
+
# sir_doc에서 해당 블록 찾기
|
|
1245
|
+
block = None
|
|
1246
|
+
for b in sir_doc.blocks:
|
|
1247
|
+
if b.block_id == target_block:
|
|
1248
|
+
block = b
|
|
1249
|
+
break
|
|
1250
|
+
|
|
1251
|
+
if not block or not block.sentences:
|
|
1252
|
+
return claim.claim_text
|
|
1253
|
+
|
|
1254
|
+
# 현재 문장 인덱스 찾기
|
|
1255
|
+
sent_idx = None
|
|
1256
|
+
for i, sent in enumerate(block.sentences):
|
|
1257
|
+
if sent.sent_id == target_sent:
|
|
1258
|
+
sent_idx = i
|
|
1259
|
+
break
|
|
1260
|
+
|
|
1261
|
+
if sent_idx is None:
|
|
1262
|
+
return claim.claim_text
|
|
1263
|
+
|
|
1264
|
+
# 앞 window개 문장 수집
|
|
1265
|
+
start = max(0, sent_idx - window)
|
|
1266
|
+
context_sents = []
|
|
1267
|
+
for i in range(start, sent_idx + 1):
|
|
1268
|
+
text = block.sentences[i].text.strip()
|
|
1269
|
+
if text:
|
|
1270
|
+
context_sents.append(text)
|
|
1271
|
+
|
|
1272
|
+
return " ".join(context_sents)
|