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,922 @@
|
|
|
1
|
+
"""
|
|
2
|
+
structverify.agent.tools.fetch_evidence — 데이터 조회 Tool.
|
|
3
|
+
|
|
4
|
+
catalog_search로 *후보 발견* → fetch_evidence로 *실제 수치 조회*.
|
|
5
|
+
|
|
6
|
+
작동:
|
|
7
|
+
1. context.datasources에서 source 선택 (catalog_search와 동일 source 권장)
|
|
8
|
+
2. context.claim.schema에서 indicator/time_period/population/unit 추출해 params 보강
|
|
9
|
+
3. source.fetch_evidence(candidate_id, params) 호출
|
|
10
|
+
4. EvidenceData 반환 + workspace observation 저장
|
|
11
|
+
|
|
12
|
+
source-specific 파라미터:
|
|
13
|
+
- KOSIS: {"prdSe": "M", "startPrdDe": "202504", "endPrdDe": "202504", ...}
|
|
14
|
+
- Custom CSV: {"row_filter": "month=4 AND year=2025", "column": "births"}
|
|
15
|
+
- 외부 API: provider별 다름
|
|
16
|
+
|
|
17
|
+
Agent는 *params를 모르면 빈 dict {}로 호출*. claim.schema에서 자동 보강됨.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from structverify.utils.logger import get_logger
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from ..schemas import ActionType
|
|
25
|
+
from .base import ToolBase, ToolContext, ToolResult, register_tool
|
|
26
|
+
|
|
27
|
+
logger = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _autoload_fallback_candidate_ids(
|
|
31
|
+
workspace, claim_id, current_id: str, limit: int = 4
|
|
32
|
+
) -> list[str]:
|
|
33
|
+
"""LLM이 input에 `_candidate_fallbacks`를 안 넘긴 경우, workspace의
|
|
34
|
+
가장 최근 catalog_search observation들에서 다른 후보 ids를 자동 추출.
|
|
35
|
+
|
|
36
|
+
이 자동 주입이 없으면 fetch_evidence가 top 후보 1개만 시도하고
|
|
37
|
+
실패하면 reflect가 catalog_search를 반복 호출 → 중복차단 → 강제
|
|
38
|
+
unverifiable로 죽음. 후보 5개 중 정확한 표가 2~5순위에 있으면
|
|
39
|
+
영영 도달 못 함.
|
|
40
|
+
|
|
41
|
+
[패치 C] 현재 claim의 catalog observation에서 충분한 후보를 못
|
|
42
|
+
얻으면 같은 job의 다른 claim들의 catalog observation에서도 후보를
|
|
43
|
+
수집해 합친다. 같은 KOSIS 표가 여러 지표를 가진 케이스에서, 한
|
|
44
|
+
claim의 검색이 우연히 정답 표를 top으로 못 잡았더라도 다른 claim의
|
|
45
|
+
catalog가 그 표를 후보로 가졌으면 활용 가능.
|
|
46
|
+
"""
|
|
47
|
+
seen = {current_id}
|
|
48
|
+
out: list[str] = []
|
|
49
|
+
|
|
50
|
+
def _collect_from(claim_cid: str) -> None:
|
|
51
|
+
try:
|
|
52
|
+
names = workspace.list_observations(claim_cid)
|
|
53
|
+
except Exception:
|
|
54
|
+
return
|
|
55
|
+
cat_names = sorted(
|
|
56
|
+
[n for n in names if "catalog_search" in n.lower()],
|
|
57
|
+
reverse=True,
|
|
58
|
+
)
|
|
59
|
+
for name in cat_names:
|
|
60
|
+
data = workspace.read_observation(claim_cid, name)
|
|
61
|
+
if not isinstance(data, dict):
|
|
62
|
+
continue
|
|
63
|
+
cands = (data.get("output") or {}).get("candidates") or []
|
|
64
|
+
for c in cands:
|
|
65
|
+
cid = c.get("id") if isinstance(c, dict) else None
|
|
66
|
+
if not cid or cid in seen:
|
|
67
|
+
continue
|
|
68
|
+
out.append(cid)
|
|
69
|
+
seen.add(cid)
|
|
70
|
+
if len(out) >= limit:
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
# 1차: 현재 claim의 catalog observations
|
|
74
|
+
_collect_from(claim_id)
|
|
75
|
+
if len(out) >= limit:
|
|
76
|
+
return out
|
|
77
|
+
# 2차: 같은 job의 다른 claim들 — 표 다양성 확보
|
|
78
|
+
try:
|
|
79
|
+
other_cids = [c for c in workspace.list_claims() if c != str(claim_id)]
|
|
80
|
+
except Exception:
|
|
81
|
+
other_cids = []
|
|
82
|
+
for other in other_cids:
|
|
83
|
+
if len(out) >= limit:
|
|
84
|
+
break
|
|
85
|
+
_collect_from(other)
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _collect_candidate_pool(
|
|
90
|
+
workspace, claim_id, current_id: str, limit: int = 20,
|
|
91
|
+
) -> list[dict]:
|
|
92
|
+
"""catalog_search + explore_catalog observation에서 후보 표 dict 수집.
|
|
93
|
+
|
|
94
|
+
각 후보 dict는 {id, name, score, category_path, raw?, _pool_source} 형태.
|
|
95
|
+
탈중복 by id. catalog_ranker가 메타데이터까지 보고 의미 점수를 매기기 위함.
|
|
96
|
+
|
|
97
|
+
수집 순서:
|
|
98
|
+
1. 현재 claim의 catalog_search candidates (cosine recall — 점수 보존)
|
|
99
|
+
2. 현재 claim의 explore_catalog example tables (categorical recall — 다른 path)
|
|
100
|
+
3. 같은 job 다른 claim의 catalog candidates (job-level diversity)
|
|
101
|
+
"""
|
|
102
|
+
seen: set[str] = set()
|
|
103
|
+
if current_id:
|
|
104
|
+
seen.add(current_id)
|
|
105
|
+
pool: list[dict] = []
|
|
106
|
+
|
|
107
|
+
def _add(cand: dict, source: str) -> bool:
|
|
108
|
+
cid = str(cand.get("id") or "").strip()
|
|
109
|
+
if not cid or cid in seen:
|
|
110
|
+
return len(pool) < limit
|
|
111
|
+
seen.add(cid)
|
|
112
|
+
out = dict(cand)
|
|
113
|
+
out["_pool_source"] = source
|
|
114
|
+
pool.append(out)
|
|
115
|
+
return len(pool) < limit
|
|
116
|
+
|
|
117
|
+
def _collect_catalog(claim_cid: str) -> None:
|
|
118
|
+
try:
|
|
119
|
+
names = workspace.list_observations(claim_cid)
|
|
120
|
+
except Exception:
|
|
121
|
+
return
|
|
122
|
+
cat_names = sorted(
|
|
123
|
+
[n for n in names if "catalog_search" in n.lower()],
|
|
124
|
+
reverse=True,
|
|
125
|
+
)
|
|
126
|
+
for name in cat_names:
|
|
127
|
+
data = workspace.read_observation(claim_cid, name)
|
|
128
|
+
if not isinstance(data, dict):
|
|
129
|
+
continue
|
|
130
|
+
cands = (data.get("output") or {}).get("candidates") or []
|
|
131
|
+
for c in cands:
|
|
132
|
+
if isinstance(c, dict):
|
|
133
|
+
if not _add(c, "catalog"):
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
def _collect_explore(claim_cid: str) -> None:
|
|
137
|
+
try:
|
|
138
|
+
names = workspace.list_observations(claim_cid)
|
|
139
|
+
except Exception:
|
|
140
|
+
return
|
|
141
|
+
ex_names = sorted(
|
|
142
|
+
[n for n in names if "explore_catalog" in n.lower()],
|
|
143
|
+
reverse=True,
|
|
144
|
+
)
|
|
145
|
+
for name in ex_names:
|
|
146
|
+
data = workspace.read_observation(claim_cid, name)
|
|
147
|
+
if not isinstance(data, dict):
|
|
148
|
+
continue
|
|
149
|
+
cats = data.get("categories") or []
|
|
150
|
+
for cat in cats:
|
|
151
|
+
if not isinstance(cat, dict):
|
|
152
|
+
continue
|
|
153
|
+
category_label = cat.get("category_label", "")
|
|
154
|
+
for ex in (cat.get("examples") or []):
|
|
155
|
+
if isinstance(ex, dict) and ex.get("stat_id"):
|
|
156
|
+
if not _add({
|
|
157
|
+
"id": ex.get("stat_id"),
|
|
158
|
+
"name": ex.get("stat_name", ""),
|
|
159
|
+
"score": 0.0,
|
|
160
|
+
"category_path": category_label,
|
|
161
|
+
}, "explore"):
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
_collect_catalog(claim_id)
|
|
165
|
+
_collect_explore(claim_id)
|
|
166
|
+
|
|
167
|
+
if len(pool) < limit:
|
|
168
|
+
try:
|
|
169
|
+
other_cids = [c for c in workspace.list_claims() if c != str(claim_id)]
|
|
170
|
+
except Exception:
|
|
171
|
+
other_cids = []
|
|
172
|
+
for other in other_cids:
|
|
173
|
+
if len(pool) >= limit:
|
|
174
|
+
break
|
|
175
|
+
_collect_catalog(other)
|
|
176
|
+
|
|
177
|
+
return pool
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@register_tool(ActionType.FETCH_EVIDENCE)
|
|
181
|
+
class FetchEvidenceTool(ToolBase):
|
|
182
|
+
"""카탈로그 후보의 실제 수치 데이터 조회.
|
|
183
|
+
|
|
184
|
+
catalog_search로 candidate_id 알아낸 후 호출.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
name = ActionType.FETCH_EVIDENCE
|
|
188
|
+
description = (
|
|
189
|
+
"데이터 소스에서 *실제 수치* 조회. catalog_search로 candidate_id 알아낸 후 호출. "
|
|
190
|
+
"params는 source별 다름 (KOSIS는 시점 필터 등). 모르면 빈 dict {} 전달 — "
|
|
191
|
+
"claim.schema에서 자동 보강됨."
|
|
192
|
+
)
|
|
193
|
+
input_schema = {
|
|
194
|
+
"candidate_id": "catalog_search 결과의 id",
|
|
195
|
+
"params": (
|
|
196
|
+
"(선택) source별 파라미터 dict. indicator/time_period/population은 "
|
|
197
|
+
"claim.schema에서 자동 보강됨. 핵심 옵션 match_criteria: 직전 fetch의 "
|
|
198
|
+
"row sample 컬럼명을 본 뒤 어떤 컬럼이 어떤 값과 매칭돼야 하는지 dict로 "
|
|
199
|
+
"명시. row 매칭이 모든 criteria 만족 row로 좁혀짐. "
|
|
200
|
+
"형식 예: {\"match_criteria\": {\"<column_name>\": \"<expected_substring>\"}}. "
|
|
201
|
+
"컬럼명은 row sample에 노출된 키를 그대로 사용 — 도메인 무관."
|
|
202
|
+
),
|
|
203
|
+
"source": "(선택) 데이터 소스 이름. catalog_search와 동일하게.",
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async def execute(
|
|
207
|
+
self,
|
|
208
|
+
input_data: dict[str, Any],
|
|
209
|
+
context: ToolContext,
|
|
210
|
+
) -> ToolResult:
|
|
211
|
+
candidate_id = (input_data.get("candidate_id") or "").strip()
|
|
212
|
+
if not candidate_id:
|
|
213
|
+
return ToolResult(
|
|
214
|
+
output={},
|
|
215
|
+
summary="실패: candidate_id 비어있음",
|
|
216
|
+
success=False,
|
|
217
|
+
error="candidate_id는 비어있을 수 없습니다.",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
params = input_data.get("params") or {}
|
|
221
|
+
if not isinstance(params, dict):
|
|
222
|
+
return ToolResult(
|
|
223
|
+
output={},
|
|
224
|
+
summary=f"실패: params는 dict이어야 함, got {type(params).__name__}",
|
|
225
|
+
success=False,
|
|
226
|
+
error="params는 dict 또는 None이어야 합니다.",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# ★ ADD: claim.schema에서 누락된 params 자동 보강
|
|
230
|
+
# Planner LLM은 KOSIS spec을 모르므로 params를 비워둠 → 여기서 보강
|
|
231
|
+
# claim.schema에는 schema_inductor가 추출한 indicator/time_period/unit/population 들어있음
|
|
232
|
+
claim = getattr(context, "claim", None)
|
|
233
|
+
schema = getattr(claim, "schema", None) if claim is not None else None
|
|
234
|
+
if schema is not None:
|
|
235
|
+
# dict 형태로 변환해서 안전하게 접근
|
|
236
|
+
params = dict(params)
|
|
237
|
+
if not params.get("indicator") and getattr(schema, "indicator", None):
|
|
238
|
+
params["indicator"] = schema.indicator
|
|
239
|
+
if not params.get("time_period") and getattr(schema, "time_period", None):
|
|
240
|
+
params["time_period"] = schema.time_period
|
|
241
|
+
# ── [L 패치 2026-05-21] population은 schema 강제 덮어쓰기 ──
|
|
242
|
+
# LLM이 claim_text 전체(여러 region 등장)를 보고 sub-claim의 schema와
|
|
243
|
+
# *다른 region*을 fetch에 박는 케이스가 잦음 (의료장비 서울 claim에
|
|
244
|
+
# population='강원도' 박아 강원 row 1336 가져옴 → evidence pool 오염
|
|
245
|
+
# → LLM이 finish할 때 헷갈려 잘못된 mismatch).
|
|
246
|
+
# sub-claim의 정체성(population)은 schema가 진실. LLM이 다른 값을
|
|
247
|
+
# 명시했더라도 schema 우선으로 덮어씀. schema 값이 "전체"/None 같은
|
|
248
|
+
# 비특정 값이면 LLM 값 유지.
|
|
249
|
+
# 주의: time_period는 *growth_rate/difference에서 LLM이 prev 시점을
|
|
250
|
+
# 의도적으로* 박아야 하므로 덮어쓰지 *않음* (기존 누락 보강만).
|
|
251
|
+
_sch_pop = (getattr(schema, "population", None) or "").strip()
|
|
252
|
+
if _sch_pop and _sch_pop not in ("전체", "전국", "계", "total"):
|
|
253
|
+
_llm_pop = (params.get("population") or "").strip()
|
|
254
|
+
if _llm_pop and _llm_pop != _sch_pop:
|
|
255
|
+
logger.info(
|
|
256
|
+
f"[fetch_evidence] population LLM={_llm_pop!r} → "
|
|
257
|
+
f"schema {_sch_pop!r} 덮어씀 (sub-claim 정체성 우선)"
|
|
258
|
+
)
|
|
259
|
+
params["population"] = _sch_pop
|
|
260
|
+
if not params.get("unit_hint") and getattr(schema, "unit", None):
|
|
261
|
+
params["unit_hint"] = schema.unit
|
|
262
|
+
# [P32 2026-05-22] LLM 기반 relevance fallback이 활용할 컨텍스트.
|
|
263
|
+
# raw_claim(원문 문장)과 parent_path(계층 카테고리)를 params에 실어
|
|
264
|
+
# KOSISDataSource의 v6.17 가드에서 LLM judge에 전달.
|
|
265
|
+
if not params.get("parent_path") and getattr(schema, "parent_path", None):
|
|
266
|
+
params["parent_path"] = schema.parent_path
|
|
267
|
+
if not params.get("raw_claim"):
|
|
268
|
+
_claim_text = getattr(claim, "claim_text", None)
|
|
269
|
+
if _claim_text:
|
|
270
|
+
params["raw_claim"] = str(_claim_text)[:400]
|
|
271
|
+
# [패치] derived claim (~증가율 등)의 unit_hint='%'는 KOSIS 표의
|
|
272
|
+
# base 단위 row(명/건) 매칭을 막아 evidence 0건 → unverifiable로
|
|
273
|
+
# 죽이는 원인. derived claim에서는 fetch 시 base row를 받아야
|
|
274
|
+
# loop의 growth_rate/difference 직접계산 경로가 작동한다.
|
|
275
|
+
# claim.schema.indicator(원본)에 derived suffix가 있으면
|
|
276
|
+
# unit_hint를 비워 _select_best_row의 unit 가드를 우회한다.
|
|
277
|
+
#
|
|
278
|
+
# [P27 2026-05-22] suffix list 확장 + *indicator unwrap*.
|
|
279
|
+
# "X 증가 수", "X 감소 수", "X 증가량" 등 *공백 포함 두 단어 표현*도 derived
|
|
280
|
+
# 류로 인식. derived 감지 시 fetch params의 indicator를 *원지표(X)*로
|
|
281
|
+
# unwrap해서 KOSIS 검색. KOSIS는 "X 증가 수" 자체 row가 거의 없으니
|
|
282
|
+
# 원지표 row를 받아 loop이 (cur - prev) 직접 계산하도록 유도.
|
|
283
|
+
#
|
|
284
|
+
# 한국어 동사형 변화 표현 — 도메인 무관 일반 패턴 (의료/인구/경제 공통).
|
|
285
|
+
_DERIVED_RATE_SUFFIXES = (
|
|
286
|
+
"증가율", "감소율", "증감률", "변화율", "상승률", "하락률",
|
|
287
|
+
"비율", "비중",
|
|
288
|
+
)
|
|
289
|
+
_DERIVED_DIFF_SUFFIXES = (
|
|
290
|
+
"증가 수", "감소 수", "증감 수",
|
|
291
|
+
"증가량", "감소량", "증감량",
|
|
292
|
+
"증가폭", "감소폭",
|
|
293
|
+
"신규 도입 수", "도입 수",
|
|
294
|
+
"신규 수", "추가 수",
|
|
295
|
+
"증가", "감소", "증감", "변화", "차이", # 짧은 형태 (K 패치 기존)
|
|
296
|
+
)
|
|
297
|
+
_claim_ind = (getattr(schema, "indicator", "") or "").strip()
|
|
298
|
+
_matched_suffix: str | None = None
|
|
299
|
+
_is_rate = False
|
|
300
|
+
for _sfx in _DERIVED_RATE_SUFFIXES:
|
|
301
|
+
if _claim_ind.endswith(_sfx):
|
|
302
|
+
_matched_suffix = _sfx
|
|
303
|
+
_is_rate = True
|
|
304
|
+
break
|
|
305
|
+
if not _matched_suffix:
|
|
306
|
+
# 긴 것 먼저 (예: "증가 수" 가 "증가"보다 먼저 매칭되도록 정렬은 list 순서로 보장)
|
|
307
|
+
for _sfx in _DERIVED_DIFF_SUFFIXES:
|
|
308
|
+
if _claim_ind.endswith(_sfx):
|
|
309
|
+
_matched_suffix = _sfx
|
|
310
|
+
break
|
|
311
|
+
|
|
312
|
+
if _matched_suffix:
|
|
313
|
+
# (1) unit_hint='%' 제거 (rate 케이스)
|
|
314
|
+
if _is_rate and params.get("unit_hint"):
|
|
315
|
+
logger.info(
|
|
316
|
+
f"[fetch_evidence] derived rate claim '{_claim_ind}' — "
|
|
317
|
+
f"unit_hint={params.get('unit_hint')!r} 제거 "
|
|
318
|
+
f"(base 단위 row 매칭 위해)"
|
|
319
|
+
)
|
|
320
|
+
params.pop("unit_hint", None)
|
|
321
|
+
# (2) indicator unwrap — KOSIS 검색용 원지표 추출
|
|
322
|
+
_root_indicator = _claim_ind[: -len(_matched_suffix)].rstrip()
|
|
323
|
+
if _root_indicator and _root_indicator != _claim_ind:
|
|
324
|
+
logger.info(
|
|
325
|
+
f"[fetch_evidence] derived indicator unwrap: "
|
|
326
|
+
f"'{_claim_ind}' → '{_root_indicator}' (suffix={_matched_suffix!r}) — "
|
|
327
|
+
f"KOSIS는 보통 *원지표 row*만 제공, 차이/증가율은 loop이 직접 계산"
|
|
328
|
+
)
|
|
329
|
+
params["indicator"] = _root_indicator
|
|
330
|
+
# rate 케이스도 unit_hint 비움 (위에서 처리했지만 안전)
|
|
331
|
+
if not _is_rate:
|
|
332
|
+
# derived_difference 케이스: 단위는 원지표 그대로 (예: 대/명/건)
|
|
333
|
+
# → unit_hint를 schema.unit으로 유지 (이미 위에서 채움)
|
|
334
|
+
pass
|
|
335
|
+
# ── [v6.17] growth_rate 직접계산용 — fetch 범위 확장 ──────────
|
|
336
|
+
# claim에 prev_time_period가 있으면(증가율/변화량 claim),
|
|
337
|
+
# startPrdDe를 prev 시점까지 당겨서 현재+이전 시점을 한 번에
|
|
338
|
+
# 받아온다. 그래야 loop이 같은 표 rows에서 prev 값을 찾아
|
|
339
|
+
# 증가율을 직접 계산할 수 있음. (1회 fetch로 두 해 확보)
|
|
340
|
+
prev_tp = getattr(schema, "prev_time_period", None)
|
|
341
|
+
cur_tp = params.get("time_period")
|
|
342
|
+
if prev_tp and cur_tp and not params.get("startPrdDe"):
|
|
343
|
+
# 'YYYY-MM'/'YYYY' → 비교해서 더 이른 쪽을 start로
|
|
344
|
+
_p = str(prev_tp).replace("-", "").strip()
|
|
345
|
+
_c = str(cur_tp).replace("-", "").strip()
|
|
346
|
+
if _p and _c and _p.isdigit() and _c.isdigit():
|
|
347
|
+
start_raw, end_raw = (prev_tp, cur_tp) if _p <= _c else (cur_tp, prev_tp)
|
|
348
|
+
params["_range_start"] = str(start_raw)
|
|
349
|
+
params["_range_end"] = str(end_raw)
|
|
350
|
+
logger.info(
|
|
351
|
+
f"[fetch_evidence] growth_rate fetch 범위 확장: "
|
|
352
|
+
f"{start_raw} ~ {end_raw} (prev={prev_tp}, current={cur_tp})"
|
|
353
|
+
)
|
|
354
|
+
logger.info(
|
|
355
|
+
f"[fetch_evidence] claim.schema에서 params 보강: "
|
|
356
|
+
f"indicator={params.get('indicator')!r} "
|
|
357
|
+
f"time_period={params.get('time_period')!r} "
|
|
358
|
+
f"population={params.get('population')!r} "
|
|
359
|
+
f"unit_hint={params.get('unit_hint')!r}"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# [2026-05-21] match_criteria carry-over 가드 — reflect LLM이 직전 claim의
|
|
363
|
+
# matched_row에서 criteria를 복사해 넘기는 경우가 있어, 다른 sub-claim
|
|
364
|
+
# (population='인천')인데 criteria={'시군구': '강원도'} 같은 충돌이 발생.
|
|
365
|
+
# 22:41:28 인천 claim 예: schema.population='인천'인데 LLM criteria='강원도'
|
|
366
|
+
# → _select_best_row가 '강원' substring 매칭 시도 → 모든 후보 매칭 실패.
|
|
367
|
+
# 가드: schema.population이 *구체적*이고 (전체/전국 등 제외), match_criteria의
|
|
368
|
+
# 어떤 value에도 schema.population과 양방향 substring 매칭이 *전혀* 없으면
|
|
369
|
+
# → criteria 폐기 (LLM의 carry-over로 간주).
|
|
370
|
+
_sch_pop_norm = _sch_pop # 위에서 정의된 schema.population
|
|
371
|
+
_criteria = params.get("match_criteria")
|
|
372
|
+
if (
|
|
373
|
+
_sch_pop_norm
|
|
374
|
+
and _sch_pop_norm not in ("전체", "전국", "계", "total")
|
|
375
|
+
and isinstance(_criteria, dict) and _criteria
|
|
376
|
+
):
|
|
377
|
+
_has_overlap = False
|
|
378
|
+
for _cv in _criteria.values():
|
|
379
|
+
_cv_s = str(_cv or "").strip()
|
|
380
|
+
if not _cv_s:
|
|
381
|
+
continue
|
|
382
|
+
if _cv_s in _sch_pop_norm or _sch_pop_norm in _cv_s:
|
|
383
|
+
_has_overlap = True
|
|
384
|
+
break
|
|
385
|
+
if not _has_overlap:
|
|
386
|
+
logger.warning(
|
|
387
|
+
f"[fetch_evidence] match_criteria carry-over 가드: "
|
|
388
|
+
f"schema.population={_sch_pop_norm!r}와 충돌하는 "
|
|
389
|
+
f"criteria={_criteria!r} 폐기 (LLM이 직전 claim 정보 복사 의심)"
|
|
390
|
+
)
|
|
391
|
+
params.pop("match_criteria", None)
|
|
392
|
+
|
|
393
|
+
# ── [v6.22] schema/params 없으면 fetch 거부 ──────────────────
|
|
394
|
+
# indicator가 없으면 connector가 '무엇을' 조회할지 자체를 모르고
|
|
395
|
+
# 기본값(drows[0])을 반환 → 통합·연간 행이 그대로 새어나온다.
|
|
396
|
+
# period guard·통합행 거부 모두 indicator를 근거로 동작하므로
|
|
397
|
+
# indicator가 비면 작동하지 못한다.
|
|
398
|
+
# 예: 기사 제목 claim은 schema 유도 실패 → schema=없음 →
|
|
399
|
+
# planner가 시점만 추측 → '출생사망혼인이혼 238317' 누수.
|
|
400
|
+
# time_period만 있고 indicator가 없으면 fetch하지 않고 거부한다.
|
|
401
|
+
if not params.get("indicator"):
|
|
402
|
+
logger.warning(
|
|
403
|
+
f"[fetch_evidence] indicator 없음 → fetch 거부: "
|
|
404
|
+
f"candidate={candidate_id} (params={ {k: v for k, v in params.items() if k in ('indicator', 'time_period', 'population')} }) "
|
|
405
|
+
f"— connector 기본값 누수 방지. claim에 검증 가능 수치가 "
|
|
406
|
+
f"없거나 schema 유도가 실패한 claim."
|
|
407
|
+
)
|
|
408
|
+
return ToolResult(
|
|
409
|
+
output={"evidence": None, "reason": "no_indicator"},
|
|
410
|
+
summary=(
|
|
411
|
+
"fetch 거부: claim에 indicator 없음 "
|
|
412
|
+
"(schema 유도 실패 claim — 검증 불가)"
|
|
413
|
+
),
|
|
414
|
+
success=False,
|
|
415
|
+
error="claim.schema에 indicator가 없어 fetch 대상을 특정할 수 없음",
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# source 선택
|
|
419
|
+
ds_config = context.config.get("data_sources", {}) if context.config else {}
|
|
420
|
+
# default_source 미지정이면 enabled 첫 소스로 폴백 (하드코딩 kosis 방지)
|
|
421
|
+
default_source = ds_config.get("default_source") or (ds_config.get("enabled") or ["kosis"])[0]
|
|
422
|
+
source_name = (input_data.get("source") or default_source).strip()
|
|
423
|
+
# LLM이 미등록 소스(프롬프트 잔재 'kosis' 등)를 지정하면 사용 가능한 소스로 강제.
|
|
424
|
+
if context.datasources and source_name not in context.datasources:
|
|
425
|
+
_avail = list(context.datasources.keys())
|
|
426
|
+
source_name = (
|
|
427
|
+
default_source if default_source in context.datasources
|
|
428
|
+
else (_avail[0] if _avail else source_name)
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# ── [v6.21] verified_facts 캐시 조회 ──────────────────────────
|
|
432
|
+
# 같은 (indicator, time_period)를 다른 claim이 이미 검증했으면
|
|
433
|
+
# catalog_search + fetch 전체를 건너뛰고 저장된 수치를 재사용한다.
|
|
434
|
+
# 예: "올해 출생아 수 20,717명" 검증 후 → "작년 대비 8.7% 증가"
|
|
435
|
+
# claim이 올해값을 재검색 없이 즉시 가져옴.
|
|
436
|
+
_cache_ind = params.get("indicator")
|
|
437
|
+
_cache_tp = params.get("time_period")
|
|
438
|
+
_cache_unit = params.get("unit_hint")
|
|
439
|
+
_cache_pop = params.get("population")
|
|
440
|
+
_ws = getattr(context, "workspace", None)
|
|
441
|
+
if _ws is not None and _cache_ind and _cache_tp:
|
|
442
|
+
try:
|
|
443
|
+
# [2026-05-21] population까지 키에 포함 — 같은 (indicator, time)이라도
|
|
444
|
+
# 다른 지역 sub-claim의 캐시 값이 적중하던 버그(22:54 트레이스 — 서울
|
|
445
|
+
# sub-claim이 강원도/197 같은 다른 값 받음) 차단.
|
|
446
|
+
hit = _ws.lookup_verified_fact(
|
|
447
|
+
_cache_ind, _cache_tp,
|
|
448
|
+
unit_hint=_cache_unit, population=_cache_pop,
|
|
449
|
+
)
|
|
450
|
+
except Exception:
|
|
451
|
+
hit = None
|
|
452
|
+
if hit is not None:
|
|
453
|
+
logger.info(
|
|
454
|
+
f"[fetch_evidence] verified_facts 캐시 적중 — "
|
|
455
|
+
f"indicator={_cache_ind!r} time={_cache_tp!r} "
|
|
456
|
+
f"value={hit.get('value')} (재검색 생략)"
|
|
457
|
+
)
|
|
458
|
+
cached_evidence = {
|
|
459
|
+
"value": hit.get("value"),
|
|
460
|
+
"unit": hit.get("unit", "") or "",
|
|
461
|
+
"time_period": hit.get("time_period", "") or "",
|
|
462
|
+
"source": hit.get("source", "KOSIS") or "KOSIS",
|
|
463
|
+
"stat_table_id": "",
|
|
464
|
+
"stat_name": "(verified_facts 캐시)",
|
|
465
|
+
"rows": [],
|
|
466
|
+
"raw": {"from_cache": True, "origin_claim": hit.get("claim_id")},
|
|
467
|
+
"from_verified_cache": True,
|
|
468
|
+
}
|
|
469
|
+
return ToolResult(
|
|
470
|
+
output={"evidence": cached_evidence, "used_candidate_id": "cache"},
|
|
471
|
+
summary=(
|
|
472
|
+
f"verified_facts 캐시 재사용: {_cache_ind} "
|
|
473
|
+
f"{_cache_tp} = {hit.get('value')}{hit.get('unit', '')}"
|
|
474
|
+
),
|
|
475
|
+
success=True,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
source = context.datasources.get(source_name) if context.datasources else None
|
|
479
|
+
if source is None:
|
|
480
|
+
available = list(context.datasources.keys()) if context.datasources else []
|
|
481
|
+
return ToolResult(
|
|
482
|
+
output={"requested_source": source_name, "available": available},
|
|
483
|
+
summary=f"실패: source={source_name!r} 등록 안 됨",
|
|
484
|
+
success=False,
|
|
485
|
+
error=(
|
|
486
|
+
f"DataSource '{source_name}'이 context.datasources에 없습니다. "
|
|
487
|
+
f"가능한 source: {available}"
|
|
488
|
+
),
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
# ── [v6.18] 후보 순회 fetch ──────────────────────────────────
|
|
492
|
+
# top 후보가 무관한 표(관련성 체크 거부)거나 데이터 없음이면
|
|
493
|
+
# _candidate_fallbacks의 다음 후보로 재시도. 최대 5개까지 시도.
|
|
494
|
+
fallback_ids = input_data.get("_candidate_fallbacks") or []
|
|
495
|
+
# ── i'' 패치: LLM이 _candidate_fallbacks를 안 넘긴 케이스 처리 ──
|
|
496
|
+
# 비어있으면 workspace의 직전 catalog_search observation에서
|
|
497
|
+
# 후보 ids를 자동 추출. 안 그러면 top 후보 1개만 시도하고 죽음.
|
|
498
|
+
# (project_fetch_lockup — reflect의 catalog_search 무한 반복)
|
|
499
|
+
if not fallback_ids and context.workspace is not None:
|
|
500
|
+
auto = _autoload_fallback_candidate_ids(
|
|
501
|
+
context.workspace, context.claim_id, candidate_id, limit=4
|
|
502
|
+
)
|
|
503
|
+
if auto:
|
|
504
|
+
fallback_ids = auto
|
|
505
|
+
logger.info(
|
|
506
|
+
f"[fetch_evidence] _candidate_fallbacks 자동 주입: "
|
|
507
|
+
f"{auto} (LLM이 안 넘김 → catalog observation에서 추출)"
|
|
508
|
+
)
|
|
509
|
+
# ── [패치 A] job 안에서 이미 fetch 성공한 stat_id를 1순위 fallback ──
|
|
510
|
+
# 같은 KOSIS 표가 여러 지표(출생아 수/합계출산율/혼인 건수)를 같이
|
|
511
|
+
# 갖고 있는데 catalog는 검색어별로 다른 표를 top으로 주는 경우 대응.
|
|
512
|
+
# 다른 claim이 표 X에서 성공했다면, 현재 claim의 top 후보가 부적절
|
|
513
|
+
# 해도 표 X를 우선 시도한다. (candidate_id 자체가 표 X면 영향 없음.)
|
|
514
|
+
prior_success_ids: list[str] = []
|
|
515
|
+
if context.workspace is not None:
|
|
516
|
+
try:
|
|
517
|
+
prior_success_ids = context.workspace.read_successful_stat_ids()
|
|
518
|
+
except Exception as e:
|
|
519
|
+
logger.debug(f"[fetch_evidence] successful_stat_ids 읽기 실패: {e}")
|
|
520
|
+
prior_success_ids = []
|
|
521
|
+
if prior_success_ids:
|
|
522
|
+
logger.info(
|
|
523
|
+
f"[fetch_evidence] 직전 success stat_id 우선 시도: "
|
|
524
|
+
f"{prior_success_ids} (job 공유)"
|
|
525
|
+
)
|
|
526
|
+
# ── [2026-05-26] catalog_ranker (LLM batch ranking) ──────────────
|
|
527
|
+
# 후보 표 N개를 한 번에 LLM에 보내 의미 매칭 점수로 ranking.
|
|
528
|
+
# 키워드 가드 + per-table relevance_judge를 통합 대체.
|
|
529
|
+
# 비활성 시 (config or LLM 실패) 기존 키워드 가드로 fallback.
|
|
530
|
+
_ranker_cfg = (
|
|
531
|
+
((context.config or {}).get("data_sources") or {})
|
|
532
|
+
.get("kosis") or {}
|
|
533
|
+
).get("catalog_ranker") or {}
|
|
534
|
+
# 랭커 적용 여부는 소스 프로파일의 검색 전략(retrieval_plan.use_ranker)이 결정한다.
|
|
535
|
+
# - 지표가 방대한 소스 → use_ranker=True (의미 순위 필요)
|
|
536
|
+
# - 지표가 적은 소스 → use_ranker=False (키워드로 충분, 랭커가 오히려 방해)
|
|
537
|
+
# 프로파일이 없으면(예: kosis 등 프로파일 미생성) 기존 동작(config enabled) 유지.
|
|
538
|
+
_plan = ((context.config or {}).get("_source_profile") or {}).get("retrieval_plan") or {}
|
|
539
|
+
if _plan:
|
|
540
|
+
_ranker_enabled = bool(_ranker_cfg.get("enabled", False)) and bool(_plan.get("use_ranker"))
|
|
541
|
+
else:
|
|
542
|
+
_ranker_enabled = bool(_ranker_cfg.get("enabled", False)) and source_name == "kosis"
|
|
543
|
+
|
|
544
|
+
# 후보 pool 구성 — ranker 활성/비활성에 따라 다름
|
|
545
|
+
if _ranker_enabled and context.workspace is not None:
|
|
546
|
+
# 메타데이터 풍부한 pool 수집 (catalog + explore union)
|
|
547
|
+
_pool_limit = int(_ranker_cfg.get("pool_limit", 20))
|
|
548
|
+
_pool = _collect_candidate_pool(
|
|
549
|
+
context.workspace, context.claim_id, candidate_id,
|
|
550
|
+
limit=_pool_limit,
|
|
551
|
+
)
|
|
552
|
+
# current_id가 _pool에 없으면 (현재 pool은 current_id를 seen으로 skip)
|
|
553
|
+
# candidate 정보 빠지므로 catalog observation에서 보강
|
|
554
|
+
_current_cand: dict | None = None
|
|
555
|
+
try:
|
|
556
|
+
for _obs_name in context.workspace.list_observations(context.claim_id):
|
|
557
|
+
if "catalog_search" not in _obs_name.lower():
|
|
558
|
+
continue
|
|
559
|
+
_obs = context.workspace.read_observation(context.claim_id, _obs_name)
|
|
560
|
+
if not isinstance(_obs, dict):
|
|
561
|
+
continue
|
|
562
|
+
for _c in (_obs.get("output") or {}).get("candidates") or []:
|
|
563
|
+
if isinstance(_c, dict) and _c.get("id") == candidate_id:
|
|
564
|
+
_current_cand = dict(_c)
|
|
565
|
+
_current_cand["_pool_source"] = "catalog"
|
|
566
|
+
break
|
|
567
|
+
if _current_cand:
|
|
568
|
+
break
|
|
569
|
+
except Exception:
|
|
570
|
+
pass
|
|
571
|
+
if _current_cand is None:
|
|
572
|
+
_current_cand = {"id": candidate_id, "name": "", "score": 0.0, "_pool_source": "catalog"}
|
|
573
|
+
|
|
574
|
+
# 전체 ranking 대상: current + prior_success + pool. 중복 제거.
|
|
575
|
+
_rank_input: list[dict] = []
|
|
576
|
+
_rank_seen: set[str] = set()
|
|
577
|
+
def _add_to_rank(cand: dict) -> None:
|
|
578
|
+
cid = str(cand.get("id") or "")
|
|
579
|
+
if not cid or cid in _rank_seen:
|
|
580
|
+
return
|
|
581
|
+
_rank_seen.add(cid)
|
|
582
|
+
_rank_input.append(cand)
|
|
583
|
+
|
|
584
|
+
_add_to_rank(_current_cand)
|
|
585
|
+
for sid in prior_success_ids:
|
|
586
|
+
if sid in _rank_seen:
|
|
587
|
+
continue
|
|
588
|
+
_add_to_rank({"id": sid, "name": "[prior_success]", "score": 0.0, "_pool_source": "prior_success"})
|
|
589
|
+
for c in _pool:
|
|
590
|
+
_add_to_rank(c)
|
|
591
|
+
|
|
592
|
+
# ranker 호출
|
|
593
|
+
from structverify.retrieval.catalog_ranker import rank_candidates
|
|
594
|
+
_ranker_threshold = float(_ranker_cfg.get("score_threshold", 0.15))
|
|
595
|
+
try:
|
|
596
|
+
_rankings = await rank_candidates(
|
|
597
|
+
claim_text=str(params.get("raw_claim") or params.get("claim_text") or ""),
|
|
598
|
+
indicator=str(params.get("indicator") or ""),
|
|
599
|
+
population=str(params.get("population") or ""),
|
|
600
|
+
time_period=str(params.get("time_period") or ""),
|
|
601
|
+
parent_path=str(params.get("parent_path") or ""),
|
|
602
|
+
candidates=_rank_input,
|
|
603
|
+
config=context.config,
|
|
604
|
+
)
|
|
605
|
+
except Exception as _e:
|
|
606
|
+
logger.warning(f"[fetch_evidence] catalog_ranker 호출 예외: {_e}")
|
|
607
|
+
_rankings = None
|
|
608
|
+
|
|
609
|
+
if _rankings:
|
|
610
|
+
# prior_success는 별도 캐시 가치라 ranker 점수 외에 *최우선 유지*.
|
|
611
|
+
_prior_set = set(prior_success_ids)
|
|
612
|
+
_ranked_ids = [
|
|
613
|
+
r["id"] for r in _rankings
|
|
614
|
+
if r["score"] >= _ranker_threshold and r["id"] not in _prior_set
|
|
615
|
+
]
|
|
616
|
+
_rejected_ids = [
|
|
617
|
+
r["id"] for r in _rankings if r["score"] < _ranker_threshold
|
|
618
|
+
]
|
|
619
|
+
# try_ids: prior_success(최우선) → ranker top → 거부된 표 (안전망, 마지막 시도)
|
|
620
|
+
try_ids = []
|
|
621
|
+
for sid in prior_success_ids + _ranked_ids:
|
|
622
|
+
if sid and sid not in try_ids:
|
|
623
|
+
try_ids.append(sid)
|
|
624
|
+
# current candidate_id가 reject 됐어도 *맨 뒤*에 한 번 더 시도 (안전망)
|
|
625
|
+
if candidate_id and candidate_id not in try_ids:
|
|
626
|
+
try_ids.append(candidate_id)
|
|
627
|
+
_max_try = int(_ranker_cfg.get("max_try", 10))
|
|
628
|
+
try_ids = try_ids[:_max_try]
|
|
629
|
+
# ── ranker 결정 로그: before/after 명시 + 각 표의 score+reason ──
|
|
630
|
+
_before_ids = [c["id"] for c in _rank_input if c.get("id")]
|
|
631
|
+
logger.info(
|
|
632
|
+
f"[fetch_evidence] catalog_ranker decision:\n"
|
|
633
|
+
f" input ({len(_before_ids)}): {_before_ids}\n"
|
|
634
|
+
f" output try_ids (top {len(try_ids)}): {try_ids}\n"
|
|
635
|
+
f" rejected<{_ranker_threshold}: {_rejected_ids[:5]}"
|
|
636
|
+
f"{'...' if len(_rejected_ids) > 5 else ''}"
|
|
637
|
+
)
|
|
638
|
+
for r in _rankings[:10]:
|
|
639
|
+
_mark = (
|
|
640
|
+
"★" if r["score"] >= _ranker_threshold and r["id"] in try_ids[:3]
|
|
641
|
+
else " "
|
|
642
|
+
)
|
|
643
|
+
logger.info(
|
|
644
|
+
f" {_mark} rank: id={r['id']} score={r['score']:.2f} "
|
|
645
|
+
f"reason={r.get('reason', '')[:140]!r}"
|
|
646
|
+
)
|
|
647
|
+
else:
|
|
648
|
+
# ranker 실패 — fallback: 기존 candidate_id + prior + fallback ids
|
|
649
|
+
logger.warning("[fetch_evidence] catalog_ranker 미적용 (실패) — 기본 순서 사용")
|
|
650
|
+
try_ids = []
|
|
651
|
+
for sid in [candidate_id] + prior_success_ids + list(fallback_ids):
|
|
652
|
+
if sid and sid not in try_ids:
|
|
653
|
+
try_ids.append(sid)
|
|
654
|
+
try_ids = try_ids[:5]
|
|
655
|
+
else:
|
|
656
|
+
# ── 기존 동작 (ranker 비활성) ─────────────────────────────
|
|
657
|
+
# try_ids: top → prior_success → catalog fallback. 중복 제거.
|
|
658
|
+
try_ids = []
|
|
659
|
+
for sid in [candidate_id] + prior_success_ids + list(fallback_ids):
|
|
660
|
+
if sid and sid not in try_ids:
|
|
661
|
+
try_ids.append(sid)
|
|
662
|
+
try_ids = try_ids[:5] # 상한 5개 유지
|
|
663
|
+
|
|
664
|
+
# Indicator Semantic Guard (키워드 룰 fallback)
|
|
665
|
+
try:
|
|
666
|
+
_indicator_str = str(params.get("indicator") or "")
|
|
667
|
+
_SPECIFIC_KWS = (
|
|
668
|
+
"체외", "쇄석", "충격파",
|
|
669
|
+
"진단방사선", "특수의료", "특수의 료",
|
|
670
|
+
"엑스선", "X선", "엑스레이",
|
|
671
|
+
"CT", "MRI", "PET", "초음파",
|
|
672
|
+
"방사선", "단층",
|
|
673
|
+
)
|
|
674
|
+
_claim_has_specific = any(
|
|
675
|
+
kw.lower() in _indicator_str.lower() for kw in _SPECIFIC_KWS
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
_id_to_name: dict[str, str] = {}
|
|
679
|
+
if context.workspace is not None:
|
|
680
|
+
try:
|
|
681
|
+
for _obs_name in context.workspace.list_observations(context.claim_id):
|
|
682
|
+
if "catalog_search" not in _obs_name.lower():
|
|
683
|
+
continue
|
|
684
|
+
_obs = context.workspace.read_observation(context.claim_id, _obs_name)
|
|
685
|
+
if not isinstance(_obs, dict):
|
|
686
|
+
continue
|
|
687
|
+
for _c in (_obs.get("output") or {}).get("candidates") or []:
|
|
688
|
+
if isinstance(_c, dict):
|
|
689
|
+
_cid, _cname = _c.get("id"), _c.get("name")
|
|
690
|
+
if _cid and _cname and _cid not in _id_to_name:
|
|
691
|
+
_id_to_name[_cid] = _cname
|
|
692
|
+
except Exception:
|
|
693
|
+
pass
|
|
694
|
+
|
|
695
|
+
def _name_specificity(name: str) -> bool:
|
|
696
|
+
if not name:
|
|
697
|
+
return False
|
|
698
|
+
return any(kw.lower() in name.lower() for kw in _SPECIFIC_KWS)
|
|
699
|
+
|
|
700
|
+
def _semantic_score(sid: str) -> int:
|
|
701
|
+
if sid in prior_success_ids:
|
|
702
|
+
return 3
|
|
703
|
+
_name = _id_to_name.get(sid, "")
|
|
704
|
+
_name_has_specific = _name_specificity(_name)
|
|
705
|
+
if _claim_has_specific and _name_has_specific:
|
|
706
|
+
return 2
|
|
707
|
+
if not _claim_has_specific and not _name_has_specific:
|
|
708
|
+
return 1
|
|
709
|
+
if _claim_has_specific and not _name_has_specific:
|
|
710
|
+
return 0
|
|
711
|
+
return -1
|
|
712
|
+
|
|
713
|
+
_before = list(try_ids)
|
|
714
|
+
try_ids.sort(key=lambda s: -_semantic_score(s))
|
|
715
|
+
if try_ids != _before:
|
|
716
|
+
logger.info(
|
|
717
|
+
f"[fetch_evidence] indicator semantic guard reorder: "
|
|
718
|
+
f"indicator={_indicator_str!r} "
|
|
719
|
+
f"(claim_has_specific={_claim_has_specific}) "
|
|
720
|
+
f"{_before} → {try_ids}"
|
|
721
|
+
)
|
|
722
|
+
except Exception as _e:
|
|
723
|
+
logger.debug(f"[fetch_evidence] indicator semantic guard 실패 (무시): {_e}")
|
|
724
|
+
|
|
725
|
+
evidence = None
|
|
726
|
+
used_id = candidate_id
|
|
727
|
+
last_err: str | None = None
|
|
728
|
+
# [패치 E] prior_success_ids로 들어온 stat_id는 표 이름 기반 관련성
|
|
729
|
+
# 가드를 우회하기 위해 params에 플래그를 단다. 같은 job에서 이미
|
|
730
|
+
# 한 번 fetch 성공한 표는 row data 안에 indicator가 있을 가능성이
|
|
731
|
+
# 입증된 것이므로, 표 이름이 indicator와 안 닿더라도 일단 fetch
|
|
732
|
+
# 시도해서 _select_best_row가 진짜 row를 찾게 한다.
|
|
733
|
+
prior_id_set = set(prior_success_ids)
|
|
734
|
+
# [2026-05-26] fetched_values 캐시 — fetch 진입 전 lookup용
|
|
735
|
+
_ws_for_cache = getattr(context, "workspace", None)
|
|
736
|
+
_ind_for_cache = str(params.get("indicator") or "")
|
|
737
|
+
_tp_for_cache = str(params.get("time_period") or "")
|
|
738
|
+
_pop_for_cache = str(params.get("population") or "")
|
|
739
|
+
for idx, try_id in enumerate(try_ids):
|
|
740
|
+
# ── [2026-05-26] fetched_values 캐시 lookup ────────────────
|
|
741
|
+
# 같은 (stat_id, indicator, time, population) 조합이 이미 fetch
|
|
742
|
+
# 됐으면 source.fetch_evidence 호출 안 하고 캐시 값 반환.
|
|
743
|
+
# 같은 claim 내 반복 fetch (LLM이 동일 data 재요청) 또는 다른
|
|
744
|
+
# claim의 재사용 모두 처리.
|
|
745
|
+
if _ws_for_cache is not None and _ind_for_cache and _tp_for_cache:
|
|
746
|
+
try:
|
|
747
|
+
_cached_ev = _ws_for_cache.lookup_fetched_value(
|
|
748
|
+
stat_id=try_id,
|
|
749
|
+
indicator=_ind_for_cache,
|
|
750
|
+
time_period=_tp_for_cache,
|
|
751
|
+
population=_pop_for_cache,
|
|
752
|
+
)
|
|
753
|
+
except Exception as _e:
|
|
754
|
+
logger.debug(f"[fetch_evidence] fetched_value lookup 실패: {_e}")
|
|
755
|
+
_cached_ev = None
|
|
756
|
+
if _cached_ev is not None:
|
|
757
|
+
evidence = _cached_ev
|
|
758
|
+
used_id = try_id
|
|
759
|
+
logger.info(
|
|
760
|
+
f"[fetch_evidence] fetched_values 캐시 적중: "
|
|
761
|
+
f"stat_id={try_id} indicator={_ind_for_cache!r} "
|
|
762
|
+
f"time={_tp_for_cache!r} population={_pop_for_cache!r} "
|
|
763
|
+
f"value={_cached_ev.get('value') if hasattr(_cached_ev, 'get') else None} "
|
|
764
|
+
f"— source.fetch_evidence skip"
|
|
765
|
+
)
|
|
766
|
+
break
|
|
767
|
+
|
|
768
|
+
try:
|
|
769
|
+
call_params = dict(params)
|
|
770
|
+
if try_id in prior_id_set:
|
|
771
|
+
call_params["_from_prior_success"] = True
|
|
772
|
+
# [P20 2026-05-22] workspace 전달 — KOSIS raw 응답 캐시 활용
|
|
773
|
+
ev = await source.fetch_evidence(
|
|
774
|
+
candidate_id=try_id, params=call_params,
|
|
775
|
+
workspace=_ws_for_cache,
|
|
776
|
+
)
|
|
777
|
+
except Exception as e:
|
|
778
|
+
logger.warning(
|
|
779
|
+
f"[fetch_evidence] 후보 {try_id} fetch 예외: "
|
|
780
|
+
f"{type(e).__name__}: {e}"
|
|
781
|
+
)
|
|
782
|
+
last_err = f"{type(e).__name__}: {e}"
|
|
783
|
+
continue
|
|
784
|
+
# [2026-05-21 P6] ev dict이지만 value=None이면 *실패로 간주*하고 다음 후보 시도.
|
|
785
|
+
# 기존엔 `ev is not None`만 봤어서 INH_1B83A35처럼 dict는 받았지만 row 비어
|
|
786
|
+
# value=None인 케이스에서도 break해버려 다음 후보(DT_1B8000G)를 안 돌렸음.
|
|
787
|
+
# → 단일 fetch로 끝나고 loop이 즉시 unverifiable로 떨어지는 버그.
|
|
788
|
+
_ev_dict = dict(ev) if (ev is not None and hasattr(ev, "items")) else {}
|
|
789
|
+
_ev_value = _ev_dict.get("value") if _ev_dict else None
|
|
790
|
+
if ev is not None and _ev_value is not None:
|
|
791
|
+
evidence = ev
|
|
792
|
+
used_id = try_id
|
|
793
|
+
# ── [2026-05-26] fetched_values 캐시 저장 ──────────────
|
|
794
|
+
if _ws_for_cache is not None and _ind_for_cache and _tp_for_cache:
|
|
795
|
+
try:
|
|
796
|
+
_ws_for_cache.append_fetched_value(
|
|
797
|
+
stat_id=try_id,
|
|
798
|
+
indicator=_ind_for_cache,
|
|
799
|
+
time_period=_tp_for_cache,
|
|
800
|
+
population=_pop_for_cache,
|
|
801
|
+
evidence=_ev_dict,
|
|
802
|
+
)
|
|
803
|
+
except Exception as _e:
|
|
804
|
+
logger.debug(f"[fetch_evidence] fetched_value 저장 실패: {_e}")
|
|
805
|
+
if idx > 0:
|
|
806
|
+
logger.info(
|
|
807
|
+
f"[fetch_evidence] top 후보 실패 → 후보 {idx+1}번째 "
|
|
808
|
+
f"{try_id} 로 성공"
|
|
809
|
+
)
|
|
810
|
+
break
|
|
811
|
+
else:
|
|
812
|
+
_why = "None" if ev is None else "value=None"
|
|
813
|
+
logger.info(
|
|
814
|
+
f"[fetch_evidence] 후보 {try_id} → {_why} "
|
|
815
|
+
f"(관련성 거부/데이터 없음), 다음 후보 시도"
|
|
816
|
+
)
|
|
817
|
+
# [P33b 2026-05-22] 실패한 stat_id를 workspace blacklist에 기록
|
|
818
|
+
# → 다음 catalog_search에서 결과에서 제외 → 같은 표 무한 반복 차단.
|
|
819
|
+
try:
|
|
820
|
+
_ws = getattr(context, "workspace", None)
|
|
821
|
+
if _ws is not None and hasattr(_ws, "append_failed_stat_id"):
|
|
822
|
+
_ws.append_failed_stat_id(
|
|
823
|
+
context.claim_id, try_id, reason=f"fetch_failed_{_why}",
|
|
824
|
+
)
|
|
825
|
+
except Exception as _e:
|
|
826
|
+
logger.debug(f"[fetch_evidence] failed_stat_id 기록 실패: {_e}")
|
|
827
|
+
candidate_id = used_id
|
|
828
|
+
|
|
829
|
+
# evidence None 처리 (모든 후보 실패)
|
|
830
|
+
if evidence is None:
|
|
831
|
+
return ToolResult(
|
|
832
|
+
output={"source": source_name, "candidate_id": candidate_id,
|
|
833
|
+
"params": params, "evidence": None,
|
|
834
|
+
"tried_candidates": try_ids},
|
|
835
|
+
summary=(
|
|
836
|
+
f"fetch({source_name}): 후보 {len(try_ids)}개 모두 실패 "
|
|
837
|
+
f"(관련 표 없음)"
|
|
838
|
+
),
|
|
839
|
+
success=False,
|
|
840
|
+
error=(
|
|
841
|
+
last_err
|
|
842
|
+
or "모든 catalog 후보가 관련성 체크 실패 또는 데이터 없음."
|
|
843
|
+
),
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
# 결과 정규화 (EvidenceData는 dict 호환)
|
|
847
|
+
evidence_dict = dict(evidence) if hasattr(evidence, "items") else {}
|
|
848
|
+
|
|
849
|
+
# workspace observation 저장
|
|
850
|
+
try:
|
|
851
|
+
obs_name = f"iter{context.iter_num:03d}_fetch_{candidate_id}"
|
|
852
|
+
context.workspace.write_observation(
|
|
853
|
+
context.claim_id,
|
|
854
|
+
obs_name,
|
|
855
|
+
{
|
|
856
|
+
"source": source_name,
|
|
857
|
+
"candidate_id": candidate_id,
|
|
858
|
+
"params": params,
|
|
859
|
+
"evidence": evidence_dict,
|
|
860
|
+
},
|
|
861
|
+
)
|
|
862
|
+
except Exception as e:
|
|
863
|
+
logger.debug(f"[fetch_evidence] observation 저장 실패: {e}")
|
|
864
|
+
|
|
865
|
+
# [패치 A] job-level successful stat_id 저장 (다음 claim이 이 표를 1순위로)
|
|
866
|
+
try:
|
|
867
|
+
sid_for_save = evidence_dict.get("stat_table_id") or candidate_id
|
|
868
|
+
context.workspace.append_successful_stat_id(str(sid_for_save))
|
|
869
|
+
except Exception as e:
|
|
870
|
+
logger.debug(f"[fetch_evidence] successful_stat_id 저장 실패: {e}")
|
|
871
|
+
|
|
872
|
+
# 요약
|
|
873
|
+
value = evidence_dict.get("value")
|
|
874
|
+
unit = evidence_dict.get("unit", "")
|
|
875
|
+
time_period = evidence_dict.get("time_period", "")
|
|
876
|
+
rows = evidence_dict.get("rows", [])
|
|
877
|
+
rows_info = f", rows={len(rows)}" if isinstance(rows, list) and rows else ""
|
|
878
|
+
summary = (
|
|
879
|
+
f"fetch({source_name}, {candidate_id}): value={value!r} unit={unit!r} "
|
|
880
|
+
f"time={time_period!r}{rows_info}"
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
# ── [T 패치 2026-05-21] sibling_evidence에 직접 저장 ──
|
|
884
|
+
# 기존엔 _save_verified_facts (verdict가 match/mismatch일 때만) 경유 →
|
|
885
|
+
# verdict가 unverifiable이거나 data_points 비어있으면 sibling 저장 누락.
|
|
886
|
+
# fetch가 *성공*한 시점에 *KOSIS 값을 받아온 사실 자체*가 sibling이 활용할
|
|
887
|
+
# 신호이므로 verdict 무관하게 여기서 직접 저장 (verdict='fetched' 표시).
|
|
888
|
+
try:
|
|
889
|
+
if value is not None and claim is not None:
|
|
890
|
+
_schema = getattr(claim, "schema", None)
|
|
891
|
+
_sent_id = str(getattr(claim, "sent_id", "") or "").strip()
|
|
892
|
+
_role = (getattr(_schema, "value_role", None) or "") if _schema else ""
|
|
893
|
+
if _sent_id and _role and hasattr(context.workspace, "record_sibling_evidence"):
|
|
894
|
+
context.workspace.record_sibling_evidence(
|
|
895
|
+
sent_id=_sent_id,
|
|
896
|
+
role=_role,
|
|
897
|
+
evidence={
|
|
898
|
+
"indicator": params.get("indicator") or "",
|
|
899
|
+
"value": value,
|
|
900
|
+
"unit": unit or "",
|
|
901
|
+
"time_period": time_period or params.get("time_period") or "",
|
|
902
|
+
"source": (
|
|
903
|
+
f"{evidence_dict.get('source') or 'kosis'}:"
|
|
904
|
+
f"{evidence_dict.get('stat_table_id') or candidate_id}"
|
|
905
|
+
),
|
|
906
|
+
"claim_id": str(context.claim_id),
|
|
907
|
+
"verdict": "fetched",
|
|
908
|
+
},
|
|
909
|
+
)
|
|
910
|
+
except Exception as e:
|
|
911
|
+
logger.debug(f"[fetch_evidence] sibling_evidence 저장 실패 (무시): {e}")
|
|
912
|
+
|
|
913
|
+
return ToolResult(
|
|
914
|
+
output={
|
|
915
|
+
"source": source_name,
|
|
916
|
+
"candidate_id": candidate_id,
|
|
917
|
+
"params": params,
|
|
918
|
+
"evidence": evidence_dict,
|
|
919
|
+
},
|
|
920
|
+
summary=summary,
|
|
921
|
+
success=True,
|
|
922
|
+
)
|