Skip to content

Commit 14e77a8

Browse files
committed
fix(extract): address code review — markdown-to-file, spatial response guard, leaner write
- Honor outputPath for markdown output (large docs would overflow context inline) - Reject 2xx responses lacking output.elements before writing, so a non-extraction body can't overwrite the target file - Write the raw response body for spatial output instead of re-stringifying (drops a copy of large payloads, preserves all API fields) - Extract writeToResolvedPath helper (de-dupes mkdir-p), drop redundant includeWords coalesce and the resolvedOutputPath casts - Add tests for markdown-to-file and the non-spatial-body guard 🔮 View transcript: https://nutrient-agentlogs.dev/s/duk4x9tr3rmlnxta7c4bwk6o
1 parent 08c217a commit 14e77a8

4 files changed

Lines changed: 71 additions & 15 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ The `data_extractor` and `query_extraction` tools wrap the standalone [DWS Data
217217
| `agentic` | Spatial or Markdown | Yes (VLM) | 18 credits |
218218

219219
- **Spatial** output returns typed elements (paragraphs, tables, key-value regions, formulas, pictures, handwriting) with bounding boxes, confidence scores, and reading order. Because the element list can be large, it is written to `outputPath` and the tool returns a content-free summary (element counts, low-confidence flags, page geometry).
220-
- **Markdown** output returns whole-document Markdown inline — useful for RAG and search indexing.
220+
- **Markdown** output returns whole-document Markdown inline, or writes it to `outputPath` when provided (recommended for large documents) — useful for RAG and search indexing.
221221

222222
Use `query_extraction` to pull just the elements you need from a saved spatial file — filter by `pages`, `region` (bounding box), `minConfidence`, or `elementTypes` — so coordinates and values enter the conversation only when you ask for them.
223223

src/dws/extract.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,17 @@ function summarizeSpatial(response: ExtractionResponse, outputPath: string, byte
7272
].join('\n')
7373
}
7474

