fastdocparse 0.1.0__tar.gz
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.
- fastdocparse-0.1.0/LICENSE +21 -0
- fastdocparse-0.1.0/PKG-INFO +132 -0
- fastdocparse-0.1.0/README.md +113 -0
- fastdocparse-0.1.0/pyproject.toml +35 -0
- fastdocparse-0.1.0/setup.cfg +4 -0
- fastdocparse-0.1.0/src/docextract/__init__.py +48 -0
- fastdocparse-0.1.0/src/docextract/cache.py +72 -0
- fastdocparse-0.1.0/src/docextract/cli.py +140 -0
- fastdocparse-0.1.0/src/docextract/config.py +38 -0
- fastdocparse-0.1.0/src/docextract/example_schemas.py +13 -0
- fastdocparse-0.1.0/src/docextract/grounding.py +306 -0
- fastdocparse-0.1.0/src/docextract/json_repair.py +79 -0
- fastdocparse-0.1.0/src/docextract/llm_client.py +82 -0
- fastdocparse-0.1.0/src/docextract/ocr_engine.py +105 -0
- fastdocparse-0.1.0/src/docextract/parser.py +313 -0
- fastdocparse-0.1.0/src/docextract/pdf_utils.py +262 -0
- fastdocparse-0.1.0/src/docextract/prompt_compiler.py +86 -0
- fastdocparse-0.1.0/src/docextract/py.typed +0 -0
- fastdocparse-0.1.0/src/docextract/result.py +42 -0
- fastdocparse-0.1.0/src/docextract/schema.py +94 -0
- fastdocparse-0.1.0/src/docextract/schema_compiler.py +68 -0
- fastdocparse-0.1.0/src/docextract/schemas/invoice.json +73 -0
- fastdocparse-0.1.0/src/docextract/schemas/shipment_manifest.json +40 -0
- fastdocparse-0.1.0/src/fastdocparse.egg-info/PKG-INFO +132 -0
- fastdocparse-0.1.0/src/fastdocparse.egg-info/SOURCES.txt +33 -0
- fastdocparse-0.1.0/src/fastdocparse.egg-info/dependency_links.txt +1 -0
- fastdocparse-0.1.0/src/fastdocparse.egg-info/entry_points.txt +2 -0
- fastdocparse-0.1.0/src/fastdocparse.egg-info/requires.txt +10 -0
- fastdocparse-0.1.0/src/fastdocparse.egg-info/top_level.txt +1 -0
- fastdocparse-0.1.0/tests/test_architecture.py +234 -0
- fastdocparse-0.1.0/tests/test_cli.py +129 -0
- fastdocparse-0.1.0/tests/test_grounding.py +72 -0
- fastdocparse-0.1.0/tests/test_parser.py +359 -0
- fastdocparse-0.1.0/tests/test_schema_compiler.py +59 -0
- fastdocparse-0.1.0/tests/test_severe_edge_cases.py +133 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pranjal Parmar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastdocparse
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Extract structured data from semi-structured documents using any OpenAI-compatible LLM, with per-field grounding and confidence.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: <3.13,>=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: pydantic>=2.0
|
|
10
|
+
Requires-Dist: openai>=1.0
|
|
11
|
+
Requires-Dist: pymupdf>=1.24
|
|
12
|
+
Requires-Dist: pillow>=10.0
|
|
13
|
+
Requires-Dist: rapidocr-onnxruntime>=1.3
|
|
14
|
+
Requires-Dist: typer>=0.12
|
|
15
|
+
Requires-Dist: PyYAML>=6.0
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# docextract
|
|
21
|
+
|
|
22
|
+
Extract structured data from semi-structured documents — invoices, bills, tax forms, resumes, bank statements, shipment manifests — using any OpenAI-compatible LLM (OpenAI, Ollama, vLLM, Groq, etc.), with **per-field grounding and confidence**, not just raw extraction.
|
|
23
|
+
|
|
24
|
+
## Why this, not just another parser
|
|
25
|
+
|
|
26
|
+
Most extractors give you a value and no way to know if it's real. This one tells you:
|
|
27
|
+
|
|
28
|
+
- **`grounded`** — the value was found verbatim (or near-verbatim) in the source document text.
|
|
29
|
+
- **`ungrounded`** — the value doesn't appear in the source — likely a hallucination. Flag for human review.
|
|
30
|
+
- **`missing_required`** — a field you marked required came back empty.
|
|
31
|
+
- **`invalid_format`** — the value doesn't match a pattern/enum constraint you declared (e.g. a shipment status outside the allowed list).
|
|
32
|
+
- **`failed_check`** — a custom cross-field rule failed (e.g. line items don't sum to the stated total).
|
|
33
|
+
|
|
34
|
+
No extra LLM call for any of this — it's deterministic, string/rule-based validation against text you already extracted.
|
|
35
|
+
|
|
36
|
+
**Where it fits:** semi-structured documents with recurring fields (invoices, bills, tax forms, resumes, statements), and prose documents where *proving* a value came from the source matters (contracts, legal clauses, insurance claims). It is not a vision-LLM pipeline — it works from extracted text (digital PDF text layer, or local OCR for scans/images), which is what keeps it fast, cheap, and usable with small local models. Messy handwritten forms or complex multi-column layouts are a known weaker spot (see [document-extractor-spec.md](document-extractor-spec.md)).
|
|
37
|
+
|
|
38
|
+
## Two ways to use it
|
|
39
|
+
|
|
40
|
+
| | Who it's for | How |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| **CLI** | No coding needed | `docextract extract <file> <schema.json>` |
|
|
43
|
+
| **Python API** | Building it into your own app | `DocumentParser(client).extract(document_bytes, schema)` |
|
|
44
|
+
|
|
45
|
+
Defining *what* to extract also has two paths — hand-write a JSON/YAML schema file, or describe it in plain English and let the LLM draft the schema for you.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
git clone <this repo>
|
|
51
|
+
cd document-extractor
|
|
52
|
+
python -m venv venv
|
|
53
|
+
source venv/bin/activate # Windows: venv\Scripts\activate
|
|
54
|
+
pip install -e .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
(Not yet published to PyPI — see [Status](#status). Until then, install from a local clone as above.)
|
|
58
|
+
|
|
59
|
+
You also need access to an LLM. Either:
|
|
60
|
+
- An OpenAI API key (`export OPENAI_API_KEY=...` or pass `--api-key`), or
|
|
61
|
+
- A local model via [Ollama](https://ollama.com/) — no API key, no cloud, documents never leave your machine.
|
|
62
|
+
|
|
63
|
+
## Quickstart — CLI (no coding)
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# 1. Extract using one of the bundled example schemas
|
|
67
|
+
docextract extract sample_invoice.png src/docextract/schemas/invoice.json \
|
|
68
|
+
--model gpt-4o-mini --api-key sk-...
|
|
69
|
+
|
|
70
|
+
# Or with a local model via Ollama (no API key needed):
|
|
71
|
+
docextract extract sample_invoice.png src/docextract/schemas/invoice.json \
|
|
72
|
+
--model llama3.2 --base-url http://localhost:11434/v1 --api-key ollama
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Output is JSON, printed to stdout (or saved with `--output result.json`):
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"_meta": { "truncated": false, "truncation_reason": null },
|
|
80
|
+
"invoice_number": { "value": "INV-9011", "confidence": "high", "flags": ["grounded"] },
|
|
81
|
+
"total_price": { "value": 100.0, "confidence": "high", "flags": ["grounded"] }
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Don't want to write JSON at all? Describe the fields in plain English instead:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
docextract schema-from-text \
|
|
89
|
+
"I want the invoice number, total price, and vendor name. Invoice number and total are required." \
|
|
90
|
+
--output my_invoice_schema.json
|
|
91
|
+
|
|
92
|
+
# review my_invoice_schema.json, then:
|
|
93
|
+
docextract extract my_invoice.pdf my_invoice_schema.json
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Quickstart — Python API
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from docextract import Schema, Field, LLMClient, DocumentParser
|
|
100
|
+
|
|
101
|
+
schema = Schema(
|
|
102
|
+
name="Invoice",
|
|
103
|
+
fields=[
|
|
104
|
+
Field(name="invoice_number", description="The invoice number", required=True),
|
|
105
|
+
Field(name="total_price", description="Total amount due", type="number", required=True),
|
|
106
|
+
],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
client = LLMClient(model="gpt-4o-mini", api_key="sk-...")
|
|
110
|
+
# or: LLMClient(base_url="http://localhost:11434/v1", api_key="ollama", model="llama3.2")
|
|
111
|
+
|
|
112
|
+
parser = DocumentParser(client=client)
|
|
113
|
+
|
|
114
|
+
with open("invoice.pdf", "rb") as f:
|
|
115
|
+
result = parser.extract(f.read(), schema)
|
|
116
|
+
|
|
117
|
+
print(result["invoice_number"]) # {'value': 'INV-9011', 'confidence': 'high', 'flags': ['grounded']}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Full documentation
|
|
121
|
+
|
|
122
|
+
- [Getting Started](docs/getting-started.md) — step-by-step install, CLI, and API walkthroughs
|
|
123
|
+
- [Schema Guide](docs/schema-guide.md) — every field option (`type`, `required`, `pattern`, `enum`, `sub_fields`, few-shot `examples`), for JSON, YAML, and plain-English authoring
|
|
124
|
+
- [Output & Validation](docs/output-format.md) — the full result shape, what each confidence flag means, and how to write custom cross-check rules
|
|
125
|
+
- [Architecture](docs/architecture.md) — diagrams of the pipeline, the module dependency graph, and where to plug in a contribution
|
|
126
|
+
- [Project spec](document-extractor-spec.md) — architecture, phased roadmap, honest competitive positioning
|
|
127
|
+
|
|
128
|
+
Want to contribute? Start with [docs/architecture.md](docs/architecture.md) for the map, then [CONTRIBUTING.md](CONTRIBUTING.md) for the process.
|
|
129
|
+
|
|
130
|
+
## Status
|
|
131
|
+
|
|
132
|
+
Core extraction, grounding, chunking, both CLI/API paths, and real packaging (`pip install -e .` installs a working `docextract` command and a proper `docextract.*` import namespace — verified with a from-scratch build and a clean-virtualenv install) are implemented and tested (74 tests, `pytest -v`). Not yet done: actually publishing to PyPI and a hosted API — see [document-extractor-spec.md](document-extractor-spec.md) for the roadmap.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# docextract
|
|
2
|
+
|
|
3
|
+
Extract structured data from semi-structured documents — invoices, bills, tax forms, resumes, bank statements, shipment manifests — using any OpenAI-compatible LLM (OpenAI, Ollama, vLLM, Groq, etc.), with **per-field grounding and confidence**, not just raw extraction.
|
|
4
|
+
|
|
5
|
+
## Why this, not just another parser
|
|
6
|
+
|
|
7
|
+
Most extractors give you a value and no way to know if it's real. This one tells you:
|
|
8
|
+
|
|
9
|
+
- **`grounded`** — the value was found verbatim (or near-verbatim) in the source document text.
|
|
10
|
+
- **`ungrounded`** — the value doesn't appear in the source — likely a hallucination. Flag for human review.
|
|
11
|
+
- **`missing_required`** — a field you marked required came back empty.
|
|
12
|
+
- **`invalid_format`** — the value doesn't match a pattern/enum constraint you declared (e.g. a shipment status outside the allowed list).
|
|
13
|
+
- **`failed_check`** — a custom cross-field rule failed (e.g. line items don't sum to the stated total).
|
|
14
|
+
|
|
15
|
+
No extra LLM call for any of this — it's deterministic, string/rule-based validation against text you already extracted.
|
|
16
|
+
|
|
17
|
+
**Where it fits:** semi-structured documents with recurring fields (invoices, bills, tax forms, resumes, statements), and prose documents where *proving* a value came from the source matters (contracts, legal clauses, insurance claims). It is not a vision-LLM pipeline — it works from extracted text (digital PDF text layer, or local OCR for scans/images), which is what keeps it fast, cheap, and usable with small local models. Messy handwritten forms or complex multi-column layouts are a known weaker spot (see [document-extractor-spec.md](document-extractor-spec.md)).
|
|
18
|
+
|
|
19
|
+
## Two ways to use it
|
|
20
|
+
|
|
21
|
+
| | Who it's for | How |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| **CLI** | No coding needed | `docextract extract <file> <schema.json>` |
|
|
24
|
+
| **Python API** | Building it into your own app | `DocumentParser(client).extract(document_bytes, schema)` |
|
|
25
|
+
|
|
26
|
+
Defining *what* to extract also has two paths — hand-write a JSON/YAML schema file, or describe it in plain English and let the LLM draft the schema for you.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
git clone <this repo>
|
|
32
|
+
cd document-extractor
|
|
33
|
+
python -m venv venv
|
|
34
|
+
source venv/bin/activate # Windows: venv\Scripts\activate
|
|
35
|
+
pip install -e .
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
(Not yet published to PyPI — see [Status](#status). Until then, install from a local clone as above.)
|
|
39
|
+
|
|
40
|
+
You also need access to an LLM. Either:
|
|
41
|
+
- An OpenAI API key (`export OPENAI_API_KEY=...` or pass `--api-key`), or
|
|
42
|
+
- A local model via [Ollama](https://ollama.com/) — no API key, no cloud, documents never leave your machine.
|
|
43
|
+
|
|
44
|
+
## Quickstart — CLI (no coding)
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# 1. Extract using one of the bundled example schemas
|
|
48
|
+
docextract extract sample_invoice.png src/docextract/schemas/invoice.json \
|
|
49
|
+
--model gpt-4o-mini --api-key sk-...
|
|
50
|
+
|
|
51
|
+
# Or with a local model via Ollama (no API key needed):
|
|
52
|
+
docextract extract sample_invoice.png src/docextract/schemas/invoice.json \
|
|
53
|
+
--model llama3.2 --base-url http://localhost:11434/v1 --api-key ollama
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Output is JSON, printed to stdout (or saved with `--output result.json`):
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"_meta": { "truncated": false, "truncation_reason": null },
|
|
61
|
+
"invoice_number": { "value": "INV-9011", "confidence": "high", "flags": ["grounded"] },
|
|
62
|
+
"total_price": { "value": 100.0, "confidence": "high", "flags": ["grounded"] }
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Don't want to write JSON at all? Describe the fields in plain English instead:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
docextract schema-from-text \
|
|
70
|
+
"I want the invoice number, total price, and vendor name. Invoice number and total are required." \
|
|
71
|
+
--output my_invoice_schema.json
|
|
72
|
+
|
|
73
|
+
# review my_invoice_schema.json, then:
|
|
74
|
+
docextract extract my_invoice.pdf my_invoice_schema.json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Quickstart — Python API
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from docextract import Schema, Field, LLMClient, DocumentParser
|
|
81
|
+
|
|
82
|
+
schema = Schema(
|
|
83
|
+
name="Invoice",
|
|
84
|
+
fields=[
|
|
85
|
+
Field(name="invoice_number", description="The invoice number", required=True),
|
|
86
|
+
Field(name="total_price", description="Total amount due", type="number", required=True),
|
|
87
|
+
],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
client = LLMClient(model="gpt-4o-mini", api_key="sk-...")
|
|
91
|
+
# or: LLMClient(base_url="http://localhost:11434/v1", api_key="ollama", model="llama3.2")
|
|
92
|
+
|
|
93
|
+
parser = DocumentParser(client=client)
|
|
94
|
+
|
|
95
|
+
with open("invoice.pdf", "rb") as f:
|
|
96
|
+
result = parser.extract(f.read(), schema)
|
|
97
|
+
|
|
98
|
+
print(result["invoice_number"]) # {'value': 'INV-9011', 'confidence': 'high', 'flags': ['grounded']}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Full documentation
|
|
102
|
+
|
|
103
|
+
- [Getting Started](docs/getting-started.md) — step-by-step install, CLI, and API walkthroughs
|
|
104
|
+
- [Schema Guide](docs/schema-guide.md) — every field option (`type`, `required`, `pattern`, `enum`, `sub_fields`, few-shot `examples`), for JSON, YAML, and plain-English authoring
|
|
105
|
+
- [Output & Validation](docs/output-format.md) — the full result shape, what each confidence flag means, and how to write custom cross-check rules
|
|
106
|
+
- [Architecture](docs/architecture.md) — diagrams of the pipeline, the module dependency graph, and where to plug in a contribution
|
|
107
|
+
- [Project spec](document-extractor-spec.md) — architecture, phased roadmap, honest competitive positioning
|
|
108
|
+
|
|
109
|
+
Want to contribute? Start with [docs/architecture.md](docs/architecture.md) for the map, then [CONTRIBUTING.md](CONTRIBUTING.md) for the process.
|
|
110
|
+
|
|
111
|
+
## Status
|
|
112
|
+
|
|
113
|
+
Core extraction, grounding, chunking, both CLI/API paths, and real packaging (`pip install -e .` installs a working `docextract` command and a proper `docextract.*` import namespace — verified with a from-scratch build and a clean-virtualenv install) are implemented and tested (74 tests, `pytest -v`). Not yet done: actually publishing to PyPI and a hosted API — see [document-extractor-spec.md](document-extractor-spec.md) for the roadmap.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fastdocparse"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Extract structured data from semi-structured documents using any OpenAI-compatible LLM, with per-field grounding and confidence."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.9,<3.13" # capped by rapidocr-onnxruntime's own upper bound (checked live against PyPI)
|
|
8
|
+
dependencies = [
|
|
9
|
+
"pydantic>=2.0",
|
|
10
|
+
"openai>=1.0",
|
|
11
|
+
"pymupdf>=1.24",
|
|
12
|
+
"pillow>=10.0",
|
|
13
|
+
"rapidocr-onnxruntime>=1.3",
|
|
14
|
+
"typer>=0.12",
|
|
15
|
+
"PyYAML>=6.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = ["pytest"]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
docextract = "docextract.cli:app"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["setuptools>=68"]
|
|
26
|
+
build-backend = "setuptools.build_meta"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.package-data]
|
|
32
|
+
docextract = ["schemas/*.json", "py.typed"]
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""docextract — extract structured data from semi-structured documents using any
|
|
2
|
+
OpenAI-compatible LLM, with per-field grounding and confidence.
|
|
3
|
+
|
|
4
|
+
from docextract import Schema, Field, LLMClient, DocumentParser
|
|
5
|
+
|
|
6
|
+
schema = Schema(name="Invoice", fields=[Field(name="total", description="Grand total", type="number")])
|
|
7
|
+
client = LLMClient(model="gpt-4o-mini", api_key="sk-...")
|
|
8
|
+
result = DocumentParser(client).extract(document_bytes, schema)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .cache import Cache, InMemoryCache
|
|
12
|
+
from .config import ExtractionConfig
|
|
13
|
+
from .grounding import Issue, check_substring, cross_check, date_parseable_rule, numeric_sum_rule, validate_field_constraints
|
|
14
|
+
from .llm_client import LLMClient, LLMClientError
|
|
15
|
+
from .parser import DocumentParser, EmptyDocumentError, UnknownIngestionKindError, register_default_ingestion_handler
|
|
16
|
+
from .result import ExtractionMeta, ExtractionResult, FieldResult
|
|
17
|
+
from .schema import Field, Schema
|
|
18
|
+
from .schema_compiler import compile_schema_from_description
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from importlib.metadata import version as _pkg_version
|
|
22
|
+
__version__ = _pkg_version("docextract")
|
|
23
|
+
except Exception:
|
|
24
|
+
__version__ = "0.0.0+unknown"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Schema",
|
|
28
|
+
"Field",
|
|
29
|
+
"LLMClient",
|
|
30
|
+
"LLMClientError",
|
|
31
|
+
"DocumentParser",
|
|
32
|
+
"EmptyDocumentError",
|
|
33
|
+
"UnknownIngestionKindError",
|
|
34
|
+
"register_default_ingestion_handler",
|
|
35
|
+
"ExtractionConfig",
|
|
36
|
+
"Cache",
|
|
37
|
+
"InMemoryCache",
|
|
38
|
+
"ExtractionResult",
|
|
39
|
+
"FieldResult",
|
|
40
|
+
"ExtractionMeta",
|
|
41
|
+
"Issue",
|
|
42
|
+
"check_substring",
|
|
43
|
+
"cross_check",
|
|
44
|
+
"validate_field_constraints",
|
|
45
|
+
"numeric_sum_rule",
|
|
46
|
+
"date_parseable_rule",
|
|
47
|
+
"compile_schema_from_description",
|
|
48
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Pluggable caching for DocumentParser.extract(). Off by default — pass a cache instance to opt in.
|
|
2
|
+
|
|
3
|
+
Caching is skipped whenever custom `rules` are passed to extract(), since a rule is an
|
|
4
|
+
arbitrary callable that can't be safely fingerprinted — caching would risk returning a
|
|
5
|
+
result validated under a different rule than the one just requested.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from typing import Any, Callable, Dict, Optional, Protocol
|
|
12
|
+
|
|
13
|
+
from .config import ExtractionConfig
|
|
14
|
+
from .schema import Schema
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Cache(Protocol):
|
|
18
|
+
def get(self, key: str) -> Optional[Dict[str, Any]]: ...
|
|
19
|
+
def set(self, key: str, value: Dict[str, Any]) -> None: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InMemoryCache:
|
|
23
|
+
"""Process-local cache. Good for a long-running batch job or server process; lost on restart.
|
|
24
|
+
|
|
25
|
+
Pass max_size to cap memory use — oldest-accessed entries are evicted once the cap is
|
|
26
|
+
hit (LRU). Leave it None (default) only for short-lived scripts/batch jobs where the
|
|
27
|
+
process exits before the cache could grow unbounded.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, max_size: Optional[int] = None):
|
|
31
|
+
if max_size is not None and max_size <= 0:
|
|
32
|
+
raise ValueError(f"max_size must be positive, got {max_size}")
|
|
33
|
+
self._max_size = max_size
|
|
34
|
+
self._store: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
|
|
35
|
+
|
|
36
|
+
def get(self, key: str) -> Optional[Dict[str, Any]]:
|
|
37
|
+
if key not in self._store:
|
|
38
|
+
return None
|
|
39
|
+
self._store.move_to_end(key)
|
|
40
|
+
return self._store[key]
|
|
41
|
+
|
|
42
|
+
def set(self, key: str, value: Dict[str, Any]) -> None:
|
|
43
|
+
self._store[key] = value
|
|
44
|
+
self._store.move_to_end(key)
|
|
45
|
+
if self._max_size is not None:
|
|
46
|
+
while len(self._store) > self._max_size:
|
|
47
|
+
self._store.popitem(last=False)
|
|
48
|
+
|
|
49
|
+
def __len__(self) -> int:
|
|
50
|
+
return len(self._store)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def make_cache_key(
|
|
54
|
+
document_bytes: bytes,
|
|
55
|
+
schema: Schema,
|
|
56
|
+
kind: str,
|
|
57
|
+
config: ExtractionConfig,
|
|
58
|
+
handler: Callable[..., str],
|
|
59
|
+
) -> str:
|
|
60
|
+
"""Fingerprint a (document, schema, ingestion kind, config, handler) combination.
|
|
61
|
+
|
|
62
|
+
handler is included, not just kind, because handlers are registered per
|
|
63
|
+
DocumentParser instance — two instances can register different handlers under the
|
|
64
|
+
same kind name, and a cache shared between them must not conflate the two.
|
|
65
|
+
Identity is process-local (qualified name + id()), which matches InMemoryCache's own
|
|
66
|
+
process-local lifetime — it isn't meant to survive a restart anyway.
|
|
67
|
+
"""
|
|
68
|
+
document_digest = hashlib.sha256(document_bytes).hexdigest()
|
|
69
|
+
schema_digest = hashlib.sha256(schema.model_dump_json().encode()).hexdigest()
|
|
70
|
+
config_digest = hashlib.sha256(json.dumps(config.__dict__, sort_keys=True).encode()).hexdigest()
|
|
71
|
+
handler_identity = f"{getattr(handler, '__module__', '?')}.{getattr(handler, '__qualname__', repr(handler))}:{id(handler)}"
|
|
72
|
+
return hashlib.sha256(f"{document_digest}:{schema_digest}:{kind}:{config_digest}:{handler_identity}".encode()).hexdigest()
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Command-line entrypoint: extract fields from a document without writing code.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
docextract extract document.pdf invoice_schema.json
|
|
5
|
+
docextract extract receipt.jpg shipment_schema.json --model llama3 --base-url http://localhost:11434/v1
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
|
|
17
|
+
from .llm_client import LLMClient, LLMClientError
|
|
18
|
+
from .parser import DocumentParser, EmptyDocumentError, UnknownIngestionKindError
|
|
19
|
+
from .schema import Schema
|
|
20
|
+
from .schema_compiler import compile_schema_from_description
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_plugins() -> None:
|
|
24
|
+
"""Import modules listed in DOCEXTRACT_PLUGINS (comma-separated) so they can call
|
|
25
|
+
parser.register_default_ingestion_handler() on import — the only way a custom
|
|
26
|
+
ingestion kind (DOCX, XLSX, ...) becomes reachable from this CLI, since a fresh CLI
|
|
27
|
+
process otherwise only knows the built-in "pdf"/"image" handlers.
|
|
28
|
+
|
|
29
|
+
Security note: this imports and runs arbitrary Python from wherever DOCEXTRACT_PLUGINS
|
|
30
|
+
points, at CLI startup, with no sandboxing — the same trust model as PYTHONSTARTUP or
|
|
31
|
+
DJANGO_SETTINGS_MODULE. That's fine for a user pointing it at their own plugin on their
|
|
32
|
+
own machine, which is the only supported use. Never let this env var be set from an
|
|
33
|
+
untrusted source (e.g. a request parameter in a hosted service built on this CLI).
|
|
34
|
+
"""
|
|
35
|
+
plugin_spec = os.environ.get("DOCEXTRACT_PLUGINS", "")
|
|
36
|
+
for module_name in filter(None, (p.strip() for p in plugin_spec.split(","))):
|
|
37
|
+
importlib.import_module(module_name)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_load_plugins()
|
|
41
|
+
|
|
42
|
+
app = typer.Typer(
|
|
43
|
+
add_completion=False,
|
|
44
|
+
help="Extract structured data from a PDF/PNG/JPG using a schema file you define — no coding required.",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg"}
|
|
48
|
+
SUPPORTED_EXTENSIONS = IMAGE_EXTENSIONS | {".pdf"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command()
|
|
52
|
+
def extract(
|
|
53
|
+
file: Path = typer.Argument(..., exists=True, readable=True, help="Path to the PDF/PNG/JPG document."),
|
|
54
|
+
schema: Path = typer.Argument(..., exists=True, readable=True, help="Path to a .json or .yaml schema file listing the fields to extract."),
|
|
55
|
+
model: str = typer.Option("gpt-4o-mini", "--model", "-m", help="Model name, e.g. gpt-4o-mini, llama3."),
|
|
56
|
+
base_url: Optional[str] = typer.Option(None, "--base-url", help="OpenAI-compatible API base URL. Omit for OpenAI; use e.g. http://localhost:11434/v1 for Ollama."),
|
|
57
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", envvar="LLM_API_KEY", help="API key. Not needed for local Ollama."),
|
|
58
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write the JSON result to this file instead of printing it."),
|
|
59
|
+
kind: Optional[str] = typer.Option(None, "--kind", help="Override ingestion routing (e.g. 'docx' for a custom handler loaded via DOCEXTRACT_PLUGINS). Defaults to auto-detecting pdf/image from the file extension."),
|
|
60
|
+
):
|
|
61
|
+
"""Extract the fields defined in SCHEMA from FILE and print the result as JSON."""
|
|
62
|
+
if kind is None and file.suffix.lower() not in SUPPORTED_EXTENSIONS:
|
|
63
|
+
typer.echo(f"Unsupported file type {file.suffix!r}. Supported: .pdf, .png, .jpg, .jpeg (or pass --kind).", err=True)
|
|
64
|
+
raise typer.Exit(code=1)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
doc_schema = Schema.from_file(schema)
|
|
68
|
+
except (ValidationError, ValueError, OSError) as e:
|
|
69
|
+
typer.echo(f"Could not load schema from {schema}: {e}", err=True)
|
|
70
|
+
raise typer.Exit(code=1)
|
|
71
|
+
|
|
72
|
+
client = LLMClient(base_url=base_url, api_key=api_key, model=model)
|
|
73
|
+
document_parser = DocumentParser(client=client)
|
|
74
|
+
|
|
75
|
+
is_image = file.suffix.lower() in IMAGE_EXTENSIONS
|
|
76
|
+
document_bytes = file.read_bytes()
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
result = document_parser.extract(document_bytes, doc_schema, is_image=is_image, kind=kind)
|
|
80
|
+
except EmptyDocumentError as e:
|
|
81
|
+
typer.echo(f"Extraction failed: {e}", err=True)
|
|
82
|
+
raise typer.Exit(code=1)
|
|
83
|
+
except LLMClientError as e:
|
|
84
|
+
typer.echo(f"Could not complete extraction: {e}", err=True)
|
|
85
|
+
raise typer.Exit(code=1)
|
|
86
|
+
except UnknownIngestionKindError as e:
|
|
87
|
+
typer.echo(f"{e} (check --kind is spelled correctly and its plugin is loaded via DOCEXTRACT_PLUGINS)", err=True)
|
|
88
|
+
raise typer.Exit(code=1)
|
|
89
|
+
except ValueError as e:
|
|
90
|
+
typer.echo(f"Extraction failed: {e}", err=True)
|
|
91
|
+
raise typer.Exit(code=1)
|
|
92
|
+
|
|
93
|
+
result_json = json.dumps(result, indent=2, default=str)
|
|
94
|
+
|
|
95
|
+
if output:
|
|
96
|
+
try:
|
|
97
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
output.write_text(result_json)
|
|
99
|
+
except OSError as e:
|
|
100
|
+
typer.echo(f"Could not write output to {output}: {e}", err=True)
|
|
101
|
+
raise typer.Exit(code=1)
|
|
102
|
+
typer.echo(f"Wrote result to {output}")
|
|
103
|
+
else:
|
|
104
|
+
typer.echo(result_json)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command(name="schema-from-text")
|
|
108
|
+
def schema_from_text(
|
|
109
|
+
description: str = typer.Argument(..., help="Plain-English description of the fields you want extracted, e.g. \"invoice number, total price, and a list of line items with product name and quantity\"."),
|
|
110
|
+
output: Path = typer.Option(..., "--output", "-o", help="Where to save the generated schema (.json)."),
|
|
111
|
+
model: str = typer.Option("gpt-4o-mini", "--model", "-m", help="Model name, e.g. gpt-4o-mini, llama3."),
|
|
112
|
+
base_url: Optional[str] = typer.Option(None, "--base-url", help="OpenAI-compatible API base URL. Omit for OpenAI; use e.g. http://localhost:11434/v1 for Ollama."),
|
|
113
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", envvar="LLM_API_KEY", help="API key. Not needed for local Ollama."),
|
|
114
|
+
):
|
|
115
|
+
"""Turn a plain-English description of the fields you want into a schema file.
|
|
116
|
+
|
|
117
|
+
Review the generated file before using it with `extract` — the LLM proposes field
|
|
118
|
+
names and types from your description, and a wrong guess here affects every
|
|
119
|
+
document you later run against this schema.
|
|
120
|
+
"""
|
|
121
|
+
client = LLMClient(base_url=base_url, api_key=api_key, model=model)
|
|
122
|
+
try:
|
|
123
|
+
doc_schema = compile_schema_from_description(description, client)
|
|
124
|
+
except (ValueError, LLMClientError) as e:
|
|
125
|
+
typer.echo(f"Could not generate a schema: {e}", err=True)
|
|
126
|
+
raise typer.Exit(code=1)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
output.write_text(json.dumps(doc_schema.model_dump(), indent=2))
|
|
131
|
+
except OSError as e:
|
|
132
|
+
typer.echo(f"Could not write schema to {output}: {e}", err=True)
|
|
133
|
+
raise typer.Exit(code=1)
|
|
134
|
+
|
|
135
|
+
typer.echo(f"Saved schema '{doc_schema.name}' with {len(doc_schema.fields)} field(s) to {output}")
|
|
136
|
+
typer.echo("Review it, then run: docextract extract <your_document> " + str(output))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
app()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Tunable extraction parameters, previously hardcoded inline in parser.py."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class ExtractionConfig:
|
|
8
|
+
"""Knobs for a DocumentParser run. Override per-instance instead of editing source."""
|
|
9
|
+
|
|
10
|
+
# The single page cap: ingestion reads (and chunks/merges across) up to this
|
|
11
|
+
# many pages. A document with more pages than this is flagged truncated —
|
|
12
|
+
# there used to be two separate, disconnected limits here (a smaller one
|
|
13
|
+
# actually used for ingestion, a larger one only used for the truncation
|
|
14
|
+
# flag), which meant a document could silently lose pages while still being
|
|
15
|
+
# reported as "not truncated." One limit now, so the flag is honest.
|
|
16
|
+
max_pages: int = 15
|
|
17
|
+
chunk_max_tokens: int = 3000
|
|
18
|
+
pdf_render_dpi: int = 150
|
|
19
|
+
max_image_dim: int = 1536
|
|
20
|
+
ocr_min_confidence: float = 0.3
|
|
21
|
+
# Chunks are processed sequentially by default (1) — safe, deterministic order.
|
|
22
|
+
# Raise this to fan LLM calls for independent chunks out over threads; chunks are
|
|
23
|
+
# I/O-bound network calls, so a plain ThreadPoolExecutor is enough (no asyncio needed).
|
|
24
|
+
max_concurrent_chunks: int = 1
|
|
25
|
+
|
|
26
|
+
def __post_init__(self):
|
|
27
|
+
if self.max_pages <= 0:
|
|
28
|
+
raise ValueError(f"max_pages must be positive, got {self.max_pages}")
|
|
29
|
+
if self.chunk_max_tokens <= 0:
|
|
30
|
+
raise ValueError(f"chunk_max_tokens must be positive, got {self.chunk_max_tokens}")
|
|
31
|
+
if self.pdf_render_dpi <= 0:
|
|
32
|
+
raise ValueError(f"pdf_render_dpi must be positive, got {self.pdf_render_dpi}")
|
|
33
|
+
if self.max_image_dim <= 0:
|
|
34
|
+
raise ValueError(f"max_image_dim must be positive, got {self.max_image_dim}")
|
|
35
|
+
if not 0.0 <= self.ocr_min_confidence <= 1.0:
|
|
36
|
+
raise ValueError(f"ocr_min_confidence must be between 0 and 1, got {self.ocr_min_confidence}")
|
|
37
|
+
if self.max_concurrent_chunks <= 0:
|
|
38
|
+
raise ValueError(f"max_concurrent_chunks must be positive, got {self.max_concurrent_chunks}")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Pre-built schemas with few-shot examples for common document types.
|
|
2
|
+
|
|
3
|
+
Loaded from schemas/*.json rather than declared twice — schemas/invoice.json is the
|
|
4
|
+
single source of truth; editing it updates both the CLI template and this import.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .schema import Schema
|
|
10
|
+
|
|
11
|
+
_SCHEMAS_DIR = Path(__file__).parent / "schemas"
|
|
12
|
+
|
|
13
|
+
INVOICE_SCHEMA = Schema.from_file(_SCHEMAS_DIR / "invoice.json")
|