structured-data-extraction 0.1.0__tar.gz → 0.2.1__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 (45) hide show
  1. structured_data_extraction-0.2.1/PKG-INFO +265 -0
  2. structured_data_extraction-0.2.1/README.md +233 -0
  3. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/pyproject.toml +2 -2
  4. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/config.py +11 -0
  5. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/extract.py +41 -13
  6. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/main.py +39 -7
  7. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/models/section_classifier/rf_classifier.joblib +1 -1
  8. structured_data_extraction-0.2.1/src/structured_data_extraction.egg-info/PKG-INFO +265 -0
  9. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/tests/test_extract.py +31 -0
  10. structured_data_extraction-0.1.0/PKG-INFO +0 -234
  11. structured_data_extraction-0.1.0/README.md +0 -202
  12. structured_data_extraction-0.1.0/src/structured_data_extraction.egg-info/PKG-INFO +0 -234
  13. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/LICENSE +0 -0
  14. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/setup.cfg +0 -0
  15. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/__init__.py +0 -0
  16. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/classify.py +0 -0
  17. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/convert.py +0 -0
  18. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/embed.py +0 -0
  19. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/features.py +0 -0
  20. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/filter_records.py +0 -0
  21. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/matching.py +0 -0
  22. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/models/section_classifier/docling_cats.joblib +0 -0
  23. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/models/section_classifier/tfidf_char_vectorizer.joblib +0 -0
  24. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/models/section_classifier/tfidf_vectorizer.joblib +0 -0
  25. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/paths.py +0 -0
  26. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/rater.py +0 -0
  27. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/records.py +0 -0
  28. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/search.py +0 -0
  29. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/tables.py +0 -0
  30. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/templates/extraction.j2 +0 -0
  31. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/templates/schema_discovery.j2 +0 -0
  32. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction/vocab.py +0 -0
  33. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction.egg-info/SOURCES.txt +0 -0
  34. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction.egg-info/dependency_links.txt +0 -0
  35. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction.egg-info/entry_points.txt +0 -0
  36. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction.egg-info/requires.txt +0 -0
  37. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/src/structured_data_extraction.egg-info/top_level.txt +0 -0
  38. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/tests/test_convert.py +0 -0
  39. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/tests/test_embed.py +0 -0
  40. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/tests/test_features.py +0 -0
  41. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/tests/test_matching.py +0 -0
  42. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/tests/test_rater.py +0 -0
  43. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/tests/test_records.py +0 -0
  44. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/tests/test_search.py +0 -0
  45. {structured_data_extraction-0.1.0 → structured_data_extraction-0.2.1}/tests/test_tables.py +0 -0
