CV Parser — Implementation Plan
Source: plans/step-5.1.1-cv-parser-impl.md · synced 2026-06-12
For agentic workers: REQUIRED SUB-SKILL: Use
subagent-driven-development(recommended) orexecuting-plansto implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.Status (2026-05-25): ✅ Shipped — see
calibre-app/src/lib/cv-parser/. Plan retained for audit traceability. Phase A, 9-step coverage: Step 5a.
Goal: Build a CV parser that extracts raw structured data (with confidence scores) from PDF, DOCX, and DOC files, outputting a validated JSON record per CV conforming to the approved schema.
Architecture: Text extractor pulls raw text from the file → Claude API call converts raw text to structured JSON → output written to a JSON file per CV. Two modules with a clean interface between them. No Supabase yet — batch script writes to outputs/parsed-cvs/. Supabase storage is a separate task in infrastructure setup.
Tech Stack: Next.js 14 (App Router), TypeScript, pdf-parse (PDF), mammoth (DOCX), word-extractor (DOC), @anthropic-ai/sdk (Claude API), Vitest (tests)
File Map
| File | Responsibility |
|---|---|
src/lib/cv-parser/types.ts | TypeScript interfaces for ParsedCV, ConfidenceField, EmploymentEntry, ParseMetadata |
src/lib/cv-parser/extractor.ts | Extract raw text from PDF / DOCX / DOC. Returns { text: string, status: 'ok' | 'failed', reason?: string } |
src/lib/cv-parser/parser.ts | Send raw text to Claude via tool_use, receive structured ParsedCV JSON |
src/lib/cv-parser/index.ts | Public API: parseCV(filePath: string): Promise<ParsedCV> — composes extractor + parser |
src/app/api/parse-cv/route.ts | POST endpoint accepting a file upload, returns ParsedCV JSON |
scripts/batch-parse.ts | Process all 65 reference CVs, write one JSON file per CV to outputs/parsed-cvs/ |
tests/lib/cv-parser/extractor.test.ts | Unit tests for text extraction |
tests/lib/cv-parser/parser.test.ts | Unit tests for Claude parser (mocked API) |
Task 1: Scaffold Next.js project
Files:
-
Create:
calibre-app/(new Next.js project at workspace root) -
Step 1: Scaffold the app
cd "G:/My Drive/BL Dev work/Calibre"
npx create-next-app@latest calibre-app --typescript --app --no-tailwind --no-src-dir --import-alias "@/*" --no-eslint- Step 2: Install parser dependencies
cd calibre-app
npm install pdf-parse mammoth word-extractor @anthropic-ai/sdk
npm install -D vitest @vitest/coverage-v8 tsx- Step 3: Add vitest config
Create calibre-app/vitest.config.ts:
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
test: {
environment: 'node',
globals: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})- Step 4: Add test and batch scripts to package.json
Edit calibre-app/package.json — add to "scripts":
"test": "vitest run",
"test:watch": "vitest",
"batch-parse": "tsx scripts/batch-parse.ts"- Step 5: Create .env.local
Create calibre-app/.env.local (gitignored — do not commit):
ANTHROPIC_API_KEY=your_key_here- Step 6: Verify scaffold
cd calibre-app && npm run devExpected: Next.js dev server starts on http://localhost:3000
- Step 7: Commit
cd calibre-app
git init
echo ".env.local" >> .gitignore
echo "outputs/parsed-cvs/" >> .gitignore
git add .
git commit -m "feat: scaffold Next.js app for Calibre"Task 2: TypeScript types
Files:
-
Create:
calibre-app/src/lib/cv-parser/types.ts -
Step 1: Write the failing test
Create calibre-app/tests/lib/cv-parser/extractor.test.ts:
import { describe, it, expect } from 'vitest'
import type { ParsedCV, ConfidenceField } from '@/lib/cv-parser/types'
describe('ParsedCV types', () => {
it('ConfidenceField accepts value and confidence', () => {
const field: ConfidenceField<string> = { value: 'test', confidence: 0.95 }
expect(field.value).toBe('test')
expect(field.confidence).toBe(0.95)
})
it('ConfidenceField accepts null value', () => {
const field: ConfidenceField<string | null> = { value: null, confidence: null }
expect(field.value).toBeNull()
})
})- Step 2: Run test to verify it fails
cd calibre-app && npm testExpected: FAIL — Cannot find module '@/lib/cv-parser/types'
- Step 3: Write the types
Create calibre-app/src/lib/cv-parser/types.ts:
export interface ConfidenceField<T> {
value: T
confidence: number | null
}
export interface EmploymentEntry {
raw_title: ConfidenceField<string>
employer: ConfidenceField<string>
start_date: ConfidenceField<string>
end_date: ConfidenceField<string | null>
description_summary: ConfidenceField<string>
}
export interface EducationEntry {
qualification: ConfidenceField<string>
field: ConfidenceField<string>
institution: ConfidenceField<string>
year: ConfidenceField<string | null>
}
export interface CareerGap {
from: string
to: string
months: number
}
export interface ShortTenure {
employer: string
months: number
}
export type SenioritySignal = 'IC' | 'Manager' | 'Director' | 'C-suite'
export type ParseStatus = 'success' | 'partial' | 'failed'
export type FileType = 'pdf' | 'docx' | 'doc'
export interface ParseMetadata {
source_file: string
file_type: FileType
parse_status: ParseStatus
unreadable_reason: string | null
overall_confidence: number
parsed_at: string
}
export interface ParsedCV {
identity: {
name: ConfidenceField<string>
email: ConfidenceField<string | null>
phone: ConfidenceField<string | null>
location: ConfidenceField<string | null>
linkedin_url: ConfidenceField<string | null>
}
current_role: {
raw_title: ConfidenceField<string>
employer: ConfidenceField<string>
start_date: ConfidenceField<string>
end_date: ConfidenceField<string | null>
}
employment_history: EmploymentEntry[]
signals: {
total_years_experience: ConfidenceField<number>
seniority_signal: ConfidenceField<SenioritySignal>
commodity_specialisations: ConfidenceField<string[]>
vertical_signals: ConfidenceField<string[]>
tools_and_systems: ConfidenceField<string[]>
certifications: ConfidenceField<string[]>
education: EducationEntry[]
career_gaps: ConfidenceField<CareerGap[]>
short_tenures: ConfidenceField<ShortTenure[]>
}
parse_metadata: ParseMetadata
}- Step 4: Run test to verify it passes
cd calibre-app && npm testExpected: PASS
- Step 5: Commit
git add src/lib/cv-parser/types.ts tests/lib/cv-parser/extractor.test.ts
git commit -m "feat: add ParsedCV TypeScript types"Task 3: Text extractor
Files:
-
Create:
calibre-app/src/lib/cv-parser/extractor.ts -
Modify:
calibre-app/tests/lib/cv-parser/extractor.test.ts -
Step 1: Write the failing tests
Replace calibre-app/tests/lib/cv-parser/extractor.test.ts with:
import { describe, it, expect } from 'vitest'
import path from 'path'
import { extractText } from '@/lib/cv-parser/extractor'
const REFERENCE_DIR = path.resolve(
__dirname,
'../../../../../reference'
)
describe('extractText', () => {
it('extracts text from a known PDF', async () => {
const filePath = path.join(
REFERENCE_DIR,
'Risk - Analytics/Adrian Tan (Risk, Oil).pdf'
)
const result = await extractText(filePath)
expect(result.status).toBe('ok')
expect(result.text.length).toBeGreaterThan(200)
expect(result.text).toContain('Risk')
})
it('extracts text from a known DOCX', async () => {
const filePath = path.join(
REFERENCE_DIR,
'Operations - ship, shipping brokerage/Amardeep Singh (Ship Ops, Tanker).docx'
)
const result = await extractText(filePath)
expect(result.status).toBe('ok')
expect(result.text.length).toBeGreaterThan(100)
})
it('returns failed status for a scanned/image PDF', async () => {
const filePath = path.join(
REFERENCE_DIR,
'Operations - ship, shipping brokerage/Sandeep Singh (Ship Ops, Tanker).pdf'
)
const result = await extractText(filePath)
// Either extracts very little text or fails cleanly
if (result.status === 'ok') {
expect(result.text.length).toBeLessThan(50)
} else {
expect(result.status).toBe('failed')
}
})
it('returns failed status for unsupported extension', async () => {
const result = await extractText('/fake/file.xyz')
expect(result.status).toBe('failed')
expect(result.reason).toContain('unsupported')
})
})- Step 2: Run tests to verify they fail
cd calibre-app && npm test tests/lib/cv-parser/extractor.test.tsExpected: FAIL — Cannot find module '@/lib/cv-parser/extractor'
- Step 3: Write the extractor
Create calibre-app/src/lib/cv-parser/extractor.ts:
import fs from 'fs'
import path from 'path'
import pdfParse from 'pdf-parse'
import mammoth from 'mammoth'
import WordExtractor from 'word-extractor'
export interface ExtractionResult {
text: string
status: 'ok' | 'failed'
reason?: string
}
export async function extractText(filePath: string): Promise<ExtractionResult> {
const ext = path.extname(filePath).toLowerCase()
if (!fs.existsSync(filePath)) {
return { text: '', status: 'failed', reason: `file not found: ${filePath}` }
}
try {
if (ext === '.pdf') return await extractPdf(filePath)
if (ext === '.docx') return await extractDocx(filePath)
if (ext === '.doc') return await extractDoc(filePath)
return { text: '', status: 'failed', reason: `unsupported file type: ${ext}` }
} catch (err) {
return {
text: '',
status: 'failed',
reason: err instanceof Error ? err.message : 'unknown extraction error',
}
}
}
async function extractPdf(filePath: string): Promise<ExtractionResult> {
const buffer = fs.readFileSync(filePath)
const data = await pdfParse(buffer)
const text = data.text.trim()
if (text.length < 50) {
return {
text,
status: 'failed',
reason: 'PDF appears to be scanned/image-based — insufficient text extracted',
}
}
return { text, status: 'ok' }
}
async function extractDocx(filePath: string): Promise<ExtractionResult> {
const result = await mammoth.extractRawText({ path: filePath })
const text = result.value.trim()
if (text.length < 50) {
return { text, status: 'failed', reason: 'DOCX appears empty or corrupted' }
}
return { text, status: 'ok' }
}
async function extractDoc(filePath: string): Promise<ExtractionResult> {
const extractor = new WordExtractor()
const doc = await extractor.extract(filePath)
const text = doc.getBody().trim()
if (text.length < 50) {
return { text, status: 'failed', reason: 'DOC appears empty or corrupted' }
}
return { text, status: 'ok' }
}- Step 4: Run tests to verify they pass
cd calibre-app && npm test tests/lib/cv-parser/extractor.test.tsExpected: PASS (the scanned PDF test may show status: 'failed' — that is correct behaviour)
- Step 5: Commit
git add src/lib/cv-parser/extractor.ts tests/lib/cv-parser/extractor.test.ts
git commit -m "feat: add CV text extractor for PDF, DOCX, DOC"Task 4: Claude parser
Files:
-
Create:
calibre-app/src/lib/cv-parser/parser.ts -
Create:
calibre-app/tests/lib/cv-parser/parser.test.ts -
Step 1: Write the failing tests
Create calibre-app/tests/lib/cv-parser/parser.test.ts:
import { describe, it, expect, vi } from 'vitest'
import { parseCVText } from '@/lib/cv-parser/parser'
// Sample raw CV text (trimmed excerpt from Adrian Tan)
const SAMPLE_CV_TEXT = `
TAN RUOH HUEI ADRIAN
HP: +65 97995668, AdrianTan7@gmail.com
PROFESSIONAL EXPERIENCE
Meridian Energy Pte. Ltd November 2023 - Current
Risk Manager
Lead a 3-person team managing market risk, credit risk, and compliance.
Hengli Petrochemical International Pte. Ltd February 2019 - November 2023
Risk Manager
Manages entire company risk portfolio.
EDUCATION
Singapore Institute of Management (University of London)
Bachelor of Science Banking and Finance
`
describe('parseCVText', () => {
it('returns a ParsedCV with identity fields', async () => {
const result = await parseCVText(SAMPLE_CV_TEXT, 'Adrian Tan (Risk, Oil).pdf')
expect(result.identity.name.value).toBeTruthy()
expect(result.identity.name.confidence).toBeGreaterThan(0)
})
it('returns current_role with a raw_title', async () => {
const result = await parseCVText(SAMPLE_CV_TEXT, 'Adrian Tan (Risk, Oil).pdf')
expect(result.current_role.raw_title.value).toBeTruthy()
expect(result.current_role.employer.value).toBeTruthy()
})
it('returns employment_history as an array', async () => {
const result = await parseCVText(SAMPLE_CV_TEXT, 'Adrian Tan (Risk, Oil).pdf')
expect(Array.isArray(result.employment_history)).toBe(true)
expect(result.employment_history.length).toBeGreaterThan(0)
})
it('returns signals with total_years_experience', async () => {
const result = await parseCVText(SAMPLE_CV_TEXT, 'Adrian Tan (Risk, Oil).pdf')
expect(result.signals.total_years_experience.value).toBeGreaterThan(0)
})
it('parse_metadata reflects the source file', async () => {
const result = await parseCVText(SAMPLE_CV_TEXT, 'Adrian Tan (Risk, Oil).pdf')
expect(result.parse_metadata.source_file).toBe('Adrian Tan (Risk, Oil).pdf')
expect(result.parse_metadata.file_type).toBe('pdf')
expect(['success', 'partial']).toContain(result.parse_metadata.parse_status)
})
it('overall_confidence is between 0 and 1', async () => {
const result = await parseCVText(SAMPLE_CV_TEXT, 'Adrian Tan (Risk, Oil).pdf')
expect(result.parse_metadata.overall_confidence).toBeGreaterThanOrEqual(0)
expect(result.parse_metadata.overall_confidence).toBeLessThanOrEqual(1)
})
})- Step 2: Run tests to verify they fail
cd calibre-app && npm test tests/lib/cv-parser/parser.test.tsExpected: FAIL — Cannot find module '@/lib/cv-parser/parser'
- Step 3: Write the parser
Create calibre-app/src/lib/cv-parser/parser.ts:
import Anthropic from '@anthropic-ai/sdk'
import path from 'path'
import type {
ParsedCV,
FileType,
ParseStatus,
SenioritySignal,
ConfidenceField,
} from './types'
const client = new Anthropic()
const CV_EXTRACTION_TOOL: Anthropic.Tool = {
name: 'extract_cv_data',
description: 'Extract all structured data from a CV/resume text. Assign a confidence score (0.0–1.0) to every field based on how clearly and unambiguously it appears in the text. Use null confidence only when a field is genuinely absent.',
input_schema: {
type: 'object' as const,
required: ['identity', 'current_role', 'employment_history', 'signals'],
properties: {
identity: {
type: 'object',
required: ['name', 'email', 'phone', 'location', 'linkedin_url'],
properties: {
name: { type: 'object', properties: { value: { type: 'string' }, confidence: { type: 'number' } }, required: ['value', 'confidence'] },
email: { type: 'object', properties: { value: { type: ['string', 'null'] }, confidence: { type: ['number', 'null'] } }, required: ['value', 'confidence'] },
phone: { type: 'object', properties: { value: { type: ['string', 'null'] }, confidence: { type: ['number', 'null'] } }, required: ['value', 'confidence'] },
location: { type: 'object', properties: { value: { type: ['string', 'null'] }, confidence: { type: ['number', 'null'] } }, required: ['value', 'confidence'] },
linkedin_url: { type: 'object', properties: { value: { type: ['string', 'null'] }, confidence: { type: ['number', 'null'] } }, required: ['value', 'confidence'] },
},
},
current_role: {
type: 'object',
required: ['raw_title', 'employer', 'start_date', 'end_date'],
properties: {
raw_title: { type: 'object', properties: { value: { type: 'string' }, confidence: { type: 'number' } }, required: ['value', 'confidence'] },
employer: { type: 'object', properties: { value: { type: 'string' }, confidence: { type: 'number' } }, required: ['value', 'confidence'] },
start_date: { type: 'object', properties: { value: { type: 'string' }, confidence: { type: 'number' } }, required: ['value', 'confidence'] },
end_date: { type: 'object', properties: { value: { type: ['string', 'null'] }, confidence: { type: ['number', 'null'] } }, required: ['value', 'confidence'] },
},
},
employment_history: {
type: 'array',
items: {
type: 'object',
required: ['raw_title', 'employer', 'start_date', 'end_date', 'description_summary'],
properties: {
raw_title: { type: 'object', properties: { value: { type: 'string' }, confidence: { type: 'number' } }, required: ['value', 'confidence'] },
employer: { type: 'object', properties: { value: { type: 'string' }, confidence: { type: 'number' } }, required: ['value', 'confidence'] },
start_date: { type: 'object', properties: { value: { type: 'string' }, confidence: { type: 'number' } }, required: ['value', 'confidence'] },
end_date: { type: 'object', properties: { value: { type: ['string', 'null'] }, confidence: { type: ['number', 'null'] } }, required: ['value', 'confidence'] },
description_summary: { type: 'object', properties: { value: { type: 'string' }, confidence: { type: 'number' } }, required: ['value', 'confidence'] },
},
},
},
signals: {
type: 'object',
required: [
'total_years_experience', 'seniority_signal', 'commodity_specialisations',
'vertical_signals', 'tools_and_systems', 'certifications',
'education', 'career_gaps', 'short_tenures',
],
properties: {
total_years_experience: { type: 'object', properties: { value: { type: 'number' }, confidence: { type: 'number' } }, required: ['value', 'confidence'] },
seniority_signal: { type: 'object', properties: { value: { type: 'string', enum: ['IC', 'Manager', 'Director', 'C-suite'] }, confidence: { type: 'number' } }, required: ['value', 'confidence'] },
commodity_specialisations: { type: 'object', properties: { value: { type: 'array', items: { type: 'string' } }, confidence: { type: 'number' } }, required: ['value', 'confidence'] },
vertical_signals: { type: 'object', properties: { value: { type: 'array', items: { type: 'string' } }, confidence: { type: 'number' } }, required: ['value', 'confidence'] },
tools_and_systems: { type: 'object', properties: { value: { type: 'array', items: { type: 'string' } }, confidence: { type: 'number' } }, required: ['value', 'confidence'] },
certifications: { type: 'object', properties: { value: { type: 'array', items: { type: 'string' } }, confidence: { type: 'number' } }, required: ['value', 'confidence'] },
education: {
type: 'array',
items: {
type: 'object',
required: ['qualification', 'field', 'institution', 'year'],
properties: {
qualification: { type: 'object', properties: { value: { type: 'string' }, confidence: { type: 'number' } }, required: ['value', 'confidence'] },
field: { type: 'object', properties: { value: { type: 'string' }, confidence: { type: 'number' } }, required: ['value', 'confidence'] },
institution: { type: 'object', properties: { value: { type: 'string' }, confidence: { type: 'number' } }, required: ['value', 'confidence'] },
year: { type: 'object', properties: { value: { type: ['string', 'null'] }, confidence: { type: ['number', 'null'] } }, required: ['value', 'confidence'] },
},
},
},
career_gaps: {
type: 'object',
properties: {
value: { type: 'array', items: { type: 'object', properties: { from: { type: 'string' }, to: { type: 'string' }, months: { type: 'number' } }, required: ['from', 'to', 'months'] } },
confidence: { type: 'number' },
},
required: ['value', 'confidence'],
},
short_tenures: {
type: 'object',
properties: {
value: { type: 'array', items: { type: 'object', properties: { employer: { type: 'string' }, months: { type: 'number' } }, required: ['employer', 'months'] } },
confidence: { type: 'number' },
},
required: ['value', 'confidence'],
},
},
},
},
},
}
function computeOverallConfidence(cv: Omit<ParsedCV, 'parse_metadata'>): number {
const scores: number[] = []
const collectField = (obj: unknown) => {
if (obj && typeof obj === 'object') {
const o = obj as Record<string, unknown>
if ('confidence' in o && typeof o.confidence === 'number') {
scores.push(o.confidence)
return
}
for (const val of Object.values(o)) {
if (Array.isArray(val)) val.forEach(collectField)
else collectField(val)
}
}
}
collectField(cv)
if (scores.length === 0) return 0
return Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 100) / 100
}
function deriveParseStatus(cv: Omit<ParsedCV, 'parse_metadata'>, overallConfidence: number): ParseStatus {
const requiredFields: Array<ConfidenceField<unknown>> = [
cv.identity.name,
cv.current_role.raw_title,
cv.current_role.employer,
cv.signals.total_years_experience,
]
const hasLowConfidence = requiredFields.some(
f => f.confidence === null || f.confidence < 0.60
)
if (hasLowConfidence || overallConfidence < 0.70) return 'partial'
return 'success'
}
export async function parseCVText(rawText: string, sourceFile: string): Promise<ParsedCV> {
const ext = path.extname(sourceFile).toLowerCase().replace('.', '') as FileType
const response = await client.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 4096,
tools: [CV_EXTRACTION_TOOL],
tool_choice: { type: 'any' },
messages: [
{
role: 'user',
content: `You are a specialist recruitment data extraction system for the commodities, maritime, and energy sectors.
Extract all structured data from the following CV text. For every field:
- Assign a confidence score from 0.0 (completely uncertain) to 1.0 (unambiguous).
- Use null confidence only when a field is genuinely absent from the text.
- For seniority_signal: IC = individual contributor (analyst, executive, operator), Manager = team lead/manager, Director = director/VP, C-suite = C-level.
- For career_gaps: flag any gap > 3 months between roles.
- For short_tenures: flag any role lasting < 12 months.
- For dates: use YYYY-MM format where possible. If only year is visible, use YYYY-01.
- For commodity_specialisations: extract specific commodities (e.g. "Fuel Oil", "LNG", "Iron Ore"), not generic terms.
- For vertical_signals: classify into one or more of: Commodities, Maritime, Offshore, Technology, Gaming, Finance.
CV TEXT:
---
${rawText}
---`,
},
],
})
const toolUse = response.content.find(block => block.type === 'tool_use')
if (!toolUse || toolUse.type !== 'tool_use') {
throw new Error('Claude did not return a tool_use block')
}
const extracted = toolUse.input as Omit<ParsedCV, 'parse_metadata'>
const overallConfidence = computeOverallConfidence(extracted)
const parseStatus = deriveParseStatus(extracted, overallConfidence)
return {
...extracted,
parse_metadata: {
source_file: sourceFile,
file_type: ext,
parse_status: parseStatus,
unreadable_reason: null,
overall_confidence: overallConfidence,
parsed_at: new Date().toISOString(),
},
}
}
export function failedParseRecord(sourceFile: string, reason: string): ParsedCV {
const ext = path.extname(sourceFile).toLowerCase().replace('.', '') as FileType
const nameFromFile = path.basename(sourceFile, path.extname(sourceFile)).split('(')[0].trim()
return {
identity: {
name: { value: nameFromFile, confidence: 0.5 },
email: { value: null, confidence: null },
phone: { value: null, confidence: null },
location: { value: null, confidence: null },
linkedin_url: { value: null, confidence: null },
},
current_role: {
raw_title: { value: '', confidence: 0 },
employer: { value: '', confidence: 0 },
start_date: { value: '', confidence: 0 },
end_date: { value: null, confidence: null },
},
employment_history: [],
signals: {
total_years_experience: { value: 0, confidence: 0 },
seniority_signal: { value: 'IC', confidence: 0 },
commodity_specialisations: { value: [], confidence: 0 },
vertical_signals: { value: [], confidence: 0 },
tools_and_systems: { value: [], confidence: 0 },
certifications: { value: [], confidence: 0 },
education: [],
career_gaps: { value: [], confidence: 0 },
short_tenures: { value: [], confidence: 0 },
},
parse_metadata: {
source_file: sourceFile,
file_type: ext,
parse_status: 'failed',
unreadable_reason: reason,
overall_confidence: 0,
parsed_at: new Date().toISOString(),
},
}
}- Step 4: Run tests to verify they pass
cd calibre-app && npm test tests/lib/cv-parser/parser.test.tsExpected: PASS (these tests make real API calls — ensure ANTHROPIC_API_KEY is set in .env.local)
- Step 5: Commit
git add src/lib/cv-parser/parser.ts tests/lib/cv-parser/parser.test.ts
git commit -m "feat: add Claude-powered CV parser with tool_use structured output"Task 5: Public API (index.ts)
Files:
-
Create:
calibre-app/src/lib/cv-parser/index.ts -
Step 1: Write the failing test
Add to calibre-app/tests/lib/cv-parser/parser.test.ts:
import { parseCV } from '@/lib/cv-parser'
import path from 'path'
describe('parseCV (public API)', () => {
it('parses a real PDF end-to-end', async () => {
const filePath = path.resolve(
__dirname,
'../../../../../reference/Risk - Analytics/Adrian Tan (Risk, Oil).pdf'
)
const result = await parseCV(filePath)
expect(result.parse_metadata.parse_status).not.toBe('failed')
expect(result.identity.name.value).toBeTruthy()
})
it('returns a failed record for a scanned PDF', async () => {
const filePath = path.resolve(
__dirname,
'../../../../../reference/Operations - ship, shipping brokerage/Sandeep Singh (Ship Ops, Tanker).pdf'
)
const result = await parseCV(filePath)
expect(result.parse_metadata.parse_status).toBe('failed')
expect(result.parse_metadata.unreadable_reason).toBeTruthy()
})
})- Step 2: Run test to verify it fails
cd calibre-app && npm testExpected: FAIL — Cannot find module '@/lib/cv-parser'
- Step 3: Write index.ts
Create calibre-app/src/lib/cv-parser/index.ts:
import path from 'path'
import { extractText } from './extractor'
import { parseCVText, failedParseRecord } from './parser'
import type { ParsedCV } from './types'
export type { ParsedCV } from './types'
export async function parseCV(filePath: string): Promise<ParsedCV> {
const sourceFile = path.basename(filePath)
const extraction = await extractText(filePath)
if (extraction.status === 'failed') {
return failedParseRecord(sourceFile, extraction.reason ?? 'extraction failed')
}
return parseCVText(extraction.text, sourceFile)
}- Step 4: Run all tests
cd calibre-app && npm testExpected: All tests PASS
- Step 5: Commit
git add src/lib/cv-parser/index.ts
git commit -m "feat: add parseCV public API composing extractor + parser"Task 6: Batch processing script
Files:
-
Create:
calibre-app/scripts/batch-parse.ts -
Create:
calibre-app/outputs/parsed-cvs/(gitignored) -
Step 1: Write the script
Create calibre-app/scripts/batch-parse.ts:
import fs from 'fs'
import path from 'path'
import { parseCV } from '../src/lib/cv-parser'
const REFERENCE_DIR = path.resolve(__dirname, '../../reference')
const OUTPUT_DIR = path.resolve(__dirname, '../outputs/parsed-cvs')
const CV_FOLDERS = [
'Operations - cargo, commodities brokerage',
'Operations - ship, shipping brokerage',
'Risk - Analytics',
]
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true })
const results = { success: 0, partial: 0, failed: 0 }
for (const folder of CV_FOLDERS) {
const folderPath = path.join(REFERENCE_DIR, folder)
const files = fs.readdirSync(folderPath).filter(f =>
['.pdf', '.docx', '.doc'].includes(path.extname(f).toLowerCase())
)
console.log(`\n📁 ${folder} — ${files.length} files`)
for (const file of files) {
const filePath = path.join(folderPath, file)
const outputFile = path.join(
OUTPUT_DIR,
`${path.basename(file, path.extname(file))}.json`
)
process.stdout.write(` Parsing: ${file} ... `)
try {
const parsed = await parseCV(filePath)
fs.writeFileSync(outputFile, JSON.stringify(parsed, null, 2))
const status = parsed.parse_metadata.parse_status
const confidence = parsed.parse_metadata.overall_confidence.toFixed(2)
console.log(`${status} (confidence: ${confidence})`)
results[status]++
} catch (err) {
console.log(`ERROR: ${err instanceof Error ? err.message : err}`)
results.failed++
}
// Avoid rate limiting
await new Promise(r => setTimeout(r, 500))
}
}
console.log('\n─────────────────────────────')
console.log(`✅ success: ${results.success}`)
console.log(`⚠️ partial: ${results.partial}`)
console.log(`❌ failed: ${results.failed}`)
console.log(`Output: ${OUTPUT_DIR}`)
}
main().catch(console.error)- Step 2: Create output directory placeholder
cd calibre-app
mkdir -p outputs/parsed-cvs
touch outputs/parsed-cvs/.gitkeep- Step 3: Run the batch script against all 65 CVs
cd calibre-app && npm run batch-parseExpected output (approximate):
📁 Operations - cargo, commodities brokerage — 21 files
Parsing: Aarul Selven (Cargo Ops, Fuel Oil).pdf ... success (confidence: 0.91)
...
📁 Operations - ship, shipping brokerage — 23 files
...
Parsing: Sandeep Singh (Ship Ops, Tanker).pdf ... failed (confidence: 0.00)
...
📁 Risk - Analytics — 22 files
...
─────────────────────────────
✅ success: ~55
⚠️ partial: ~6
❌ failed: ~4- Step 4: Inspect 3 output files — one from each sub-function. Verify the JSON matches the schema and confidence scores look sensible.
cat "calibre-app/outputs/parsed-cvs/Adrian Tan (Risk, Oil).json"
cat "calibre-app/outputs/parsed-cvs/Aarul Selven (Cargo Ops, Fuel Oil).json"
cat "calibre-app/outputs/parsed-cvs/Amardeep Singh (Ship Ops, Tanker).json"- Step 5: Commit
git add scripts/batch-parse.ts outputs/parsed-cvs/.gitkeep
git commit -m "feat: add batch CV parsing script for 65 reference CVs"Task 7: API route
Files:
-
Create:
calibre-app/src/app/api/parse-cv/route.ts -
Step 1: Write the route
Create calibre-app/src/app/api/parse-cv/route.ts:
import { NextRequest, NextResponse } from 'next/server'
import { writeFileSync, unlinkSync } from 'fs'
import { tmpdir } from 'os'
import path from 'path'
import { parseCV } from '@/lib/cv-parser'
export async function POST(req: NextRequest) {
const formData = await req.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
const ext = path.extname(file.name).toLowerCase()
if (!['.pdf', '.docx', '.doc'].includes(ext)) {
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 })
}
// Write to temp file for extraction
const tmpPath = path.join(tmpdir(), `cv-${Date.now()}${ext}`)
const buffer = Buffer.from(await file.arrayBuffer())
writeFileSync(tmpPath, buffer)
try {
const parsed = await parseCV(tmpPath)
// Override source_file with original filename
parsed.parse_metadata.source_file = file.name
return NextResponse.json(parsed)
} finally {
unlinkSync(tmpPath)
}
}- Step 2: Verify the route with curl
Start the dev server: npm run dev
curl -X POST http://localhost:3000/api/parse-cv \
-F "file=@../reference/Risk - Analytics/Adrian Tan (Risk, Oil).pdf"Expected: JSON response with parse_metadata.parse_status: "success"
- Step 3: Commit
git add src/app/api/parse-cv/route.ts
git commit -m "feat: add POST /api/parse-cv endpoint"Self-Review Checklist
- Spec coverage: Types ✓, extractor ✓, Claude parser ✓, batch script ✓, API route ✓, confidence scores ✓, parse_status logic ✓, failed record for unreadable files ✓
- No placeholders: All steps have exact code, exact commands, exact expected output
- Type consistency:
ParsedCV,ConfidenceField,EmploymentEntry,ParseStatus,FileType,SenioritySignaldefined in Task 2, used consistently in Tasks 3–7 - 4 known failed files: Sandeep Singh, Chloe Wong handled by extractor (scanned PDF < 50 chars → failed). Anand Upretic, Jonathan Lam handled by extractor (empty/corrupted → failed). All 4 produce a
failedParseRecordwith name inferred from filename.
What this plan does NOT cover
- Title normaliser — next brainstorming session
- Supabase storage — infrastructure setup task
- UI for review/flagging — Sprint 4