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.
Files changed (35) hide show
  1. fastdocparse-0.1.0/LICENSE +21 -0
  2. fastdocparse-0.1.0/PKG-INFO +132 -0
  3. fastdocparse-0.1.0/README.md +113 -0
  4. fastdocparse-0.1.0/pyproject.toml +35 -0
  5. fastdocparse-0.1.0/setup.cfg +4 -0
  6. fastdocparse-0.1.0/src/docextract/__init__.py +48 -0
  7. fastdocparse-0.1.0/src/docextract/cache.py +72 -0
  8. fastdocparse-0.1.0/src/docextract/cli.py +140 -0
  9. fastdocparse-0.1.0/src/docextract/config.py +38 -0
  10. fastdocparse-0.1.0/src/docextract/example_schemas.py +13 -0
  11. fastdocparse-0.1.0/src/docextract/grounding.py +306 -0
  12. fastdocparse-0.1.0/src/docextract/json_repair.py +79 -0
  13. fastdocparse-0.1.0/src/docextract/llm_client.py +82 -0
  14. fastdocparse-0.1.0/src/docextract/ocr_engine.py +105 -0
  15. fastdocparse-0.1.0/src/docextract/parser.py +313 -0
  16. fastdocparse-0.1.0/src/docextract/pdf_utils.py +262 -0
  17. fastdocparse-0.1.0/src/docextract/prompt_compiler.py +86 -0
  18. fastdocparse-0.1.0/src/docextract/py.typed +0 -0
  19. fastdocparse-0.1.0/src/docextract/result.py +42 -0
  20. fastdocparse-0.1.0/src/docextract/schema.py +94 -0
  21. fastdocparse-0.1.0/src/docextract/schema_compiler.py +68 -0
  22. fastdocparse-0.1.0/src/docextract/schemas/invoice.json +73 -0
  23. fastdocparse-0.1.0/src/docextract/schemas/shipment_manifest.json +40 -0
  24. fastdocparse-0.1.0/src/fastdocparse.egg-info/PKG-INFO +132 -0
  25. fastdocparse-0.1.0/src/fastdocparse.egg-info/SOURCES.txt +33 -0
  26. fastdocparse-0.1.0/src/fastdocparse.egg-info/dependency_links.txt +1 -0
  27. fastdocparse-0.1.0/src/fastdocparse.egg-info/entry_points.txt +2 -0
  28. fastdocparse-0.1.0/src/fastdocparse.egg-info/requires.txt +10 -0
  29. fastdocparse-0.1.0/src/fastdocparse.egg-info/top_level.txt +1 -0
  30. fastdocparse-0.1.0/tests/test_architecture.py +234 -0
  31. fastdocparse-0.1.0/tests/test_cli.py +129 -0
  32. fastdocparse-0.1.0/tests/test_grounding.py +72 -0
  33. fastdocparse-0.1.0/tests/test_parser.py +359 -0
  34. fastdocparse-0.1.0/tests/test_schema_compiler.py +59 -0
  35. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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")