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,1541 @@
|
|
|
1
|
+
"""structverify.retrieval.kosis_source — KOSIS DataSource adapter (wire-completed).
|
|
2
|
+
|
|
3
|
+
사용자 코드의 *KOSISConnector*를 *BaseDataSource* 인터페이스로 wrap.
|
|
4
|
+
|
|
5
|
+
핵심 설계:
|
|
6
|
+
- KOSISConnector 인스턴스를 *주입* (runtime_agent.self.kosis 재사용)
|
|
7
|
+
- 새 connector를 만들지 않음 — *기존 인스턴스 그대로 사용*
|
|
8
|
+
- 시그니처: KOSISConnector.search(ConnectorQuery) + .fetch(stat_id, params)
|
|
9
|
+
|
|
10
|
+
Phase D 개선사항:
|
|
11
|
+
1. claim.schema → time_period 자동 KOSIS 파라미터 변환
|
|
12
|
+
("2025-04" → prdSe="M" startPrdDe="202504" endPrdDe="202504")
|
|
13
|
+
2. ★ rows 안에서 indicator + time_period 매칭 row 직접 선택
|
|
14
|
+
(connector가 drows[0]만 official_value로 만들어 통합값을 받는 문제 해결.
|
|
15
|
+
DT_1B8000G 같은 출생/사망/혼인/이혼 통합표에서 정확한 row 추출)
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .base import BaseDataSource, CatalogCandidate, EvidenceData
|
|
24
|
+
from .registry import register_datasource
|
|
25
|
+
from structverify.utils.logger import get_logger
|
|
26
|
+
|
|
27
|
+
logger = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── time_period 파싱 helper ──────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
def _parse_time_period(tp: str) -> tuple[str, str, str]:
|
|
33
|
+
"""time_period 문자열 → (prdSe, startPrdDe, endPrdDe) KOSIS API 파라미터.
|
|
34
|
+
|
|
35
|
+
KOSIS API spec:
|
|
36
|
+
- prdSe: 수록주기. "M"(월), "Q"(분기), "Y"(연간), "IR"(부정기)
|
|
37
|
+
- startPrdDe / endPrdDe: 시점 (prdSe에 따라 형식 다름)
|
|
38
|
+
· M: YYYYMM (예: "202504")
|
|
39
|
+
· Q: YYYY0Q (예: "20251" = 2025년 1분기)
|
|
40
|
+
· Y: YYYY (예: "2025")
|
|
41
|
+
|
|
42
|
+
예시:
|
|
43
|
+
"2025-04" → ("M", "202504", "202504") — 월별
|
|
44
|
+
"2025-Q1" → ("Q", "20251", "20251") — 분기
|
|
45
|
+
"2025" → ("Y", "2025", "2025") — 연간
|
|
46
|
+
"" → ("Y", "", "") — fallback
|
|
47
|
+
"""
|
|
48
|
+
if not tp:
|
|
49
|
+
return ("Y", "", "")
|
|
50
|
+
tp = str(tp).strip()
|
|
51
|
+
|
|
52
|
+
# YYYY-MM, YYYY.MM, YYYY/MM
|
|
53
|
+
m = re.match(r"^(\d{4})[-./](\d{1,2})$", tp)
|
|
54
|
+
if m:
|
|
55
|
+
year, month = m.group(1), m.group(2).zfill(2)
|
|
56
|
+
return ("M", f"{year}{month}", f"{year}{month}")
|
|
57
|
+
|
|
58
|
+
# YYYY-Q1, YYYY.Q1, YYYY/Q1, YYYYQ1
|
|
59
|
+
m = re.match(r"^(\d{4})[-./]?Q([1-4])$", tp, re.IGNORECASE)
|
|
60
|
+
if m:
|
|
61
|
+
year, q = m.group(1), m.group(2)
|
|
62
|
+
return ("Q", f"{year}0{q}", f"{year}0{q}")
|
|
63
|
+
|
|
64
|
+
# YYYY only
|
|
65
|
+
m = re.match(r"^(\d{4})$", tp)
|
|
66
|
+
if m:
|
|
67
|
+
return ("Y", m.group(1), m.group(1))
|
|
68
|
+
|
|
69
|
+
return ("Y", "", "")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _normalize_prd_de(tp: str) -> str:
|
|
73
|
+
"""time_period를 KOSIS PRD_DE 포맷으로 변환.
|
|
74
|
+
|
|
75
|
+
"2025-04" → "202504"
|
|
76
|
+
"2025" → "2025"
|
|
77
|
+
"" / None → ""
|
|
78
|
+
"""
|
|
79
|
+
if not tp:
|
|
80
|
+
return ""
|
|
81
|
+
tp = str(tp).strip()
|
|
82
|
+
m = re.match(r"^(\d{4})[-./](\d{1,2})$", tp)
|
|
83
|
+
if m:
|
|
84
|
+
return f"{m.group(1)}{m.group(2).zfill(2)}"
|
|
85
|
+
m = re.match(r"^(\d{4})[-./]?Q([1-4])$", tp, re.IGNORECASE)
|
|
86
|
+
if m:
|
|
87
|
+
return f"{m.group(1)}0{m.group(2)}"
|
|
88
|
+
m = re.match(r"^(\d{4})$", tp)
|
|
89
|
+
if m:
|
|
90
|
+
return m.group(1)
|
|
91
|
+
return tp.replace("-", "").replace(".", "").replace("/", "")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _period_granularity(prd: str) -> str:
|
|
95
|
+
"""[v6.19] PRD_DE 문자열의 시점 단위를 판별.
|
|
96
|
+
|
|
97
|
+
"202409" (6자리) → "month"
|
|
98
|
+
"20243" (5자리, 분기) → "quarter"
|
|
99
|
+
"2024" (4자리) → "year"
|
|
100
|
+
그 외 → "unknown"
|
|
101
|
+
|
|
102
|
+
claim 시점과 KOSIS 행 시점의 단위가 다른지(월 claim에 연값 매칭)
|
|
103
|
+
가드하는 데 쓴다.
|
|
104
|
+
"""
|
|
105
|
+
if not prd:
|
|
106
|
+
return "unknown"
|
|
107
|
+
p = str(prd).strip()
|
|
108
|
+
if not p.isdigit():
|
|
109
|
+
return "unknown"
|
|
110
|
+
if len(p) == 6:
|
|
111
|
+
return "month"
|
|
112
|
+
if len(p) == 5:
|
|
113
|
+
return "quarter"
|
|
114
|
+
if len(p) == 4:
|
|
115
|
+
return "year"
|
|
116
|
+
return "unknown"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _table_has_period_for(rows: list[dict], claim_period: str) -> bool:
|
|
120
|
+
"""[v6.20] 표(rows)에 claim 시점 단위의 데이터가 존재하는지 판별.
|
|
121
|
+
|
|
122
|
+
도메인 독립적 규칙: claim이 월(YYYY-MM)인데 표의 모든 행이
|
|
123
|
+
연 단위(PRD_SE='A' 또는 PRD_DE 4자리/없음)면, 그 표엔 월
|
|
124
|
+
데이터가 없는 것 → fetch해도 가짜 매칭만 나오므로 일찍 거부한다.
|
|
125
|
+
|
|
126
|
+
KOSIS 표마다 수록주기가 다르다 (DT_1YL9801=연, DT_1B8000G의
|
|
127
|
+
일부 행=연만 응답 등). 표별 하드코딩 대신 행 데이터로 판별해
|
|
128
|
+
어느 도메인 표에도 동작하게 한다.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
True — claim 단위의 행이 하나라도 있음 (또는 판별 불가 → 통과)
|
|
132
|
+
False — claim은 월/분기인데 표는 연 단위뿐 → 거부 권장
|
|
133
|
+
"""
|
|
134
|
+
claim_gran = _period_granularity(_normalize_prd_de(claim_period or ""))
|
|
135
|
+
if claim_gran not in ("month", "quarter"):
|
|
136
|
+
return True # 연 단위 claim — 가드 불필요
|
|
137
|
+
if not rows:
|
|
138
|
+
return True # 행 없음 — 별도 처리
|
|
139
|
+
saw_any_period = False
|
|
140
|
+
for r in rows:
|
|
141
|
+
prd = str(r.get("PRD_DE", "") or "").strip()
|
|
142
|
+
prd_se = str(r.get("PRD_SE", "") or "").strip().upper()
|
|
143
|
+
row_gran = _period_granularity(prd)
|
|
144
|
+
if prd_se == "A":
|
|
145
|
+
row_gran = "year"
|
|
146
|
+
if row_gran != "unknown":
|
|
147
|
+
saw_any_period = True
|
|
148
|
+
# claim 단위(월/분기)와 호환되는 행을 하나라도 찾으면 OK
|
|
149
|
+
if claim_gran == "month" and row_gran == "month":
|
|
150
|
+
return True
|
|
151
|
+
if claim_gran == "quarter" and row_gran in ("month", "quarter"):
|
|
152
|
+
return True
|
|
153
|
+
# 시점 정보가 있는 행은 봤지만 claim 단위와 맞는 게 하나도 없음 → 거부
|
|
154
|
+
# 시점 정보가 아예 없는 표(saw_any_period=False)는 판별 불가 → 통과
|
|
155
|
+
return not saw_any_period
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _normalize_korean(s: str) -> str:
|
|
159
|
+
"""KOSIS 한국어 비교용 정규화 — 공백/특수문자 제거, 소문자."""
|
|
160
|
+
if not s:
|
|
161
|
+
return ""
|
|
162
|
+
s = str(s)
|
|
163
|
+
# 공백, 중간점, 슬래시 등 표기 차이 흡수
|
|
164
|
+
for ch in (" ", "\t", "\u00A0", "·", "ㆍ", "・", "/", "-", "_", "(", ")", "[", "]"):
|
|
165
|
+
s = s.replace(ch, "")
|
|
166
|
+
return s.strip().lower()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ── [수정 v6.23] 국가 차원 인식 ───────────────────────────────────────
|
|
170
|
+
# [추가 이유] DT_2KAA207('합계출산율')처럼 표 이름엔 국가 표시가 없지만
|
|
171
|
+
# 실제 행은 국가별(세계/대한민국/일본/중국...)인 표가 있다. 표 이름만
|
|
172
|
+
# 보는 가드는 이런 표를 통과시키고, 행 선택은 첫 행('세계' 2.25)을
|
|
173
|
+
# 집어 한국 기사값(0.72)과 비교 → '불일치(거짓)'로 오판한다.
|
|
174
|
+
# [해결] 가져온 행 데이터의 카테고리 컬럼을 보고 '국가 차원'인지
|
|
175
|
+
# 판별한다. 국가 차원이면 반드시 '대한민국' 행을 골라야 하고,
|
|
176
|
+
# 한국 행이 없으면(외국만 있으면) 그 표는 국내 claim에 부적합.
|
|
177
|
+
# 도메인 무관 — KOSIS 표준 국가 라벨만 사용, 지표명 하드코딩 없음.
|
|
178
|
+
|
|
179
|
+
# KOSIS에서 '대한민국'을 가리키는 표기들
|
|
180
|
+
_KOREA_LABELS = {"대한민국", "한국", "korea", "republicofkorea", "southkorea", "kor"}
|
|
181
|
+
|
|
182
|
+
# 국가 차원임을 강하게 시사하는, 한국이 아닌 대표 국가/지역 라벨.
|
|
183
|
+
# (이 라벨이 행 카테고리에 보이면 그 컬럼은 '국가 차원'이다)
|
|
184
|
+
_FOREIGN_COUNTRY_LABELS = {
|
|
185
|
+
"세계", "아시아", "유럽", "아프리카", "북아메리카", "남아메리카",
|
|
186
|
+
"오세아니아", "일본", "중국", "미국", "독일", "프랑스", "영국",
|
|
187
|
+
"인도", "베트남", "태국", "러시아", "이탈리아", "스페인", "캐나다",
|
|
188
|
+
"호주", "브라질", "멕시코", "인도네시아", "필리핀", "대만", "홍콩",
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _is_korea_label(value: str) -> bool:
|
|
193
|
+
"""카테고리 값이 '대한민국'을 가리키는지."""
|
|
194
|
+
n = _normalize_korean(value)
|
|
195
|
+
return n in _KOREA_LABELS
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _detect_country_field(rows: list[dict]) -> str | None:
|
|
199
|
+
"""행 목록에서 '국가 차원'을 담은 카테고리 컬럼명을 찾는다.
|
|
200
|
+
|
|
201
|
+
C1_NM~C4_NM 중, 값들에 외국 국가/대륙 라벨이 하나라도 보이면
|
|
202
|
+
그 컬럼이 국가 차원 — 컬럼명을 반환. 없으면 None.
|
|
203
|
+
|
|
204
|
+
표 이름이 아니라 *실제 가져온 데이터*를 보고 판단하므로,
|
|
205
|
+
이름에 국가 표시가 없는 표(DT_2KAA207 등)도 잡아낸다.
|
|
206
|
+
"""
|
|
207
|
+
for field_name in ("C1_NM", "C2_NM", "C3_NM", "C4_NM"):
|
|
208
|
+
seen_foreign = False
|
|
209
|
+
seen_any = False
|
|
210
|
+
for r in rows:
|
|
211
|
+
raw = r.get(field_name)
|
|
212
|
+
if raw is None:
|
|
213
|
+
continue
|
|
214
|
+
seen_any = True
|
|
215
|
+
n = _normalize_korean(str(raw))
|
|
216
|
+
if n in {_normalize_korean(x) for x in _FOREIGN_COUNTRY_LABELS}:
|
|
217
|
+
seen_foreign = True
|
|
218
|
+
break
|
|
219
|
+
if seen_any and seen_foreign:
|
|
220
|
+
return field_name
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ── [수정 v6.24] 데이터 기반 관련성 판정 ──────────────────────────────
|
|
225
|
+
# [추가 이유] 가계동향조사 표(DT_1L9V153 '가구원수별 가구당 월평균 가계수지'
|
|
226
|
+
# 등)는 표 이름에 '평균소비성향'·'지출 비중' 같은 지표명이 없다. 지표는
|
|
227
|
+
# 표 안의 *항목 행*(ITM_NM 등)으로 들어있다. 표 이름만 보는 관련성
|
|
228
|
+
# 가드(_is_table_relevant)는 이런 표를 전부 '관련 없음'으로 거부 →
|
|
229
|
+
# 가계동향조사 기사 claim이 행 선택까지 가지도 못하고 전멸한다.
|
|
230
|
+
# (출생아 수 표는 이름에 '출생'이 있어 우연히 통과했을 뿐.)
|
|
231
|
+
# [해결] 이미 fetch해서 손에 든 rows 안에서 indicator 항목을 직접 찾는다.
|
|
232
|
+
# 행에 있으면 그 표는 관련 있는 표 — 표 이름과 무관하게 통과.
|
|
233
|
+
# 도메인 무관 — 지표명/표 ID 하드코딩 없음. 행 데이터로만 판별.
|
|
234
|
+
|
|
235
|
+
def _indicator_in_rows(rows: list[dict], indicator: str | None) -> bool:
|
|
236
|
+
"""fetch한 rows의 항목 컬럼에 indicator가 실제로 들어있는지.
|
|
237
|
+
|
|
238
|
+
ITM_NM·C1_NM~C4_NM 값을 정규화해서 indicator와 비교한다.
|
|
239
|
+
- 정규화 후 한쪽이 다른 쪽에 포함되면 매칭 (표기차 흡수).
|
|
240
|
+
True면 '표 이름이 안 맞아도 데이터 안에 지표가 있는 표'.
|
|
241
|
+
|
|
242
|
+
[패치 M] length ratio guard 추가. 이전엔 row 컬럼이 indicator의 *극히
|
|
243
|
+
일부분*만 포함해도 통과시켜 도메인이 완전히 다른 표를 잘못 인정했다.
|
|
244
|
+
예: indicator='전국 표준단독주택 공시가격 상승률'(정규화 16자) 대해
|
|
245
|
+
C1_NM='전국'(2자, 지역명)만 매칭돼도 → True → 범죄 통계표 통과 →
|
|
246
|
+
부동산 claim에 범죄 row가 evidence로 박히는 가짜 매칭 발생.
|
|
247
|
+
이제 양쪽 길이 비율이 너무 차이나면(짧은 쪽 / 긴 쪽 < 0.5) 거부.
|
|
248
|
+
"""
|
|
249
|
+
if not rows or not indicator:
|
|
250
|
+
return False
|
|
251
|
+
ind_norm = _normalize_korean(str(indicator).strip())
|
|
252
|
+
if not ind_norm or len(ind_norm) < 2:
|
|
253
|
+
return False
|
|
254
|
+
_FIELDS = ("ITM_NM", "C1_NM", "C2_NM", "C3_NM", "C4_NM")
|
|
255
|
+
# 매칭 비율 가드: row 컬럼과 indicator의 길이 차이가 2배 이상이면 부정합
|
|
256
|
+
# (지역명/단위 같은 짧은 단어가 긴 indicator 안에 우연히 들어있어
|
|
257
|
+
# 잘못 매칭되는 케이스 차단)
|
|
258
|
+
_MIN_LEN_RATIO = 0.5
|
|
259
|
+
for r in rows:
|
|
260
|
+
if not isinstance(r, dict):
|
|
261
|
+
continue
|
|
262
|
+
for f in _FIELDS:
|
|
263
|
+
raw = r.get(f)
|
|
264
|
+
if raw is None:
|
|
265
|
+
continue
|
|
266
|
+
fn = _normalize_korean(str(raw))
|
|
267
|
+
if not fn:
|
|
268
|
+
continue
|
|
269
|
+
shorter, longer = min(len(fn), len(ind_norm)), max(len(fn), len(ind_norm))
|
|
270
|
+
if shorter / longer < _MIN_LEN_RATIO:
|
|
271
|
+
continue # 길이 차 너무 큼 — 짧은 키워드 우연 매칭 차단
|
|
272
|
+
# 정규화 후 양방향 substring (예: '평균소비성향' in '가구당평균소비성향')
|
|
273
|
+
if ind_norm in fn or fn in ind_norm:
|
|
274
|
+
return True
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
async def _select_best_row(
|
|
279
|
+
rows: list[dict],
|
|
280
|
+
indicator: str | None,
|
|
281
|
+
time_period: str | None,
|
|
282
|
+
population: str | None = None,
|
|
283
|
+
unit_hint: str | None = None,
|
|
284
|
+
match_criteria: dict[str, Any] | None = None,
|
|
285
|
+
llm_fallback_ctx: dict[str, Any] | None = None,
|
|
286
|
+
) -> dict | None:
|
|
287
|
+
"""KOSIS row 목록에서 indicator + time_period 매칭 row 1개 선택.
|
|
288
|
+
|
|
289
|
+
KOSIS row 표준 필드:
|
|
290
|
+
DT: 데이터 값 (string, "20717" 같은)
|
|
291
|
+
PRD_DE: 시점 ("202504", "2025" 등)
|
|
292
|
+
ITM_NM: 항목명 ("출생아 수", "사망자 수", ...)
|
|
293
|
+
C1_NM / C2_NM / C3_NM: 카테고리 차원 (지역, 성별 등)
|
|
294
|
+
UNIT_NM: 단위 ("명", "건", ...)
|
|
295
|
+
|
|
296
|
+
매칭 우선순위:
|
|
297
|
+
1. indicator + PRD_DE 둘 다 정확 매칭 (가장 신뢰)
|
|
298
|
+
2. indicator만 매칭, 그중 PRD_DE가 정답 시점에 *포함*되거나 *시작*하는 것
|
|
299
|
+
3. indicator만 매칭 (어떤 시점이든)
|
|
300
|
+
4. PRD_DE만 매칭
|
|
301
|
+
|
|
302
|
+
한국어 정규화: '출생아 수' = '출생아수' = '출생아·수'. 공백/특수문자 차이를
|
|
303
|
+
흡수해서 KOSIS 표기차로 매칭 실패하지 않도록 함.
|
|
304
|
+
|
|
305
|
+
[2026-05-21 추가] match_criteria: LLM이 row sample 보고 직접 명시한 *컬럼-값 매칭
|
|
306
|
+
제약*. {col_name: expected_value} 형태. 예: {"C1_NM": "강원", "ITM_NM": "주요의료장비"}.
|
|
307
|
+
한 row가 모든 criteria를 만족(substring 매칭, 한국어 정규화)해야 채택됨. 도메인별
|
|
308
|
+
하드코딩(C1_NM=지역) 없이, LLM이 매번 표 구조 보고 적절한 컬럼/값을 박는 방식.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
매칭된 row dict 또는 None.
|
|
312
|
+
"""
|
|
313
|
+
if not rows:
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
prd_target = _normalize_prd_de(time_period or "")
|
|
317
|
+
ind_norm_raw = (indicator or "").strip()
|
|
318
|
+
ind_norm = _normalize_korean(ind_norm_raw)
|
|
319
|
+
pop_norm = _normalize_korean((population or "").strip())
|
|
320
|
+
|
|
321
|
+
# ── [2026-05-21] match_criteria 사전 필터 ────────────────────────
|
|
322
|
+
# LLM이 명시한 {col: value} 제약을 *모든 row에 한 번에* 적용. 한 row가
|
|
323
|
+
# 모든 criteria를 정규화 후 substring 매칭해야 통과. criteria가 비어있으면
|
|
324
|
+
# noop. domain-specific 가드 없이 LLM 판단으로 row 좁히는 메커니즘.
|
|
325
|
+
#
|
|
326
|
+
# [2026-05-21 P19] LLM column명 hallucination 가드 — KOSIS는 column에 의미적
|
|
327
|
+
# 이름을 안 주고 C1_NM/C2_NM/... 같은 generic 이름만 줘서, LLM이 '시도명'·
|
|
328
|
+
# '광역자치단체명' 같은 *추측 컬럼명*을 넘기는 경우 잦음. 실제 row에 그 키가
|
|
329
|
+
# *전혀 없으면* 그 criterion만 *무시*. (23:55 로그 — '시도명':'강원도'가
|
|
330
|
+
# row에 없는 column이라 891 row 전부 거부됐던 케이스)
|
|
331
|
+
if match_criteria and rows:
|
|
332
|
+
_sample_keys = set(rows[0].keys()) if isinstance(rows[0], dict) else set()
|
|
333
|
+
criteria_norm: dict[str, str] = {}
|
|
334
|
+
_ignored_cols: list[str] = []
|
|
335
|
+
for k, v in match_criteria.items():
|
|
336
|
+
if v is None or not str(v).strip():
|
|
337
|
+
continue
|
|
338
|
+
if k not in _sample_keys:
|
|
339
|
+
_ignored_cols.append(k)
|
|
340
|
+
continue
|
|
341
|
+
criteria_norm[str(k)] = _normalize_korean(str(v))
|
|
342
|
+
if _ignored_cols:
|
|
343
|
+
logger.info(
|
|
344
|
+
f"[_select_best_row] match_criteria 컬럼명 hallucination 가드 — "
|
|
345
|
+
f"실제 row에 없는 키 {_ignored_cols} 무시 "
|
|
346
|
+
f"(LLM이 추측한 컬럼명. 실제 row 키: {sorted(_sample_keys)[:10]}...)"
|
|
347
|
+
)
|
|
348
|
+
if criteria_norm:
|
|
349
|
+
def _criteria_match(row: dict) -> bool:
|
|
350
|
+
for col, expected in criteria_norm.items():
|
|
351
|
+
raw = row.get(col)
|
|
352
|
+
if raw is None:
|
|
353
|
+
return False
|
|
354
|
+
actual = _normalize_korean(str(raw))
|
|
355
|
+
if expected not in actual and actual not in expected:
|
|
356
|
+
return False
|
|
357
|
+
return True
|
|
358
|
+
|
|
359
|
+
filtered = [r for r in rows if _criteria_match(r)]
|
|
360
|
+
if filtered:
|
|
361
|
+
logger.info(
|
|
362
|
+
f"[_select_best_row] match_criteria {criteria_norm} 적용: "
|
|
363
|
+
f"{len(rows)} → {len(filtered)} rows"
|
|
364
|
+
)
|
|
365
|
+
rows = filtered
|
|
366
|
+
else:
|
|
367
|
+
# criteria 다 만족하는 row가 한 개도 없음 → 표 부적합
|
|
368
|
+
logger.warning(
|
|
369
|
+
f"[_select_best_row] match_criteria {criteria_norm} "
|
|
370
|
+
f"만족하는 row 없음 → 매칭 실패 (호출자가 다음 candidate 시도)"
|
|
371
|
+
)
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
# KOSIS row에서 indicator를 담을 가능성이 있는 모든 string column
|
|
375
|
+
_INDICATOR_FIELDS = ("ITM_NM", "C1_NM", "C2_NM", "C3_NM", "C4_NM")
|
|
376
|
+
|
|
377
|
+
def _ind_match(row: dict) -> bool:
|
|
378
|
+
"""엄격한 indicator 매칭.
|
|
379
|
+
|
|
380
|
+
- 정규화 후 정확 일치 (양방향 ==)
|
|
381
|
+
- 또는 row가 *단일 indicator*일 때만 substring 허용
|
|
382
|
+
("출생사망혼인이혼" 같은 통합 라벨은 제외)
|
|
383
|
+
"""
|
|
384
|
+
if not ind_norm:
|
|
385
|
+
return True
|
|
386
|
+
# KOSIS 통합 카테고리 라벨 (이게 매칭되면 잘못된 row)
|
|
387
|
+
_COMBO_LABELS = (
|
|
388
|
+
"출생사망혼인이혼", "출생사망", "혼인이혼",
|
|
389
|
+
"출생사망혼인", "사망혼인이혼",
|
|
390
|
+
)
|
|
391
|
+
for field_name in _INDICATOR_FIELDS:
|
|
392
|
+
raw = row.get(field_name)
|
|
393
|
+
if raw is None:
|
|
394
|
+
continue
|
|
395
|
+
field_n = _normalize_korean(str(raw))
|
|
396
|
+
if not field_n:
|
|
397
|
+
continue
|
|
398
|
+
# 통합 라벨은 거부
|
|
399
|
+
if field_n in _COMBO_LABELS:
|
|
400
|
+
continue
|
|
401
|
+
# 정확 일치
|
|
402
|
+
if field_n == ind_norm:
|
|
403
|
+
return True
|
|
404
|
+
# 길이 차이가 너무 크면 거부 (예: "출생아수" vs "출생률" 둘 다 짧을 때만 허용)
|
|
405
|
+
len_ratio = max(len(field_n), len(ind_norm)) / max(1, min(len(field_n), len(ind_norm)))
|
|
406
|
+
if len_ratio > 2.5:
|
|
407
|
+
continue
|
|
408
|
+
# 한쪽이 다른 쪽에 완전히 포함되면 OK (예: "출생아수" in "월별출생아수")
|
|
409
|
+
if ind_norm in field_n or field_n in ind_norm:
|
|
410
|
+
return True
|
|
411
|
+
return False
|
|
412
|
+
|
|
413
|
+
# 연 claim('2025')에 분기/월 row('202512')의 prefix match를 허용할지
|
|
414
|
+
# 판단하는 보수적 휴리스틱. stock(저량) 변수만 허용 — flow(유량)/rate(비율)는
|
|
415
|
+
# 단일 분기/월 값을 연값으로 받으면 silent 데이터 손실 (예: 1월 출생아수를
|
|
416
|
+
# 연합계로 잘못 매칭).
|
|
417
|
+
#
|
|
418
|
+
# stock 신호:
|
|
419
|
+
# - UNIT_NM이 보유/존재 단위 ('대', '개소', '병상', '기관', '곳', '동', '호')
|
|
420
|
+
# - ITM_NM에 '현황'/'보유'/'재적' 같은 *snapshot 의미어* 포함
|
|
421
|
+
# 둘 다 보고 *둘 중 하나라도* 양성이면 stock 추정 (재현율 우선).
|
|
422
|
+
# 한쪽이라도 *명확한 비-stock 단위*('%', '원/명', '℃')면 무조건 거부.
|
|
423
|
+
_STOCK_UNITS = {"대", "개소", "병상", "기관", "곳", "동", "호"}
|
|
424
|
+
_NON_STOCK_UNITS = {"%", "원/명", "℃", "도", "배", "점", "위", "원/㎡"}
|
|
425
|
+
_STOCK_ITM_KEYWORDS = ("현황", "보유", "재적", "재고")
|
|
426
|
+
|
|
427
|
+
def _is_stock_row(row: dict) -> bool:
|
|
428
|
+
unit = str(row.get("UNIT_NM", "") or "").strip()
|
|
429
|
+
if unit in _NON_STOCK_UNITS or "%" in unit:
|
|
430
|
+
return False
|
|
431
|
+
if unit in _STOCK_UNITS:
|
|
432
|
+
return True
|
|
433
|
+
itm = str(row.get("ITM_NM", "") or "")
|
|
434
|
+
return any(k in itm for k in _STOCK_ITM_KEYWORDS)
|
|
435
|
+
|
|
436
|
+
def _time_match(row: dict) -> bool:
|
|
437
|
+
if not prd_target:
|
|
438
|
+
return True
|
|
439
|
+
prd = str(row.get("PRD_DE", "") or "").strip()
|
|
440
|
+
if prd == prd_target:
|
|
441
|
+
return True
|
|
442
|
+
# [2026-05-25] 연 claim('2025') ↔ 분기/월 row('202512') prefix match.
|
|
443
|
+
# stock 변수일 때만 허용 (연말/최신 분기 스냅샷 ≈ 연값).
|
|
444
|
+
# flow/rate는 단일 시점값을 연값으로 오인할 위험 → 거부.
|
|
445
|
+
if len(prd_target) == 4 and len(prd) > 4 and prd.startswith(prd_target):
|
|
446
|
+
if _is_stock_row(row):
|
|
447
|
+
return True
|
|
448
|
+
return False
|
|
449
|
+
|
|
450
|
+
# [v6.19] claim 시점 단위 (월/연/분기)
|
|
451
|
+
_claim_gran = _period_granularity(prd_target)
|
|
452
|
+
|
|
453
|
+
def _granularity_ok(row: dict) -> bool:
|
|
454
|
+
"""[v6.19] claim과 row의 시점 단위가 호환되는지.
|
|
455
|
+
|
|
456
|
+
claim이 월(YYYY-MM)인데 row가 연(YYYY) 단위면 → 부적합.
|
|
457
|
+
그 표엔 월 데이터가 없는 것이므로, 연값을 월 claim에
|
|
458
|
+
붙이는 가짜 매칭을 막는다. (예: "9월 24.7도" vs 연평균 14.5도)
|
|
459
|
+
|
|
460
|
+
claim이 연 단위거나 claim 시점이 없으면 가드하지 않음.
|
|
461
|
+
"""
|
|
462
|
+
if _claim_gran not in ("month", "quarter"):
|
|
463
|
+
return True # 연 단위 claim — 가드 불필요
|
|
464
|
+
prd = str(row.get("PRD_DE", "") or "").strip()
|
|
465
|
+
row_gran = _period_granularity(prd)
|
|
466
|
+
# PRD_SE='A'(Annual)도 연 단위 신호 — 함께 본다
|
|
467
|
+
prd_se = str(row.get("PRD_SE", "") or "").strip().upper()
|
|
468
|
+
if prd_se == "A":
|
|
469
|
+
row_gran = "year"
|
|
470
|
+
if row_gran == "unknown":
|
|
471
|
+
return True # 판별 불가 — 막지 않음
|
|
472
|
+
# 월 claim에 연/분기 row, 분기 claim에 연 row → 부적합
|
|
473
|
+
if _claim_gran == "month" and row_gran != "month":
|
|
474
|
+
return False
|
|
475
|
+
if _claim_gran == "quarter" and row_gran == "year":
|
|
476
|
+
return False
|
|
477
|
+
return True
|
|
478
|
+
|
|
479
|
+
def _pop_match(row: dict) -> bool:
|
|
480
|
+
if not pop_norm or pop_norm in ("전체", "전국", "계", "total"):
|
|
481
|
+
return True
|
|
482
|
+
for field_name in ("C1_NM", "C2_NM"):
|
|
483
|
+
raw = row.get(field_name)
|
|
484
|
+
if raw is None:
|
|
485
|
+
continue
|
|
486
|
+
field_n = _normalize_korean(str(raw))
|
|
487
|
+
if not field_n:
|
|
488
|
+
continue
|
|
489
|
+
if pop_norm in field_n or field_n in pop_norm:
|
|
490
|
+
return True
|
|
491
|
+
return False
|
|
492
|
+
|
|
493
|
+
# ── [수정 v6.25] 단위 적합성 가드 ────────────────────────────────
|
|
494
|
+
# [추가 이유] 가계동향조사 표는 비목 '금액(원)' 행과, 기사 claim의
|
|
495
|
+
# '지출 비중(%)'·'평균소비성향(%)'이 한 표에 섞여 있다. 단위를
|
|
496
|
+
# 안 보면 ITM='전체가구' 행의 2.253원을 claim '4.8%'와 비교해
|
|
497
|
+
# 가짜 mismatch를 낸다. claim unit_hint와 행 UNIT_NM의 타입이
|
|
498
|
+
# 다르면(% vs 원/명) 그 행은 답이 될 수 없으므로 후보에서 제외.
|
|
499
|
+
# [효과] '지출 비중' claim은 표에 % 행이 없으면 전 행 탈락 → None
|
|
500
|
+
# → 가짜 mismatch 대신 정직한 unverifiable.
|
|
501
|
+
from structverify.retrieval.kosis_connector import is_same_unit_type
|
|
502
|
+
_unit_hint = (unit_hint or "").strip()
|
|
503
|
+
|
|
504
|
+
# ── [패치 3-2b] derived indicator(~증가율 등)면 unit 가드 완화 ──
|
|
505
|
+
# claim이 '출생아 수 증가율'(unit=%) 같은 파생 지표일 때, KOSIS 표엔
|
|
506
|
+
# 증가율 row가 없고 base 값(명/건) row만 있음. unit_hint='%'를 그대로
|
|
507
|
+
# 적용하면 모든 row가 탈락 → 매칭 실패 → unverifiable. loop의
|
|
508
|
+
# _try_growth_rate_from_rows가 prev/current 두 시점 base 값으로 직접
|
|
509
|
+
# 비율을 계산하는 경로가 있으므로, derived indicator에서는 단위 다른
|
|
510
|
+
# base row를 받아들이고 비율 계산은 그 다음 단계에 맡긴다.
|
|
511
|
+
_DERIVED_SUFFIXES = (
|
|
512
|
+
"증가율", "감소율", "증감률", "변화율", "상승률", "하락률",
|
|
513
|
+
)
|
|
514
|
+
_indicator_raw = (indicator or "").strip()
|
|
515
|
+
_is_derived_indicator = any(
|
|
516
|
+
_indicator_raw.endswith(s) for s in _DERIVED_SUFFIXES
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
def _unit_match(row: dict) -> bool:
|
|
520
|
+
if not _unit_hint:
|
|
521
|
+
return True # claim 단위 정보 없음 — 가드 불가
|
|
522
|
+
if _is_derived_indicator:
|
|
523
|
+
return True # 파생 지표 — base 단위 row를 받아 다음 단계에 맡김
|
|
524
|
+
row_unit = str(row.get("UNIT_NM", "") or "").strip()
|
|
525
|
+
if not row_unit:
|
|
526
|
+
return True # 행 단위 미상 — 막지 않음
|
|
527
|
+
return is_same_unit_type(_unit_hint, row_unit)
|
|
528
|
+
|
|
529
|
+
# ── [수정 v6.23] 국가 차원 필터 ──────────────────────────────────
|
|
530
|
+
# 행 데이터에 국가 차원(세계/일본/중국...)이 있으면, 국내 claim은
|
|
531
|
+
# 반드시 '대한민국' 행이어야 한다. population='전체'라 해도 국가
|
|
532
|
+
# 차원에선 '세계'가 아니라 '대한민국'을 골라야 한다.
|
|
533
|
+
# _pop_match는 '전체'를 무조건 통과시켜 첫 행(세계)을 잡으므로,
|
|
534
|
+
# 그 위에 이 가드를 덧씌운다.
|
|
535
|
+
_country_field = _detect_country_field(rows)
|
|
536
|
+
if _country_field is not None:
|
|
537
|
+
_korea_rows = [
|
|
538
|
+
r for r in rows if _is_korea_label(str(r.get(_country_field, "")))
|
|
539
|
+
]
|
|
540
|
+
if _korea_rows:
|
|
541
|
+
# 국가 차원이 있는 표 → 한국 행으로만 후보를 좁힌다.
|
|
542
|
+
logger.info(
|
|
543
|
+
f"[_select_best_row] 국가 차원 감지({_country_field}) "
|
|
544
|
+
f"→ 대한민국 행 {len(_korea_rows)}개로 한정"
|
|
545
|
+
)
|
|
546
|
+
rows = _korea_rows
|
|
547
|
+
else:
|
|
548
|
+
# 국가 차원인데 한국 행이 없음 → 해외 전용 표.
|
|
549
|
+
# 국내 claim에는 부적합 → 매칭 실패 처리.
|
|
550
|
+
logger.warning(
|
|
551
|
+
f"[_select_best_row] 국가 차원({_country_field})에 "
|
|
552
|
+
f"대한민국 행 없음 → 해외 전용 표, 국내 claim 부적합"
|
|
553
|
+
)
|
|
554
|
+
return None
|
|
555
|
+
|
|
556
|
+
# ── [2026-05-21 I 패치] pop + indicator pre-filter ──────────────
|
|
557
|
+
# 기존: 1·2·3차는 가드 검사, 4차(시점만)는 pop/indicator 무시하고 첫 row 반환.
|
|
558
|
+
# 이로 인해:
|
|
559
|
+
# - 서울 claim에 강원 row(1336)가 누수 (의료장비 #2 케이스)
|
|
560
|
+
# - '의료장비 수' claim에 ITM_NM='진료실인원수'(DT_35003_A10) 누수 (#3)
|
|
561
|
+
# 처방: pop/indicator가 명시되어 있고 *그 어떤 row도* 매칭 안 되면 표 자체
|
|
562
|
+
# 부적합 → None 반환해 호출자가 다음 candidate 시도. 매칭 row가 있으면
|
|
563
|
+
# 그것만 후보로 좁혀서 모든 차수 매칭에 사용 (4차 fallback도 좁혀진 안에서).
|
|
564
|
+
# 도메인 무관: pop/indicator는 KOSIS 표준 신호, value_role/도메인 무관.
|
|
565
|
+
if pop_norm and pop_norm not in ("전체", "전국", "계", "total"):
|
|
566
|
+
pop_matched = [r for r in rows if _pop_match(r)]
|
|
567
|
+
if not pop_matched:
|
|
568
|
+
logger.warning(
|
|
569
|
+
f"[_select_best_row] population={pop_norm!r} 매칭 row 없음 "
|
|
570
|
+
f"({len(rows)} rows 검사) → 표 부적합, 다음 candidate 시도"
|
|
571
|
+
)
|
|
572
|
+
return None
|
|
573
|
+
if len(pop_matched) < len(rows):
|
|
574
|
+
logger.info(
|
|
575
|
+
f"[_select_best_row] population={pop_norm!r} pre-filter: "
|
|
576
|
+
f"{len(rows)} → {len(pop_matched)} rows"
|
|
577
|
+
)
|
|
578
|
+
rows = pop_matched
|
|
579
|
+
|
|
580
|
+
# indicator pre-filter — derived 지표(~증가율)는 base 단위 row를 받아야
|
|
581
|
+
# 하므로 ind_match 가드 우회 (기존 _is_derived_indicator 로직과 일관).
|
|
582
|
+
if ind_norm and not _is_derived_indicator:
|
|
583
|
+
ind_matched = [r for r in rows if _ind_match(r)]
|
|
584
|
+
if not ind_matched:
|
|
585
|
+
# [P33a 2026-05-22] 매칭 실패 디버그 로그 강화 — 56 row의 unique
|
|
586
|
+
# ITM_NM/C1~C4_NM 분포를 찍어 *진짜 indicator가 표에 없는지* 또는
|
|
587
|
+
# *룰 매칭 함수가 못 잡는지* 다음 trace에서 판별 가능하도록.
|
|
588
|
+
try:
|
|
589
|
+
_fields_to_log = ("ITM_NM", "C1_NM", "C2_NM", "C3_NM", "C4_NM")
|
|
590
|
+
for _f in _fields_to_log:
|
|
591
|
+
_vals: list[str] = []
|
|
592
|
+
_seen: set[str] = set()
|
|
593
|
+
for _r in rows:
|
|
594
|
+
_v = _r.get(_f)
|
|
595
|
+
if _v:
|
|
596
|
+
_s = str(_v).strip()
|
|
597
|
+
if _s and _s not in _seen:
|
|
598
|
+
_seen.add(_s)
|
|
599
|
+
_vals.append(_s)
|
|
600
|
+
if _vals:
|
|
601
|
+
logger.warning(
|
|
602
|
+
f"[_select_best_row] {_f} unique({len(_vals)}): "
|
|
603
|
+
f"{_vals[:30]}{'...' if len(_vals) > 30 else ''}"
|
|
604
|
+
)
|
|
605
|
+
except Exception:
|
|
606
|
+
pass
|
|
607
|
+
|
|
608
|
+
# [P33c 2026-05-22] LLM row matching fallback. 룰 매칭 0건일 때
|
|
609
|
+
# rows의 unique 분류 값 list를 LLM에 던져 *의미적으로* 매칭되는
|
|
610
|
+
# 컬럼 값을 식별, 해당 row만 통과시킴. llm_fallback_ctx가 없으면
|
|
611
|
+
# 기존 동작(None 반환). LLM도 매칭 없다면 진짜 표에 없는 것 → None.
|
|
612
|
+
_ind_matched_via_llm: list[dict] = []
|
|
613
|
+
if llm_fallback_ctx:
|
|
614
|
+
try:
|
|
615
|
+
from structverify.retrieval.row_matcher import (
|
|
616
|
+
llm_select_rows as _llm_select_rows,
|
|
617
|
+
)
|
|
618
|
+
_ind_matched_via_llm = await _llm_select_rows(
|
|
619
|
+
rows=rows,
|
|
620
|
+
indicator=ind_norm_raw,
|
|
621
|
+
claim_text=str(llm_fallback_ctx.get("claim_text") or "")[:400],
|
|
622
|
+
parent_path=str(llm_fallback_ctx.get("parent_path") or ""),
|
|
623
|
+
population=str(llm_fallback_ctx.get("population") or population or ""),
|
|
624
|
+
config=llm_fallback_ctx.get("config"),
|
|
625
|
+
)
|
|
626
|
+
except Exception as _e:
|
|
627
|
+
logger.debug(f"[_select_best_row] LLM row matching 실패: {_e}")
|
|
628
|
+
|
|
629
|
+
if _ind_matched_via_llm:
|
|
630
|
+
logger.info(
|
|
631
|
+
f"[_select_best_row] indicator={ind_norm!r} 룰 매칭 0 → "
|
|
632
|
+
f"LLM rescued: {len(rows)} → {len(_ind_matched_via_llm)} rows"
|
|
633
|
+
)
|
|
634
|
+
rows = _ind_matched_via_llm
|
|
635
|
+
# [2026-05-25] LLM이 의미적으로 indicator 매칭을 끝낸 rows.
|
|
636
|
+
# 후속 1·2·3차 가드의 _ind_match는 *같은 룰*로 또 떨어트리므로
|
|
637
|
+
# (애초에 룰이 못 잡아서 LLM rescue를 부른 거임) 여기서 우회.
|
|
638
|
+
# 우회 후엔 time/pop/unit 가드만 효력.
|
|
639
|
+
_ind_match = lambda _r: True # noqa: E731
|
|
640
|
+
else:
|
|
641
|
+
logger.warning(
|
|
642
|
+
f"[_select_best_row] indicator={ind_norm!r} 매칭 row 없음 "
|
|
643
|
+
f"({len(rows)} rows 검사, LLM도 매칭 0) → 표 부적합, 다음 candidate 시도"
|
|
644
|
+
)
|
|
645
|
+
return None
|
|
646
|
+
else:
|
|
647
|
+
if len(ind_matched) < len(rows):
|
|
648
|
+
logger.info(
|
|
649
|
+
f"[_select_best_row] indicator={ind_norm!r} pre-filter: "
|
|
650
|
+
f"{len(rows)} → {len(ind_matched)} rows"
|
|
651
|
+
)
|
|
652
|
+
rows = ind_matched
|
|
653
|
+
|
|
654
|
+
# 1차: indicator + 정확 시점 (+ population + 단위)
|
|
655
|
+
for r in rows:
|
|
656
|
+
if _ind_match(r) and _time_match(r) and _pop_match(r) and _unit_match(r):
|
|
657
|
+
return r
|
|
658
|
+
|
|
659
|
+
# 2차: indicator + 정확 시점 + 단위
|
|
660
|
+
if prd_target:
|
|
661
|
+
for r in rows:
|
|
662
|
+
if _ind_match(r) and _time_match(r) and _unit_match(r):
|
|
663
|
+
return r
|
|
664
|
+
|
|
665
|
+
# 3차: indicator만 매칭 — 가장 최근 시점 row 우선
|
|
666
|
+
# [v6.19] 단, claim이 월 단위면 연 단위 row는 제외 (가짜 매칭 방지)
|
|
667
|
+
# [v6.25] 단위 불일치 row도 제외
|
|
668
|
+
# [패치 3-2] claim에 명시 시점(prd_target)이 있는데 1·2차에서 정확
|
|
669
|
+
# 매칭 못 했으면, 3차에서 다른 시점 row를 default로 반환하지 않는다.
|
|
670
|
+
# 이전엔 claim '2024-04'인데 표에 4월 row 없으면 1월 row를 reverse
|
|
671
|
+
# sort로 잡아 evidence 반환 → "(connector default value=21412 → override)"
|
|
672
|
+
# 로그와 함께 calculate가 잘못된 prev값으로 가짜 mismatch를 냈음.
|
|
673
|
+
# prd_target가 명시된 경우엔 시점 누수보다 None(검증 불가)이 안전 —
|
|
674
|
+
# 호출자가 i'' 자동 fallback으로 다음 candidate를 시도하게 한다.
|
|
675
|
+
if ind_norm and not prd_target:
|
|
676
|
+
matched = [
|
|
677
|
+
r for r in rows
|
|
678
|
+
if _ind_match(r) and _granularity_ok(r) and _unit_match(r)
|
|
679
|
+
]
|
|
680
|
+
if matched:
|
|
681
|
+
matched.sort(
|
|
682
|
+
key=lambda r: str(r.get("PRD_DE", "") or ""),
|
|
683
|
+
reverse=True,
|
|
684
|
+
)
|
|
685
|
+
return matched[0]
|
|
686
|
+
|
|
687
|
+
# 4차: 시점만 매칭 (+ 단위)
|
|
688
|
+
if prd_target:
|
|
689
|
+
for r in rows:
|
|
690
|
+
if _time_match(r) and _unit_match(r):
|
|
691
|
+
return r
|
|
692
|
+
|
|
693
|
+
# [v6.19] 월 claim인데 연 단위 표만 있는 경우 — 여기까지 왔으면
|
|
694
|
+
# 단위 호환 row가 하나도 없는 것. None 반환해 검증 불가 처리.
|
|
695
|
+
# (이전엔 3차가 연값을 잡아 "9월 24.7도 vs 연평균 14.5도" 가짜 mismatch 발생)
|
|
696
|
+
return None
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _parse_value(dt_raw: Any) -> float | None:
|
|
700
|
+
"""KOSIS DT 필드 → float. 콤마 제거 + 공백 처리."""
|
|
701
|
+
if dt_raw is None:
|
|
702
|
+
return None
|
|
703
|
+
s = str(dt_raw).strip().replace(",", "").replace(" ", "")
|
|
704
|
+
if not s or s in ("-", "X", "x", "..", "..."):
|
|
705
|
+
return None
|
|
706
|
+
try:
|
|
707
|
+
return float(s)
|
|
708
|
+
except ValueError:
|
|
709
|
+
return None
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
# ── DataSource ───────────────────────────────────────────────────
|
|
713
|
+
|
|
714
|
+
@register_datasource("kosis")
|
|
715
|
+
class KOSISDataSource(BaseDataSource):
|
|
716
|
+
"""KOSIS DataSource — *기존 KOSISConnector* 인스턴스 wrap.
|
|
717
|
+
|
|
718
|
+
사용:
|
|
719
|
+
from structverify.retrieval.kosis_connector import KOSISConnector
|
|
720
|
+
connector = KOSISConnector(config=...)
|
|
721
|
+
ds = KOSISDataSource(connector=connector)
|
|
722
|
+
cands = await ds.search_catalog(query="출생아 수")
|
|
723
|
+
"""
|
|
724
|
+
|
|
725
|
+
name = "kosis"
|
|
726
|
+
|
|
727
|
+
# ── [패치 F] _record_cache를 클래스 레벨로 — claim 간 StatRecord 공유 ──
|
|
728
|
+
# _verify_with_agent는 claim마다 KOSISDataSource를 새로 만든다.
|
|
729
|
+
# 인스턴스 별 _record_cache면, claim A의 catalog_search가 캐싱한
|
|
730
|
+
# DT_1B8000G StatRecord(org_id 포함)가 claim B 인스턴스에는 없어,
|
|
731
|
+
# claim B가 prior_success로 DT_1B8000G를 fetch할 때 stat_record=None →
|
|
732
|
+
# connector가 빈 StatRecord(org_id 없음) 생성 → _fetch_with_retry가
|
|
733
|
+
# org_id 없음으로 즉시 None 반환 → 빈 rows → value=None.
|
|
734
|
+
# 클래스 레벨로 공유하면, claim A가 채운 캐시를 claim B가 그대로 본다.
|
|
735
|
+
# (같은 프로세스 안에서만 공유; cross-process는 workspace 영속 필요.)
|
|
736
|
+
_record_cache: dict[str, Any] = {} # stat_id → StatRecord (class-level)
|
|
737
|
+
|
|
738
|
+
def __init__(self, connector: Any = None, **kwargs: Any):
|
|
739
|
+
"""
|
|
740
|
+
Args:
|
|
741
|
+
connector: 기존 KOSISConnector 인스턴스. None이면 lazy 생성.
|
|
742
|
+
**kwargs: KOSISConnector 생성 시 사용할 config (connector=None일 때만)
|
|
743
|
+
"""
|
|
744
|
+
self._connector = connector
|
|
745
|
+
self._connector_config = kwargs
|
|
746
|
+
|
|
747
|
+
logger.info(
|
|
748
|
+
f"[KOSISDataSource] 초기화. connector={'주입됨' if connector else 'lazy 생성 예정'}, "
|
|
749
|
+
f"shared _record_cache 크기={len(KOSISDataSource._record_cache)}"
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
def _get_connector(self):
|
|
753
|
+
"""기존 connector 인스턴스 반환 — 없으면 lazy 생성."""
|
|
754
|
+
if self._connector is None:
|
|
755
|
+
from structverify.retrieval.kosis_connector import KOSISConnector
|
|
756
|
+
self._connector = KOSISConnector(config=self._connector_config)
|
|
757
|
+
logger.info("[KOSISDataSource] KOSISConnector lazy 생성됨")
|
|
758
|
+
return self._connector
|
|
759
|
+
|
|
760
|
+
def _make_query(self, query_str: str, **kwargs) -> Any:
|
|
761
|
+
"""ConnectorQuery 생성 — 사용자 코드의 dataclass."""
|
|
762
|
+
from structverify.retrieval.base_connector import ConnectorQuery
|
|
763
|
+
return ConnectorQuery(
|
|
764
|
+
keyword=query_str,
|
|
765
|
+
indicator=kwargs.get("indicator", query_str),
|
|
766
|
+
time_period=kwargs.get("time_period"),
|
|
767
|
+
population=kwargs.get("population"),
|
|
768
|
+
extra_params=kwargs.get("extra_params", {}),
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
async def _lookup_stat_record_by_id(self, stat_id: str) -> Any | None:
|
|
772
|
+
"""[패치 F-2] kosis_stat_catalog 테이블에서 stat_id로 StatRecord 직접 조회.
|
|
773
|
+
|
|
774
|
+
용도: prior_success로 들어온 stat_id가 클래스 캐시에도 없을 때
|
|
775
|
+
(cross-process / restart) DB에서 한 번 끌어와 placeholder
|
|
776
|
+
fetch가 빈 rows로 침묵 실패하는 걸 방지.
|
|
777
|
+
"""
|
|
778
|
+
if not stat_id:
|
|
779
|
+
return None
|
|
780
|
+
try:
|
|
781
|
+
import asyncpg
|
|
782
|
+
except ImportError:
|
|
783
|
+
return None
|
|
784
|
+
connector = self._get_connector()
|
|
785
|
+
catalog = getattr(connector, "catalog", None)
|
|
786
|
+
pg_dsn = getattr(catalog, "pg_dsn", None) if catalog else None
|
|
787
|
+
if not pg_dsn:
|
|
788
|
+
return None
|
|
789
|
+
try:
|
|
790
|
+
conn = await asyncpg.connect(pg_dsn)
|
|
791
|
+
except Exception as e:
|
|
792
|
+
logger.debug(f"[_lookup_stat_record_by_id] DB 연결 실패: {e}")
|
|
793
|
+
return None
|
|
794
|
+
try:
|
|
795
|
+
row = await conn.fetchrow(
|
|
796
|
+
"""
|
|
797
|
+
SELECT stat_id, stat_name, org_id, org_name, category_path, keywords
|
|
798
|
+
FROM kosis_stat_catalog
|
|
799
|
+
WHERE stat_id = $1
|
|
800
|
+
LIMIT 1
|
|
801
|
+
""",
|
|
802
|
+
stat_id,
|
|
803
|
+
)
|
|
804
|
+
except Exception as e:
|
|
805
|
+
logger.debug(f"[_lookup_stat_record_by_id] 쿼리 실패: {e}")
|
|
806
|
+
row = None
|
|
807
|
+
finally:
|
|
808
|
+
try:
|
|
809
|
+
await conn.close()
|
|
810
|
+
except Exception:
|
|
811
|
+
pass
|
|
812
|
+
if not row:
|
|
813
|
+
return None
|
|
814
|
+
from structverify.retrieval.base_connector import StatRecord
|
|
815
|
+
record = StatRecord(
|
|
816
|
+
stat_id=row["stat_id"],
|
|
817
|
+
stat_name=row["stat_name"],
|
|
818
|
+
org_id=row["org_id"],
|
|
819
|
+
org_name=row["org_name"] or "",
|
|
820
|
+
available_periods=[],
|
|
821
|
+
relevance_score=1.0,
|
|
822
|
+
metadata={
|
|
823
|
+
"source": "pgvector_lookup_by_id",
|
|
824
|
+
"category_path": row.get("category_path"),
|
|
825
|
+
"keywords": row.get("keywords"),
|
|
826
|
+
},
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
# ── [2026-05-25] on-demand getMeta enrich ─────────────────────
|
|
830
|
+
# DB lookup으로 만든 StatRecord는 getMeta_PRD/CMMT가 비어있음.
|
|
831
|
+
# 이 상태로 _fetch_with_retry에 넘기면 PRD_SE pruning이 무력화되어
|
|
832
|
+
# Y/M/Q 전 주기를 매번 시도(낭비 4~5 API 호출/표).
|
|
833
|
+
# 여기서 1회만 enrich → 캐시되어 다음 fetch부터 pruning이 작동.
|
|
834
|
+
# 비용: 표당 PRD+CMMT 2 API. 절약: 표당 4~5 호출 × 매 fetch.
|
|
835
|
+
try:
|
|
836
|
+
if record.org_id and record.stat_id:
|
|
837
|
+
connector = self._get_connector()
|
|
838
|
+
api_key = getattr(connector, "api_key", "") or ""
|
|
839
|
+
base = (
|
|
840
|
+
(getattr(connector, "config", {}) or {}).get("base_url")
|
|
841
|
+
or getattr(connector, "BASE_URL", "https://kosis.kr/openapi")
|
|
842
|
+
).rstrip("/")
|
|
843
|
+
if api_key:
|
|
844
|
+
import httpx as _httpx
|
|
845
|
+
from structverify.retrieval.kosis_connector import (
|
|
846
|
+
kosis_get_meta as _kosis_get_meta,
|
|
847
|
+
)
|
|
848
|
+
async with _httpx.AsyncClient() as _client:
|
|
849
|
+
_prd, _cmmt = await asyncio.gather(
|
|
850
|
+
_kosis_get_meta(
|
|
851
|
+
_client, base, api_key,
|
|
852
|
+
record.org_id, record.stat_id, "PRD", 15.0,
|
|
853
|
+
),
|
|
854
|
+
_kosis_get_meta(
|
|
855
|
+
_client, base, api_key,
|
|
856
|
+
record.org_id, record.stat_id, "CMMT", 15.0,
|
|
857
|
+
),
|
|
858
|
+
)
|
|
859
|
+
record.metadata["getMeta_PRD"] = _prd
|
|
860
|
+
record.metadata["getMeta_CMMT"] = _cmmt
|
|
861
|
+
logger.info(
|
|
862
|
+
f"[_lookup_stat_record_by_id] on-demand meta enrich 완료: "
|
|
863
|
+
f"{record.stat_id} (PRD/CMMT 2 calls) — "
|
|
864
|
+
f"이후 PRD_SE pruning 작동."
|
|
865
|
+
)
|
|
866
|
+
except Exception as e:
|
|
867
|
+
logger.debug(f"[_lookup_stat_record_by_id] meta enrich 실패 (무시): {e}")
|
|
868
|
+
|
|
869
|
+
return record
|
|
870
|
+
|
|
871
|
+
# ── BaseDataSource 인터페이스 ──
|
|
872
|
+
|
|
873
|
+
async def search_catalog(
|
|
874
|
+
self,
|
|
875
|
+
query: str,
|
|
876
|
+
category: list[str] | None = None,
|
|
877
|
+
top_k: int = 10,
|
|
878
|
+
context: dict[str, Any] | None = None,
|
|
879
|
+
) -> list[CatalogCandidate]:
|
|
880
|
+
"""KOSIS 카탈로그 검색. KOSISConnector.search(ConnectorQuery) 호출.
|
|
881
|
+
|
|
882
|
+
[P31 2026-05-22] context dict로 schema의 parent_path, raw_claim, population을
|
|
883
|
+
받아 ConnectorQuery.extra_params에 묶음 → CatalogSearch._extract_category_and_keyword
|
|
884
|
+
가 LLM 호출 시 활용. parent_path가 있으면 LLM 호출 *skip*하고 KOSIS 카테고리
|
|
885
|
+
어휘 그대로 사용. raw_claim 있으면 LLM이 전체 문장 보고 카테고리 추출.
|
|
886
|
+
"""
|
|
887
|
+
connector = self._get_connector()
|
|
888
|
+
# extra_params 구성 — category + (P31) context의 schema 정보 머지
|
|
889
|
+
_extra: dict[str, Any] = {}
|
|
890
|
+
if category:
|
|
891
|
+
_extra["category"] = category
|
|
892
|
+
_ctx = context or {}
|
|
893
|
+
# [2026-05-27 Fix B] time_period 추가 — catalog_search에서 schema.time_period를
|
|
894
|
+
# context에 실어 보내면 여기서 _extra에 합치고 _make_query를 통해 ConnectorQuery
|
|
895
|
+
# .time_period로 매핑된다. CatalogSearch._search_pgvector_with_time_union이
|
|
896
|
+
# 이 값을 보고 "embedding_text + year" 추가 검색을 돌려 dedup union으로
|
|
897
|
+
# 합집합 → historical 케이스 (1991 등) 정답 표 surface.
|
|
898
|
+
for _k in ("parent_path", "raw_claim", "population", "indicator", "time_period"):
|
|
899
|
+
_v = _ctx.get(_k)
|
|
900
|
+
if _v:
|
|
901
|
+
_extra[_k] = _v
|
|
902
|
+
# _make_query는 indicator/population/time_period를 별도 인자로 받음 (ConnectorQuery
|
|
903
|
+
# 필드에 매핑). 나머지(parent_path/raw_claim/category)는 extra_params에.
|
|
904
|
+
cq = self._make_query(
|
|
905
|
+
query,
|
|
906
|
+
indicator=_ctx.get("indicator") or query,
|
|
907
|
+
population=_ctx.get("population"),
|
|
908
|
+
time_period=_ctx.get("time_period"),
|
|
909
|
+
extra_params=_extra,
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
try:
|
|
913
|
+
# [2026-05-27 Fix A] top_k propagation — kosis_source는 호출자(CatalogSearchTool)
|
|
914
|
+
# top_k를 받지만 그동안 connector.search에 전달 안 함. 결과 connector가
|
|
915
|
+
# 기본 10만 리턴 → pgvector top 15가 잘려 historical 케이스 (1991 등)의
|
|
916
|
+
# 정답 표가 surface 못 함. KOSISConnector.search도 top_k 받도록 시그니처
|
|
917
|
+
# 보강 (default 10 유지로 다른 호출 영향 없음).
|
|
918
|
+
records = await connector.search(cq, top_k=top_k)
|
|
919
|
+
except Exception as e:
|
|
920
|
+
logger.warning(f"[KOSISDataSource] search 실패: {type(e).__name__}: {e}")
|
|
921
|
+
return []
|
|
922
|
+
|
|
923
|
+
# StatRecord → CatalogCandidate
|
|
924
|
+
candidates: list[CatalogCandidate] = []
|
|
925
|
+
for r in (records or [])[:top_k]:
|
|
926
|
+
sid = str(getattr(r, "stat_id", ""))
|
|
927
|
+
if sid:
|
|
928
|
+
self._record_cache[sid] = r # 캐시 저장
|
|
929
|
+
candidates.append({
|
|
930
|
+
"id": sid,
|
|
931
|
+
"name": str(getattr(r, "stat_name", "")),
|
|
932
|
+
"score": float(getattr(r, "relevance_score", 0.0) or 0.0),
|
|
933
|
+
"raw": r,
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
logger.info(
|
|
937
|
+
f"[KOSISDataSource] search_catalog(query={query!r}): {len(candidates)}개 후보"
|
|
938
|
+
)
|
|
939
|
+
return candidates
|
|
940
|
+
|
|
941
|
+
async def get_table_meta(
|
|
942
|
+
self,
|
|
943
|
+
candidate_id: str,
|
|
944
|
+
meta_type: str = "ITM",
|
|
945
|
+
) -> Any | None:
|
|
946
|
+
"""[P30 2026-05-22] KOSIS getMeta API 호출 — *데이터 X*, 항목/분류 메타만.
|
|
947
|
+
|
|
948
|
+
meta_type:
|
|
949
|
+
- "ITM": 통계항목 list (ITM_ID/ITM_NM). 예: "체외 충격파 쇄석술기" 같은
|
|
950
|
+
세부 항목 식별 가능.
|
|
951
|
+
- "OBJL01"~"OBJL08": 분류 항목 list (C1_NM 등). 지역/연령 등.
|
|
952
|
+
- "PRD": 수록주기, "CMMT": 표 설명 (참고).
|
|
953
|
+
|
|
954
|
+
catalog_search → fetch_evidence 사이에서 *표 내부 구조*를 LLM이 판단해야
|
|
955
|
+
할 때 사용. fetch보다 가벼움 (KOSIS 응답이 메타만이라 ~1초).
|
|
956
|
+
"""
|
|
957
|
+
import httpx
|
|
958
|
+
from structverify.retrieval.kosis_connector import (
|
|
959
|
+
kosis_get_meta as _kosis_get_meta,
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
if not candidate_id:
|
|
963
|
+
return None
|
|
964
|
+
|
|
965
|
+
# connector에서 base_url + api_key + org_id 추출
|
|
966
|
+
connector = self._get_connector()
|
|
967
|
+
base = (connector.config.get("base_url") or connector.BASE_URL).rstrip("/")
|
|
968
|
+
api_key = getattr(connector, "api_key", "") or ""
|
|
969
|
+
if not api_key:
|
|
970
|
+
logger.debug(f"[KOSISDataSource.get_table_meta] api_key 없음 → None")
|
|
971
|
+
return None
|
|
972
|
+
timeout = float(connector.config.get("timeout", 30))
|
|
973
|
+
|
|
974
|
+
# org_id를 _record_cache에서 조회 (없으면 stat_record_by_id로 fetch)
|
|
975
|
+
rec = self._record_cache.get(candidate_id)
|
|
976
|
+
if rec is None:
|
|
977
|
+
rec = await self._lookup_stat_record_by_id(candidate_id)
|
|
978
|
+
if rec is not None:
|
|
979
|
+
self._record_cache[candidate_id] = rec
|
|
980
|
+
org_id = ""
|
|
981
|
+
if rec is not None:
|
|
982
|
+
org_id = str(getattr(rec, "org_id", "") or (rec.metadata or {}).get("ORG_ID", "") or "")
|
|
983
|
+
if not org_id:
|
|
984
|
+
logger.debug(
|
|
985
|
+
f"[KOSISDataSource.get_table_meta] {candidate_id}: org_id 없음 → None"
|
|
986
|
+
)
|
|
987
|
+
return None
|
|
988
|
+
|
|
989
|
+
try:
|
|
990
|
+
async with httpx.AsyncClient() as client:
|
|
991
|
+
data = await _kosis_get_meta(
|
|
992
|
+
client, base, api_key, org_id, candidate_id, meta_type, timeout,
|
|
993
|
+
)
|
|
994
|
+
except Exception as e:
|
|
995
|
+
logger.debug(
|
|
996
|
+
f"[KOSISDataSource.get_table_meta] {candidate_id}/{meta_type} 실패: {e}"
|
|
997
|
+
)
|
|
998
|
+
return None
|
|
999
|
+
|
|
1000
|
+
# error payload면 None
|
|
1001
|
+
if isinstance(data, dict) and (data.get("kosis_error") or data.get("err")):
|
|
1002
|
+
logger.debug(
|
|
1003
|
+
f"[KOSISDataSource.get_table_meta] {candidate_id}/{meta_type} → "
|
|
1004
|
+
f"error={data.get('kosis_error') or data.get('errMsg')}"
|
|
1005
|
+
)
|
|
1006
|
+
return None
|
|
1007
|
+
return data
|
|
1008
|
+
|
|
1009
|
+
async def fetch_evidence(
|
|
1010
|
+
self,
|
|
1011
|
+
candidate_id: str,
|
|
1012
|
+
params: dict[str, Any] | None = None,
|
|
1013
|
+
workspace: Any = None,
|
|
1014
|
+
) -> EvidenceData | None:
|
|
1015
|
+
"""KOSIS에서 실제 수치 조회. KOSISConnector.fetch(stat_id, params) 호출.
|
|
1016
|
+
|
|
1017
|
+
Phase D: rows 안에서 indicator + time_period 매칭 row를 직접 골라
|
|
1018
|
+
정확한 value/unit/time을 EvidenceData에 담아 반환.
|
|
1019
|
+
|
|
1020
|
+
[P20 2026-05-22] workspace가 주어지면 KOSIS API raw 응답을 캐싱.
|
|
1021
|
+
같은 (stat_id, prdSe, startPrdDe, endPrdDe, newEstPrdCnt) 조합 재호출 시
|
|
1022
|
+
API 안 부르고 캐시 사용. TTL은 config.kosis.cache_ttl_hours (default 24).
|
|
1023
|
+
"""
|
|
1024
|
+
connector = self._get_connector()
|
|
1025
|
+
params = params or {}
|
|
1026
|
+
|
|
1027
|
+
# stat_record가 params에 없으면 캐시에서 복원
|
|
1028
|
+
if not params.get("stat_record"):
|
|
1029
|
+
cached = self._record_cache.get(candidate_id)
|
|
1030
|
+
if cached is not None:
|
|
1031
|
+
params = {**params, "stat_record": cached}
|
|
1032
|
+
logger.info(
|
|
1033
|
+
f"[KOSISDataSource] stat_record 캐시 적중: {candidate_id} "
|
|
1034
|
+
f"(org_id={getattr(cached, 'org_id', None)!r})"
|
|
1035
|
+
)
|
|
1036
|
+
else:
|
|
1037
|
+
# ── [패치 F-2] 캐시 미스 → DB에서 stat_id 직접 lookup ──
|
|
1038
|
+
# 클래스 레벨 캐시(F)에도 없으면(예: 다른 프로세스/restart),
|
|
1039
|
+
# pgvector 카탈로그 DB에서 stat_id로 직접 StatRecord를 조회.
|
|
1040
|
+
# 없으면 placeholder StatRecord로 fetch가 빈 rows 반환 →
|
|
1041
|
+
# value=None 침묵 실패가 나므로, 여기서 한 번 강제 enrich한다.
|
|
1042
|
+
try:
|
|
1043
|
+
fetched = await self._lookup_stat_record_by_id(candidate_id)
|
|
1044
|
+
except Exception as e:
|
|
1045
|
+
logger.debug(
|
|
1046
|
+
f"[KOSISDataSource] stat_id lookup 실패: {candidate_id} — {e}"
|
|
1047
|
+
)
|
|
1048
|
+
fetched = None
|
|
1049
|
+
if fetched is not None:
|
|
1050
|
+
self._record_cache[candidate_id] = fetched # 다음 호출 캐시
|
|
1051
|
+
params = {**params, "stat_record": fetched}
|
|
1052
|
+
logger.info(
|
|
1053
|
+
f"[KOSISDataSource] stat_record DB 복원 성공: {candidate_id} "
|
|
1054
|
+
f"(org_id={getattr(fetched, 'org_id', None)!r})"
|
|
1055
|
+
)
|
|
1056
|
+
else:
|
|
1057
|
+
logger.warning(
|
|
1058
|
+
f"[KOSISDataSource] stat_record 캐시·DB 모두 미스: {candidate_id} "
|
|
1059
|
+
f"— connector가 placeholder로 fetch 시도하지만 org_id 없어 실패 가능"
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
# ── [2026-05-25] cache hit이든 fresh든 — metadata 비어있으면 enrich ──
|
|
1063
|
+
# catalog_search나 다른 경로에서 record가 cache에 들어갈 때 metadata에
|
|
1064
|
+
# getMeta_PRD/CMMT가 비어있는 경우가 있음. 이 상태로 _fetch_with_retry에
|
|
1065
|
+
# 넘기면 PRD_SE pruning이 무력화되어 Y/M/Q 전 주기 시도 (낭비).
|
|
1066
|
+
# 여기서 record를 *최종 확인*하고 비어있으면 1회 enrich → 캐시에도 반영.
|
|
1067
|
+
_rec = params.get("stat_record")
|
|
1068
|
+
if _rec is not None:
|
|
1069
|
+
_meta = getattr(_rec, "metadata", None) or {}
|
|
1070
|
+
_has_prd = bool(_meta.get("getMeta_PRD"))
|
|
1071
|
+
_org_id = getattr(_rec, "org_id", "") or _meta.get("ORG_ID", "")
|
|
1072
|
+
if not _has_prd and _org_id:
|
|
1073
|
+
try:
|
|
1074
|
+
import httpx as _httpx
|
|
1075
|
+
from structverify.retrieval.kosis_connector import (
|
|
1076
|
+
kosis_get_meta as _kosis_get_meta,
|
|
1077
|
+
)
|
|
1078
|
+
_conn = self._get_connector()
|
|
1079
|
+
_api_key = getattr(_conn, "api_key", "") or ""
|
|
1080
|
+
_base = (
|
|
1081
|
+
(getattr(_conn, "config", {}) or {}).get("base_url")
|
|
1082
|
+
or getattr(_conn, "BASE_URL", "https://kosis.kr/openapi")
|
|
1083
|
+
).rstrip("/")
|
|
1084
|
+
if _api_key:
|
|
1085
|
+
async with _httpx.AsyncClient() as _client:
|
|
1086
|
+
_prd, _cmmt = await asyncio.gather(
|
|
1087
|
+
_kosis_get_meta(
|
|
1088
|
+
_client, _base, _api_key,
|
|
1089
|
+
_org_id, candidate_id, "PRD", 15.0,
|
|
1090
|
+
),
|
|
1091
|
+
_kosis_get_meta(
|
|
1092
|
+
_client, _base, _api_key,
|
|
1093
|
+
_org_id, candidate_id, "CMMT", 15.0,
|
|
1094
|
+
),
|
|
1095
|
+
)
|
|
1096
|
+
if _rec.metadata is None:
|
|
1097
|
+
_rec.metadata = {}
|
|
1098
|
+
_rec.metadata["getMeta_PRD"] = _prd
|
|
1099
|
+
_rec.metadata["getMeta_CMMT"] = _cmmt
|
|
1100
|
+
self._record_cache[candidate_id] = _rec # 캐시에도 반영
|
|
1101
|
+
logger.info(
|
|
1102
|
+
f"[KOSISDataSource] on-demand meta enrich (cache hit 경로): "
|
|
1103
|
+
f"{candidate_id} (PRD/CMMT 2 calls) — 이후 PRD_SE pruning 작동."
|
|
1104
|
+
)
|
|
1105
|
+
except Exception as _e:
|
|
1106
|
+
logger.debug(f"[KOSISDataSource] cache hit meta enrich 실패 (무시): {_e}")
|
|
1107
|
+
|
|
1108
|
+
cq = params.get("query")
|
|
1109
|
+
if cq is None:
|
|
1110
|
+
cq = self._make_query(
|
|
1111
|
+
params.get("query_str", candidate_id),
|
|
1112
|
+
indicator=params.get("indicator"),
|
|
1113
|
+
time_period=params.get("time_period"),
|
|
1114
|
+
population=params.get("population"),
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
# fetch params 구성
|
|
1118
|
+
fetch_params = {
|
|
1119
|
+
"time_period": params.get("time_period"),
|
|
1120
|
+
"population": params.get("population"),
|
|
1121
|
+
"query": cq,
|
|
1122
|
+
"stat_record": params.get("stat_record"),
|
|
1123
|
+
"prdSe": params.get("prdSe", "Y"),
|
|
1124
|
+
"startPrdDe": params.get("startPrdDe", ""),
|
|
1125
|
+
"endPrdDe": params.get("endPrdDe", ""),
|
|
1126
|
+
}
|
|
1127
|
+
fetch_params.update({k: v for k, v in params.items() if k not in fetch_params})
|
|
1128
|
+
|
|
1129
|
+
# time_period → KOSIS API 파라미터 자동 변환
|
|
1130
|
+
# [패치] 이전엔 `not params.get("prdSe")`로 가드해서, prdSe가 명시되면
|
|
1131
|
+
# _parse_time_period가 발동 안 하고 params에 들어온 잘못된 startPrdDe
|
|
1132
|
+
# ('202401' 같은 1월 값)가 그대로 KOSIS에 전송 → 4월 row 못 받음.
|
|
1133
|
+
# 이제 time_period가 있으면 무조건 _parse_time_period 결과로 덮어씀.
|
|
1134
|
+
# 변환에 성공한 경우에만 적용 (잘못된 형식은 기존 값 유지).
|
|
1135
|
+
tp = params.get("time_period")
|
|
1136
|
+
if tp:
|
|
1137
|
+
prd_se, start, end = _parse_time_period(tp)
|
|
1138
|
+
if prd_se and start and end:
|
|
1139
|
+
fetch_params["prdSe"] = prd_se
|
|
1140
|
+
fetch_params["startPrdDe"] = start
|
|
1141
|
+
fetch_params["endPrdDe"] = end
|
|
1142
|
+
logger.info(
|
|
1143
|
+
f"[KOSISDataSource] time_period={tp!r} → "
|
|
1144
|
+
f"prdSe={prd_se!r} startPrdDe={start!r} endPrdDe={end!r} "
|
|
1145
|
+
f"(params에 prdSe={params.get('prdSe')!r} "
|
|
1146
|
+
f"startPrdDe={params.get('startPrdDe')!r} 있어도 덮어씀)"
|
|
1147
|
+
)
|
|
1148
|
+
# ── [v6.17] growth_rate 범위 확장 — prev 시점까지 받아오기 ──
|
|
1149
|
+
# fetch_evidence 도구가 _range_start/_range_end를 넣어줬으면
|
|
1150
|
+
# startPrdDe/endPrdDe를 그 범위로 덮어쓴다. 현재+이전 시점을
|
|
1151
|
+
# 한 번에 받아 loop이 증가율을 직접 계산할 수 있게 함.
|
|
1152
|
+
rng_start = params.get("_range_start")
|
|
1153
|
+
rng_end = params.get("_range_end")
|
|
1154
|
+
if rng_start and rng_end:
|
|
1155
|
+
_ps, _s, _ = _parse_time_period(str(rng_start))
|
|
1156
|
+
_, _e, _ = _parse_time_period(str(rng_end))
|
|
1157
|
+
if _s and _e:
|
|
1158
|
+
fetch_params["prdSe"] = _ps
|
|
1159
|
+
fetch_params["startPrdDe"] = _s
|
|
1160
|
+
fetch_params["endPrdDe"] = _e
|
|
1161
|
+
logger.info(
|
|
1162
|
+
f"[KOSISDataSource] growth_rate 범위 적용: "
|
|
1163
|
+
f"prdSe={_ps!r} startPrdDe={_s!r} endPrdDe={_e!r} "
|
|
1164
|
+
f"(time_period={tp!r}, prev까지 확장)"
|
|
1165
|
+
)
|
|
1166
|
+
else:
|
|
1167
|
+
logger.info(
|
|
1168
|
+
f"[KOSISDataSource] time_period {tp!r} → "
|
|
1169
|
+
f"prdSe={prd_se!r} startPrdDe={start!r} endPrdDe={end!r}"
|
|
1170
|
+
)
|
|
1171
|
+
else:
|
|
1172
|
+
logger.info(
|
|
1173
|
+
f"[KOSISDataSource] time_period {tp!r} → "
|
|
1174
|
+
f"prdSe={prd_se!r} startPrdDe={start!r} endPrdDe={end!r}"
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
# ── [P34 2026-05-22] Dimension resolver — N차원 슬라이스 코드 결정 ──
|
|
1178
|
+
# KOSIS connector는 기본적으로 cmmt_rows[0]의 ITM_ID/OBJ_ID를 박는데
|
|
1179
|
+
# 이게 보통 *합계 코드*라 세부 항목 row가 누락됨. fetch *전*에 표의
|
|
1180
|
+
# getMeta(ITM/OBJL01~)를 받아 LLM이 claim의 indicator/population과
|
|
1181
|
+
# 매칭되는 코드를 결정 → fetch_params에 dim_overrides로 박음.
|
|
1182
|
+
# preview 모드일 때는 skip (preview는 표 구조 식별용).
|
|
1183
|
+
if (
|
|
1184
|
+
not params.get("_preview")
|
|
1185
|
+
and not fetch_params.get("dim_overrides")
|
|
1186
|
+
):
|
|
1187
|
+
_dr_cfg = (self._connector_config or {}).get("dimension_resolver") or {}
|
|
1188
|
+
if bool(_dr_cfg.get("enabled", True)):
|
|
1189
|
+
try:
|
|
1190
|
+
from structverify.retrieval.dimension_resolver import (
|
|
1191
|
+
resolve_dimensions as _resolve_dims,
|
|
1192
|
+
)
|
|
1193
|
+
_dims = await _resolve_dims(
|
|
1194
|
+
stat_id=candidate_id,
|
|
1195
|
+
stat_name=stat_name_str,
|
|
1196
|
+
source=self,
|
|
1197
|
+
indicator=str(params.get("indicator") or ""),
|
|
1198
|
+
population=str(params.get("population") or ""),
|
|
1199
|
+
claim_text=str(params.get("raw_claim") or params.get("claim_text") or ""),
|
|
1200
|
+
parent_path=str(params.get("parent_path") or ""),
|
|
1201
|
+
config={
|
|
1202
|
+
"kosis": {"dimension_resolver": _dr_cfg},
|
|
1203
|
+
},
|
|
1204
|
+
)
|
|
1205
|
+
if _dims:
|
|
1206
|
+
fetch_params["dim_overrides"] = _dims
|
|
1207
|
+
except Exception as _e:
|
|
1208
|
+
logger.debug(f"[KOSISDataSource] dimension_resolver 실패: {_e}")
|
|
1209
|
+
|
|
1210
|
+
# ── [P20 2026-05-22] workspace KOSIS 응답 캐시 ──────────────────
|
|
1211
|
+
# workspace가 주어졌고 cache 메서드가 있으면 cache key 만들어 조회.
|
|
1212
|
+
# cache hit → connector.fetch skip, raw 응답으로 EvidenceData 복원.
|
|
1213
|
+
# cache miss → 평소대로 connector.fetch + 응답 저장.
|
|
1214
|
+
_ws_cache_key: str | None = None
|
|
1215
|
+
_ttl_hours: float = 24.0
|
|
1216
|
+
try:
|
|
1217
|
+
_cache_cfg = (self.config or {}).get("cache") or {}
|
|
1218
|
+
# config 위치 우선순위: kosis.cache_ttl_hours > kosis.cache.ttl_hours
|
|
1219
|
+
_kosis_cfg = self.config or {}
|
|
1220
|
+
_ttl_hours = float(
|
|
1221
|
+
_kosis_cfg.get("cache_ttl_hours")
|
|
1222
|
+
or _cache_cfg.get("ttl_hours")
|
|
1223
|
+
or 24.0
|
|
1224
|
+
)
|
|
1225
|
+
except Exception:
|
|
1226
|
+
_ttl_hours = 24.0
|
|
1227
|
+
|
|
1228
|
+
data = None
|
|
1229
|
+
if workspace is not None and hasattr(workspace, "read_kosis_response_cache"):
|
|
1230
|
+
try:
|
|
1231
|
+
_ws_cache_key = type(workspace).make_kosis_cache_key(
|
|
1232
|
+
candidate_id, fetch_params
|
|
1233
|
+
)
|
|
1234
|
+
_cached = workspace.read_kosis_response_cache(
|
|
1235
|
+
_ws_cache_key, ttl_hours=_ttl_hours,
|
|
1236
|
+
)
|
|
1237
|
+
if _cached is not None and isinstance(_cached, dict):
|
|
1238
|
+
# cache hit → raw dict로 EvidenceData 재구성
|
|
1239
|
+
data = EvidenceData(_cached)
|
|
1240
|
+
logger.info(
|
|
1241
|
+
f"[KOSISDataSource] kosis_cache 적중: {_ws_cache_key} "
|
|
1242
|
+
f"(API call skip, value={data.get('value')}, "
|
|
1243
|
+
f"rows={len(data.get('rows') or [])})"
|
|
1244
|
+
)
|
|
1245
|
+
except Exception as _e:
|
|
1246
|
+
logger.debug(f"[KOSISDataSource] kosis_cache 조회 실패: {_e}")
|
|
1247
|
+
data = None
|
|
1248
|
+
|
|
1249
|
+
if data is None:
|
|
1250
|
+
# cache miss (또는 workspace 없음) → 실제 API 호출
|
|
1251
|
+
try:
|
|
1252
|
+
data = await connector.fetch(candidate_id, fetch_params)
|
|
1253
|
+
except Exception as e:
|
|
1254
|
+
logger.warning(
|
|
1255
|
+
f"[KOSISDataSource] fetch({candidate_id}) 실패: {type(e).__name__}: {e}"
|
|
1256
|
+
)
|
|
1257
|
+
return None
|
|
1258
|
+
|
|
1259
|
+
if data is None:
|
|
1260
|
+
return None
|
|
1261
|
+
|
|
1262
|
+
# API 호출 성공 → 캐시에 저장 (workspace 있을 때만)
|
|
1263
|
+
if workspace is not None and _ws_cache_key is not None:
|
|
1264
|
+
try:
|
|
1265
|
+
workspace.write_kosis_response_cache(
|
|
1266
|
+
_ws_cache_key, dict(data),
|
|
1267
|
+
)
|
|
1268
|
+
except Exception as _e:
|
|
1269
|
+
logger.debug(f"[KOSISDataSource] kosis_cache 저장 실패: {_e}")
|
|
1270
|
+
|
|
1271
|
+
# raw_response에서 rows 추출 (KOSIS API 응답 형식)
|
|
1272
|
+
raw = getattr(data, "raw_response", None) or {}
|
|
1273
|
+
if isinstance(raw, dict):
|
|
1274
|
+
rows = raw.get("row") or raw.get("rows") or []
|
|
1275
|
+
else:
|
|
1276
|
+
rows = []
|
|
1277
|
+
|
|
1278
|
+
stat_id_str = str(getattr(data, "stat_id", candidate_id))
|
|
1279
|
+
stat_name_str = str(getattr(data, "stat_name", "")) or candidate_id
|
|
1280
|
+
|
|
1281
|
+
# [P21B 2026-05-22] preview 모드 — catalog_search rerank가 sample row만
|
|
1282
|
+
# 필요해 호출. 관련성 가드/row 매칭/단위 가드 모두 *skip*하고 rows를 그대로
|
|
1283
|
+
# evidence에 담아 반환. value는 None이어도 OK (rerank LLM은 sample row만 봄).
|
|
1284
|
+
if params.get("_preview"):
|
|
1285
|
+
_preview_ev = EvidenceData({
|
|
1286
|
+
"value": None,
|
|
1287
|
+
"unit": "",
|
|
1288
|
+
"time_period": "",
|
|
1289
|
+
"source": "kosis",
|
|
1290
|
+
"stat_table_id": stat_id_str,
|
|
1291
|
+
"stat_name": stat_name_str,
|
|
1292
|
+
"rows": rows,
|
|
1293
|
+
"raw": data,
|
|
1294
|
+
"_preview": True,
|
|
1295
|
+
})
|
|
1296
|
+
logger.info(
|
|
1297
|
+
f"[KOSISDataSource] preview mode: [{stat_id_str}] "
|
|
1298
|
+
f"{stat_name_str!r} → {len(rows)} rows (관련성/매칭 가드 skip)"
|
|
1299
|
+
)
|
|
1300
|
+
return _preview_ev
|
|
1301
|
+
|
|
1302
|
+
# ── [v6.17] 테이블 관련성 체크 ──────────────────────────────────
|
|
1303
|
+
# catalog top 후보를 무조건 fetch하면 "합계출산율 - 동북·중앙아시아"
|
|
1304
|
+
# 같은 엉뚱한 표를 한국 claim에 가져오게 됨. claim의 indicator/population이
|
|
1305
|
+
# 표 이름과 같은 분야인지 검사해서, 무관하면 fetch 거부 (None 반환).
|
|
1306
|
+
from structverify.retrieval.kosis_relevance import (
|
|
1307
|
+
is_table_relevant, is_overseas_mismatch, is_specialized_mismatch,
|
|
1308
|
+
)
|
|
1309
|
+
claim_indicator = (params.get("indicator") or "").strip()
|
|
1310
|
+
claim_population = (params.get("population") or "").strip()
|
|
1311
|
+
# indicator + population 합쳐서 검사 (지역명이 population에 있을 수 있음)
|
|
1312
|
+
relevance_query = f"{claim_indicator} {claim_population}".strip()
|
|
1313
|
+
|
|
1314
|
+
if is_overseas_mismatch(stat_name_str, relevance_query):
|
|
1315
|
+
logger.warning(f"[KOSISDataSource] 해외/국제 통계표 → fetch 거부: [{stat_id_str}]")
|
|
1316
|
+
return None
|
|
1317
|
+
|
|
1318
|
+
if is_specialized_mismatch(stat_name_str, relevance_query):
|
|
1319
|
+
logger.warning(f"[KOSISDataSource] 특수 관측 통계표 → fetch 거부: [{stat_id_str}]")
|
|
1320
|
+
return None
|
|
1321
|
+
|
|
1322
|
+
# (b) 일반 관련성 체크 — indicator가 표 이름과 같은 분야인지
|
|
1323
|
+
# [수정 v6.24] 표 이름만 보던 것을 데이터 기반으로 보강.
|
|
1324
|
+
# 가계동향조사 표처럼 지표가 표 이름이 아니라 *행 항목*(ITM_NM 등)
|
|
1325
|
+
# 으로 들어있는 표는 _is_table_relevant(이름 기반)가 거부한다.
|
|
1326
|
+
# → 먼저 이미 fetch한 rows 안에 indicator가 있는지 확인하고,
|
|
1327
|
+
# 있으면 '데이터에 지표가 실재하는 관련 표'이므로 통과시킨다.
|
|
1328
|
+
# rows에 없을 때만 기존 이름 기반 가드로 폴백.
|
|
1329
|
+
# [P32 2026-05-22] 룰베이스 가드 false negative 회복용 LLM fallback:
|
|
1330
|
+
# - indicator의 토큰이 2글자뿐이거나, table_name과 동일 분류 트리에 있지만
|
|
1331
|
+
# 단어 형태가 달라 룰이 못 잡는 케이스 (예: "체외 충격파 쇄석술 장비"
|
|
1332
|
+
# vs "시군구별 주요 의료장비 현황")에서 LLM이 *의미적*으로 한 번 더 판단.
|
|
1333
|
+
# - 룰 통과 시엔 LLM 호출 안 함 (속도). 룰 거부 + rows/prior_success로도
|
|
1334
|
+
# 건질 수 없을 때만 LLM에 위임.
|
|
1335
|
+
# [2026-05-26] relevance_guard.enabled=false면 가드 전체 우회.
|
|
1336
|
+
# catalog_ranker가 이미 의미 점수로 거부했을 거라 중복 판단 불필요.
|
|
1337
|
+
_kosis_cfg_top = self._connector_config or {}
|
|
1338
|
+
_rg_cfg_top = _kosis_cfg_top.get("relevance_guard") or {}
|
|
1339
|
+
_rg_enabled = bool(_rg_cfg_top.get("enabled", True))
|
|
1340
|
+
if not _rg_enabled:
|
|
1341
|
+
logger.debug(
|
|
1342
|
+
f"[KOSISDataSource] relevance_guard.enabled=false → "
|
|
1343
|
+
f"테이블 관련성 가드 전체 우회: [{stat_id_str}]"
|
|
1344
|
+
)
|
|
1345
|
+
elif relevance_query and not is_table_relevant(relevance_query, stat_name_str):
|
|
1346
|
+
if _indicator_in_rows(rows, claim_indicator):
|
|
1347
|
+
logger.info(
|
|
1348
|
+
f"[KOSISDataSource] 표 이름엔 지표 없으나 행 데이터에 "
|
|
1349
|
+
f"'{claim_indicator}' 존재 → 관련 표로 인정: [{stat_id_str}]"
|
|
1350
|
+
)
|
|
1351
|
+
elif params.get("_from_prior_success"):
|
|
1352
|
+
# [패치 E] 같은 job의 다른 claim이 이미 fetch 성공한 표는 표
|
|
1353
|
+
# 이름이 indicator와 안 닿더라도 row 안에 indicator가 있을
|
|
1354
|
+
# 가능성이 입증됨. row 매칭은 _select_best_row가 책임진다.
|
|
1355
|
+
# (안전성: row 매칭이 None을 반환하면 어차피 evidence는 None.)
|
|
1356
|
+
logger.info(
|
|
1357
|
+
f"[KOSISDataSource] 표 관련성 가드 우회 (prior_success): "
|
|
1358
|
+
f"[{stat_id_str}] — row 매칭 단계에서 indicator 검증"
|
|
1359
|
+
)
|
|
1360
|
+
else:
|
|
1361
|
+
# [P32] LLM fallback — relevance_guard.llm_fallback=true면 룰 거부
|
|
1362
|
+
# 결정 전에 의미 기반 LLM 판단 1회. self.config가 KOSISDataSource에는
|
|
1363
|
+
# 없으므로 _connector_config(= data_sources.kosis 섹션) 안의
|
|
1364
|
+
# relevance_guard 키를 본다. LLM config는 LLMClient가 환경변수에서
|
|
1365
|
+
# NCP_API_KEY를 알아서 찾으므로 None으로 넘겨도 동작.
|
|
1366
|
+
_kosis_cfg = self._connector_config or {}
|
|
1367
|
+
_rg_cfg = _kosis_cfg.get("relevance_guard") or {}
|
|
1368
|
+
_llm_rescued = False
|
|
1369
|
+
if bool(_rg_cfg.get("llm_fallback", True)):
|
|
1370
|
+
try:
|
|
1371
|
+
from structverify.retrieval.relevance_judge import (
|
|
1372
|
+
is_table_relevant_semantic as _llm_judge,
|
|
1373
|
+
)
|
|
1374
|
+
_claim_text = (params.get("raw_claim") or params.get("claim_text") or "")
|
|
1375
|
+
_parent_path = (params.get("parent_path") or "")
|
|
1376
|
+
_rel, _reason = await _llm_judge(
|
|
1377
|
+
claim_text=str(_claim_text)[:400],
|
|
1378
|
+
indicator=claim_indicator,
|
|
1379
|
+
population=claim_population,
|
|
1380
|
+
parent_path=str(_parent_path),
|
|
1381
|
+
table_name=stat_name_str,
|
|
1382
|
+
config={"kosis": {"relevance_guard": _rg_cfg}},
|
|
1383
|
+
)
|
|
1384
|
+
if _rel is True:
|
|
1385
|
+
_llm_rescued = True
|
|
1386
|
+
logger.info(
|
|
1387
|
+
f"[KOSISDataSource] 룰 거부 → LLM rescued: "
|
|
1388
|
+
f"[{stat_id_str}] {stat_name_str!r} "
|
|
1389
|
+
f"(reason={_reason[:80]!r})"
|
|
1390
|
+
)
|
|
1391
|
+
elif _rel is False:
|
|
1392
|
+
logger.info(
|
|
1393
|
+
f"[KOSISDataSource] 룰 + LLM 둘 다 거부: "
|
|
1394
|
+
f"[{stat_id_str}] (reason={_reason[:80]!r})"
|
|
1395
|
+
)
|
|
1396
|
+
# _rel is None (LLM 실패/파싱 실패) → 보수적으로 룰 결정 유지
|
|
1397
|
+
except Exception as _e:
|
|
1398
|
+
logger.debug(f"[KOSISDataSource] LLM relevance fallback 실패: {_e}")
|
|
1399
|
+
|
|
1400
|
+
if not _llm_rescued:
|
|
1401
|
+
logger.warning(
|
|
1402
|
+
f"[KOSISDataSource] 테이블 관련성 없음 → fetch 거부: "
|
|
1403
|
+
f"[{stat_id_str}] {stat_name_str!r} vs "
|
|
1404
|
+
f"indicator={claim_indicator!r} population={claim_population!r}"
|
|
1405
|
+
)
|
|
1406
|
+
return None
|
|
1407
|
+
|
|
1408
|
+
# ★ rows에서 indicator + time_period 매칭 row 직접 선택
|
|
1409
|
+
# connector가 drows[0]만 official_value로 만들기 때문에 통합표에서 잘못된 row를 받음.
|
|
1410
|
+
# 여기서 rows 전체를 보고 정확한 row를 찾아 value/unit/time override.
|
|
1411
|
+
connector_value = getattr(data, "official_value", None)
|
|
1412
|
+
connector_unit = getattr(data, "unit", "") or ""
|
|
1413
|
+
connector_time = getattr(data, "time_period", "") or ""
|
|
1414
|
+
|
|
1415
|
+
# [v6.20] 표 단위 적합성 사전 체크 — claim이 월(YYYY-MM)인데
|
|
1416
|
+
# 표가 연 단위 데이터만 있으면, 그 표엔 월 데이터가 없는 것.
|
|
1417
|
+
# fetch해도 가짜 매칭(연값을 월 claim에)만 나오므로 일찍 거부.
|
|
1418
|
+
# 도메인 무관: KOSIS 표별 수록주기 차이를 행 데이터로 판별.
|
|
1419
|
+
_claim_period = params.get("time_period") or ""
|
|
1420
|
+
if rows and not _table_has_period_for(rows, _claim_period):
|
|
1421
|
+
logger.warning(
|
|
1422
|
+
f"[KOSISDataSource] 표 시점 단위 불일치 → fetch 거부: "
|
|
1423
|
+
f"[{stat_id_str}] {stat_name_str!r} — "
|
|
1424
|
+
f"claim 시점은 '{_claim_period}'(월/분기)인데 "
|
|
1425
|
+
f"표에 해당 단위 데이터 없음 (연 단위 표). evidence 없음 처리."
|
|
1426
|
+
)
|
|
1427
|
+
return None
|
|
1428
|
+
|
|
1429
|
+
best_row = None
|
|
1430
|
+
if rows:
|
|
1431
|
+
# [P33c 2026-05-22] llm_fallback_ctx — _select_best_row가 indicator
|
|
1432
|
+
# 룰 매칭 0건일 때 LLM row matcher에 위임. raw_claim/parent_path은
|
|
1433
|
+
# P32 흐름으로 이미 params에 들어옴 (fetch_evidence Tool에서 주입).
|
|
1434
|
+
_kosis_cfg_for_row = self._connector_config or {}
|
|
1435
|
+
_row_llm_enabled = bool(
|
|
1436
|
+
(_kosis_cfg_for_row.get("relevance_guard") or {}).get(
|
|
1437
|
+
"row_match_llm_fallback", True
|
|
1438
|
+
)
|
|
1439
|
+
)
|
|
1440
|
+
_row_llm_ctx: dict[str, Any] | None = None
|
|
1441
|
+
if _row_llm_enabled:
|
|
1442
|
+
_row_llm_ctx = {
|
|
1443
|
+
"claim_text": params.get("raw_claim") or params.get("claim_text") or "",
|
|
1444
|
+
"parent_path": params.get("parent_path") or "",
|
|
1445
|
+
"population": params.get("population") or "",
|
|
1446
|
+
"config": {
|
|
1447
|
+
"kosis": {"relevance_guard": _kosis_cfg_for_row.get("relevance_guard") or {}},
|
|
1448
|
+
},
|
|
1449
|
+
}
|
|
1450
|
+
best_row = await _select_best_row(
|
|
1451
|
+
rows,
|
|
1452
|
+
indicator=params.get("indicator"),
|
|
1453
|
+
time_period=params.get("time_period"),
|
|
1454
|
+
population=params.get("population"),
|
|
1455
|
+
unit_hint=params.get("unit_hint"),
|
|
1456
|
+
match_criteria=params.get("match_criteria"),
|
|
1457
|
+
llm_fallback_ctx=_row_llm_ctx,
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1460
|
+
if best_row is not None:
|
|
1461
|
+
matched_value = _parse_value(best_row.get("DT"))
|
|
1462
|
+
matched_unit = str(best_row.get("UNIT_NM", "") or "").strip()
|
|
1463
|
+
matched_time = str(best_row.get("PRD_DE", "") or "").strip()
|
|
1464
|
+
matched_indicator = str(best_row.get("ITM_NM", "") or "").strip()
|
|
1465
|
+
|
|
1466
|
+
if matched_value is not None:
|
|
1467
|
+
logger.info(
|
|
1468
|
+
f"[KOSISDataSource] row 매칭 성공: "
|
|
1469
|
+
f"ITM_NM={matched_indicator!r} PRD_DE={matched_time!r} "
|
|
1470
|
+
f"DT={matched_value} UNIT={matched_unit!r} "
|
|
1471
|
+
f"(connector default value={connector_value} → override, {len(rows)} rows 중)"
|
|
1472
|
+
)
|
|
1473
|
+
return {
|
|
1474
|
+
"value": matched_value,
|
|
1475
|
+
"unit": matched_unit,
|
|
1476
|
+
"time_period": matched_time,
|
|
1477
|
+
"source": "kosis",
|
|
1478
|
+
"stat_table_id": stat_id_str,
|
|
1479
|
+
"stat_name": stat_name_str,
|
|
1480
|
+
"rows": rows,
|
|
1481
|
+
"matched_row": best_row,
|
|
1482
|
+
"matched_indicator": matched_indicator,
|
|
1483
|
+
"raw": data,
|
|
1484
|
+
}
|
|
1485
|
+
else:
|
|
1486
|
+
logger.warning(
|
|
1487
|
+
f"[KOSISDataSource] row 매칭은 됐지만 DT 파싱 실패: "
|
|
1488
|
+
f"DT={best_row.get('DT')!r}"
|
|
1489
|
+
)
|
|
1490
|
+
else:
|
|
1491
|
+
# ★ 매칭 실패 시 row sample 로그 — KOSIS 표 column 구조 파악용 (한 번만 보면 됨)
|
|
1492
|
+
if rows:
|
|
1493
|
+
all_keys = list((rows[0] or {}).keys())
|
|
1494
|
+
sample_keys = all_keys[:10]
|
|
1495
|
+
logger.warning(
|
|
1496
|
+
f"[KOSISDataSource] indicator/time 매칭 row 못 찾음 "
|
|
1497
|
+
f"(rows={len(rows)}, indicator={params.get('indicator')!r}, "
|
|
1498
|
+
f"time={params.get('time_period')!r}) — evidence 없음 처리."
|
|
1499
|
+
)
|
|
1500
|
+
logger.warning(f" row[0] 전체 키({len(all_keys)}개): {all_keys}")
|
|
1501
|
+
sample_prds = sorted(
|
|
1502
|
+
set(str(r.get("PRD_DE", "MISSING")) for r in rows[:200])
|
|
1503
|
+
)
|
|
1504
|
+
logger.warning(
|
|
1505
|
+
f" PRD_DE 분포 (상위 200 row 중 unique {len(sample_prds)}개, "
|
|
1506
|
+
f"앞 20개): {sample_prds[:20]}"
|
|
1507
|
+
)
|
|
1508
|
+
for i, r in enumerate(rows[:3]):
|
|
1509
|
+
snippet = {k: r.get(k) for k in sample_keys if k in r}
|
|
1510
|
+
logger.warning(f" row[{i}]: {snippet}")
|
|
1511
|
+
# [v6.19] rows는 있는데 claim에 맞는 row가 없음 →
|
|
1512
|
+
# connector default 값(rows[0] 등 엉뚱한 값)을 쓰면
|
|
1513
|
+
# "9월 24.7도 vs 연평균 14.5도" 같은 가짜 mismatch가 남.
|
|
1514
|
+
# default로 둔갑시키지 말고 evidence 없음(None)으로 반환.
|
|
1515
|
+
return None
|
|
1516
|
+
else:
|
|
1517
|
+
logger.info(
|
|
1518
|
+
f"[KOSISDataSource] rows 비어있음 (indicator={params.get('indicator')!r}, "
|
|
1519
|
+
f"time={params.get('time_period')!r}) — connector default 값 사용"
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
# fallback: rows가 아예 없을 때만 connector가 골라준 값 사용
|
|
1523
|
+
return {
|
|
1524
|
+
"value": connector_value,
|
|
1525
|
+
"unit": connector_unit,
|
|
1526
|
+
"time_period": connector_time,
|
|
1527
|
+
"source": "kosis",
|
|
1528
|
+
"stat_table_id": stat_id_str,
|
|
1529
|
+
"stat_name": stat_name_str,
|
|
1530
|
+
"rows": rows,
|
|
1531
|
+
"raw": data,
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
def supports_time_filter(self) -> bool:
|
|
1535
|
+
return True
|
|
1536
|
+
|
|
1537
|
+
def supports_population_filter(self) -> bool:
|
|
1538
|
+
return False # KOSIS는 표마다 population 분리 (별도 dim)
|
|
1539
|
+
|
|
1540
|
+
async def close(self) -> None:
|
|
1541
|
+
pass
|