Skip to Content
PlansCV Parser — Implementation Plan

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) or executing-plans to 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

FileResponsibility
src/lib/cv-parser/types.tsTypeScript interfaces for ParsedCV, ConfidenceField, EmploymentEntry, ParseMetadata
src/lib/cv-parser/extractor.tsExtract raw text from PDF / DOCX / DOC. Returns { text: string, status: 'ok' | 'failed', reason?: string }
src/lib/cv-parser/parser.tsSend raw text to Claude via tool_use, receive structured ParsedCV JSON
src/lib/cv-parser/index.tsPublic API: parseCV(filePath: string): Promise<ParsedCV> — composes extractor + parser
src/app/api/parse-cv/route.tsPOST endpoint accepting a file upload, returns ParsedCV JSON
scripts/batch-parse.tsProcess all 65 reference CVs, write one JSON file per CV to outputs/parsed-cvs/
tests/lib/cv-parser/extractor.test.tsUnit tests for text extraction
tests/lib/cv-parser/parser.test.tsUnit 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 dev

Expected: 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 test

Expected: 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 test

Expected: 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.ts

Expected: 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.ts

Expected: 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.ts

Expected: 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.ts

Expected: 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 test

Expected: 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 test

Expected: 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-parse

Expected 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, SenioritySignal defined 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 failedParseRecord with 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
Last updated on