Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

10 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@edwinfom/resume-intel

LLM-first resume parsing infrastructure.
Model-agnostic · Spatial extraction · OCR fallback · 15 sections · Streaming · CLI included

npm version TypeScript License: MIT

Documentation · npm · GitHub · Changelog


What's new in v0.2.1

  • redactPii option — redacts email, phone, addresses, and URLs before sending to the LLM. Real values are reinjected after extraction. GDPR-friendly.
  • confidenceScore in sectionResults — per-section reliability signal (0.0–1.0) based on retry count and field completeness.
  • redactPii, reinjectPii, describePiiRedaction exports — utility functions for custom redaction pipelines.

What's new in v0.2.0

  • 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-01 date fix"2025-01""2025" (month-only padding now stripped)
  • Empty arrays removedvolunteer: [], interests: [] are now omitted from output
  • Empty skill categories removed — skills with no keywords are filtered out
  • StreamResumeEvent type export

The Problem

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.


Installation

npm install @edwinfom/resume-intel ai
# or
pnpm add @edwinfom/resume-intel ai

Install 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 Gemini

For Ollama (local models), use @ai-sdk/openai with a custom baseURL — no extra package needed.


Serverless Environments (Vercel, AWS Lambda)

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 OcrNotEnabledError with a clear message

PII Redaction (GDPR mode)

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)

Quick Start

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) // 1850

Streaming

streamResume() 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().


CLI

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 --help

Selecting Sections

By 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


Custom Output Schema

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)

Model Agnosticism

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'),
})

Output Format

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 },
    ]
  }
}

API Reference

parseResume(input, options)

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

Exports

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'

Error Handling

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)
  }
}

Choosing the Right Model

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

Why Not Just Use [other package]?

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.

Roadmap

  • 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, sections option, outputSchema option, CLI, serverless worker fix
  • v0.1.2-hotfix.1disableOcr option — 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.3maxConcurrency, onProgress, ocrLanguage options
  • v0.1.3 — Improved scan detection heuristic (block count + char density)
  • v0.2.0streamResume() — AsyncGenerator streaming API
  • v0.2.0YYYY-01 date fix, empty arrays removed, empty skill categories removed
  • v0.2.1redactPii option — PII redaction before LLM submission (GDPR-friendly)
  • v0.2.1confidenceScore in sectionResults — per-section reliability signal
  • v0.3 — Bounding box metadata in output (for PDF highlight UI)

Contributing

Issues and PRs are welcome. Please open an issue before submitting a large change.


License

MIT © Edwin Fom

About

LLM-first resume parsing infrastructure. Model-agnostic · Spatial extraction · OCR fallback · 15 sections · Streaming · CLI included

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages