verifydoc 0.1.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.
- verifydoc/__init__.py +16 -0
- verifydoc/adapters/__init__.py +33 -0
- verifydoc/adapters/_ocr_common.py +80 -0
- verifydoc/adapters/api_vlm.py +97 -0
- verifydoc/adapters/base.py +31 -0
- verifydoc/adapters/docling.py +45 -0
- verifydoc/adapters/dots_ocr.py +66 -0
- verifydoc/adapters/mock.py +95 -0
- verifydoc/adapters/paddleocr_vl.py +50 -0
- verifydoc/adapters/text_search.py +53 -0
- verifydoc/calibration/__init__.py +20 -0
- verifydoc/calibration/base.py +56 -0
- verifydoc/calibration/conformal.py +75 -0
- verifydoc/calibration/histogram.py +34 -0
- verifydoc/calibration/isotonic.py +23 -0
- verifydoc/calibration/platt.py +30 -0
- verifydoc/calibration/splits.py +44 -0
- verifydoc/calibration/temperature.py +30 -0
- verifydoc/cli.py +48 -0
- verifydoc/confidence/__init__.py +18 -0
- verifydoc/confidence/combined.py +37 -0
- verifydoc/confidence/consensus.py +71 -0
- verifydoc/confidence/grounding_based.py +29 -0
- verifydoc/confidence/token_prob.py +43 -0
- verifydoc/confidence/verbalized.py +30 -0
- verifydoc/eval/__init__.py +1 -0
- verifydoc/eval/calibration.py +154 -0
- verifydoc/eval/extraction.py +534 -0
- verifydoc/eval/grounding.py +124 -0
- verifydoc/eval/harness.py +354 -0
- verifydoc/eval/selective.py +170 -0
- verifydoc/eval/stats.py +97 -0
- verifydoc/grounding/__init__.py +5 -0
- verifydoc/grounding/attach.py +87 -0
- verifydoc/ingest/__init__.py +5 -0
- verifydoc/ingest/loader.py +100 -0
- verifydoc/pipeline.py +134 -0
- verifydoc/policy/__init__.py +5 -0
- verifydoc/policy/abstention.py +73 -0
- verifydoc/types.py +230 -0
- verifydoc-0.1.0.dist-info/METADATA +374 -0
- verifydoc-0.1.0.dist-info/RECORD +45 -0
- verifydoc-0.1.0.dist-info/WHEEL +4 -0
- verifydoc-0.1.0.dist-info/entry_points.txt +2 -0
- verifydoc-0.1.0.dist-info/licenses/LICENSE +202 -0
verifydoc/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""VerifyDoc: a trust layer for document -> structured-JSON extraction."""
|
|
2
|
+
|
|
3
|
+
from verifydoc.pipeline import VerifiedResult, verify
|
|
4
|
+
from verifydoc.types import Document, FieldGold, FieldPrediction, Grounding, Schema
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Document",
|
|
10
|
+
"FieldGold",
|
|
11
|
+
"FieldPrediction",
|
|
12
|
+
"Grounding",
|
|
13
|
+
"Schema",
|
|
14
|
+
"VerifiedResult",
|
|
15
|
+
"verify",
|
|
16
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Extractor adapters. Golden rule #1: all model-specific code lives here.
|
|
2
|
+
|
|
3
|
+
Nothing outside this package may import a model SDK; adding a new extractor
|
|
4
|
+
is one new file implementing ``ExtractorAdapter``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from verifydoc.adapters.base import ExtractorAdapter
|
|
8
|
+
from verifydoc.adapters.mock import MockAdapter
|
|
9
|
+
from verifydoc.adapters.text_search import TextSearchAdapter
|
|
10
|
+
|
|
11
|
+
__all__ = ["ExtractorAdapter", "MockAdapter", "TextSearchAdapter", "get_adapter"]
|
|
12
|
+
|
|
13
|
+
_REGISTRY = {
|
|
14
|
+
"mock": "verifydoc.adapters.mock:MockAdapter",
|
|
15
|
+
"text-search": "verifydoc.adapters.text_search:TextSearchAdapter",
|
|
16
|
+
"paddleocr-vl": "verifydoc.adapters.paddleocr_vl:PaddleOCRVLAdapter",
|
|
17
|
+
"dots-ocr": "verifydoc.adapters.dots_ocr:DotsOCRAdapter",
|
|
18
|
+
"docling": "verifydoc.adapters.docling:DoclingAdapter",
|
|
19
|
+
"api-vlm": "verifydoc.adapters.api_vlm:APIVLMAdapter",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_adapter(name: str, **kwargs: object) -> ExtractorAdapter:
|
|
24
|
+
"""Instantiate a registered adapter by CLI name (lazy import)."""
|
|
25
|
+
import importlib
|
|
26
|
+
from typing import cast
|
|
27
|
+
|
|
28
|
+
if name not in _REGISTRY:
|
|
29
|
+
raise ValueError(f"unknown adapter {name!r}; available: {sorted(_REGISTRY)}")
|
|
30
|
+
module_name, _, class_name = _REGISTRY[name].partition(":")
|
|
31
|
+
module = importlib.import_module(module_name)
|
|
32
|
+
adapter_cls = getattr(module, class_name)
|
|
33
|
+
return cast(ExtractorAdapter, adapter_cls(**kwargs))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Shared OCR-token plumbing for local OCR adapters (PaddleOCR-VL, dots.ocr).
|
|
2
|
+
|
|
3
|
+
OCR engines return (text, bbox, recognition-score) tokens per page. This
|
|
4
|
+
module clusters tokens into reading-order lines, builds a text-layer
|
|
5
|
+
``Document``, and lets the shared field-finding heuristic run on top —
|
|
6
|
+
attaching each token's recognition score as ``token_logprobs`` so the
|
|
7
|
+
token-prob confidence signal works for local OCR models.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from verifydoc.adapters.text_search import TextSearchAdapter
|
|
16
|
+
from verifydoc.types import Document, FieldPrediction, Page, Schema, Word
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class OCRToken:
|
|
21
|
+
"""One recognized token: text, normalized bbox, recognition score in (0, 1]."""
|
|
22
|
+
|
|
23
|
+
text: str
|
|
24
|
+
bbox: tuple[float, float, float, float]
|
|
25
|
+
score: float
|
|
26
|
+
page: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def document_from_ocr_tokens(doc_id: str, tokens: list[OCRToken]) -> Document:
|
|
30
|
+
"""Cluster OCR tokens into lines (by vertical overlap) and build a Document."""
|
|
31
|
+
pages: dict[int, list[OCRToken]] = {}
|
|
32
|
+
for tok in tokens:
|
|
33
|
+
pages.setdefault(tok.page, []).append(tok)
|
|
34
|
+
|
|
35
|
+
out_pages = []
|
|
36
|
+
for page_no in sorted(pages):
|
|
37
|
+
toks = sorted(pages[page_no], key=lambda t: (t.bbox[1], t.bbox[0]))
|
|
38
|
+
lines: list[list[OCRToken]] = []
|
|
39
|
+
for tok in toks:
|
|
40
|
+
placed = False
|
|
41
|
+
for line in lines:
|
|
42
|
+
y0 = sum(t.bbox[1] for t in line) / len(line)
|
|
43
|
+
y1 = sum(t.bbox[3] for t in line) / len(line)
|
|
44
|
+
mid = (tok.bbox[1] + tok.bbox[3]) / 2
|
|
45
|
+
if y0 <= mid <= y1:
|
|
46
|
+
line.append(tok)
|
|
47
|
+
placed = True
|
|
48
|
+
break
|
|
49
|
+
if not placed:
|
|
50
|
+
lines.append([tok])
|
|
51
|
+
for line in lines:
|
|
52
|
+
line.sort(key=lambda t: t.bbox[0])
|
|
53
|
+
text = "\n".join(" ".join(t.text for t in line) for line in lines)
|
|
54
|
+
words = [Word(text=t.text, bbox=t.bbox) for line in lines for t in line]
|
|
55
|
+
out_pages.append(Page(page=page_no, width=1.0, height=1.0, text=text, words=words))
|
|
56
|
+
return Document(doc_id=doc_id, pages=out_pages)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def predictions_from_ocr_tokens(
|
|
60
|
+
doc_id: str, tokens: list[OCRToken], schema: Schema
|
|
61
|
+
) -> list[FieldPrediction]:
|
|
62
|
+
"""Field predictions from OCR tokens: line clustering + label search +
|
|
63
|
+
per-field token log-scores for the token-prob confidence signal."""
|
|
64
|
+
doc = document_from_ocr_tokens(doc_id, tokens)
|
|
65
|
+
preds = TextSearchAdapter().extract(doc, schema)
|
|
66
|
+
by_text: dict[str, list[float]] = {}
|
|
67
|
+
for tok in tokens:
|
|
68
|
+
by_text.setdefault(tok.text.casefold(), []).append(tok.score)
|
|
69
|
+
out = []
|
|
70
|
+
for pred in preds:
|
|
71
|
+
logprobs = [
|
|
72
|
+
math.log(max(1e-6, min(1.0, by_text[part.casefold()][0])))
|
|
73
|
+
for part in str(pred.value).split()
|
|
74
|
+
if part.casefold() in by_text
|
|
75
|
+
]
|
|
76
|
+
meta = dict(pred.meta)
|
|
77
|
+
if logprobs:
|
|
78
|
+
meta["token_logprobs"] = logprobs
|
|
79
|
+
out.append(pred.model_copy(update={"meta": meta}))
|
|
80
|
+
return out
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""API-VLM adapter (comparison rows only; local models are the defaults).
|
|
2
|
+
|
|
3
|
+
Prompts a vision-language model API for schema-conforming JSON where every
|
|
4
|
+
leaf carries a 0-1 self-assessed confidence — the *verbalized* signal. The
|
|
5
|
+
client is injectable, so unit tests run with a fake and no network
|
|
6
|
+
(golden rule #5); without an injected client the Anthropic SDK is used.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
from verifydoc.adapters.base import ExtractorAdapter
|
|
15
|
+
from verifydoc.types import Document, FieldPrediction, Schema, flatten_json
|
|
16
|
+
|
|
17
|
+
_SYSTEM = (
|
|
18
|
+
"You extract structured data from documents. Reply with ONLY a JSON object "
|
|
19
|
+
"matching the requested schema, where every leaf value is replaced by "
|
|
20
|
+
'{"value": ..., "confidence": <float 0-1 honestly reflecting P(correct)>}.'
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CompletionClient(Protocol):
|
|
25
|
+
"""Minimal client surface so tests can inject a fake."""
|
|
26
|
+
|
|
27
|
+
def complete(self, system: str, prompt: str) -> str: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class APIVLMAdapter(ExtractorAdapter):
|
|
31
|
+
name = "api-vlm"
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
client: CompletionClient | None = None,
|
|
36
|
+
model: str = "claude-sonnet-5",
|
|
37
|
+
) -> None:
|
|
38
|
+
self._client = client
|
|
39
|
+
self._model = model
|
|
40
|
+
|
|
41
|
+
def extract(self, doc: Document, schema: Schema) -> list[FieldPrediction]:
|
|
42
|
+
text = "\n\f\n".join(page.text or "" for page in doc.pages)
|
|
43
|
+
prompt = (
|
|
44
|
+
f"Schema (JSON Schema):\n{json.dumps(schema.raw)}\n\n"
|
|
45
|
+
f"Document text:\n{text}\n\n"
|
|
46
|
+
"Extract every schema field you can find."
|
|
47
|
+
)
|
|
48
|
+
raw = self._complete(_SYSTEM, prompt)
|
|
49
|
+
return self._parse(raw)
|
|
50
|
+
|
|
51
|
+
def _complete(self, system: str, prompt: str) -> str:
|
|
52
|
+
if self._client is not None:
|
|
53
|
+
return self._client.complete(system, prompt)
|
|
54
|
+
return self._anthropic_complete(system, prompt) # pragma: no cover - network
|
|
55
|
+
|
|
56
|
+
def _anthropic_complete(self, system: str, prompt: str) -> str: # pragma: no cover
|
|
57
|
+
try:
|
|
58
|
+
import anthropic
|
|
59
|
+
except ImportError as exc:
|
|
60
|
+
raise ImportError(
|
|
61
|
+
"APIVLMAdapter without an injected client requires: pip install anthropic"
|
|
62
|
+
) from exc
|
|
63
|
+
response = anthropic.Anthropic().messages.create(
|
|
64
|
+
model=self._model,
|
|
65
|
+
max_tokens=4096,
|
|
66
|
+
system=system,
|
|
67
|
+
messages=[{"role": "user", "content": prompt}],
|
|
68
|
+
)
|
|
69
|
+
return str(response.content[0].text)
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _parse(raw: str) -> list[FieldPrediction]:
|
|
73
|
+
"""Parse {leaf: {value, confidence}} JSON into predictions."""
|
|
74
|
+
start, end = raw.find("{"), raw.rfind("}")
|
|
75
|
+
if start == -1 or end <= start:
|
|
76
|
+
return []
|
|
77
|
+
try:
|
|
78
|
+
obj: Any = json.loads(raw[start : end + 1])
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
return []
|
|
81
|
+
flat = flatten_json(obj)
|
|
82
|
+
by_field: dict[str, dict[str, Any]] = {}
|
|
83
|
+
for path, value in flat.items():
|
|
84
|
+
if path.endswith(".value"):
|
|
85
|
+
by_field.setdefault(path[: -len(".value")], {})["value"] = value
|
|
86
|
+
elif path.endswith(".confidence"):
|
|
87
|
+
by_field.setdefault(path[: -len(".confidence")], {})["confidence"] = value
|
|
88
|
+
preds = []
|
|
89
|
+
for path, parts in by_field.items():
|
|
90
|
+
if "value" not in parts:
|
|
91
|
+
continue
|
|
92
|
+
verbalized = parts.get("confidence")
|
|
93
|
+
meta = {"verbalized_confidence": float(verbalized)} if verbalized is not None else {}
|
|
94
|
+
preds.append(
|
|
95
|
+
FieldPrediction(path=path, value=parts["value"], confidence=0.5, meta=meta)
|
|
96
|
+
)
|
|
97
|
+
return preds
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The ExtractorAdapter interface: extract(doc, schema) -> list[FieldPrediction]."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from verifydoc.types import Document, FieldPrediction, Schema
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExtractorAdapter(ABC):
|
|
11
|
+
"""Wraps one extraction model behind a model-agnostic interface.
|
|
12
|
+
|
|
13
|
+
Adapters emit raw predictions (values + whatever raw signals they have in
|
|
14
|
+
``meta``); confidence, calibration, grounding, and abstention are applied
|
|
15
|
+
by later stages, never inside an adapter.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
name: str = "adapter"
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def extract(self, doc: Document, schema: Schema) -> list[FieldPrediction]:
|
|
22
|
+
"""Extract one prediction per schema leaf found in the document."""
|
|
23
|
+
|
|
24
|
+
def extract_samples(
|
|
25
|
+
self, doc: Document, schema: Schema, k: int = 1
|
|
26
|
+
) -> list[list[FieldPrediction]]:
|
|
27
|
+
"""k independent runs for self-consistency; override when the model
|
|
28
|
+
supports cheaper native sampling (e.g. temperature > 0 decoding)."""
|
|
29
|
+
if k < 1:
|
|
30
|
+
raise ValueError("k must be >= 1")
|
|
31
|
+
return [self.extract(doc, schema) for _ in range(k)]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Docling/MinerU output adapter: reuse a parse you already ran.
|
|
2
|
+
|
|
3
|
+
Consumes either a live ``docling`` conversion (SDK installed) or an exported
|
|
4
|
+
markdown/text file from Docling/MinerU/Marker, then runs the shared
|
|
5
|
+
field-finding heuristic over the parsed text.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from verifydoc.adapters.base import ExtractorAdapter
|
|
13
|
+
from verifydoc.adapters.text_search import TextSearchAdapter
|
|
14
|
+
from verifydoc.ingest import document_from_text
|
|
15
|
+
from verifydoc.types import Document, FieldPrediction, Schema
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DoclingAdapter(ExtractorAdapter):
|
|
19
|
+
name = "docling"
|
|
20
|
+
|
|
21
|
+
def __init__(self, parsed_output: str | Path | None = None) -> None:
|
|
22
|
+
"""``parsed_output``: path to an exported markdown/text parse. When
|
|
23
|
+
omitted, the docling SDK is used to convert the source directly."""
|
|
24
|
+
self._parsed_output = Path(parsed_output) if parsed_output is not None else None
|
|
25
|
+
|
|
26
|
+
def extract(self, doc: Document, schema: Schema) -> list[FieldPrediction]:
|
|
27
|
+
if self._parsed_output is not None:
|
|
28
|
+
text = self._parsed_output.read_text(encoding="utf-8")
|
|
29
|
+
else: # pragma: no cover - needs docling SDK
|
|
30
|
+
text = self._convert_with_sdk(doc)
|
|
31
|
+
parsed_doc = document_from_text(doc.doc_id, text.split("\f"))
|
|
32
|
+
return TextSearchAdapter().extract(parsed_doc, schema)
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def _convert_with_sdk(doc: Document) -> str: # pragma: no cover - needs docling SDK
|
|
36
|
+
try:
|
|
37
|
+
from docling.document_converter import DocumentConverter
|
|
38
|
+
except ImportError as exc:
|
|
39
|
+
raise ImportError(
|
|
40
|
+
"DoclingAdapter without parsed_output requires docling: pip install docling"
|
|
41
|
+
) from exc
|
|
42
|
+
if doc.source_path is None:
|
|
43
|
+
raise ValueError("document has no source_path for docling conversion")
|
|
44
|
+
result = DocumentConverter().convert(doc.source_path)
|
|
45
|
+
return str(result.document.export_to_markdown())
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""dots.ocr adapter (local, single-GPU second extractor).
|
|
2
|
+
|
|
3
|
+
Requires the dots.ocr weights served via transformers; see
|
|
4
|
+
https://github.com/rednote-hilab/dots.ocr. SDK use is isolated here; parsed
|
|
5
|
+
layout tokens go through the shared ``_ocr_common`` pathway.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from verifydoc.adapters._ocr_common import OCRToken, predictions_from_ocr_tokens
|
|
13
|
+
from verifydoc.adapters.base import ExtractorAdapter
|
|
14
|
+
from verifydoc.types import Document, FieldPrediction, Schema
|
|
15
|
+
|
|
16
|
+
_PROMPT = "Please output the layout information from the image, including text and bbox."
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DotsOCRAdapter(ExtractorAdapter):
|
|
20
|
+
name = "dots-ocr"
|
|
21
|
+
|
|
22
|
+
def __init__(self, model_id: str = "rednote-hilab/dots.ocr") -> None: # pragma: no cover
|
|
23
|
+
try:
|
|
24
|
+
import torch # noqa: F401
|
|
25
|
+
from transformers import AutoModelForCausalLM, AutoProcessor
|
|
26
|
+
except ImportError as exc:
|
|
27
|
+
raise ImportError(
|
|
28
|
+
"DotsOCRAdapter requires torch + transformers: " "pip install torch transformers"
|
|
29
|
+
) from exc
|
|
30
|
+
self._model = AutoModelForCausalLM.from_pretrained(
|
|
31
|
+
model_id, trust_remote_code=True, torch_dtype="auto", device_map="auto"
|
|
32
|
+
)
|
|
33
|
+
self._processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=True)
|
|
34
|
+
|
|
35
|
+
def extract(self, doc: Document, schema: Schema) -> list[FieldPrediction]: # pragma: no cover
|
|
36
|
+
tokens: list[OCRToken] = []
|
|
37
|
+
for page in doc.pages:
|
|
38
|
+
if page.image_path is None:
|
|
39
|
+
continue
|
|
40
|
+
raw = self._generate(page.image_path)
|
|
41
|
+
for item in json.loads(raw):
|
|
42
|
+
x0, y0, x1, y1 = item["bbox"]
|
|
43
|
+
tokens.append(
|
|
44
|
+
OCRToken(
|
|
45
|
+
text=item.get("text", ""),
|
|
46
|
+
bbox=(
|
|
47
|
+
x0 / page.width,
|
|
48
|
+
y0 / page.height,
|
|
49
|
+
x1 / page.width,
|
|
50
|
+
y1 / page.height,
|
|
51
|
+
),
|
|
52
|
+
score=float(item.get("score", 0.9)),
|
|
53
|
+
page=page.page,
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
return predictions_from_ocr_tokens(doc.doc_id, tokens, schema)
|
|
57
|
+
|
|
58
|
+
def _generate(self, image_path: str) -> str: # pragma: no cover
|
|
59
|
+
from PIL import Image
|
|
60
|
+
|
|
61
|
+
image = Image.open(image_path)
|
|
62
|
+
inputs = self._processor(text=_PROMPT, images=image, return_tensors="pt").to(
|
|
63
|
+
self._model.device
|
|
64
|
+
)
|
|
65
|
+
output = self._model.generate(**inputs, max_new_tokens=4096)
|
|
66
|
+
return str(self._processor.batch_decode(output, skip_special_tokens=True)[0])
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Mock adapter: canned or noisy predictions for tests and the synthetic benchmark.
|
|
2
|
+
|
|
3
|
+
No network, no model. Two modes:
|
|
4
|
+
- canned: a mapping of doc_id -> predictions, returned verbatim;
|
|
5
|
+
- noisy: given gold fields per doc, corrupt/omit/hallucinate at seeded rates
|
|
6
|
+
to simulate a realistic extractor for the benchmark harness.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import random
|
|
13
|
+
|
|
14
|
+
from verifydoc.adapters.base import ExtractorAdapter
|
|
15
|
+
from verifydoc.types import Document, FieldGold, FieldPrediction, Schema
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MockAdapter(ExtractorAdapter):
|
|
19
|
+
name = "mock"
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
canned: dict[str, list[FieldPrediction]] | None = None,
|
|
24
|
+
gold: dict[str, list[FieldGold]] | None = None,
|
|
25
|
+
error_rate: float = 0.15,
|
|
26
|
+
omit_rate: float = 0.05,
|
|
27
|
+
hallucinate_rate: float = 0.05,
|
|
28
|
+
seed: int = 0,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._canned = canned or {}
|
|
31
|
+
self._gold = gold or {}
|
|
32
|
+
self.error_rate = error_rate
|
|
33
|
+
self.omit_rate = omit_rate
|
|
34
|
+
self.hallucinate_rate = hallucinate_rate
|
|
35
|
+
self._rng = random.Random(seed)
|
|
36
|
+
|
|
37
|
+
def extract(self, doc: Document, schema: Schema) -> list[FieldPrediction]:
|
|
38
|
+
if doc.doc_id in self._canned:
|
|
39
|
+
return [p.model_copy(deep=True) for p in self._canned[doc.doc_id]]
|
|
40
|
+
if doc.doc_id in self._gold:
|
|
41
|
+
return self._noisy(self._gold[doc.doc_id])
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
def _noisy(self, golds: list[FieldGold]) -> list[FieldPrediction]:
|
|
45
|
+
rng = self._rng
|
|
46
|
+
preds: list[FieldPrediction] = []
|
|
47
|
+
for gold in golds:
|
|
48
|
+
roll = rng.random()
|
|
49
|
+
if roll < self.omit_rate:
|
|
50
|
+
continue
|
|
51
|
+
correct = roll >= self.omit_rate + self.error_rate
|
|
52
|
+
value = gold.value if correct else self._corrupt(gold)
|
|
53
|
+
preds.append(
|
|
54
|
+
FieldPrediction(
|
|
55
|
+
path=gold.path, value=value, confidence=0.5, meta=self._signals(correct)
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
if golds and rng.random() < self.hallucinate_rate:
|
|
59
|
+
preds.append(
|
|
60
|
+
FieldPrediction(
|
|
61
|
+
path=f"spurious_{rng.randrange(1000)}",
|
|
62
|
+
value="ghost",
|
|
63
|
+
confidence=0.5,
|
|
64
|
+
meta=self._signals(False),
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
return preds
|
|
68
|
+
|
|
69
|
+
def _signals(self, correct: bool) -> dict[str, object]:
|
|
70
|
+
"""Simulated raw signals with realistic pathologies: verbalized is
|
|
71
|
+
inflated regardless of correctness (the RLHF failure mode); token
|
|
72
|
+
scores carry weak-but-real signal."""
|
|
73
|
+
rng = self._rng
|
|
74
|
+
verbalized = min(1.0, max(0.0, 0.9 + rng.uniform(-0.05, 0.08)))
|
|
75
|
+
base = 0.97 if correct else 0.90
|
|
76
|
+
logprobs = [math.log(min(1.0, max(0.05, base + rng.gauss(0.0, 0.03)))) for _ in range(3)]
|
|
77
|
+
return {"verbalized_confidence": verbalized, "token_logprobs": logprobs}
|
|
78
|
+
|
|
79
|
+
def _corrupt(self, gold: FieldGold) -> object:
|
|
80
|
+
"""Silently-wrong value: digit swaps for numbers, char noise for text."""
|
|
81
|
+
rng = self._rng
|
|
82
|
+
text = str(gold.value)
|
|
83
|
+
if gold.scoring == "numeric":
|
|
84
|
+
digits = [ch for ch in text if ch.isdigit()]
|
|
85
|
+
if len(digits) >= 2:
|
|
86
|
+
chars = list(text)
|
|
87
|
+
positions = [i for i, ch in enumerate(chars) if ch.isdigit()]
|
|
88
|
+
i, j = rng.sample(positions, 2)
|
|
89
|
+
chars[i], chars[j] = chars[j], chars[i]
|
|
90
|
+
return "".join(chars)
|
|
91
|
+
return text + "0"
|
|
92
|
+
if len(text) >= 2:
|
|
93
|
+
i = rng.randrange(len(text) - 1)
|
|
94
|
+
return text[:i] + text[i + 1] + text[i] + text[i + 2 :]
|
|
95
|
+
return text + "x"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""PaddleOCR-VL adapter (local, single-GPU default extractor).
|
|
2
|
+
|
|
3
|
+
Requires ``pip install paddleocr`` and a GPU (or slow CPU). All SDK use is
|
|
4
|
+
isolated here; the token -> field mapping is the shared, unit-tested
|
|
5
|
+
``_ocr_common`` pathway.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from verifydoc.adapters._ocr_common import OCRToken, predictions_from_ocr_tokens
|
|
11
|
+
from verifydoc.adapters.base import ExtractorAdapter
|
|
12
|
+
from verifydoc.types import Document, FieldPrediction, Schema
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PaddleOCRVLAdapter(ExtractorAdapter):
|
|
16
|
+
name = "paddleocr-vl"
|
|
17
|
+
|
|
18
|
+
def __init__(self, lang: str = "en") -> None: # pragma: no cover - needs SDK
|
|
19
|
+
try:
|
|
20
|
+
from paddleocr import PaddleOCR
|
|
21
|
+
except ImportError as exc:
|
|
22
|
+
raise ImportError(
|
|
23
|
+
"PaddleOCRVLAdapter requires the paddleocr package: pip install paddleocr"
|
|
24
|
+
) from exc
|
|
25
|
+
self._ocr = PaddleOCR(use_angle_cls=True, lang=lang, show_log=False)
|
|
26
|
+
|
|
27
|
+
def extract(self, doc: Document, schema: Schema) -> list[FieldPrediction]: # pragma: no cover
|
|
28
|
+
tokens: list[OCRToken] = []
|
|
29
|
+
for page in doc.pages:
|
|
30
|
+
if page.image_path is None:
|
|
31
|
+
continue
|
|
32
|
+
result = self._ocr.ocr(page.image_path, cls=True)
|
|
33
|
+
for line in result[0] or []:
|
|
34
|
+
quad, (text, score) = line
|
|
35
|
+
xs = [pt[0] for pt in quad]
|
|
36
|
+
ys = [pt[1] for pt in quad]
|
|
37
|
+
tokens.append(
|
|
38
|
+
OCRToken(
|
|
39
|
+
text=text,
|
|
40
|
+
bbox=(
|
|
41
|
+
min(xs) / page.width,
|
|
42
|
+
min(ys) / page.height,
|
|
43
|
+
max(xs) / page.width,
|
|
44
|
+
max(ys) / page.height,
|
|
45
|
+
),
|
|
46
|
+
score=float(score),
|
|
47
|
+
page=page.page,
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
return predictions_from_ocr_tokens(doc.doc_id, tokens, schema)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Dependency-free heuristic extractor: label/value search over the text layer.
|
|
2
|
+
|
|
3
|
+
The honest floor baseline: for each schema leaf, find a line whose text
|
|
4
|
+
contains the leaf's label ("invoice_id" -> "invoice id") and take what follows
|
|
5
|
+
the separator as the value. Real OCR/VLM adapters reuse this field-finding on
|
|
6
|
+
top of their own text layers; the paper reports it as the no-model baseline.
|
|
7
|
+
|
|
8
|
+
# DECISION: the heuristic baseline skips array leaves (``items[].price``) —
|
|
9
|
+
# repeating-group detection is extractor territory, not a text heuristic's;
|
|
10
|
+
# array handling is exercised through the mock and model adapters.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from verifydoc.adapters.base import ExtractorAdapter
|
|
16
|
+
from verifydoc.types import Document, FieldPrediction, Schema
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TextSearchAdapter(ExtractorAdapter):
|
|
20
|
+
name = "text-search"
|
|
21
|
+
|
|
22
|
+
def extract(self, doc: Document, schema: Schema) -> list[FieldPrediction]:
|
|
23
|
+
preds: list[FieldPrediction] = []
|
|
24
|
+
for leaf in schema.leaves:
|
|
25
|
+
if "[]" in leaf.path:
|
|
26
|
+
continue
|
|
27
|
+
label = leaf.path.split(".")[-1].replace("_", " ").casefold()
|
|
28
|
+
found = self._find(doc, label)
|
|
29
|
+
if found is not None:
|
|
30
|
+
value, page_no, line = found
|
|
31
|
+
preds.append(
|
|
32
|
+
FieldPrediction(
|
|
33
|
+
path=leaf.path,
|
|
34
|
+
value=value,
|
|
35
|
+
confidence=0.5,
|
|
36
|
+
meta={"source_page": page_no, "source_line": line},
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
return preds
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _find(doc: Document, label: str) -> tuple[str, int, str] | None:
|
|
43
|
+
for page in doc.pages:
|
|
44
|
+
if not page.text:
|
|
45
|
+
continue
|
|
46
|
+
for line in page.text.split("\n"):
|
|
47
|
+
idx = line.casefold().find(label)
|
|
48
|
+
if idx == -1:
|
|
49
|
+
continue
|
|
50
|
+
value = line[idx + len(label) :].lstrip(" \t:—–-").strip()
|
|
51
|
+
if value:
|
|
52
|
+
return value, page.page, line
|
|
53
|
+
return None
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Post-hoc calibrators (PROJECT.md §5.G), fit ONLY on the calibration split."""
|
|
2
|
+
|
|
3
|
+
from verifydoc.calibration.base import Calibrator
|
|
4
|
+
from verifydoc.calibration.conformal import ConformalAbstention
|
|
5
|
+
from verifydoc.calibration.histogram import HistogramBinning
|
|
6
|
+
from verifydoc.calibration.isotonic import IsotonicCalibrator
|
|
7
|
+
from verifydoc.calibration.platt import PlattScaling
|
|
8
|
+
from verifydoc.calibration.splits import assert_disjoint, split_calibration
|
|
9
|
+
from verifydoc.calibration.temperature import TemperatureScaling
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Calibrator",
|
|
13
|
+
"ConformalAbstention",
|
|
14
|
+
"HistogramBinning",
|
|
15
|
+
"IsotonicCalibrator",
|
|
16
|
+
"PlattScaling",
|
|
17
|
+
"TemperatureScaling",
|
|
18
|
+
"assert_disjoint",
|
|
19
|
+
"split_calibration",
|
|
20
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Calibrator interface: fit on the calibration split, transform anywhere else.
|
|
2
|
+
|
|
3
|
+
Golden rule #4: calibration parameters are NEVER tuned on test data. Fit takes
|
|
4
|
+
the calibration split's (confidence, correctness) pairs; transform maps raw
|
|
5
|
+
confidences to calibrated ones.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
_EPS = 1e-7
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Calibrator(ABC):
|
|
19
|
+
"""Post-hoc mapping from raw confidence to calibrated probability."""
|
|
20
|
+
|
|
21
|
+
_fitted: bool = False
|
|
22
|
+
|
|
23
|
+
def fit(self, conf: Sequence[float], correct: Sequence[int]) -> Calibrator:
|
|
24
|
+
c = np.asarray(conf, dtype=float)
|
|
25
|
+
y = np.asarray(correct, dtype=float)
|
|
26
|
+
if c.shape != y.shape or c.ndim != 1 or c.size == 0:
|
|
27
|
+
raise ValueError("conf and correct must be 1-D, equal-length, non-empty")
|
|
28
|
+
if ((c < 0) | (c > 1)).any():
|
|
29
|
+
raise ValueError("confidences must lie in [0, 1]")
|
|
30
|
+
self._fit(c, y)
|
|
31
|
+
self._fitted = True
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
def transform(self, conf: Sequence[float]) -> np.ndarray:
|
|
35
|
+
if not self._fitted:
|
|
36
|
+
raise RuntimeError(f"{type(self).__name__} must be fit on the calibration split first")
|
|
37
|
+
c = np.asarray(conf, dtype=float)
|
|
38
|
+
return np.clip(self._transform(c), 0.0, 1.0)
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def _fit(self, conf: np.ndarray, correct: np.ndarray) -> None: ...
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def _transform(self, conf: np.ndarray) -> np.ndarray: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def logit(p: np.ndarray) -> np.ndarray:
|
|
48
|
+
"""Log-odds with clipping so 0/1 confidences stay finite."""
|
|
49
|
+
p = np.clip(p, _EPS, 1.0 - _EPS)
|
|
50
|
+
return np.log(p / (1.0 - p))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sigmoid(z: np.ndarray) -> np.ndarray:
|
|
54
|
+
from scipy.special import expit # numerically stable for extreme log-odds
|
|
55
|
+
|
|
56
|
+
return np.asarray(expit(z), dtype=float)
|