LLM-first resume parsing infrastructure.
Model-agnostic · Spatial extraction · OCR fallback · 15 sections · Streaming · CLI included
Documentation · npm · GitHub · Changelog
redactPiioption — redacts email, phone, addresses, and URLs before sending to the LLM. Real values are reinjected after extraction. GDPR-friendly.confidenceScoreinsectionResults— per-section reliability signal (0.0–1.0) based on retry count and field completeness.redactPii,reinjectPii,describePiiRedactionexports — utility functions for custom redaction pipelines.
streamResume()— AsyncGenerator that yields events as each section is extracted. Update your UI progressively instead of waiting 15-20 seconds for the full result.YYYY-01date fix —"2025-01"→"2025"(month-only padding now stripped)- Empty arrays removed —
volunteer: [],interests: []are now omitted from output - Empty skill categories removed — skills with no keywords are filtered out
StreamResumeEventtype export
Extracting structured data from resume PDFs is harder than it looks.
Most tools either use brittle regex patterns that break on modern CV designs, or they wrap a single AI provider's API and lock you in forever. Neither approach is production-ready.
Here's what actually goes wrong in practice:
- Multi-column layouts — column 1 and column 2 get interleaved. Dates mix with job descriptions. The LLM receives semantic chaos and hallucinates.
- DeepSeek and similar models can't read raw PDFs — they are text-only models. You must extract and clean the text first, or every call fails silently.
- LLMs produce broken JSON — missing closing braces, trailing commas, JSON wrapped in markdown fences. Without a repair layer, your pipeline crashes.
- Vendor lock-in — if your parser only works with Claude or GPT-4, you can't switch to a cheaper or local model without rewriting your integration.
- Scanned PDFs — no text layer at all. A basic extractor returns an empty string and you never know why.
- Serverless deployments — file-path based worker resolution breaks on Vercel and AWS Lambda.
resume-intel solves all of this with a four-layer pipeline: spatial extraction, OCR fallback, parallel task decomposition, and resilient JSON validation.
npm install @edwinfom/resume-intel ai
# or
pnpm add @edwinfom/resume-intel aiInstall the AI provider SDK you want to use:
npm install @ai-sdk/deepseek # DeepSeek — recommended
npm install @ai-sdk/openai # OpenAI
npm install @ai-sdk/anthropic # Anthropic
npm install @ai-sdk/google # Google GeminiFor Ollama (local models), use @ai-sdk/openai with a custom baseURL — no extra package needed.
Tesseract.js requires WASM files that are not bundled by Vercel and AWS Lambda. If your PDF is scanned and OCR triggers, you will get:
ENOENT: no such file or directory, open '/var/task/node_modules/tesseract.js-core/tesseract-core-relaxedsimd.wasm'
Fix: set disableOcr: true in your serverless function.
// app/api/parse-resume/route.ts (Next.js App Router)
const result = await parseResume(pdfBuffer, {
model,
disableOcr: true, // required on Vercel — WASM not available in serverless
})
// Auto-detect serverless environment
const result = await parseResume(pdfBuffer, {
model,
disableOcr: !!process.env.VERCEL || !!process.env.AWS_LAMBDA_FUNCTION_NAME,
})When disableOcr: true:
- Text-native PDFs → extracted normally
- Scanned PDFs → throws
OcrNotEnabledErrorwith a clear message
Set redactPii: true to prevent personal data from being sent to the LLM in plaintext. Email addresses, phone numbers, physical addresses, and URLs are replaced with deterministic placeholders before the LLM call. The real values are reinjected into the structured output after extraction.
const result = await parseResume(buffer, {
model,
redactPii: true,
})
// The LLM never sees "john.doe@gmail.com" — it processes "__PII_EMAIL_0__"
// The final output still contains the real email address
console.log(result.data.basics?.email) // "john.doe@gmail.com"Works with both parseResume() and streamResume(). The redaction is transparent — the output is identical to a non-redacted run.
For advanced use cases, the redaction utilities are exported:
import { redactPii, reinjectPii, describePiiRedaction } from '@edwinfom/resume-intel'
const { redactedText, placeholders } = redactPii(rawText)
console.log(describePiiRedaction({ redactedText, placeholders }))
// "2 emails, 3 urls, 1 phone"
const restored = reinjectPii(extractedData, placeholders)import { parseResume } from '@edwinfom/resume-intel'
import { createDeepSeek } from '@ai-sdk/deepseek'
import { readFileSync } from 'node:fs'
const result = await parseResume(readFileSync('./resume.pdf'), {
model: createDeepSeek({ apiKey: process.env.DEEPSEEK_API_KEY })('deepseek-chat'),
})
console.log(result.data.basics?.name) // "Jane Doe"
console.log(result.data.work?.length) // 3
console.log(result.data.certificates?.length) // 2
console.log(result.meta.ocrFallback) // true if scanned PDF
console.log(result.meta.sectionsRequested) // ['basics', 'work', ...]
console.log(result.meta.tokenUsage?.totalTokens) // 1850streamResume() yields events as each section is extracted. Your UI updates progressively instead of waiting for the full result.
import { streamResume } from '@edwinfom/resume-intel'
import { createDeepSeek } from '@ai-sdk/deepseek'
for await (const event of streamResume(readFileSync('./resume.pdf'), {
model: createDeepSeek({ apiKey: process.env.DEEPSEEK_API_KEY })('deepseek-chat'),
})) {
if (event.type === 'section') {
console.log(`${event.section} extracted`)
updateUI(event.section, event.data) // update your UI immediately
}
if (event.type === 'error') {
console.warn(`${event.section} failed: ${event.error}`)
}
if (event.type === 'done') {
console.log('Complete:', event.result.data.basics?.name)
console.log('Duration:', event.result.meta.durationMs, 'ms')
}
}Event types:
| Event | When | Fields |
|---|---|---|
section |
After each section is extracted | section, data, success |
error |
When a section fails after all retries | section, error |
done |
When all sections are complete | result (full ResumeIntelResult) |
streamResume() accepts the same options as parseResume().
Parse resume PDFs directly from the terminal:
# Install globally
npm install -g @edwinfom/resume-intel ai @ai-sdk/deepseek
# Parse a resume
DEEPSEEK_API_KEY=sk-... resume-intel parse resume.pdf
# Extract only specific sections
resume-intel parse resume.pdf --sections basics,work,education
# Write JSON output to file
resume-intel parse resume.pdf --out result.json
# Use a different model
resume-intel parse resume.pdf --model openai:gpt-4o-mini
# Raw JSON for piping
resume-intel parse resume.pdf --output json | jq .data.basics.name
# Show help
resume-intel --helpBy default, 8 sections are extracted. Use the sections option to extract only what you need:
import { parseResume, ALL_SECTIONS, DEFAULT_SECTIONS } from '@edwinfom/resume-intel'
// Extract only basics and work — saves tokens
const result = await parseResume(buffer, {
model,
sections: ['basics', 'work'],
})
// Extract all 12 available sections
const result = await parseResume(buffer, {
model,
sections: [...ALL_SECTIONS],
})Available sections: basics, work, education, skills, languages, projects, awards, certificates, publications, volunteer, interests, references
Default sections: basics, work, education, skills, languages, projects, certificates, volunteer
Replace the default JSON Resume schema with your own Zod schema:
import { z } from 'zod'
const result = await parseResume(buffer, {
model,
outputSchema: z.object({
fullName: z.string(),
email: z.string().email().optional(),
skills: z.array(z.string()),
yearsOfExperience: z.number().optional(),
}),
useTaskDecomposition: false, // single-shot works best with custom schemas
})
console.log(result.data.fullName)
console.log(result.data.skills)The model is just a parameter. Swap it without touching anything else.
// DeepSeek V3 — fast and cheap (recommended default)
const result = await parseResume(buffer, {
model: createDeepSeek({ apiKey: process.env.DEEPSEEK_API_KEY })('deepseek-chat'),
})
// OpenAI
const result = await parseResume(buffer, {
model: createOpenAI({ apiKey: process.env.OPENAI_API_KEY })('gpt-4o-mini'),
})
// Ollama — fully local, no API key, no data leaves your machine
const result = await parseResume(buffer, {
model: createOpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' })('llama3.1'),
})The output conforms to the JSON Resume v1 specification.
{
data: {
basics: { name, label, email, phone, url, summary, location, profiles },
work: [{ name, position, startDate, endDate, highlights }],
education: [{ institution, studyType, area, startDate, endDate }],
skills: [{ name, keywords }],
languages: [{ language, fluency }],
projects: [{ name, description, url }],
certificates: [{ name, issuer, date }],
volunteer: [{ organization, position, startDate }],
awards: [...],
publications: [...],
interests: [...],
references: [...]
},
meta: {
durationMs: 2340,
ocrFallback: false,
layoutStrategy: 'spatial',
pageCount: 2,
sectionsRequested: ['basics', 'work', 'education', 'skills', 'languages',
'projects', 'certificates', 'volunteer'],
tokenUsage: { promptTokens: 1200, completionTokens: 650, totalTokens: 1850 },
sectionResults: [
{ section: 'basics', success: true, retryCount: 0, error: null },
{ section: 'work', success: true, retryCount: 0, error: null },
]
}
}| Parameter | Type | Default | Description |
|---|---|---|---|
input |
Buffer | ArrayBuffer | Uint8Array |
required | PDF file content |
options.model |
LanguageModel |
required | Any Vercel AI SDK compatible model |
options.sections |
ResumeSectionKey[] |
8 default sections | Which sections to extract |
options.outputSchema |
ZodType |
JSON Resume v1 | Custom Zod schema for output |
options.maxRetries |
number |
3 |
Max self-correction attempts per section |
options.layoutStrategy |
'spatial' | 'linear' |
'spatial' |
PDF text extraction strategy |
options.useTaskDecomposition |
boolean |
true |
Parallel per-section extraction |
options.systemPromptPrefix |
string |
'' |
Custom instructions before extraction prompts |
options.disableOcr |
boolean |
false |
Disable OCR fallback — required on Vercel/Lambda |
options.maxConcurrency |
number |
undefined |
Limit parallel section calls (useful for rate-limited APIs) |
options.onProgress |
(section, success) => void |
— | Called after each section extraction |
options.ocrLanguage |
string |
'eng' |
Tesseract language code for non-English CVs |
options.abortSignal |
AbortSignal |
— | Cancellation signal |
options.redactPii |
boolean |
false |
Redact PII before LLM submission, reinject after extraction |
import {
parseResume, // main function
streamResume, // streaming function — yields events as sections are extracted
JsonResumeSchema, // Zod schema for JSON Resume v1
SectionSchemas, // individual section schemas
ALL_SECTIONS, // all 12 section keys
DEFAULT_SECTIONS, // the 8 default sections
normalizeDate, // normalize a date string (strips -01 padding, returns null for "Present")
cleanUrl, // validate and clean a URL string
redactPii, // redact PII from text, returns { redactedText, placeholders }
reinjectPii, // reinject PII placeholders back into extracted data
describePiiRedaction, // human-readable summary of what was redacted
ResumeExtractionError,
OcrNotEnabledError,
} from '@edwinfom/resume-intel'
import type {
StreamResumeEvent, // union type for streaming events
} from '@edwinfom/resume-intel'import { parseResume, OcrNotEnabledError, ResumeExtractionError } from '@edwinfom/resume-intel'
try {
const result = await parseResume(buffer, { model })
} catch (error) {
if (error instanceof OcrNotEnabledError) {
console.error('OCR failed:', error.message)
} else if (error instanceof ResumeExtractionError) {
console.error('Extraction failed after retries:', error.message)
console.error('Root cause:', error.cause?.message)
}
}| Model | Speed | Cost | Best for |
|---|---|---|---|
deepseek-chat (V3) |
Fast | ~$0.27/M tokens | Most CVs — recommended default |
gpt-4o-mini |
Fast | Low | Good accuracy, widely available |
claude-3-5-haiku |
Fast | Low | Complex or non-standard formats |
gemini-1.5-flash |
Very fast | Very low | High volume processing |
llama3.1 (Ollama) |
Slow | Free | Privacy-sensitive data, local dev |
deepseek-reasoner (R1) |
Slow | High | Avoid — schema echo problem on extraction |
| Package | What's wrong |
|---|---|
pdf-parse |
Destroys multi-column layout. Crashes in Node 20+ ESM. No LLM integration. |
pdfjs-dist directly |
No spatial reconstruction. No LLM integration. Worker conflicts. |
@racsodev/cv-pdf-to-json |
Hard-coded to Anthropic Claude. Cannot use DeepSeek, Ollama, or any other model. |
simple-resume-parser |
Regex-based. Fails on any CV that doesn't match its expected patterns. |
- Spatial PDF extraction with multi-column support
- Automatic OCR fallback (Tesseract.js +
@napi-rs/canvas) - Model-agnostic adapter via Vercel AI SDK
- JSON Resume v1 output schema
- Zod validation +
jsonrepair+ self-correcting retry loop - Per-section
maxTokens,temperature: 0, OCR text cleaning - Per-section retry with self-correction
- Post-extraction deduplication
- v0.1.2 — 15 sections,
sectionsoption,outputSchemaoption, CLI, serverless worker fix - v0.1.2-hotfix.1 —
disableOcroption — prevents Tesseract WASM crash on Vercel/Lambda - v0.1.3 — Output normalizer (date padding, truncated URLs, invalid profiles, "Present" endDate)
- v0.1.3 — Work section fix (personal projects as work experience now included)
- v0.1.3 —
maxConcurrency,onProgress,ocrLanguageoptions - v0.1.3 — Improved scan detection heuristic (block count + char density)
- v0.2.0 —
streamResume()— AsyncGenerator streaming API - v0.2.0 —
YYYY-01date fix, empty arrays removed, empty skill categories removed - v0.2.1 —
redactPiioption — PII redaction before LLM submission (GDPR-friendly) - v0.2.1 —
confidenceScoreinsectionResults— per-section reliability signal - v0.3 — Bounding box metadata in output (for PDF highlight UI)
Issues and PRs are welcome. Please open an issue before submitting a large change.
MIT © Edwin Fom