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,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
structverify.retrieval.base — DataSource 추상 인터페이스.
|
|
3
|
+
|
|
4
|
+
KOSIS만이 아니라 *어떤 데이터 소스*든 (회사 DB, CSV 파일, 외부 API)
|
|
5
|
+
*동일한 인터페이스*로 플러그인 가능하도록 설계.
|
|
6
|
+
|
|
7
|
+
회사 사용 시나리오:
|
|
8
|
+
- 글로벌 회사: World Bank API + OECD API
|
|
9
|
+
- 사내 데이터: PostgreSQL/Snowflake + S3 CSV
|
|
10
|
+
- 한국 미디어: KOSIS (현재 default)
|
|
11
|
+
|
|
12
|
+
Config (config/default.yaml):
|
|
13
|
+
data_sources:
|
|
14
|
+
enabled: ["kosis", "custom_csv"]
|
|
15
|
+
kosis: {...}
|
|
16
|
+
custom_csv: {...}
|
|
17
|
+
|
|
18
|
+
Registry로 등록:
|
|
19
|
+
from structverify.retrieval.registry import register_datasource
|
|
20
|
+
|
|
21
|
+
@register_datasource("my_internal_db")
|
|
22
|
+
class MyInternalDB(BaseDataSource):
|
|
23
|
+
async def search_catalog(self, query, ...): ...
|
|
24
|
+
async def fetch_evidence(self, candidate_id, ...): ...
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from abc import ABC, abstractmethod
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Result 타입 (dict 호환, 가볍게) ──────────────────────────────
|
|
33
|
+
|
|
34
|
+
class CatalogCandidate(dict):
|
|
35
|
+
"""카탈로그 검색 결과 1건.
|
|
36
|
+
|
|
37
|
+
필수 키:
|
|
38
|
+
id : 후보 식별자 (예: 'DT_1L9U108')
|
|
39
|
+
name : 표/지표 이름
|
|
40
|
+
score : 검색 점수 (0~1)
|
|
41
|
+
선택 키:
|
|
42
|
+
category : 분류 (예: '인구/가구')
|
|
43
|
+
description : 설명
|
|
44
|
+
...source-specific 필드
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class EvidenceData(dict):
|
|
49
|
+
"""fetch 결과 raw 데이터.
|
|
50
|
+
|
|
51
|
+
필수 키:
|
|
52
|
+
value : 수치값 (float)
|
|
53
|
+
unit : 단위
|
|
54
|
+
time_period : 시점 (YYYY 또는 YYYY-MM)
|
|
55
|
+
선택 키:
|
|
56
|
+
official_value : 정규화된 값
|
|
57
|
+
raw_response : API 원본 응답 (디버깅용)
|
|
58
|
+
rows : 전체 row 목록 (KOSIS 같은 표 데이터)
|
|
59
|
+
...
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ── 추상 인터페이스 ───────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
class BaseDataSource(ABC):
|
|
66
|
+
"""
|
|
67
|
+
모든 데이터 소스의 공통 인터페이스.
|
|
68
|
+
|
|
69
|
+
필수 구현:
|
|
70
|
+
- search_catalog: 키워드로 표/지표 후보 검색
|
|
71
|
+
- fetch_evidence: 후보 1개의 실제 데이터 조회
|
|
72
|
+
|
|
73
|
+
선택 구현 (기본은 False / no-op):
|
|
74
|
+
- supports_time_filter: API가 시점 파라미터 지원하는지
|
|
75
|
+
- supports_population_filter: 인구 필터 지원 여부
|
|
76
|
+
- close: 리소스 정리 (DB 커넥션 등)
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
name: str = "base"
|
|
80
|
+
"""Registry에 등록될 이름. @register_datasource로 자동 설정."""
|
|
81
|
+
|
|
82
|
+
@abstractmethod
|
|
83
|
+
async def search_catalog(
|
|
84
|
+
self,
|
|
85
|
+
query: str,
|
|
86
|
+
category: list[str] | None = None,
|
|
87
|
+
top_k: int = 10,
|
|
88
|
+
context: dict[str, Any] | None = None,
|
|
89
|
+
) -> list[CatalogCandidate]:
|
|
90
|
+
"""카탈로그 검색.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
query: 검색 키워드 (자연어 또는 키워드 조합).
|
|
94
|
+
category: 분류 힌트 (예: ['인구', '가구']).
|
|
95
|
+
top_k: 반환할 최대 후보 수.
|
|
96
|
+
context: [P31 2026-05-22] source-specific 부가 정보. 검색 정확도 보강용.
|
|
97
|
+
권장 키 (source가 알 만한 것만 사용):
|
|
98
|
+
- 'parent_path': str — schema의 계층 카테고리 (예: "보건 > 의료자원 > 의료장비")
|
|
99
|
+
- 'raw_claim': str — 원문 claim 텍스트 (앞 200자)
|
|
100
|
+
- 'population': str — schema의 대상 집단/지역
|
|
101
|
+
- 'indicator': str — schema의 지표명
|
|
102
|
+
source가 모르는 키는 무시. KOSIS는 LLM 카테고리 추출 시 활용.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
CatalogCandidate 리스트. score 내림차순 권장.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
@abstractmethod
|
|
109
|
+
async def fetch_evidence(
|
|
110
|
+
self,
|
|
111
|
+
candidate_id: str,
|
|
112
|
+
params: dict[str, Any] | None = None,
|
|
113
|
+
workspace: Any = None,
|
|
114
|
+
) -> EvidenceData | None:
|
|
115
|
+
"""실제 데이터 조회.
|
|
116
|
+
|
|
117
|
+
workspace: (선택) source별 캐시 핸들. KOSIS는 raw 응답 캐싱에 사용,
|
|
118
|
+
캐시가 불필요한 소스(CSV 등)는 받되 무시한다. 호출부(FetchEvidenceTool)가
|
|
119
|
+
모든 소스에 workspace를 넘기므로 시그니처에 포함한다.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
candidate_id: search_catalog 결과의 'id'.
|
|
123
|
+
params: source-specific 파라미터.
|
|
124
|
+
예 (KOSIS): {'prdSe': 'M', 'startPrdDe': '202504'}.
|
|
125
|
+
예 (CSV): {'column': 'sales', 'row_filter': 'region=KR'}.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
EvidenceData 또는 None (못 찾으면).
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
# ── 선택 기능 (기본 false / no-op) ─────────────────────────
|
|
132
|
+
|
|
133
|
+
def supports_time_filter(self) -> bool:
|
|
134
|
+
"""API가 시점 파라미터 필터링을 지원하는지.
|
|
135
|
+
|
|
136
|
+
True면 fetch_evidence에 시점 params 전달 가능.
|
|
137
|
+
False면 모든 row 가져온 후 verifier가 필터링.
|
|
138
|
+
"""
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
def supports_population_filter(self) -> bool:
|
|
142
|
+
"""인구/카테고리 필터 지원 여부."""
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
async def close(self) -> None:
|
|
146
|
+
"""리소스 정리. DB 커넥션 등."""
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
async def get_table_meta(
|
|
150
|
+
self,
|
|
151
|
+
candidate_id: str,
|
|
152
|
+
meta_type: str = "ITM",
|
|
153
|
+
) -> dict[str, Any] | list[dict[str, Any]] | None:
|
|
154
|
+
"""[P30 2026-05-22] 표의 *항목/분류 메타* 조회 — *데이터는 X*.
|
|
155
|
+
|
|
156
|
+
용도: catalog_search → fetch_evidence 사이에서, 표 이름만으론 정답인지
|
|
157
|
+
불확실할 때 LLM이 *표 내부 구조*(어떤 항목/분류 코드)를 보고 판단하기
|
|
158
|
+
위한 가벼운 메타 호출.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
candidate_id: search_catalog 결과의 'id' (stat_id 등).
|
|
162
|
+
meta_type: source-specific 메타 타입.
|
|
163
|
+
KOSIS: "ITM" (통계항목), "OBJL01"~"OBJL08" (분류 항목), "PRD", "CMMT".
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
메타 dict 또는 list (source 형식 그대로). 실패/미지원이면 None.
|
|
167
|
+
"""
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── 헬퍼: 다중 source orchestration (Phase B+에서 활용) ───────────
|
|
172
|
+
|
|
173
|
+
class MultiSourceRouter:
|
|
174
|
+
"""
|
|
175
|
+
여러 DataSource를 묶어서 *순차/병렬* 검색.
|
|
176
|
+
|
|
177
|
+
Phase B에서 tool wrapper가 이 클래스 사용:
|
|
178
|
+
- config.data_sources.enabled = ["kosis", "custom_db"] 면 *둘 다* 검색
|
|
179
|
+
- 결과 합쳐서 ranked list 반환
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def __init__(self, sources: list[BaseDataSource]):
|
|
183
|
+
self.sources = sources
|
|
184
|
+
|
|
185
|
+
async def search_catalog_all(
|
|
186
|
+
self,
|
|
187
|
+
query: str,
|
|
188
|
+
category: list[str] | None = None,
|
|
189
|
+
top_k_per_source: int = 5,
|
|
190
|
+
) -> dict[str, list[CatalogCandidate]]:
|
|
191
|
+
"""각 source 검색 결과 dict로."""
|
|
192
|
+
results: dict[str, list[CatalogCandidate]] = {}
|
|
193
|
+
for src in self.sources:
|
|
194
|
+
try:
|
|
195
|
+
results[src.name] = await src.search_catalog(
|
|
196
|
+
query, category=category, top_k=top_k_per_source
|
|
197
|
+
)
|
|
198
|
+
except Exception as e:
|
|
199
|
+
# 한 source 실패해도 나머지 진행
|
|
200
|
+
results[src.name] = []
|
|
201
|
+
return results
|
|
202
|
+
|
|
203
|
+
async def close_all(self) -> None:
|
|
204
|
+
for src in self.sources:
|
|
205
|
+
try:
|
|
206
|
+
await src.close()
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
retrieval/base_connector.py — 데이터 커넥터 추상 인터페이스
|
|
3
|
+
|
|
4
|
+
v2: to_graph_nodes(), tag_provenance() 메서드 추가
|
|
5
|
+
|
|
6
|
+
[참고] RAG (Lewis et al., NeurIPS 2020) — https://github.com/huggingface/transformers
|
|
7
|
+
검색 → LLM context 주입 패턴. 커넥터의 search→fetch→context 흐름에 참고.
|
|
8
|
+
"""
|
|
9
|
+
# 수정자: 신준수
|
|
10
|
+
# 수정 날짜: 2026-04-27
|
|
11
|
+
# 수정 내용: StatData에 official_value·unit·time_period(증거 정규화; 출처 API 키는 커넥터가 채움)
|
|
12
|
+
# [2026-05-14 | 이수민] memory/v1: StatData.category_path 추가
|
|
13
|
+
# - KOSIS catalog의 카테고리 경로를 evidence까지 전달해 verifier 도메인 가드에 활용
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
from structverify.core.schemas import GraphNode, ProvenanceRecord
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ConnectorQuery:
|
|
23
|
+
keyword: str
|
|
24
|
+
indicator: str | None = None
|
|
25
|
+
time_period: str | None = None
|
|
26
|
+
population: str | None = None
|
|
27
|
+
extra_params: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class StatRecord:
|
|
31
|
+
stat_id: str; stat_name: str; org_name: str; org_id: str | None = None
|
|
32
|
+
available_periods: list[str] = field(default_factory=list)
|
|
33
|
+
relevance_score: float = 0.0
|
|
34
|
+
# 출처별 search 응답 한 행 전체; 커넥터·API별 추가 키는 여기로.
|
|
35
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class StatData:
|
|
39
|
+
stat_id: str; stat_name: str
|
|
40
|
+
values: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
raw_response: dict[str, Any] = field(default_factory=dict)
|
|
42
|
+
# Evidence/검증용 정규화(커넥터가 출처 API에서 채움; subgraph는 values 키를 직접 보지 않음)
|
|
43
|
+
official_value: float | None = None
|
|
44
|
+
unit: str | None = None
|
|
45
|
+
time_period: str | None = None
|
|
46
|
+
# [이수민 2026-05-14] working memory 도메인 가드용 — KOSIS catalog category_path
|
|
47
|
+
category_path: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BaseConnector(ABC):
|
|
51
|
+
"""
|
|
52
|
+
모든 커넥터의 추상 기본 클래스.
|
|
53
|
+
v2에서 to_graph_nodes()와 tag_provenance() 추가.
|
|
54
|
+
"""
|
|
55
|
+
@abstractmethod
|
|
56
|
+
async def search(self, query: ConnectorQuery) -> list[StatRecord]: ...
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
async def fetch(self, stat_id: str, params: dict[str, Any]) -> StatData: ...
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def to_graph_nodes(self, data: StatData) -> list[GraphNode]:
|
|
63
|
+
"""v2: 조회 결과를 그래프 노드로 변환"""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def tag_provenance(self, data: StatData, query: ConnectorQuery) -> ProvenanceRecord:
|
|
68
|
+
"""v2: 출처 이력 기록"""
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
async def search_and_fetch(self, query: ConnectorQuery) -> StatData | None:
|
|
72
|
+
records = await self.search(query)
|
|
73
|
+
if not records:
|
|
74
|
+
|
|
75
|
+
return None
|
|
76
|
+
best = max(records, key=lambda r: r.relevance_score)
|
|
77
|
+
return await self.fetch(
|
|
78
|
+
best.stat_id,
|
|
79
|
+
{
|
|
80
|
+
"time_period": query.time_period,
|
|
81
|
+
"population": query.population,
|
|
82
|
+
"query": query,
|
|
83
|
+
"stat_record": best,
|
|
84
|
+
},
|
|
85
|
+
)
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""
|
|
2
|
+
structverify.retrieval.catalog_ranker — LLM batch ranking.
|
|
3
|
+
|
|
4
|
+
배경:
|
|
5
|
+
기존 indicator semantic guard(키워드 룰) + relevance_judge(per-table T/F) 두 가드를
|
|
6
|
+
*후보 N개 한 번에 비교하는 LLM ranking*으로 통합. 이유:
|
|
7
|
+
- 키워드 룰은 한정자가 사전에 없는 도메인(투석/혈관조영 등) 확장 불가.
|
|
8
|
+
- per-table 판단은 *비교*가 필요한 한정자 매칭(특수의료장비 vs 의료장비)을 잡지 못함.
|
|
9
|
+
- N표를 한 번에 비교하면 한정자 매칭 + 부분집합/상위집합 관계 판별이 자연스러움.
|
|
10
|
+
|
|
11
|
+
설계:
|
|
12
|
+
- input: claim(indicator/population/parent_path/원문) + candidates[{id, name, org, ...}]
|
|
13
|
+
- output: [{id, score, reason}, ...] (score 0~1, 높은 순 정렬)
|
|
14
|
+
- 호출자가 score 임계치(예: 0.15) 미만은 reject, 나머지는 score 순서로 try.
|
|
15
|
+
- LLM 1회 호출 (메타데이터는 표 이름 + org_name + category_path까지만 노출 — 과적합 회피).
|
|
16
|
+
|
|
17
|
+
config:
|
|
18
|
+
data_sources.kosis.catalog_ranker.{enabled, score_threshold, model_tier}
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from structverify.utils.logger import get_logger
|
|
27
|
+
|
|
28
|
+
logger = get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
# LLM 응답이 표 5~25개 ranking이라 ~1KB 정도. v3 light tier 충분.
|
|
31
|
+
_DEFAULT_MODEL_TIER = "light"
|
|
32
|
+
_DEFAULT_SCORE_THRESHOLD = 0.15 # 이 미만은 reject
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize_candidate_for_prompt(c: dict) -> dict:
|
|
36
|
+
"""LLM 프롬프트용으로 후보 메타데이터를 안전 추출. raw StatRecord 핸들링.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
{id, name, org_name, category_path}. 비어있는 필드는 ''.
|
|
40
|
+
"""
|
|
41
|
+
cid = str(c.get("id") or "")
|
|
42
|
+
name = str(c.get("name") or "")
|
|
43
|
+
# name에 "[같은 job에서 ...]" 같은 hint label 붙어있으면 제거 (LLM 판단 오염 방지)
|
|
44
|
+
name = re.sub(r"\s*\[같은\s+job[^\]]*\]\s*$", "", name).strip()
|
|
45
|
+
|
|
46
|
+
org_name = ""
|
|
47
|
+
category_path = ""
|
|
48
|
+
|
|
49
|
+
# candidate dict 직접 필드
|
|
50
|
+
if c.get("org_name"):
|
|
51
|
+
org_name = str(c["org_name"])
|
|
52
|
+
if c.get("category_path"):
|
|
53
|
+
category_path = str(c["category_path"])
|
|
54
|
+
|
|
55
|
+
# raw에서 보강 (StatRecord 또는 dict)
|
|
56
|
+
raw = c.get("raw")
|
|
57
|
+
if raw is not None:
|
|
58
|
+
# dataclass StatRecord
|
|
59
|
+
if not org_name:
|
|
60
|
+
_org = getattr(raw, "org_name", None)
|
|
61
|
+
if _org:
|
|
62
|
+
org_name = str(_org)
|
|
63
|
+
if not category_path:
|
|
64
|
+
_md = getattr(raw, "metadata", None)
|
|
65
|
+
if isinstance(_md, dict):
|
|
66
|
+
_cp = _md.get("category_path")
|
|
67
|
+
if _cp:
|
|
68
|
+
category_path = str(_cp)
|
|
69
|
+
# dict raw (예: from_job_success)
|
|
70
|
+
if isinstance(raw, dict):
|
|
71
|
+
if not org_name and raw.get("org_name"):
|
|
72
|
+
org_name = str(raw["org_name"])
|
|
73
|
+
if not category_path and raw.get("category_path"):
|
|
74
|
+
category_path = str(raw["category_path"])
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
"id": cid,
|
|
78
|
+
"name": name,
|
|
79
|
+
"org_name": org_name,
|
|
80
|
+
"category_path": category_path,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _build_prompt(
|
|
85
|
+
*,
|
|
86
|
+
claim_text: str,
|
|
87
|
+
indicator: str,
|
|
88
|
+
population: str,
|
|
89
|
+
time_period: str,
|
|
90
|
+
parent_path: str,
|
|
91
|
+
candidates: list[dict],
|
|
92
|
+
source_context: str = "",
|
|
93
|
+
) -> str:
|
|
94
|
+
"""LLM 프롬프트 구성. candidates는 _normalize_candidate_for_prompt 결과.
|
|
95
|
+
|
|
96
|
+
source_context가 있으면(회사 등 임의 소스) *소스 무관 중립 프롬프트*를 쓴다 —
|
|
97
|
+
'KOSIS/해외 통계' 같은 공공통계 전용 가정을 넣지 않는다.
|
|
98
|
+
"""
|
|
99
|
+
cands_lines: list[str] = []
|
|
100
|
+
for i, c in enumerate(candidates, start=1):
|
|
101
|
+
line = f" [{i}] id={c['id']!r}\n name={c['name']!r}"
|
|
102
|
+
if c.get("org_name"):
|
|
103
|
+
line += f"\n org={c['org_name']!r}"
|
|
104
|
+
if c.get("category_path"):
|
|
105
|
+
line += f"\n category_path={c['category_path']!r}"
|
|
106
|
+
cands_lines.append(line)
|
|
107
|
+
cands_block = "\n".join(cands_lines) if cands_lines else " (없음)"
|
|
108
|
+
|
|
109
|
+
if source_context:
|
|
110
|
+
# ── 소스 무관 중립 랭커 (방대한 회사 데이터 등) ──
|
|
111
|
+
return f"""당신은 '검증 기준 데이터' 후보 ranking reviewer입니다.
|
|
112
|
+
아래 주장을 검증하기에 적합한 후보를 의미 매칭 정도로 0~1 점수로 매기세요.
|
|
113
|
+
|
|
114
|
+
[검증 기준 데이터]
|
|
115
|
+
{source_context}
|
|
116
|
+
|
|
117
|
+
[사용자 주장]
|
|
118
|
+
- 원문: {claim_text or '(없음)'}
|
|
119
|
+
- 검증 지표: {indicator or '(없음)'}
|
|
120
|
+
- 대상/지역: {population or '(없음)'}
|
|
121
|
+
- 시점: {time_period or '(없음)'}
|
|
122
|
+
|
|
123
|
+
[후보 N개]
|
|
124
|
+
{cands_block}
|
|
125
|
+
|
|
126
|
+
[판단 기준]
|
|
127
|
+
1. 후보의 이름/지표가 주장의 지표와 *의미상 일치*하면 score ↑ (같은 대상을 가리키는가?).
|
|
128
|
+
2. 대상/지역/시점이 맞으면 가점. 지표 자체가 다르면 감점.
|
|
129
|
+
3. 무관한 지표는 score 0.0~0.1. (공공/해외 여부는 판단하지 말 것 — 이 소스가 정답 기준이다.)
|
|
130
|
+
4. score: 0.9~1.0 정확 / 0.5~0.8 도메인 맞고 부분 / 0.2~0.4 광범위·협소 / 0.0~0.1 무관.
|
|
131
|
+
|
|
132
|
+
[응답 — JSON only, 모든 후보]
|
|
133
|
+
{{"rankings": [{{"id": "<후보 id>", "score": 0.95, "reason": "<한 줄>"}}, ...]}}
|
|
134
|
+
* id는 입력 candidates의 id 그대로. 누락 후보는 score=0.0 처리.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
return f"""당신은 KOSIS 통계표 ranking reviewer입니다. 사용자 claim 검증에 적합한
|
|
138
|
+
*표 후보 N개를 의미 매칭 정도로 비교*해 0~1 점수로 ranking 하세요.
|
|
139
|
+
|
|
140
|
+
[사용자 claim]
|
|
141
|
+
- 원문: {claim_text or '(없음)'}
|
|
142
|
+
- 검증 지표 (indicator): {indicator or '(없음)'}
|
|
143
|
+
- 대상 집단/지역 (population): {population or '(없음)'}
|
|
144
|
+
- 시점 (time_period): {time_period or '(없음)'}
|
|
145
|
+
- 카테고리 경로 (parent_path): {parent_path or '(없음)'}
|
|
146
|
+
|
|
147
|
+
[후보 표 N개]
|
|
148
|
+
{cands_block}
|
|
149
|
+
|
|
150
|
+
[판단 기준 — 중요]
|
|
151
|
+
1. **한정자 매칭**:
|
|
152
|
+
- claim의 indicator에 *한정자*가 있으면 (예: "체외 충격파 쇄석술 장비", "MRI 장비")
|
|
153
|
+
→ 표 이름에 *그 한정자*가 직접/상위로 매칭되는 표가 *최우선* (예: "특수의료장비",
|
|
154
|
+
"진단방사선 장비" 등 한정자 포함 표가 score ↑).
|
|
155
|
+
- claim의 indicator에 한정자가 *없으면* (예: 단순 "의료장비 수", "인구")
|
|
156
|
+
→ 표도 *한정자 없는 일반 집합* 표가 우선 (한정자 박힌 표는 *부분집합*이라 score ↓).
|
|
157
|
+
2. **population 매칭**:
|
|
158
|
+
- 표 이름이나 category_path에 population을 포함/매칭하면 score ↑.
|
|
159
|
+
- "시도별/시군구별"처럼 지역 분할 표는 대부분의 population에 적합.
|
|
160
|
+
3. **외국/장래/다른 도메인**:
|
|
161
|
+
- 해외 통계, 장래 추계·전망, 도메인 불일치(인구↔경제↔기상) → score 0.0~0.1.
|
|
162
|
+
4. **score 의미**:
|
|
163
|
+
- 0.9~1.0: 한정자/도메인 모두 정확 매칭, 정답 확신
|
|
164
|
+
- 0.5~0.8: 도메인 맞고 한정자 부분 매칭 (상위 집합 등 row 검색으로 회수 가능)
|
|
165
|
+
- 0.2~0.4: 도메인 맞지만 한정자 mismatch 또는 너무 광범위/협소
|
|
166
|
+
- 0.0~0.1: 무관한 표 — 거부 권장
|
|
167
|
+
|
|
168
|
+
[응답 형식 — JSON only, 모든 후보에 대해 작성]
|
|
169
|
+
{{
|
|
170
|
+
"rankings": [
|
|
171
|
+
{{"id": "<후보 id>", "score": 0.95, "reason": "<한 줄 이유>"}},
|
|
172
|
+
...
|
|
173
|
+
]
|
|
174
|
+
}}
|
|
175
|
+
|
|
176
|
+
* id는 반드시 입력 candidates의 id 그대로 사용. 누락된 표는 score=0.0으로 처리됨.
|
|
177
|
+
* score 순서로 정렬할 필요는 없음 (호출자가 정렬).
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse(raw: str, valid_ids: set[str]) -> list[dict] | None:
|
|
182
|
+
"""LLM JSON 응답 파싱. valid_ids에 있는 id만 채택.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
[{id, score, reason}, ...] — score 높은 순 정렬.
|
|
186
|
+
파싱 실패 시 None.
|
|
187
|
+
"""
|
|
188
|
+
if not raw:
|
|
189
|
+
return None
|
|
190
|
+
# JSON 블록 추출 (코드펜스 / 앞뒤 텍스트 핸들링)
|
|
191
|
+
m = re.search(r"\{[\s\S]*\}", raw)
|
|
192
|
+
if not m:
|
|
193
|
+
logger.debug(f"[catalog_ranker] JSON 블록 못 찾음: raw={raw[:200]!r}")
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
data = json.loads(m.group(0))
|
|
197
|
+
except json.JSONDecodeError as e:
|
|
198
|
+
logger.debug(f"[catalog_ranker] JSON parse 실패: {e}, raw={raw[:200]!r}")
|
|
199
|
+
return None
|
|
200
|
+
rankings = data.get("rankings")
|
|
201
|
+
if not isinstance(rankings, list):
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
out: list[dict] = []
|
|
205
|
+
seen_ids: set[str] = set()
|
|
206
|
+
for r in rankings:
|
|
207
|
+
if not isinstance(r, dict):
|
|
208
|
+
continue
|
|
209
|
+
rid = str(r.get("id") or "").strip()
|
|
210
|
+
if not rid or rid not in valid_ids or rid in seen_ids:
|
|
211
|
+
continue
|
|
212
|
+
try:
|
|
213
|
+
score = float(r.get("score", 0.0) or 0.0)
|
|
214
|
+
except (TypeError, ValueError):
|
|
215
|
+
score = 0.0
|
|
216
|
+
score = max(0.0, min(score, 1.0))
|
|
217
|
+
reason = str(r.get("reason") or "")[:200]
|
|
218
|
+
out.append({"id": rid, "score": score, "reason": reason})
|
|
219
|
+
seen_ids.add(rid)
|
|
220
|
+
|
|
221
|
+
# LLM이 누락한 id는 score=0.0으로 추가
|
|
222
|
+
for vid in valid_ids:
|
|
223
|
+
if vid not in seen_ids:
|
|
224
|
+
out.append({"id": vid, "score": 0.0, "reason": "LLM 응답에서 누락 — 기본 0.0"})
|
|
225
|
+
|
|
226
|
+
# score 순 정렬
|
|
227
|
+
out.sort(key=lambda x: -x["score"])
|
|
228
|
+
return out
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def rank_candidates(
|
|
232
|
+
*,
|
|
233
|
+
claim_text: str,
|
|
234
|
+
indicator: str,
|
|
235
|
+
population: str,
|
|
236
|
+
time_period: str,
|
|
237
|
+
parent_path: str,
|
|
238
|
+
candidates: list[dict],
|
|
239
|
+
config: dict | None = None,
|
|
240
|
+
) -> list[dict] | None:
|
|
241
|
+
"""LLM batch ranking으로 후보 표 N개를 의미 점수로 정렬.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
claim_text: claim 원문 (1~3문장).
|
|
245
|
+
indicator: schema.indicator.
|
|
246
|
+
population: schema.population.
|
|
247
|
+
time_period: schema.time_period.
|
|
248
|
+
parent_path: schema.parent_path.
|
|
249
|
+
candidates: [{id, name, score, raw?}, ...] — catalog_search 결과.
|
|
250
|
+
config: 전체 config dict. data_sources.kosis.catalog_ranker 섹션 사용.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
[{id, score, reason}, ...] — score 높은 순 정렬.
|
|
254
|
+
candidates 비었거나 LLM 실패 시 None.
|
|
255
|
+
"""
|
|
256
|
+
if not candidates:
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
# 후보 정규화 (메타데이터 추출)
|
|
260
|
+
norm_cands = [_normalize_candidate_for_prompt(c) for c in candidates]
|
|
261
|
+
valid_ids: set[str] = {c["id"] for c in norm_cands if c["id"]}
|
|
262
|
+
if not valid_ids:
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
# config 추출 — data_sources.kosis.catalog_ranker
|
|
266
|
+
_cfg = ((config or {}).get("data_sources") or {}).get("kosis") or {}
|
|
267
|
+
_rk = _cfg.get("catalog_ranker") or {}
|
|
268
|
+
model_tier = str(_rk.get("model_tier") or _DEFAULT_MODEL_TIER).strip().lower()
|
|
269
|
+
|
|
270
|
+
# 소스 프로파일이 있으면 그 컨텍스트로 *중립 랭커*를 쓴다 (KOSIS 편향 제거).
|
|
271
|
+
_plan = ((config or {}).get("_source_profile") or {}).get("retrieval_plan") or {}
|
|
272
|
+
_source_context = str(_plan.get("ranker_context") or "")
|
|
273
|
+
prompt = _build_prompt(
|
|
274
|
+
claim_text=claim_text,
|
|
275
|
+
indicator=indicator,
|
|
276
|
+
population=population,
|
|
277
|
+
time_period=time_period,
|
|
278
|
+
parent_path=parent_path,
|
|
279
|
+
candidates=norm_cands,
|
|
280
|
+
source_context=_source_context,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
from structverify.utils.llm_client import LLMClient
|
|
284
|
+
llm = LLMClient(config=(config or {}).get("llm") or {})
|
|
285
|
+
try:
|
|
286
|
+
raw = await llm.generate(
|
|
287
|
+
prompt=prompt,
|
|
288
|
+
system_prompt="KOSIS 표 ranking reviewer. JSON만 응답.",
|
|
289
|
+
model_tier=model_tier,
|
|
290
|
+
)
|
|
291
|
+
except Exception as e:
|
|
292
|
+
logger.warning(f"[catalog_ranker] LLM 호출 실패: {e}")
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
parsed = _parse(raw, valid_ids)
|
|
296
|
+
if parsed is None:
|
|
297
|
+
logger.warning(f"[catalog_ranker] 응답 파싱 실패 — raw={raw[:300]!r}")
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
return parsed
|