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,208 @@
1
+ """
2
+ structverify.retrieval.base — DataSource 추상 인터페이스.
3
+
4
+ KOSIS만이 아니라 *어떤 데이터 소스*든 (회사 DB, CSV 파일, 외부 API)
5
+ *동일한 인터페이스*로 플러그인 가능하도록 설계.
6
+
7
+ 회사 사용 시나리오:
8
+ - 글로벌 회사: World Bank API + OECD API
9
+ - 사내 데이터: PostgreSQL/Snowflake + S3 CSV
10
+ - 한국 미디어: KOSIS (현재 default)
11
+
12
+ Config (config/default.yaml):
13
+ data_sources:
14
+ enabled: ["kosis", "custom_csv"]
15
+ kosis: {...}
16
+ custom_csv: {...}
17
+
18
+ Registry로 등록:
19
+ from structverify.retrieval.registry import register_datasource
20
+
21
+ @register_datasource("my_internal_db")
22
+ class MyInternalDB(BaseDataSource):
23
+ async def search_catalog(self, query, ...): ...
24
+ async def fetch_evidence(self, candidate_id, ...): ...
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from abc import ABC, abstractmethod
29
+ from typing import Any
30
+
31
+
32
+ # ── Result 타입 (dict 호환, 가볍게) ──────────────────────────────
33
+
34
+ class CatalogCandidate(dict):
35
+ """카탈로그 검색 결과 1건.
36
+
37
+ 필수 키:
38
+ id : 후보 식별자 (예: 'DT_1L9U108')
39
+ name : 표/지표 이름
40
+ score : 검색 점수 (0~1)
41
+ 선택 키:
42
+ category : 분류 (예: '인구/가구')
43
+ description : 설명
44
+ ...source-specific 필드
45
+ """
46
+
47
+
48
+ class EvidenceData(dict):
49
+ """fetch 결과 raw 데이터.
50
+
51
+ 필수 키:
52
+ value : 수치값 (float)
53
+ unit : 단위
54
+ time_period : 시점 (YYYY 또는 YYYY-MM)
55
+ 선택 키:
56
+ official_value : 정규화된 값
57
+ raw_response : API 원본 응답 (디버깅용)
58
+ rows : 전체 row 목록 (KOSIS 같은 표 데이터)
59
+ ...
60
+ """
61
+
62
+
63
+ # ── 추상 인터페이스 ───────────────────────────────────────────────
64
+
65
+ class BaseDataSource(ABC):
66
+ """
67
+ 모든 데이터 소스의 공통 인터페이스.
68
+
69
+ 필수 구현:
70
+ - search_catalog: 키워드로 표/지표 후보 검색
71
+ - fetch_evidence: 후보 1개의 실제 데이터 조회
72
+
73
+ 선택 구현 (기본은 False / no-op):
74
+ - supports_time_filter: API가 시점 파라미터 지원하는지
75
+ - supports_population_filter: 인구 필터 지원 여부
76
+ - close: 리소스 정리 (DB 커넥션 등)
77
+ """
78
+
79
+ name: str = "base"
80
+ """Registry에 등록될 이름. @register_datasource로 자동 설정."""
81
+
82
+ @abstractmethod
83
+ async def search_catalog(
84
+ self,
85
+ query: str,
86
+ category: list[str] | None = None,
87
+ top_k: int = 10,
88
+ context: dict[str, Any] | None = None,
89
+ ) -> list[CatalogCandidate]:
90
+ """카탈로그 검색.
91
+
92
+ Args:
93
+ query: 검색 키워드 (자연어 또는 키워드 조합).
94
+ category: 분류 힌트 (예: ['인구', '가구']).
95
+ top_k: 반환할 최대 후보 수.
96
+ context: [P31 2026-05-22] source-specific 부가 정보. 검색 정확도 보강용.
97
+ 권장 키 (source가 알 만한 것만 사용):
98
+ - 'parent_path': str — schema의 계층 카테고리 (예: "보건 > 의료자원 > 의료장비")
99
+ - 'raw_claim': str — 원문 claim 텍스트 (앞 200자)
100
+ - 'population': str — schema의 대상 집단/지역
101
+ - 'indicator': str — schema의 지표명
102
+ source가 모르는 키는 무시. KOSIS는 LLM 카테고리 추출 시 활용.
103
+
104
+ Returns:
105
+ CatalogCandidate 리스트. score 내림차순 권장.
106
+ """
107
+
108
+ @abstractmethod
109
+ async def fetch_evidence(
110
+ self,
111
+ candidate_id: str,
112
+ params: dict[str, Any] | None = None,
113
+ workspace: Any = None,
114
+ ) -> EvidenceData | None:
115
+ """실제 데이터 조회.
116
+
117
+ workspace: (선택) source별 캐시 핸들. KOSIS는 raw 응답 캐싱에 사용,
118
+ 캐시가 불필요한 소스(CSV 등)는 받되 무시한다. 호출부(FetchEvidenceTool)가
119
+ 모든 소스에 workspace를 넘기므로 시그니처에 포함한다.
120
+
121
+ Args:
122
+ candidate_id: search_catalog 결과의 'id'.
123
+ params: source-specific 파라미터.
124
+ 예 (KOSIS): {'prdSe': 'M', 'startPrdDe': '202504'}.
125
+ 예 (CSV): {'column': 'sales', 'row_filter': 'region=KR'}.
126
+
127
+ Returns:
128
+ EvidenceData 또는 None (못 찾으면).
129
+ """
130
+
131
+ # ── 선택 기능 (기본 false / no-op) ─────────────────────────
132
+
133
+ def supports_time_filter(self) -> bool:
134
+ """API가 시점 파라미터 필터링을 지원하는지.
135
+
136
+ True면 fetch_evidence에 시점 params 전달 가능.
137
+ False면 모든 row 가져온 후 verifier가 필터링.
138
+ """
139
+ return False
140
+
141
+ def supports_population_filter(self) -> bool:
142
+ """인구/카테고리 필터 지원 여부."""
143
+ return False
144
+
145
+ async def close(self) -> None:
146
+ """리소스 정리. DB 커넥션 등."""
147
+ return None
148
+
149
+ async def get_table_meta(
150
+ self,
151
+ candidate_id: str,
152
+ meta_type: str = "ITM",
153
+ ) -> dict[str, Any] | list[dict[str, Any]] | None:
154
+ """[P30 2026-05-22] 표의 *항목/분류 메타* 조회 — *데이터는 X*.
155
+
156
+ 용도: catalog_search → fetch_evidence 사이에서, 표 이름만으론 정답인지
157
+ 불확실할 때 LLM이 *표 내부 구조*(어떤 항목/분류 코드)를 보고 판단하기
158
+ 위한 가벼운 메타 호출.
159
+
160
+ Args:
161
+ candidate_id: search_catalog 결과의 'id' (stat_id 등).
162
+ meta_type: source-specific 메타 타입.
163
+ KOSIS: "ITM" (통계항목), "OBJL01"~"OBJL08" (분류 항목), "PRD", "CMMT".
164
+
165
+ Returns:
166
+ 메타 dict 또는 list (source 형식 그대로). 실패/미지원이면 None.
167
+ """
168
+ return None
169
+
170
+
171
+ # ── 헬퍼: 다중 source orchestration (Phase B+에서 활용) ───────────
172
+
173
+ class MultiSourceRouter:
174
+ """
175
+ 여러 DataSource를 묶어서 *순차/병렬* 검색.
176
+
177
+ Phase B에서 tool wrapper가 이 클래스 사용:
178
+ - config.data_sources.enabled = ["kosis", "custom_db"] 면 *둘 다* 검색
179
+ - 결과 합쳐서 ranked list 반환
180
+ """
181
+
182
+ def __init__(self, sources: list[BaseDataSource]):
183
+ self.sources = sources
184
+
185
+ async def search_catalog_all(
186
+ self,
187
+ query: str,
188
+ category: list[str] | None = None,
189
+ top_k_per_source: int = 5,
190
+ ) -> dict[str, list[CatalogCandidate]]:
191
+ """각 source 검색 결과 dict로."""
192
+ results: dict[str, list[CatalogCandidate]] = {}
193
+ for src in self.sources:
194
+ try:
195
+ results[src.name] = await src.search_catalog(
196
+ query, category=category, top_k=top_k_per_source
197
+ )
198
+ except Exception as e:
199
+ # 한 source 실패해도 나머지 진행
200
+ results[src.name] = []
201
+ return results
202
+
203
+ async def close_all(self) -> None:
204
+ for src in self.sources:
205
+ try:
206
+ await src.close()
207
+ except Exception:
208
+ pass
@@ -0,0 +1,85 @@
1
+ """
2
+ retrieval/base_connector.py — 데이터 커넥터 추상 인터페이스
3
+
4
+ v2: to_graph_nodes(), tag_provenance() 메서드 추가
5
+
6
+ [참고] RAG (Lewis et al., NeurIPS 2020) — https://github.com/huggingface/transformers
7
+ 검색 → LLM context 주입 패턴. 커넥터의 search→fetch→context 흐름에 참고.
8
+ """
9
+ # 수정자: 신준수
10
+ # 수정 날짜: 2026-04-27
11
+ # 수정 내용: StatData에 official_value·unit·time_period(증거 정규화; 출처 API 키는 커넥터가 채움)
12
+ # [2026-05-14 | 이수민] memory/v1: StatData.category_path 추가
13
+ # - KOSIS catalog의 카테고리 경로를 evidence까지 전달해 verifier 도메인 가드에 활용
14
+ from __future__ import annotations
15
+ from abc import ABC, abstractmethod
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+ from structverify.core.schemas import GraphNode, ProvenanceRecord
19
+
20
+
21
+ @dataclass
22
+ class ConnectorQuery:
23
+ keyword: str
24
+ indicator: str | None = None
25
+ time_period: str | None = None
26
+ population: str | None = None
27
+ extra_params: dict[str, Any] = field(default_factory=dict)
28
+
29
+ @dataclass
30
+ class StatRecord:
31
+ stat_id: str; stat_name: str; org_name: str; org_id: str | None = None
32
+ available_periods: list[str] = field(default_factory=list)
33
+ relevance_score: float = 0.0
34
+ # 출처별 search 응답 한 행 전체; 커넥터·API별 추가 키는 여기로.
35
+ metadata: dict[str, Any] = field(default_factory=dict)
36
+
37
+ @dataclass
38
+ class StatData:
39
+ stat_id: str; stat_name: str
40
+ values: dict[str, Any] = field(default_factory=dict)
41
+ raw_response: dict[str, Any] = field(default_factory=dict)
42
+ # Evidence/검증용 정규화(커넥터가 출처 API에서 채움; subgraph는 values 키를 직접 보지 않음)
43
+ official_value: float | None = None
44
+ unit: str | None = None
45
+ time_period: str | None = None
46
+ # [이수민 2026-05-14] working memory 도메인 가드용 — KOSIS catalog category_path
47
+ category_path: str | None = None
48
+
49
+
50
+ class BaseConnector(ABC):
51
+ """
52
+ 모든 커넥터의 추상 기본 클래스.
53
+ v2에서 to_graph_nodes()와 tag_provenance() 추가.
54
+ """
55
+ @abstractmethod
56
+ async def search(self, query: ConnectorQuery) -> list[StatRecord]: ...
57
+
58
+ @abstractmethod
59
+ async def fetch(self, stat_id: str, params: dict[str, Any]) -> StatData: ...
60
+
61
+ @abstractmethod
62
+ def to_graph_nodes(self, data: StatData) -> list[GraphNode]:
63
+ """v2: 조회 결과를 그래프 노드로 변환"""
64
+ ...
65
+
66
+ @abstractmethod
67
+ def tag_provenance(self, data: StatData, query: ConnectorQuery) -> ProvenanceRecord:
68
+ """v2: 출처 이력 기록"""
69
+ ...
70
+
71
+ async def search_and_fetch(self, query: ConnectorQuery) -> StatData | None:
72
+ records = await self.search(query)
73
+ if not records:
74
+
75
+ return None
76
+ best = max(records, key=lambda r: r.relevance_score)
77
+ return await self.fetch(
78
+ best.stat_id,
79
+ {
80
+ "time_period": query.time_period,
81
+ "population": query.population,
82
+ "query": query,
83
+ "stat_record": best,
84
+ },
85
+ )
@@ -0,0 +1,300 @@
1
+ """
2
+ structverify.retrieval.catalog_ranker — LLM batch ranking.
3
+
4
+ 배경:
5
+ 기존 indicator semantic guard(키워드 룰) + relevance_judge(per-table T/F) 두 가드를
6
+ *후보 N개 한 번에 비교하는 LLM ranking*으로 통합. 이유:
7
+ - 키워드 룰은 한정자가 사전에 없는 도메인(투석/혈관조영 등) 확장 불가.
8
+ - per-table 판단은 *비교*가 필요한 한정자 매칭(특수의료장비 vs 의료장비)을 잡지 못함.
9
+ - N표를 한 번에 비교하면 한정자 매칭 + 부분집합/상위집합 관계 판별이 자연스러움.
10
+
11
+ 설계:
12
+ - input: claim(indicator/population/parent_path/원문) + candidates[{id, name, org, ...}]
13
+ - output: [{id, score, reason}, ...] (score 0~1, 높은 순 정렬)
14
+ - 호출자가 score 임계치(예: 0.15) 미만은 reject, 나머지는 score 순서로 try.
15
+ - LLM 1회 호출 (메타데이터는 표 이름 + org_name + category_path까지만 노출 — 과적합 회피).
16
+
17
+ config:
18
+ data_sources.kosis.catalog_ranker.{enabled, score_threshold, model_tier}
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ from typing import Any
25
+
26
+ from structverify.utils.logger import get_logger
27
+
28
+ logger = get_logger(__name__)
29
+
30
+ # LLM 응답이 표 5~25개 ranking이라 ~1KB 정도. v3 light tier 충분.
31
+ _DEFAULT_MODEL_TIER = "light"
32
+ _DEFAULT_SCORE_THRESHOLD = 0.15 # 이 미만은 reject
33
+
34
+
35
+ def _normalize_candidate_for_prompt(c: dict) -> dict:
36
+ """LLM 프롬프트용으로 후보 메타데이터를 안전 추출. raw StatRecord 핸들링.
37
+
38
+ Returns:
39
+ {id, name, org_name, category_path}. 비어있는 필드는 ''.
40
+ """
41
+ cid = str(c.get("id") or "")
42
+ name = str(c.get("name") or "")
43
+ # name에 "[같은 job에서 ...]" 같은 hint label 붙어있으면 제거 (LLM 판단 오염 방지)
44
+ name = re.sub(r"\s*\[같은\s+job[^\]]*\]\s*$", "", name).strip()
45
+
46
+ org_name = ""
47
+ category_path = ""
48
+
49
+ # candidate dict 직접 필드
50
+ if c.get("org_name"):
51
+ org_name = str(c["org_name"])
52
+ if c.get("category_path"):
53
+ category_path = str(c["category_path"])
54
+
55
+ # raw에서 보강 (StatRecord 또는 dict)
56
+ raw = c.get("raw")
57
+ if raw is not None:
58
+ # dataclass StatRecord
59
+ if not org_name:
60
+ _org = getattr(raw, "org_name", None)
61
+ if _org:
62
+ org_name = str(_org)
63
+ if not category_path:
64
+ _md = getattr(raw, "metadata", None)
65
+ if isinstance(_md, dict):
66
+ _cp = _md.get("category_path")
67
+ if _cp:
68
+ category_path = str(_cp)
69
+ # dict raw (예: from_job_success)
70
+ if isinstance(raw, dict):
71
+ if not org_name and raw.get("org_name"):
72
+ org_name = str(raw["org_name"])
73
+ if not category_path and raw.get("category_path"):
74
+ category_path = str(raw["category_path"])
75
+
76
+ return {
77
+ "id": cid,
78
+ "name": name,
79
+ "org_name": org_name,
80
+ "category_path": category_path,
81
+ }
82
+
83
+
84
+ def _build_prompt(
85
+ *,
86
+ claim_text: str,
87
+ indicator: str,
88
+ population: str,
89
+ time_period: str,
90
+ parent_path: str,
91
+ candidates: list[dict],
92
+ source_context: str = "",
93
+ ) -> str:
94
+ """LLM 프롬프트 구성. candidates는 _normalize_candidate_for_prompt 결과.
95
+
96
+ source_context가 있으면(회사 등 임의 소스) *소스 무관 중립 프롬프트*를 쓴다 —
97
+ 'KOSIS/해외 통계' 같은 공공통계 전용 가정을 넣지 않는다.
98
+ """
99
+ cands_lines: list[str] = []
100
+ for i, c in enumerate(candidates, start=1):
101
+ line = f" [{i}] id={c['id']!r}\n name={c['name']!r}"
102
+ if c.get("org_name"):
103
+ line += f"\n org={c['org_name']!r}"
104
+ if c.get("category_path"):
105
+ line += f"\n category_path={c['category_path']!r}"
106
+ cands_lines.append(line)
107
+ cands_block = "\n".join(cands_lines) if cands_lines else " (없음)"
108
+
109
+ if source_context:
110
+ # ── 소스 무관 중립 랭커 (방대한 회사 데이터 등) ──
111
+ return f"""당신은 '검증 기준 데이터' 후보 ranking reviewer입니다.
112
+ 아래 주장을 검증하기에 적합한 후보를 의미 매칭 정도로 0~1 점수로 매기세요.
113
+
114
+ [검증 기준 데이터]
115
+ {source_context}
116
+
117
+ [사용자 주장]
118
+ - 원문: {claim_text or '(없음)'}
119
+ - 검증 지표: {indicator or '(없음)'}
120
+ - 대상/지역: {population or '(없음)'}
121
+ - 시점: {time_period or '(없음)'}
122
+
123
+ [후보 N개]
124
+ {cands_block}
125
+
126
+ [판단 기준]
127
+ 1. 후보의 이름/지표가 주장의 지표와 *의미상 일치*하면 score ↑ (같은 대상을 가리키는가?).
128
+ 2. 대상/지역/시점이 맞으면 가점. 지표 자체가 다르면 감점.
129
+ 3. 무관한 지표는 score 0.0~0.1. (공공/해외 여부는 판단하지 말 것 — 이 소스가 정답 기준이다.)
130
+ 4. score: 0.9~1.0 정확 / 0.5~0.8 도메인 맞고 부분 / 0.2~0.4 광범위·협소 / 0.0~0.1 무관.
131
+
132
+ [응답 — JSON only, 모든 후보]
133
+ {{"rankings": [{{"id": "<후보 id>", "score": 0.95, "reason": "<한 줄>"}}, ...]}}
134
+ * id는 입력 candidates의 id 그대로. 누락 후보는 score=0.0 처리.
135
+ """
136
+
137
+ return f"""당신은 KOSIS 통계표 ranking reviewer입니다. 사용자 claim 검증에 적합한
138
+ *표 후보 N개를 의미 매칭 정도로 비교*해 0~1 점수로 ranking 하세요.
139
+
140
+ [사용자 claim]
141
+ - 원문: {claim_text or '(없음)'}
142
+ - 검증 지표 (indicator): {indicator or '(없음)'}
143
+ - 대상 집단/지역 (population): {population or '(없음)'}
144
+ - 시점 (time_period): {time_period or '(없음)'}
145
+ - 카테고리 경로 (parent_path): {parent_path or '(없음)'}
146
+
147
+ [후보 표 N개]
148
+ {cands_block}
149
+
150
+ [판단 기준 — 중요]
151
+ 1. **한정자 매칭**:
152
+ - claim의 indicator에 *한정자*가 있으면 (예: "체외 충격파 쇄석술 장비", "MRI 장비")
153
+ → 표 이름에 *그 한정자*가 직접/상위로 매칭되는 표가 *최우선* (예: "특수의료장비",
154
+ "진단방사선 장비" 등 한정자 포함 표가 score ↑).
155
+ - claim의 indicator에 한정자가 *없으면* (예: 단순 "의료장비 수", "인구")
156
+ → 표도 *한정자 없는 일반 집합* 표가 우선 (한정자 박힌 표는 *부분집합*이라 score ↓).
157
+ 2. **population 매칭**:
158
+ - 표 이름이나 category_path에 population을 포함/매칭하면 score ↑.
159
+ - "시도별/시군구별"처럼 지역 분할 표는 대부분의 population에 적합.
160
+ 3. **외국/장래/다른 도메인**:
161
+ - 해외 통계, 장래 추계·전망, 도메인 불일치(인구↔경제↔기상) → score 0.0~0.1.
162
+ 4. **score 의미**:
163
+ - 0.9~1.0: 한정자/도메인 모두 정확 매칭, 정답 확신
164
+ - 0.5~0.8: 도메인 맞고 한정자 부분 매칭 (상위 집합 등 row 검색으로 회수 가능)
165
+ - 0.2~0.4: 도메인 맞지만 한정자 mismatch 또는 너무 광범위/협소
166
+ - 0.0~0.1: 무관한 표 — 거부 권장
167
+
168
+ [응답 형식 — JSON only, 모든 후보에 대해 작성]
169
+ {{
170
+ "rankings": [
171
+ {{"id": "<후보 id>", "score": 0.95, "reason": "<한 줄 이유>"}},
172
+ ...
173
+ ]
174
+ }}
175
+
176
+ * id는 반드시 입력 candidates의 id 그대로 사용. 누락된 표는 score=0.0으로 처리됨.
177
+ * score 순서로 정렬할 필요는 없음 (호출자가 정렬).
178
+ """
179
+
180
+
181
+ def _parse(raw: str, valid_ids: set[str]) -> list[dict] | None:
182
+ """LLM JSON 응답 파싱. valid_ids에 있는 id만 채택.
183
+
184
+ Returns:
185
+ [{id, score, reason}, ...] — score 높은 순 정렬.
186
+ 파싱 실패 시 None.
187
+ """
188
+ if not raw:
189
+ return None
190
+ # JSON 블록 추출 (코드펜스 / 앞뒤 텍스트 핸들링)
191
+ m = re.search(r"\{[\s\S]*\}", raw)
192
+ if not m:
193
+ logger.debug(f"[catalog_ranker] JSON 블록 못 찾음: raw={raw[:200]!r}")
194
+ return None
195
+ try:
196
+ data = json.loads(m.group(0))
197
+ except json.JSONDecodeError as e:
198
+ logger.debug(f"[catalog_ranker] JSON parse 실패: {e}, raw={raw[:200]!r}")
199
+ return None
200
+ rankings = data.get("rankings")
201
+ if not isinstance(rankings, list):
202
+ return None
203
+
204
+ out: list[dict] = []
205
+ seen_ids: set[str] = set()
206
+ for r in rankings:
207
+ if not isinstance(r, dict):
208
+ continue
209
+ rid = str(r.get("id") or "").strip()
210
+ if not rid or rid not in valid_ids or rid in seen_ids:
211
+ continue
212
+ try:
213
+ score = float(r.get("score", 0.0) or 0.0)
214
+ except (TypeError, ValueError):
215
+ score = 0.0
216
+ score = max(0.0, min(score, 1.0))
217
+ reason = str(r.get("reason") or "")[:200]
218
+ out.append({"id": rid, "score": score, "reason": reason})
219
+ seen_ids.add(rid)
220
+
221
+ # LLM이 누락한 id는 score=0.0으로 추가
222
+ for vid in valid_ids:
223
+ if vid not in seen_ids:
224
+ out.append({"id": vid, "score": 0.0, "reason": "LLM 응답에서 누락 — 기본 0.0"})
225
+
226
+ # score 순 정렬
227
+ out.sort(key=lambda x: -x["score"])
228
+ return out
229
+
230
+
231
+ async def rank_candidates(
232
+ *,
233
+ claim_text: str,
234
+ indicator: str,
235
+ population: str,
236
+ time_period: str,
237
+ parent_path: str,
238
+ candidates: list[dict],
239
+ config: dict | None = None,
240
+ ) -> list[dict] | None:
241
+ """LLM batch ranking으로 후보 표 N개를 의미 점수로 정렬.
242
+
243
+ Args:
244
+ claim_text: claim 원문 (1~3문장).
245
+ indicator: schema.indicator.
246
+ population: schema.population.
247
+ time_period: schema.time_period.
248
+ parent_path: schema.parent_path.
249
+ candidates: [{id, name, score, raw?}, ...] — catalog_search 결과.
250
+ config: 전체 config dict. data_sources.kosis.catalog_ranker 섹션 사용.
251
+
252
+ Returns:
253
+ [{id, score, reason}, ...] — score 높은 순 정렬.
254
+ candidates 비었거나 LLM 실패 시 None.
255
+ """
256
+ if not candidates:
257
+ return None
258
+
259
+ # 후보 정규화 (메타데이터 추출)
260
+ norm_cands = [_normalize_candidate_for_prompt(c) for c in candidates]
261
+ valid_ids: set[str] = {c["id"] for c in norm_cands if c["id"]}
262
+ if not valid_ids:
263
+ return None
264
+
265
+ # config 추출 — data_sources.kosis.catalog_ranker
266
+ _cfg = ((config or {}).get("data_sources") or {}).get("kosis") or {}
267
+ _rk = _cfg.get("catalog_ranker") or {}
268
+ model_tier = str(_rk.get("model_tier") or _DEFAULT_MODEL_TIER).strip().lower()
269
+
270
+ # 소스 프로파일이 있으면 그 컨텍스트로 *중립 랭커*를 쓴다 (KOSIS 편향 제거).
271
+ _plan = ((config or {}).get("_source_profile") or {}).get("retrieval_plan") or {}
272
+ _source_context = str(_plan.get("ranker_context") or "")
273
+ prompt = _build_prompt(
274
+ claim_text=claim_text,
275
+ indicator=indicator,
276
+ population=population,
277
+ time_period=time_period,
278
+ parent_path=parent_path,
279
+ candidates=norm_cands,
280
+ source_context=_source_context,
281
+ )
282
+
283
+ from structverify.utils.llm_client import LLMClient
284
+ llm = LLMClient(config=(config or {}).get("llm") or {})
285
+ try:
286
+ raw = await llm.generate(
287
+ prompt=prompt,
288
+ system_prompt="KOSIS 표 ranking reviewer. JSON만 응답.",
289
+ model_tier=model_tier,
290
+ )
291
+ except Exception as e:
292
+ logger.warning(f"[catalog_ranker] LLM 호출 실패: {e}")
293
+ return None
294
+
295
+ parsed = _parse(raw, valid_ids)
296
+ if parsed is None:
297
+ logger.warning(f"[catalog_ranker] 응답 파싱 실패 — raw={raw[:300]!r}")
298
+ return None
299
+
300
+ return parsed