@@ -0,0 +1,265 @@
1
+ Metadata-Version: 2.4
2
+ Name: structured-data-extraction
3
+ Version: 0.2.1
4
+ Summary: Pipeline for extracting structured data (tables, sections, key/value fields) from PDF documents using docling, embeddings, and LLMs.
5
+ Author-email: Dimitrios Avellas <dimitrisavellas8@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/dimitrisavellas/structured-extraction.git
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: docling==2.82.0
12
+ Requires-Dist: docling-core==2.70.2
13
+ Requires-Dist: docling-parse==5.6.1
14
+ Requires-Dist: docling-ibm-models==3.13.0
15
+ Requires-Dist: yake
16
+ Requires-Dist: sentence-transformers
17
+ Requires-Dist: numpy
18
+ Requires-Dist: pandas
19
+ Requires-Dist: pydantic
20
+ Requires-Dist: instructor
21
+ Requires-Dist: openai
22
+ Requires-Dist: jinja2
23
+ Requires-Dist: scikit-learn==1.8.0
24
+ Requires-Dist: joblib
25
+ Requires-Dist: pymupdf
26
+ Requires-Dist: einops
27
+ Requires-Dist: rapidfuzz
28
+ Requires-Dist: python-dotenv
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # Structured Data Extraction
34
+
35
+ [![PyPI](https://img.shields.io/pypi/v/structured-data-extraction)](https://pypi.org/project/structured-data-extraction/)
36
+
37
+ Extract user-specified fields from arbitrary PDFs as structured JSON with provenance.
38
+
39
+ The pipeline is **domain-agnostic**: vocabulary, acronyms and field shapes are derived from each document at runtime, so the same code handles a chip datasheet or an earnings report without changes. It runs against any OpenAI-compatible endpoint, from cloud models to small local models on a laptop.
40
+
41
+ ## Contents
42
+
43
+ - [How it works](#how-it-works)
44
+ - [Installation](#installation)
45
+ - [Configuration](#configuration)
46
+ - [Usage](#usage)
47
+ - [Running on local models](#running-on-local-models)
48
+ - [Outputs](#outputs)
49
+ - [Development](#development)
50
+
51
+ ## How it works
52
+
53
+ Given a PDF and a list of keys, the pipeline converts the PDF to text, learns the document's vocabulary, classifies and embeds its sections, retrieves the passages closest to each key, repairs messy tables among them, and asks an LLM to return validated, structured values.
54
+
55
+ | Step | Module | Job |
56
+ |------|--------|-----|
57
+ | Convert | `convert.py` | Docling to markdown + `items.json` (label, page, bbox). |
58
+ | Vocab | `vocab.py` | Per-document vocabulary (YAKE, up to 4-grams); one-word keys are expanded with related terms. |
59
+ | Classify | `classify.py` | Pre-trained RandomForest tags each item as header, list, table, text or title. |
60
+ | Tables | `tables.py` | Scores table complexity; matched complex tables are rebuilt by a vision LLM. |
61
+ | Embed | `embed.py` | Sentence-transformer embeddings of sections and of each table row with its group header. |
62
+ | Search | `search.py` | Cosine similarity, keeping hits above `SIMILARITY_THRESHOLD` (at most `SEARCH_TOP_K`). |
63
+ | Extract | `extract.py` | Schema discovery, a Pydantic model built on the fly, [Instructor](https://python.useinstructor.com/)-validated extraction, dedup. |
64
+ | Rate | `rater.py` | Clusters records across models, attaches a consensus confidence, scores agreement (Cohen's kappa). |
65
+ | Package | `records.py` | Wraps the fused result into one provenance-stamped record per document. |
66
+
67
+ Design choices worth knowing:
68
+
69
+ - **Semantic search.** "CMRR" and "common-mode rejection ratio" share no letters but sit close in embedding space.
70
+ - **Lazy vision.** Only tables that matched a key are sent to the vision model, so cost tracks the request, not the document size.
71
+ - **Constrained output.** The LLM fills a Pydantic model; free text is rejected and retried.
72
+ - **Optional voting.** List several extraction models to get per-record confidence (`models agreeing / total models`).
73
+ - **Caching.** Every step writes to disk and is skipped on re-run, so iterating on a prompt does not re-run docling.
74
+
75
+ Each module's docstring explains its non-obvious design decisions.
76
+
77
+ ## Installation
78
+
79
+ Requires Python 3.11+ and an OpenAI-compatible LLM endpoint (the defaults assume [Ollama](https://ollama.com)). The embedding model is downloaded from Hugging Face on first run.
80
+
81
+ ```bash
82
+ python3 -m venv venv && source venv/bin/activate
83
+ pip install structured-data-extraction
84
+ ```
85
+
86
+ For development, install an editable checkout:
87
+
88
+ ```bash
89
+ git clone https://github.com/dimitrisavellas/structured-extraction.git
90
+ cd structured-extraction
91
+ pip install -e ".[dev]"
92
+ ```
93
+
94
+ ## Configuration
95
+
96
+ Create a `.env` in the directory you run the tool from (parent directories are searched too). Shell environment variables override it.
97
+
98
+ ```bash
99
+ # Provider: any OpenAI-compatible endpoint
100
+ LLM_BASE_URL=http://localhost:11434/v1
101
+ LLM_API_KEY=ollama
102
+
103
+ # Models
104
+ SCHEMA_MODEL=glm-5.1:cloud
105
+ EXTRACTION_MODELS=gemma4:31b-cloud # comma-separated for multi-model voting
106
+ VISION_MODEL=kimi-k2.7-code:cloud # must support images
107
+ EMBEDDING_MODEL=nomic-ai/nomic-embed-text-v1.5 # Hugging Face model ID
108
+
109
+ # Runtime
110
+ EMBED_BATCH_SIZE=4 # raise on bigger GPUs
111
+ MAX_LLM_RETRIES=2 # retries per extraction model
112
+ EXTRACTION_WORKERS=4 # parallel workers across extraction models
113
+ ```
114
+
115
+ Optional settings (defaults shown):
116
+
117
+ | Variable | Default | Purpose |
118
+ |----------|---------|---------|
119
+ | `SIMILARITY_THRESHOLD` | `0.55` | Cosine floor for a section or row to count as a match. |
120
+ | `SEARCH_TOP_K` | `25` | Maximum matches kept per key. |
121
+ | `VISION_COMPLEXITY_THRESHOLD` | `0.15` | Minimum table complexity sent to the vision model. |
122
+ | `VISION_PARALLELISM` | `4` | Parallel vision calls. |
123
+ | `VOCAB_TOP_N` / `VOCAB_MAX_NGRAM` | `50` / `4` | Vocabulary size and longest phrase. |
124
+ | `EXPAND_KEYS` | `true` | Expand keys with related document vocabulary before search. |
125
+ | `REASONING_EFFORT` | unset | Sent as `reasoning_effort` on schema and extraction calls; `none` disables thinking. |
126
+ | `LLM_TIMEOUT` | `600` | Seconds per schema or extraction request. |
127
+ | `EXTRACT_PER_SECTION` | `false` | One extraction call per matched section instead of one for all. |
128
+
129
+ The first model in `EXTRACTION_MODELS` is the primary, used wherever a single model is needed. Every named model must exist on your provider.
130
+
131
+ ## Usage
132
+
133
+ ### Command line
134
+
135
+ ```bash
136
+ structured-extract report.pdf --keys revenue "net income" # one PDF
137
+ structured-extract pdfs/ --keys gain CMRR # a directory
138
+ ```
139
+
140
+ Quote multi-word keys. Without `--keys` you are prompted for them.
141
+
142
+ | Flag | Effect |
143
+ |------|--------|
144
+ | `--schema FILE` | Use your own schema and skip schema discovery (see below). |
145
+ | `--local` | Settings for small local models (see [Running on local models](#running-on-local-models)). |
146
+ | `--levels 1.0 0.75` | Keep only these confidence tiers (`all` keeps everything); multi-model runs prompt otherwise. |
147
+ | `--force` | Recompute every step, ignoring cached files. |
148
+ | `--output DIR` | Output directory (default `output/`). |
149
+ | `--dump-templates DIR` | Write the prompt templates to `DIR` for editing; point `TEMPLATES_DIR` at it. |
150
+
151
+ ### Python
152
+
153
+ ```python
154
+ from structured_data_extraction.main import process_single_pdf, process_batch, finalize_outputs
155
+
156
+ process_single_pdf("report.pdf", ["revenue", "net income"]) # or process_batch("pdfs/", keys)
157
+ finalize_outputs(levels=["all"])
158
+ ```
159
+
160
+ ### Custom schema
161
+
162
+ `--schema` takes a JSON file in the same format as `intermediate/*_schema.json` and skips schema discovery; keys are derived from it. Setting `"fixed"` on a sub-field turns it into a `Literal`, so the model can only emit that value. This is useful for slicing a table down to one variant or column:
163
+
164
+ ```json
165
+ {
166
+ "document_type": "operational amplifier datasheet",
167
+ "schema": {
168
+ "quiescent_current_s_typ": {
169
+ "type": "list",
170
+ "description": "Quiescent current typical value for the AD840S variant",
171
+ "fields": {
172
+ "differentiator": { "type": "string", "description": "Product variant", "fixed": "AD840S" },
173
+ "qualifier": { "type": "string", "description": "Column label: min, typ, or max", "fixed": "Typ" },
174
+ "condition": { "type": "string", "description": "Operating condition" },
175
+ "value": { "type": "number", "description": "Quiescent current value", "unit": "mA" },
176
+ "metadata": { "type": "object", "description": "Source and provenance" }
177
+ }
178
+ }
179
+ }
180
+ }
181
+ ```
182
+
183
+ ### Single stages
184
+
185
+ Each step is also a console script, run from the same working directory:
186
+
187
+ ```bash
188
+ structured-extract-convert --input pdf/example.pdf
189
+ structured-extract-vocab --input markdown/example.md
190
+ structured-extract-classify --stem example
191
+ structured-extract-embed --stem example --keys gain CMRR
192
+ structured-extract-search --stem example --original-keys gain CMRR
193
+ structured-extract-extract --stem example --keys gain CMRR
194
+ structured-extract-rate # fuse models, score agreement
195
+ structured-extract-records # wrap fused.json into records.json
196
+ structured-extract-filter --levels 1.0 0.75 # keep selected confidence tiers
197
+ ```
198
+
199
+ ## Running on local models
200
+
201
+ The pipeline runs fully offline with small models (tested with 9B models on a 16 GB MacBook Air via Ollama). Pass `--local`:
202
+
203
+ ```bash
204
+ structured-extract report.pdf --keys "net income" --local
205
+ ```
206
+
207
+ `--local` applies three settings that small models need:
208
+
209
+ - **One extraction call per section** (`EXTRACT_PER_SECTION`). When several tables repeat the same rows in one prompt, 9B models at temperature 0 tend to loop on them indefinitely.
210
+ - **Thinking off** (`REASONING_EFFORT=none`), unless you set `REASONING_EFFORT` yourself. Thinking at temperature 0 can run away.
211
+ - **30-minute request timeout** (`LLM_TIMEOUT`), unless you set a longer one.
212
+
213
+ Ollama loads models with a small context window by default (4096 tokens on machines with less than about 24 GB of GPU memory) and **silently truncates** longer prompts. The schema-discovery prompt can reach about 17k tokens, so start Ollama with a larger window:
214
+
215
+ ```bash
216
+ OLLAMA_CONTEXT_LENGTH=40960 ollama serve
217
+ ```
218
+
219
+ Passing `--schema` skips discovery. With `--local`, each extraction prompt holds one section (under 7k tokens in testing), so a 16k window is usually enough.
220
+
221
+ Expect lower precision than with large models: 9B models occasionally invent records. Listing two or more extraction models and filtering with `--levels` mitigates this.
222
+
223
+ ## Outputs
224
+
225
+ Steps cache their results in `intermediate/`; deliverables go to `output/`.
226
+
227
+ ```
228
+ output/<stem>_extraction_<model>.json raw records, one file per extraction model
229
+ fused.json every fact, confidence-sorted per key
230
+ records.json fused.json in a provenance envelope, one entry per document
231
+ rating_report.json agreement statistics and pairwise Cohen's kappa
232
+ ```
233
+
234
+ A `records.json` entry pairs provenance with the extracted fields, each a list of records sorted by confidence:
235
+
236
+ ```jsonc
237
+ {
238
+ "0150200051": {
239
+ "type": "ExtractionResult",
240
+ "provenance": {
241
+ "source_file": "0150200051.pdf",
242
+ "document_type": "cable connector product datasheet",
243
+ "models": { "schema_discovery": "glm-5.1:cloud", "vision": "kimi-k2.7-code:cloud",
244
+ "embedding": "nomic-ai/nomic-embed-text-v1.5" },
245
+ "user_keys": ["product family", "Voltage"]
246
+ },
247
+ "voltage": [
248
+ { "confidence": 1.0, "models": ["deepseek-v4-flash:cloud", "gemma4:31b-cloud"],
249
+ "qualifier": "Maximum", "value": 60.0, "value_unit": "V AC",
250
+ "metadata": { "section_type": "text", "specified": true } }
251
+ ]
252
+ }
253
+ }
254
+ ```
255
+
256
+ ## Development
257
+
258
+ ```bash
259
+ pip install -e ".[dev]"
260
+ pytest
261
+ ```
262
+
263
+ ## License
264
+
265
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,233 @@
1
+ # Structured Data Extraction
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/structured-data-extraction)](https://pypi.org/project/structured-data-extraction/)
4
+
5
+ Extract user-specified fields from arbitrary PDFs as structured JSON with provenance.
6
+
7
+ The pipeline is **domain-agnostic**: vocabulary, acronyms and field shapes are derived from each document at runtime, so the same code handles a chip datasheet or an earnings report without changes. It runs against any OpenAI-compatible endpoint, from cloud models to small local models on a laptop.
8
+
9
+ ## Contents
10
+
11
+ - [How it works](#how-it-works)
12
+ - [Installation](#installation)
13
+ - [Configuration](#configuration)
14
+ - [Usage](#usage)
15
+ - [Running on local models](#running-on-local-models)
16
+ - [Outputs](#outputs)
17
+ - [Development](#development)
18
+
19
+ ## How it works
20
+
21
+ Given a PDF and a list of keys, the pipeline converts the PDF to text, learns the document's vocabulary, classifies and embeds its sections, retrieves the passages closest to each key, repairs messy tables among them, and asks an LLM to return validated, structured values.
22
+
23
+ | Step | Module | Job |
24
+ |------|--------|-----|
25
+ | Convert | `convert.py` | Docling to markdown + `items.json` (label, page, bbox). |
26
+ | Vocab | `vocab.py` | Per-document vocabulary (YAKE, up to 4-grams); one-word keys are expanded with related terms. |
27
+ | Classify | `classify.py` | Pre-trained RandomForest tags each item as header, list, table, text or title. |
28
+ | Tables | `tables.py` | Scores table complexity; matched complex tables are rebuilt by a vision LLM. |
29
+ | Embed | `embed.py` | Sentence-transformer embeddings of sections and of each table row with its group header. |
30
+ | Search | `search.py` | Cosine similarity, keeping hits above `SIMILARITY_THRESHOLD` (at most `SEARCH_TOP_K`). |
31
+ | Extract | `extract.py` | Schema discovery, a Pydantic model built on the fly, [Instructor](https://python.useinstructor.com/)-validated extraction, dedup. |
32
+ | Rate | `rater.py` | Clusters records across models, attaches a consensus confidence, scores agreement (Cohen's kappa). |
33
+ | Package | `records.py` | Wraps the fused result into one provenance-stamped record per document. |
34
+
35
+ Design choices worth knowing:
36
+
37
+ - **Semantic search.** "CMRR" and "common-mode rejection ratio" share no letters but sit close in embedding space.
38
+ - **Lazy vision.** Only tables that matched a key are sent to the vision model, so cost tracks the request, not the document size.
39
+ - **Constrained output.** The LLM fills a Pydantic model; free text is rejected and retried.
40
+ - **Optional voting.** List several extraction models to get per-record confidence (`models agreeing / total models`).
41
+ - **Caching.** Every step writes to disk and is skipped on re-run, so iterating on a prompt does not re-run docling.
42
+
43
+ Each module's docstring explains its non-obvious design decisions.
44
+
45
+ ## Installation
46
+
47
+ Requires Python 3.11+ and an OpenAI-compatible LLM endpoint (the defaults assume [Ollama](https://ollama.com)). The embedding model is downloaded from Hugging Face on first run.
48
+
49
+ ```bash
50
+ python3 -m venv venv && source venv/bin/activate
51
+ pip install structured-data-extraction
52
+ ```
53
+
54
+ For development, install an editable checkout:
55
+
56
+ ```bash
57
+ git clone https://github.com/dimitrisavellas/structured-extraction.git
58
+ cd structured-extraction
59
+ pip install -e ".[dev]"
60
+ ```
61
+
62
+ ## Configuration
63
+
64
+ Create a `.env` in the directory you run the tool from (parent directories are searched too). Shell environment variables override it.
65
+
66
+ ```bash
67
+ # Provider: any OpenAI-compatible endpoint
68
+ LLM_BASE_URL=http://localhost:11434/v1
69
+ LLM_API_KEY=ollama
70
+
71
+ # Models
72
+ SCHEMA_MODEL=glm-5.1:cloud
73
+ EXTRACTION_MODELS=gemma4:31b-cloud # comma-separated for multi-model voting
74
+ VISION_MODEL=kimi-k2.7-code:cloud # must support images
75
+ EMBEDDING_MODEL=nomic-ai/nomic-embed-text-v1.5 # Hugging Face model ID
76
+
77
+ # Runtime
78
+ EMBED_BATCH_SIZE=4 # raise on bigger GPUs
79
+ MAX_LLM_RETRIES=2 # retries per extraction model
80
+ EXTRACTION_WORKERS=4 # parallel workers across extraction models
81
+ ```
82
+
83
+ Optional settings (defaults shown):
84
+
85
+ | Variable | Default | Purpose |
86
+ |----------|---------|---------|
87
+ | `SIMILARITY_THRESHOLD` | `0.55` | Cosine floor for a section or row to count as a match. |
88
+ | `SEARCH_TOP_K` | `25` | Maximum matches kept per key. |
89
+ | `VISION_COMPLEXITY_THRESHOLD` | `0.15` | Minimum table complexity sent to the vision model. |
90
+ | `VISION_PARALLELISM` | `4` | Parallel vision calls. |
91
+ | `VOCAB_TOP_N` / `VOCAB_MAX_NGRAM` | `50` / `4` | Vocabulary size and longest phrase. |
92
+ | `EXPAND_KEYS` | `true` | Expand keys with related document vocabulary before search. |
93
+ | `REASONING_EFFORT` | unset | Sent as `reasoning_effort` on schema and extraction calls; `none` disables thinking. |
94
+ | `LLM_TIMEOUT` | `600` | Seconds per schema or extraction request. |
95
+ | `EXTRACT_PER_SECTION` | `false` | One extraction call per matched section instead of one for all. |
96
+
97
+ The first model in `EXTRACTION_MODELS` is the primary, used wherever a single model is needed. Every named model must exist on your provider.
98
+
99
+ ## Usage
100
+
101
+ ### Command line
102
+
103
+ ```bash
104
+ structured-extract report.pdf --keys revenue "net income" # one PDF
105
+ structured-extract pdfs/ --keys gain CMRR # a directory
106
+ ```
107
+
108
+ Quote multi-word keys. Without `--keys` you are prompted for them.
109
+
110
+ | Flag | Effect |
111
+ |------|--------|
112
+ | `--schema FILE` | Use your own schema and skip schema discovery (see below). |
113
+ | `--local` | Settings for small local models (see [Running on local models](#running-on-local-models)). |
114
+ | `--levels 1.0 0.75` | Keep only these confidence tiers (`all` keeps everything); multi-model runs prompt otherwise. |
115
+ | `--force` | Recompute every step, ignoring cached files. |
116
+ | `--output DIR` | Output directory (default `output/`). |
117
+ | `--dump-templates DIR` | Write the prompt templates to `DIR` for editing; point `TEMPLATES_DIR` at it. |
118
+
119
+ ### Python
120
+
121
+ ```python
122
+ from structured_data_extraction.main import process_single_pdf, process_batch, finalize_outputs
123
+
124
+ process_single_pdf("report.pdf", ["revenue", "net income"]) # or process_batch("pdfs/", keys)
125
+ finalize_outputs(levels=["all"])
126
+ ```
127
+
128
+ ### Custom schema
129
+
130
+ `--schema` takes a JSON file in the same format as `intermediate/*_schema.json` and skips schema discovery; keys are derived from it. Setting `"fixed"` on a sub-field turns it into a `Literal`, so the model can only emit that value. This is useful for slicing a table down to one variant or column:
131
+
132
+ ```json
133
+ {
134
+ "document_type": "operational amplifier datasheet",
135
+ "schema": {
136
+ "quiescent_current_s_typ": {
137
+ "type": "list",
138
+ "description": "Quiescent current typical value for the AD840S variant",
139
+ "fields": {
140
+ "differentiator": { "type": "string", "description": "Product variant", "fixed": "AD840S" },
141
+ "qualifier": { "type": "string", "description": "Column label: min, typ, or max", "fixed": "Typ" },
142
+ "condition": { "type": "string", "description": "Operating condition" },
143
+ "value": { "type": "number", "description": "Quiescent current value", "unit": "mA" },
144
+ "metadata": { "type": "object", "description": "Source and provenance" }
145
+ }
146
+ }
147
+ }
148
+ }
149
+ ```
150
+
151
+ ### Single stages
152
+
153
+ Each step is also a console script, run from the same working directory:
154
+
155
+ ```bash
156
+ structured-extract-convert --input pdf/example.pdf
157
+ structured-extract-vocab --input markdown/example.md
158
+ structured-extract-classify --stem example
159
+ structured-extract-embed --stem example --keys gain CMRR
160
+ structured-extract-search --stem example --original-keys gain CMRR
161
+ structured-extract-extract --stem example --keys gain CMRR
162
+ structured-extract-rate # fuse models, score agreement
163
+ structured-extract-records # wrap fused.json into records.json
164
+ structured-extract-filter --levels 1.0 0.75 # keep selected confidence tiers
165
+ ```
166
+
167
+ ## Running on local models
168
+
169
+ The pipeline runs fully offline with small models (tested with 9B models on a 16 GB MacBook Air via Ollama). Pass `--local`:
170
+
171
+ ```bash
172
+ structured-extract report.pdf --keys "net income" --local
173
+ ```
174
+
175
+ `--local` applies three settings that small models need:
176
+
177
+ - **One extraction call per section** (`EXTRACT_PER_SECTION`). When several tables repeat the same rows in one prompt, 9B models at temperature 0 tend to loop on them indefinitely.
178
+ - **Thinking off** (`REASONING_EFFORT=none`), unless you set `REASONING_EFFORT` yourself. Thinking at temperature 0 can run away.
179
+ - **30-minute request timeout** (`LLM_TIMEOUT`), unless you set a longer one.
180
+
181
+ Ollama loads models with a small context window by default (4096 tokens on machines with less than about 24 GB of GPU memory) and **silently truncates** longer prompts. The schema-discovery prompt can reach about 17k tokens, so start Ollama with a larger window:
182
+
183
+ ```bash
184
+ OLLAMA_CONTEXT_LENGTH=40960 ollama serve
185
+ ```
186
+
187
+ Passing `--schema` skips discovery. With `--local`, each extraction prompt holds one section (under 7k tokens in testing), so a 16k window is usually enough.
188
+
189
+ Expect lower precision than with large models: 9B models occasionally invent records. Listing two or more extraction models and filtering with `--levels` mitigates this.
190
+
191
+ ## Outputs
192
+
193
+ Steps cache their results in `intermediate/`; deliverables go to `output/`.
194
+
195
+ ```
196
+ output/<stem>_extraction_<model>.json raw records, one file per extraction model
197
+ fused.json every fact, confidence-sorted per key
198
+ records.json fused.json in a provenance envelope, one entry per document
199
+ rating_report.json agreement statistics and pairwise Cohen's kappa
200
+ ```
201
+
202
+ A `records.json` entry pairs provenance with the extracted fields, each a list of records sorted by confidence:
203
+
204
+ ```jsonc
205
+ {
206
+ "0150200051": {
207
+ "type": "ExtractionResult",
208
+ "provenance": {
209
+ "source_file": "0150200051.pdf",
210
+ "document_type": "cable connector product datasheet",
211
+ "models": { "schema_discovery": "glm-5.1:cloud", "vision": "kimi-k2.7-code:cloud",
212
+ "embedding": "nomic-ai/nomic-embed-text-v1.5" },
213
+ "user_keys": ["product family", "Voltage"]
214
+ },
215
+ "voltage": [
216
+ { "confidence": 1.0, "models": ["deepseek-v4-flash:cloud", "gemma4:31b-cloud"],
217
+ "qualifier": "Maximum", "value": 60.0, "value_unit": "V AC",
218
+ "metadata": { "section_type": "text", "specified": true } }
219
+ ]
220
+ }
221
+ }
222
+ ```
223
+
224
+ ## Development
225
+
226
+ ```bash
227
+ pip install -e ".[dev]"
228
+ pytest
229
+ ```
230
+
231
+ ## License
232
+
233
+ MIT, see [LICENSE](LICENSE).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "structured-data-extraction"
7
- version = "0.1.0"
7
+ version = "0.2.1"
8
8
  description = "Pipeline for extracting structured data (tables, sections, key/value fields) from PDF documents using docling, embeddings, and LLMs."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -51,7 +51,7 @@ structured-extract-records = "structured_data_extraction.records:main"
51
51
  structured-extract-filter = "structured_data_extraction.filter_records:main"
52
52
 
53
53
  [project.urls]
54
- Repository = "https://gitlab.indiscale.com/caosdb/src/structured-data-extraction"
54
+ Repository = "https://github.com/dimitrisavellas/structured-extraction.git"
55
55
 
56
56
  [tool.setuptools.packages.find]
57
57
  where = ["src"]
@@ -42,6 +42,14 @@ EMBEDDING_DEVICE = os.environ.get("EMBEDDING_DEVICE") or _auto_device()
42
42
  EXTRACTION_MODELS = [m.strip() for m in os.environ["EXTRACTION_MODELS"].split(",") if m.strip()]
43
43
  assert EXTRACTION_MODELS, "EXTRACTION_MODELS in .env must list at least one model"
44
44
 
45
+ # Optional thinking control for schema/extraction calls (e.g. "none" stops local thinking models
46
+ # from looping). Unset sends nothing, so providers that reject the field are unaffected.
47
+ LLM_EXTRA = {"reasoning_effort": v} if (v := os.environ.get("REASONING_EFFORT")) else {}
48
+
49
+ # Seconds per LLM request; 600 is the openai client default. Raise it for slow local models
50
+ # whose full extraction (tens of records) takes longer than 10 minutes to generate.
51
+ LLM_TIMEOUT = float(os.environ.get("LLM_TIMEOUT", "600"))
52
+
45
53
  # When True (set by main.py --force), every cached pipeline step recomputes instead of skipping.
46
54
  FORCE = False
47
55
 
@@ -60,3 +68,6 @@ SEARCH_TOP_K = int(os.environ.get("SEARCH_TOP_K", 25))
60
68
  VISION_COMPLEXITY_THRESHOLD = float(os.environ.get("VISION_COMPLEXITY_THRESHOLD", 0.15))
61
69
  VISION_PARALLELISM = int(os.environ.get("VISION_PARALLELISM", 4))
62
70
  EXPAND_KEYS = _env_bool("EXPAND_KEYS", True)
71
+ # One extraction call per matched section instead of one call for all of them. Slower, but
72
+ # stops small local models from looping on rows repeated across tables.
73
+ EXTRACT_PER_SECTION = _env_bool("EXTRACT_PER_SECTION", False)
@@ -23,13 +23,13 @@ import os
23
23
  from concurrent.futures import ThreadPoolExecutor, as_completed
24
24
  from datetime import datetime, timezone
25
25
  from pathlib import Path
26
- from typing import Any, List, Optional
26
+ from typing import Any, List, Literal, Optional
27
27
  import instructor
28
28
  from jinja2 import Environment, ChoiceLoader, FileSystemLoader, PackageLoader
29
29
  from openai import OpenAI
30
30
  from pydantic import BaseModel, Field, create_model
31
31
  from . import config, matching
32
- from .paths import inter, read_json, cached
32
+ from .paths import inter, read_json, write_json, cached
33
33
  from .tables import strip_code_fence
34
34
 
35
35
  logger = logging.getLogger(__name__)
@@ -62,7 +62,8 @@ _raw = None
62
62
  def _raw_client():
63
63
  global _raw
64
64
  if _raw is None:
65
- _raw = OpenAI(base_url=os.environ["LLM_BASE_URL"], api_key=os.environ["LLM_API_KEY"])
65
+ _raw = OpenAI(base_url=os.environ["LLM_BASE_URL"], api_key=os.environ["LLM_API_KEY"],
66
+ timeout=config.LLM_TIMEOUT)
66
67
  return _raw
67
68
 
68
69
 
@@ -110,10 +111,15 @@ def _build_extraction_model(schema, user_keys):
110
111
  for sub_name, sub_def in fields_spec.items():
111
112
  if sub_name == "metadata":
112
113
  continue
113
- field_type = type_map.get(sub_def.get("type", "string"), str)
114
114
  sub_desc = sub_def.get("description", "")
115
- default = "" if field_type is str else None
116
- sub_fields[sub_name] = (field_type, Field(default=default, description=sub_desc))
115
+ fixed = sub_def.get("fixed")
116
+ if fixed is not None:
117
+ field_type = Literal[fixed]
118
+ sub_fields[sub_name] = (field_type, Field(default=fixed, description=sub_desc))
119
+ else:
120
+ field_type = type_map.get(sub_def.get("type", "string"), str)
121
+ default = "" if field_type is str else None
122
+ sub_fields[sub_name] = (field_type, Field(default=default, description=sub_desc))
117
123
  if sub_def.get("unit") and not has_unit_field:
118
124
  sub_fields[f"{sub_name}_unit"] = (str, Field(default="", description=f"Unit for {sub_name}"))
119
125
 
@@ -190,6 +196,7 @@ def discover_schema(markdown_text, matched_sections, vocab, user_keys):
190
196
  model=os.environ["SCHEMA_MODEL"],
191
197
  messages=[{"role": "user", "content": prompt}],
192
198
  temperature=0,
199
+ **config.LLM_EXTRA,
193
200
  )
194
201
  raw = strip_code_fence(resp.choices[0].message.content.strip())
195
202
 
@@ -208,7 +215,23 @@ def discover_schema(markdown_text, matched_sections, vocab, user_keys):
208
215
  def extract_values(client, matched_sections, vocab, user_keys, schema,
209
216
  document_type="unknown", source_file="", model=None):
210
217
  """Run one extraction LLM through Instructor and return {field: [record dicts]}.
211
- Retries up to MAX_LLM_RETRIES; returns {} if every attempt fails."""
218
+ Retries up to MAX_LLM_RETRIES; returns {} if every attempt fails.
219
+ With EXTRACT_PER_SECTION, makes one call per section and concatenates the records."""
220
+ if config.EXTRACT_PER_SECTION and len(matched_sections) > 1:
221
+ # small local models loop when several tables repeat the same rows in one prompt
222
+ merged, failed = {}, 0
223
+ for section in matched_sections:
224
+ part = extract_values(client, [section], vocab, user_keys, schema,
225
+ document_type=document_type, source_file=source_file,
226
+ model=model)
227
+ failed += not part
228
+ for field, records in part.items():
229
+ merged.setdefault(field, []).extend(records)
230
+ if failed:
231
+ # the merged result is still cached, so say which run is incomplete
232
+ logger.warning(f"[{model}] {failed}/{len(matched_sections)} sections failed extraction")
233
+ return merged
234
+
212
235
  model = model or config.EXTRACTION_MODELS[0]
213
236
  template = jinja_env.get_template("extraction.j2")
214
237
 
@@ -237,6 +260,7 @@ def extract_values(client, matched_sections, vocab, user_keys, schema,
237
260
  messages=[{"role": "user", "content": prompt}],
238
261
  temperature=0.0,
239
262
  response_model=extraction_model,
263
+ **config.LLM_EXTRA,
240
264
  )
241
265
  output = {}
242
266
  for field in extraction_model.model_fields:
@@ -368,7 +392,7 @@ def _run_one_model(model_name, stem, out_dir, unique, vocab, user_keys, schema,
368
392
  return extraction
369
393
 
370
394
 
371
- def extract_parallel(stem, user_keys):
395
+ def extract_parallel(stem, user_keys, user_schema=None):
372
396
  """Run every configured extraction model over one document in parallel.
373
397
 
374
398
  Stamps run provenance (run.json), discovers the schema once for all models, then fans
@@ -387,11 +411,15 @@ def extract_parallel(stem, user_keys):
387
411
 
388
412
  md_text, vocab, unique = _prepare_sections(stem)
389
413
 
390
- # schema discovery once for all models — cached to intermediate/<stem>_schema.json
391
- disc = cached(inter(stem, "schema.json"),
392
- lambda: dict(zip(("document_type", "schema"),
393
- discover_schema(md_text, unique, vocab, user_keys))))
394
- doc_type, schema = disc["document_type"], disc["schema"]
414
+ if user_schema is not None:
415
+ doc_type, schema = user_schema["document_type"], user_schema["schema"]
416
+ write_json(inter(stem, "schema.json"), user_schema)
417
+ logger.info("Using user-supplied schema (skipped schema discovery)")
418
+ else:
419
+ disc = cached(inter(stem, "schema.json"),
420
+ lambda: dict(zip(("document_type", "schema"),
421
+ discover_schema(md_text, unique, vocab, user_keys))))
422
+ doc_type, schema = disc["document_type"], disc["schema"]
395
423
  logger.info(f"Schema: document_type='{doc_type}'")
396
424
 
397
425
  results = []