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,423 @@
|
|
|
1
|
+
"""
|
|
2
|
+
structverify.agent.tools.explore_catalog — 카탈로그 어휘 탐색 Tool.
|
|
3
|
+
|
|
4
|
+
용도: LLM이 어떤 카테고리 어휘를 써야 할지 모를 때 우선 호출.
|
|
5
|
+
KOSIS 카탈로그가 실제로 쓰는 분류명/대표 표를 보여줘서,
|
|
6
|
+
룰베이스 도메인 매핑 없이도 LLM이 정확한 catalog_search query/category를
|
|
7
|
+
만들 수 있도록 self-learning 시킨다.
|
|
8
|
+
|
|
9
|
+
[패치 R1] 검색을 **임베딩 기반**으로 재구현. 기존 ILIKE 키워드 매칭은
|
|
10
|
+
'연평균 기온' 같은 합성어를 못 잡아 적합 카테고리('기상관측통계') 누락.
|
|
11
|
+
이제 query embedding → pgvector 거리 정렬로 top N 표 → 그 표들의
|
|
12
|
+
category_path 분포 집계. 의미 매칭이므로 키워드가 표 이름에 직접
|
|
13
|
+
안 들어가도 가까운 카테고리를 정확히 찾는다.
|
|
14
|
+
|
|
15
|
+
예시:
|
|
16
|
+
query="연평균 기온"
|
|
17
|
+
→ query embedding과 가까운 top 100 표를 거리 정렬
|
|
18
|
+
→ 그 표들이 속한 카테고리 집계:
|
|
19
|
+
1. 기상관측통계 (45개) [DT_14102_B001 [종관기상] ..., DT_14104_N_002 ...]
|
|
20
|
+
2. 환경 (12개) [DT_2OEEG008 연평균 기온 변화, ...]
|
|
21
|
+
3. ...
|
|
22
|
+
→ LLM이 정확한 cat_label로 catalog_search 호출
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
from typing import Any
|
|
28
|
+
from collections import defaultdict
|
|
29
|
+
from structverify.utils.logger import get_logger
|
|
30
|
+
from structverify.utils.embedding_client import EmbeddingClient
|
|
31
|
+
|
|
32
|
+
from ..schemas import ActionType
|
|
33
|
+
from .base import ToolBase, ToolContext, ToolResult, register_tool
|
|
34
|
+
|
|
35
|
+
logger = get_logger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@register_tool(ActionType.EXPLORE_CATALOG)
|
|
39
|
+
class ExploreCatalogTool(ToolBase):
|
|
40
|
+
"""카탈로그 카테고리 + 대표 표 탐색 — LLM의 self-learning용 (임베딩 기반)."""
|
|
41
|
+
|
|
42
|
+
name = ActionType.EXPLORE_CATALOG
|
|
43
|
+
description = (
|
|
44
|
+
"카탈로그의 카테고리 분포 + 대표 표 미리보기 (임베딩 의미 검색). "
|
|
45
|
+
"어떤 분류 어휘를 써야 할지 모를 때 catalog_search 전에 먼저 호출. "
|
|
46
|
+
"결과의 category_label을 catalog_search의 category 인자에 그대로 넣어라."
|
|
47
|
+
)
|
|
48
|
+
input_schema = {
|
|
49
|
+
"query": "(선택) 관심 주제 키워드. 비우면 전체 대분류 분포.",
|
|
50
|
+
"top_categories": "(선택) 보여줄 카테고리 수. 기본 5",
|
|
51
|
+
"examples_per_category": "(선택) 카테고리당 대표 표 수. 기본 2",
|
|
52
|
+
"scan_pool": "(선택) 임베딩 검색 풀 크기. 기본 100",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# 임베딩 helper 캐시 (CatalogSearchTool 인스턴스 재사용)
|
|
56
|
+
_embedding_helper = None
|
|
57
|
+
|
|
58
|
+
async def execute(
|
|
59
|
+
self,
|
|
60
|
+
input_data: dict[str, Any],
|
|
61
|
+
context: ToolContext,
|
|
62
|
+
) -> ToolResult:
|
|
63
|
+
query = (input_data.get("query") or "").strip()
|
|
64
|
+
# [#67-D] context.config.embedding 을 _embed_query가 쓰도록 self에 보관.
|
|
65
|
+
self._embedding_config = (context.config or {}).get("embedding") or {}
|
|
66
|
+
|
|
67
|
+
def _safe_int(k, default, lo, hi):
|
|
68
|
+
try:
|
|
69
|
+
v = int(input_data.get(k) or default)
|
|
70
|
+
except (TypeError, ValueError):
|
|
71
|
+
v = default
|
|
72
|
+
return max(lo, min(v, hi))
|
|
73
|
+
|
|
74
|
+
top_categories = _safe_int("top_categories", 5, 1, 15)
|
|
75
|
+
examples_per = _safe_int("examples_per_category", 2, 1, 5)
|
|
76
|
+
scan_pool = _safe_int("scan_pool", 100, 20, 300)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
import asyncpg
|
|
80
|
+
except ImportError:
|
|
81
|
+
return ToolResult(
|
|
82
|
+
output={},
|
|
83
|
+
summary="실패: asyncpg 미설치 — pgvector 탐색 불가",
|
|
84
|
+
success=False,
|
|
85
|
+
error="asyncpg not installed",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
pg_dsn = os.environ.get(
|
|
89
|
+
"PGVECTOR_DSN",
|
|
90
|
+
"postgresql://structverify:svpass123@localhost:5432/structverify",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
conn = await asyncpg.connect(pg_dsn)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.warning(f"[explore_catalog] DB 연결 실패: {e}")
|
|
97
|
+
return ToolResult(
|
|
98
|
+
output={},
|
|
99
|
+
summary=f"실패: 카탈로그 DB 연결 — {type(e).__name__}: {e}",
|
|
100
|
+
success=False,
|
|
101
|
+
error=f"{type(e).__name__}: {e}",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
if query:
|
|
106
|
+
categories_info = await self._fetch_by_embedding(
|
|
107
|
+
conn, query, top_categories, examples_per, scan_pool
|
|
108
|
+
)
|
|
109
|
+
used_method = "embedding"
|
|
110
|
+
# 임베딩 실패 시 ILIKE fallback
|
|
111
|
+
if not categories_info:
|
|
112
|
+
logger.info(
|
|
113
|
+
f"[explore_catalog] embedding 결과 없음 → ILIKE fallback"
|
|
114
|
+
)
|
|
115
|
+
categories_info = await self._fetch_by_ilike(
|
|
116
|
+
conn, query, top_categories, examples_per
|
|
117
|
+
)
|
|
118
|
+
used_method = "ilike_fallback"
|
|
119
|
+
else:
|
|
120
|
+
# query 없으면 전체 대분류 분포
|
|
121
|
+
categories_info = await self._fetch_all_categories(
|
|
122
|
+
conn, top_categories, examples_per
|
|
123
|
+
)
|
|
124
|
+
used_method = "overview"
|
|
125
|
+
except Exception as e:
|
|
126
|
+
logger.exception(f"[explore_catalog] 쿼리 실패: query={query!r}")
|
|
127
|
+
try:
|
|
128
|
+
await conn.close()
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
return ToolResult(
|
|
132
|
+
output={},
|
|
133
|
+
summary=f"실패: 카탈로그 쿼리 — {type(e).__name__}: {e}",
|
|
134
|
+
success=False,
|
|
135
|
+
error=f"{type(e).__name__}: {e}",
|
|
136
|
+
)
|
|
137
|
+
finally:
|
|
138
|
+
try:
|
|
139
|
+
await conn.close()
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
# LLM 친화 포맷 — sim 값은 (정규화되지 않은) 임베딩이라 절대값이 큰 음수로
|
|
144
|
+
# 표시될 수 있어 LLM에 혼란. 순위만 보여주고 sim은 숨김.
|
|
145
|
+
lines: list[str] = []
|
|
146
|
+
for idx, info in enumerate(categories_info, start=1):
|
|
147
|
+
cat_label = info["category_label"]
|
|
148
|
+
count = info["table_count"]
|
|
149
|
+
lines.append(f"{idx}. {cat_label} ({count}개 표)")
|
|
150
|
+
for ex in info["examples"]:
|
|
151
|
+
lines.append(f" - [{ex['stat_id']}] {ex['stat_name']}")
|
|
152
|
+
summary_block = "\n".join(lines) if lines else "(카탈로그에 매칭 카테고리 없음)"
|
|
153
|
+
|
|
154
|
+
# [R1.5] 다음 단계 명령을 *복사 가능한 JSON 형식*으로 명시.
|
|
155
|
+
# LLM이 자기 어휘로 catalog_search category를 만드는 걸 방지.
|
|
156
|
+
# (시스템은 R1.5 패치로 자동 union 하지만, 프롬프트도 강화.)
|
|
157
|
+
top_labels = [info["category_label"] for info in categories_info[:2]]
|
|
158
|
+
if top_labels:
|
|
159
|
+
cat_json = '["' + '", "'.join(top_labels) + '"]'
|
|
160
|
+
next_step = (
|
|
161
|
+
f"→ 다음 단계 (필수): catalog_search 호출 시 input.category 인자에 "
|
|
162
|
+
f"위 1·2위 카테고리를 *그대로 복사*해서 사용하라:\n"
|
|
163
|
+
f' input.category = {cat_json}\n'
|
|
164
|
+
f" query는 claim의 핵심 지표명(예: indicator)을 그대로 쓸 것. "
|
|
165
|
+
f"임의의 자유어('기후 변화', '날씨 정보' 같은)는 사용 금지."
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
next_step = (
|
|
169
|
+
"→ 다음 단계: explore_catalog를 더 specific한 query로 재시도하거나, "
|
|
170
|
+
"catalog_search에 query만 넣고 호출 (category 없이)."
|
|
171
|
+
)
|
|
172
|
+
summary_head = (
|
|
173
|
+
f"explore_catalog(query={query!r}, method={used_method}): "
|
|
174
|
+
f"{len(categories_info)}개 카테고리\n{summary_block}\n\n{next_step}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# observation 저장
|
|
178
|
+
try:
|
|
179
|
+
obs_name = f"iter{context.iter_num:03d}_explore_catalog"
|
|
180
|
+
context.workspace.write_observation(
|
|
181
|
+
context.claim_id,
|
|
182
|
+
obs_name,
|
|
183
|
+
{
|
|
184
|
+
"query": query,
|
|
185
|
+
"method": used_method,
|
|
186
|
+
"categories": categories_info,
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.debug(f"[explore_catalog] observation 저장 실패: {e}")
|
|
191
|
+
|
|
192
|
+
return ToolResult(
|
|
193
|
+
output={
|
|
194
|
+
"query": query,
|
|
195
|
+
"method": used_method,
|
|
196
|
+
"categories": categories_info,
|
|
197
|
+
"category_count": len(categories_info),
|
|
198
|
+
},
|
|
199
|
+
summary=summary_head,
|
|
200
|
+
success=True,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# ── 임베딩 기반 탐색 (주 경로) ──────────────────────────────────
|
|
204
|
+
|
|
205
|
+
async def _fetch_by_embedding(
|
|
206
|
+
self,
|
|
207
|
+
conn,
|
|
208
|
+
query: str,
|
|
209
|
+
top_categories: int,
|
|
210
|
+
examples_per: int,
|
|
211
|
+
scan_pool: int,
|
|
212
|
+
) -> list[dict[str, Any]]:
|
|
213
|
+
"""query 임베딩 → pgvector 거리 정렬 → top N 표의 카테고리 분포 집계."""
|
|
214
|
+
embedding = await self._embed_query(query)
|
|
215
|
+
if embedding is None:
|
|
216
|
+
logger.info(
|
|
217
|
+
f"[explore_catalog] 임베딩 생성 실패(API/key 문제) — ILIKE fallback 예정"
|
|
218
|
+
)
|
|
219
|
+
return []
|
|
220
|
+
|
|
221
|
+
vector_str = "[" + ",".join(str(v) for v in embedding) + "]"
|
|
222
|
+
rows = await conn.fetch(
|
|
223
|
+
"""
|
|
224
|
+
SELECT stat_id, stat_name, category_path,
|
|
225
|
+
1 - (embedding <-> $1::vector) AS similarity
|
|
226
|
+
FROM kosis_stat_catalog
|
|
227
|
+
WHERE embedding IS NOT NULL
|
|
228
|
+
AND category_path IS NOT NULL
|
|
229
|
+
ORDER BY embedding <-> $1::vector
|
|
230
|
+
LIMIT $2
|
|
231
|
+
""",
|
|
232
|
+
vector_str, scan_pool,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# 카테고리별 그룹화 (category_label = path의 첫 의미 segment)
|
|
236
|
+
buckets: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
237
|
+
for r in rows:
|
|
238
|
+
path = r["category_path"]
|
|
239
|
+
cat_label = self._cat_label_from_path(path)
|
|
240
|
+
if not cat_label:
|
|
241
|
+
continue
|
|
242
|
+
buckets[cat_label].append({
|
|
243
|
+
"stat_id": r["stat_id"],
|
|
244
|
+
"stat_name": r["stat_name"],
|
|
245
|
+
"similarity": float(r["similarity"]),
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
# 카테고리 정렬: 그 안의 평균 similarity 내림차순
|
|
249
|
+
scored: list[tuple[str, int, float, list[dict[str, Any]]]] = []
|
|
250
|
+
for cat, tables in buckets.items():
|
|
251
|
+
avg_sim = sum(t["similarity"] for t in tables) / len(tables)
|
|
252
|
+
# 표는 similarity 내림차순으로 정렬
|
|
253
|
+
tables.sort(key=lambda t: t["similarity"], reverse=True)
|
|
254
|
+
scored.append((cat, len(tables), avg_sim, tables))
|
|
255
|
+
scored.sort(key=lambda x: x[2], reverse=True)
|
|
256
|
+
|
|
257
|
+
result: list[dict[str, Any]] = []
|
|
258
|
+
for cat, cnt, avg_sim, tables in scored[:top_categories]:
|
|
259
|
+
examples = [
|
|
260
|
+
{"stat_id": t["stat_id"], "stat_name": t["stat_name"]}
|
|
261
|
+
for t in tables[:examples_per]
|
|
262
|
+
]
|
|
263
|
+
result.append({
|
|
264
|
+
"category_label": cat,
|
|
265
|
+
"table_count": cnt, # scan_pool 내 카운트
|
|
266
|
+
"avg_similarity": round(avg_sim, 3),
|
|
267
|
+
"examples": examples,
|
|
268
|
+
})
|
|
269
|
+
return result
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def _cat_label_from_path(path: str) -> str:
|
|
273
|
+
"""category_path → 의미 있는 첫 segment.
|
|
274
|
+
|
|
275
|
+
'MT_ZTITLE > 기상관측통계 > 종관기상' → '기상관측통계'
|
|
276
|
+
'MT_ZTITLE > 인구동향조사' → '인구동향조사'
|
|
277
|
+
'기상관측통계' (단독) → '기상관측통계'
|
|
278
|
+
"""
|
|
279
|
+
if not path:
|
|
280
|
+
return ""
|
|
281
|
+
parts = [p.strip() for p in path.split(" > ") if p.strip()]
|
|
282
|
+
if not parts:
|
|
283
|
+
return ""
|
|
284
|
+
# 첫 segment가 메타 prefix면 다음 사용
|
|
285
|
+
_META_PREFIXES = ("MT_ZTITLE", "MT_OTITLE")
|
|
286
|
+
if parts[0] in _META_PREFIXES and len(parts) > 1:
|
|
287
|
+
return parts[1]
|
|
288
|
+
return parts[0]
|
|
289
|
+
|
|
290
|
+
# ── 임베딩 helper ──────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
async def _embed_query(self, text: str) -> list[float] | None:
|
|
293
|
+
"""텍스트 → 임베딩. 공용 EmbeddingClient 사용 (#67-D).
|
|
294
|
+
|
|
295
|
+
키 우선순위: 원래 NCP_API_KEY 우선이라 api_key_env 기본을 "NCP_API_KEY"로 둠.
|
|
296
|
+
config.embedding.api_key_env 가 있으면 그 값을 우선.
|
|
297
|
+
주의: 원래 NCP_API_KEY or CLOVASTUDIO_API_KEY 폴백이었으나 EmbeddingClient는
|
|
298
|
+
단일 api_key_env → NCP만 1순위(NCP 없을 때 CLOVA 폴백은 제거됨).
|
|
299
|
+
"""
|
|
300
|
+
emb_cfg = dict(getattr(self, "_embedding_config", None) or {})
|
|
301
|
+
emb_cfg.setdefault("api_key_env", "NCP_API_KEY")
|
|
302
|
+
try:
|
|
303
|
+
return await EmbeddingClient(emb_cfg).embed(text)
|
|
304
|
+
except Exception as e: # noqa: BLE001
|
|
305
|
+
logger.debug(f"[explore_catalog] _embed_query 실패: {e}")
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
# ── ILIKE fallback (임베딩 실패 / 0건 결과 시) ──────────────────
|
|
309
|
+
|
|
310
|
+
async def _fetch_by_ilike(
|
|
311
|
+
self,
|
|
312
|
+
conn,
|
|
313
|
+
query: str,
|
|
314
|
+
top_categories: int,
|
|
315
|
+
examples_per: int,
|
|
316
|
+
) -> list[dict[str, Any]]:
|
|
317
|
+
"""기존 ILIKE 키워드 매칭 — 임베딩 fallback용."""
|
|
318
|
+
tokens = [t for t in query.split() if len(t) >= 2] or [query]
|
|
319
|
+
where_parts: list[str] = []
|
|
320
|
+
params: list[Any] = []
|
|
321
|
+
for t in tokens:
|
|
322
|
+
params.append(f"%{t}%")
|
|
323
|
+
idx_a = len(params)
|
|
324
|
+
where_parts.append(
|
|
325
|
+
f"(stat_name ILIKE ${idx_a} OR category_path ILIKE ${idx_a})"
|
|
326
|
+
)
|
|
327
|
+
where_sql = " OR ".join(where_parts)
|
|
328
|
+
params.append(top_categories)
|
|
329
|
+
limit_idx = len(params)
|
|
330
|
+
sql = f"""
|
|
331
|
+
SELECT
|
|
332
|
+
COALESCE(NULLIF(split_part(category_path, ' > ', 2), ''),
|
|
333
|
+
category_path) AS cat_label,
|
|
334
|
+
COUNT(*) AS cnt
|
|
335
|
+
FROM kosis_stat_catalog
|
|
336
|
+
WHERE ({where_sql})
|
|
337
|
+
AND category_path IS NOT NULL
|
|
338
|
+
GROUP BY cat_label
|
|
339
|
+
ORDER BY cnt DESC
|
|
340
|
+
LIMIT ${limit_idx}
|
|
341
|
+
"""
|
|
342
|
+
rows = await conn.fetch(sql, *params)
|
|
343
|
+
|
|
344
|
+
result: list[dict[str, Any]] = []
|
|
345
|
+
for row in rows:
|
|
346
|
+
cat_label = row["cat_label"]
|
|
347
|
+
cnt = int(row["cnt"])
|
|
348
|
+
# 대표 표 — 같은 ILIKE 매칭으로 fetch
|
|
349
|
+
ex_where: list[str] = []
|
|
350
|
+
ex_params: list[Any] = [cat_label]
|
|
351
|
+
for t in tokens:
|
|
352
|
+
ex_params.append(f"%{t}%")
|
|
353
|
+
ex_where.append(
|
|
354
|
+
f"(stat_name ILIKE ${len(ex_params)} OR category_path ILIKE ${len(ex_params)})"
|
|
355
|
+
)
|
|
356
|
+
ex_params.append(examples_per)
|
|
357
|
+
ex_sql = f"""
|
|
358
|
+
SELECT stat_id, stat_name
|
|
359
|
+
FROM kosis_stat_catalog
|
|
360
|
+
WHERE COALESCE(NULLIF(split_part(category_path, ' > ', 2), ''),
|
|
361
|
+
category_path) = $1
|
|
362
|
+
AND ({" OR ".join(ex_where)})
|
|
363
|
+
ORDER BY stat_name
|
|
364
|
+
LIMIT ${len(ex_params)}
|
|
365
|
+
"""
|
|
366
|
+
ex_rows = await conn.fetch(ex_sql, *ex_params)
|
|
367
|
+
examples = [
|
|
368
|
+
{"stat_id": r["stat_id"], "stat_name": r["stat_name"]}
|
|
369
|
+
for r in ex_rows
|
|
370
|
+
]
|
|
371
|
+
result.append({
|
|
372
|
+
"category_label": cat_label,
|
|
373
|
+
"table_count": cnt,
|
|
374
|
+
"examples": examples,
|
|
375
|
+
})
|
|
376
|
+
return result
|
|
377
|
+
|
|
378
|
+
# ── query 없을 때 전체 대분류 분포 ────────────────────────────
|
|
379
|
+
|
|
380
|
+
async def _fetch_all_categories(
|
|
381
|
+
self,
|
|
382
|
+
conn,
|
|
383
|
+
top_categories: int,
|
|
384
|
+
examples_per: int,
|
|
385
|
+
) -> list[dict[str, Any]]:
|
|
386
|
+
rows = await conn.fetch(
|
|
387
|
+
"""
|
|
388
|
+
SELECT
|
|
389
|
+
COALESCE(NULLIF(split_part(category_path, ' > ', 2), ''),
|
|
390
|
+
category_path) AS cat_label,
|
|
391
|
+
COUNT(*) AS cnt
|
|
392
|
+
FROM kosis_stat_catalog
|
|
393
|
+
WHERE category_path IS NOT NULL
|
|
394
|
+
GROUP BY cat_label
|
|
395
|
+
ORDER BY cnt DESC
|
|
396
|
+
LIMIT $1
|
|
397
|
+
""",
|
|
398
|
+
top_categories,
|
|
399
|
+
)
|
|
400
|
+
result: list[dict[str, Any]] = []
|
|
401
|
+
for row in rows:
|
|
402
|
+
cat_label = row["cat_label"]
|
|
403
|
+
cnt = int(row["cnt"])
|
|
404
|
+
ex_rows = await conn.fetch(
|
|
405
|
+
"""
|
|
406
|
+
SELECT stat_id, stat_name
|
|
407
|
+
FROM kosis_stat_catalog
|
|
408
|
+
WHERE COALESCE(NULLIF(split_part(category_path, ' > ', 2), ''),
|
|
409
|
+
category_path) = $1
|
|
410
|
+
ORDER BY stat_name
|
|
411
|
+
LIMIT $2
|
|
412
|
+
""",
|
|
413
|
+
cat_label, examples_per,
|
|
414
|
+
)
|
|
415
|
+
result.append({
|
|
416
|
+
"category_label": cat_label,
|
|
417
|
+
"table_count": cnt,
|
|
418
|
+
"examples": [
|
|
419
|
+
{"stat_id": r["stat_id"], "stat_name": r["stat_name"]}
|
|
420
|
+
for r in ex_rows
|
|
421
|
+
],
|
|
422
|
+
})
|
|
423
|
+
return result
|