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,386 @@
|
|
|
1
|
+
"""structverify.retrieval.custom_csv_source — Custom CSV DataSource.
|
|
2
|
+
|
|
3
|
+
회사 업로드 CSV를 BaseDataSource 인터페이스로 노출.
|
|
4
|
+
가정 CSV 형태: indicator,year,region,value,unit (행 = 시점별 값).
|
|
5
|
+
|
|
6
|
+
레퍼런스: retrieval/kosis_source.py (KOSISDataSource).
|
|
7
|
+
search_catalog: 키워드 매칭 (임베딩/DB 없음). fetch_evidence: 다음 단계.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .base import BaseDataSource, CatalogCandidate, EvidenceData
|
|
17
|
+
from .registry import register_datasource
|
|
18
|
+
from structverify.utils.logger import get_logger
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
# CSV 컬럼 → 표준 필드 기본 매핑 (config.column_mapping 으로 덮어쓰기)
|
|
23
|
+
_DEFAULT_COLUMN_MAPPING = {
|
|
24
|
+
"indicator": "indicator",
|
|
25
|
+
"time_period": "year",
|
|
26
|
+
"region": "region",
|
|
27
|
+
"value": "value",
|
|
28
|
+
"unit": "unit",
|
|
29
|
+
"operator": "operator", # (선택) 규정/한도 비교 연산자: <= >= < > = (없으면 일치 비교)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _cosine(a: list[float], b: list[float]) -> float:
|
|
34
|
+
dot = sum(x * y for x, y in zip(a, b))
|
|
35
|
+
na = sum(x * x for x in a) ** 0.5
|
|
36
|
+
nb = sum(y * y for y in b) ** 0.5
|
|
37
|
+
return dot / (na * nb) if na and nb else 0.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@register_datasource("custom_csv")
|
|
41
|
+
class CustomCSVDataSource(BaseDataSource):
|
|
42
|
+
"""회사 업로드 CSV 데이터소스.
|
|
43
|
+
|
|
44
|
+
config 예 (config/default.yaml의 data_sources.custom_csv):
|
|
45
|
+
base_path: "./uploads/custom"
|
|
46
|
+
catalog_json: "tables.csv" # CSV 파일명 (base_path 기준)
|
|
47
|
+
column_mapping: {value: "val", ...} # CSV 컬럼명 커스텀 (선택)
|
|
48
|
+
csv_path: "/abs/path.csv" # (테스트 편의) CSV 경로 직접 주입
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
name = "custom_csv"
|
|
52
|
+
|
|
53
|
+
def __init__(self, **config: Any):
|
|
54
|
+
self.config = config
|
|
55
|
+
self.base_path = config.get("base_path", "")
|
|
56
|
+
self.catalog_json = config.get("catalog_json", "")
|
|
57
|
+
# 테스트 편의: csv_path 직접 주입 우선. 없으면 base_path/catalog_json로 해석.
|
|
58
|
+
self.csv_path = config.get("csv_path") or self._resolve_csv_path()
|
|
59
|
+
# CSV 컬럼명 매핑 — config 우선, 없으면 기본
|
|
60
|
+
self.column_mapping = {
|
|
61
|
+
**_DEFAULT_COLUMN_MAPPING,
|
|
62
|
+
**(config.get("column_mapping") or {}),
|
|
63
|
+
}
|
|
64
|
+
# ── 임베딩 검색 (지표가 방대할 때) ──
|
|
65
|
+
# embedding config가 있고 distinct 지표 수가 임계 초과면 임베딩 의미검색을 쓴다.
|
|
66
|
+
# (지표가 적으면 키워드 매칭으로 충분 — 임베딩 비용 회피.)
|
|
67
|
+
self._embed_cfg = config.get("embedding") or {}
|
|
68
|
+
self._embed_threshold = int(config.get("embed_threshold", 200))
|
|
69
|
+
self._embed_client = None
|
|
70
|
+
self._ind_index: list[dict] | None = None # [{key, vec}]
|
|
71
|
+
logger.info(
|
|
72
|
+
f"[CustomCSVDataSource] 초기화: csv_path={self.csv_path!r}, "
|
|
73
|
+
f"column_mapping={self.column_mapping}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# ── CSV 헬퍼 ──
|
|
77
|
+
|
|
78
|
+
def _resolve_csv_path(self) -> str:
|
|
79
|
+
"""base_path/catalog_json 으로 CSV 경로 해석."""
|
|
80
|
+
if self.base_path and self.catalog_json:
|
|
81
|
+
return os.path.join(self.base_path, self.catalog_json)
|
|
82
|
+
return self.catalog_json or self.base_path or ""
|
|
83
|
+
|
|
84
|
+
def _read_rows(self) -> list[dict[str, str]]:
|
|
85
|
+
"""CSV 전체 행을 dict 리스트로. 경로 없거나 못 읽으면 []."""
|
|
86
|
+
if not self.csv_path or not os.path.exists(self.csv_path):
|
|
87
|
+
logger.warning(f"[CustomCSVDataSource] CSV 경로 없음: {self.csv_path!r}")
|
|
88
|
+
return []
|
|
89
|
+
try:
|
|
90
|
+
with open(self.csv_path, encoding="utf-8") as f:
|
|
91
|
+
return list(csv.DictReader(f))
|
|
92
|
+
except Exception as e: # noqa: BLE001
|
|
93
|
+
logger.warning(f"[CustomCSVDataSource] CSV 읽기 실패: {e}")
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _match_score(query: str, indicator: str) -> float:
|
|
98
|
+
"""query↔indicator 매칭 점수. 한국어 띄어쓰기/접두어 변이에 견고.
|
|
99
|
+
|
|
100
|
+
완전일치 1.0 / 부분포함 0.8 / 공백제거 부분포함 0.75 /
|
|
101
|
+
부분-토큰 겹침 비례(≤0.6) / 없으면 0.0.
|
|
102
|
+
|
|
103
|
+
핵심: 한국어는 띄어쓰기가 흔들린다("고객 수" vs "고객수")·수식어가 붙는다
|
|
104
|
+
("누적 고객 수" vs "총 고객수"). 그래서 토큰 완전일치가 아니라 *부분 문자열*
|
|
105
|
+
토큰 겹침(고객 ⊂ 고객수)까지 인정한다. (검색 리콜 우선 — 정밀도는 랭커/fetch가.)
|
|
106
|
+
"""
|
|
107
|
+
q, ind = query.strip().lower(), indicator.strip().lower()
|
|
108
|
+
if not q or not ind:
|
|
109
|
+
return 0.0
|
|
110
|
+
if q == ind:
|
|
111
|
+
return 1.0
|
|
112
|
+
if q in ind or ind in q:
|
|
113
|
+
return 0.8
|
|
114
|
+
# 공백 제거 후 부분포함 ("고객 수" → "고객수" ⊂ "총고객수")
|
|
115
|
+
qn, indn = q.replace(" ", ""), ind.replace(" ", "")
|
|
116
|
+
if qn and indn and (qn in indn or indn in qn):
|
|
117
|
+
return 0.75
|
|
118
|
+
# 부분-토큰 겹침: query 토큰이 indicator 토큰에 (부분)포함되면 hit
|
|
119
|
+
qtok = re.findall(r"\w+", q)
|
|
120
|
+
itok = re.findall(r"\w+", ind)
|
|
121
|
+
if qtok and itok:
|
|
122
|
+
hits = sum(1 for t in qtok if any(t in it or it in t for it in itok))
|
|
123
|
+
if hits:
|
|
124
|
+
return min(0.6, 0.6 * hits / len(qtok))
|
|
125
|
+
return 0.0
|
|
126
|
+
|
|
127
|
+
# ── 임베딩 검색 (지표 방대 시) ──
|
|
128
|
+
|
|
129
|
+
def _distinct_indicators(self) -> list[tuple[str, str]]:
|
|
130
|
+
ind_c = self.column_mapping["indicator"]
|
|
131
|
+
reg_c = self.column_mapping["region"]
|
|
132
|
+
seen: set = set()
|
|
133
|
+
out: list[tuple[str, str]] = []
|
|
134
|
+
for r in self._read_rows():
|
|
135
|
+
i = (r.get(ind_c) or "").strip()
|
|
136
|
+
if not i:
|
|
137
|
+
continue
|
|
138
|
+
reg = (r.get(reg_c) or "").strip()
|
|
139
|
+
k = (i, reg)
|
|
140
|
+
if k not in seen:
|
|
141
|
+
seen.add(k)
|
|
142
|
+
out.append(k)
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
def _embed_enabled(self) -> bool:
|
|
146
|
+
"""임베딩 의미검색 사용 여부.
|
|
147
|
+
|
|
148
|
+
config.use_embedding 으로 명시 제어(기본 auto):
|
|
149
|
+
· "true"/"on"/"embedding" → 항상 임베딩(임베딩 config가 있으면)
|
|
150
|
+
· "false"/"off"/"keyword" → 항상 키워드
|
|
151
|
+
· "auto"(기본) → 지표가 embed_threshold 초과면 임베딩, 아니면 키워드
|
|
152
|
+
"""
|
|
153
|
+
if not self._embed_cfg:
|
|
154
|
+
return False
|
|
155
|
+
mode = str(self.config.get("use_embedding", "auto")).strip().lower()
|
|
156
|
+
if mode in ("false", "off", "no", "keyword"):
|
|
157
|
+
return False
|
|
158
|
+
if mode in ("true", "on", "yes", "embedding"):
|
|
159
|
+
return True
|
|
160
|
+
return len(self._distinct_indicators()) > self._embed_threshold
|
|
161
|
+
|
|
162
|
+
def _index_cache_path(self, texts: list[str]) -> str | None:
|
|
163
|
+
"""임베딩 인덱스 디스크 캐시 경로. 지표 집합+모델이 바뀌면 키가 달라져 자동 재구축.
|
|
164
|
+
|
|
165
|
+
비활성화: embedding.cache=false. 위치: embedding.cache_dir (기본 임시디렉토리).
|
|
166
|
+
"""
|
|
167
|
+
import hashlib
|
|
168
|
+
import os
|
|
169
|
+
import tempfile
|
|
170
|
+
if self._embed_cfg.get("cache") is False:
|
|
171
|
+
return None
|
|
172
|
+
base = self._embed_cfg.get("cache_dir") or os.path.join(
|
|
173
|
+
tempfile.gettempdir(), "structverify_emb"
|
|
174
|
+
)
|
|
175
|
+
model = f"{self._embed_cfg.get('provider', '')}:{self._embed_cfg.get('model') or 'default'}"
|
|
176
|
+
_ident = getattr(self, "table", None) or getattr(self, "csv_path", None) or self.name
|
|
177
|
+
key = hashlib.md5(
|
|
178
|
+
("|".join(sorted(texts)) + "|" + model + "|" + str(_ident)).encode("utf-8")
|
|
179
|
+
).hexdigest()[:16]
|
|
180
|
+
return os.path.join(base, f"{self.name}_{key}.json")
|
|
181
|
+
|
|
182
|
+
async def _ensure_ind_index(self) -> None:
|
|
183
|
+
if self._ind_index is not None:
|
|
184
|
+
return
|
|
185
|
+
import json
|
|
186
|
+
import os
|
|
187
|
+
from structverify.utils.embedding_client import EmbeddingClient
|
|
188
|
+
if self._embed_client is None:
|
|
189
|
+
self._embed_client = EmbeddingClient(self._embed_cfg)
|
|
190
|
+
pairs = self._distinct_indicators()
|
|
191
|
+
texts = [f"{i} ({r})" if r else i for i, r in pairs]
|
|
192
|
+
|
|
193
|
+
# ── 디스크 캐시 로드 (첫 사용 후 재실행/새 프로세스에서 즉시 로드) ──
|
|
194
|
+
cache_path = self._index_cache_path(texts)
|
|
195
|
+
if cache_path and os.path.exists(cache_path):
|
|
196
|
+
try:
|
|
197
|
+
with open(cache_path, encoding="utf-8") as f:
|
|
198
|
+
self._ind_index = json.load(f)
|
|
199
|
+
logger.info(
|
|
200
|
+
f"[{self.name}] 지표 임베딩 인덱스 캐시 로드 {len(self._ind_index)}개 "
|
|
201
|
+
f"({cache_path})"
|
|
202
|
+
)
|
|
203
|
+
return
|
|
204
|
+
except Exception as e: # noqa: BLE001 — 캐시 손상 시 재구축
|
|
205
|
+
logger.warning(f"[{self.name}] 인덱스 캐시 로드 실패 → 재구축: {e}")
|
|
206
|
+
|
|
207
|
+
self._ind_index = []
|
|
208
|
+
# 배치 임베딩 — 지표가 200+개여도 직렬 N회가 아니라 배치 몇 회로(속도·429 방지).
|
|
209
|
+
_CHUNK = 96 # upstage/openai 배치 입력 상한 여유
|
|
210
|
+
for k in range(0, len(texts), _CHUNK):
|
|
211
|
+
chunk_pairs = pairs[k:k + _CHUNK]
|
|
212
|
+
chunk_texts = texts[k:k + _CHUNK]
|
|
213
|
+
try:
|
|
214
|
+
vecs = await self._embed_client.embed_batch(chunk_texts, role="passage")
|
|
215
|
+
except Exception as e: # noqa: BLE001
|
|
216
|
+
logger.warning(f"[{self.name}] 지표 배치 임베딩 실패(chunk {k}): {e}")
|
|
217
|
+
vecs = []
|
|
218
|
+
for (ind, reg), text, vec in zip(chunk_pairs, chunk_texts, vecs or []):
|
|
219
|
+
if vec:
|
|
220
|
+
self._ind_index.append({"id": f"{ind}|{reg}", "name": text, "vec": vec})
|
|
221
|
+
logger.info(
|
|
222
|
+
f"[{self.name}] 지표 임베딩 인덱스 {len(self._ind_index)}개 "
|
|
223
|
+
f"(배치 {(len(texts) + _CHUNK - 1) // _CHUNK}회, 첫 구축)"
|
|
224
|
+
)
|
|
225
|
+
# ── 디스크 캐시 저장 (다음 실행부터 즉시 로드) ──
|
|
226
|
+
if cache_path and self._ind_index:
|
|
227
|
+
try:
|
|
228
|
+
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
|
|
229
|
+
with open(cache_path, "w", encoding="utf-8") as f:
|
|
230
|
+
json.dump(self._ind_index, f)
|
|
231
|
+
logger.info(f"[{self.name}] 지표 임베딩 인덱스 캐시 저장 → {cache_path}")
|
|
232
|
+
except Exception as e: # noqa: BLE001 — 저장 실패는 무시(다음에 재구축)
|
|
233
|
+
logger.debug(f"[{self.name}] 인덱스 캐시 저장 실패: {e}")
|
|
234
|
+
|
|
235
|
+
async def _search_embedding(self, query: str, top_k: int) -> list[CatalogCandidate]:
|
|
236
|
+
await self._ensure_ind_index()
|
|
237
|
+
qv = await self._embed_client.embed(query, role="query")
|
|
238
|
+
scored = sorted(
|
|
239
|
+
((_cosine(qv, it["vec"]), it) for it in (self._ind_index or [])),
|
|
240
|
+
key=lambda x: x[0], reverse=True,
|
|
241
|
+
)
|
|
242
|
+
cands = [{"id": it["id"], "name": it["name"], "score": float(s)} for s, it in scored[:top_k]]
|
|
243
|
+
logger.info(f"[CustomCSVDataSource] 임베딩 검색(query={query!r}): {len(cands)}개 후보")
|
|
244
|
+
return cands
|
|
245
|
+
|
|
246
|
+
# ── BaseDataSource 인터페이스 ──
|
|
247
|
+
|
|
248
|
+
async def search_catalog(
|
|
249
|
+
self,
|
|
250
|
+
query: str,
|
|
251
|
+
category: list[str] | None = None,
|
|
252
|
+
top_k: int = 10,
|
|
253
|
+
context: dict[str, Any] | None = None,
|
|
254
|
+
) -> list[CatalogCandidate]:
|
|
255
|
+
# 지표가 방대하면 임베딩 의미검색, 아니면 키워드 매칭.
|
|
256
|
+
if self._embed_enabled():
|
|
257
|
+
return await self._search_embedding(query, top_k)
|
|
258
|
+
"""CSV indicator 컬럼을 query로 키워드 매칭 → 후보 반환.
|
|
259
|
+
|
|
260
|
+
같은 (indicator, region)은 시점별 여러 행이 있어도 후보 1개로 묶음.
|
|
261
|
+
반환: [{"id": "<indicator>|<region>", "name": "<indicator> (<region>)",
|
|
262
|
+
"score": <매칭점수>}] — score 내림차순, 매칭 없으면 [].
|
|
263
|
+
"""
|
|
264
|
+
ind_col = self.column_mapping["indicator"]
|
|
265
|
+
reg_col = self.column_mapping["region"]
|
|
266
|
+
|
|
267
|
+
# (indicator, region) 단위로 묶어 표 1개 = 후보 1개
|
|
268
|
+
groups: dict[tuple[str, str], float] = {}
|
|
269
|
+
for row in self._read_rows():
|
|
270
|
+
indicator = (row.get(ind_col) or "").strip()
|
|
271
|
+
if not indicator:
|
|
272
|
+
continue
|
|
273
|
+
region = (row.get(reg_col) or "").strip()
|
|
274
|
+
score = self._match_score(query, indicator)
|
|
275
|
+
if score <= 0.0:
|
|
276
|
+
continue
|
|
277
|
+
groups.setdefault((indicator, region), score) # 같은 그룹 첫 점수 유지
|
|
278
|
+
|
|
279
|
+
candidates: list[CatalogCandidate] = [
|
|
280
|
+
{
|
|
281
|
+
"id": f"{ind}|{reg}",
|
|
282
|
+
"name": f"{ind} ({reg})" if reg else ind,
|
|
283
|
+
"score": sc,
|
|
284
|
+
}
|
|
285
|
+
for (ind, reg), sc in groups.items()
|
|
286
|
+
]
|
|
287
|
+
candidates.sort(key=lambda c: c["score"], reverse=True)
|
|
288
|
+
logger.info(
|
|
289
|
+
f"[CustomCSVDataSource] search_catalog(query={query!r}): "
|
|
290
|
+
f"{len(candidates)}개 후보"
|
|
291
|
+
)
|
|
292
|
+
return candidates[:top_k]
|
|
293
|
+
|
|
294
|
+
@staticmethod
|
|
295
|
+
def _year_key(year: Any) -> int:
|
|
296
|
+
"""연도 정렬용 키. 파싱 실패 시 -1 (가장 뒤)."""
|
|
297
|
+
try:
|
|
298
|
+
return int(str(year).strip())
|
|
299
|
+
except (TypeError, ValueError):
|
|
300
|
+
return -1
|
|
301
|
+
|
|
302
|
+
async def fetch_evidence(
|
|
303
|
+
self,
|
|
304
|
+
candidate_id: str,
|
|
305
|
+
params: dict[str, Any] | None = None,
|
|
306
|
+
workspace: Any = None, # 인터페이스 호환 (CSV는 캐시 없음 — 받되 무시)
|
|
307
|
+
) -> EvidenceData | None:
|
|
308
|
+
"""candidate_id("지표|지역") + params["time_period"]로 CSV 행 매칭 → EvidenceData.
|
|
309
|
+
|
|
310
|
+
time_period 매칭: year 컬럼과 문자열 정규화 비교 ("2024" == 2024).
|
|
311
|
+
time_period 없으면 가장 최근 연도 행. 맞는 행 없으면 None.
|
|
312
|
+
"""
|
|
313
|
+
params = params or {}
|
|
314
|
+
indicator, _, region = candidate_id.partition("|") # "지표|지역" 분해
|
|
315
|
+
indicator, region = indicator.strip(), region.strip()
|
|
316
|
+
|
|
317
|
+
ind_col = self.column_mapping["indicator"]
|
|
318
|
+
reg_col = self.column_mapping["region"]
|
|
319
|
+
year_col = self.column_mapping["time_period"]
|
|
320
|
+
val_col = self.column_mapping["value"]
|
|
321
|
+
unit_col = self.column_mapping["unit"]
|
|
322
|
+
|
|
323
|
+
# 해당 indicator(+region) 행만 추림
|
|
324
|
+
rows = [
|
|
325
|
+
r for r in self._read_rows()
|
|
326
|
+
if (r.get(ind_col) or "").strip() == indicator
|
|
327
|
+
and (r.get(reg_col) or "").strip() == region
|
|
328
|
+
]
|
|
329
|
+
if not rows:
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
tp = params.get("time_period")
|
|
333
|
+
tp_str = str(tp).strip() if tp is not None else ""
|
|
334
|
+
if tp_str:
|
|
335
|
+
matched = next(
|
|
336
|
+
(r for r in rows if str(r.get(year_col, "")).strip() == tp_str),
|
|
337
|
+
None,
|
|
338
|
+
)
|
|
339
|
+
# 이 지표에 해당 연도 행이 없을 때 — 두 경우로 나뉜다:
|
|
340
|
+
if matched is None:
|
|
341
|
+
# (b) 그 연도가 *소스의 다른 지표엔* 존재 → 연도별 데이터가 있다는 뜻.
|
|
342
|
+
# 이 표(연도 없는 누적 등)로 폴백하면 누적값을 특정연도값인 척
|
|
343
|
+
# 씌우게 되므로 거부(None) → 에이전트가 '연간 …' 같은 연도별
|
|
344
|
+
# 지표 후보를 시도하게 유도. (총 주문건수 vs 연간 주문건수 혼동 방지.)
|
|
345
|
+
tp_in_source = any(
|
|
346
|
+
str(r.get(year_col, "")).strip() == tp_str
|
|
347
|
+
for r in self._read_rows()
|
|
348
|
+
)
|
|
349
|
+
if tp_in_source:
|
|
350
|
+
logger.info(
|
|
351
|
+
f"[{self.name}] time_period={tp_str!r}이 소스 다른 지표엔 존재 "
|
|
352
|
+
f"→ 이 표('{indicator}') 부적합, fetch 거부(연도별 후보 유도)"
|
|
353
|
+
)
|
|
354
|
+
return None
|
|
355
|
+
# (a) 그 연도가 소스 어디에도 없음(예: "2023년 말 기준" as-of 시점,
|
|
356
|
+
# 정답은 연도 없는 누적 총계) → 최근/유일 행으로 폴백.
|
|
357
|
+
matched = max(rows, key=lambda r: self._year_key(r.get(year_col)))
|
|
358
|
+
logger.info(
|
|
359
|
+
f"[{self.name}] time_period={tp_str!r} 소스에 없음 → "
|
|
360
|
+
f"누적/최근 행 폴백 (year={matched.get(year_col)!r})"
|
|
361
|
+
)
|
|
362
|
+
else:
|
|
363
|
+
matched = max(rows, key=lambda r: self._year_key(r.get(year_col)))
|
|
364
|
+
|
|
365
|
+
if matched is None:
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
value = float((matched.get(val_col) or "").strip())
|
|
370
|
+
except (TypeError, ValueError):
|
|
371
|
+
logger.warning(
|
|
372
|
+
f"[CustomCSVDataSource] value 파싱 실패: {matched.get(val_col)!r}"
|
|
373
|
+
)
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
op_col = self.column_mapping.get("operator", "operator")
|
|
377
|
+
return {
|
|
378
|
+
"value": value,
|
|
379
|
+
"unit": (matched.get(unit_col) or "").strip(),
|
|
380
|
+
"time_period": str(matched.get(year_col, "")).strip(),
|
|
381
|
+
"operator": (matched.get(op_col) or "").strip(), # 규정 비교 연산자 (없으면 "")
|
|
382
|
+
"source": self.name, # custom_csv / custom_db (하위클래스)
|
|
383
|
+
"indicator": indicator,
|
|
384
|
+
"region": region,
|
|
385
|
+
"matched_row": matched,
|
|
386
|
+
}
|