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.
Files changed (168) hide show
  1. structverify/__init__.py +83 -0
  2. structverify/adaptation/__init__.py +0 -0
  3. structverify/adaptation/adapter_trainer.py +341 -0
  4. structverify/adaptation/feedback_store.py +31 -0
  5. structverify/adaptation/kosis_crawler.py +317 -0
  6. structverify/adaptation/sample_builder.py +149 -0
  7. structverify/adaptation/synthetic_generator.py +320 -0
  8. structverify/adaptation/update_embeddings.py +178 -0
  9. structverify/agent/__init__.py +21 -0
  10. structverify/agent/builder_agent.py +226 -0
  11. structverify/agent/conformance_agent.py +171 -0
  12. structverify/agent/dependency_planner.py +151 -0
  13. structverify/agent/indexing_agent.py +153 -0
  14. structverify/agent/indexing_planner.py +169 -0
  15. structverify/agent/integration_example.py +182 -0
  16. structverify/agent/loop.py +1165 -0
  17. structverify/agent/memory.py +207 -0
  18. structverify/agent/planner.py +817 -0
  19. structverify/agent/prompts/__init__.py +15 -0
  20. structverify/agent/prompts/planner_prompts.py +219 -0
  21. structverify/agent/prompts/reflect_prompts.py +387 -0
  22. structverify/agent/reflect.py +227 -0
  23. structverify/agent/runtime_agent.py +1272 -0
  24. structverify/agent/schemas.py +262 -0
  25. structverify/agent/source_profiler.py +229 -0
  26. structverify/agent/tools/__init__.py +64 -0
  27. structverify/agent/tools/base.py +222 -0
  28. structverify/agent/tools/calculate.py +244 -0
  29. structverify/agent/tools/catalog_search.py +859 -0
  30. structverify/agent/tools/deep_explore.py +293 -0
  31. structverify/agent/tools/explore_catalog.py +423 -0
  32. structverify/agent/tools/fetch_evidence.py +922 -0
  33. structverify/agent/tools/finish.py +423 -0
  34. structverify/agent/tools/meta_explore.py +267 -0
  35. structverify/agent/tools/query_rewriter.py +134 -0
  36. structverify/agent/tools/read_original.py +144 -0
  37. structverify/agent/tools/replan.py +365 -0
  38. structverify/agent/workspace.py +958 -0
  39. structverify/api.py +804 -0
  40. structverify/config/default.yaml +350 -0
  41. structverify/core/__init__.py +0 -0
  42. structverify/core/config_loader.py +30 -0
  43. structverify/core/pipeline.py +280 -0
  44. structverify/core/schemas.py +362 -0
  45. structverify/detection/__init__.py +26 -0
  46. structverify/detection/_config.py +163 -0
  47. structverify/detection/_llm.py +24 -0
  48. structverify/detection/candidate/__init__.py +1 -0
  49. structverify/detection/candidate/heuristic.py +60 -0
  50. structverify/detection/candidate/llm.py +51 -0
  51. structverify/detection/candidate_scorer.py +81 -0
  52. structverify/detection/claim_detector.py +164 -0
  53. structverify/detection/claims/__init__.py +1 -0
  54. structverify/detection/claims/worthiness.py +142 -0
  55. structverify/detection/domain/__init__.py +1 -0
  56. structverify/detection/domain/classify.py +84 -0
  57. structverify/detection/domain/preview.py +36 -0
  58. structverify/detection/domain/registry.py +99 -0
  59. structverify/detection/domain_classifier.py +75 -0
  60. structverify/detection/prompts/__init__.py +1 -0
  61. structverify/detection/prompts/candidate.py +38 -0
  62. structverify/detection/prompts/claim_worthiness.py +48 -0
  63. structverify/detection/prompts/domain.py +41 -0
  64. structverify/detection/prompts/schema.py +508 -0
  65. structverify/detection/prompts_loader.py +167 -0
  66. structverify/detection/schema/__init__.py +1 -0
  67. structverify/detection/schema/expand.py +83 -0
  68. structverify/detection/schema/induce.py +441 -0
  69. structverify/detection/schema/regenerate.py +162 -0
  70. structverify/detection/schema/temporal_hints.py +130 -0
  71. structverify/detection/schema/validate.py +193 -0
  72. structverify/detection/schema_inductor.py +112 -0
  73. structverify/detection/synthetic_generator.py +270 -0
  74. structverify/explanation/__init__.py +0 -0
  75. structverify/explanation/_config.py +18 -0
  76. structverify/explanation/_llm.py +25 -0
  77. structverify/explanation/explainer.py +183 -0
  78. structverify/explanation/fallback.py +29 -0
  79. structverify/explanation/formatters.py +75 -0
  80. structverify/explanation/prompts/__init__.py +1 -0
  81. structverify/explanation/prompts/match.py +27 -0
  82. structverify/explanation/prompts/mismatch.py +20 -0
  83. structverify/explanation/prompts/multihop.py +16 -0
  84. structverify/explanation/prompts/unverifiable.py +17 -0
  85. structverify/graph/__init__.py +0 -0
  86. structverify/graph/claim_graph.py +226 -0
  87. structverify/graph/document_graph.py +487 -0
  88. structverify/graph/graph_builder.py +238 -0
  89. structverify/graph/graph_multihop.py +335 -0
  90. structverify/graph/graph_store.py +281 -0
  91. structverify/graph/provenance.py +52 -0
  92. structverify/memory/__init__.py +44 -0
  93. structverify/memory/agent_memory.py +142 -0
  94. structverify/memory/embedder.py +69 -0
  95. structverify/memory/exemplar_store.py +241 -0
  96. structverify/memory/normalizer.py +91 -0
  97. structverify/memory/schema.py +119 -0
  98. structverify/memory/storage/__init__.py +29 -0
  99. structverify/memory/storage/jsonl_store.py +117 -0
  100. structverify/memory/working_memory.py +370 -0
  101. structverify/preprocessing/Dockerfile.scraper +27 -0
  102. structverify/preprocessing/__init__.py +0 -0
  103. structverify/preprocessing/extractor.py +574 -0
  104. structverify/preprocessing/pdf/__init__.py +16 -0
  105. structverify/preprocessing/pdf/fields.py +95 -0
  106. structverify/preprocessing/pdf/markdown.py +107 -0
  107. structverify/preprocessing/pdf/models.py +34 -0
  108. structverify/preprocessing/pdf/ocr.py +172 -0
  109. structverify/preprocessing/pdf/pipeline.py +74 -0
  110. structverify/preprocessing/pdf/reader.py +119 -0
  111. structverify/preprocessing/pdf/scoring.py +61 -0
  112. structverify/preprocessing/scraper_sandbox.py +561 -0
  113. structverify/preprocessing/segmenter.py +48 -0
  114. structverify/preprocessing/sir_builder.py +240 -0
  115. structverify/progress.py +591 -0
  116. structverify/retrieval/__init__.py +0 -0
  117. structverify/retrieval/base.py +208 -0
  118. structverify/retrieval/base_connector.py +85 -0
  119. structverify/retrieval/catalog_ranker.py +300 -0
  120. structverify/retrieval/catalog_search.py +583 -0
  121. structverify/retrieval/chunking.py +92 -0
  122. structverify/retrieval/custom_csv_source.py +386 -0
  123. structverify/retrieval/custom_db_source.py +396 -0
  124. structverify/retrieval/custom_docs_source.py +152 -0
  125. structverify/retrieval/dimension_resolver.py +281 -0
  126. structverify/retrieval/evidence_subgraph.py +63 -0
  127. structverify/retrieval/kosis_connector.py +1192 -0
  128. structverify/retrieval/kosis_relevance.py +142 -0
  129. structverify/retrieval/kosis_source.py +1541 -0
  130. structverify/retrieval/query_builder.py +72 -0
  131. structverify/retrieval/registry.py +133 -0
  132. structverify/retrieval/relevance_judge.py +141 -0
  133. structverify/retrieval/row_matcher.py +267 -0
  134. structverify/storage/__init__.py +0 -0
  135. structverify/storage/db_manager.py +157 -0
  136. structverify/storage/dwh_manager.py +92 -0
  137. structverify/storage/init_db.py +99 -0
  138. structverify/storage/raw_storage.py +29 -0
  139. structverify/training/__init__.py +26 -0
  140. structverify/training/curator.py +124 -0
  141. structverify/training/dataset.py +134 -0
  142. structverify/training/doctor.py +99 -0
  143. structverify/training/evalgate.py +96 -0
  144. structverify/training/generate.py +101 -0
  145. structverify/training/loop.py +116 -0
  146. structverify/training/recipe/train_mlx.py +99 -0
  147. structverify/training/recipe/train_qlora.py +104 -0
  148. structverify/training/tasks.py +79 -0
  149. structverify/utils/__init__.py +0 -0
  150. structverify/utils/embedding_client.py +248 -0
  151. structverify/utils/llm_client.py +809 -0
  152. structverify/utils/logger.py +81 -0
  153. structverify/verification/__init__.py +0 -0
  154. structverify/verification/_config.py +45 -0
  155. structverify/verification/adapters.py +405 -0
  156. structverify/verification/conformance.py +117 -0
  157. structverify/verification/decide_verdict.py +216 -0
  158. structverify/verification/decide_verdict_agent.py +454 -0
  159. structverify/verification/growth_diff.py +267 -0
  160. structverify/verification/row_match.py +345 -0
  161. structverify/verification/units.py +64 -0
  162. structverify/verification/verdict_thresholds.py +232 -0
  163. structverify/verification/verifier.py +84 -0
  164. structverify-0.3.0.dist-info/METADATA +903 -0
  165. structverify-0.3.0.dist-info/RECORD +168 -0
  166. structverify-0.3.0.dist-info/WHEEL +5 -0
  167. structverify-0.3.0.dist-info/licenses/LICENSE +21 -0
  168. 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
+ )