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
structverify/api.py ADDED
@@ -0,0 +1,804 @@
1
+ """structverify.api — the ergonomic, high-level public API.
2
+
3
+ This is the "point-and-call" surface most users touch, inspired by the feel of
4
+ libraries like ``transformers``: configure once, then call a method and read a
5
+ plain boolean / result off the returned object.
6
+
7
+ >>> import structverify as sv
8
+ >>>
9
+ >>> # 규정 준수 검사 (conformance) — bring your own rulebook (PDF/txt)
10
+ >>> rules = sv.Ruleset.from_file("safety_standard.pdf", provider="upstage")
11
+ >>> v = rules.check("총납 함량은 120mg/kg으로 측정되었다")
12
+ >>> v.compliant # False ← plain True/False
13
+ False
14
+ >>> v.article # "제3조 (총납) … 100mg/kg 이하"
15
+ >>> v.rule_value, v.claim_value, v.unit
16
+ (100.0, 120.0, 'mg/kg')
17
+ >>>
18
+ >>> # 사실 검증 (fact verification) against a data source
19
+ >>> report = sv.verify("과수농가 65세 이상 비율은 64.2%다", provider="upstage")
20
+ >>> report.ok # no false claim detected?
21
+ >>> for r in report:
22
+ ... r.ok, r.verdict, r.reason
23
+
24
+ Everything here is synchronous by default (each call blocks and returns a
25
+ result). Every sync method has an ``a``-prefixed async twin
26
+ (``check``/``acheck``, ``verify``/``averify``) for use inside an event loop.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import copy
32
+ import os
33
+ import re
34
+ import tempfile
35
+ import threading
36
+ from typing import Any, Iterator
37
+
38
+ from structverify.utils.logger import configure_logging # re-export
39
+
40
+ __all__ = [
41
+ "verify",
42
+ "Verifier",
43
+ "Ruleset",
44
+ "DataSource",
45
+ "Report",
46
+ "Result",
47
+ "Verdict",
48
+ "build_config",
49
+ "configure_logging",
50
+ ]
51
+
52
+
53
+ # ── sync/async bridge ────────────────────────────────────────────────────────
54
+
55
+ _loop: asyncio.AbstractEventLoop | None = None
56
+ _loop_lock = threading.Lock()
57
+
58
+
59
+ def _background_loop() -> asyncio.AbstractEventLoop:
60
+ """A single, long-lived event loop running in a daemon thread.
61
+
62
+ All sync calls run on this one loop, so stateful async resources created by
63
+ one call (e.g. an ``httpx.AsyncClient`` opened while indexing a ruleset)
64
+ stay bound to a live loop across subsequent calls — unlike ``asyncio.run``,
65
+ which opens and closes a fresh loop every time.
66
+ """
67
+ global _loop
68
+ if _loop is not None and not _loop.is_closed():
69
+ return _loop
70
+ with _loop_lock:
71
+ if _loop is None or _loop.is_closed():
72
+ loop = asyncio.new_event_loop()
73
+ threading.Thread(
74
+ target=loop.run_forever, name="structverify", daemon=True,
75
+ ).start()
76
+ _loop = loop
77
+ return _loop
78
+
79
+
80
+ def _run(coro):
81
+ """Run an async coroutine to completion from synchronous code.
82
+
83
+ Works everywhere — including inside a notebook or an already-running event
84
+ loop — because the coroutine executes on a dedicated background loop and the
85
+ calling thread simply blocks on the result.
86
+ """
87
+ return asyncio.run_coroutine_threadsafe(coro, _background_loop()).result()
88
+
89
+
90
+ # ── config building ──────────────────────────────────────────────────────────
91
+
92
+ def _deep_merge(base: dict, overlay: dict) -> dict:
93
+ for k, v in overlay.items():
94
+ if isinstance(v, dict) and isinstance(base.get(k), dict):
95
+ _deep_merge(base[k], v)
96
+ else:
97
+ base[k] = v
98
+ return base
99
+
100
+
101
+ class DataSource:
102
+ """A ground-truth data source for fact verification.
103
+
104
+ Point :class:`Verifier` at *your company's* data instead of (or alongside)
105
+ the public KOSIS statistics catalog.
106
+
107
+ >>> sv.Verifier(provider="upstage", data=sv.DataSource.csv("budget.csv"))
108
+ >>> sv.Verifier(provider="upstage", data="reference.csv") # str is inferred
109
+
110
+ Factories:
111
+ * :meth:`csv` — a tabular reference file (``indicator,…,value[,operator]``).
112
+ * :meth:`docs` — company documents (PDF/txt) searched semantically.
113
+ * :meth:`kosis` — the built-in public statistics catalog.
114
+ """
115
+
116
+ __slots__ = ("name", "config")
117
+
118
+ def __init__(self, name: str, config: dict):
119
+ self.name = name
120
+ self.config = config
121
+
122
+ @classmethod
123
+ def csv(cls, path: str, *, columns: dict | None = None) -> "DataSource":
124
+ """A company CSV of reference values (rows of ``indicator,…,value``).
125
+
126
+ Args:
127
+ path: Path to the CSV file.
128
+ columns: Optional mapping of your column names onto the expected
129
+ fields, e.g. ``{"value": "amount", "operator": "op"}``.
130
+ """
131
+ cfg: dict[str, Any] = {"csv_path": path}
132
+ if columns:
133
+ cfg["column_mapping"] = columns
134
+ return cls("custom_csv", cfg)
135
+
136
+ @classmethod
137
+ def docs(cls, path: str, *, chunk_size: int = 120,
138
+ embedding: dict | None = None) -> "DataSource":
139
+ """Company documents (PDF/txt/md) used as searchable ground truth."""
140
+ cfg: dict[str, Any] = {"docs_path": path, "chunk_size": chunk_size}
141
+ if embedding:
142
+ cfg["embedding"] = embedding
143
+ return cls("custom_docs", cfg)
144
+
145
+ @classmethod
146
+ def db(cls, dsn: str, *, table: str | None = None, query: str | None = None,
147
+ columns: dict | None = None, agentic: bool = False,
148
+ tables: list | None = None, use_embedding: str | bool | None = None,
149
+ embed_threshold: int | None = None) -> "DataSource":
150
+ """A company SQL database table of reference values.
151
+
152
+ Works with any SQLAlchemy DSN — SQLite, PostgreSQL, Snowflake, MySQL, … .
153
+ Needs the ``[db]`` extra (``pip install "structverify[db]"``); Snowflake
154
+ additionally needs ``snowflake-sqlalchemy``.
155
+
156
+ Args:
157
+ dsn: SQLAlchemy connection string, e.g.
158
+ ``"postgresql://user:pw@host/db"`` or
159
+ ``"snowflake://user:pw@account/DB/SCHEMA?warehouse=WH"``.
160
+ table: Table to read (schema-qualified if needed).
161
+ query: A full SQL query to run instead of ``SELECT * FROM table``.
162
+ columns: Map your column names onto the expected fields, e.g.
163
+ ``{"indicator": "NAME", "value": "AMOUNT"}``.
164
+ agentic: Fully-agentic mode for **raw transactional tables**. Instead of
165
+ assuming a tidy ``(indicator, value, …)`` table, the agent inspects
166
+ the schema and writes an aggregation ``SELECT`` per claim (read-only,
167
+ guarded) — so ``"total revenue = SUM(...)"`` is defined automatically,
168
+ no config needed. Use for row-level data (orders, events, ledgers).
169
+ tables: (agentic) Optional whitelist of tables to inspect. If omitted,
170
+ tables are auto-discovered from the connection's schema.
171
+ """
172
+ cfg: dict[str, Any] = {"dsn": dsn}
173
+ if table:
174
+ cfg["table"] = table
175
+ if query:
176
+ cfg["query"] = query
177
+ if columns:
178
+ cfg["column_mapping"] = columns
179
+ if agentic:
180
+ # 에이전틱 모드: 원시 테이블 스키마를 조사해 claim마다 집계 SQL을 자동 생성.
181
+ # 지표 정의(총 매출=SUM(...))를 사람이 config에 줄 필요 없음.
182
+ cfg["agentic"] = True
183
+ if tables:
184
+ cfg["tables"] = list(tables)
185
+ # 임베딩 의미검색 제어 (정돈형 소스). auto(기본)=규모 기반, true/false=명시.
186
+ if use_embedding is not None:
187
+ cfg["use_embedding"] = use_embedding
188
+ if embed_threshold is not None:
189
+ cfg["embed_threshold"] = int(embed_threshold)
190
+ return cls("custom_db", cfg)
191
+
192
+ @classmethod
193
+ def kosis(cls) -> "DataSource":
194
+ """The built-in public KOSIS statistics catalog."""
195
+ return cls("kosis", {})
196
+
197
+ @classmethod
198
+ def _coerce(cls, data: "DataSource | str | None") -> "DataSource | None":
199
+ """Accept a DataSource, a path string (type inferred by extension), or None."""
200
+ if data is None or isinstance(data, cls):
201
+ return data
202
+ if isinstance(data, str):
203
+ ext = os.path.splitext(data)[1].lower()
204
+ if ext == ".csv":
205
+ return cls.csv(data)
206
+ if ext in (".pdf", ".txt", ".md", ".docx"):
207
+ return cls.docs(data)
208
+ raise ValueError(f"cannot infer a DataSource from path {data!r}; use DataSource.csv/docs()")
209
+ raise TypeError(f"data must be a DataSource or path string, got {type(data).__name__}")
210
+
211
+ def _overlay(self) -> dict:
212
+ """The ``data_sources`` config fragment that enables just this source.
213
+
214
+ Emits a *copy* of ``self.config`` so callers (e.g. ``build_config``)
215
+ can layer defaults onto the overlay without mutating this DataSource.
216
+ """
217
+ return {
218
+ "data_sources": {
219
+ "enabled": [self.name],
220
+ "default_source": self.name,
221
+ self.name: dict(self.config),
222
+ }
223
+ }
224
+
225
+ def __repr__(self) -> str:
226
+ return f"<DataSource {self.name!r} {self.config}>"
227
+
228
+
229
+ def build_config(
230
+ *,
231
+ provider: str | None = None,
232
+ api_key: str | None = None,
233
+ model: str | None = None,
234
+ embedding_provider: str | None = None,
235
+ embedding_api_key: str | None = None,
236
+ tolerance: float | None = None,
237
+ data: "DataSource | str | None" = None,
238
+ extra: dict | None = None,
239
+ ) -> dict[str, Any]:
240
+ """Build a full engine config from a few high-level knobs.
241
+
242
+ Starts from the packaged defaults (``config/default.yaml``) and overlays only
243
+ what you pass. ``api_key`` is injected directly (``_direct_api_key``) so no
244
+ environment variable is required. When ``embedding_provider`` is omitted it
245
+ mirrors ``provider`` (and reuses ``api_key`` for embeddings).
246
+
247
+ Args:
248
+ provider: LLM provider — ``"upstage"`` | ``"openai"`` | ``"gemini"`` | ``"hcx"``.
249
+ api_key: API key for the LLM provider. If omitted, the provider's
250
+ environment variable is used.
251
+ model: Override the provider's default chat model.
252
+ embedding_provider: Embedding provider (defaults to ``provider``).
253
+ embedding_api_key: Embedding key (defaults to ``api_key`` when the
254
+ embedding provider matches the LLM provider).
255
+ tolerance: Numeric comparison tolerance in percent.
256
+ data: Ground-truth source for fact verification — a :class:`DataSource`
257
+ or a path string. Defaults to the config's existing source (KOSIS).
258
+ extra: Any additional deep-merge overrides for the raw config.
259
+
260
+ Returns:
261
+ A config dict ready to hand to :class:`Verifier` or the pipeline.
262
+ """
263
+ from structverify.core.config_loader import load_config
264
+
265
+ cfg = copy.deepcopy(load_config())
266
+
267
+ llm = cfg.setdefault("llm", {})
268
+ if provider:
269
+ llm["provider"] = provider
270
+ if api_key:
271
+ llm["_direct_api_key"] = api_key
272
+ if model:
273
+ # LLMClient resolves models per tier (heavy/light/structured); a single
274
+ # requested model overrides every tier.
275
+ llm["models"] = {"heavy": model, "light": model, "structured": model}
276
+
277
+ emb = cfg.setdefault("embedding", {})
278
+ emb_prov = embedding_provider or provider
279
+ if emb_prov:
280
+ emb["provider"] = emb_prov
281
+ # Reuse the LLM key for embeddings only when the resolved embedding provider
282
+ # is the *same* as the LLM provider (not merely when it was left unset).
283
+ ek = embedding_api_key or (api_key if emb_prov == provider else None)
284
+ if ek:
285
+ emb["_direct_api_key"] = ek
286
+
287
+ if tolerance is not None:
288
+ cfg.setdefault("verification", {})["tolerance_percent"] = tolerance
289
+
290
+ source = DataSource._coerce(data)
291
+ if source is not None:
292
+ # A company source needs the same embedding provider/key for search.
293
+ src_overlay = source._overlay()
294
+ # docs/csv/db 모두 임베딩 검색(지표 방대 시)을 쓸 수 있으므로 embedding 주입.
295
+ if source.name in ("custom_docs", "custom_csv", "custom_db"):
296
+ # Fresh dict so we never alias (nor mutate) cfg["embedding"].
297
+ src_overlay["data_sources"][source.name].setdefault("embedding", dict(cfg["embedding"]))
298
+ # 에이전틱 DB 소스는 fetch 시 claim마다 SQL을 생성하므로 LLM 설정이 필요.
299
+ # (소스는 data_sources.custom_db config로 생성되어 최상위 llm에 접근 못 하므로 주입.)
300
+ if source.name == "custom_db" and source.config.get("agentic"):
301
+ src_overlay["data_sources"]["custom_db"].setdefault("llm", dict(cfg["llm"]))
302
+ _deep_merge(cfg, src_overlay)
303
+
304
+ if extra:
305
+ _deep_merge(cfg, extra)
306
+ return cfg
307
+
308
+
309
+ # ── result objects ───────────────────────────────────────────────────────────
310
+
311
+ class Result:
312
+ """One verified claim from a :class:`Report`.
313
+
314
+ Truthy when the claim was confirmed by the evidence
315
+ (``bool(result) == result.ok``).
316
+
317
+ Attributes:
318
+ verdict: ``"match"`` | ``"mismatch"`` | ``"unverifiable"``.
319
+ claim: The claim sentence that was checked.
320
+ reason: Natural-language explanation of the verdict.
321
+ confidence: Model confidence in ``[0, 1]``.
322
+ value: The official/reference value found in the evidence, if any.
323
+ source: Name of the evidence source (e.g. ``"KOSIS"``).
324
+ unit: Unit of ``value``.
325
+ """
326
+
327
+ __slots__ = ("verdict", "claim", "reason", "confidence", "value", "source", "unit")
328
+
329
+ def __init__(self, verdict, claim, reason, confidence, value, source, unit):
330
+ self.verdict = verdict
331
+ self.claim = claim
332
+ self.reason = reason
333
+ self.confidence = confidence
334
+ self.value = value
335
+ self.source = source
336
+ self.unit = unit
337
+
338
+ @classmethod
339
+ def _from(cls, res, claim) -> "Result":
340
+ ev = getattr(res, "evidence", None)
341
+ verdict = res.verdict.value if hasattr(res.verdict, "value") else str(res.verdict)
342
+ return cls(
343
+ verdict=verdict,
344
+ claim=getattr(claim, "claim_text", None),
345
+ reason=res.explanation,
346
+ confidence=res.confidence,
347
+ value=getattr(ev, "official_value", None) if ev else None,
348
+ source=getattr(ev, "source_name", None) if ev else None,
349
+ unit=getattr(ev, "unit", None) if ev else None,
350
+ )
351
+
352
+ @property
353
+ def ok(self) -> bool:
354
+ """True when the claim was confirmed (``verdict == "match"``)."""
355
+ return self.verdict == "match"
356
+
357
+ @property
358
+ def is_match(self) -> bool:
359
+ return self.verdict == "match"
360
+
361
+ @property
362
+ def is_mismatch(self) -> bool:
363
+ return self.verdict == "mismatch"
364
+
365
+ @property
366
+ def is_unverifiable(self) -> bool:
367
+ return self.verdict == "unverifiable"
368
+
369
+ def __bool__(self) -> bool:
370
+ return self.ok
371
+
372
+ def to_dict(self) -> dict[str, Any]:
373
+ return {
374
+ "verdict": self.verdict,
375
+ "ok": self.ok,
376
+ "claim": self.claim,
377
+ "reason": self.reason,
378
+ "confidence": self.confidence,
379
+ "value": self.value,
380
+ "source": self.source,
381
+ "unit": self.unit,
382
+ }
383
+
384
+ def __repr__(self) -> str:
385
+ mark = {"match": "✓", "mismatch": "✗", "unverifiable": "?"}.get(self.verdict, "?")
386
+ claim = (self.claim or "").strip()
387
+ if len(claim) > 60:
388
+ claim = claim[:57] + "…"
389
+ return f"<Result {mark} {self.verdict} ({self.confidence:.2f}) — {claim!r}>"
390
+
391
+
392
+ class Report:
393
+ """The outcome of verifying a document — an iterable collection of :class:`Result`.
394
+
395
+ Truthy when no false claim was found (``bool(report) == report.ok``), so a
396
+ document can be gated with a single ``if``::
397
+
398
+ if sv.verify(text, provider="upstage"):
399
+ print("no false claims detected")
400
+ """
401
+
402
+ __slots__ = ("results", "summary", "raw")
403
+
404
+ def __init__(self, results: list[Result], summary: str | None = None, raw=None):
405
+ self.results = results
406
+ self.summary = summary
407
+ self.raw = raw # the underlying VerificationReport, for power users
408
+
409
+ @classmethod
410
+ def _from(cls, vr) -> "Report":
411
+ claims_by_id = {c.claim_id: c for c in vr.claims}
412
+ # NB: build the list directly — never filter by truthiness here, since a
413
+ # mismatch Result is *falsy* and would be silently dropped.
414
+ results = [Result._from(r, claims_by_id.get(r.claim_id)) for r in vr.results]
415
+ return cls(results=results, summary=vr.summary, raw=vr)
416
+
417
+ @property
418
+ def matches(self) -> list[Result]:
419
+ return [r for r in self.results if r.is_match]
420
+
421
+ @property
422
+ def mismatches(self) -> list[Result]:
423
+ """Claims contradicted by the evidence (the ones worth acting on)."""
424
+ return [r for r in self.results if r.is_mismatch]
425
+
426
+ @property
427
+ def unverifiable(self) -> list[Result]:
428
+ return [r for r in self.results if r.is_unverifiable]
429
+
430
+ @property
431
+ def ok(self) -> bool:
432
+ """True when no claim was contradicted by the evidence."""
433
+ return not self.mismatches
434
+
435
+ passed = ok # readability alias when used as ``report.passed``
436
+
437
+ @property
438
+ def all_match(self) -> bool:
439
+ """Stricter than :attr:`ok`: every claim was positively confirmed."""
440
+ return bool(self.results) and all(r.is_match for r in self.results)
441
+
442
+ def to_dict(self) -> dict[str, Any]:
443
+ return {
444
+ "ok": self.ok,
445
+ "total": len(self.results),
446
+ "matches": len(self.matches),
447
+ "mismatches": len(self.mismatches),
448
+ "unverifiable": len(self.unverifiable),
449
+ "summary": self.summary,
450
+ "results": [r.to_dict() for r in self.results],
451
+ }
452
+
453
+ def __iter__(self) -> Iterator[Result]:
454
+ return iter(self.results)
455
+
456
+ def __len__(self) -> int:
457
+ return len(self.results)
458
+
459
+ def __getitem__(self, i) -> Result:
460
+ return self.results[i]
461
+
462
+ def __bool__(self) -> bool:
463
+ return self.ok
464
+
465
+ def __repr__(self) -> str:
466
+ return (
467
+ f"<Report ok={self.ok} claims={len(self.results)} "
468
+ f"match={len(self.matches)} mismatch={len(self.mismatches)} "
469
+ f"unverifiable={len(self.unverifiable)}>"
470
+ )
471
+
472
+
473
+ class Verdict:
474
+ """A conformance decision for one statement against a :class:`Ruleset`.
475
+
476
+ Truthy when the statement complies (``bool(verdict) == verdict.compliant``).
477
+
478
+ Attributes:
479
+ verdict: ``"compliant"`` | ``"violation"`` | ``"unverifiable"``.
480
+ claim: The statement that was checked.
481
+ article: The rule/article that was applied.
482
+ rule_value: The threshold the rule requires.
483
+ claim_value: The value asserted in the statement.
484
+ unit: Unit shared by ``rule_value`` and ``claim_value``.
485
+ reason: Natural-language explanation.
486
+ """
487
+
488
+ __slots__ = ("verdict", "claim", "article", "rule_value", "claim_value",
489
+ "unit", "reason", "iterations")
490
+
491
+ def __init__(self, verdict, claim, article, rule_value, claim_value, unit,
492
+ reason, iterations=None):
493
+ self.verdict = verdict
494
+ self.claim = claim
495
+ self.article = article
496
+ self.rule_value = rule_value
497
+ self.claim_value = claim_value
498
+ self.unit = unit
499
+ self.reason = reason
500
+ self.iterations = iterations # rounds taken by the agent loop (None = one-shot)
501
+
502
+ @classmethod
503
+ def _from(cls, d: dict, claim: str) -> "Verdict":
504
+ return cls(
505
+ verdict=d.get("verdict", "unverifiable"),
506
+ claim=claim,
507
+ article=d.get("applicable_article"),
508
+ rule_value=d.get("rule_value"),
509
+ claim_value=d.get("claim_value"),
510
+ unit=d.get("unit"),
511
+ reason=d.get("explanation"),
512
+ iterations=d.get("iterations"),
513
+ )
514
+
515
+ @property
516
+ def compliant(self) -> bool:
517
+ """True when the statement satisfies the applicable rule."""
518
+ return self.verdict == "compliant"
519
+
520
+ ok = compliant
521
+
522
+ @property
523
+ def violated(self) -> bool:
524
+ """True when the statement breaks the applicable rule."""
525
+ return self.verdict == "violation"
526
+
527
+ @property
528
+ def is_unverifiable(self) -> bool:
529
+ return self.verdict == "unverifiable"
530
+
531
+ def __bool__(self) -> bool:
532
+ return self.compliant
533
+
534
+ def to_dict(self) -> dict[str, Any]:
535
+ return {
536
+ "verdict": self.verdict,
537
+ "compliant": self.compliant,
538
+ "claim": self.claim,
539
+ "article": self.article,
540
+ "rule_value": self.rule_value,
541
+ "claim_value": self.claim_value,
542
+ "unit": self.unit,
543
+ "reason": self.reason,
544
+ "iterations": self.iterations,
545
+ }
546
+
547
+ def __repr__(self) -> str:
548
+ mark = {"compliant": "✓", "violation": "✗", "unverifiable": "?"}.get(self.verdict, "?")
549
+ return (
550
+ f"<Verdict {mark} {self.verdict} "
551
+ f"rule={self.rule_value} claim={self.claim_value} {self.unit or ''}>".replace(" ", " ")
552
+ )
553
+
554
+
555
+ # ── fact verification ────────────────────────────────────────────────────────
556
+
557
+ class Verifier:
558
+ """A reusable fact-verification engine.
559
+
560
+ Configure a provider once, then call :meth:`verify` as many times as you
561
+ like. Verification checks numeric/factual claims in a document against a
562
+ configured data source (e.g. a statistics catalog).
563
+
564
+ >>> v = sv.Verifier(provider="upstage", api_key="up_...")
565
+ >>> report = v.verify("...")
566
+ >>> report.ok
567
+
568
+ Point it at company data with ``data`` (defaults to the built-in KOSIS
569
+ catalog)::
570
+
571
+ >>> sv.Verifier(provider="upstage", data=sv.DataSource.csv("budget.csv"))
572
+ """
573
+
574
+ def __init__(
575
+ self,
576
+ *,
577
+ provider: str | None = None,
578
+ api_key: str | None = None,
579
+ model: str | None = None,
580
+ embedding_provider: str | None = None,
581
+ embedding_api_key: str | None = None,
582
+ tolerance: float | None = None,
583
+ data: "DataSource | str | None" = None,
584
+ config: dict | None = None,
585
+ ):
586
+ self.config = config or build_config(
587
+ provider=provider,
588
+ api_key=api_key,
589
+ model=model,
590
+ embedding_provider=embedding_provider,
591
+ embedding_api_key=embedding_api_key,
592
+ tolerance=tolerance,
593
+ data=data,
594
+ )
595
+
596
+ async def averify(self, text: str) -> Report:
597
+ """Async: verify a block of text, returning a :class:`Report`."""
598
+ from structverify.core.pipeline import VerificationPipeline
599
+
600
+ vr = await VerificationPipeline(self.config).run(text, "text")
601
+ return Report._from(vr)
602
+
603
+ def verify(self, text: str) -> Report:
604
+ """Verify a block of text, returning a :class:`Report`."""
605
+ return _run(self.averify(text))
606
+
607
+ # ``check`` reads naturally too.
608
+ check = verify
609
+
610
+ async def averify_file(self, path: str) -> Report:
611
+ from structverify.retrieval.chunking import read_document
612
+
613
+ return await self.averify(read_document(path))
614
+
615
+ def verify_file(self, path: str) -> Report:
616
+ """Verify a document file (PDF/DOCX/txt), returning a :class:`Report`."""
617
+ return _run(self.averify_file(path))
618
+
619
+
620
+ def verify(
621
+ text: str,
622
+ *,
623
+ provider: str | None = None,
624
+ api_key: str | None = None,
625
+ model: str | None = None,
626
+ **kwargs: Any,
627
+ ) -> Report:
628
+ """One-shot fact verification — the quickest way in.
629
+
630
+ >>> import structverify as sv
631
+ >>> report = sv.verify("...", provider="upstage", api_key="up_...")
632
+ >>> if report:
633
+ ... print("no false claims")
634
+
635
+ Equivalent to ``Verifier(provider=..., ...).verify(text)``.
636
+ """
637
+ return Verifier(provider=provider, api_key=api_key, model=model, **kwargs).verify(text)
638
+
639
+
640
+ # ── conformance (규정 준수) ───────────────────────────────────────────────────
641
+
642
+ # Only lines that carry a measurement (a number + a recognizable unit) are worth
643
+ # checking against a rulebook; headers/dates are skipped.
644
+ _UNIT = re.compile(
645
+ r"㎍/?g|μg/?g|mg/?kg|CFU|ppm|㎎|㎍|㎖|℃|kcal|mg|kg|mL|mL당|g당|g\b|%|"
646
+ r"원|개|명|건|시간|일|개월|년|회|㎡|배"
647
+ )
648
+
649
+
650
+ class Ruleset:
651
+ """A rulebook you bring yourself — a PDF/txt of regulations, standards, or
652
+ internal policy — that statements can be checked against for compliance.
653
+
654
+ The rulebook is chunked per article and embedded once; each
655
+ :meth:`check` semantically retrieves the applicable article(s) and returns a
656
+ deterministic :class:`Verdict`.
657
+
658
+ >>> rules = sv.Ruleset.from_file("safety_standard.pdf", provider="upstage")
659
+ >>> rules.check("총납 함량은 120mg/kg으로 측정되었다").compliant
660
+ False
661
+ >>> len(rules) # number of indexed articles
662
+ 10
663
+
664
+ For a **large** rulebook, enable the ReAct agent loop so retrieval becomes
665
+ adaptive (search → judge → reformulate → search again) instead of a single
666
+ top-k shot::
667
+
668
+ >>> rules = sv.Ruleset.from_file("big_rulebook.pdf", provider="upstage", agent=True)
669
+ >>> v = rules.check("…"); v.iterations # rounds the agent took
670
+ """
671
+
672
+ def __init__(self, ds, config: dict, chunks: int, name: str | None = None,
673
+ *, agent: bool = False):
674
+ self._ds = ds
675
+ self._config = config
676
+ self._chunks = chunks
677
+ self.name = name
678
+ self._use_agent = agent
679
+
680
+ # -- construction --------------------------------------------------------
681
+
682
+ @classmethod
683
+ async def afrom_file(
684
+ cls,
685
+ path: str,
686
+ *,
687
+ provider: str | None = None,
688
+ api_key: str | None = None,
689
+ embedding_provider: str | None = None,
690
+ embedding_api_key: str | None = None,
691
+ tolerance: float | None = None,
692
+ chunk_size: int = 120,
693
+ name: str | None = None,
694
+ agent: bool = False,
695
+ ) -> "Ruleset":
696
+ """Async: build a ruleset by indexing a regulation file (PDF/txt/md)."""
697
+ from structverify.retrieval.custom_docs_source import CustomDocsDataSource
698
+
699
+ config = build_config(
700
+ provider=provider,
701
+ api_key=api_key,
702
+ embedding_provider=embedding_provider,
703
+ embedding_api_key=embedding_api_key,
704
+ tolerance=tolerance,
705
+ )
706
+ ds = CustomDocsDataSource(
707
+ docs_path=path, chunk_size=chunk_size, embedding=config["embedding"],
708
+ )
709
+ n = await ds.build_index()
710
+ return cls(ds, config, n, name=name or os.path.basename(path), agent=agent)
711
+
712
+ @classmethod
713
+ def from_file(cls, path: str, **kwargs: Any) -> "Ruleset":
714
+ """Build a ruleset by indexing a regulation file (PDF/txt/md)."""
715
+ return _run(cls.afrom_file(path, **kwargs))
716
+
717
+ @classmethod
718
+ async def afrom_text(cls, text: str, **kwargs: Any) -> "Ruleset":
719
+ """Async: build a ruleset from an in-memory rulebook string."""
720
+ tmpdir = tempfile.mkdtemp(prefix="sv_ruleset_")
721
+ path = os.path.join(tmpdir, "rules.txt")
722
+ with open(path, "w", encoding="utf-8") as f:
723
+ f.write(text)
724
+ kwargs.setdefault("name", "inline-rules")
725
+ return await cls.afrom_file(path, **kwargs)
726
+
727
+ @classmethod
728
+ def from_text(cls, text: str, **kwargs: Any) -> "Ruleset":
729
+ """Build a ruleset from an in-memory rulebook string."""
730
+ return _run(cls.afrom_text(text, **kwargs))
731
+
732
+ # -- checking ------------------------------------------------------------
733
+
734
+ async def acheck(
735
+ self, statement: str, *, top_k: int = 5, agent: bool | None = None,
736
+ ) -> Verdict:
737
+ """Async: check one statement for compliance, returning a :class:`Verdict`.
738
+
739
+ Set ``agent=True`` (or construct the ruleset with ``agent=True``) to run
740
+ the adaptive ReAct loop instead of a single retrieval — worthwhile for a
741
+ large rulebook where the applicable article may not be in the first
742
+ top-k results.
743
+ """
744
+ use_agent = self._use_agent if agent is None else agent
745
+ if use_agent:
746
+ from structverify.agent.conformance_agent import ConformanceAgent
747
+
748
+ d = await ConformanceAgent(
749
+ self._ds, self._config, top_k=top_k,
750
+ ).check(statement)
751
+ return Verdict._from(d, statement)
752
+
753
+ # one-shot: retrieve top-k once, then judge
754
+ from structverify.verification.conformance import judge_conformance
755
+
756
+ cands = await self._ds.search_catalog(statement, top_k=top_k)
757
+ articles: list[str] = []
758
+ for c in cands:
759
+ cid = c.get("id")
760
+ if cid is None:
761
+ continue
762
+ ev = await self._ds.fetch_evidence(cid)
763
+ if ev and ev.get("text"):
764
+ articles.append(ev["text"])
765
+ d = await judge_conformance(statement, "\n---\n".join(articles), self._config)
766
+ return Verdict._from(d, statement)
767
+
768
+ def check(self, statement: str, *, top_k: int = 5, agent: bool | None = None) -> Verdict:
769
+ """Check one statement for compliance, returning a :class:`Verdict`."""
770
+ return _run(self.acheck(statement, top_k=top_k, agent=agent))
771
+
772
+ async def acheck_document(
773
+ self, text: str, *, top_k: int = 5, agent: bool | None = None,
774
+ ) -> list[Verdict]:
775
+ """Async: check every measurement line in a document."""
776
+ lines = [
777
+ ln.strip() for ln in text.splitlines()
778
+ if ln.strip() and any(ch.isdigit() for ch in ln) and _UNIT.search(ln)
779
+ ]
780
+ out: list[Verdict] = []
781
+ for ln in lines:
782
+ out.append(await self.acheck(ln, top_k=top_k, agent=agent))
783
+ return out
784
+
785
+ def check_document(
786
+ self, text: str, *, top_k: int = 5, agent: bool | None = None,
787
+ ) -> list[Verdict]:
788
+ """Check every measurement line in a document for compliance.
789
+
790
+ Returns one :class:`Verdict` per measurement line found.
791
+ """
792
+ return _run(self.acheck_document(text, top_k=top_k, agent=agent))
793
+
794
+ def check_file(self, path: str, *, top_k: int = 5, agent: bool | None = None) -> list[Verdict]:
795
+ """Check every measurement line in a document file (PDF/txt)."""
796
+ from structverify.retrieval.chunking import read_document
797
+
798
+ return self.check_document(read_document(path), top_k=top_k, agent=agent)
799
+
800
+ def __len__(self) -> int:
801
+ return self._chunks
802
+
803
+ def __repr__(self) -> str:
804
+ return f"<Ruleset {self.name!r} articles={self._chunks}>"