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,859 @@
|
|
|
1
|
+
"""
|
|
2
|
+
structverify.agent.tools.catalog_search — 카탈로그 검색 Tool.
|
|
3
|
+
|
|
4
|
+
Agent가 *키워드 → 데이터 표/지표 후보*를 찾을 때 호출.
|
|
5
|
+
|
|
6
|
+
작동:
|
|
7
|
+
1. context.datasources에서 source 선택 (기본: default_source)
|
|
8
|
+
2. source.search_catalog(query, category, top_k) 호출
|
|
9
|
+
3. 후보 리스트 반환 + workspace observation 저장
|
|
10
|
+
|
|
11
|
+
source는 BaseDataSource 인터페이스를 구현한 *어떤 것이든* 사용 가능 (KOSIS, custom_csv, ...).
|
|
12
|
+
Phase B에서는 *추상 인터페이스만* — 실제 KOSIS DataSource 구현은 Phase D에서 wiring.
|
|
13
|
+
|
|
14
|
+
호출 예:
|
|
15
|
+
input = {"query": "출생아 수 4월", "category": ["인구", "출생"], "top_k": 5}
|
|
16
|
+
→ 5개 후보 candidates: [{id, name, score, ...}, ...]
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from typing import Any
|
|
22
|
+
from structverify.utils.logger import get_logger
|
|
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 _read_last_explore_categories(workspace, claim_id, top_n: int = 2) -> list[str]:
|
|
31
|
+
"""[R1.5] 같은 claim의 직전 explore_catalog observation에서 top N 카테고리 추출.
|
|
32
|
+
|
|
33
|
+
explore_catalog가 만든 observation은 name이 'iter{NNN}_explore_catalog' 형식.
|
|
34
|
+
가장 최근(iter 큰) 것을 골라서 categories[].category_label top_n개 반환.
|
|
35
|
+
LLM이 catalog_search에 category를 안 넘기거나 KOSIS 어휘와 안 맞는 자유어를
|
|
36
|
+
넘겨도, 시스템이 임베딩으로 찾은 정확한 KOSIS 카테고리를 보강할 수 있도록.
|
|
37
|
+
"""
|
|
38
|
+
if workspace is None or not claim_id:
|
|
39
|
+
return []
|
|
40
|
+
try:
|
|
41
|
+
names = workspace.list_observations(claim_id)
|
|
42
|
+
except Exception:
|
|
43
|
+
return []
|
|
44
|
+
# iter 큰(가장 최근) explore observation 우선
|
|
45
|
+
explore_names = sorted(
|
|
46
|
+
[n for n in names if "explore_catalog" in n.lower()],
|
|
47
|
+
reverse=True,
|
|
48
|
+
)
|
|
49
|
+
if not explore_names:
|
|
50
|
+
return []
|
|
51
|
+
for name in explore_names:
|
|
52
|
+
data = workspace.read_observation(claim_id, name)
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
continue
|
|
55
|
+
cats = (data.get("output") or {}).get("categories") or []
|
|
56
|
+
labels: list[str] = []
|
|
57
|
+
for c in cats[:top_n]:
|
|
58
|
+
if not isinstance(c, dict):
|
|
59
|
+
continue
|
|
60
|
+
lbl = c.get("category_label")
|
|
61
|
+
if lbl:
|
|
62
|
+
labels.append(str(lbl))
|
|
63
|
+
if labels:
|
|
64
|
+
return labels
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@register_tool(ActionType.CATALOG_SEARCH)
|
|
69
|
+
class CatalogSearchTool(ToolBase):
|
|
70
|
+
"""데이터 소스 카탈로그(표 목록) 검색.
|
|
71
|
+
|
|
72
|
+
여러 source 중 *config.data_sources.default_source* (또는 input.source 명시) 사용.
|
|
73
|
+
DataSource 추상화 덕분에 *KOSIS 외 회사 자체 DB/CSV*도 동일 인터페이스로 검색.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
name = ActionType.CATALOG_SEARCH
|
|
77
|
+
description = (
|
|
78
|
+
"데이터 소스의 표/지표 카탈로그를 키워드로 검색. "
|
|
79
|
+
"한 번 시도해서 잘못된 표가 나오면 *다른 검색어*로 재시도 권장. "
|
|
80
|
+
"memory에 *이미 시도한 검색어* 있으면 중복 금지."
|
|
81
|
+
)
|
|
82
|
+
input_schema = {
|
|
83
|
+
"query": "검색 키워드 (한국어, 3-6단어 권장). 예: '출생아 수 인구동향'",
|
|
84
|
+
"category": "(선택) 분류 힌트 리스트. 예: ['인구', '출생']",
|
|
85
|
+
"top_k": "(선택) 최대 후보 수. 기본 5",
|
|
86
|
+
"source": "(선택) 데이터 소스 이름. 기본은 config.data_sources.default_source",
|
|
87
|
+
"force_explore": (
|
|
88
|
+
"(선택) true면 catalog top1 점수와 무관하게 deep_explore "
|
|
89
|
+
"(top 표들에서 sample row 가져와 LLM이 row 단서 기반 reasoning) "
|
|
90
|
+
"강제 발동. fetch_evidence가 row를 못 찾아 회복이 필요할 때 사용."
|
|
91
|
+
),
|
|
92
|
+
"explore_mode": (
|
|
93
|
+
"(선택) force_explore=true 시 사용할 방식. 'meta'(기본, 권장): KOSIS "
|
|
94
|
+
"getMeta(ITM/OBJ) 호출로 표 항목/분류 list만 받아 LLM이 정답 표 식별 "
|
|
95
|
+
"(빠름, ~표당 1초). 'row_preview': 표 전체 row preview 받아 LLM이 외삽 "
|
|
96
|
+
"reasoning (느림, 표당 22초). 'meta'가 정답 표 직접 식별에 유리."
|
|
97
|
+
),
|
|
98
|
+
"query_rewrite": (
|
|
99
|
+
"(선택) true면 catalog_search 실행 *전에* LLM이 query를 표 이름 친화 "
|
|
100
|
+
"어휘로 변형(예: '체외 충격파 쇄석술 장비 수' → '시군구별 의료장비'). "
|
|
101
|
+
"원본 query에 row-level keyword만 있어 catalog 후보에 정답 표가 안 들어올 "
|
|
102
|
+
"때 사용. 각 변형으로 search → 합집합 반환."
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async def execute(
|
|
107
|
+
self,
|
|
108
|
+
input_data: dict[str, Any],
|
|
109
|
+
context: ToolContext,
|
|
110
|
+
) -> ToolResult:
|
|
111
|
+
query = (input_data.get("query") or "").strip()
|
|
112
|
+
if not query:
|
|
113
|
+
return ToolResult(
|
|
114
|
+
output={},
|
|
115
|
+
summary="실패: query 비어있음",
|
|
116
|
+
success=False,
|
|
117
|
+
error="query는 비어있을 수 없습니다.",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
category = input_data.get("category") or None
|
|
121
|
+
if category is not None and not isinstance(category, list):
|
|
122
|
+
category = [str(category)]
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
top_k = int(input_data.get("top_k") or 15)
|
|
126
|
+
except (TypeError, ValueError):
|
|
127
|
+
top_k = 10
|
|
128
|
+
# [P29' 2026-05-22] 기본 5 → 15. catalog 임베딩이 표 이름만 보는 한계로
|
|
129
|
+
# row-level keyword 쿼리(예: "체외 충격파 쇄석술 장비")는 정답 표가
|
|
130
|
+
# cosine 6~15위에 깔리는 경우가 있어 top 5만 노출하면 reflect/deep_explore
|
|
131
|
+
# 모두 정답에 접근 불가. 19개 합집합을 거의 그대로 노출해도 prompt
|
|
132
|
+
# context 부담은 적당하고 정답 진입률 ↑.
|
|
133
|
+
top_k = max(15, min(top_k, 20))
|
|
134
|
+
|
|
135
|
+
# ── [패치 R1.5] explore_catalog 결과 자동 활용 ─────────────────
|
|
136
|
+
# LLM이 explore_catalog 결과를 무시하고 자기 머릿속 카테고리 어휘를
|
|
137
|
+
# 그대로 catalog_search에 넘기는 케이스가 잦다 (예: ['기후 변화', '날씨 정보']).
|
|
138
|
+
# 직전 explore observation을 자동 읽어, LLM 카테고리에 explore가 추천한
|
|
139
|
+
# top 2 카테고리를 union으로 추가. 임베딩 의미 검색이 찾아준 정확한
|
|
140
|
+
# KOSIS 카테고리 어휘 (예: '기상관측통계')가 ILIKE 필터에 들어가
|
|
141
|
+
# 무관한 카테고리만 봐서 헛돌이가 되는 걸 방지.
|
|
142
|
+
try:
|
|
143
|
+
explored_cats = _read_last_explore_categories(
|
|
144
|
+
context.workspace, context.claim_id, top_n=2,
|
|
145
|
+
)
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logger.debug(f"[catalog_search] explore observation 읽기 실패: {e}")
|
|
148
|
+
explored_cats = []
|
|
149
|
+
if explored_cats:
|
|
150
|
+
existing = set(category or [])
|
|
151
|
+
new_cats = [c for c in explored_cats if c and c not in existing]
|
|
152
|
+
if new_cats:
|
|
153
|
+
category = list(category or []) + new_cats
|
|
154
|
+
logger.info(
|
|
155
|
+
f"[catalog_search] 직전 explore_catalog top 카테고리 자동 추가: "
|
|
156
|
+
f"{new_cats} → 최종 category={category}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# source 선택
|
|
160
|
+
ds_config = context.config.get("data_sources", {}) if context.config else {}
|
|
161
|
+
# default_source 미지정이면 enabled 첫 소스로 폴백 (하드코딩 kosis 방지)
|
|
162
|
+
default_source = ds_config.get("default_source") or (ds_config.get("enabled") or ["kosis"])[0]
|
|
163
|
+
source_name = (input_data.get("source") or default_source).strip()
|
|
164
|
+
# LLM이 미등록 소스(프롬프트 잔재 'kosis' 등)를 지정하면 사용 가능한 소스로 강제.
|
|
165
|
+
if context.datasources and source_name not in context.datasources:
|
|
166
|
+
_avail = list(context.datasources.keys())
|
|
167
|
+
source_name = (
|
|
168
|
+
default_source if default_source in context.datasources
|
|
169
|
+
else (_avail[0] if _avail else source_name)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# DataSource 인스턴스 찾기
|
|
173
|
+
source = context.datasources.get(source_name) if context.datasources else None
|
|
174
|
+
if source is None:
|
|
175
|
+
available = list(context.datasources.keys()) if context.datasources else []
|
|
176
|
+
return ToolResult(
|
|
177
|
+
output={"requested_source": source_name, "available": available},
|
|
178
|
+
summary=f"실패: source={source_name!r} 등록 안 됨. 가능: {available}",
|
|
179
|
+
success=False,
|
|
180
|
+
error=(
|
|
181
|
+
f"DataSource '{source_name}'이 context.datasources에 없습니다. "
|
|
182
|
+
f"가능한 source: {available}. config.data_sources.enabled 확인."
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# ── [P30 2026-05-22] query_rewrite — 검색 *전*에 LLM이 query 변형 ──
|
|
187
|
+
# query가 row-level keyword면 catalog 표 이름과 매칭이 약함. LLM이
|
|
188
|
+
# 표 이름 친화 어휘로 변형 후 각 변형으로 검색 → 합집합. 원본 + 변형
|
|
189
|
+
# 둘 다 시도해 정답 표 진입률 ↑.
|
|
190
|
+
_queries_to_search: list[str] = [query]
|
|
191
|
+
_qr_applied: list[str] = []
|
|
192
|
+
if bool(input_data.get("query_rewrite")) and context.claim is not None:
|
|
193
|
+
try:
|
|
194
|
+
from .query_rewriter import rewrite_query as _rewrite_query
|
|
195
|
+
_variations = await _rewrite_query(
|
|
196
|
+
query=query, claim=context.claim, config=context.config,
|
|
197
|
+
)
|
|
198
|
+
for v in _variations:
|
|
199
|
+
if v and v not in _queries_to_search:
|
|
200
|
+
_queries_to_search.append(v)
|
|
201
|
+
_qr_applied.append(v)
|
|
202
|
+
if _qr_applied:
|
|
203
|
+
logger.info(
|
|
204
|
+
f"[catalog_search] query_rewrite 적용 — 원본={query!r} + "
|
|
205
|
+
f"변형 {len(_qr_applied)}개: {_qr_applied}"
|
|
206
|
+
)
|
|
207
|
+
except Exception as _e:
|
|
208
|
+
logger.warning(f"[catalog_search] query_rewrite 실패: {_e}")
|
|
209
|
+
|
|
210
|
+
# [P31 2026-05-22] claim에서 schema 정보 추출 → source.search_catalog의
|
|
211
|
+
# context로 전달. KOSIS DataSource는 이걸 ConnectorQuery.extra_params에 묶어
|
|
212
|
+
# _extract_category_and_keyword에 전달하므로:
|
|
213
|
+
# - parent_path 있으면 LLM 카테고리 추출 *skip*하고 KOSIS 어휘 그대로 사용
|
|
214
|
+
# - raw_claim 있으면 LLM이 전체 문장 보고 더 정확한 카테고리/검색어
|
|
215
|
+
# claim/schema가 없으면 None인 채로 안전하게 동작.
|
|
216
|
+
_ctx_for_source: dict[str, Any] = {}
|
|
217
|
+
_claim = context.claim
|
|
218
|
+
if _claim is not None:
|
|
219
|
+
_claim_text = getattr(_claim, "claim_text", None)
|
|
220
|
+
if _claim_text:
|
|
221
|
+
_ctx_for_source["raw_claim"] = str(_claim_text)[:200]
|
|
222
|
+
_schema = getattr(_claim, "schema", None)
|
|
223
|
+
if _schema is not None:
|
|
224
|
+
# [2026-05-27 Fix B] time_period 포함 — catalog_search에서 시점 토큰
|
|
225
|
+
# 기반 union 검색을 발동시키기 위해 schema.time_period를 context에
|
|
226
|
+
# 실어 보낸다. kosis_source._make_query가 ConnectorQuery.time_period로
|
|
227
|
+
# 매핑하고 CatalogSearch가 "embedding_text + year"로 추가 pgvector
|
|
228
|
+
# 검색을 돌려 dedup union.
|
|
229
|
+
for _k in ("parent_path", "population", "indicator", "time_period"):
|
|
230
|
+
_v = getattr(_schema, _k, None)
|
|
231
|
+
if _v:
|
|
232
|
+
_ctx_for_source[_k] = str(_v)
|
|
233
|
+
|
|
234
|
+
# 검색 실행 — 단일 query 또는 다중 query 합집합
|
|
235
|
+
try:
|
|
236
|
+
if len(_queries_to_search) == 1:
|
|
237
|
+
candidates = await source.search_catalog(
|
|
238
|
+
query=query, category=category, top_k=top_k,
|
|
239
|
+
context=_ctx_for_source or None,
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
import asyncio as _aio
|
|
243
|
+
_all = await _aio.gather(
|
|
244
|
+
*[
|
|
245
|
+
source.search_catalog(
|
|
246
|
+
query=q, category=category, top_k=top_k,
|
|
247
|
+
context=_ctx_for_source or None,
|
|
248
|
+
)
|
|
249
|
+
for q in _queries_to_search
|
|
250
|
+
],
|
|
251
|
+
return_exceptions=True,
|
|
252
|
+
)
|
|
253
|
+
# 합집합 + 중복 제거 (id 기준)
|
|
254
|
+
candidates = []
|
|
255
|
+
_seen: set[str] = set()
|
|
256
|
+
for res in _all:
|
|
257
|
+
if isinstance(res, Exception):
|
|
258
|
+
continue
|
|
259
|
+
for c in (res or []):
|
|
260
|
+
cid = c.get("id") if hasattr(c, "get") else None
|
|
261
|
+
if not cid or cid in _seen:
|
|
262
|
+
continue
|
|
263
|
+
_seen.add(cid)
|
|
264
|
+
candidates.append(c)
|
|
265
|
+
# 점수순 정렬 (각 query의 score를 그대로 사용)
|
|
266
|
+
candidates.sort(
|
|
267
|
+
key=lambda c: float(c.get("score", 0.0) or 0.0),
|
|
268
|
+
reverse=True,
|
|
269
|
+
)
|
|
270
|
+
except Exception as e:
|
|
271
|
+
logger.exception(f"[catalog_search] source={source_name} query={query!r} 실패")
|
|
272
|
+
return ToolResult(
|
|
273
|
+
output={"source": source_name, "query": query},
|
|
274
|
+
summary=f"실패: catalog_search({source_name}) — {type(e).__name__}: {e}",
|
|
275
|
+
success=False,
|
|
276
|
+
error=f"{type(e).__name__}: {e}",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# 결과 정규화 (CatalogCandidate dict 호환)
|
|
280
|
+
normalized: list[dict[str, Any]] = []
|
|
281
|
+
for c in candidates or []:
|
|
282
|
+
# dict이거나 dict-like
|
|
283
|
+
d = dict(c) if hasattr(c, "items") else {}
|
|
284
|
+
normalized.append(d)
|
|
285
|
+
|
|
286
|
+
# ── [P33b 2026-05-22] 이전 fetch 실패 stat_id 제외 ────────────
|
|
287
|
+
# 같은 claim에서 catalog_search → fetch_evidence가 *관련성 거부* 또는
|
|
288
|
+
# *row 매칭 실패*로 None 반환한 표는 *다음 catalog_search에서도 다시
|
|
289
|
+
# 후보로 올라옴* (KOSIS 검색이 deterministic). reflect가 thought엔
|
|
290
|
+
# "다른 표 시도"라 쓰지만 input은 거의 같은 query라 결국 같은 5개
|
|
291
|
+
# 후보 → 같은 표 반복 거부 → 무한 헛돌이.
|
|
292
|
+
# workspace에 *이 claim에서 실패한 stat_id 목록*을 두고 결과에서 제거.
|
|
293
|
+
_failed_count = 0
|
|
294
|
+
try:
|
|
295
|
+
_failed_ids = set(
|
|
296
|
+
context.workspace.read_failed_stat_ids(context.claim_id)
|
|
297
|
+
if context.workspace else []
|
|
298
|
+
)
|
|
299
|
+
except Exception:
|
|
300
|
+
_failed_ids = set()
|
|
301
|
+
if _failed_ids:
|
|
302
|
+
_before = len(normalized)
|
|
303
|
+
normalized = [c for c in normalized if c.get("id") not in _failed_ids]
|
|
304
|
+
_failed_count = _before - len(normalized)
|
|
305
|
+
if _failed_count > 0:
|
|
306
|
+
logger.info(
|
|
307
|
+
f"[catalog_search] 이전 fetch 실패 stat_id {_failed_count}개 제외 "
|
|
308
|
+
f"({sorted(_failed_ids)[:5]}{'...' if len(_failed_ids) > 5 else ''}) "
|
|
309
|
+
f"— 같은 표 무한 반복 방지"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# ── [패치 D] job에서 이미 fetch 성공한 stat_id를 결과 맨 앞에 prepend ──
|
|
313
|
+
# 같은 KOSIS 표가 여러 지표(출생아 수/합계출산율/혼인 건수)를 같이
|
|
314
|
+
# 갖고 있는데 catalog는 검색어별로 다른 표를 top으로 주는 경우 대응.
|
|
315
|
+
# 예: '합계출산율' 검색 시 catalog top은 'DT_XNN0004(해외)'인데
|
|
316
|
+
# 같은 job의 다른 claim이 이미 'DT_1B8000G(국내 인구동향)'에서
|
|
317
|
+
# 성공했고 그 표 안에 합계출산율 row가 있으므로 이걸 1순위로
|
|
318
|
+
# 노출시켜 reflect agent가 fetch_evidence를 호출하게 유도.
|
|
319
|
+
try:
|
|
320
|
+
prior_ids = context.workspace.read_successful_stat_ids() if context.workspace else []
|
|
321
|
+
except Exception:
|
|
322
|
+
prior_ids = []
|
|
323
|
+
if prior_ids:
|
|
324
|
+
existing_ids = {c.get("id") for c in normalized if c.get("id")}
|
|
325
|
+
# [패치 D'] prepend 시 진짜 stat_name도 같이 보여줘서 LLM이
|
|
326
|
+
# "이 표 안에 다른 지표도 있을 가능성"을 판단할 수 있게 한다.
|
|
327
|
+
# workspace에 저장한 catalog observation에서 stat_id의 name을 찾아옴.
|
|
328
|
+
# [2026-05-21 P5] 이전 observation의 name엔 우리가 박아둔 "[...]"
|
|
329
|
+
# annotation이 누적될 수 있음 — 새로 prepend할 때 그걸 떼서 *원본 표 이름*만
|
|
330
|
+
# 가져오도록 정규화한다. 안 그러면 매 검색마다 "[표 안에 'X' 관련 ...]
|
|
331
|
+
# [표 안에 'Y' 관련 ...]" 식으로 거짓 힌트가 무한 누적된다.
|
|
332
|
+
import re as _re_local
|
|
333
|
+
_ANN_RE = _re_local.compile(r"\s*\[같은 job에서[^\]]*\]\s*")
|
|
334
|
+
def _clean_name(nm: str) -> str:
|
|
335
|
+
return _ANN_RE.sub("", nm or "").strip()
|
|
336
|
+
stat_names: dict[str, str] = {}
|
|
337
|
+
try:
|
|
338
|
+
for other_cid in context.workspace.list_claims():
|
|
339
|
+
for obs_name in context.workspace.list_observations(other_cid):
|
|
340
|
+
if "catalog_search" not in obs_name.lower():
|
|
341
|
+
continue
|
|
342
|
+
data = context.workspace.read_observation(other_cid, obs_name)
|
|
343
|
+
if not isinstance(data, dict):
|
|
344
|
+
continue
|
|
345
|
+
for cd in (data.get("output") or {}).get("candidates") or []:
|
|
346
|
+
sid = cd.get("id") if isinstance(cd, dict) else None
|
|
347
|
+
nm = cd.get("name") if isinstance(cd, dict) else None
|
|
348
|
+
if sid and nm and sid not in stat_names:
|
|
349
|
+
stat_names[sid] = _clean_name(nm)
|
|
350
|
+
if len(stat_names) > 50:
|
|
351
|
+
break # 충분히 많이 모음
|
|
352
|
+
except Exception as e:
|
|
353
|
+
logger.debug(f"[catalog_search] stat_name lookup 실패: {e}")
|
|
354
|
+
|
|
355
|
+
# [2026-05-21 P5] 각 prior stat_id가 *어떤 지표* 검증에 사용됐는지
|
|
356
|
+
# verified_facts에서 역추적. source 필드는 보통 "KOSIS:DT_..." 또는
|
|
357
|
+
# "kosis:DT_..." 형식이라 case-insensitive substring 매칭. 이게 있으면
|
|
358
|
+
# LLM이 "이 표는 출생아 수 검증에 썼던 표 — 합계출산율이랑 같은 인구동향
|
|
359
|
+
# 표일 가능성"을 판단할 수 있다. 거짓 "row 있을 가능성" 단언은 제거.
|
|
360
|
+
stat_id_to_indicators: dict[str, list[str]] = {}
|
|
361
|
+
try:
|
|
362
|
+
_facts = context.workspace.read_verified_facts() if context.workspace else []
|
|
363
|
+
except Exception:
|
|
364
|
+
_facts = []
|
|
365
|
+
for _f in _facts or []:
|
|
366
|
+
_src = str(_f.get("source") or "").lower()
|
|
367
|
+
_ind = str(_f.get("indicator") or "").strip()
|
|
368
|
+
if not _ind:
|
|
369
|
+
continue
|
|
370
|
+
for sid in prior_ids:
|
|
371
|
+
if sid.lower() in _src:
|
|
372
|
+
bucket = stat_id_to_indicators.setdefault(sid, [])
|
|
373
|
+
if _ind not in bucket:
|
|
374
|
+
bucket.append(_ind)
|
|
375
|
+
# sibling_evidence의 source는 "kosis:{stat_id}" 형식 — 같이 합쳐 정확도 ↑
|
|
376
|
+
try:
|
|
377
|
+
if hasattr(context.workspace, "_sibling_evidence_key"):
|
|
378
|
+
# 모든 sent_id를 모르므로 backend로 직접 읽기
|
|
379
|
+
_key = context.workspace._sibling_evidence_key()
|
|
380
|
+
if context.workspace.backend.exists(_key):
|
|
381
|
+
_sib_data = json.loads(
|
|
382
|
+
context.workspace.backend.read_text(_key)
|
|
383
|
+
)
|
|
384
|
+
if isinstance(_sib_data, dict):
|
|
385
|
+
for _sent_id, _entries in _sib_data.items():
|
|
386
|
+
if not isinstance(_entries, list):
|
|
387
|
+
continue
|
|
388
|
+
for _e in _entries:
|
|
389
|
+
if not isinstance(_e, dict):
|
|
390
|
+
continue
|
|
391
|
+
_src = str(_e.get("source") or "").lower()
|
|
392
|
+
_ind = str(_e.get("indicator") or "").strip()
|
|
393
|
+
if not _ind:
|
|
394
|
+
continue
|
|
395
|
+
for sid in prior_ids:
|
|
396
|
+
if sid.lower() in _src:
|
|
397
|
+
bucket = stat_id_to_indicators.setdefault(sid, [])
|
|
398
|
+
if _ind not in bucket:
|
|
399
|
+
bucket.append(_ind)
|
|
400
|
+
except Exception as e:
|
|
401
|
+
logger.debug(f"[catalog_search] sibling_evidence 읽기 실패 (무시): {e}")
|
|
402
|
+
|
|
403
|
+
# [P21 2026-05-22] prior_success score 완화 — 1.5 고정 X.
|
|
404
|
+
# 기존 1.5 박으면 *의미적으로 더 잘 맞는 catalog 결과 (score=1.0)*가
|
|
405
|
+
# 무조건 prior 표 뒤로 밀림. "치료 가능 사망률" claim에 의료장비 표
|
|
406
|
+
# (DT_35003_A7 prior)가 top이 되고 진짜 정답(DT_117049_A083 score=1.0)이
|
|
407
|
+
# 3순위로 깔리는 회귀 케이스 (22:54 로그).
|
|
408
|
+
# → prior bonus는 catalog top score 살짝 *아래*로. catalog 매칭이
|
|
409
|
+
# 명확하면(top score ≥ 0.9) prior는 *후순위*로 보이고, catalog가 약하면
|
|
410
|
+
# (top score < prior_bonus) prior가 자연스럽게 위로 올라옴.
|
|
411
|
+
_catalog_top_score = max(
|
|
412
|
+
(float(c.get("score", 0.0) or 0.0) for c in normalized),
|
|
413
|
+
default=0.0,
|
|
414
|
+
)
|
|
415
|
+
# prior score: catalog top score - 0.05. 단 최소 0.5는 보장 (catalog 결과
|
|
416
|
+
# 자체가 없거나 약할 때도 prior가 fetch 후보로 살아남도록).
|
|
417
|
+
_prior_score = max(_catalog_top_score - 0.05, 0.5)
|
|
418
|
+
prepend: list[dict[str, Any]] = []
|
|
419
|
+
for sid in prior_ids:
|
|
420
|
+
if sid in existing_ids:
|
|
421
|
+
continue
|
|
422
|
+
real_name = stat_names.get(sid, sid)
|
|
423
|
+
_prior_inds = stat_id_to_indicators.get(sid) or []
|
|
424
|
+
if _prior_inds:
|
|
425
|
+
_ind_label = ", ".join(f"'{x}'" for x in _prior_inds[:3])
|
|
426
|
+
_hint = (
|
|
427
|
+
f"[같은 job에서 {_ind_label} 검증에 사용된 표 — "
|
|
428
|
+
f"'{query}' row 존재는 fetch 시도로 확인]"
|
|
429
|
+
)
|
|
430
|
+
else:
|
|
431
|
+
# indicator 추적 못 한 경우 *중립적* 라벨 (거짓 단언 금지)
|
|
432
|
+
_hint = "[같은 job 다른 claim이 사용한 표 — 관련성 fetch로 확인]"
|
|
433
|
+
prepend.append({
|
|
434
|
+
"id": sid,
|
|
435
|
+
"name": f"{real_name} {_hint}",
|
|
436
|
+
"score": _prior_score,
|
|
437
|
+
"raw": {"from_job_success": True},
|
|
438
|
+
})
|
|
439
|
+
if prepend:
|
|
440
|
+
logger.info(
|
|
441
|
+
f"[catalog_search] job-success stat_id "
|
|
442
|
+
f"{[p['id'] for p in prepend]} prior 추가 "
|
|
443
|
+
f"(score={_prior_score:.3f}, catalog top={_catalog_top_score:.3f})"
|
|
444
|
+
)
|
|
445
|
+
# score 순으로 다시 정렬 — prior가 catalog top 위에 있을지 아래일지는
|
|
446
|
+
# score 비교에 맡김.
|
|
447
|
+
normalized = sorted(
|
|
448
|
+
prepend + normalized,
|
|
449
|
+
key=lambda c: float(c.get("score", 0.0) or 0.0),
|
|
450
|
+
reverse=True,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# ── [P28 2026-05-22] Deep Exploration (T1 — 사전 보강) ────────────
|
|
454
|
+
# catalog top1 점수가 낮거나 top1-top2 gap이 작으면, top N 표의 sample row를
|
|
455
|
+
# 가져와 LLM이 row 단서 기반 reasoning으로 best 표 추천. P21B와 달리:
|
|
456
|
+
# - top 3 (기본), 표당 row 5개, Y prdSe만 → KOSIS 부하 ↓
|
|
457
|
+
# - prompt: "row 단서로 더 파볼 가치 있는 표 외삽 추천" (best 단순 선택 X)
|
|
458
|
+
# - none_signal → output에 표시 → reflect가 query refinement 결정
|
|
459
|
+
# 또 force_explore=True (T2 회복용)면 점수 조건 무시하고 발동.
|
|
460
|
+
_cs_cfg = (context.config or {}).get("catalog_search") or {}
|
|
461
|
+
_dx_cfg = _cs_cfg.get("deep_explore") or {}
|
|
462
|
+
_dx_enabled = bool(_dx_cfg.get("enabled", False))
|
|
463
|
+
_force_explore = bool(input_data.get("force_explore"))
|
|
464
|
+
_explore_meta: dict[str, Any] | None = None
|
|
465
|
+
|
|
466
|
+
if _dx_enabled and normalized and context.claim is not None:
|
|
467
|
+
_low_score_thr = float(_dx_cfg.get("trigger_low_score") or 0.6)
|
|
468
|
+
_gap_thr = float(_dx_cfg.get("trigger_score_gap") or 0.1)
|
|
469
|
+
_top1_score = float(normalized[0].get("score", 0.0) or 0.0)
|
|
470
|
+
_top2_score = (
|
|
471
|
+
float(normalized[1].get("score", 0.0) or 0.0)
|
|
472
|
+
if len(normalized) >= 2 else 0.0
|
|
473
|
+
)
|
|
474
|
+
_t1_fire = (
|
|
475
|
+
_top1_score < _low_score_thr
|
|
476
|
+
or (_top1_score - _top2_score) < _gap_thr
|
|
477
|
+
)
|
|
478
|
+
# per-claim 발동 횟수 제한 (loop 방지)
|
|
479
|
+
_max_per_claim = int(_dx_cfg.get("max_per_claim") or 2)
|
|
480
|
+
_dx_obs_count = 0
|
|
481
|
+
try:
|
|
482
|
+
for _n in context.workspace.list_observations(context.claim_id):
|
|
483
|
+
if "deep_explore" in _n.lower():
|
|
484
|
+
_dx_obs_count += 1
|
|
485
|
+
except Exception:
|
|
486
|
+
pass
|
|
487
|
+
|
|
488
|
+
if (_force_explore or _t1_fire) and _dx_obs_count < _max_per_claim:
|
|
489
|
+
trigger_label = "T2/force" if _force_explore else "T1/score"
|
|
490
|
+
# [P30 2026-05-22] explore_mode 분기:
|
|
491
|
+
# - "meta" (기본): getMeta(ITM/OBJ) → 빠름 + 정확
|
|
492
|
+
# - "row_preview": 표 전체 row preview → 느림 + 외삽
|
|
493
|
+
# input의 explore_mode가 우선, 없으면 config의 default 사용.
|
|
494
|
+
_explore_mode = str(
|
|
495
|
+
input_data.get("explore_mode")
|
|
496
|
+
or _dx_cfg.get("explore_mode")
|
|
497
|
+
or "meta"
|
|
498
|
+
).strip().lower()
|
|
499
|
+
logger.info(
|
|
500
|
+
f"[catalog_search] deep_explore 발동 ({trigger_label}, "
|
|
501
|
+
f"mode={_explore_mode}) — top1={_top1_score:.3f}, "
|
|
502
|
+
f"gap={_top1_score-_top2_score:.3f}, prev_calls={_dx_obs_count}"
|
|
503
|
+
)
|
|
504
|
+
try:
|
|
505
|
+
if _explore_mode == "meta":
|
|
506
|
+
from .meta_explore import meta_explore as _explore_fn
|
|
507
|
+
else:
|
|
508
|
+
from .deep_explore import deep_explore as _explore_fn
|
|
509
|
+
_result = await _explore_fn(
|
|
510
|
+
query=query,
|
|
511
|
+
candidates=normalized,
|
|
512
|
+
claim=context.claim,
|
|
513
|
+
source=source,
|
|
514
|
+
workspace=context.workspace,
|
|
515
|
+
config=context.config,
|
|
516
|
+
)
|
|
517
|
+
if _result.used:
|
|
518
|
+
_explore_meta = {
|
|
519
|
+
"best_table_id": _result.best_table_id,
|
|
520
|
+
"reasoning": _result.reasoning,
|
|
521
|
+
"none_signal": _result.none_signal,
|
|
522
|
+
"previewed_ids": _result.previewed_ids,
|
|
523
|
+
"trigger": trigger_label,
|
|
524
|
+
"mode": _explore_mode,
|
|
525
|
+
}
|
|
526
|
+
# workspace observation으로도 별도 기록 (per-claim counter용)
|
|
527
|
+
try:
|
|
528
|
+
context.workspace.write_observation(
|
|
529
|
+
context.claim_id,
|
|
530
|
+
f"iter{context.iter_num:03d}_deep_explore",
|
|
531
|
+
_explore_meta,
|
|
532
|
+
)
|
|
533
|
+
except Exception as _e:
|
|
534
|
+
logger.debug(
|
|
535
|
+
f"[catalog_search] deep_explore observation 저장 실패: {_e}"
|
|
536
|
+
)
|
|
537
|
+
# best가 있으면 해당 표를 normalized top으로 승격
|
|
538
|
+
if _result.best_table_id and not _result.none_signal:
|
|
539
|
+
_ids = [c.get("id", "") for c in normalized]
|
|
540
|
+
if _result.best_table_id in _ids:
|
|
541
|
+
_idx = _ids.index(_result.best_table_id)
|
|
542
|
+
if _idx != 0:
|
|
543
|
+
normalized = (
|
|
544
|
+
[normalized[_idx]]
|
|
545
|
+
+ [c for i, c in enumerate(normalized) if i != _idx]
|
|
546
|
+
)
|
|
547
|
+
logger.info(
|
|
548
|
+
f"[catalog_search] deep_explore best="
|
|
549
|
+
f"{_result.best_table_id!r} → top1 승격"
|
|
550
|
+
)
|
|
551
|
+
except Exception as _e:
|
|
552
|
+
logger.warning(
|
|
553
|
+
f"[catalog_search] deep_explore 실패 (catalog 원본 유지): {_e}"
|
|
554
|
+
)
|
|
555
|
+
elif _dx_obs_count >= _max_per_claim:
|
|
556
|
+
logger.info(
|
|
557
|
+
f"[catalog_search] deep_explore skip — claim당 호출 한도 "
|
|
558
|
+
f"({_dx_obs_count}/{_max_per_claim})"
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
# ── [P21B 2026-05-22] Row-aware LLM rerank (P25에서 disable, P28과 별개) ───
|
|
562
|
+
# top N 표에 sample row 1개 fetch해 LLM이 claim과 가장 잘 매칭되는 표 선택.
|
|
563
|
+
# 임베딩 score만으론 "치료 가능 사망률"과 "사망률"·"의료장비"를 잘 못 구분
|
|
564
|
+
# 하는 케이스 (23:54 로그) 대응. P20 KOSIS cache hit이면 비용 거의 0.
|
|
565
|
+
_rerank_mode = str(_cs_cfg.get("rerank_mode") or "none").lower()
|
|
566
|
+
_rerank_top_n = int(_cs_cfg.get("rerank_top_n") or 5)
|
|
567
|
+
_rerank_gap = float(_cs_cfg.get("rerank_min_score_gap") or 0.15)
|
|
568
|
+
if (
|
|
569
|
+
_rerank_mode in ("row_preview", "table_name")
|
|
570
|
+
and len(normalized) >= 2
|
|
571
|
+
and context.claim is not None
|
|
572
|
+
):
|
|
573
|
+
try:
|
|
574
|
+
_rerank_top = normalized[:_rerank_top_n]
|
|
575
|
+
_top1 = float(_rerank_top[0].get("score", 0.0) or 0.0)
|
|
576
|
+
_top2 = float(_rerank_top[1].get("score", 0.0) or 0.0)
|
|
577
|
+
if _top1 - _top2 >= _rerank_gap:
|
|
578
|
+
logger.info(
|
|
579
|
+
f"[catalog_search] rerank skip — top1({_top1:.3f}) - "
|
|
580
|
+
f"top2({_top2:.3f}) gap={_top1-_top2:.3f} ≥ {_rerank_gap} "
|
|
581
|
+
f"(LLM call 절약, top1이 이미 명확)"
|
|
582
|
+
)
|
|
583
|
+
else:
|
|
584
|
+
_reranked = await _row_preview_rerank(
|
|
585
|
+
candidates=_rerank_top,
|
|
586
|
+
claim=context.claim,
|
|
587
|
+
source=source,
|
|
588
|
+
workspace=context.workspace,
|
|
589
|
+
config=context.config,
|
|
590
|
+
do_row_preview=(_rerank_mode == "row_preview"),
|
|
591
|
+
)
|
|
592
|
+
if _reranked:
|
|
593
|
+
# rerank된 top + 남은 후보 (원래 score 순서)
|
|
594
|
+
_reranked_ids = {c.get("id") for c in _reranked}
|
|
595
|
+
_rest = [c for c in normalized if c.get("id") not in _reranked_ids]
|
|
596
|
+
normalized = _reranked + _rest
|
|
597
|
+
logger.info(
|
|
598
|
+
f"[catalog_search] LLM rerank 적용 — "
|
|
599
|
+
f"top1={normalized[0].get('id')!r}"
|
|
600
|
+
)
|
|
601
|
+
except Exception as _e:
|
|
602
|
+
logger.warning(f"[catalog_search] rerank 실패 (catalog 원본 유지): {_e}")
|
|
603
|
+
|
|
604
|
+
# workspace observation 저장 (raw)
|
|
605
|
+
try:
|
|
606
|
+
obs_name = f"iter{context.iter_num:03d}_catalog_search"
|
|
607
|
+
context.workspace.write_observation(
|
|
608
|
+
context.claim_id,
|
|
609
|
+
obs_name,
|
|
610
|
+
{"query": query, "category": category, "source": source_name,
|
|
611
|
+
"candidates": normalized},
|
|
612
|
+
)
|
|
613
|
+
except Exception as e:
|
|
614
|
+
logger.debug(f"[catalog_search] observation 저장 실패: {e}")
|
|
615
|
+
|
|
616
|
+
# 요약 (top 3 후보 이름)
|
|
617
|
+
top_names = []
|
|
618
|
+
for c in normalized[:3]:
|
|
619
|
+
cid = c.get("id", "")
|
|
620
|
+
cname = c.get("name", "")
|
|
621
|
+
score = c.get("score")
|
|
622
|
+
top_names.append(
|
|
623
|
+
f"[{cid}] {cname}" + (f" (score={score:.3f})" if isinstance(score, (int, float)) else "")
|
|
624
|
+
)
|
|
625
|
+
summary_qr = (
|
|
626
|
+
f" | query_rewrite +{len(_qr_applied)}" if _qr_applied else ""
|
|
627
|
+
)
|
|
628
|
+
summary = (
|
|
629
|
+
f"catalog_search({source_name}) query={query!r} → "
|
|
630
|
+
f"{len(normalized)}개 후보{summary_qr}. "
|
|
631
|
+
f"Top: {' | '.join(top_names) if top_names else '(없음)'}"
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
_out: dict[str, Any] = {
|
|
635
|
+
"source": source_name,
|
|
636
|
+
"query": query,
|
|
637
|
+
"category": category,
|
|
638
|
+
"candidates": normalized,
|
|
639
|
+
"candidate_count": len(normalized),
|
|
640
|
+
}
|
|
641
|
+
if _qr_applied:
|
|
642
|
+
_out["_query_rewrite"] = {
|
|
643
|
+
"original": query,
|
|
644
|
+
"variations": _qr_applied,
|
|
645
|
+
}
|
|
646
|
+
if _explore_meta is not None:
|
|
647
|
+
_out["_deep_explore"] = _explore_meta
|
|
648
|
+
# summary에도 신호 추가 — reflect가 observation summary만 봐도 알도록
|
|
649
|
+
if _explore_meta.get("none_signal"):
|
|
650
|
+
summary += (
|
|
651
|
+
" | deep_explore: top 표들의 sample row에서 적합한 row 없음 — "
|
|
652
|
+
"다른 검색어로 catalog_search 재시도 권장."
|
|
653
|
+
)
|
|
654
|
+
elif _explore_meta.get("best_table_id"):
|
|
655
|
+
summary += (
|
|
656
|
+
f" | deep_explore best={_explore_meta['best_table_id']} "
|
|
657
|
+
f"({_explore_meta.get('reasoning', '')[:60]})"
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
return ToolResult(
|
|
661
|
+
output=_out,
|
|
662
|
+
summary=summary,
|
|
663
|
+
success=True,
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
# ────────────────────────────────────────────────────────────────────
|
|
668
|
+
# [P21B 2026-05-22] Row-aware LLM rerank 헬퍼
|
|
669
|
+
# ────────────────────────────────────────────────────────────────────
|
|
670
|
+
import asyncio as _asyncio
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
async def _row_preview_rerank(
|
|
674
|
+
candidates: list[dict[str, Any]],
|
|
675
|
+
claim: Any,
|
|
676
|
+
source: Any,
|
|
677
|
+
workspace: Any,
|
|
678
|
+
config: dict | None,
|
|
679
|
+
do_row_preview: bool = True,
|
|
680
|
+
) -> list[dict[str, Any]] | None:
|
|
681
|
+
"""top N 후보에 대해 (옵션) sample row fetch + LLM rerank.
|
|
682
|
+
|
|
683
|
+
Returns:
|
|
684
|
+
rerank된 후보 list (best가 [0]). 실패/no-op이면 None.
|
|
685
|
+
"""
|
|
686
|
+
if not candidates:
|
|
687
|
+
return None
|
|
688
|
+
|
|
689
|
+
# 1) (옵션) preview fetch — newEstPrdCnt=1로 KOSIS 1 row만 받아옴.
|
|
690
|
+
# P20 cache hit이면 즉시 반환. cold이면 KOSIS API 호출.
|
|
691
|
+
previews: dict[str, dict] = {}
|
|
692
|
+
if do_row_preview:
|
|
693
|
+
async def _preview(cid: str) -> tuple[str, dict | None]:
|
|
694
|
+
try:
|
|
695
|
+
ev = await source.fetch_evidence(
|
|
696
|
+
candidate_id=cid,
|
|
697
|
+
params={
|
|
698
|
+
"newEstPrdCnt": "1", # 가장 최근 1 시점만
|
|
699
|
+
"_preview": True,
|
|
700
|
+
},
|
|
701
|
+
workspace=workspace,
|
|
702
|
+
)
|
|
703
|
+
if ev is None:
|
|
704
|
+
return cid, None
|
|
705
|
+
rows = ev.get("rows") or []
|
|
706
|
+
first = rows[0] if rows else {}
|
|
707
|
+
# 핵심 컬럼만 추출 (LLM 토큰 절약)
|
|
708
|
+
_PICK = ("ITM_NM", "C1_NM", "C2_NM", "C3_NM", "C4_NM",
|
|
709
|
+
"PRD_DE", "UNIT_NM")
|
|
710
|
+
row_summary = {k: first.get(k) for k in _PICK if first.get(k)}
|
|
711
|
+
return cid, {
|
|
712
|
+
"stat_name": ev.get("stat_name") or "",
|
|
713
|
+
"sample_row": row_summary,
|
|
714
|
+
"rows_count": len(rows),
|
|
715
|
+
}
|
|
716
|
+
except Exception as _e:
|
|
717
|
+
logger.debug(f"[catalog_search.rerank] preview fetch 실패 {cid}: {_e}")
|
|
718
|
+
return cid, None
|
|
719
|
+
|
|
720
|
+
results = await _asyncio.gather(
|
|
721
|
+
*[_preview(c.get("id", "")) for c in candidates if c.get("id")],
|
|
722
|
+
return_exceptions=False,
|
|
723
|
+
)
|
|
724
|
+
for cid, info in results:
|
|
725
|
+
if info is not None:
|
|
726
|
+
previews[cid] = info
|
|
727
|
+
|
|
728
|
+
# 2) LLM 호출 — claim + 후보들 + preview를 던지고 best 1개 선택
|
|
729
|
+
from structverify.utils.llm_client import LLMClient
|
|
730
|
+
_llm = LLMClient(config=(config or {}).get("llm") or {})
|
|
731
|
+
|
|
732
|
+
# claim 정보 추출
|
|
733
|
+
_schema = getattr(claim, "schema", None)
|
|
734
|
+
_info = {
|
|
735
|
+
"indicator": (getattr(_schema, "indicator", None) or "") if _schema else "",
|
|
736
|
+
"time_period": (getattr(_schema, "time_period", None) or "") if _schema else "",
|
|
737
|
+
"population": (getattr(_schema, "population", None) or "") if _schema else "",
|
|
738
|
+
"unit": (getattr(_schema, "unit", None) or "") if _schema else "",
|
|
739
|
+
"value": (getattr(_schema, "value", None)) if _schema else None,
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
# 후보 리스트 → 프롬프트 텍스트
|
|
743
|
+
_cand_lines: list[str] = []
|
|
744
|
+
for i, c in enumerate(candidates, start=1):
|
|
745
|
+
cid = c.get("id", "")
|
|
746
|
+
cname = (c.get("name", "") or "").strip()
|
|
747
|
+
cscore = c.get("score")
|
|
748
|
+
_line = f"{i}. [{cid}] {cname}"
|
|
749
|
+
if isinstance(cscore, (int, float)):
|
|
750
|
+
_line += f" (catalog_score={cscore:.3f})"
|
|
751
|
+
_preview = previews.get(cid)
|
|
752
|
+
if _preview and _preview.get("sample_row"):
|
|
753
|
+
_row = _preview["sample_row"]
|
|
754
|
+
_row_str = ", ".join(f"{k}={v!r}" for k, v in _row.items())
|
|
755
|
+
_line += f"\n sample row → {_row_str}"
|
|
756
|
+
_line += f" | rows_count={_preview.get('rows_count')}"
|
|
757
|
+
_cand_lines.append(_line)
|
|
758
|
+
|
|
759
|
+
_prompt = f"""다음 사실검증 claim에 *가장 적합한 KOSIS 통계표 1개*를 선택하세요.
|
|
760
|
+
|
|
761
|
+
[Claim]
|
|
762
|
+
- indicator (검증 대상 지표): {_info['indicator']!r}
|
|
763
|
+
- population (대상 집단): {_info['population']!r}
|
|
764
|
+
- time_period: {_info['time_period']!r}
|
|
765
|
+
- unit: {_info['unit']!r}
|
|
766
|
+
- value: {_info['value']}
|
|
767
|
+
|
|
768
|
+
[후보 표 (catalog 검색 score 순)]
|
|
769
|
+
{chr(10).join(_cand_lines)}
|
|
770
|
+
|
|
771
|
+
[선택 기준]
|
|
772
|
+
- ITM_NM 또는 C1_NM~C4_NM 어느 컬럼에 claim의 indicator가 *직접 포함*되거나 *의미적으로 매칭*되는 표 우선.
|
|
773
|
+
- C1_NM/C2_NM 등이 claim.population (지역/대상)을 포함하는 표 우선.
|
|
774
|
+
- 표 이름이 claim과 유사해 보이더라도 sample row의 ITM_NM이 무관하면 *배제*.
|
|
775
|
+
(예: "치료 가능 사망률" claim에 sample row ITM_NM='의료장비'인 표는 배제)
|
|
776
|
+
- preview가 없는 표는 *catalog score만으로 추정* (불확실하다고 답해도 OK).
|
|
777
|
+
|
|
778
|
+
[응답 형식 — JSON only, 다른 텍스트 금지]
|
|
779
|
+
{{
|
|
780
|
+
"best_stat_id": "DT_XXX",
|
|
781
|
+
"reason": "한 줄 이유"
|
|
782
|
+
}}
|
|
783
|
+
|
|
784
|
+
선택을 못 하겠으면 best_stat_id를 첫 번째 후보 ID로.
|
|
785
|
+
"""
|
|
786
|
+
|
|
787
|
+
try:
|
|
788
|
+
raw = await _llm.generate(
|
|
789
|
+
prompt=_prompt,
|
|
790
|
+
system_prompt="KOSIS 통계표 선정 전문가. JSON으로만 답하세요.",
|
|
791
|
+
model_tier="light",
|
|
792
|
+
)
|
|
793
|
+
except Exception as _e:
|
|
794
|
+
logger.warning(f"[catalog_search.rerank] LLM 호출 실패: {_e}")
|
|
795
|
+
return None
|
|
796
|
+
|
|
797
|
+
# JSON 파싱 (관대하게)
|
|
798
|
+
import json as _json
|
|
799
|
+
import re as _re
|
|
800
|
+
_best: str | None = None
|
|
801
|
+
try:
|
|
802
|
+
# raw에서 JSON 블록 추출
|
|
803
|
+
_match = _re.search(r"\{[^{}]*\}", raw, _re.DOTALL)
|
|
804
|
+
if _match:
|
|
805
|
+
data = _json.loads(_match.group(0))
|
|
806
|
+
_raw_best = (data.get("best_stat_id") or "").strip() or None
|
|
807
|
+
# [P24 2026-05-22] LLM이 brackets/quotes 포함해서 반환하는 케이스 정규화.
|
|
808
|
+
# prompt에 후보를 "1. [DT_XXX] 표이름" 형식으로 노출하니 LLM이 그대로
|
|
809
|
+
# "[DT_XXX]" 복사하는 일이 잦음. brackets/quotes/공백 strip + (옵션)
|
|
810
|
+
# candidates list에 substring 매칭 fallback.
|
|
811
|
+
if _raw_best:
|
|
812
|
+
_best = _raw_best.strip().strip("[]").strip("'\"").strip()
|
|
813
|
+
if _best != _raw_best:
|
|
814
|
+
logger.info(
|
|
815
|
+
f"[catalog_search.rerank] best 정규화: {_raw_best!r} → {_best!r}"
|
|
816
|
+
)
|
|
817
|
+
_reason = data.get("reason") or ""
|
|
818
|
+
if _best:
|
|
819
|
+
logger.info(
|
|
820
|
+
f"[catalog_search.rerank] LLM 선택: best={_best!r} "
|
|
821
|
+
f"reason={_reason[:80]!r}"
|
|
822
|
+
)
|
|
823
|
+
except Exception as _e:
|
|
824
|
+
logger.debug(f"[catalog_search.rerank] LLM 응답 파싱 실패: {_e}")
|
|
825
|
+
|
|
826
|
+
if not _best:
|
|
827
|
+
return None
|
|
828
|
+
|
|
829
|
+
# best를 0번으로 끌어올리기
|
|
830
|
+
_ids = [c.get("id", "") for c in candidates]
|
|
831
|
+
# [P24] substring fallback — 여전히 매칭 안 되면 candidates 중 best가 substring으로
|
|
832
|
+
# 들어가는 표 찾기 (예: LLM이 'DT_117049_A083_2020' 대신 'DT_117049_A083' 반환).
|
|
833
|
+
if _best not in _ids:
|
|
834
|
+
_substring_match = None
|
|
835
|
+
for _cid in _ids:
|
|
836
|
+
if not _cid:
|
|
837
|
+
continue
|
|
838
|
+
if _best in _cid or _cid in _best:
|
|
839
|
+
_substring_match = _cid
|
|
840
|
+
break
|
|
841
|
+
if _substring_match:
|
|
842
|
+
logger.info(
|
|
843
|
+
f"[catalog_search.rerank] best={_best!r} substring 매칭 → "
|
|
844
|
+
f"{_substring_match!r} 사용"
|
|
845
|
+
)
|
|
846
|
+
_best = _substring_match
|
|
847
|
+
else:
|
|
848
|
+
logger.info(
|
|
849
|
+
f"[catalog_search.rerank] best={_best!r}가 후보 list "
|
|
850
|
+
f"{_ids[:5]}에 없음 — rerank skip"
|
|
851
|
+
)
|
|
852
|
+
return None
|
|
853
|
+
_best_idx = _ids.index(_best)
|
|
854
|
+
if _best_idx == 0:
|
|
855
|
+
return None # 이미 top — no-op
|
|
856
|
+
_reordered = [candidates[_best_idx]] + [
|
|
857
|
+
c for i, c in enumerate(candidates) if i != _best_idx
|
|
858
|
+
]
|
|
859
|
+
return _reordered
|