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.
- structverify/__init__.py +83 -0
- structverify/adaptation/__init__.py +0 -0
- structverify/adaptation/adapter_trainer.py +341 -0
- structverify/adaptation/feedback_store.py +31 -0
- structverify/adaptation/kosis_crawler.py +317 -0
- structverify/adaptation/sample_builder.py +149 -0
- structverify/adaptation/synthetic_generator.py +320 -0
- structverify/adaptation/update_embeddings.py +178 -0
- structverify/agent/__init__.py +21 -0
- structverify/agent/builder_agent.py +226 -0
- structverify/agent/conformance_agent.py +171 -0
- structverify/agent/dependency_planner.py +151 -0
- structverify/agent/indexing_agent.py +153 -0
- structverify/agent/indexing_planner.py +169 -0
- structverify/agent/integration_example.py +182 -0
- structverify/agent/loop.py +1165 -0
- structverify/agent/memory.py +207 -0
- structverify/agent/planner.py +817 -0
- structverify/agent/prompts/__init__.py +15 -0
- structverify/agent/prompts/planner_prompts.py +219 -0
- structverify/agent/prompts/reflect_prompts.py +387 -0
- structverify/agent/reflect.py +227 -0
- structverify/agent/runtime_agent.py +1272 -0
- structverify/agent/schemas.py +262 -0
- structverify/agent/source_profiler.py +229 -0
- structverify/agent/tools/__init__.py +64 -0
- structverify/agent/tools/base.py +222 -0
- structverify/agent/tools/calculate.py +244 -0
- structverify/agent/tools/catalog_search.py +859 -0
- structverify/agent/tools/deep_explore.py +293 -0
- structverify/agent/tools/explore_catalog.py +423 -0
- structverify/agent/tools/fetch_evidence.py +922 -0
- structverify/agent/tools/finish.py +423 -0
- structverify/agent/tools/meta_explore.py +267 -0
- structverify/agent/tools/query_rewriter.py +134 -0
- structverify/agent/tools/read_original.py +144 -0
- structverify/agent/tools/replan.py +365 -0
- structverify/agent/workspace.py +958 -0
- structverify/api.py +804 -0
- structverify/config/default.yaml +350 -0
- structverify/core/__init__.py +0 -0
- structverify/core/config_loader.py +30 -0
- structverify/core/pipeline.py +280 -0
- structverify/core/schemas.py +362 -0
- structverify/detection/__init__.py +26 -0
- structverify/detection/_config.py +163 -0
- structverify/detection/_llm.py +24 -0
- structverify/detection/candidate/__init__.py +1 -0
- structverify/detection/candidate/heuristic.py +60 -0
- structverify/detection/candidate/llm.py +51 -0
- structverify/detection/candidate_scorer.py +81 -0
- structverify/detection/claim_detector.py +164 -0
- structverify/detection/claims/__init__.py +1 -0
- structverify/detection/claims/worthiness.py +142 -0
- structverify/detection/domain/__init__.py +1 -0
- structverify/detection/domain/classify.py +84 -0
- structverify/detection/domain/preview.py +36 -0
- structverify/detection/domain/registry.py +99 -0
- structverify/detection/domain_classifier.py +75 -0
- structverify/detection/prompts/__init__.py +1 -0
- structverify/detection/prompts/candidate.py +38 -0
- structverify/detection/prompts/claim_worthiness.py +48 -0
- structverify/detection/prompts/domain.py +41 -0
- structverify/detection/prompts/schema.py +508 -0
- structverify/detection/prompts_loader.py +167 -0
- structverify/detection/schema/__init__.py +1 -0
- structverify/detection/schema/expand.py +83 -0
- structverify/detection/schema/induce.py +441 -0
- structverify/detection/schema/regenerate.py +162 -0
- structverify/detection/schema/temporal_hints.py +130 -0
- structverify/detection/schema/validate.py +193 -0
- structverify/detection/schema_inductor.py +112 -0
- structverify/detection/synthetic_generator.py +270 -0
- structverify/explanation/__init__.py +0 -0
- structverify/explanation/_config.py +18 -0
- structverify/explanation/_llm.py +25 -0
- structverify/explanation/explainer.py +183 -0
- structverify/explanation/fallback.py +29 -0
- structverify/explanation/formatters.py +75 -0
- structverify/explanation/prompts/__init__.py +1 -0
- structverify/explanation/prompts/match.py +27 -0
- structverify/explanation/prompts/mismatch.py +20 -0
- structverify/explanation/prompts/multihop.py +16 -0
- structverify/explanation/prompts/unverifiable.py +17 -0
- structverify/graph/__init__.py +0 -0
- structverify/graph/claim_graph.py +226 -0
- structverify/graph/document_graph.py +487 -0
- structverify/graph/graph_builder.py +238 -0
- structverify/graph/graph_multihop.py +335 -0
- structverify/graph/graph_store.py +281 -0
- structverify/graph/provenance.py +52 -0
- structverify/memory/__init__.py +44 -0
- structverify/memory/agent_memory.py +142 -0
- structverify/memory/embedder.py +69 -0
- structverify/memory/exemplar_store.py +241 -0
- structverify/memory/normalizer.py +91 -0
- structverify/memory/schema.py +119 -0
- structverify/memory/storage/__init__.py +29 -0
- structverify/memory/storage/jsonl_store.py +117 -0
- structverify/memory/working_memory.py +370 -0
- structverify/preprocessing/Dockerfile.scraper +27 -0
- structverify/preprocessing/__init__.py +0 -0
- structverify/preprocessing/extractor.py +574 -0
- structverify/preprocessing/pdf/__init__.py +16 -0
- structverify/preprocessing/pdf/fields.py +95 -0
- structverify/preprocessing/pdf/markdown.py +107 -0
- structverify/preprocessing/pdf/models.py +34 -0
- structverify/preprocessing/pdf/ocr.py +172 -0
- structverify/preprocessing/pdf/pipeline.py +74 -0
- structverify/preprocessing/pdf/reader.py +119 -0
- structverify/preprocessing/pdf/scoring.py +61 -0
- structverify/preprocessing/scraper_sandbox.py +561 -0
- structverify/preprocessing/segmenter.py +48 -0
- structverify/preprocessing/sir_builder.py +240 -0
- structverify/progress.py +591 -0
- structverify/retrieval/__init__.py +0 -0
- structverify/retrieval/base.py +208 -0
- structverify/retrieval/base_connector.py +85 -0
- structverify/retrieval/catalog_ranker.py +300 -0
- structverify/retrieval/catalog_search.py +583 -0
- structverify/retrieval/chunking.py +92 -0
- structverify/retrieval/custom_csv_source.py +386 -0
- structverify/retrieval/custom_db_source.py +396 -0
- structverify/retrieval/custom_docs_source.py +152 -0
- structverify/retrieval/dimension_resolver.py +281 -0
- structverify/retrieval/evidence_subgraph.py +63 -0
- structverify/retrieval/kosis_connector.py +1192 -0
- structverify/retrieval/kosis_relevance.py +142 -0
- structverify/retrieval/kosis_source.py +1541 -0
- structverify/retrieval/query_builder.py +72 -0
- structverify/retrieval/registry.py +133 -0
- structverify/retrieval/relevance_judge.py +141 -0
- structverify/retrieval/row_matcher.py +267 -0
- structverify/storage/__init__.py +0 -0
- structverify/storage/db_manager.py +157 -0
- structverify/storage/dwh_manager.py +92 -0
- structverify/storage/init_db.py +99 -0
- structverify/storage/raw_storage.py +29 -0
- structverify/training/__init__.py +26 -0
- structverify/training/curator.py +124 -0
- structverify/training/dataset.py +134 -0
- structverify/training/doctor.py +99 -0
- structverify/training/evalgate.py +96 -0
- structverify/training/generate.py +101 -0
- structverify/training/loop.py +116 -0
- structverify/training/recipe/train_mlx.py +99 -0
- structverify/training/recipe/train_qlora.py +104 -0
- structverify/training/tasks.py +79 -0
- structverify/utils/__init__.py +0 -0
- structverify/utils/embedding_client.py +248 -0
- structverify/utils/llm_client.py +809 -0
- structverify/utils/logger.py +81 -0
- structverify/verification/__init__.py +0 -0
- structverify/verification/_config.py +45 -0
- structverify/verification/adapters.py +405 -0
- structverify/verification/conformance.py +117 -0
- structverify/verification/decide_verdict.py +216 -0
- structverify/verification/decide_verdict_agent.py +454 -0
- structverify/verification/growth_diff.py +267 -0
- structverify/verification/row_match.py +345 -0
- structverify/verification/units.py +64 -0
- structverify/verification/verdict_thresholds.py +232 -0
- structverify/verification/verifier.py +84 -0
- structverify-0.3.0.dist-info/METADATA +903 -0
- structverify-0.3.0.dist-info/RECORD +168 -0
- structverify-0.3.0.dist-info/WHEEL +5 -0
- structverify-0.3.0.dist-info/licenses/LICENSE +21 -0
- 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}>"
|