Skip to content

Commit 2705176

Browse files
nickwinderclaude
andauthored
feat: add client.parse() for the Data Extraction API (/extraction/parse) (#47)
* feat: add client.parse() for the Data Extraction API (/extraction/parse) Adds first-class support for the Data Extraction API on NutrientClient. Covers all four processing modes (text, structure, understand, agentic) and both output shapes (spatial elements and whole-document Markdown). The response surface is a fully typed ParseResponse TypedDict with a discriminated union of element variants (paragraph, table, formula, picture, keyValueRegion, handwriting) so callers can narrow on `type`. The Data Extraction API is billed against extraction credits, which are a separate billing bucket from the processor API credits consumed by the other endpoints used by this client (Build, sign, OCR, watermarking, etc.). Docstrings, README, and changelog make that distinction explicit so callers do not conflate the two buckets. Verification: - 16 new unit tests in tests/unit/test_parse.py (request shape per mode, response parsing, error propagation for 401 / 400 / 402 / 500). - mypy strict and ruff clean on src/. Endpoint surface (httpx-multipart): POST /extraction/parse with a 'file' part and an optional 'instructions' part carrying the JSON {mode, output:{format}} body. Extends the existing send_request infra (RequestConfig + TypeGuard + overload) without churn to existing endpoint paths. * refactor(types): extract ExtractionCredits to dedicated module The extraction-credits accounting shape (cost + remainingCredits) will surface on every future endpoint billed against the extraction-credits bucket, not just /extraction/parse. Factor it out of types/parse.py into its own module so other endpoints can import it without pulling in the whole parse type tree. Also clarify ParseBounds: document that (x, y) is the top-left corner and that bounds share a coordinate space with the page dimensions in ParsePageRef. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(client): align parse() style with the rest of the file Three small style nits surfaced in code review against the patterns set by sign() and the other raw-send_request methods (get_account_info, create_token, delete_token): - Drop the redundant inner cast("ParseOutput", {"format": output_format}). ParseOutput is a single-key TypedDict with total=False; the literal already satisfies it structurally via the surrounding ParseInstructions annotation. No other call site in client.py casts an inner literal this way. - Replace the RequestConfig(...) constructor call with an inline dict literal at the send_request boundary, matching sign / create_token / delete_token / get_account_info. RequestConfig is a generic TypedDict; the constructor form is the outlier. - Broaden the file parameter docstring to call out that the endpoint accepts PDFs, Office documents, and images. Unlike sign(), parsing is not PDF-only, and the previous docstring implicitly invited readers to transplant sign()'s PDF-only mental model. No behavior change. format) combinations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: explain what client.parse() is good for The README's Data Extraction section previously described WHAT parse() does (modes, output formats, billing) without explaining WHY a user would reach for it over the existing extract_* helpers. Rework so the positioning leads: - New "designed for" bullets up top — RAG ingestion, search indexing, content migration, form/invoice extraction, layout-aware document understanding. - New output-format selector table mapping each format to its primary use case (markdown → RAG/search; spatial → form/layout). - Modes table reworded so each row says when to pick it, not just what it technically does (text = born-digital only; structure = OCR for scanned input; understand = AI-augmented for complex layouts; agentic = + VLM for image-heavy content). - Two worked recipes: RAG ingestion (PDF → markdown → embed) and form extraction (PDF → spatial elements → structured dict). Also adds a parse() entry to docs/METHODS.md (it was missing entirely) and a "Designed for" preamble to the parse() docstring so the method's positioning is visible in IDE hover popups. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(client): route parse() via DWS Extract key and reject text+spatial DWS Extract is a separate product from DWS Processor with its own API key and credit pool. Calling /extraction/parse with the Processor key returns 403. Add an optional extract_api_key constructor parameter (str or async callable) that parse() prefers over api_key when set; non-parse methods keep using api_key. Falling back to api_key keeps a single-key setup working once tenants get global DWS keys. Also reject mode='text' + output_format='spatial' before the request goes out — the text mode only produces markdown, so the combination would 502 on the server side. Surface it as a ValidationError with guidance. Addresses PR #47 review feedback from HungKNguyen. * fix(types): align ParsePageRef TypedDict shape with its docstring The docstring promises pageIndex/width/height are always populated and only pageNumber may be absent, but the class was declared `total=False`, which contradicts that and forces type-strict callers to guard every subscript access on guaranteed-present fields. Switch to the default (`total=True`) shape with pageNumber explicitly `NotRequired`, matching the precedent set by ParseBounds in the same module. No runtime impact — the wire already populates these fields. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7457a52 commit 2705176

11 files changed

Lines changed: 1311 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `client.parse()` — first-class support for the Data Extraction API
13+
(`/extraction/parse`). Supports all four processing modes (`text`,
14+
`structure`, `understand`, `agentic`) and both output shapes (spatial
15+
elements and whole-document Markdown). Typed response model with
16+
discriminated element variants (paragraph, table, formula, picture,
17+
keyValueRegion, handwriting). Billed against **extraction credits**, a
18+
separate billing bucket from the **processor API credits** used by the
19+
other endpoints.
20+
- New types exported from `nutrient_dws`: `ParseResponse`,
21+
`ParseInstructions`, `ParseMode`, `ParseOutputFormat`, `ParseElement`,
22+
`ParseOutputBody`, `ParseOutputElements`, `ParseOutputMarkdown`,
23+
`ParagraphElement`, `TableElement`, `TableCell`, `FormulaElement`,
24+
`PictureElement`, `KeyValueRegionElement`, `KeyValuePair`,
25+
`HandwritingElement`.
26+
1027
## [3.0.0] - 2026-01-30
1128

1229
### Security

README.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,133 @@ asyncio.run(main())
8888

8989
For a complete list of available methods with examples, see the [Methods Documentation](docs/METHODS.md).
9090

91+
## Data Extraction (`/extraction/parse`)
92+
93+
`client.parse()` exposes Nutrient's Data Extraction API. It's designed for
94+
**content-extraction workflows** where you need to feed document content into a
95+
downstream pipeline rather than render or transform the document itself:
96+
97+
> **Heads up — separate API key.** DWS Extract is a different product from
98+
> DWS Processor and has its own API key. Pass it as
99+
> `NutrientClient(api_key=..., extract_api_key=...)`; the Extract key is
100+
> used only for `parse()`, while every other method continues to use the
101+
> Processor key. Using the Processor key against `/extraction/parse`
102+
> returns `403`. If `extract_api_key` is omitted, `parse()` falls back to
103+
> the main `api_key` — that path works once your tenant moves to global
104+
> DWS API keys.
105+
106+
- **RAG (retrieval-augmented generation) pipelines** — pull a clean Markdown
107+
representation of a document for chunking, embedding, and indexing in a
108+
vector store.
109+
- **Search indexing and content migration** — convert documents into Markdown
110+
for full-text search or for migration into a new content management system.
111+
- **Form and invoice extraction** — pull structured fields (key/value pairs,
112+
tables, semantic regions) out of business documents with bounding boxes and
113+
confidence scores attached to every element.
114+
- **Layout-aware document understanding** — get a typed, page-anchored element
115+
list (paragraphs with semantic roles, tables with cell spans, formulas in
116+
LaTeX, pictures, handwriting) suitable for building document-comprehension
117+
tooling, including agentic workflows.
118+
119+
### Choosing an output format
120+
121+
| Format | Best for | Shape |
122+
|-------------------|----------------------------------------------------------------------------|----------------------------------------------------------------------|
123+
| `markdown` | RAG, search indexing, content migration — anywhere structured text beats spatial data | One whole-document Markdown string at `response['output']['markdown']` |
124+
| `spatial` (default) | Form/invoice extraction, layout reconstruction, flows that need per-element confidence | Flat list of typed elements at `response['output']['elements']` |
125+
126+
Spatial output requires an OCR-capable mode (`structure`, `understand`, or
127+
`agentic`); `mode='text'` is markdown-only and the client rejects the
128+
`text` + `spatial` combination before the request goes out.
129+
130+
### Quick start
131+
132+
```python
133+
import asyncio
134+
from nutrient_dws import NutrientClient
135+
136+
async def main():
137+
client = NutrientClient(
138+
api_key='your_processor_key',
139+
extract_api_key='your_extract_key',
140+
)
141+
142+
# Spatial elements (default) — paragraphs, tables, formulas, pictures, etc.
143+
response = await client.parse('contract.pdf', mode='understand')
144+
for element in response['output']['elements']:
145+
if element['type'] == 'table':
146+
print(element['rowCount'], element['columnCount'])
147+
148+
# Whole-document Markdown from a born-digital PDF
149+
response = await client.parse(
150+
'report.pdf', mode='text', output_format='markdown',
151+
)
152+
print(response['output']['markdown'])
153+
154+
asyncio.run(main())
155+
```
156+
157+
### Modes — when to use which
158+
159+
| Mode | Credits / page | When to use |
160+
|--------------|----------------|----------------------------------------------------------------------------------------------|
161+
| `text` | 1 | Born-digital documents only. No OCR, no AI. Fastest and cheapest path to Markdown. |
162+
| `structure` | 1.5 | OCR-based segmentation with bounding boxes. Handles scanned documents, images, and any input requiring OCR. |
163+
| `understand` | 9 | Full pipeline with AI augmentation on top of OCR. Most accurate for documents with tables, multi-column layouts, formulas, and form fields. |
164+
| `agentic` | 18 | Builds on `understand` and adds a vision-language model. Best for image descriptions, complex visual layouts, and deeper semantic understanding. |
165+
166+
### Recipes
167+
168+
**RAG ingestion** — PDF → Markdown → chunks → embeddings → vector store:
169+
170+
```python
171+
response = await client.parse('whitepaper.pdf', mode='text', output_format='markdown')
172+
markdown = response['output']['markdown']
173+
# Then: chunk on headings, embed, push to your vector store of choice.
174+
```
175+
176+
For born-digital PDFs, `mode='text'` is the cheapest path (1 credit/page).
177+
For scanned PDFs or images, switch to `mode='structure'` so OCR runs.
178+
179+
**Form/invoice extraction** — PDF → spatial elements → structured dict:
180+
181+
```python
182+
response = await client.parse('invoice.pdf', mode='understand')
183+
elements = response['output']['elements']
184+
185+
# Pull key/value pairs from form regions
186+
fields = {}
187+
for element in elements:
188+
if element['type'] == 'keyValueRegion':
189+
for pair in element['pairs']:
190+
fields[pair['key']['value']] = pair['value']['value']
191+
192+
# Walk tables — each cell carries row/col indices and span counts
193+
for element in elements:
194+
if element['type'] == 'table':
195+
print(f"Table: {element['rowCount']}×{element['columnCount']}")
196+
for cell in element['cells']:
197+
print(f" [{cell['row']}][{cell['column']}] {cell['text']}")
198+
```
199+
200+
For complex layouts that mix dense images with text, step up to
201+
`mode='agentic'` so the VLM can produce image descriptions and semantic
202+
classifications (18 credits/page).
203+
204+
### Billing — extraction credits vs processor credits
205+
206+
The Data Extraction API is billed against **extraction credits**, which are a
207+
separate billing bucket from the **processor API credits** consumed by
208+
`/build`, `/sign`, OCR, and the other Processor API endpoints used by this
209+
client (`convert`, `watermark_text`, `merge`, etc.). The response surfaces the
210+
extraction-credit accounting under `response['usage']['data_extraction_credits']`:
211+
212+
```python
213+
usage = response['usage']['data_extraction_credits']
214+
print(f"Cost: {usage['cost']} extraction credits, "
215+
f"remaining: {usage['remainingCredits']}")
216+
```
217+
91218
## Workflow System
92219

93220
The client also provides a fluent builder pattern with staged interfaces to create document processing workflows:

docs/METHODS.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,52 @@ if kvps and len(kvps) > 0:
449449
print(f'Total Amount: {dictionary.get("Total")}')
450450
```
451451

452+
##### parse(file, mode?, output_format?)
453+
Calls the Data Extraction API (`/extraction/parse`) to extract structured
454+
content from a document. Designed for **RAG ingestion**, **search indexing**,
455+
**content migration**, and **form/invoice extraction** workflows where the
456+
goal is to feed document content into a downstream pipeline rather than
457+
render or transform the document itself.
458+
459+
Billed against **extraction credits** — a separate billing bucket from the
460+
processor API credits consumed by every other method on this client. See the
461+
[README's Data Extraction section](../README.md#data-extraction-extractionparse)
462+
for the full positioning, the per-mode comparison, and worked recipes.
463+
464+
**Parameters**:
465+
- `file: LocalFileInput` - The document to parse. The endpoint accepts PDFs,
466+
Office documents, and images. Only local inputs (paths, bytes, file-like
467+
objects) are supported — URLs are not, because the underlying API surface is
468+
multipart-only.
469+
- `mode: ParseMode` - `"text"` (1 credit/page, born-digital only, no OCR/AI),
470+
`"structure"` (1.5 credits/page, OCR + spatial layout — default),
471+
`"understand"` (9 credits/page, AI-augmented), or `"agentic"` (18 credits/page,
472+
adds a vision-language model).
473+
- `output_format: ParseOutputFormat` - `"spatial"` (default — typed elements
474+
with bounds and confidence at `response['output']['elements']`) or
475+
`"markdown"` (whole-document Markdown string at `response['output']['markdown']`).
476+
477+
**Returns**: `ParseResponse` - The full response envelope, including `output`,
478+
`metrics`, `configuration`, and `usage['data_extraction_credits']` (cost and
479+
remaining balance in the extraction-credits bucket).
480+
481+
```python
482+
# RAG ingestion — born-digital PDF to Markdown, cheap and fast.
483+
response = await client.parse('whitepaper.pdf', mode='text', output_format='markdown')
484+
markdown = response['output']['markdown']
485+
486+
# Form extraction — typed spatial elements with bounds and confidence.
487+
response = await client.parse('invoice.pdf', mode='understand')
488+
for element in response['output']['elements']:
489+
if element['type'] == 'keyValueRegion':
490+
for pair in element['pairs']:
491+
print(pair['key']['value'], '', pair['value']['value'])
492+
493+
# Inspect billing — cost is in extraction credits, not processor credits.
494+
usage = response['usage']['data_extraction_credits']
495+
print(f"Cost: {usage['cost']} extraction credits, remaining: {usage['remainingCredits']}")
496+
```
497+
452498
##### flatten(file, annotation_ids?)
453499
Flattens annotations in a PDF document.
454500

src/nutrient_dws/__init__.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,52 @@
1919
process_file_input,
2020
validate_file_input,
2121
)
22+
from nutrient_dws.types.extraction_credits import ExtractionCredits
23+
from nutrient_dws.types.parse import (
24+
FormulaElement,
25+
HandwritingElement,
26+
KeyValuePair,
27+
KeyValueRegionElement,
28+
ParagraphElement,
29+
ParseElement,
30+
ParseInstructions,
31+
ParseMode,
32+
ParseOutputBody,
33+
ParseOutputElements,
34+
ParseOutputFormat,
35+
ParseOutputMarkdown,
36+
ParseResponse,
37+
PictureElement,
38+
TableCell,
39+
TableElement,
40+
)
2241
from nutrient_dws.utils import get_library_version, get_user_agent
2342

2443
__all__ = [
2544
"APIError",
2645
"AuthenticationError",
46+
"ExtractionCredits",
2747
"FileInput",
48+
"FormulaElement",
49+
"HandwritingElement",
50+
"KeyValuePair",
51+
"KeyValueRegionElement",
2852
"LocalFileInput",
2953
"NetworkError",
3054
"NutrientClient",
3155
"NutrientError",
56+
"ParagraphElement",
57+
"ParseElement",
58+
"ParseInstructions",
59+
"ParseMode",
60+
"ParseOutputBody",
61+
"ParseOutputElements",
62+
"ParseOutputFormat",
63+
"ParseOutputMarkdown",
64+
"ParseResponse",
65+
"PictureElement",
66+
"TableCell",
67+
"TableElement",
3268
"UrlFileInput",
3369
"ValidationError",
3470
"get_library_version",

0 commit comments

Comments
 (0)