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,1192 @@
|
|
|
1
|
+
# 수정자: 신준수
|
|
2
|
+
# 수정 날짜: 2026-04-22
|
|
3
|
+
# 수정 내용: KOSIS API search() 실제 호출 구현
|
|
4
|
+
# 수정자: 신준수
|
|
5
|
+
# 수정 날짜: 2026-04-25
|
|
6
|
+
# 수정 내용: 통합검색 기본 5건 + getMeta(PRD, CMMT) 병렬 보강(통계표설명; fetch 재료)
|
|
7
|
+
# 수정자: 신준수
|
|
8
|
+
# 수정 날짜: 2026-04-26
|
|
9
|
+
# 수정 내용: Param/statisticsParameterData.do?method=getList (통계표선택) fetch 구현
|
|
10
|
+
# 수정자: 신준수
|
|
11
|
+
# 수정 날짜: 2026-04-27
|
|
12
|
+
# 수정 내용: fetch → StatData.official_value / unit / time_period (Param 셀 → 공용 필드)
|
|
13
|
+
|
|
14
|
+
# [DONE] KOSIS API search() 구현
|
|
15
|
+
# [DONE] getMeta(PRD/CMMT) 보강
|
|
16
|
+
# [DONE] KOSIS API fetch() Param/statisticsParameterData
|
|
17
|
+
# [TODO] 응답 파싱·obj/itm 매칭 정교화
|
|
18
|
+
# [2026-05-14 | 이수민] memory/v1: category_path 보강 로직 추가
|
|
19
|
+
# - _lookup_category_path(): kosis_stat_catalog 테이블에서 stat_id로 직접 조회
|
|
20
|
+
# - search_and_fetch 성공 시 stat_rec.metadata 우선, 없으면 DB 조회로 보강
|
|
21
|
+
# - working memory 도메인 가드(verifier)에서 활용
|
|
22
|
+
"""
|
|
23
|
+
retrieval/kosis_connector.py — KOSIS Open API 커넥터 (v3: CatalogSearchTool + LLM Agent)
|
|
24
|
+
|
|
25
|
+
[신준수 - 기존]
|
|
26
|
+
- KOSIS 통합검색 + getMeta(PRD/CMMT) 병렬 + Param fetch 구현
|
|
27
|
+
|
|
28
|
+
[김예슬 - 2026-04-30 / v4]
|
|
29
|
+
- search_and_fetch() 재설계 (v4: 후보 순회 retry):
|
|
30
|
+
· 1단계: CatalogSearchTool.search() → 후보 stat_id top_k
|
|
31
|
+
· 2단계: LLM Agent stat_id 선택 + 파라미터 결정 (HCX-DASH-002)
|
|
32
|
+
· 3단계: KOSIS Param/statisticsParameterData.do fetch
|
|
33
|
+
· 4단계: 실패 → tried_ids에 추가 → Agent에게 "이건 실패, 남은 후보에서 골라라"
|
|
34
|
+
· 5단계: 남은 후보에서 재선택 → fetch → 최대 _MAX_CANDIDATES(5)개 순회
|
|
35
|
+
· Agent가 "NONE" 답하거나 남은 후보 없으면 포기
|
|
36
|
+
|
|
37
|
+
- fetch() 개선 (factcheck_test.py 참고):
|
|
38
|
+
· prd_se 순회: Y → M → Q (연간 없으면 월간 시도)
|
|
39
|
+
· objL 점진: err:20 시 objL2~8 순서로 추가
|
|
40
|
+
· newEstPrdCnt=3 폴백: 기간 지정 실패 시 최신 3건
|
|
41
|
+
· 단위 검증: is_same_unit_type으로 명↔개월 혼용 방지
|
|
42
|
+
|
|
43
|
+
[모듈 분리]
|
|
44
|
+
CatalogSearchTool ← catalog_search.py (pgvector 검색 전담)
|
|
45
|
+
KOSISConnector ← kosis_connector.py (LLM Agent + KOSIS API fetch)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
[박재윤 - 2026-05-11]
|
|
49
|
+
- tried_log UnboundLocalError 버그 수정
|
|
50
|
+
· candidates 없을 때 tried_ids, tried_log 초기화 누락 수정
|
|
51
|
+
|
|
52
|
+
[박재윤 - 2026-05-14 ]_is_table_relevant 국가 불일치 체크 추가
|
|
53
|
+
· indicator에 외국 국가명 있고 테이블이 국내 통계면 → False
|
|
54
|
+
· 미국 소비자물가 → 한국 소비자물가 테이블 매칭 방지
|
|
55
|
+
|
|
56
|
+
_is_table_relevant 해외 지역명 체크 추가
|
|
57
|
+
· indicator에 국가명 없는데 테이블에 해외 지역명 있으면 → False
|
|
58
|
+
· 합계출산율 → 합계출산율 남부·동남아시아 테이블 매칭 방지
|
|
59
|
+
|
|
60
|
+
_AGENT_SELECT_PROMPT 개선
|
|
61
|
+
· 국가 일치 규칙 추가 (외국 지표 → 국내 테이블 선택 방지)
|
|
62
|
+
· 지표/시점 일치 규칙 명시
|
|
63
|
+
· 부적합 후보 NONE 반환 조건 추가
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
"""
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
import asyncio
|
|
70
|
+
import json
|
|
71
|
+
import os
|
|
72
|
+
import re
|
|
73
|
+
from typing import Any
|
|
74
|
+
|
|
75
|
+
import httpx
|
|
76
|
+
import json5 # type: ignore[import-untyped]
|
|
77
|
+
|
|
78
|
+
from structverify.core.schemas import GraphNode, GraphNodeType, ProvenanceRecord
|
|
79
|
+
from structverify.retrieval.base_connector import (
|
|
80
|
+
BaseConnector, ConnectorQuery, StatData, StatRecord,
|
|
81
|
+
)
|
|
82
|
+
from structverify.retrieval.catalog_search import CatalogSearchTool
|
|
83
|
+
from structverify.retrieval.kosis_relevance import is_table_relevant
|
|
84
|
+
from structverify.utils.logger import get_logger
|
|
85
|
+
|
|
86
|
+
logger = get_logger(__name__)
|
|
87
|
+
|
|
88
|
+
_FETCH_MAX_RETRY = 2 # 단일 stat_id 내 prd_se 순회 retry
|
|
89
|
+
_MAX_CANDIDATES = 8 # [v4] 후보 순회 최대 횟수
|
|
90
|
+
_JSON_HEADERS: dict[str, str] = {
|
|
91
|
+
"Accept": "application/json, */*;q=0.1",
|
|
92
|
+
"User-Agent": "StructVerify/1.0 (KOSIS OpenAPI; +https://kosis.kr/openapi/)",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# ── LLM Agent 프롬프트 ──────────────────────────────────────────────────────
|
|
96
|
+
# [박재윤 - 2026-05-14]: _AGENT_SELECT_PROMPT 국가/지표/시점 일치 규칙 추가
|
|
97
|
+
|
|
98
|
+
_AGENT_SELECT_PROMPT = """당신은 한국 공식 통계 전문가입니다.
|
|
99
|
+
아래 검증 주장에 가장 적합한 KOSIS 통계표를 선택하고 조회 파라미터를 결정하세요.
|
|
100
|
+
|
|
101
|
+
검증 주장: "{claim_text}"
|
|
102
|
+
indicator: {indicator}
|
|
103
|
+
time_period: {time_period}
|
|
104
|
+
population: {population}
|
|
105
|
+
|
|
106
|
+
후보 통계표:
|
|
107
|
+
{candidates}
|
|
108
|
+
|
|
109
|
+
[선택 규칙 — 반드시 준수]
|
|
110
|
+
1. 국가 일치: indicator에 미국/일본/중국 등 외국이 명시되면 반드시 국제/해외 통계표 선택.
|
|
111
|
+
한국 국내 통계표(예: 소비자물가등락률, 경제활동인구) 절대 선택 금지.
|
|
112
|
+
2. 지표 일치: indicator와 테이블명의 측정 대상이 같아야 함.
|
|
113
|
+
예: indicator=출생아수 → 출생 관련 테이블, indicator=고용률 → 고용 관련 테이블.
|
|
114
|
+
3. 시점 일치: time_period가 "YYYY-MM"이면 prd_se=M(월간), "YYYY"이면 prd_se=Y(연간).
|
|
115
|
+
4. 부적합 후보: 위 조건 불만족 시 stat_id를 "NONE"으로 답하세요.
|
|
116
|
+
5. 장래 추계 데이터 금지: UN/IMF 출처이거나 2050년 이후 데이터가 있는 테이블 선택 금지.
|
|
117
|
+
실측 통계만 선택하세요.
|
|
118
|
+
|
|
119
|
+
JSON으로만 답하세요:
|
|
120
|
+
{{
|
|
121
|
+
"stat_id": "선택한 통계표 ID 또는 NONE",
|
|
122
|
+
"stat_name": "통계표명",
|
|
123
|
+
"reason": "선택 이유 한 줄 (국가/지표/시점 일치 여부 포함)",
|
|
124
|
+
"prd_se": "Y(연간)/M(월간)/Q(분기)",
|
|
125
|
+
"start_prd_de": "시작 기간 (예: 2024, 202401)",
|
|
126
|
+
"end_prd_de": "종료 기간"
|
|
127
|
+
}}"""
|
|
128
|
+
|
|
129
|
+
_AGENT_RETRY_PROMPT = """KOSIS API 조회가 실패했습니다. 다른 후보 통계표를 선택하세요.
|
|
130
|
+
|
|
131
|
+
검증 주장: "{claim_text}"
|
|
132
|
+
|
|
133
|
+
이미 실패한 통계표 (다시 선택 금지):
|
|
134
|
+
{tried_list}
|
|
135
|
+
|
|
136
|
+
남은 후보 통계표:
|
|
137
|
+
{remaining_candidates}
|
|
138
|
+
|
|
139
|
+
[중요]
|
|
140
|
+
- 이미 실패한 stat_id는 절대 다시 선택하지 마세요
|
|
141
|
+
- 남은 후보 중에서 가장 적합한 통계표를 선택하세요
|
|
142
|
+
- 남은 후보가 모두 부적합하면 stat_id를 "NONE"으로 답하세요
|
|
143
|
+
|
|
144
|
+
JSON으로만 답하세요:
|
|
145
|
+
{{
|
|
146
|
+
"stat_id": "선택한 통계표 ID 또는 NONE",
|
|
147
|
+
"prd_se": "Y/M/Q",
|
|
148
|
+
"start_prd_de": "기간",
|
|
149
|
+
"end_prd_de": "기간",
|
|
150
|
+
"reason": "선택 이유"
|
|
151
|
+
}}"""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ── 단위 검증 유틸 (factcheck_test.py 참고) ──────────────────────────────────
|
|
155
|
+
# 이 함수를 kosis_connector.py의 기존 _is_table_relevant 대신 넣으세요
|
|
156
|
+
|
|
157
|
+
def normalize_value(value: float, kosis_unit: str) -> float:
|
|
158
|
+
"""KOSIS 단위 → 기본 단위로 변환 (천명 → 명 등)"""
|
|
159
|
+
u = kosis_unit.lower()
|
|
160
|
+
# [v4] 천명개월은 KOSIS 단위명 오류 — 실제로는 개월 단위, 변환 안 함
|
|
161
|
+
if "천명개월" in u:
|
|
162
|
+
return value
|
|
163
|
+
if "천" in u:
|
|
164
|
+
return value * 1000
|
|
165
|
+
if "백만" in u or "million" in u:
|
|
166
|
+
return value * 1_000_000
|
|
167
|
+
if "억" in u:
|
|
168
|
+
return value * 100_000_000
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def is_same_unit_type(claim_unit: str, kosis_unit: str) -> bool:
|
|
173
|
+
"""단위 타입이 같은지 확인 (명↔개월 혼용 방지)"""
|
|
174
|
+
claim_unit = (claim_unit or "").lower().strip()
|
|
175
|
+
kosis_unit = (kosis_unit or "").lower().strip()
|
|
176
|
+
|
|
177
|
+
if not claim_unit or not kosis_unit:
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
# [v4] 천명개월은 KOSIS 단위명 오류 — 비교 자체를 통과
|
|
181
|
+
if "천명개월" in kosis_unit:
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
_TYPES = {
|
|
185
|
+
"people": ["명", "인구", "가구", "세대", "person"],
|
|
186
|
+
"time": ["개월", "월", "month", "년", "일", "주"],
|
|
187
|
+
"ratio": ["%", "퍼센트", "percent", "율", "비율"],
|
|
188
|
+
"money": ["원", "won", "달러", "dollar", "usd"],
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
def _get_type(u: str) -> str:
|
|
192
|
+
for t, kws in _TYPES.items():
|
|
193
|
+
if any(kw in u for kw in kws):
|
|
194
|
+
return t
|
|
195
|
+
return "unknown"
|
|
196
|
+
|
|
197
|
+
ct = _get_type(claim_unit)
|
|
198
|
+
kt = _get_type(kosis_unit)
|
|
199
|
+
return (ct == "unknown" or kt == "unknown") or (ct == kt)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ── [이수민 2026-05-14] category_path DB 직접 조회 헬퍼 ─────────────────────
|
|
203
|
+
# stat_rec.metadata에 category_path가 없는 경우 (KOSIS API 검색 결과 등)
|
|
204
|
+
# kosis_stat_catalog 테이블에서 직접 조회. 도메인 가드용.
|
|
205
|
+
async def _lookup_category_path(stat_id: str) -> str | None:
|
|
206
|
+
try:
|
|
207
|
+
import asyncpg
|
|
208
|
+
dsn = os.environ.get(
|
|
209
|
+
"PGVECTOR_DSN",
|
|
210
|
+
"postgresql://postgres:1234@localhost:5432/factcheck",
|
|
211
|
+
)
|
|
212
|
+
conn = await asyncpg.connect(dsn)
|
|
213
|
+
try:
|
|
214
|
+
row = await conn.fetchrow(
|
|
215
|
+
"SELECT category_path FROM kosis_stat_catalog WHERE stat_id = $1",
|
|
216
|
+
stat_id,
|
|
217
|
+
)
|
|
218
|
+
return row["category_path"] if row else None
|
|
219
|
+
finally:
|
|
220
|
+
await conn.close()
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logger.debug(f"category_path 조회 실패 ({stat_id}): {e}")
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ── 기존 헬퍼 (신준수) ───────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
def _meta_error_payload(tag: str, exc: Exception | None = None) -> dict[str, Any]:
|
|
229
|
+
d: dict[str, Any] = {"kosis_error": tag}
|
|
230
|
+
if exc is not None:
|
|
231
|
+
d["detail"] = str(exc)[:500]
|
|
232
|
+
return d
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _kosis_text_to_json(text: str) -> Any | None:
|
|
236
|
+
t = (text or "").strip()
|
|
237
|
+
if not t or t.lstrip().startswith("<"):
|
|
238
|
+
return None
|
|
239
|
+
try:
|
|
240
|
+
return json.loads(t)
|
|
241
|
+
except json.JSONDecodeError:
|
|
242
|
+
try:
|
|
243
|
+
return json5.loads(t)
|
|
244
|
+
except (ValueError, TypeError):
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _kosis_cell_str(v: Any) -> str | None:
|
|
249
|
+
s = str(v).strip() if v is not None else ""
|
|
250
|
+
return s or None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _rows_from_kosis_body(data: Any) -> list[dict[str, Any]]:
|
|
254
|
+
if isinstance(data, dict) and data.get("kosis_error"):
|
|
255
|
+
return []
|
|
256
|
+
if isinstance(data, dict) and isinstance(data.get("row"), list):
|
|
257
|
+
return [x for x in data["row"] if isinstance(x, dict)]
|
|
258
|
+
if isinstance(data, list):
|
|
259
|
+
return [x for x in data if isinstance(x, dict)]
|
|
260
|
+
return []
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def kosis_get_meta(
|
|
264
|
+
client: httpx.AsyncClient, base: str, api_key: str,
|
|
265
|
+
org_id: str, tbl_id: str, meta_type: str, timeout: float,
|
|
266
|
+
) -> Any:
|
|
267
|
+
p: dict[str, Any] = {
|
|
268
|
+
"method": "getMeta", "type": meta_type, "apiKey": api_key,
|
|
269
|
+
"orgId": org_id, "tblId": tbl_id, "format": "json", "content": "json",
|
|
270
|
+
}
|
|
271
|
+
if meta_type == "PRD":
|
|
272
|
+
p["detail"] = "Y"
|
|
273
|
+
url = f"{base.rstrip('/')}/statisticsData.do"
|
|
274
|
+
logger.info("[ ] 요청: org_id=%s, tbl_id=%s, meta_type=%s", org_id, tbl_id, meta_type)
|
|
275
|
+
try:
|
|
276
|
+
r = await client.get(url, params=p, headers=_JSON_HEADERS, timeout=timeout)
|
|
277
|
+
r.raise_for_status()
|
|
278
|
+
logger.info("[kosis_get_meta] 응답 raw text (앞 500자): %s", (r.text or "")[:500])
|
|
279
|
+
data = _kosis_text_to_json(r.text or "")
|
|
280
|
+
if data is None:
|
|
281
|
+
logger.info("[kosis_get_meta] JSON 파싱 실패 (data=None)")
|
|
282
|
+
return _meta_error_payload("parse")
|
|
283
|
+
if isinstance(data, dict) and data.get("err") is not None and "row" not in data:
|
|
284
|
+
logger.info("[kosis_get_meta] API 오류 응답: err=%s, errMsg=%s", data.get("err"), data.get("errMsg"))
|
|
285
|
+
return {"kosis_error": "api_err", "err": data.get("err"), "errMsg": data.get("errMsg")}
|
|
286
|
+
logger.info("[kosis_get_meta] 파싱 성공: type=%s, len=%s", type(data).__name__, len(data) if isinstance(data, (list, dict)) else "-")
|
|
287
|
+
return data
|
|
288
|
+
except Exception as e:
|
|
289
|
+
logger.info("[kosis_get_meta] HTTP 예외: %s", e)
|
|
290
|
+
return _meta_error_payload("http", e)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def kosis_enrich_stat_records(
|
|
294
|
+
client: httpx.AsyncClient, base: str, api_key: str,
|
|
295
|
+
records: list[StatRecord], *, timeout: float, record_concurrency: int = 5,
|
|
296
|
+
) -> None:
|
|
297
|
+
if not records:
|
|
298
|
+
return
|
|
299
|
+
sem = asyncio.Semaphore(max(1, min(record_concurrency * 2, 16)))
|
|
300
|
+
|
|
301
|
+
async def _one(rec: StatRecord) -> None:
|
|
302
|
+
oid = (rec.org_id or (rec.metadata or {}).get("ORG_ID") or "")
|
|
303
|
+
if isinstance(oid, str):
|
|
304
|
+
oid = oid.strip() or None
|
|
305
|
+
tid = (rec.stat_id or "").strip()
|
|
306
|
+
if not oid or not tid:
|
|
307
|
+
err = _meta_error_payload("no_org_or_tbl")
|
|
308
|
+
rec.metadata["getMeta_PRD"] = err
|
|
309
|
+
rec.metadata["getMeta_CMMT"] = err
|
|
310
|
+
return
|
|
311
|
+
try:
|
|
312
|
+
async def _g(meta: str) -> Any:
|
|
313
|
+
async with sem:
|
|
314
|
+
return await kosis_get_meta(client, base, api_key, oid, tid, meta, timeout)
|
|
315
|
+
prd, cmmt = await asyncio.gather(_g("PRD"), _g("CMMT"))
|
|
316
|
+
except Exception as e:
|
|
317
|
+
epl = _meta_error_payload("enrich", e)
|
|
318
|
+
prd, cmmt = epl, epl
|
|
319
|
+
rec.metadata["getMeta_PRD"] = prd
|
|
320
|
+
rec.metadata["getMeta_CMMT"] = cmmt
|
|
321
|
+
|
|
322
|
+
await asyncio.gather(*[_one(r) for r in records])
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
326
|
+
|
|
327
|
+
class KOSISConnector(BaseConnector):
|
|
328
|
+
"""
|
|
329
|
+
KOSIS Open API 커넥터 (v3)
|
|
330
|
+
|
|
331
|
+
search_and_fetch() 흐름:
|
|
332
|
+
CatalogSearchTool → 후보 top_k
|
|
333
|
+
LLM Agent → stat_id 선택 + 파라미터
|
|
334
|
+
fetch_with_retry() → KOSIS API + prd_se 순회 + objL 점진
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
BASE_URL = "https://kosis.kr/openapi"
|
|
338
|
+
|
|
339
|
+
def __init__(self, config: dict | None = None):
|
|
340
|
+
self.config = config or {}
|
|
341
|
+
self.api_key = os.environ.get(self.config.get("api_key_env", "KOSIS_API_KEY"), "")
|
|
342
|
+
self.timeout = self.config.get("timeout", 30)
|
|
343
|
+
self.catalog = CatalogSearchTool(config=self.config)
|
|
344
|
+
|
|
345
|
+
# ── search_and_fetch (v3 핵심) ────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
async def search_and_fetch(self, query: ConnectorQuery) -> StatData | None:
|
|
348
|
+
"""
|
|
349
|
+
Catalog → LLM Agent → KOSIS fetch 파이프라인 (v4: 후보 순회 retry).
|
|
350
|
+
|
|
351
|
+
[v1] KOSIS 통합검색 직접 → err=30 다수
|
|
352
|
+
[v3] CatalogSearchTool(pgvector) → LLM Agent → fetch_with_retry
|
|
353
|
+
[v4 김예슬] 후보 순회 retry:
|
|
354
|
+
1) Agent가 1순위 stat_id 선택 → fetch
|
|
355
|
+
2) 실패 → tried_ids에 추가 → Agent에게 "이건 실패했다, 남은 후보에서 골라라"
|
|
356
|
+
3) 남은 후보에서 재선택 → fetch
|
|
357
|
+
4) 최대 _MAX_CANDIDATES(5)개까지 순회
|
|
358
|
+
5) Agent가 "NONE" 답하거나 남은 후보 없으면 포기
|
|
359
|
+
|
|
360
|
+
ReAct 패턴:
|
|
361
|
+
Thought: "이 주장을 검증하려면 어떤 통계표가 필요한가?"
|
|
362
|
+
Action: stat_id 선택 + 파라미터 결정
|
|
363
|
+
Observation: fetch 결과 (성공/실패)
|
|
364
|
+
→ 실패 시 Thought: "이 테이블은 안 됐다, 다른 후보에서 골라야 한다"
|
|
365
|
+
→ Action: 다음 stat_id 선택
|
|
366
|
+
"""
|
|
367
|
+
# 1단계: Catalog 검색 (pgvector + KOSIS API)
|
|
368
|
+
candidates = await self.catalog.search(query, top_k=10)
|
|
369
|
+
|
|
370
|
+
if not candidates:
|
|
371
|
+
logger.warning(f"후보 없음: {query.keyword}")
|
|
372
|
+
# [v6.1] 후보 없음 → 검색어 단순화 재검색 (공용 헬퍼)
|
|
373
|
+
retried = await self._retry_with_simplified_keyword(
|
|
374
|
+
query, set(), []
|
|
375
|
+
)
|
|
376
|
+
if retried is not None:
|
|
377
|
+
return retried
|
|
378
|
+
logger.warning(f"최종 Evidence 없음: {query.keyword}")
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
# 2단계: getMeta 보강 (PRD/CMMT)
|
|
382
|
+
if self.api_key and self.config.get("enrich_get_meta", True):
|
|
383
|
+
try:
|
|
384
|
+
base = (self.config.get("base_url") or self.BASE_URL).rstrip("/")
|
|
385
|
+
async with httpx.AsyncClient(timeout=self.timeout) as mclient:
|
|
386
|
+
await kosis_enrich_stat_records(
|
|
387
|
+
mclient, base, self.api_key, candidates[:5],
|
|
388
|
+
timeout=float(self.timeout),
|
|
389
|
+
)
|
|
390
|
+
except Exception as e:
|
|
391
|
+
logger.debug(f"getMeta 보강 실패: {e}")
|
|
392
|
+
|
|
393
|
+
# ── [v4] 후보 순회 retry 루프 ────────────────────────────────────
|
|
394
|
+
tried_ids: set[str] = set()
|
|
395
|
+
tried_log: list[dict] = [] # Agent에게 보여줄 실패 이력
|
|
396
|
+
|
|
397
|
+
for round_idx in range(_MAX_CANDIDATES):
|
|
398
|
+
remaining = [c for c in candidates if c.stat_id not in tried_ids]
|
|
399
|
+
if not remaining:
|
|
400
|
+
logger.info("모든 후보 소진 → 종료")
|
|
401
|
+
break
|
|
402
|
+
|
|
403
|
+
# 3단계: stat_id 선택
|
|
404
|
+
if round_idx == 0:
|
|
405
|
+
# 첫 번째: LLM Agent가 최적 후보 선택
|
|
406
|
+
agent_decision = await self._agent_select_stat(query, candidates)
|
|
407
|
+
else:
|
|
408
|
+
# [v4]: Agent 재선택 대신 순차 순회 (factcheck_test.py 방식)
|
|
409
|
+
# Agent가 재선택해도 엉뚱한 테이블을 고르는 경우가 많아서
|
|
410
|
+
# relevance_score 순서대로 순차 시도하는 게 정확도 더 높음
|
|
411
|
+
agent_decision = {
|
|
412
|
+
"stat_id": remaining[0].stat_id,
|
|
413
|
+
"prd_se": "Y",
|
|
414
|
+
"start_prd_de": query.time_period or "",
|
|
415
|
+
"end_prd_de": query.time_period or "",
|
|
416
|
+
}
|
|
417
|
+
logger.info(
|
|
418
|
+
f"[순차 순회] 다음 후보: [{remaining[0].stat_id}] "
|
|
419
|
+
f"{remaining[0].stat_name}"
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
if agent_decision and agent_decision.get("stat_id"):
|
|
423
|
+
selected_id = agent_decision["stat_id"]
|
|
424
|
+
# "NONE" 답변 → Agent가 적합한 후보 없다고 판단
|
|
425
|
+
if selected_id.upper() == "NONE":
|
|
426
|
+
logger.info("Agent: 적합한 후보 없음 → 종료")
|
|
427
|
+
break
|
|
428
|
+
# 이미 시도한 stat_id를 다시 선택한 경우 → 강제로 다음 후보
|
|
429
|
+
if selected_id in tried_ids:
|
|
430
|
+
selected_id = remaining[0].stat_id
|
|
431
|
+
prd_se = agent_decision.get("prd_se", "Y")
|
|
432
|
+
start_prd_de = agent_decision.get("start_prd_de", "")
|
|
433
|
+
end_prd_de = agent_decision.get("end_prd_de", "")
|
|
434
|
+
else:
|
|
435
|
+
# Agent 실패 → relevance_score 최고 후보 (시도 안 한 것 중)
|
|
436
|
+
selected_id = remaining[0].stat_id
|
|
437
|
+
prd_se = "Y"
|
|
438
|
+
start_prd_de = query.time_period or ""
|
|
439
|
+
end_prd_de = query.time_period or ""
|
|
440
|
+
|
|
441
|
+
stat_rec = next(
|
|
442
|
+
(r for r in candidates if r.stat_id == selected_id),
|
|
443
|
+
remaining[0]
|
|
444
|
+
)
|
|
445
|
+
tried_ids.add(selected_id)
|
|
446
|
+
|
|
447
|
+
logger.info(
|
|
448
|
+
f"[후보 {round_idx+1}/{_MAX_CANDIDATES}] "
|
|
449
|
+
f"시도: [{selected_id}] {stat_rec.stat_name}"
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# 4단계: fetch (prd_se 순회 + objL 점진)
|
|
453
|
+
data = await self._fetch_with_retry(
|
|
454
|
+
stat_id=selected_id,
|
|
455
|
+
stat_rec=stat_rec,
|
|
456
|
+
query=query,
|
|
457
|
+
prd_se_hint=prd_se,
|
|
458
|
+
start_prd_de=start_prd_de,
|
|
459
|
+
end_prd_de=end_prd_de,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
if data and data.official_value is not None:
|
|
463
|
+
# [v5] 가짜 match 방지 — 테이블 관련성 체크
|
|
464
|
+
indicator = query.indicator or query.keyword or ""
|
|
465
|
+
table_name = stat_rec.stat_name or ""
|
|
466
|
+
if not is_table_relevant(indicator, table_name):
|
|
467
|
+
logger.warning(
|
|
468
|
+
f"테이블 관련성 없음 → skip: [{selected_id}] "
|
|
469
|
+
f"{table_name} vs indicator={indicator}"
|
|
470
|
+
)
|
|
471
|
+
tried_ids.add(selected_id)
|
|
472
|
+
tried_log.append({
|
|
473
|
+
"stat_id": selected_id,
|
|
474
|
+
"stat_name": table_name,
|
|
475
|
+
"error": "테이블 관련성 없음",
|
|
476
|
+
})
|
|
477
|
+
continue
|
|
478
|
+
|
|
479
|
+
# [이수민 2026-05-14] working memory 도메인 가드용
|
|
480
|
+
# stat_rec.metadata에 있으면 사용, 없으면 DB 직접 조회로 보강
|
|
481
|
+
if data.category_path is None:
|
|
482
|
+
data.category_path = (stat_rec.metadata or {}).get("category_path")
|
|
483
|
+
if data.category_path is None:
|
|
484
|
+
data.category_path = await _lookup_category_path(selected_id)
|
|
485
|
+
|
|
486
|
+
logger.info(
|
|
487
|
+
f"Evidence 조회 성공 (후보 {round_idx+1}): [{selected_id}] "
|
|
488
|
+
f"value={data.official_value} {data.unit or ''} "
|
|
489
|
+
f"category={data.category_path}"
|
|
490
|
+
)
|
|
491
|
+
return data
|
|
492
|
+
|
|
493
|
+
# 실패 이력 기록 → 다음 라운드에서 Agent에게 전달
|
|
494
|
+
last_error = "데이터 없음"
|
|
495
|
+
if data and data.raw_response:
|
|
496
|
+
err = data.raw_response.get("err") or data.raw_response.get("error", "")
|
|
497
|
+
last_error = f"err={err}"
|
|
498
|
+
if data.raw_response.get("errMsg"):
|
|
499
|
+
last_error += f" {data.raw_response['errMsg']}"
|
|
500
|
+
|
|
501
|
+
tried_log.append({
|
|
502
|
+
"stat_id": selected_id,
|
|
503
|
+
"stat_name": stat_rec.stat_name,
|
|
504
|
+
"error": last_error,
|
|
505
|
+
})
|
|
506
|
+
logger.warning(
|
|
507
|
+
f"fetch 실패 (후보 {round_idx+1}): [{selected_id}] "
|
|
508
|
+
f"{stat_rec.stat_name} | {last_error}"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# ── [v6.1] 후보 전부 실패 → LLM Agent 검색어 재생성 후 재검색 ──────
|
|
512
|
+
# 기존엔 candidates가 빈 경우에만 재검색 → 22개 후보가 전부
|
|
513
|
+
# 관련성 없음으로 skip되는 경우 재검색이 안 돌던 버그 수정.
|
|
514
|
+
retried = await self._retry_with_simplified_keyword(
|
|
515
|
+
query, tried_ids, tried_log
|
|
516
|
+
)
|
|
517
|
+
if retried is not None:
|
|
518
|
+
return retried
|
|
519
|
+
|
|
520
|
+
logger.warning(f"최종 Evidence 없음 ({len(tried_ids)}개 시도): {query.keyword}")
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
async def _retry_with_simplified_keyword(
|
|
524
|
+
self,
|
|
525
|
+
query: "ConnectorQuery",
|
|
526
|
+
tried_ids: set[str],
|
|
527
|
+
tried_log: list[dict],
|
|
528
|
+
) -> "StatData | None":
|
|
529
|
+
"""
|
|
530
|
+
[v6.1] 검색어 단순화 재검색.
|
|
531
|
+
|
|
532
|
+
후보가 전혀 없거나(candidates 빈 경우),
|
|
533
|
+
모든 후보가 관련성 없음으로 skip된 경우 호출.
|
|
534
|
+
|
|
535
|
+
LLM Agent가 실패 이력을 보고 더 단순한 검색어를 생성
|
|
536
|
+
("서울 표준주택 공시가격 변동률" → "공시가격") → 재검색.
|
|
537
|
+
"""
|
|
538
|
+
simplified = await self._agent_simplify_keyword(query, tried_log)
|
|
539
|
+
original_kw = query.indicator or query.keyword or ""
|
|
540
|
+
if not simplified or simplified == original_kw:
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
logger.info(f"[재검색] Agent 검색어 변경: '{original_kw}' → '{simplified}'")
|
|
544
|
+
from structverify.retrieval.base_connector import ConnectorQuery
|
|
545
|
+
|
|
546
|
+
retry_query = ConnectorQuery(
|
|
547
|
+
keyword=simplified,
|
|
548
|
+
indicator=simplified,
|
|
549
|
+
time_period=query.time_period,
|
|
550
|
+
population=query.population,
|
|
551
|
+
extra_params=query.extra_params,
|
|
552
|
+
)
|
|
553
|
+
retry_candidates = await self.catalog.search(retry_query, top_k=8)
|
|
554
|
+
retry_candidates = [
|
|
555
|
+
c for c in retry_candidates if c.stat_id not in tried_ids
|
|
556
|
+
]
|
|
557
|
+
if not retry_candidates:
|
|
558
|
+
logger.info("[재검색] 새 후보 없음")
|
|
559
|
+
return None
|
|
560
|
+
|
|
561
|
+
# [수정 v6.22] 재검색 경로도 claim 시점 단위를 존중 — 'Y' 하드코딩 제거.
|
|
562
|
+
# [BEFORE] prd_se_hint="Y" 하드코딩 → 월별 claim도 연간부터 시도.
|
|
563
|
+
# [AFTER] query.time_period가 'YYYY-MM'이면 'M', 분기면 'Q', 아니면 'Y'.
|
|
564
|
+
_tp = str(query.time_period or "").strip()
|
|
565
|
+
if re.match(r"^\d{4}[-./]?\d{2}$", _tp):
|
|
566
|
+
_retry_prd = "M"
|
|
567
|
+
elif re.search(r"[Qq][1-4]", _tp) or re.match(r"^\d{4}[-./]?[1-4]$", _tp):
|
|
568
|
+
_retry_prd = "Q"
|
|
569
|
+
else:
|
|
570
|
+
_retry_prd = "Y"
|
|
571
|
+
|
|
572
|
+
# 단순화한 검색어 기준으로 관련성 체크 + fetch
|
|
573
|
+
for rc in retry_candidates[:5]:
|
|
574
|
+
if not is_table_relevant(simplified, rc.stat_name):
|
|
575
|
+
continue
|
|
576
|
+
data = await self._fetch_with_retry(
|
|
577
|
+
stat_id=rc.stat_id, stat_rec=rc, query=query,
|
|
578
|
+
prd_se_hint=_retry_prd, # [수정 v6.22] 'Y' 하드코딩 → 시점 추론
|
|
579
|
+
start_prd_de=query.time_period or "",
|
|
580
|
+
end_prd_de=query.time_period or "",
|
|
581
|
+
)
|
|
582
|
+
if data and data.official_value is not None:
|
|
583
|
+
# 재검색 결과도 원래 indicator 기준으로 관련성 재확인
|
|
584
|
+
indicator = query.indicator or query.keyword or ""
|
|
585
|
+
if not is_table_relevant(indicator, rc.stat_name):
|
|
586
|
+
if not is_table_relevant(simplified, rc.stat_name):
|
|
587
|
+
continue
|
|
588
|
+
logger.info(
|
|
589
|
+
f"[재검색] 성공: [{rc.stat_id}] {rc.stat_name} "
|
|
590
|
+
f"value={data.official_value}"
|
|
591
|
+
)
|
|
592
|
+
return data
|
|
593
|
+
logger.info("[재검색] 단순화 후에도 적합 후보 없음")
|
|
594
|
+
return None
|
|
595
|
+
|
|
596
|
+
# ── prd_se 순회 + objL 점진 fetch (factcheck_test.py 참고) ───────────────
|
|
597
|
+
|
|
598
|
+
async def _fetch_with_retry(
|
|
599
|
+
self,
|
|
600
|
+
stat_id: str,
|
|
601
|
+
stat_rec: StatRecord,
|
|
602
|
+
query: ConnectorQuery,
|
|
603
|
+
prd_se_hint: str = "Y",
|
|
604
|
+
start_prd_de: str = "",
|
|
605
|
+
end_prd_de: str = "",
|
|
606
|
+
dim_overrides: dict[str, str] | None = None,
|
|
607
|
+
) -> StatData | None:
|
|
608
|
+
"""
|
|
609
|
+
prd_se 순회(Y→M→Q) + objL 점진 + newEstPrdCnt 폴백.
|
|
610
|
+
|
|
611
|
+
factcheck_test.py의 fetch_kosis_data() 로직을 비동기로 재구현.
|
|
612
|
+
"""
|
|
613
|
+
# 연도 추출
|
|
614
|
+
time_ref = query.time_period or ""
|
|
615
|
+
year_m = re.search(r"(\d{4})", start_prd_de or time_ref)
|
|
616
|
+
year = year_m.group(1) if year_m else "2024"
|
|
617
|
+
|
|
618
|
+
org_id = (
|
|
619
|
+
stat_rec.org_id
|
|
620
|
+
or (stat_rec.metadata or {}).get("ORG_ID")
|
|
621
|
+
or ""
|
|
622
|
+
)
|
|
623
|
+
if not org_id:
|
|
624
|
+
logger.debug(f"org_id 없음: {stat_id}")
|
|
625
|
+
return None
|
|
626
|
+
|
|
627
|
+
prd_m = (stat_rec.metadata or {}).get("getMeta_PRD")
|
|
628
|
+
cmmt_m = (stat_rec.metadata or {}).get("getMeta_CMMT")
|
|
629
|
+
prd_rows = _rows_from_kosis_body(prd_m)
|
|
630
|
+
cmmt_rows = _rows_from_kosis_body(cmmt_m)
|
|
631
|
+
|
|
632
|
+
# ── [2026-05-25] PRD_SE-aware: 표가 실제 지원하는 주기로 strategy 좁히기 ──
|
|
633
|
+
# 기존: hint(Y) + 모든 주기(M/Q/Y) 순회 → 표가 Q만 지원해도 Y/M API 호출 낭비.
|
|
634
|
+
# 개선: getMeta_PRD에서 표가 지원하는 PRD_SE set 추출, 미지원 주기 제외.
|
|
635
|
+
# hint가 미지원 주기면 표가 지원하는 주기 중 가장 fine-grained로 변경 (M>Q>Y>A).
|
|
636
|
+
# prd_rows가 비어있거나 PRD_SE 필드가 없으면 기존 fallback 동작 유지(보수).
|
|
637
|
+
# KOSIS getMeta(PRD) 응답 필드명이 표마다 다를 수 있어 여러 후보 키 확인.
|
|
638
|
+
# ★ PRD_SE 값은 한국어('년','분기','월') 또는 영문('Y','Q','M') 둘 다 가능 →
|
|
639
|
+
# 영문 코드로 정규화 후 비교 (회귀: 한국어 값을 영문 strategy와 비교해 0 strategies가 됨).
|
|
640
|
+
_PRD_SE_KO_TO_EN = {
|
|
641
|
+
"월": "M", "MONTH": "M",
|
|
642
|
+
"분기": "Q", "QUARTER": "Q",
|
|
643
|
+
"년": "Y", "연": "Y", "연간": "Y", "YEAR": "Y", "ANNUAL": "Y",
|
|
644
|
+
"반기": "H", "HALF": "H",
|
|
645
|
+
"부정기": "IR", "IRREGULAR": "IR",
|
|
646
|
+
# 영문 코드는 그대로 통과
|
|
647
|
+
"M": "M", "Q": "Q", "Y": "Y", "A": "Y", "H": "H", "IR": "IR",
|
|
648
|
+
}
|
|
649
|
+
available_prd_se: set[str] = set()
|
|
650
|
+
_PRD_SE_KEYS = ("PRD_SE", "PRD_SE_CD", "prdSe", "PRD_SE_NM")
|
|
651
|
+
for _pr in prd_rows:
|
|
652
|
+
for _k in _PRD_SE_KEYS:
|
|
653
|
+
_ps = str(_pr.get(_k) or "").strip().upper()
|
|
654
|
+
if not _ps:
|
|
655
|
+
continue
|
|
656
|
+
_en = _PRD_SE_KO_TO_EN.get(_ps)
|
|
657
|
+
if _en:
|
|
658
|
+
available_prd_se.add(_en)
|
|
659
|
+
break
|
|
660
|
+
|
|
661
|
+
# 진단 로그 — pruning이 작동했는지 한 번에 추적 (INFO 레벨로 강제)
|
|
662
|
+
if not prd_rows:
|
|
663
|
+
logger.info(
|
|
664
|
+
f"[KOSISConnector] PRD_SE pruning skip: prd_rows 비어있음 "
|
|
665
|
+
f"(stat_id={stat_id}, meta_PRD={'있음' if prd_m else '없음'}) "
|
|
666
|
+
f"→ 기존 Y/M/Q 전체 fallback 사용. "
|
|
667
|
+
f"원인 후보: stat_record 캐시 적중인데 getMeta_PRD enrich 미실행."
|
|
668
|
+
)
|
|
669
|
+
elif not available_prd_se:
|
|
670
|
+
# PRD row는 있는데 PRD_SE를 못 찾음 — 필드명 변형 추적
|
|
671
|
+
_sample_keys = list(prd_rows[0].keys())[:15] if prd_rows[0] else []
|
|
672
|
+
logger.warning(
|
|
673
|
+
f"[KOSISConnector] PRD_SE pruning skip: prd_rows({len(prd_rows)}건)에 "
|
|
674
|
+
f"PRD_SE 필드 없음 — sample keys: {_sample_keys} "
|
|
675
|
+
f"(stat_id={stat_id})"
|
|
676
|
+
)
|
|
677
|
+
else:
|
|
678
|
+
logger.info(
|
|
679
|
+
f"[KOSISConnector] PRD_SE 검출: stat_id={stat_id} → "
|
|
680
|
+
f"표 지원 주기={sorted(available_prd_se)}"
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
if available_prd_se and prd_se_hint not in available_prd_se:
|
|
684
|
+
# M > Q > Y > A 우선순위로 best available 선택
|
|
685
|
+
# [2026-05-26 회귀] start/end 자동 보정 *제거* — 그 보정이 *부분 집합 표*
|
|
686
|
+
# (예: DT_HIRA4Q '진단방사선·특수의료장비')에서 fetch 성공시켜버려, 사용자가
|
|
687
|
+
# 원하던 *전체 의료장비* 표(DT_35003_A7)로 fallback 못 가는 부작용. 보정
|
|
688
|
+
# 없이 두면 Q 단위 표에 Y 포맷 start/end가 가서 빈 응답 → fallback 진행.
|
|
689
|
+
for _p in ("M", "Q", "Y", "A"):
|
|
690
|
+
if _p in available_prd_se:
|
|
691
|
+
logger.info(
|
|
692
|
+
f"[KOSISConnector] prd_se_hint={prd_se_hint!r} 미지원 — "
|
|
693
|
+
f"표 지원 주기 {sorted(available_prd_se)} → {_p!r}로 변경 "
|
|
694
|
+
f"(stat_id={stat_id})"
|
|
695
|
+
)
|
|
696
|
+
prd_se_hint = _p
|
|
697
|
+
break
|
|
698
|
+
|
|
699
|
+
# ── [수정 v6.22] prd_se 순회 전략 — claim 시점 단위를 최우선 존중 ──
|
|
700
|
+
# [BEFORE 버그] prd_se_hint가 'M'(월)이어도 아래처럼 'Y'를 맨 앞에
|
|
701
|
+
# insert(0, ...)했음:
|
|
702
|
+
# if prd_se_hint != "Y":
|
|
703
|
+
# prd_strategies.insert(0, {"prdSe": "Y", ...})
|
|
704
|
+
# → 연간 데이터도 가진 표(예: '월.분기.연간 인구동향' DT_1B8000G)는
|
|
705
|
+
# 'Y' 요청이 먼저 성공 → 연 데이터 반환 → period guard가
|
|
706
|
+
# '연 단위 표'로 거부 → 월별 claim이 영영 검증 불가.
|
|
707
|
+
# [AFTER 수정] prd_se_hint(claim에서 유래한 시점 단위)를 1순위로
|
|
708
|
+
# 고정하고, 나머지 주기는 그 '뒤'에만 폴백으로 둔다. claim이
|
|
709
|
+
# 월이면 월을 먼저 시도해야 월 데이터를 받는다.
|
|
710
|
+
# 도메인 무관 — 시점 단위 우선순위만 다룸.
|
|
711
|
+
prd_strategies = [
|
|
712
|
+
{"prdSe": prd_se_hint, "startPrdDe": start_prd_de or year,
|
|
713
|
+
"endPrdDe": end_prd_de or year},
|
|
714
|
+
]
|
|
715
|
+
# 나머지 주기는 hint 뒤로만 추가 (hint가 항상 우선).
|
|
716
|
+
_other_periods = [
|
|
717
|
+
("M", f"{year}01", f"{year}12"),
|
|
718
|
+
("Q", f"{year}01", f"{year}04"),
|
|
719
|
+
("Y", year, year),
|
|
720
|
+
]
|
|
721
|
+
for prd, sp, ep in _other_periods:
|
|
722
|
+
if prd != prd_se_hint:
|
|
723
|
+
prd_strategies.append({"prdSe": prd, "startPrdDe": sp, "endPrdDe": ep})
|
|
724
|
+
|
|
725
|
+
# 기간 지정 실패 시 최신 데이터 폴백 — 여기서도 hint 주기를 먼저.
|
|
726
|
+
# [수정 v6.22] 폴백 순서도 ["Y","M","Q"] 고정 → hint 우선으로 변경.
|
|
727
|
+
_fb_order = [prd_se_hint] + [p for p in ["M", "Q", "Y"] if p != prd_se_hint]
|
|
728
|
+
fallbacks = [
|
|
729
|
+
{"prdSe": p, "newEstPrdCnt": "3"}
|
|
730
|
+
for p in _fb_order
|
|
731
|
+
]
|
|
732
|
+
|
|
733
|
+
# [2026-05-25] 표가 지원하는 주기만 남기기 — 미지원 주기 호출 차단.
|
|
734
|
+
# available_prd_se가 비어있으면(meta 없거나 파싱 실패) 기존 전체 strategies 유지.
|
|
735
|
+
if available_prd_se:
|
|
736
|
+
_before = len(prd_strategies) + len(fallbacks)
|
|
737
|
+
prd_strategies = [s for s in prd_strategies if s["prdSe"] in available_prd_se]
|
|
738
|
+
fallbacks = [s for s in fallbacks if s["prdSe"] in available_prd_se]
|
|
739
|
+
_after = len(prd_strategies) + len(fallbacks)
|
|
740
|
+
if _after < _before:
|
|
741
|
+
logger.info(
|
|
742
|
+
f"[KOSISConnector] prdSe pruning: {_before} → {_after} strategies "
|
|
743
|
+
f"(stat_id={stat_id}, 지원 주기={sorted(available_prd_se)})"
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
obj_l1 = "ALL"
|
|
747
|
+
itm_id = "ALL"
|
|
748
|
+
if cmmt_rows:
|
|
749
|
+
r0 = cmmt_rows[0]
|
|
750
|
+
obj_l1 = str(r0.get("OBJ_ID") or r0.get("C1") or "ALL").strip() or "ALL"
|
|
751
|
+
itm_id = str(r0.get("ITM_ID") or "ALL").strip() or "ALL"
|
|
752
|
+
|
|
753
|
+
# [P34 2026-05-22] dimension_resolver가 결정한 itmId/objL을 *우선* 사용.
|
|
754
|
+
# cmmt_rows[0]은 보통 합계 코드라 ITM='00' 같은 *전체 묶음 슬라이스*만
|
|
755
|
+
# 반환 → 세부 항목 row 누락. dim_overrides가 있으면 그것으로 덮어씀.
|
|
756
|
+
if dim_overrides:
|
|
757
|
+
_ov_itm = dim_overrides.get("itmId")
|
|
758
|
+
if _ov_itm:
|
|
759
|
+
itm_id = _ov_itm
|
|
760
|
+
_ov_obj1 = dim_overrides.get("objL1")
|
|
761
|
+
if _ov_obj1:
|
|
762
|
+
obj_l1 = _ov_obj1
|
|
763
|
+
logger.info(
|
|
764
|
+
f"[KOSISConnector] dim_overrides 적용: stat_id={stat_id} "
|
|
765
|
+
f"itmId={itm_id!r} objL1={obj_l1!r} "
|
|
766
|
+
f"(objL2+={[(k, v) for k, v in dim_overrides.items() if k.startswith('objL') and k != 'objL1']})"
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
base = (self.config.get("base_url") or self.BASE_URL).rstrip("/")
|
|
770
|
+
|
|
771
|
+
for strategy in prd_strategies + fallbacks:
|
|
772
|
+
base_params: dict[str, Any] = {
|
|
773
|
+
"method": "getList",
|
|
774
|
+
"apiKey": self.api_key,
|
|
775
|
+
"format": "json",
|
|
776
|
+
"content": "json",
|
|
777
|
+
"orgId": org_id,
|
|
778
|
+
"tblId": stat_id,
|
|
779
|
+
"objL1": obj_l1,
|
|
780
|
+
"itmId": itm_id,
|
|
781
|
+
"prdSe": strategy["prdSe"],
|
|
782
|
+
}
|
|
783
|
+
# [P34] objL2~objL8도 dim_overrides에서 받음
|
|
784
|
+
if dim_overrides:
|
|
785
|
+
for _lv in range(2, 9):
|
|
786
|
+
_key = f"objL{_lv}"
|
|
787
|
+
_v = dim_overrides.get(_key)
|
|
788
|
+
if _v:
|
|
789
|
+
base_params[_key] = _v
|
|
790
|
+
if "startPrdDe" in strategy:
|
|
791
|
+
base_params["startPrdDe"] = strategy["startPrdDe"]
|
|
792
|
+
base_params["endPrdDe"] = strategy.get("endPrdDe", strategy["startPrdDe"])
|
|
793
|
+
if "newEstPrdCnt" in strategy:
|
|
794
|
+
base_params["newEstPrdCnt"] = strategy["newEstPrdCnt"]
|
|
795
|
+
|
|
796
|
+
data = await self._try_with_objl_escalation(base, base_params, stat_id, stat_rec)
|
|
797
|
+
if data is not None:
|
|
798
|
+
return data
|
|
799
|
+
|
|
800
|
+
return None
|
|
801
|
+
|
|
802
|
+
async def _try_with_objl_escalation(
|
|
803
|
+
self,
|
|
804
|
+
base: str,
|
|
805
|
+
base_params: dict[str, Any],
|
|
806
|
+
stat_id: str,
|
|
807
|
+
stat_rec: StatRecord,
|
|
808
|
+
) -> StatData | None:
|
|
809
|
+
"""
|
|
810
|
+
objL 점진 추가 (err:20 → objL2~8 순서로 추가).
|
|
811
|
+
factcheck_test.py의 _try_with_objL_escalation 참고.
|
|
812
|
+
"""
|
|
813
|
+
result = await self._call_kosis_param(base, base_params, stat_id, stat_rec)
|
|
814
|
+
if result is None:
|
|
815
|
+
return None
|
|
816
|
+
|
|
817
|
+
# err:20 = objL 부족 → 점진 추가
|
|
818
|
+
if result.raw_response.get("err") == "20":
|
|
819
|
+
for level in range(2, 9):
|
|
820
|
+
key = f"objL{level}"
|
|
821
|
+
if key not in base_params:
|
|
822
|
+
base_params[key] = "ALL"
|
|
823
|
+
result = await self._call_kosis_param(base, base_params, stat_id, stat_rec)
|
|
824
|
+
if result is None:
|
|
825
|
+
return None
|
|
826
|
+
if result.raw_response.get("err") != "20":
|
|
827
|
+
break
|
|
828
|
+
|
|
829
|
+
# 여전히 에러면 None
|
|
830
|
+
if result.raw_response.get("err"):
|
|
831
|
+
return None
|
|
832
|
+
|
|
833
|
+
return result if result.official_value is not None else None
|
|
834
|
+
|
|
835
|
+
async def _call_kosis_param(
|
|
836
|
+
self,
|
|
837
|
+
base: str,
|
|
838
|
+
params: dict[str, Any],
|
|
839
|
+
stat_id: str,
|
|
840
|
+
stat_rec: StatRecord,
|
|
841
|
+
) -> StatData | None:
|
|
842
|
+
"""KOSIS Param/statisticsParameterData.do 단일 호출"""
|
|
843
|
+
public_req = {k: v for k, v in params.items() if k != "apiKey"}
|
|
844
|
+
tnm = getattr(stat_rec, "stat_name", stat_id) or stat_id
|
|
845
|
+
|
|
846
|
+
# [디버그] KOSIS API에 실제로 보내는 시점 파라미터 확인용
|
|
847
|
+
logger.info(
|
|
848
|
+
f"[KOSISConnector] _call_kosis_param 요청: stat_id={stat_id} "
|
|
849
|
+
f"prdSe={public_req.get('prdSe')!r} "
|
|
850
|
+
f"startPrdDe={public_req.get('startPrdDe')!r} "
|
|
851
|
+
f"endPrdDe={public_req.get('endPrdDe')!r} "
|
|
852
|
+
f"newEstPrdCnt={public_req.get('newEstPrdCnt')!r}"
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
try:
|
|
856
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
857
|
+
r = await client.get(
|
|
858
|
+
f"{base}/Param/statisticsParameterData.do",
|
|
859
|
+
params=params,
|
|
860
|
+
headers=_JSON_HEADERS,
|
|
861
|
+
)
|
|
862
|
+
r.raise_for_status()
|
|
863
|
+
text = (r.text or "").strip()
|
|
864
|
+
|
|
865
|
+
if not text or text.lstrip().startswith("<"):
|
|
866
|
+
return StatData(stat_id=stat_id, stat_name=tnm, values={},
|
|
867
|
+
raw_response={"error": "empty_or_html", "request": public_req})
|
|
868
|
+
|
|
869
|
+
j = _kosis_text_to_json(text)
|
|
870
|
+
if j is None:
|
|
871
|
+
return StatData(stat_id=stat_id, stat_name=tnm, values={},
|
|
872
|
+
raw_response={"error": "json_parse", "request": public_req})
|
|
873
|
+
|
|
874
|
+
if isinstance(j, dict) and j.get("err") is not None and "row" not in j:
|
|
875
|
+
return StatData(stat_id=stat_id, stat_name=tnm, values={},
|
|
876
|
+
raw_response={
|
|
877
|
+
"err": j.get("err"),
|
|
878
|
+
"errMsg": j.get("errMsg"),
|
|
879
|
+
"request": public_req,
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
drows = _rows_from_kosis_body(j)
|
|
883
|
+
if not drows:
|
|
884
|
+
return StatData(stat_id=stat_id, stat_name=tnm, values={},
|
|
885
|
+
raw_response={**(j if isinstance(j, dict) else {}),
|
|
886
|
+
"request": public_req})
|
|
887
|
+
|
|
888
|
+
cell0 = drows[0]
|
|
889
|
+
tnm2 = (cell0.get("TBL_NM") or tnm) or stat_id
|
|
890
|
+
raw_out = {**j, "request": public_req} if isinstance(j, dict) else {
|
|
891
|
+
"row": drows, "request": public_req}
|
|
892
|
+
|
|
893
|
+
dt_s = str(cell0.get("DT") or "").strip()
|
|
894
|
+
val: float | None = None
|
|
895
|
+
if dt_s:
|
|
896
|
+
try:
|
|
897
|
+
val = float(dt_s.replace(",", ""))
|
|
898
|
+
except ValueError:
|
|
899
|
+
pass
|
|
900
|
+
|
|
901
|
+
# 단위 타입 검증
|
|
902
|
+
kosis_unit = _kosis_cell_str(cell0.get("UNIT_NM")) or ""
|
|
903
|
+
# verifier.py에서 unit 타입 확인용으로 metadata에 저장
|
|
904
|
+
tp = _kosis_cell_str(cell0.get("PRD_DE"))
|
|
905
|
+
|
|
906
|
+
return StatData(
|
|
907
|
+
stat_id=stat_id,
|
|
908
|
+
stat_name=str(tnm2).strip() or stat_id,
|
|
909
|
+
values={
|
|
910
|
+
"value": val,
|
|
911
|
+
"DT": cell0.get("DT"),
|
|
912
|
+
"PRD_DE": cell0.get("PRD_DE"),
|
|
913
|
+
"ITM_NM": cell0.get("ITM_NM"),
|
|
914
|
+
"UNIT_NM": kosis_unit,
|
|
915
|
+
},
|
|
916
|
+
raw_response=raw_out,
|
|
917
|
+
official_value=val,
|
|
918
|
+
unit=kosis_unit,
|
|
919
|
+
time_period=tp,
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
except httpx.HTTPError as e:
|
|
923
|
+
logger.error("KOSIS param HTTP: %s", e)
|
|
924
|
+
return None
|
|
925
|
+
except Exception as e:
|
|
926
|
+
logger.error("KOSIS param: %s", e)
|
|
927
|
+
return None
|
|
928
|
+
|
|
929
|
+
# ── LLM Agent ────────────────────────────────────────────────────────────
|
|
930
|
+
|
|
931
|
+
async def _agent_select_stat(
|
|
932
|
+
self, query: ConnectorQuery, candidates: list[StatRecord]
|
|
933
|
+
) -> dict[str, Any] | None:
|
|
934
|
+
"""HCX-DASH-002로 후보 중 최적 stat_id 선택"""
|
|
935
|
+
if not candidates:
|
|
936
|
+
return None
|
|
937
|
+
try:
|
|
938
|
+
from structverify.utils.llm_client import LLMClient
|
|
939
|
+
llm = LLMClient(config=self.config.get("llm", {}))
|
|
940
|
+
except ImportError:
|
|
941
|
+
return None
|
|
942
|
+
|
|
943
|
+
candidate_text = "\n".join([
|
|
944
|
+
f" {i+1}. [{r.stat_id}] {r.stat_name} ({r.org_name}) "
|
|
945
|
+
f"[{r.metadata.get('category_path','')}]"
|
|
946
|
+
for i, r in enumerate(candidates[:10])
|
|
947
|
+
])
|
|
948
|
+
|
|
949
|
+
raw_claim = (query.extra_params or {}).get("raw_claim", "")
|
|
950
|
+
prompt = _AGENT_SELECT_PROMPT.format(
|
|
951
|
+
claim_text=raw_claim[:200] or query.keyword,
|
|
952
|
+
indicator=query.indicator or "",
|
|
953
|
+
time_period=query.time_period or "",
|
|
954
|
+
population=query.population or "",
|
|
955
|
+
candidates=candidate_text,
|
|
956
|
+
)
|
|
957
|
+
logger.info(
|
|
958
|
+
"[LLM 진입 직전 | _agent_select_stat] claim=%r | indicator=%r | time=%r | population=%r | 후보(%d개):\n%s",
|
|
959
|
+
raw_claim[:200] or query.keyword,
|
|
960
|
+
query.indicator or "",
|
|
961
|
+
query.time_period or "",
|
|
962
|
+
query.population or "",
|
|
963
|
+
len(candidates),
|
|
964
|
+
candidate_text,
|
|
965
|
+
)
|
|
966
|
+
try:
|
|
967
|
+
result = await llm.generate_json(
|
|
968
|
+
prompt=prompt,
|
|
969
|
+
system_prompt="한국 통계 전문가. JSON으로만 답하세요.",
|
|
970
|
+
model_tier="light",
|
|
971
|
+
)
|
|
972
|
+
if result and result.get("stat_id"):
|
|
973
|
+
logger.info(
|
|
974
|
+
f"Agent 선택: [{result['stat_id']}] {result.get('stat_name','')} "
|
|
975
|
+
f"— {result.get('reason','')}"
|
|
976
|
+
)
|
|
977
|
+
return result
|
|
978
|
+
except Exception as e:
|
|
979
|
+
logger.debug(f"Agent 선택 실패: {e}")
|
|
980
|
+
return None
|
|
981
|
+
|
|
982
|
+
async def _agent_retry_params(
|
|
983
|
+
self,
|
|
984
|
+
query: ConnectorQuery,
|
|
985
|
+
candidates: list[StatRecord],
|
|
986
|
+
prev_stat_id: str,
|
|
987
|
+
prev_params: dict,
|
|
988
|
+
error: str,
|
|
989
|
+
) -> dict[str, Any] | None:
|
|
990
|
+
"""HCX-DASH-002로 fetch 실패 시 파라미터 수정"""
|
|
991
|
+
try:
|
|
992
|
+
from structverify.utils.llm_client import LLMClient
|
|
993
|
+
llm = LLMClient(config=self.config.get("llm", {}))
|
|
994
|
+
except ImportError:
|
|
995
|
+
return None
|
|
996
|
+
|
|
997
|
+
candidate_text = "\n".join([
|
|
998
|
+
f" {i+1}. [{r.stat_id}] {r.stat_name} ({r.org_name})"
|
|
999
|
+
for i, r in enumerate(candidates[:10])
|
|
1000
|
+
])
|
|
1001
|
+
raw_claim = (query.extra_params or {}).get("raw_claim", "")
|
|
1002
|
+
prompt = _AGENT_RETRY_PROMPT.format(
|
|
1003
|
+
claim_text=raw_claim[:200] or query.keyword,
|
|
1004
|
+
stat_id=prev_stat_id,
|
|
1005
|
+
prev_params=json.dumps(prev_params, ensure_ascii=False)[:300],
|
|
1006
|
+
error=error[:200],
|
|
1007
|
+
candidates=candidate_text,
|
|
1008
|
+
)
|
|
1009
|
+
logger.info(
|
|
1010
|
+
"[LLM 진입 직전 | _agent_retry_params] claim=%r | 실패 stat_id=%s | prev_params=%s | error=%r | 후보(%d개):\n%s",
|
|
1011
|
+
raw_claim[:200] or query.keyword,
|
|
1012
|
+
prev_stat_id,
|
|
1013
|
+
json.dumps(prev_params, ensure_ascii=False)[:300],
|
|
1014
|
+
error[:200],
|
|
1015
|
+
len(candidates),
|
|
1016
|
+
candidate_text,
|
|
1017
|
+
)
|
|
1018
|
+
try:
|
|
1019
|
+
result = await llm.generate_json(
|
|
1020
|
+
prompt=prompt,
|
|
1021
|
+
system_prompt="한국 통계 전문가. JSON으로만 답하세요.",
|
|
1022
|
+
model_tier="light",
|
|
1023
|
+
)
|
|
1024
|
+
return result
|
|
1025
|
+
except Exception as e:
|
|
1026
|
+
logger.debug(f"Agent retry 실패: {e}")
|
|
1027
|
+
return None
|
|
1028
|
+
|
|
1029
|
+
# ── 기존 search() 유지 (catalog_search 폴백용) ───────────────────────────
|
|
1030
|
+
|
|
1031
|
+
async def _agent_retry_with_rotation(
|
|
1032
|
+
self,
|
|
1033
|
+
query: ConnectorQuery,
|
|
1034
|
+
remaining: list[StatRecord],
|
|
1035
|
+
tried_log: list[dict],
|
|
1036
|
+
) -> dict[str, Any] | None:
|
|
1037
|
+
"""
|
|
1038
|
+
[v4 김예슬] 실패한 후보 제외하고 남은 후보에서 재선택.
|
|
1039
|
+
|
|
1040
|
+
ReAct Observation:
|
|
1041
|
+
"DT_1BC0501 → 데이터 없음"
|
|
1042
|
+
"DT_705003 → err=30"
|
|
1043
|
+
→ Thought: "산업 취업자 통계는 안 됨, 경제활동인구 통계로 시도"
|
|
1044
|
+
→ Action: 다음 stat_id 선택
|
|
1045
|
+
"""
|
|
1046
|
+
if not remaining:
|
|
1047
|
+
return None
|
|
1048
|
+
|
|
1049
|
+
try:
|
|
1050
|
+
from structverify.utils.llm_client import LLMClient
|
|
1051
|
+
llm = LLMClient(config=self.config.get("llm", {}))
|
|
1052
|
+
except ImportError:
|
|
1053
|
+
return None
|
|
1054
|
+
|
|
1055
|
+
# 실패 이력 텍스트
|
|
1056
|
+
tried_text = "\n".join([
|
|
1057
|
+
f" - [{t['stat_id']}] {t['stat_name']} → {t['error']}"
|
|
1058
|
+
for t in tried_log
|
|
1059
|
+
]) or " (없음)"
|
|
1060
|
+
|
|
1061
|
+
# 남은 후보 텍스트
|
|
1062
|
+
remaining_text = "\n".join([
|
|
1063
|
+
f" {i+1}. [{r.stat_id}] {r.stat_name} ({r.org_name}) "
|
|
1064
|
+
f"[{r.metadata.get('category_path', '')}]"
|
|
1065
|
+
for i, r in enumerate(remaining[:10])
|
|
1066
|
+
])
|
|
1067
|
+
|
|
1068
|
+
raw_claim = (query.extra_params or {}).get("raw_claim", "")
|
|
1069
|
+
prompt = _AGENT_RETRY_PROMPT.format(
|
|
1070
|
+
claim_text=raw_claim[:200] or query.keyword,
|
|
1071
|
+
tried_list=tried_text,
|
|
1072
|
+
remaining_candidates=remaining_text,
|
|
1073
|
+
)
|
|
1074
|
+
logger.info(
|
|
1075
|
+
"[LLM 진입 직전 | _agent_retry_with_rotation] claim=%r | 실패 이력(%d개):\n%s\n남은 후보(%d개):\n%s",
|
|
1076
|
+
raw_claim[:200] or query.keyword,
|
|
1077
|
+
len(tried_log),
|
|
1078
|
+
tried_text,
|
|
1079
|
+
len(remaining),
|
|
1080
|
+
remaining_text,
|
|
1081
|
+
)
|
|
1082
|
+
|
|
1083
|
+
try:
|
|
1084
|
+
result = await llm.generate_json(
|
|
1085
|
+
prompt=prompt,
|
|
1086
|
+
system_prompt="한국 통계 전문가. 이미 실패한 통계표는 절대 다시 선택하지 마세요. JSON으로만 답하세요.",
|
|
1087
|
+
model_tier="light",
|
|
1088
|
+
)
|
|
1089
|
+
if result and result.get("stat_id"):
|
|
1090
|
+
logger.info(
|
|
1091
|
+
f"Agent 재선택: [{result['stat_id']}] "
|
|
1092
|
+
f"{result.get('reason', '')}"
|
|
1093
|
+
)
|
|
1094
|
+
return result
|
|
1095
|
+
except Exception as e:
|
|
1096
|
+
logger.debug(f"Agent 재선택 실패: {e}")
|
|
1097
|
+
return None
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
async def _agent_simplify_keyword(
|
|
1101
|
+
self, query: ConnectorQuery, tried_log: list[dict]
|
|
1102
|
+
) -> str | None:
|
|
1103
|
+
"""LLM Agent가 실패 이력을 보고 검색어를 재생성"""
|
|
1104
|
+
try:
|
|
1105
|
+
from structverify.utils.llm_client import LLMClient
|
|
1106
|
+
llm = LLMClient(config=self.config.get("llm", {}))
|
|
1107
|
+
except ImportError:
|
|
1108
|
+
return None
|
|
1109
|
+
|
|
1110
|
+
tried_text = "\n".join([
|
|
1111
|
+
f" - {t['stat_name']} → {t['error']}"
|
|
1112
|
+
for t in tried_log
|
|
1113
|
+
]) or " (없음)"
|
|
1114
|
+
|
|
1115
|
+
raw_claim = (query.extra_params or {}).get("raw_claim", "")
|
|
1116
|
+
prompt = f"""KOSIS 통계표 검색이 실패했습니다. 검색어를 바꿔서 재시도하려 합니다.
|
|
1117
|
+
|
|
1118
|
+
검증 주장: "{raw_claim[:200]}"
|
|
1119
|
+
기존 검색어: "{query.keyword}"
|
|
1120
|
+
실패한 테이블들:
|
|
1121
|
+
{tried_text}
|
|
1122
|
+
|
|
1123
|
+
원래 검색어로는 관련 테이블을 찾지 못했습니다.
|
|
1124
|
+
KOSIS 통계표 이름에 실제로 들어갈 법한 더 단순하고 핵심적인 검색어 1~2단어를 제안하세요.
|
|
1125
|
+
|
|
1126
|
+
예시:
|
|
1127
|
+
"연평균 기온 순위" → "기온"
|
|
1128
|
+
"청년 쉬었음 비율 변화" → "경제활동인구"
|
|
1129
|
+
"평균 최저기온 및 평균 최고기온" → "기온"
|
|
1130
|
+
|
|
1131
|
+
검색어만 답하세요 (설명 없이):"""
|
|
1132
|
+
|
|
1133
|
+
try:
|
|
1134
|
+
result = await llm.generate(
|
|
1135
|
+
prompt=prompt,
|
|
1136
|
+
system_prompt="KOSIS 통계 검색 전문가. 검색어만 답하세요.",
|
|
1137
|
+
model_tier="light",
|
|
1138
|
+
)
|
|
1139
|
+
keyword = result.strip().strip('"\'')
|
|
1140
|
+
if keyword and len(keyword) >= 2:
|
|
1141
|
+
return keyword
|
|
1142
|
+
except Exception as e:
|
|
1143
|
+
logger.debug(f"Agent 검색어 재생성 실패: {e}")
|
|
1144
|
+
return None
|
|
1145
|
+
async def search(self, query: ConnectorQuery, top_k: int = 10) -> list[StatRecord]:
|
|
1146
|
+
# [2026-05-27] top_k 전달 — kosis_source가 CatalogSearchTool의 top_k 제어 가능.
|
|
1147
|
+
# 기본 10 유지로 기존 호출 안전.
|
|
1148
|
+
return await self.catalog.search(query, top_k=top_k)
|
|
1149
|
+
|
|
1150
|
+
async def fetch(self, stat_id: str, params: dict[str, Any]) -> StatData:
|
|
1151
|
+
"""BaseConnector 인터페이스 유지 (search_and_fetch 내부에서 직접 사용)"""
|
|
1152
|
+
stat_rec = params.get("stat_record") or StatRecord(
|
|
1153
|
+
stat_id=stat_id, stat_name=stat_id, org_name="", # ★ ADD org_name=""
|
|
1154
|
+
)
|
|
1155
|
+
query = params.get("query") or ConnectorQuery(keyword=stat_id)
|
|
1156
|
+
prd_se = params.get("prdSe", "Y")
|
|
1157
|
+
sp = params.get("startPrdDe", "")
|
|
1158
|
+
ep = params.get("endPrdDe", "")
|
|
1159
|
+
|
|
1160
|
+
# [P34 2026-05-22] dim_overrides — params에 들어와 있으면 _fetch_with_retry로 전달.
|
|
1161
|
+
# 호출자(KOSISDataSource)가 dimension_resolver로 결정한 itmId/objL.
|
|
1162
|
+
_dim_ov = params.get("dim_overrides") if isinstance(params.get("dim_overrides"), dict) else None
|
|
1163
|
+
|
|
1164
|
+
result = await self._fetch_with_retry(
|
|
1165
|
+
stat_id=stat_id,
|
|
1166
|
+
stat_rec=stat_rec,
|
|
1167
|
+
query=query,
|
|
1168
|
+
prd_se_hint=prd_se,
|
|
1169
|
+
start_prd_de=sp,
|
|
1170
|
+
end_prd_de=ep,
|
|
1171
|
+
dim_overrides=_dim_ov,
|
|
1172
|
+
)
|
|
1173
|
+
return result or StatData(
|
|
1174
|
+
stat_id=stat_id, stat_name=stat_id, values={},
|
|
1175
|
+
raw_response={"error": "fetch_failed"},
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
def to_graph_nodes(self, data: StatData) -> list[GraphNode]:
|
|
1179
|
+
return [GraphNode(
|
|
1180
|
+
node_id=f"evidence:{data.stat_id}",
|
|
1181
|
+
node_type=GraphNodeType.EVIDENCE,
|
|
1182
|
+
label=data.stat_name,
|
|
1183
|
+
properties=data.values,
|
|
1184
|
+
)]
|
|
1185
|
+
|
|
1186
|
+
def tag_provenance(self, data: StatData, query: ConnectorQuery) -> ProvenanceRecord:
|
|
1187
|
+
return ProvenanceRecord(
|
|
1188
|
+
source_connector="KOSIS",
|
|
1189
|
+
source_id=data.stat_id,
|
|
1190
|
+
query_used=query.keyword,
|
|
1191
|
+
raw_snapshot=data.raw_response,
|
|
1192
|
+
)
|