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,396 @@
1
+ """structverify.retrieval.custom_db_source — Custom DB DataSource.
2
+
3
+ 회사 자체 DB(테이블)를 정답 데이터로 사용. CustomCSV와 동일한 인터페이스로,
4
+ 행을 CSV 대신 **SQL 테이블에서 조회**한다.
5
+
6
+ SQLAlchemy DSN 하나로 sqlite / postgresql / snowflake / mysql 등을 지원:
7
+ sqlite:///path.db
8
+ postgresql://user:pw@host:5432/db
9
+ snowflake://user:pw@account/DB/SCHEMA?warehouse=WH
10
+
11
+ 가정 테이블 형태: indicator, year, region, value, unit (CSV와 동일).
12
+ 컬럼명이 다르면 column_mapping 으로 매핑. (Snowflake는 컬럼이 대문자로 오므로 매핑 권장.)
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import re
18
+ from typing import Any
19
+
20
+ from .base import CatalogCandidate, EvidenceData
21
+ from .custom_csv_source import CustomCSVDataSource, _DEFAULT_COLUMN_MAPPING
22
+ from .registry import register_datasource
23
+ from structverify.utils.logger import get_logger
24
+
25
+ logger = get_logger(__name__)
26
+
27
+ # ── 에이전틱(text-to-SQL) 모드 SQL 안전 가드 ──
28
+ # 원장형 원시 테이블을 정답으로 쓸 때, LLM이 claim마다 집계 SQL을 생성한다.
29
+ # 파괴적/부작용 SQL을 원천 차단 — SELECT/WITH 로 시작하는 단일문만 허용.
30
+ _SQL_FORBIDDEN = re.compile(
31
+ r"\b(insert|update|delete|drop|alter|create|merge|truncate|grant|revoke|"
32
+ r"call|copy|use|set|comment|put|remove|unload)\b",
33
+ re.IGNORECASE,
34
+ )
35
+
36
+
37
+ def _is_safe_select(sql: str) -> bool:
38
+ """읽기 전용 단일 SELECT/WITH 문인지 검증. 아니면 실행 거부."""
39
+ s = (sql or "").strip().rstrip(";").strip()
40
+ if not s:
41
+ return False
42
+ if not re.match(r"(?is)^\s*(with|select)\b", s):
43
+ return False
44
+ if ";" in s: # 다중문 차단
45
+ return False
46
+ if _SQL_FORBIDDEN.search(s):
47
+ return False
48
+ return True
49
+
50
+
51
+ @register_datasource("custom_db")
52
+ class CustomDBDataSource(CustomCSVDataSource):
53
+ """회사 DB 테이블 데이터소스 (CustomCSV 상속 — 조회만 DB로).
54
+
55
+ config 예 (data_sources.custom_db):
56
+ dsn: "sqlite:///company.db" # 또는 dsn_env 로 환경변수 지정
57
+ dsn_env: "CUSTOM_DB_DSN"
58
+ table: "reference_values" # 조회할 테이블 (스키마 포함 가능)
59
+ query: "SELECT ... FROM ..." # (선택) 직접 SQL — table 대신
60
+ column_mapping: {value: "VAL", ...} # 테이블 컬럼명 매핑 (선택)
61
+ """
62
+
63
+ name = "custom_db"
64
+
65
+ def __init__(self, **config: Any):
66
+ self.config = config
67
+ # DSN: 직접 주입 우선, 없으면 환경변수(dsn_env, 기본 CUSTOM_DB_DSN)
68
+ self.dsn = config.get("dsn") or os.getenv(
69
+ config.get("dsn_env") or "CUSTOM_DB_DSN", ""
70
+ )
71
+ self.table = config.get("table") or config.get("catalog_table") or ""
72
+ self.query = config.get("query") # 직접 SQL (선택)
73
+ self.column_mapping = {
74
+ **_DEFAULT_COLUMN_MAPPING,
75
+ **(config.get("column_mapping") or {}),
76
+ }
77
+ self._cache: list[dict[str, str]] | None = None
78
+ # 임베딩 검색 필드 (부모 CustomCSVDataSource와 동일 — 지표 방대 시 의미검색).
79
+ # __init__을 완전히 오버라이드하므로 여기서 직접 세팅해야 한다.
80
+ self._embed_cfg = config.get("embedding") or {}
81
+ self._embed_threshold = int(config.get("embed_threshold", 200))
82
+ self._embed_client = None
83
+ self._ind_index: list[dict] | None = None
84
+ # ── 에이전틱(text-to-SQL) 모드 ──
85
+ # agentic=True면 '지표=값' 정돈 테이블을 가정하지 않고, 원시 테이블 스키마를
86
+ # 조사해 claim마다 집계 SQL을 LLM이 생성/실행한다("총 매출=SUM(...)"을 자동 정의).
87
+ self.agentic = bool(config.get("agentic"))
88
+ self.tables = list(config.get("tables") or []) # (선택) 조사 대상 테이블 화이트리스트
89
+ self._schema_text: str | None = None
90
+ logger.info(
91
+ f"[CustomDBDataSource] 초기화: table={self.table!r}, "
92
+ f"dsn={'(set)' if self.dsn else '(none)'}, column_mapping={self.column_mapping}"
93
+ )
94
+
95
+ def _read_rows(self) -> list[dict[str, str]]:
96
+ """DB 테이블 전체 행을 dict 리스트로 (문자열 정규화). 1회 캐시."""
97
+ if self._cache is not None:
98
+ return self._cache
99
+ if not self.dsn:
100
+ logger.warning("[CustomDBDataSource] DSN 없음 (dsn 또는 CUSTOM_DB_DSN 설정 필요)")
101
+ return []
102
+ try:
103
+ import sqlalchemy as sa
104
+ except ImportError as e: # pragma: no cover
105
+ raise ImportError(
106
+ "DB 커넥터는 SQLAlchemy가 필요합니다: pip install \"structverify[db]\" "
107
+ "(Snowflake는 추가로 snowflake-sqlalchemy)"
108
+ ) from e
109
+
110
+ sql = self.query or f"SELECT * FROM {self.table}"
111
+ try:
112
+ engine = sa.create_engine(self.dsn)
113
+ with engine.connect() as conn:
114
+ result = conn.execute(sa.text(sql))
115
+ raw = [dict(r._mapping) for r in result]
116
+ except Exception as e: # noqa: BLE001 — 조회 실패는 빈 결과로(파이프라인 보호)
117
+ logger.warning(f"[CustomDBDataSource] DB 조회 실패: {e}")
118
+ return []
119
+
120
+ # 값 str 정규화(None→"") + 컬럼명 casing 정규화.
121
+ # DB/드라이버마다 결과 키 대소문자가 다르다(snowflake-sqlalchemy는 소문자,
122
+ # postgres는 소문자, 일부는 대문자). column_mapping이 어떤 casing이든 맞도록,
123
+ # 각 행에 매핑 대상 이름을 case-insensitive로 채워 넣는다.
124
+ targets = {v for v in self.column_mapping.values() if isinstance(v, str)}
125
+ rows: list[dict[str, str]] = []
126
+ for row in raw:
127
+ low = {
128
+ (k.lower() if isinstance(k, str) else k): ("" if v is None else str(v))
129
+ for k, v in row.items()
130
+ }
131
+ norm = dict(low) # 소문자 키 접근 보존
132
+ for t in targets: # 매핑이 요구하는 정확한 이름으로도 접근 가능하게
133
+ norm[t] = low.get(t.lower(), low.get(t, ""))
134
+ rows.append(norm)
135
+ self._cache = rows
136
+ logger.info(f"[CustomDBDataSource] {len(rows)}행 로드 (table={self.table!r})")
137
+ return rows
138
+
139
+ # ══════════════════════════════════════════════════════════════════
140
+ # 에이전틱(text-to-SQL) 모드 — 원시 테이블을 정답으로. 스키마 조사 후
141
+ # claim마다 집계 SQL을 LLM이 생성/실행. ('총 매출=SUM(...)' 자동 정의)
142
+ # ══════════════════════════════════════════════════════════════════
143
+
144
+ def _engine(self):
145
+ import sqlalchemy as sa
146
+ return sa.create_engine(self.dsn)
147
+
148
+ def _list_tables(self) -> list[str]:
149
+ """조사 대상 테이블. tables 화이트리스트 우선, 없으면 스키마에서 자동 탐색."""
150
+ if self.tables:
151
+ return self.tables
152
+ try:
153
+ import sqlalchemy as sa
154
+ insp = sa.inspect(self._engine())
155
+ return list(insp.get_table_names())[:12]
156
+ except Exception as e: # noqa: BLE001
157
+ logger.warning(f"[custom_db/agentic] 테이블 자동탐색 실패: {e}")
158
+ return []
159
+
160
+ @staticmethod
161
+ def _infer_relationships(table_cols: dict[str, list[str]]) -> str:
162
+ """FK 메타가 없을 때, 컬럼명으로 테이블 간 조인 관계를 추정.
163
+
164
+ 분석용 DB는 FK 선언이 없는 경우가 많다. 대신 key/id 성격 컬럼을 테이블 접두어를
165
+ 떼고(base) 묶어, 같은 base를 가진 서로 다른 테이블의 컬럼쌍을 조인 후보로 본다.
166
+ (예: ORDERS.o_custkey ↔ CUSTOMER.c_custkey — 둘 다 base='custkey')
167
+ """
168
+ from collections import defaultdict
169
+ groups: dict[str, list[tuple[str, str]]] = defaultdict(list)
170
+ for t, cols in table_cols.items():
171
+ for c in cols:
172
+ cl = str(c).lower()
173
+ base = re.sub(r"^[a-z]+_", "", cl) # 테이블 접두어 제거
174
+ if base.endswith("key") or base.endswith("id"):
175
+ groups[base].append((t, c))
176
+ lines: list[str] = []
177
+ for base, tc in groups.items():
178
+ if len(tc) < 2:
179
+ continue
180
+ for i in range(len(tc)):
181
+ for j in range(i + 1, len(tc)):
182
+ lines.append(f" {tc[i][0]}.{tc[i][1]} = {tc[j][0]}.{tc[j][1]}")
183
+ return "\n".join(lines)
184
+
185
+ def _introspect_schema(self) -> str:
186
+ """각 테이블을 LIMIT 조회해 (컬럼명 + 샘플값 + 조인관계)로 스키마 텍스트 구성. 1회 캐시.
187
+
188
+ DB 방언 무관하게 동작하도록 information_schema 대신 `SELECT * ... LIMIT`을 쓴다.
189
+ FK 메타가 없어도 컬럼명으로 조인 관계를 추정해 넣어(다중테이블 집계 신뢰도↑).
190
+ """
191
+ if self._schema_text is not None:
192
+ return self._schema_text
193
+ import sqlalchemy as sa
194
+ eng = self._engine()
195
+ parts: list[str] = []
196
+ table_cols: dict[str, list[str]] = {}
197
+ for t in self._list_tables():
198
+ try:
199
+ with eng.connect() as conn:
200
+ res = conn.execute(sa.text(f"SELECT * FROM {t} LIMIT 3"))
201
+ cols = list(res.keys())
202
+ sample = [dict(r._mapping) for r in res]
203
+ table_cols[t] = [str(c) for c in cols]
204
+ col_line = ", ".join(str(c) for c in cols)
205
+ samp_line = ""
206
+ if sample:
207
+ first = {k: sample[0][k] for k in list(sample[0])[:8]}
208
+ samp_line = f"\n 예시행: {first}"
209
+ parts.append(f"- 테이블 {t} (컬럼: {col_line}){samp_line}")
210
+ except Exception as e: # noqa: BLE001
211
+ logger.warning(f"[custom_db/agentic] 스키마 조사 실패({t}): {e}")
212
+ rels = self._infer_relationships(table_cols)
213
+ if rels:
214
+ parts.append(f"\n[테이블 간 조인 관계 (컬럼명 기반 추정 — JOIN에 사용)]\n{rels}")
215
+ self._schema_text = "\n".join(parts) if parts else "(스키마 조사 실패)"
216
+ logger.info(
217
+ f"[custom_db/agentic] 스키마 조사: {len(table_cols)}개 테이블"
218
+ f"{', 조인관계 ' + str(len(rels.splitlines())) + '쌍' if rels else ''}"
219
+ )
220
+ return self._schema_text
221
+
222
+ async def propose_metrics(self, config: dict | None = None) -> dict[str, Any]:
223
+ """스키마를 보고 이 데이터로 검증 가능한 지표 후보를 LLM이 제안(프로파일러용)."""
224
+ schema = self._introspect_schema()
225
+ from structverify.utils.llm_client import LLMClient
226
+ llm = LLMClient(config=(config or {}).get("llm", {}))
227
+ prompt = (
228
+ "아래는 회사 DB의 원시 테이블 스키마다. 이 데이터를 SQL 집계로 검증할 수 있는 "
229
+ "'지표(metric)' 후보를 제안하라. 트랜잭션 원장이면 합계/건수/평균 등으로 지표를 만든다.\n\n"
230
+ f"[스키마]\n{schema}\n\n"
231
+ "JSON만:\n"
232
+ '{"domain":"영문 스네이크 도메인","description":"이 데이터가 다루는 내용 한 문장",'
233
+ '"metrics":[{"indicator":"지표명(한글)","unit":"단위"}, ...]}'
234
+ )
235
+ try:
236
+ out = await llm.generate_json_light(prompt) or {}
237
+ except Exception as e: # noqa: BLE001
238
+ logger.warning(f"[custom_db/agentic] 지표 제안 실패: {e}")
239
+ out = {}
240
+ metrics = out.get("metrics") or []
241
+ inds = [str(m.get("indicator", "")).strip() for m in metrics if m.get("indicator")]
242
+ units = [str(m.get("unit", "")).strip() for m in metrics if m.get("unit")]
243
+ return {
244
+ "domain": str(out.get("domain", "") or "").strip(),
245
+ "description": str(out.get("description", "") or "").strip(),
246
+ "indicators": inds,
247
+ "units": list(dict.fromkeys(units)),
248
+ "samples": [f"{i} ({u})" for i, u in zip(inds, units)][:6],
249
+ }
250
+
251
+ async def _agentic_fetch(
252
+ self, indicator: str, params: dict[str, Any]
253
+ ) -> EvidenceData | None:
254
+ """claim (지표/시점/대상)에 대해 LLM이 집계 SQL 생성 → 실행 → 단일 값 반환.
255
+
256
+ LLM SQL은 가끔 FROM 누락/오타가 있으므로, 에러를 피드백해 최대 2회 재시도한다.
257
+ """
258
+ schema = self._introspect_schema()
259
+ dialect = (self.dsn.split(":", 1)[0] if self.dsn else "sql").split("+")[0]
260
+ tp = str(params.get("time_period") or "").strip()
261
+ pop = str(params.get("population") or "").strip()
262
+ raw_claim = str(params.get("raw_claim") or params.get("claim_text") or "").strip()
263
+ # 목표 단위 — 파생계산 판단의 핵심 신호(%이면 비율/증감율). schema.unit에서 옴.
264
+ unit_hint = str(params.get("unit_hint") or params.get("unit") or "").strip()
265
+ _is_derived = ("%" in unit_hint) or any(
266
+ k in indicator for k in ("비율", "비중", "증감율", "증가율", "감소율", "변화율")
267
+ )
268
+ from structverify.utils.llm_client import LLMClient
269
+ llm = LLMClient(config=(self.config.get("llm") if isinstance(self.config, dict) else {}) or {})
270
+
271
+ base_prompt = (
272
+ f"너는 {dialect} SQL 분석가다. 아래 원시 테이블에서 주장 검증에 필요한 값을 "
273
+ "구하는 **완전히 실행 가능한 단일 SELECT** 집계 쿼리를 작성하라. 결과는 값 1개(스칼라).\n\n"
274
+ f"[스키마]\n{schema}\n\n"
275
+ f"[검증할 지표] {indicator}\n"
276
+ # ★ 절대값엔 스케일 단위(억/만 달러)를 목표로 주면 LLM이 raw값에 그 라벨을
277
+ # 붙여 스케일이 어긋난다. 파생(%)만 목표단위 명시, 절대값은 '원시 단위' 지시.
278
+ f"[목표 단위] {'% ← 파생 계산(비율/증감율). 규칙 8 적용.' if _is_derived else '원시 단위 — 달러/명/건 등, 억·만·조 접두어 금지(값도 나누지 말 것)'}\n"
279
+ f"[시점] {tp or '(무관/전체)'}\n"
280
+ f"[대상/지역] {pop or '(전체)'}\n"
281
+ f"[주장 원문] {raw_claim or '(없음)'}\n\n"
282
+ "규칙(엄수):\n"
283
+ "1. SELECT/WITH로 시작하는 읽기전용 단일문. 반드시 **FROM 절**로 실제 테이블을 참조.\n"
284
+ "2. 결과 컬럼 1개(집계 원시값). 컬럼/테이블명은 위 스키마에 있는 것만.\n"
285
+ "3. **최소 테이블 원칙**: 필터/집계에 필요한 컬럼이 한 테이블에 다 있으면 그 테이블만 "
286
+ "사용(불필요한 JOIN 금지 — 예: 세그먼트별 고객수는 CUSTOMER만; ORDERS를 조인하면 "
287
+ "주문 없는 고객이 빠져 값이 왜곡됨). 다른 테이블 컬럼이 실제로 필요할 때만, 위 '조인 "
288
+ "관계'의 컬럼쌍으로 JOIN. FROM에 없는 테이블/별칭 참조 금지.\n"
289
+ "4. 지표에 '누적/총/전체'가 있으면 시점 필터 없이 전체 집계. "
290
+ "특정 연도가 명시된 경우에만 날짜 컬럼으로 필터.\n"
291
+ "5. unit은 **기본 단위만**(명/건/개/달러/원 등). 억·만·조 배수 접두어 금지 "
292
+ "(값을 나누지 말고 원시 집계값 그대로 — 스케일은 비교 단계가 처리).\n"
293
+ "6. 지역/세그먼트 등 대상이 있으면 해당 명칭 컬럼으로 필터(예: 지역명, 세그먼트명).\n"
294
+ "7. **지표 의미 일관성**: 지표에 '순'(순매출·순이익 등)이 있으면 할인/공제를 반영한 "
295
+ "*순액*으로 계산(할인 컬럼이 있으면 반드시 반영, 예: 가격×(1-할인율)). '총'만 있고 "
296
+ "'순'이 없으면 총액. 같은 문서 안에서 같은 지표는 항상 같은 공식으로.\n"
297
+ "8. 세미콜론·주석·여러 문장 금지.\n"
298
+ "9. **파생 계산 여부는 오직 [검증할 지표]로 판단**(문장의 비교 문맥에 반응하지 말 것):\n"
299
+ " - [검증할 지표]가 '비율/비중/증감율/증감량' 등 *계산된 지표*이거나 단위가 %면 → "
300
+ "원시 데이터로 파생값을 SQL로 직접 계산(결과 1개, unit='%'):\n"
301
+ " · 비율/비중 = 부분 / 전체 × 100 (서브쿼리 2개를 나눔)\n"
302
+ " · 증감율 = (현재 시점 값 − 이전 시점 값) / 이전 시점 값 × 100\n"
303
+ " - [검증할 지표]가 *절대값*(매출·고객수·건수·금액 등)이면 → 문장에 '대비/감소/증가/"
304
+ "비율' 같은 표현이 있어도 **무시하고 그 절대값만** 집계(규칙 2~6 적용).\n"
305
+ " ※ 조건부 합계에 `FILTER (WHERE ...)` 금지(방언 비호환). 두 시점 비교는 "
306
+ "**서브쿼리 2개**(예5 형태)나 `CASE WHEN`/`IFF`로만.\n"
307
+ "예1(단일테이블): SELECT COUNT(DISTINCT c_custkey) FROM CUSTOMER WHERE c_mktsegment='MACHINERY'\n"
308
+ "예2(연도필터): SELECT SUM(o_totalprice) FROM ORDERS WHERE YEAR(o_orderdate)=1996\n"
309
+ "예3(조인): SELECT SUM(o_totalprice) FROM ORDERS "
310
+ "JOIN CUSTOMER ON o_custkey=c_custkey "
311
+ "JOIN NATION ON c_nationkey=n_nationkey "
312
+ "JOIN REGION ON n_regionkey=r_regionkey WHERE r_name='EUROPE'\n"
313
+ "예4(비율): SELECT (SELECT SUM(l_extendedprice*(1-l_discount)) FROM LINEITEM l "
314
+ "JOIN ORDERS o ON l.l_orderkey=o.o_orderkey JOIN CUSTOMER c ON o.o_custkey=c.c_custkey "
315
+ "JOIN NATION n ON c.c_nationkey=n.n_nationkey JOIN REGION r ON n.n_regionkey=r.r_regionkey "
316
+ "WHERE r_name='AMERICA') / (SELECT SUM(l_extendedprice*(1-l_discount)) FROM LINEITEM) * 100\n"
317
+ "예5(증감율): SELECT (a.v - b.v)/b.v*100 FROM "
318
+ "(SELECT SUM(l_extendedprice*(1-l_discount)) v FROM LINEITEM l JOIN ORDERS o ON l.l_orderkey=o.o_orderkey "
319
+ "WHERE YEAR(o.o_orderdate)=1998) a, "
320
+ "(SELECT SUM(l_extendedprice*(1-l_discount)) v FROM LINEITEM l JOIN ORDERS o ON l.l_orderkey=o.o_orderkey "
321
+ "WHERE YEAR(o.o_orderdate)=1997) b\n"
322
+ "JSON만:\n"
323
+ '{"sql":"SELECT ... FROM ...","unit":"기본단위 또는 %"}'
324
+ )
325
+
326
+ err_hint = ""
327
+ for attempt in range(3): # 복합 JOIN은 실패 시 에러 피드백으로 2회까지 재시도
328
+ prompt = base_prompt + (
329
+ f"\n\n[직전 실패] 다음 오류를 고쳐 다시 작성: {err_hint}" if err_hint else ""
330
+ )
331
+ try:
332
+ out = await llm.generate_json_light(prompt) or {}
333
+ except Exception as e: # noqa: BLE001
334
+ logger.warning(f"[custom_db/agentic] SQL 생성 실패: {e}")
335
+ return None
336
+ sql = str(out.get("sql", "") or "").strip().rstrip(";").strip()
337
+ unit = str(out.get("unit", "") or "").strip()
338
+ # ★ 결정적 단위 정규화: SQL 결과는 항상 원시값이므로, LLM이 억/만/조 접두어를
339
+ # 붙였어도 제거해 기본단위로 강제(값-단위 일관성). 파생(%)은 접두어 없어 무영향.
340
+ # 예: "억 달러" → "달러", "만 건" → "건". (스케일은 비교 단계가 처리)
341
+ _stripped = re.sub(r"^\s*(천|만|억|조)\s*", "", unit).strip()
342
+ if _stripped:
343
+ unit = _stripped
344
+ # 가드: 읽기전용 + FROM 절 필수(FROM 없는 COUNT(*)가 1을 반환하는 silent-wrong 방지)
345
+ if not _is_safe_select(sql) or not re.search(r"(?is)\bfrom\b", sql):
346
+ err_hint = f"안전하지 않거나 FROM 누락: {sql[:120]!r}"
347
+ logger.warning(f"[custom_db/agentic] SQL 거부(시도 {attempt+1}): {err_hint}")
348
+ continue
349
+ try:
350
+ import sqlalchemy as sa
351
+ with self._engine().connect() as conn:
352
+ row = conn.execute(sa.text(sql)).fetchone()
353
+ except Exception as e: # noqa: BLE001
354
+ err_hint = " ".join(str(e).split())[:300] # 개행 제거·더 많은 detail 전달
355
+ logger.warning(f"[custom_db/agentic] SQL 실행 실패(시도 {attempt+1}): {err_hint} — sql={sql[:120]!r}")
356
+ continue
357
+ if row is None or row[0] is None:
358
+ err_hint = "결과가 비었음 — 필터/조인 확인"
359
+ logger.info(f"[custom_db/agentic] 결과 없음(시도 {attempt+1}) — sql={sql[:120]!r}")
360
+ continue
361
+ try:
362
+ value = float(row[0])
363
+ except (TypeError, ValueError):
364
+ logger.warning(f"[custom_db/agentic] 스칼라 아님: {row[0]!r}")
365
+ return None
366
+ logger.info(
367
+ f"[custom_db/agentic] '{indicator}'(시점 {tp or '전체'}) → {value:g}{unit} "
368
+ f"| SQL={sql[:100]}"
369
+ )
370
+ return EvidenceData({
371
+ "value": value, "unit": unit, "time_period": tp,
372
+ "operator": "", "source": self.name, "indicator": indicator,
373
+ "region": pop, "matched_row": {"sql": sql},
374
+ })
375
+ logger.warning(f"[custom_db/agentic] '{indicator}' SQL 3회 실패 — 포기")
376
+ return None
377
+
378
+ # ── BaseDataSource 오버라이드 (에이전틱이면 SQL 경로, 아니면 정돈-테이블 경로) ──
379
+
380
+ async def search_catalog(
381
+ self, query: str, category: list | None = None,
382
+ top_k: int = 10, context: dict | None = None,
383
+ ) -> list[CatalogCandidate]:
384
+ if not self.agentic:
385
+ return await super().search_catalog(query, category, top_k, context)
386
+ # 에이전틱: 후보 발견이 따로 없다(값은 fetch에서 SQL로 계산). query 자체를 후보로.
387
+ return [CatalogCandidate({"id": query, "name": query, "score": 1.0})]
388
+
389
+ async def fetch_evidence(
390
+ self, candidate_id: str, params: dict | None = None, workspace: Any = None,
391
+ ) -> EvidenceData | None:
392
+ if not self.agentic:
393
+ return await super().fetch_evidence(candidate_id, params, workspace)
394
+ params = params or {}
395
+ indicator = str(params.get("indicator") or candidate_id or "").strip()
396
+ return await self._agentic_fetch(indicator, params)
@@ -0,0 +1,152 @@
1
+ """structverify.retrieval.custom_docs_source — 문서형(광범위) 회사 데이터.
2
+
3
+ 사규·규정·계약 등 긴 자연어 문서를 **청킹 → 임베딩 → 의미검색** 한다.
4
+ 표형(custom_csv, 키워드)과 달리, 동의어/의역까지 매칭.
5
+
6
+ - 색인: 문서 로드 → chunk_text로 조각 → 각 조각을 EmbeddingClient(config.embedding)로 임베딩.
7
+ - 저장: in-memory (프로세스 내). 대규모는 pgvector 백엔드로 확장(계획 P3).
8
+ - 검색: 질의 임베딩 → 코사인 top-k 청크.
9
+ - 임베딩 provider는 config.embedding(hcx/openai/upstage) — 색인·질의 동일 client라 차원 self-consistent.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ import os
15
+ from typing import Any
16
+
17
+ from .base import BaseDataSource, CatalogCandidate, EvidenceData
18
+ from .chunking import chunk_text, read_document
19
+ from .registry import register_datasource
20
+ from structverify.utils.embedding_client import EmbeddingClient
21
+ from structverify.utils.logger import get_logger
22
+
23
+ logger = get_logger(__name__)
24
+
25
+
26
+ def _cosine(a: list[float], b: list[float]) -> float:
27
+ dot = sum(x * y for x, y in zip(a, b))
28
+ na = math.sqrt(sum(x * x for x in a))
29
+ nb = math.sqrt(sum(y * y for y in b))
30
+ return dot / (na * nb) if na and nb else 0.0
31
+
32
+
33
+ @register_datasource("custom_docs")
34
+ class CustomDocsDataSource(BaseDataSource):
35
+ """문서형 회사 데이터 — 청킹 후 임베딩 → 의미검색 (in-memory).
36
+
37
+ config 예 (data_sources.custom_docs):
38
+ docs_path: "./uploads/regulations" # .txt/.md 파일 또는 디렉토리
39
+ chunk_size: 400
40
+ chunk_overlap: 60
41
+ top_k: 5
42
+ embedding: { provider: "upstage" } # 색인·질의 임베딩 provider
43
+ """
44
+
45
+ name = "custom_docs"
46
+
47
+ def __init__(self, **config: Any):
48
+ self.config = config
49
+ self.docs_path = (
50
+ config.get("docs_path") or config.get("csv_path")
51
+ or config.get("base_path") or ""
52
+ )
53
+ self.chunk_size = int(config.get("chunk_size", 400))
54
+ self.overlap = int(config.get("chunk_overlap", 60))
55
+ self.top_k_default = int(config.get("top_k", 5))
56
+ self._client = EmbeddingClient(config.get("embedding") or {})
57
+ self._index: list[dict[str, Any]] | None = None # [{id, text, vec}]
58
+ logger.info(
59
+ f"[CustomDocsDataSource] docs_path={self.docs_path!r} "
60
+ f"chunk_size={self.chunk_size} provider={self._client.provider}"
61
+ )
62
+
63
+ def _load_text(self) -> str:
64
+ p = self.docs_path
65
+ if not p or not os.path.exists(p):
66
+ logger.warning(f"[CustomDocsDataSource] 경로 없음: {p!r}")
67
+ return ""
68
+ if os.path.isdir(p):
69
+ texts = [
70
+ read_document(os.path.join(p, fn))
71
+ for fn in sorted(os.listdir(p))
72
+ if fn.lower().endswith((".txt", ".md", ".pdf"))
73
+ ]
74
+ return "\n\n".join(t for t in texts if t)
75
+ return read_document(p)
76
+
77
+ async def _ensure_index(self) -> None:
78
+ """최초 검색 시 1회: 문서 로드 → 청킹 → 임베딩 (in-memory)."""
79
+ if self._index is not None:
80
+ return
81
+ chunks = chunk_text(
82
+ self._load_text(), chunk_size=self.chunk_size, overlap=self.overlap,
83
+ )
84
+ idx: list[dict[str, Any]] = []
85
+ for i, c in enumerate(chunks):
86
+ vec = await self._client.embed(c, role="passage") # 문서 색인 → passage 모델
87
+ if vec is None:
88
+ logger.warning(f"[CustomDocsDataSource] 청크 {i} 임베딩 실패 — skip")
89
+ continue
90
+ idx.append({"id": f"chunk#{i}", "text": c, "vec": vec})
91
+ self._index = idx
92
+ logger.info(
93
+ f"[CustomDocsDataSource] 색인 완료: {len(idx)}/{len(chunks)} 청크 임베딩 "
94
+ f"(provider={self._client.provider}, dim={len(idx[0]['vec']) if idx else 0})"
95
+ )
96
+
97
+ async def build_index(self) -> int:
98
+ """색인을 즉시 빌드하고 청크 수 반환 (업로드 직후 호출용)."""
99
+ await self._ensure_index()
100
+ return len(self._index or [])
101
+
102
+ async def search_catalog(
103
+ self, query: str, category: list[str] | None = None,
104
+ top_k: int | None = None, context: dict[str, Any] | None = None,
105
+ ) -> list[CatalogCandidate]:
106
+ await self._ensure_index()
107
+ if not self._index:
108
+ return []
109
+ qv = await self._client.embed(query, role="query") # 검색어 → query 모델
110
+ if qv is None:
111
+ logger.warning("[CustomDocsDataSource] 질의 임베딩 실패")
112
+ return []
113
+ scored = sorted(
114
+ ((_cosine(qv, it["vec"]), it) for it in self._index),
115
+ key=lambda x: x[0], reverse=True,
116
+ )
117
+ k = top_k or self.top_k_default
118
+ out: list[CatalogCandidate] = [
119
+ {
120
+ "id": it["id"],
121
+ "name": it["text"][:40].replace("\n", " "),
122
+ "score": round(float(sc), 4),
123
+ }
124
+ for sc, it in scored[:k]
125
+ ]
126
+ logger.info(
127
+ f"[CustomDocsDataSource] search(query={query!r}): "
128
+ f"{len(out)}개 (top={out[0]['score'] if out else None})"
129
+ )
130
+ return out
131
+
132
+ async def fetch_evidence(
133
+ self, candidate_id: str, params: dict[str, Any] | None = None,
134
+ workspace: Any = None,
135
+ ) -> EvidenceData | None:
136
+ """검색된 청크 텍스트를 근거로 반환. (문서형 — 수치 없음, 텍스트가 근거)
137
+
138
+ 수치 규정(예: '한도는 10만원')이면 후속 단계에서 텍스트→값 추출/LLM 준수판정 필요.
139
+ """
140
+ if self._index is None:
141
+ await self._ensure_index()
142
+ it = next((x for x in (self._index or []) if x["id"] == candidate_id), None)
143
+ if it is None:
144
+ return None
145
+ return {
146
+ "value": None,
147
+ "unit": "",
148
+ "time_period": "",
149
+ "text": it["text"], # 검색된 규정 조각 (근거)
150
+ "source": "custom_docs",
151
+ "indicator": candidate_id,
152
+ }