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,423 @@
1
+ """
2
+ structverify.agent.tools.explore_catalog — 카탈로그 어휘 탐색 Tool.
3
+
4
+ 용도: LLM이 어떤 카테고리 어휘를 써야 할지 모를 때 우선 호출.
5
+ KOSIS 카탈로그가 실제로 쓰는 분류명/대표 표를 보여줘서,
6
+ 룰베이스 도메인 매핑 없이도 LLM이 정확한 catalog_search query/category를
7
+ 만들 수 있도록 self-learning 시킨다.
8
+
9
+ [패치 R1] 검색을 **임베딩 기반**으로 재구현. 기존 ILIKE 키워드 매칭은
10
+ '연평균 기온' 같은 합성어를 못 잡아 적합 카테고리('기상관측통계') 누락.
11
+ 이제 query embedding → pgvector 거리 정렬로 top N 표 → 그 표들의
12
+ category_path 분포 집계. 의미 매칭이므로 키워드가 표 이름에 직접
13
+ 안 들어가도 가까운 카테고리를 정확히 찾는다.
14
+
15
+ 예시:
16
+ query="연평균 기온"
17
+ → query embedding과 가까운 top 100 표를 거리 정렬
18
+ → 그 표들이 속한 카테고리 집계:
19
+ 1. 기상관측통계 (45개) [DT_14102_B001 [종관기상] ..., DT_14104_N_002 ...]
20
+ 2. 환경 (12개) [DT_2OEEG008 연평균 기온 변화, ...]
21
+ 3. ...
22
+ → LLM이 정확한 cat_label로 catalog_search 호출
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ from typing import Any
28
+ from collections import defaultdict
29
+ from structverify.utils.logger import get_logger
30
+ from structverify.utils.embedding_client import EmbeddingClient
31
+
32
+ from ..schemas import ActionType
33
+ from .base import ToolBase, ToolContext, ToolResult, register_tool
34
+
35
+ logger = get_logger(__name__)
36
+
37
+
38
+ @register_tool(ActionType.EXPLORE_CATALOG)
39
+ class ExploreCatalogTool(ToolBase):
40
+ """카탈로그 카테고리 + 대표 표 탐색 — LLM의 self-learning용 (임베딩 기반)."""
41
+
42
+ name = ActionType.EXPLORE_CATALOG
43
+ description = (
44
+ "카탈로그의 카테고리 분포 + 대표 표 미리보기 (임베딩 의미 검색). "
45
+ "어떤 분류 어휘를 써야 할지 모를 때 catalog_search 전에 먼저 호출. "
46
+ "결과의 category_label을 catalog_search의 category 인자에 그대로 넣어라."
47
+ )
48
+ input_schema = {
49
+ "query": "(선택) 관심 주제 키워드. 비우면 전체 대분류 분포.",
50
+ "top_categories": "(선택) 보여줄 카테고리 수. 기본 5",
51
+ "examples_per_category": "(선택) 카테고리당 대표 표 수. 기본 2",
52
+ "scan_pool": "(선택) 임베딩 검색 풀 크기. 기본 100",
53
+ }
54
+
55
+ # 임베딩 helper 캐시 (CatalogSearchTool 인스턴스 재사용)
56
+ _embedding_helper = None
57
+
58
+ async def execute(
59
+ self,
60
+ input_data: dict[str, Any],
61
+ context: ToolContext,
62
+ ) -> ToolResult:
63
+ query = (input_data.get("query") or "").strip()
64
+ # [#67-D] context.config.embedding 을 _embed_query가 쓰도록 self에 보관.
65
+ self._embedding_config = (context.config or {}).get("embedding") or {}
66
+
67
+ def _safe_int(k, default, lo, hi):
68
+ try:
69
+ v = int(input_data.get(k) or default)
70
+ except (TypeError, ValueError):
71
+ v = default
72
+ return max(lo, min(v, hi))
73
+
74
+ top_categories = _safe_int("top_categories", 5, 1, 15)
75
+ examples_per = _safe_int("examples_per_category", 2, 1, 5)
76
+ scan_pool = _safe_int("scan_pool", 100, 20, 300)
77
+
78
+ try:
79
+ import asyncpg
80
+ except ImportError:
81
+ return ToolResult(
82
+ output={},
83
+ summary="실패: asyncpg 미설치 — pgvector 탐색 불가",
84
+ success=False,
85
+ error="asyncpg not installed",
86
+ )
87
+
88
+ pg_dsn = os.environ.get(
89
+ "PGVECTOR_DSN",
90
+ "postgresql://structverify:svpass123@localhost:5432/structverify",
91
+ )
92
+
93
+ try:
94
+ conn = await asyncpg.connect(pg_dsn)
95
+ except Exception as e:
96
+ logger.warning(f"[explore_catalog] DB 연결 실패: {e}")
97
+ return ToolResult(
98
+ output={},
99
+ summary=f"실패: 카탈로그 DB 연결 — {type(e).__name__}: {e}",
100
+ success=False,
101
+ error=f"{type(e).__name__}: {e}",
102
+ )
103
+
104
+ try:
105
+ if query:
106
+ categories_info = await self._fetch_by_embedding(
107
+ conn, query, top_categories, examples_per, scan_pool
108
+ )
109
+ used_method = "embedding"
110
+ # 임베딩 실패 시 ILIKE fallback
111
+ if not categories_info:
112
+ logger.info(
113
+ f"[explore_catalog] embedding 결과 없음 → ILIKE fallback"
114
+ )
115
+ categories_info = await self._fetch_by_ilike(
116
+ conn, query, top_categories, examples_per
117
+ )
118
+ used_method = "ilike_fallback"
119
+ else:
120
+ # query 없으면 전체 대분류 분포
121
+ categories_info = await self._fetch_all_categories(
122
+ conn, top_categories, examples_per
123
+ )
124
+ used_method = "overview"
125
+ except Exception as e:
126
+ logger.exception(f"[explore_catalog] 쿼리 실패: query={query!r}")
127
+ try:
128
+ await conn.close()
129
+ except Exception:
130
+ pass
131
+ return ToolResult(
132
+ output={},
133
+ summary=f"실패: 카탈로그 쿼리 — {type(e).__name__}: {e}",
134
+ success=False,
135
+ error=f"{type(e).__name__}: {e}",
136
+ )
137
+ finally:
138
+ try:
139
+ await conn.close()
140
+ except Exception:
141
+ pass
142
+
143
+ # LLM 친화 포맷 — sim 값은 (정규화되지 않은) 임베딩이라 절대값이 큰 음수로
144
+ # 표시될 수 있어 LLM에 혼란. 순위만 보여주고 sim은 숨김.
145
+ lines: list[str] = []
146
+ for idx, info in enumerate(categories_info, start=1):
147
+ cat_label = info["category_label"]
148
+ count = info["table_count"]
149
+ lines.append(f"{idx}. {cat_label} ({count}개 표)")
150
+ for ex in info["examples"]:
151
+ lines.append(f" - [{ex['stat_id']}] {ex['stat_name']}")
152
+ summary_block = "\n".join(lines) if lines else "(카탈로그에 매칭 카테고리 없음)"
153
+
154
+ # [R1.5] 다음 단계 명령을 *복사 가능한 JSON 형식*으로 명시.
155
+ # LLM이 자기 어휘로 catalog_search category를 만드는 걸 방지.
156
+ # (시스템은 R1.5 패치로 자동 union 하지만, 프롬프트도 강화.)
157
+ top_labels = [info["category_label"] for info in categories_info[:2]]
158
+ if top_labels:
159
+ cat_json = '["' + '", "'.join(top_labels) + '"]'
160
+ next_step = (
161
+ f"→ 다음 단계 (필수): catalog_search 호출 시 input.category 인자에 "
162
+ f"위 1·2위 카테고리를 *그대로 복사*해서 사용하라:\n"
163
+ f' input.category = {cat_json}\n'
164
+ f" query는 claim의 핵심 지표명(예: indicator)을 그대로 쓸 것. "
165
+ f"임의의 자유어('기후 변화', '날씨 정보' 같은)는 사용 금지."
166
+ )
167
+ else:
168
+ next_step = (
169
+ "→ 다음 단계: explore_catalog를 더 specific한 query로 재시도하거나, "
170
+ "catalog_search에 query만 넣고 호출 (category 없이)."
171
+ )
172
+ summary_head = (
173
+ f"explore_catalog(query={query!r}, method={used_method}): "
174
+ f"{len(categories_info)}개 카테고리\n{summary_block}\n\n{next_step}"
175
+ )
176
+
177
+ # observation 저장
178
+ try:
179
+ obs_name = f"iter{context.iter_num:03d}_explore_catalog"
180
+ context.workspace.write_observation(
181
+ context.claim_id,
182
+ obs_name,
183
+ {
184
+ "query": query,
185
+ "method": used_method,
186
+ "categories": categories_info,
187
+ },
188
+ )
189
+ except Exception as e:
190
+ logger.debug(f"[explore_catalog] observation 저장 실패: {e}")
191
+
192
+ return ToolResult(
193
+ output={
194
+ "query": query,
195
+ "method": used_method,
196
+ "categories": categories_info,
197
+ "category_count": len(categories_info),
198
+ },
199
+ summary=summary_head,
200
+ success=True,
201
+ )
202
+
203
+ # ── 임베딩 기반 탐색 (주 경로) ──────────────────────────────────
204
+
205
+ async def _fetch_by_embedding(
206
+ self,
207
+ conn,
208
+ query: str,
209
+ top_categories: int,
210
+ examples_per: int,
211
+ scan_pool: int,
212
+ ) -> list[dict[str, Any]]:
213
+ """query 임베딩 → pgvector 거리 정렬 → top N 표의 카테고리 분포 집계."""
214
+ embedding = await self._embed_query(query)
215
+ if embedding is None:
216
+ logger.info(
217
+ f"[explore_catalog] 임베딩 생성 실패(API/key 문제) — ILIKE fallback 예정"
218
+ )
219
+ return []
220
+
221
+ vector_str = "[" + ",".join(str(v) for v in embedding) + "]"
222
+ rows = await conn.fetch(
223
+ """
224
+ SELECT stat_id, stat_name, category_path,
225
+ 1 - (embedding <-> $1::vector) AS similarity
226
+ FROM kosis_stat_catalog
227
+ WHERE embedding IS NOT NULL
228
+ AND category_path IS NOT NULL
229
+ ORDER BY embedding <-> $1::vector
230
+ LIMIT $2
231
+ """,
232
+ vector_str, scan_pool,
233
+ )
234
+
235
+ # 카테고리별 그룹화 (category_label = path의 첫 의미 segment)
236
+ buckets: dict[str, list[dict[str, Any]]] = defaultdict(list)
237
+ for r in rows:
238
+ path = r["category_path"]
239
+ cat_label = self._cat_label_from_path(path)
240
+ if not cat_label:
241
+ continue
242
+ buckets[cat_label].append({
243
+ "stat_id": r["stat_id"],
244
+ "stat_name": r["stat_name"],
245
+ "similarity": float(r["similarity"]),
246
+ })
247
+
248
+ # 카테고리 정렬: 그 안의 평균 similarity 내림차순
249
+ scored: list[tuple[str, int, float, list[dict[str, Any]]]] = []
250
+ for cat, tables in buckets.items():
251
+ avg_sim = sum(t["similarity"] for t in tables) / len(tables)
252
+ # 표는 similarity 내림차순으로 정렬
253
+ tables.sort(key=lambda t: t["similarity"], reverse=True)
254
+ scored.append((cat, len(tables), avg_sim, tables))
255
+ scored.sort(key=lambda x: x[2], reverse=True)
256
+
257
+ result: list[dict[str, Any]] = []
258
+ for cat, cnt, avg_sim, tables in scored[:top_categories]:
259
+ examples = [
260
+ {"stat_id": t["stat_id"], "stat_name": t["stat_name"]}
261
+ for t in tables[:examples_per]
262
+ ]
263
+ result.append({
264
+ "category_label": cat,
265
+ "table_count": cnt, # scan_pool 내 카운트
266
+ "avg_similarity": round(avg_sim, 3),
267
+ "examples": examples,
268
+ })
269
+ return result
270
+
271
+ @staticmethod
272
+ def _cat_label_from_path(path: str) -> str:
273
+ """category_path → 의미 있는 첫 segment.
274
+
275
+ 'MT_ZTITLE > 기상관측통계 > 종관기상' → '기상관측통계'
276
+ 'MT_ZTITLE > 인구동향조사' → '인구동향조사'
277
+ '기상관측통계' (단독) → '기상관측통계'
278
+ """
279
+ if not path:
280
+ return ""
281
+ parts = [p.strip() for p in path.split(" > ") if p.strip()]
282
+ if not parts:
283
+ return ""
284
+ # 첫 segment가 메타 prefix면 다음 사용
285
+ _META_PREFIXES = ("MT_ZTITLE", "MT_OTITLE")
286
+ if parts[0] in _META_PREFIXES and len(parts) > 1:
287
+ return parts[1]
288
+ return parts[0]
289
+
290
+ # ── 임베딩 helper ──────────────────────────────────────────────
291
+
292
+ async def _embed_query(self, text: str) -> list[float] | None:
293
+ """텍스트 → 임베딩. 공용 EmbeddingClient 사용 (#67-D).
294
+
295
+ 키 우선순위: 원래 NCP_API_KEY 우선이라 api_key_env 기본을 "NCP_API_KEY"로 둠.
296
+ config.embedding.api_key_env 가 있으면 그 값을 우선.
297
+ 주의: 원래 NCP_API_KEY or CLOVASTUDIO_API_KEY 폴백이었으나 EmbeddingClient는
298
+ 단일 api_key_env → NCP만 1순위(NCP 없을 때 CLOVA 폴백은 제거됨).
299
+ """
300
+ emb_cfg = dict(getattr(self, "_embedding_config", None) or {})
301
+ emb_cfg.setdefault("api_key_env", "NCP_API_KEY")
302
+ try:
303
+ return await EmbeddingClient(emb_cfg).embed(text)
304
+ except Exception as e: # noqa: BLE001
305
+ logger.debug(f"[explore_catalog] _embed_query 실패: {e}")
306
+ return None
307
+
308
+ # ── ILIKE fallback (임베딩 실패 / 0건 결과 시) ──────────────────
309
+
310
+ async def _fetch_by_ilike(
311
+ self,
312
+ conn,
313
+ query: str,
314
+ top_categories: int,
315
+ examples_per: int,
316
+ ) -> list[dict[str, Any]]:
317
+ """기존 ILIKE 키워드 매칭 — 임베딩 fallback용."""
318
+ tokens = [t for t in query.split() if len(t) >= 2] or [query]
319
+ where_parts: list[str] = []
320
+ params: list[Any] = []
321
+ for t in tokens:
322
+ params.append(f"%{t}%")
323
+ idx_a = len(params)
324
+ where_parts.append(
325
+ f"(stat_name ILIKE ${idx_a} OR category_path ILIKE ${idx_a})"
326
+ )
327
+ where_sql = " OR ".join(where_parts)
328
+ params.append(top_categories)
329
+ limit_idx = len(params)
330
+ sql = f"""
331
+ SELECT
332
+ COALESCE(NULLIF(split_part(category_path, ' > ', 2), ''),
333
+ category_path) AS cat_label,
334
+ COUNT(*) AS cnt
335
+ FROM kosis_stat_catalog
336
+ WHERE ({where_sql})
337
+ AND category_path IS NOT NULL
338
+ GROUP BY cat_label
339
+ ORDER BY cnt DESC
340
+ LIMIT ${limit_idx}
341
+ """
342
+ rows = await conn.fetch(sql, *params)
343
+
344
+ result: list[dict[str, Any]] = []
345
+ for row in rows:
346
+ cat_label = row["cat_label"]
347
+ cnt = int(row["cnt"])
348
+ # 대표 표 — 같은 ILIKE 매칭으로 fetch
349
+ ex_where: list[str] = []
350
+ ex_params: list[Any] = [cat_label]
351
+ for t in tokens:
352
+ ex_params.append(f"%{t}%")
353
+ ex_where.append(
354
+ f"(stat_name ILIKE ${len(ex_params)} OR category_path ILIKE ${len(ex_params)})"
355
+ )
356
+ ex_params.append(examples_per)
357
+ ex_sql = f"""
358
+ SELECT stat_id, stat_name
359
+ FROM kosis_stat_catalog
360
+ WHERE COALESCE(NULLIF(split_part(category_path, ' > ', 2), ''),
361
+ category_path) = $1
362
+ AND ({" OR ".join(ex_where)})
363
+ ORDER BY stat_name
364
+ LIMIT ${len(ex_params)}
365
+ """
366
+ ex_rows = await conn.fetch(ex_sql, *ex_params)
367
+ examples = [
368
+ {"stat_id": r["stat_id"], "stat_name": r["stat_name"]}
369
+ for r in ex_rows
370
+ ]
371
+ result.append({
372
+ "category_label": cat_label,
373
+ "table_count": cnt,
374
+ "examples": examples,
375
+ })
376
+ return result
377
+
378
+ # ── query 없을 때 전체 대분류 분포 ────────────────────────────
379
+
380
+ async def _fetch_all_categories(
381
+ self,
382
+ conn,
383
+ top_categories: int,
384
+ examples_per: int,
385
+ ) -> list[dict[str, Any]]:
386
+ rows = await conn.fetch(
387
+ """
388
+ SELECT
389
+ COALESCE(NULLIF(split_part(category_path, ' > ', 2), ''),
390
+ category_path) AS cat_label,
391
+ COUNT(*) AS cnt
392
+ FROM kosis_stat_catalog
393
+ WHERE category_path IS NOT NULL
394
+ GROUP BY cat_label
395
+ ORDER BY cnt DESC
396
+ LIMIT $1
397
+ """,
398
+ top_categories,
399
+ )
400
+ result: list[dict[str, Any]] = []
401
+ for row in rows:
402
+ cat_label = row["cat_label"]
403
+ cnt = int(row["cnt"])
404
+ ex_rows = await conn.fetch(
405
+ """
406
+ SELECT stat_id, stat_name
407
+ FROM kosis_stat_catalog
408
+ WHERE COALESCE(NULLIF(split_part(category_path, ' > ', 2), ''),
409
+ category_path) = $1
410
+ ORDER BY stat_name
411
+ LIMIT $2
412
+ """,
413
+ cat_label, examples_per,
414
+ )
415
+ result.append({
416
+ "category_label": cat_label,
417
+ "table_count": cnt,
418
+ "examples": [
419
+ {"stat_id": r["stat_id"], "stat_name": r["stat_name"]}
420
+ for r in ex_rows
421
+ ],
422
+ })
423
+ return result