75+
/** Writes `data` to `resolvedPath`, creating parent directories as needed. */
76+
async function writeToResolvedPath(resolvedPath: string, data: string): Promise<void> {
77+
const outputDir = path.dirname(resolvedPath)
78+
try {
79+
await fs.promises.access(outputDir)
80+
} catch {
81+
await fs.promises.mkdir(outputDir, { recursive: true })
82+
}
83+
await fs.promises.writeFile(resolvedPath, data)
84+
}
85+
7586
/**
7687
* Calls the Nutrient DWS Data Extraction API (`POST /extraction/parse`).
7788
*
@@ -105,9 +116,10 @@ export async function performExtractCall(
105116
)
106117
}
107118

108-
// Resolve the output path first (fail early on a sandbox escape, before any API call).
119+
// Resolve any provided output path first (fail early on a sandbox escape,
120+
// before the API call). Required for spatial, optional for markdown.
109121
let resolvedOutputPath: string | undefined
110-
if (format === 'spatial' && outputPath) {
122+
if (outputPath) {
111123
try {
112124
resolvedOutputPath = await resolveWriteFilePath(outputPath)
113125
} catch (error) {
@@ -129,7 +141,7 @@ export async function performExtractCall(
129141

130142
const instructions: Record<string, unknown> = {
131143
mode,
132-
output: format === 'spatial' ? { format, includeWords: includeWords ?? false } : { format },
144+
output: format === 'spatial' ? { format, includeWords } : { format },
133145
}
134146
if (language && mode !== 'text') {
135147
instructions.options = { language }
@@ -155,20 +167,30 @@ export async function performExtractCall(
155167
if (typeof markdown !== 'string') {
156168
return createErrorResponse('Error: the Data Extraction API did not return markdown output.')
157169
}
170+
// Honor outputPath for markdown too — a large document returned inline
171+
// would overflow the conversation. Only return inline when no path given.
172+
if (resolvedOutputPath) {
173+
await writeToResolvedPath(resolvedOutputPath, markdown)
174+
return createSuccessResponse(`Wrote ${Buffer.byteLength(markdown)} bytes of Markdown to ${resolvedOutputPath}.`)
175+
}
158176
return createSuccessResponse(markdown)
159177
}
160178

161-
// Spatial: write the full result to disk, return a content-free summary.
162-
const outputDir = path.dirname(resolvedOutputPath as string)
163-
try {
164-
await fs.promises.access(outputDir)
165-
} catch {
166-
await fs.promises.mkdir(outputDir, { recursive: true })
179+
// Spatial. The early guard guarantees outputPath was provided.
180+
if (!resolvedOutputPath) {
181+
return createErrorResponse('Error: spatial output requires outputPath.')
167182
}
168-
const json = JSON.stringify(parsed, null, 2)
169-
await fs.promises.writeFile(resolvedOutputPath as string, json)
170-
171-
return createSuccessResponse(summarizeSpatial(parsed, resolvedOutputPath as string, Buffer.byteLength(json)))
183+
// Guard against a 2xx response that is not a spatial result, so we never
184+
// overwrite the target file with a non-extraction body.
185+
if (!Array.isArray(parsed.output?.elements)) {
186+
return createErrorResponse(
187+
'Error: the Data Extraction API response did not contain a spatial element list (output.elements). Nothing was written.',
188+
)
189+
}
190+
// Write the raw response body: avoids re-serializing a potentially large
191+
// payload and preserves every field the API returned.
192+
await writeToResolvedPath(resolvedOutputPath, body)
193+
return createSuccessResponse(summarizeSpatial(parsed, resolvedOutputPath, Buffer.byteLength(body)))
172194
} catch (error) {
173195
return handleApiError(error)
174196
}

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ Returns: subscription type, total credits, used credits, and remaining credits.`
176176
177177
Output formats:
178178
• spatial — typed elements (paragraphs, tables, key-value pairs, formulas, pictures, handwriting) with bounding boxes, confidence scores, and reading order. Written to outputPath (the list can be large); retrieve slices with the query_extraction tool.
179-
• markdown — whole-document Markdown, returned inline. Good for RAG and search indexing.
179+
• markdown — whole-document Markdown. Returned inline, or written to outputPath when provided (recommended for large documents). Good for RAG and search indexing.
180180
181181
Processing modes (cost per page): text = fast Markdown, no OCR (1 credit); structure = OCR spatial (1.5 credits); understand = AI-augmented, default (9 credits); agentic = VLM-augmented (18 credits).
182182

tests/extract.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,40 @@ describe('performExtractCall', () => {
101101
expect(post).toHaveBeenCalledOnce()
102102
})
103103

104+
it('writes markdown to a file when outputPath is given, returning a summary not the content', async () => {
105+
const input = await writeInput()
106+
const outName = `out-${counter}.md`
107+
const { client } = mockClient({ output: { markdown: '# Big Document\n\nlots of text' } })
108+
109+
const result = await performExtractCall(
110+
extractArgs({ filePath: input, mode: 'text', format: 'markdown', outputPath: outName }),
111+
client,
112+
)
113+
114+
expect(result.isError).toBeFalsy()
115+
const summary = text(result)
116+
expect(summary).toContain('Wrote')
117+
expect(summary).toContain(outName)
118+
expect(summary).not.toContain('lots of text')
119+
const written = await fs.promises.readFile(path.join(sandboxDir, outName), 'utf-8')
120+
expect(written).toBe('# Big Document\n\nlots of text')
121+
})
122+
123+
it('rejects a 2xx response with no spatial element list without writing the file', async () => {
124+
const input = await writeInput()
125+
const outName = `out-${counter}.json`
126+
const { client } = mockClient({ status: 200, output: { markdown: 'oops wrong shape' } })
127+
128+
const result = await performExtractCall(
129+
extractArgs({ filePath: input, mode: 'structure', format: 'spatial', outputPath: outName }),
130+
client,
131+
)
132+
133+
expect(result.isError).toBe(true)
134+
expect(text(result)).toContain('output.elements')
135+
await expect(fs.promises.access(path.join(sandboxDir, outName))).rejects.toThrow()
136+
})
137+
104138
it('writes spatial output to a file and returns a content-free summary', async () => {
105139
const input = await writeInput()
106140
const outName = `out-${counter}.json`

0 commit comments

Comments
 (0)