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,859 @@
1
+ """
2
+ structverify.agent.tools.catalog_search — 카탈로그 검색 Tool.
3
+
4
+ Agent가 *키워드 → 데이터 표/지표 후보*를 찾을 때 호출.
5
+
6
+ 작동:
7
+ 1. context.datasources에서 source 선택 (기본: default_source)
8
+ 2. source.search_catalog(query, category, top_k) 호출
9
+ 3. 후보 리스트 반환 + workspace observation 저장
10
+
11
+ source는 BaseDataSource 인터페이스를 구현한 *어떤 것이든* 사용 가능 (KOSIS, custom_csv, ...).
12
+ Phase B에서는 *추상 인터페이스만* — 실제 KOSIS DataSource 구현은 Phase D에서 wiring.
13
+
14
+ 호출 예:
15
+ input = {"query": "출생아 수 4월", "category": ["인구", "출생"], "top_k": 5}
16
+ → 5개 후보 candidates: [{id, name, score, ...}, ...]
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from typing import Any
22
+ from structverify.utils.logger import get_logger
23
+
24
+ from ..schemas import ActionType
25
+ from .base import ToolBase, ToolContext, ToolResult, register_tool
26
+
27
+ logger = get_logger(__name__)
28
+
29
+
30
+ def _read_last_explore_categories(workspace, claim_id, top_n: int = 2) -> list[str]:
31
+ """[R1.5] 같은 claim의 직전 explore_catalog observation에서 top N 카테고리 추출.
32
+
33
+ explore_catalog가 만든 observation은 name이 'iter{NNN}_explore_catalog' 형식.
34
+ 가장 최근(iter 큰) 것을 골라서 categories[].category_label top_n개 반환.
35
+ LLM이 catalog_search에 category를 안 넘기거나 KOSIS 어휘와 안 맞는 자유어를
36
+ 넘겨도, 시스템이 임베딩으로 찾은 정확한 KOSIS 카테고리를 보강할 수 있도록.
37
+ """
38
+ if workspace is None or not claim_id:
39
+ return []
40
+ try:
41
+ names = workspace.list_observations(claim_id)
42
+ except Exception:
43
+ return []
44
+ # iter 큰(가장 최근) explore observation 우선
45
+ explore_names = sorted(
46
+ [n for n in names if "explore_catalog" in n.lower()],
47
+ reverse=True,
48
+ )
49
+ if not explore_names:
50
+ return []
51
+ for name in explore_names:
52
+ data = workspace.read_observation(claim_id, name)
53
+ if not isinstance(data, dict):
54
+ continue
55
+ cats = (data.get("output") or {}).get("categories") or []
56
+ labels: list[str] = []
57
+ for c in cats[:top_n]:
58
+ if not isinstance(c, dict):
59
+ continue
60
+ lbl = c.get("category_label")
61
+ if lbl:
62
+ labels.append(str(lbl))
63
+ if labels:
64
+ return labels
65
+ return []
66
+
67
+
68
+ @register_tool(ActionType.CATALOG_SEARCH)
69
+ class CatalogSearchTool(ToolBase):
70
+ """데이터 소스 카탈로그(표 목록) 검색.
71
+
72
+ 여러 source 중 *config.data_sources.default_source* (또는 input.source 명시) 사용.
73
+ DataSource 추상화 덕분에 *KOSIS 외 회사 자체 DB/CSV*도 동일 인터페이스로 검색.
74
+ """
75
+
76
+ name = ActionType.CATALOG_SEARCH
77
+ description = (
78
+ "데이터 소스의 표/지표 카탈로그를 키워드로 검색. "
79
+ "한 번 시도해서 잘못된 표가 나오면 *다른 검색어*로 재시도 권장. "
80
+ "memory에 *이미 시도한 검색어* 있으면 중복 금지."
81
+ )
82
+ input_schema = {
83
+ "query": "검색 키워드 (한국어, 3-6단어 권장). 예: '출생아 수 인구동향'",
84
+ "category": "(선택) 분류 힌트 리스트. 예: ['인구', '출생']",
85
+ "top_k": "(선택) 최대 후보 수. 기본 5",
86
+ "source": "(선택) 데이터 소스 이름. 기본은 config.data_sources.default_source",
87
+ "force_explore": (
88
+ "(선택) true면 catalog top1 점수와 무관하게 deep_explore "
89
+ "(top 표들에서 sample row 가져와 LLM이 row 단서 기반 reasoning) "
90
+ "강제 발동. fetch_evidence가 row를 못 찾아 회복이 필요할 때 사용."
91
+ ),
92
+ "explore_mode": (
93
+ "(선택) force_explore=true 시 사용할 방식. 'meta'(기본, 권장): KOSIS "
94
+ "getMeta(ITM/OBJ) 호출로 표 항목/분류 list만 받아 LLM이 정답 표 식별 "
95
+ "(빠름, ~표당 1초). 'row_preview': 표 전체 row preview 받아 LLM이 외삽 "
96
+ "reasoning (느림, 표당 22초). 'meta'가 정답 표 직접 식별에 유리."
97
+ ),
98
+ "query_rewrite": (
99
+ "(선택) true면 catalog_search 실행 *전에* LLM이 query를 표 이름 친화 "
100
+ "어휘로 변형(예: '체외 충격파 쇄석술 장비 수' → '시군구별 의료장비'). "
101
+ "원본 query에 row-level keyword만 있어 catalog 후보에 정답 표가 안 들어올 "
102
+ "때 사용. 각 변형으로 search → 합집합 반환."
103
+ ),
104
+ }
105
+
106
+ async def execute(
107
+ self,
108
+ input_data: dict[str, Any],
109
+ context: ToolContext,
110
+ ) -> ToolResult:
111
+ query = (input_data.get("query") or "").strip()
112
+ if not query:
113
+ return ToolResult(
114
+ output={},
115
+ summary="실패: query 비어있음",
116
+ success=False,
117
+ error="query는 비어있을 수 없습니다.",
118
+ )
119
+
120
+ category = input_data.get("category") or None
121
+ if category is not None and not isinstance(category, list):
122
+ category = [str(category)]
123
+
124
+ try:
125
+ top_k = int(input_data.get("top_k") or 15)
126
+ except (TypeError, ValueError):
127
+ top_k = 10
128
+ # [P29' 2026-05-22] 기본 5 → 15. catalog 임베딩이 표 이름만 보는 한계로
129
+ # row-level keyword 쿼리(예: "체외 충격파 쇄석술 장비")는 정답 표가
130
+ # cosine 6~15위에 깔리는 경우가 있어 top 5만 노출하면 reflect/deep_explore
131
+ # 모두 정답에 접근 불가. 19개 합집합을 거의 그대로 노출해도 prompt
132
+ # context 부담은 적당하고 정답 진입률 ↑.
133
+ top_k = max(15, min(top_k, 20))
134
+
135
+ # ── [패치 R1.5] explore_catalog 결과 자동 활용 ─────────────────
136
+ # LLM이 explore_catalog 결과를 무시하고 자기 머릿속 카테고리 어휘를
137
+ # 그대로 catalog_search에 넘기는 케이스가 잦다 (예: ['기후 변화', '날씨 정보']).
138
+ # 직전 explore observation을 자동 읽어, LLM 카테고리에 explore가 추천한
139
+ # top 2 카테고리를 union으로 추가. 임베딩 의미 검색이 찾아준 정확한
140
+ # KOSIS 카테고리 어휘 (예: '기상관측통계')가 ILIKE 필터에 들어가
141
+ # 무관한 카테고리만 봐서 헛돌이가 되는 걸 방지.
142
+ try:
143
+ explored_cats = _read_last_explore_categories(
144
+ context.workspace, context.claim_id, top_n=2,
145
+ )
146
+ except Exception as e:
147
+ logger.debug(f"[catalog_search] explore observation 읽기 실패: {e}")
148
+ explored_cats = []
149
+ if explored_cats:
150
+ existing = set(category or [])
151
+ new_cats = [c for c in explored_cats if c and c not in existing]
152
+ if new_cats:
153
+ category = list(category or []) + new_cats
154
+ logger.info(
155
+ f"[catalog_search] 직전 explore_catalog top 카테고리 자동 추가: "
156
+ f"{new_cats} → 최종 category={category}"
157
+ )
158
+
159
+ # source 선택
160
+ ds_config = context.config.get("data_sources", {}) if context.config else {}
161
+ # default_source 미지정이면 enabled 첫 소스로 폴백 (하드코딩 kosis 방지)
162
+ default_source = ds_config.get("default_source") or (ds_config.get("enabled") or ["kosis"])[0]
163
+ source_name = (input_data.get("source") or default_source).strip()
164
+ # LLM이 미등록 소스(프롬프트 잔재 'kosis' 등)를 지정하면 사용 가능한 소스로 강제.
165
+ if context.datasources and source_name not in context.datasources:
166
+ _avail = list(context.datasources.keys())
167
+ source_name = (
168
+ default_source if default_source in context.datasources
169
+ else (_avail[0] if _avail else source_name)
170
+ )
171
+
172
+ # DataSource 인스턴스 찾기
173
+ source = context.datasources.get(source_name) if context.datasources else None
174
+ if source is None:
175
+ available = list(context.datasources.keys()) if context.datasources else []
176
+ return ToolResult(
177
+ output={"requested_source": source_name, "available": available},
178
+ summary=f"실패: source={source_name!r} 등록 안 됨. 가능: {available}",
179
+ success=False,
180
+ error=(
181
+ f"DataSource '{source_name}'이 context.datasources에 없습니다. "
182
+ f"가능한 source: {available}. config.data_sources.enabled 확인."
183
+ ),
184
+ )
185
+
186
+ # ── [P30 2026-05-22] query_rewrite — 검색 *전*에 LLM이 query 변형 ──
187
+ # query가 row-level keyword면 catalog 표 이름과 매칭이 약함. LLM이
188
+ # 표 이름 친화 어휘로 변형 후 각 변형으로 검색 → 합집합. 원본 + 변형
189
+ # 둘 다 시도해 정답 표 진입률 ↑.
190
+ _queries_to_search: list[str] = [query]
191
+ _qr_applied: list[str] = []
192
+ if bool(input_data.get("query_rewrite")) and context.claim is not None:
193
+ try:
194
+ from .query_rewriter import rewrite_query as _rewrite_query
195
+ _variations = await _rewrite_query(
196
+ query=query, claim=context.claim, config=context.config,
197
+ )
198
+ for v in _variations:
199
+ if v and v not in _queries_to_search:
200
+ _queries_to_search.append(v)
201
+ _qr_applied.append(v)
202
+ if _qr_applied:
203
+ logger.info(
204
+ f"[catalog_search] query_rewrite 적용 — 원본={query!r} + "
205
+ f"변형 {len(_qr_applied)}개: {_qr_applied}"
206
+ )
207
+ except Exception as _e:
208
+ logger.warning(f"[catalog_search] query_rewrite 실패: {_e}")
209
+
210
+ # [P31 2026-05-22] claim에서 schema 정보 추출 → source.search_catalog의
211
+ # context로 전달. KOSIS DataSource는 이걸 ConnectorQuery.extra_params에 묶어
212
+ # _extract_category_and_keyword에 전달하므로:
213
+ # - parent_path 있으면 LLM 카테고리 추출 *skip*하고 KOSIS 어휘 그대로 사용
214
+ # - raw_claim 있으면 LLM이 전체 문장 보고 더 정확한 카테고리/검색어
215
+ # claim/schema가 없으면 None인 채로 안전하게 동작.
216
+ _ctx_for_source: dict[str, Any] = {}
217
+ _claim = context.claim
218
+ if _claim is not None:
219
+ _claim_text = getattr(_claim, "claim_text", None)
220
+ if _claim_text:
221
+ _ctx_for_source["raw_claim"] = str(_claim_text)[:200]
222
+ _schema = getattr(_claim, "schema", None)
223
+ if _schema is not None:
224
+ # [2026-05-27 Fix B] time_period 포함 — catalog_search에서 시점 토큰
225
+ # 기반 union 검색을 발동시키기 위해 schema.time_period를 context에
226
+ # 실어 보낸다. kosis_source._make_query가 ConnectorQuery.time_period로
227
+ # 매핑하고 CatalogSearch가 "embedding_text + year"로 추가 pgvector
228
+ # 검색을 돌려 dedup union.
229
+ for _k in ("parent_path", "population", "indicator", "time_period"):
230
+ _v = getattr(_schema, _k, None)
231
+ if _v:
232
+ _ctx_for_source[_k] = str(_v)
233
+
234
+ # 검색 실행 — 단일 query 또는 다중 query 합집합
235
+ try:
236
+ if len(_queries_to_search) == 1:
237
+ candidates = await source.search_catalog(
238
+ query=query, category=category, top_k=top_k,
239
+ context=_ctx_for_source or None,
240
+ )
241
+ else:
242
+ import asyncio as _aio
243
+ _all = await _aio.gather(
244
+ *[
245
+ source.search_catalog(
246
+ query=q, category=category, top_k=top_k,
247
+ context=_ctx_for_source or None,
248
+ )
249
+ for q in _queries_to_search
250
+ ],
251
+ return_exceptions=True,
252
+ )
253
+ # 합집합 + 중복 제거 (id 기준)
254
+ candidates = []
255
+ _seen: set[str] = set()
256
+ for res in _all:
257
+ if isinstance(res, Exception):
258
+ continue
259
+ for c in (res or []):
260
+ cid = c.get("id") if hasattr(c, "get") else None
261
+ if not cid or cid in _seen:
262
+ continue
263
+ _seen.add(cid)
264
+ candidates.append(c)
265
+ # 점수순 정렬 (각 query의 score를 그대로 사용)
266
+ candidates.sort(
267
+ key=lambda c: float(c.get("score", 0.0) or 0.0),
268
+ reverse=True,
269
+ )
270
+ except Exception as e:
271
+ logger.exception(f"[catalog_search] source={source_name} query={query!r} 실패")
272
+ return ToolResult(
273
+ output={"source": source_name, "query": query},
274
+ summary=f"실패: catalog_search({source_name}) — {type(e).__name__}: {e}",
275
+ success=False,
276
+ error=f"{type(e).__name__}: {e}",
277
+ )
278
+
279
+ # 결과 정규화 (CatalogCandidate dict 호환)
280
+ normalized: list[dict[str, Any]] = []
281
+ for c in candidates or []:
282
+ # dict이거나 dict-like
283
+ d = dict(c) if hasattr(c, "items") else {}
284
+ normalized.append(d)
285
+
286
+ # ── [P33b 2026-05-22] 이전 fetch 실패 stat_id 제외 ────────────
287
+ # 같은 claim에서 catalog_search → fetch_evidence가 *관련성 거부* 또는
288
+ # *row 매칭 실패*로 None 반환한 표는 *다음 catalog_search에서도 다시
289
+ # 후보로 올라옴* (KOSIS 검색이 deterministic). reflect가 thought엔
290
+ # "다른 표 시도"라 쓰지만 input은 거의 같은 query라 결국 같은 5개
291
+ # 후보 → 같은 표 반복 거부 → 무한 헛돌이.
292
+ # workspace에 *이 claim에서 실패한 stat_id 목록*을 두고 결과에서 제거.
293
+ _failed_count = 0
294
+ try:
295
+ _failed_ids = set(
296
+ context.workspace.read_failed_stat_ids(context.claim_id)
297
+ if context.workspace else []
298
+ )
299
+ except Exception:
300
+ _failed_ids = set()
301
+ if _failed_ids:
302
+ _before = len(normalized)
303
+ normalized = [c for c in normalized if c.get("id") not in _failed_ids]
304
+ _failed_count = _before - len(normalized)
305
+ if _failed_count > 0:
306
+ logger.info(
307
+ f"[catalog_search] 이전 fetch 실패 stat_id {_failed_count}개 제외 "
308
+ f"({sorted(_failed_ids)[:5]}{'...' if len(_failed_ids) > 5 else ''}) "
309
+ f"— 같은 표 무한 반복 방지"
310
+ )
311
+
312
+ # ── [패치 D] job에서 이미 fetch 성공한 stat_id를 결과 맨 앞에 prepend ──
313
+ # 같은 KOSIS 표가 여러 지표(출생아 수/합계출산율/혼인 건수)를 같이
314
+ # 갖고 있는데 catalog는 검색어별로 다른 표를 top으로 주는 경우 대응.
315
+ # 예: '합계출산율' 검색 시 catalog top은 'DT_XNN0004(해외)'인데
316
+ # 같은 job의 다른 claim이 이미 'DT_1B8000G(국내 인구동향)'에서
317
+ # 성공했고 그 표 안에 합계출산율 row가 있으므로 이걸 1순위로
318
+ # 노출시켜 reflect agent가 fetch_evidence를 호출하게 유도.
319
+ try:
320
+ prior_ids = context.workspace.read_successful_stat_ids() if context.workspace else []
321
+ except Exception:
322
+ prior_ids = []
323
+ if prior_ids:
324
+ existing_ids = {c.get("id") for c in normalized if c.get("id")}
325
+ # [패치 D'] prepend 시 진짜 stat_name도 같이 보여줘서 LLM이
326
+ # "이 표 안에 다른 지표도 있을 가능성"을 판단할 수 있게 한다.
327
+ # workspace에 저장한 catalog observation에서 stat_id의 name을 찾아옴.
328
+ # [2026-05-21 P5] 이전 observation의 name엔 우리가 박아둔 "[...]"
329
+ # annotation이 누적될 수 있음 — 새로 prepend할 때 그걸 떼서 *원본 표 이름*만
330
+ # 가져오도록 정규화한다. 안 그러면 매 검색마다 "[표 안에 'X' 관련 ...]
331
+ # [표 안에 'Y' 관련 ...]" 식으로 거짓 힌트가 무한 누적된다.
332
+ import re as _re_local
333
+ _ANN_RE = _re_local.compile(r"\s*\[같은 job에서[^\]]*\]\s*")
334
+ def _clean_name(nm: str) -> str:
335
+ return _ANN_RE.sub("", nm or "").strip()
336
+ stat_names: dict[str, str] = {}
337
+ try:
338
+ for other_cid in context.workspace.list_claims():
339
+ for obs_name in context.workspace.list_observations(other_cid):
340
+ if "catalog_search" not in obs_name.lower():
341
+ continue
342
+ data = context.workspace.read_observation(other_cid, obs_name)
343
+ if not isinstance(data, dict):
344
+ continue
345
+ for cd in (data.get("output") or {}).get("candidates") or []:
346
+ sid = cd.get("id") if isinstance(cd, dict) else None
347
+ nm = cd.get("name") if isinstance(cd, dict) else None
348
+ if sid and nm and sid not in stat_names:
349
+ stat_names[sid] = _clean_name(nm)
350
+ if len(stat_names) > 50:
351
+ break # 충분히 많이 모음
352
+ except Exception as e:
353
+ logger.debug(f"[catalog_search] stat_name lookup 실패: {e}")
354
+
355
+ # [2026-05-21 P5] 각 prior stat_id가 *어떤 지표* 검증에 사용됐는지
356
+ # verified_facts에서 역추적. source 필드는 보통 "KOSIS:DT_..." 또는
357
+ # "kosis:DT_..." 형식이라 case-insensitive substring 매칭. 이게 있으면
358
+ # LLM이 "이 표는 출생아 수 검증에 썼던 표 — 합계출산율이랑 같은 인구동향
359
+ # 표일 가능성"을 판단할 수 있다. 거짓 "row 있을 가능성" 단언은 제거.
360
+ stat_id_to_indicators: dict[str, list[str]] = {}
361
+ try:
362
+ _facts = context.workspace.read_verified_facts() if context.workspace else []
363
+ except Exception:
364
+ _facts = []
365
+ for _f in _facts or []:
366
+ _src = str(_f.get("source") or "").lower()
367
+ _ind = str(_f.get("indicator") or "").strip()
368
+ if not _ind:
369
+ continue
370
+ for sid in prior_ids:
371
+ if sid.lower() in _src:
372
+ bucket = stat_id_to_indicators.setdefault(sid, [])
373
+ if _ind not in bucket:
374
+ bucket.append(_ind)
375
+ # sibling_evidence의 source는 "kosis:{stat_id}" 형식 — 같이 합쳐 정확도 ↑
376
+ try:
377
+ if hasattr(context.workspace, "_sibling_evidence_key"):
378
+ # 모든 sent_id를 모르므로 backend로 직접 읽기
379
+ _key = context.workspace._sibling_evidence_key()
380
+ if context.workspace.backend.exists(_key):
381
+ _sib_data = json.loads(
382
+ context.workspace.backend.read_text(_key)
383
+ )
384
+ if isinstance(_sib_data, dict):
385
+ for _sent_id, _entries in _sib_data.items():
386
+ if not isinstance(_entries, list):
387
+ continue
388
+ for _e in _entries:
389
+ if not isinstance(_e, dict):
390
+ continue
391
+ _src = str(_e.get("source") or "").lower()
392
+ _ind = str(_e.get("indicator") or "").strip()
393
+ if not _ind:
394
+ continue
395
+ for sid in prior_ids:
396
+ if sid.lower() in _src:
397
+ bucket = stat_id_to_indicators.setdefault(sid, [])
398
+ if _ind not in bucket:
399
+ bucket.append(_ind)
400
+ except Exception as e:
401
+ logger.debug(f"[catalog_search] sibling_evidence 읽기 실패 (무시): {e}")
402
+
403
+ # [P21 2026-05-22] prior_success score 완화 — 1.5 고정 X.
404
+ # 기존 1.5 박으면 *의미적으로 더 잘 맞는 catalog 결과 (score=1.0)*가
405
+ # 무조건 prior 표 뒤로 밀림. "치료 가능 사망률" claim에 의료장비 표
406
+ # (DT_35003_A7 prior)가 top이 되고 진짜 정답(DT_117049_A083 score=1.0)이
407
+ # 3순위로 깔리는 회귀 케이스 (22:54 로그).
408
+ # → prior bonus는 catalog top score 살짝 *아래*로. catalog 매칭이
409
+ # 명확하면(top score ≥ 0.9) prior는 *후순위*로 보이고, catalog가 약하면
410
+ # (top score < prior_bonus) prior가 자연스럽게 위로 올라옴.
411
+ _catalog_top_score = max(
412
+ (float(c.get("score", 0.0) or 0.0) for c in normalized),
413
+ default=0.0,
414
+ )
415
+ # prior score: catalog top score - 0.05. 단 최소 0.5는 보장 (catalog 결과
416
+ # 자체가 없거나 약할 때도 prior가 fetch 후보로 살아남도록).
417
+ _prior_score = max(_catalog_top_score - 0.05, 0.5)
418
+ prepend: list[dict[str, Any]] = []
419
+ for sid in prior_ids:
420
+ if sid in existing_ids:
421
+ continue
422
+ real_name = stat_names.get(sid, sid)
423
+ _prior_inds = stat_id_to_indicators.get(sid) or []
424
+ if _prior_inds:
425
+ _ind_label = ", ".join(f"'{x}'" for x in _prior_inds[:3])
426
+ _hint = (
427
+ f"[같은 job에서 {_ind_label} 검증에 사용된 표 — "
428
+ f"'{query}' row 존재는 fetch 시도로 확인]"
429
+ )
430
+ else:
431
+ # indicator 추적 못 한 경우 *중립적* 라벨 (거짓 단언 금지)
432
+ _hint = "[같은 job 다른 claim이 사용한 표 — 관련성 fetch로 확인]"
433
+ prepend.append({
434
+ "id": sid,
435
+ "name": f"{real_name} {_hint}",
436
+ "score": _prior_score,
437
+ "raw": {"from_job_success": True},
438
+ })
439
+ if prepend:
440
+ logger.info(
441
+ f"[catalog_search] job-success stat_id "
442
+ f"{[p['id'] for p in prepend]} prior 추가 "
443
+ f"(score={_prior_score:.3f}, catalog top={_catalog_top_score:.3f})"
444
+ )
445
+ # score 순으로 다시 정렬 — prior가 catalog top 위에 있을지 아래일지는
446
+ # score 비교에 맡김.
447
+ normalized = sorted(
448
+ prepend + normalized,
449
+ key=lambda c: float(c.get("score", 0.0) or 0.0),
450
+ reverse=True,
451
+ )
452
+
453
+ # ── [P28 2026-05-22] Deep Exploration (T1 — 사전 보강) ────────────
454
+ # catalog top1 점수가 낮거나 top1-top2 gap이 작으면, top N 표의 sample row를
455
+ # 가져와 LLM이 row 단서 기반 reasoning으로 best 표 추천. P21B와 달리:
456
+ # - top 3 (기본), 표당 row 5개, Y prdSe만 → KOSIS 부하 ↓
457
+ # - prompt: "row 단서로 더 파볼 가치 있는 표 외삽 추천" (best 단순 선택 X)
458
+ # - none_signal → output에 표시 → reflect가 query refinement 결정
459
+ # 또 force_explore=True (T2 회복용)면 점수 조건 무시하고 발동.
460
+ _cs_cfg = (context.config or {}).get("catalog_search") or {}
461
+ _dx_cfg = _cs_cfg.get("deep_explore") or {}
462
+ _dx_enabled = bool(_dx_cfg.get("enabled", False))
463
+ _force_explore = bool(input_data.get("force_explore"))
464
+ _explore_meta: dict[str, Any] | None = None
465
+
466
+ if _dx_enabled and normalized and context.claim is not None:
467
+ _low_score_thr = float(_dx_cfg.get("trigger_low_score") or 0.6)
468
+ _gap_thr = float(_dx_cfg.get("trigger_score_gap") or 0.1)
469
+ _top1_score = float(normalized[0].get("score", 0.0) or 0.0)
470
+ _top2_score = (
471
+ float(normalized[1].get("score", 0.0) or 0.0)
472
+ if len(normalized) >= 2 else 0.0
473
+ )
474
+ _t1_fire = (
475
+ _top1_score < _low_score_thr
476
+ or (_top1_score - _top2_score) < _gap_thr
477
+ )
478
+ # per-claim 발동 횟수 제한 (loop 방지)
479
+ _max_per_claim = int(_dx_cfg.get("max_per_claim") or 2)
480
+ _dx_obs_count = 0
481
+ try:
482
+ for _n in context.workspace.list_observations(context.claim_id):
483
+ if "deep_explore" in _n.lower():
484
+ _dx_obs_count += 1
485
+ except Exception:
486
+ pass
487
+
488
+ if (_force_explore or _t1_fire) and _dx_obs_count < _max_per_claim:
489
+ trigger_label = "T2/force" if _force_explore else "T1/score"
490
+ # [P30 2026-05-22] explore_mode 분기:
491
+ # - "meta" (기본): getMeta(ITM/OBJ) → 빠름 + 정확
492
+ # - "row_preview": 표 전체 row preview → 느림 + 외삽
493
+ # input의 explore_mode가 우선, 없으면 config의 default 사용.
494
+ _explore_mode = str(
495
+ input_data.get("explore_mode")
496
+ or _dx_cfg.get("explore_mode")
497
+ or "meta"
498
+ ).strip().lower()
499
+ logger.info(
500
+ f"[catalog_search] deep_explore 발동 ({trigger_label}, "
501
+ f"mode={_explore_mode}) — top1={_top1_score:.3f}, "
502
+ f"gap={_top1_score-_top2_score:.3f}, prev_calls={_dx_obs_count}"
503
+ )
504
+ try:
505
+ if _explore_mode == "meta":
506
+ from .meta_explore import meta_explore as _explore_fn
507
+ else:
508
+ from .deep_explore import deep_explore as _explore_fn
509
+ _result = await _explore_fn(
510
+ query=query,
511
+ candidates=normalized,
512
+ claim=context.claim,
513
+ source=source,
514
+ workspace=context.workspace,
515
+ config=context.config,
516
+ )
517
+ if _result.used:
518
+ _explore_meta = {
519
+ "best_table_id": _result.best_table_id,
520
+ "reasoning": _result.reasoning,
521
+ "none_signal": _result.none_signal,
522
+ "previewed_ids": _result.previewed_ids,
523
+ "trigger": trigger_label,
524
+ "mode": _explore_mode,
525
+ }
526
+ # workspace observation으로도 별도 기록 (per-claim counter용)
527
+ try:
528
+ context.workspace.write_observation(
529
+ context.claim_id,
530
+ f"iter{context.iter_num:03d}_deep_explore",
531
+ _explore_meta,
532
+ )
533
+ except Exception as _e:
534
+ logger.debug(
535
+ f"[catalog_search] deep_explore observation 저장 실패: {_e}"
536
+ )
537
+ # best가 있으면 해당 표를 normalized top으로 승격
538
+ if _result.best_table_id and not _result.none_signal:
539
+ _ids = [c.get("id", "") for c in normalized]
540
+ if _result.best_table_id in _ids:
541
+ _idx = _ids.index(_result.best_table_id)
542
+ if _idx != 0:
543
+ normalized = (
544
+ [normalized[_idx]]
545
+ + [c for i, c in enumerate(normalized) if i != _idx]
546
+ )
547
+ logger.info(
548
+ f"[catalog_search] deep_explore best="
549
+ f"{_result.best_table_id!r} → top1 승격"
550
+ )
551
+ except Exception as _e:
552
+ logger.warning(
553
+ f"[catalog_search] deep_explore 실패 (catalog 원본 유지): {_e}"
554
+ )
555
+ elif _dx_obs_count >= _max_per_claim:
556
+ logger.info(
557
+ f"[catalog_search] deep_explore skip — claim당 호출 한도 "
558
+ f"({_dx_obs_count}/{_max_per_claim})"
559
+ )
560
+
561
+ # ── [P21B 2026-05-22] Row-aware LLM rerank (P25에서 disable, P28과 별개) ───
562
+ # top N 표에 sample row 1개 fetch해 LLM이 claim과 가장 잘 매칭되는 표 선택.
563
+ # 임베딩 score만으론 "치료 가능 사망률"과 "사망률"·"의료장비"를 잘 못 구분
564
+ # 하는 케이스 (23:54 로그) 대응. P20 KOSIS cache hit이면 비용 거의 0.
565
+ _rerank_mode = str(_cs_cfg.get("rerank_mode") or "none").lower()
566
+ _rerank_top_n = int(_cs_cfg.get("rerank_top_n") or 5)
567
+ _rerank_gap = float(_cs_cfg.get("rerank_min_score_gap") or 0.15)
568
+ if (
569
+ _rerank_mode in ("row_preview", "table_name")
570
+ and len(normalized) >= 2
571
+ and context.claim is not None
572
+ ):
573
+ try:
574
+ _rerank_top = normalized[:_rerank_top_n]
575
+ _top1 = float(_rerank_top[0].get("score", 0.0) or 0.0)
576
+ _top2 = float(_rerank_top[1].get("score", 0.0) or 0.0)
577
+ if _top1 - _top2 >= _rerank_gap:
578
+ logger.info(
579
+ f"[catalog_search] rerank skip — top1({_top1:.3f}) - "
580
+ f"top2({_top2:.3f}) gap={_top1-_top2:.3f} ≥ {_rerank_gap} "
581
+ f"(LLM call 절약, top1이 이미 명확)"
582
+ )
583
+ else:
584
+ _reranked = await _row_preview_rerank(
585
+ candidates=_rerank_top,
586
+ claim=context.claim,
587
+ source=source,
588
+ workspace=context.workspace,
589
+ config=context.config,
590
+ do_row_preview=(_rerank_mode == "row_preview"),
591
+ )
592
+ if _reranked:
593
+ # rerank된 top + 남은 후보 (원래 score 순서)
594
+ _reranked_ids = {c.get("id") for c in _reranked}
595
+ _rest = [c for c in normalized if c.get("id") not in _reranked_ids]
596
+ normalized = _reranked + _rest
597
+ logger.info(
598
+ f"[catalog_search] LLM rerank 적용 — "
599
+ f"top1={normalized[0].get('id')!r}"
600
+ )
601
+ except Exception as _e:
602
+ logger.warning(f"[catalog_search] rerank 실패 (catalog 원본 유지): {_e}")
603
+
604
+ # workspace observation 저장 (raw)
605
+ try:
606
+ obs_name = f"iter{context.iter_num:03d}_catalog_search"
607
+ context.workspace.write_observation(
608
+ context.claim_id,
609
+ obs_name,
610
+ {"query": query, "category": category, "source": source_name,
611
+ "candidates": normalized},
612
+ )
613
+ except Exception as e:
614
+ logger.debug(f"[catalog_search] observation 저장 실패: {e}")
615
+
616
+ # 요약 (top 3 후보 이름)
617
+ top_names = []
618
+ for c in normalized[:3]:
619
+ cid = c.get("id", "")
620
+ cname = c.get("name", "")
621
+ score = c.get("score")
622
+ top_names.append(
623
+ f"[{cid}] {cname}" + (f" (score={score:.3f})" if isinstance(score, (int, float)) else "")
624
+ )
625
+ summary_qr = (
626
+ f" | query_rewrite +{len(_qr_applied)}" if _qr_applied else ""
627
+ )
628
+ summary = (
629
+ f"catalog_search({source_name}) query={query!r} → "
630
+ f"{len(normalized)}개 후보{summary_qr}. "
631
+ f"Top: {' | '.join(top_names) if top_names else '(없음)'}"
632
+ )
633
+
634
+ _out: dict[str, Any] = {
635
+ "source": source_name,
636
+ "query": query,
637
+ "category": category,
638
+ "candidates": normalized,
639
+ "candidate_count": len(normalized),
640
+ }
641
+ if _qr_applied:
642
+ _out["_query_rewrite"] = {
643
+ "original": query,
644
+ "variations": _qr_applied,
645
+ }
646
+ if _explore_meta is not None:
647
+ _out["_deep_explore"] = _explore_meta
648
+ # summary에도 신호 추가 — reflect가 observation summary만 봐도 알도록
649
+ if _explore_meta.get("none_signal"):
650
+ summary += (
651
+ " | deep_explore: top 표들의 sample row에서 적합한 row 없음 — "
652
+ "다른 검색어로 catalog_search 재시도 권장."
653
+ )
654
+ elif _explore_meta.get("best_table_id"):
655
+ summary += (
656
+ f" | deep_explore best={_explore_meta['best_table_id']} "
657
+ f"({_explore_meta.get('reasoning', '')[:60]})"
658
+ )
659
+
660
+ return ToolResult(
661
+ output=_out,
662
+ summary=summary,
663
+ success=True,
664
+ )
665
+
666
+
667
+ # ────────────────────────────────────────────────────────────────────
668
+ # [P21B 2026-05-22] Row-aware LLM rerank 헬퍼
669
+ # ────────────────────────────────────────────────────────────────────
670
+ import asyncio as _asyncio
671
+
672
+
673
+ async def _row_preview_rerank(
674
+ candidates: list[dict[str, Any]],
675
+ claim: Any,
676
+ source: Any,
677
+ workspace: Any,
678
+ config: dict | None,
679
+ do_row_preview: bool = True,
680
+ ) -> list[dict[str, Any]] | None:
681
+ """top N 후보에 대해 (옵션) sample row fetch + LLM rerank.
682
+
683
+ Returns:
684
+ rerank된 후보 list (best가 [0]). 실패/no-op이면 None.
685
+ """
686
+ if not candidates:
687
+ return None
688
+
689
+ # 1) (옵션) preview fetch — newEstPrdCnt=1로 KOSIS 1 row만 받아옴.
690
+ # P20 cache hit이면 즉시 반환. cold이면 KOSIS API 호출.
691
+ previews: dict[str, dict] = {}
692
+ if do_row_preview:
693
+ async def _preview(cid: str) -> tuple[str, dict | None]:
694
+ try:
695
+ ev = await source.fetch_evidence(
696
+ candidate_id=cid,
697
+ params={
698
+ "newEstPrdCnt": "1", # 가장 최근 1 시점만
699
+ "_preview": True,
700
+ },
701
+ workspace=workspace,
702
+ )
703
+ if ev is None:
704
+ return cid, None
705
+ rows = ev.get("rows") or []
706
+ first = rows[0] if rows else {}
707
+ # 핵심 컬럼만 추출 (LLM 토큰 절약)
708
+ _PICK = ("ITM_NM", "C1_NM", "C2_NM", "C3_NM", "C4_NM",
709
+ "PRD_DE", "UNIT_NM")
710
+ row_summary = {k: first.get(k) for k in _PICK if first.get(k)}
711
+ return cid, {
712
+ "stat_name": ev.get("stat_name") or "",
713
+ "sample_row": row_summary,
714
+ "rows_count": len(rows),
715
+ }
716
+ except Exception as _e:
717
+ logger.debug(f"[catalog_search.rerank] preview fetch 실패 {cid}: {_e}")
718
+ return cid, None
719
+
720
+ results = await _asyncio.gather(
721
+ *[_preview(c.get("id", "")) for c in candidates if c.get("id")],
722
+ return_exceptions=False,
723
+ )
724
+ for cid, info in results:
725
+ if info is not None:
726
+ previews[cid] = info
727
+
728
+ # 2) LLM 호출 — claim + 후보들 + preview를 던지고 best 1개 선택
729
+ from structverify.utils.llm_client import LLMClient
730
+ _llm = LLMClient(config=(config or {}).get("llm") or {})
731
+
732
+ # claim 정보 추출
733
+ _schema = getattr(claim, "schema", None)
734
+ _info = {
735
+ "indicator": (getattr(_schema, "indicator", None) or "") if _schema else "",
736
+ "time_period": (getattr(_schema, "time_period", None) or "") if _schema else "",
737
+ "population": (getattr(_schema, "population", None) or "") if _schema else "",
738
+ "unit": (getattr(_schema, "unit", None) or "") if _schema else "",
739
+ "value": (getattr(_schema, "value", None)) if _schema else None,
740
+ }
741
+
742
+ # 후보 리스트 → 프롬프트 텍스트
743
+ _cand_lines: list[str] = []
744
+ for i, c in enumerate(candidates, start=1):
745
+ cid = c.get("id", "")
746
+ cname = (c.get("name", "") or "").strip()
747
+ cscore = c.get("score")
748
+ _line = f"{i}. [{cid}] {cname}"
749
+ if isinstance(cscore, (int, float)):
750
+ _line += f" (catalog_score={cscore:.3f})"
751
+ _preview = previews.get(cid)
752
+ if _preview and _preview.get("sample_row"):
753
+ _row = _preview["sample_row"]
754
+ _row_str = ", ".join(f"{k}={v!r}" for k, v in _row.items())
755
+ _line += f"\n sample row → {_row_str}"
756
+ _line += f" | rows_count={_preview.get('rows_count')}"
757
+ _cand_lines.append(_line)
758
+
759
+ _prompt = f"""다음 사실검증 claim에 *가장 적합한 KOSIS 통계표 1개*를 선택하세요.
760
+
761
+ [Claim]
762
+ - indicator (검증 대상 지표): {_info['indicator']!r}
763
+ - population (대상 집단): {_info['population']!r}
764
+ - time_period: {_info['time_period']!r}
765
+ - unit: {_info['unit']!r}
766
+ - value: {_info['value']}
767
+
768
+ [후보 표 (catalog 검색 score 순)]
769
+ {chr(10).join(_cand_lines)}
770
+
771
+ [선택 기준]
772
+ - ITM_NM 또는 C1_NM~C4_NM 어느 컬럼에 claim의 indicator가 *직접 포함*되거나 *의미적으로 매칭*되는 표 우선.
773
+ - C1_NM/C2_NM 등이 claim.population (지역/대상)을 포함하는 표 우선.
774
+ - 표 이름이 claim과 유사해 보이더라도 sample row의 ITM_NM이 무관하면 *배제*.
775
+ (예: "치료 가능 사망률" claim에 sample row ITM_NM='의료장비'인 표는 배제)
776
+ - preview가 없는 표는 *catalog score만으로 추정* (불확실하다고 답해도 OK).
777
+
778
+ [응답 형식 — JSON only, 다른 텍스트 금지]
779
+ {{
780
+ "best_stat_id": "DT_XXX",
781
+ "reason": "한 줄 이유"
782
+ }}
783
+
784
+ 선택을 못 하겠으면 best_stat_id를 첫 번째 후보 ID로.
785
+ """
786
+
787
+ try:
788
+ raw = await _llm.generate(
789
+ prompt=_prompt,
790
+ system_prompt="KOSIS 통계표 선정 전문가. JSON으로만 답하세요.",
791
+ model_tier="light",
792
+ )
793
+ except Exception as _e:
794
+ logger.warning(f"[catalog_search.rerank] LLM 호출 실패: {_e}")
795
+ return None
796
+
797
+ # JSON 파싱 (관대하게)
798
+ import json as _json
799
+ import re as _re
800
+ _best: str | None = None
801
+ try:
802
+ # raw에서 JSON 블록 추출
803
+ _match = _re.search(r"\{[^{}]*\}", raw, _re.DOTALL)
804
+ if _match:
805
+ data = _json.loads(_match.group(0))
806
+ _raw_best = (data.get("best_stat_id") or "").strip() or None
807
+ # [P24 2026-05-22] LLM이 brackets/quotes 포함해서 반환하는 케이스 정규화.
808
+ # prompt에 후보를 "1. [DT_XXX] 표이름" 형식으로 노출하니 LLM이 그대로
809
+ # "[DT_XXX]" 복사하는 일이 잦음. brackets/quotes/공백 strip + (옵션)
810
+ # candidates list에 substring 매칭 fallback.
811
+ if _raw_best:
812
+ _best = _raw_best.strip().strip("[]").strip("'\"").strip()
813
+ if _best != _raw_best:
814
+ logger.info(
815
+ f"[catalog_search.rerank] best 정규화: {_raw_best!r} → {_best!r}"
816
+ )
817
+ _reason = data.get("reason") or ""
818
+ if _best:
819
+ logger.info(
820
+ f"[catalog_search.rerank] LLM 선택: best={_best!r} "
821
+ f"reason={_reason[:80]!r}"
822
+ )
823
+ except Exception as _e:
824
+ logger.debug(f"[catalog_search.rerank] LLM 응답 파싱 실패: {_e}")
825
+
826
+ if not _best:
827
+ return None
828
+
829
+ # best를 0번으로 끌어올리기
830
+ _ids = [c.get("id", "") for c in candidates]
831
+ # [P24] substring fallback — 여전히 매칭 안 되면 candidates 중 best가 substring으로
832
+ # 들어가는 표 찾기 (예: LLM이 'DT_117049_A083_2020' 대신 'DT_117049_A083' 반환).
833
+ if _best not in _ids:
834
+ _substring_match = None
835
+ for _cid in _ids:
836
+ if not _cid:
837
+ continue
838
+ if _best in _cid or _cid in _best:
839
+ _substring_match = _cid
840
+ break
841
+ if _substring_match:
842
+ logger.info(
843
+ f"[catalog_search.rerank] best={_best!r} substring 매칭 → "
844
+ f"{_substring_match!r} 사용"
845
+ )
846
+ _best = _substring_match
847
+ else:
848
+ logger.info(
849
+ f"[catalog_search.rerank] best={_best!r}가 후보 list "
850
+ f"{_ids[:5]}에 없음 — rerank skip"
851
+ )
852
+ return None
853
+ _best_idx = _ids.index(_best)
854
+ if _best_idx == 0:
855
+ return None # 이미 top — no-op
856
+ _reordered = [candidates[_best_idx]] + [
857
+ c for i, c in enumerate(candidates) if i != _best_idx
858
+ ]
859
+ return _reordered