Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
import { copyFile, mkdir } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import tailwindcss from "@tailwindcss/vite";
import { readEnvFlagOverrides } from "./shared/feature-flags";

// Resolved here so the build fails loudly at config load if pdfjs-dist ever
// moves the file, rather than silently shipping a server that can't read PDFs.
const pdfjsWorkerPath = createRequire(import.meta.url).resolve(
"pdfjs-dist/legacy/build/pdf.worker.mjs",
);

const railwayEnvironmentName =
process.env.RAILWAY_ENVIRONMENT_NAME?.toLowerCase() ?? "";
const railwayPublicDomain =
Expand Down Expand Up @@ -297,6 +306,27 @@ export default defineNuxtConfig({
experimental: {
tasks: true,
},
hooks: {
// pdfjs-dist builds its worker import specifier at runtime, so Nitro's
// dependency tracer never sees pdf.worker.mjs and drops it from
// .output/server/node_modules. Every PDF parse then dies with
// "Setting up fake worker failed: Cannot find module .../pdf.worker.mjs",
// which reaches recruiters as unreadable CVs and 0% AI scores.
//
// externals.traceInclude can't fix this: Nitro feeds the entry back
// through Rollup's resolver, which returns the bare specifier, and then
// hands that to nodeFileTrace as a root-relative path. So place the file
// ourselves, next to the pdf.mjs that imports it.
async compiled(nitro) {
if (nitro.options.dev) return;
const destination = join(
nitro.options.output.serverDir,
"node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs",
);
await mkdir(dirname(destination), { recursive: true });
await copyFile(pdfjsWorkerPath, destination);
},
},
scheduledTasks: {
// Every minute: drain the recruiter notification outbox (instant cadence).
"* * * * *": ["notification-dispatch"],
Expand Down
16 changes: 13 additions & 3 deletions server/api/candidates/extract-cv.post.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { fileTypeFromBuffer } from 'file-type'
import { ALLOWED_MIME_TYPES, MAX_FILE_SIZE } from '../../utils/schemas/document'
import { parseDocument } from '../../utils/resume-parser'
import { parseDocumentDetailed } from '../../utils/resume-parser'
import { resolveAnalysisProvider } from '../../utils/ai/resolveProvider'
import { assertPlatformBudget, BudgetExceededError, budgetErrorToHttp } from '../../utils/ai/budget'
import { computeCostUsdMicros } from '../../utils/ai/pricing'
Expand Down Expand Up @@ -78,8 +78,18 @@ export default defineEventHandler(async (event) => {
// 2. Extract text
// ─────────────────────────────────────────────

const parsed = await parseDocument(fileBuffer, mimeType)
const text = parsed?.text?.trim() ?? ''
const parseResult = await parseDocumentDetailed(fileBuffer, mimeType)

// A parser crash is our problem, not the recruiter's — say so, and use a 5xx
// so it doesn't get filed away as "another scanned CV".
if (!parseResult.ok && parseResult.reason === 'failed') {
throw createError({
statusCode: 503,
statusMessage: 'Could not read this CV right now. Please try again, or fill the form in manually.',
})
}

const text = parseResult.ok ? parseResult.parsed.text.trim() : ''

if (text.length < MIN_TEXT_LENGTH) {
throw createError({
Expand Down
70 changes: 60 additions & 10 deletions server/utils/resume-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,39 +61,85 @@ export interface ResumeSection {
}

/**
* Parse a document buffer and extract text content.
* Why a parse produced no text.
*
* The distinction matters: `empty` is the document's fault (a scan, an
* image-only export) and the recruiter can act on it, while `failed` is ours
* and they can only be told to retry. Collapsing the two once let a missing
* pdfjs worker in the production bundle masquerade as "every CV is a scan".
*/
export type DocumentParseFailure = 'unsupported_type' | 'empty' | 'failed'

export type DocumentParseResult =
| { ok: true, parsed: ParsedResume }
| { ok: false, reason: DocumentParseFailure }

/**
* Parse a document buffer and extract text content, reporting *why* on failure.
* Routes to the appropriate parser based on MIME type.
*
* @param buffer - Raw file bytes
* @param mimeType - Validated MIME type of the document
* @returns Structured parsed content, or null if extraction fails
*/
export async function parseDocument(
export async function parseDocumentDetailed(
buffer: Buffer,
mimeType: string,
): Promise<ParsedResume | null> {
): Promise<DocumentParseResult> {
let parsed: ParsedResume | null
try {
switch (mimeType) {
case 'application/pdf':
return await parsePdf(buffer)
parsed = await parsePdf(buffer)
break
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
return await parseDocx(buffer)
parsed = await parseDocx(buffer)
break
case 'application/msword':
return await parseDoc(buffer)
parsed = await parseDoc(buffer)
break
default:
logWarn('resume_parser.unsupported_mime_type', {
mime_type: mimeType,
})
return null
return { ok: false, reason: 'unsupported_type' }
}
}
catch (error) {
logError('resume_parser.parse_failed', {
mime_type: mimeType,
error_message: error instanceof Error ? error.message : String(error),
})
return null
return { ok: false, reason: 'failed' }
}

if (!parsed) {
// Readable file, nothing to read — the scanned/image-only case. Logged so
// it stays separable from `resume_parser.parse_failed` in PostHog.
logWarn('resume_parser.no_text_extracted', {
mime_type: mimeType,
byte_size: buffer.length,
})
return { ok: false, reason: 'empty' }
}

return { ok: true, parsed }
}

/**
* Parse a document buffer and extract text content.
* Returns null when no text could be extracted, for whatever reason — use
* {@link parseDocumentDetailed} when the caller needs to tell the reasons apart.
*
* @param buffer - Raw file bytes
* @param mimeType - Validated MIME type of the document
* @returns Structured parsed content, or null if extraction fails
*/
export async function parseDocument(
buffer: Buffer,
mimeType: string,
): Promise<ParsedResume | null> {
const result = await parseDocumentDetailed(buffer, mimeType)
return result.ok ? result.parsed : null
}

// ─── PDF Parser ───────────────────────────────────────────────────
Expand All @@ -106,7 +152,11 @@ async function parsePdf(buffer: Buffer): Promise<ParsedResume | null> {
const { PDFParse } = await import('pdf-parse')

const parser = new PDFParse({ data: buffer })
const result = await parser.getText()
// pageJoiner defaults to "\n-- page_number of total_number --". Those markers
// are not resume content: on an image-only CV they are the *only* text, so the
// document reads as non-empty, gets stored, and reaches the model as the
// candidate's entire resume — which scores 0% with nothing logged anywhere.
const result = await parser.getText({ pageJoiner: '' })

const text = normalizeText(result.text)
if (!text) {
Expand Down
36 changes: 35 additions & 1 deletion tests/unit/resume-parser.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parseDocument, extractResumeText, type ParsedResume } from '../../server/utils/resume-parser'
import { parseDocument, parseDocumentDetailed, extractResumeText, type ParsedResume } from '../../server/utils/resume-parser'

/**
* Create a minimal valid PDF containing the given text.
Expand Down Expand Up @@ -81,6 +81,14 @@ describe('resume-parser', () => {
expect(result!.metadata.pageCount).toBe(1)
})

it('does not inject page markers into the extracted text', async () => {
// pdf-parse stamps "-- 1 of 2 --" between pages by default. That text is
// not the candidate's, and on a scanned CV it is all the model would see.
const result = await parseDocument(createTestPdf('John Doe Software Engineer'), 'application/pdf')

expect(result!.text).not.toMatch(/--\s*\d+\s*of\s*\d+\s*--/)
})

it('handles DOCX mime type gracefully with invalid data', async () => {
const buffer = Buffer.from('not a real docx')
const result = await parseDocument(buffer, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
Expand All @@ -94,6 +102,32 @@ describe('resume-parser', () => {
})
})

describe('parseDocumentDetailed', () => {
it('reports the reason as unsupported_type for an unknown MIME type', async () => {
const result = await parseDocumentDetailed(Buffer.from('test content'), 'text/plain')
expect(result).toEqual({ ok: false, reason: 'unsupported_type' })
})

it('separates a parser failure from a document with no text', async () => {
// A corrupt file makes the parser throw; that is our problem, not the
// recruiter's, and must not be reported as "this CV is a scan".
const result = await parseDocumentDetailed(Buffer.from('not a real pdf file content'), 'application/pdf')
expect(result).toEqual({ ok: false, reason: 'failed' })
})

it('reports the reason as empty for a readable file with no text', async () => {
const result = await parseDocumentDetailed(createTestPdf(''), 'application/pdf')
expect(result).toEqual({ ok: false, reason: 'empty' })
})

it('returns the parsed document on success', async () => {
const result = await parseDocumentDetailed(createTestPdf('John Doe Software Engineer'), 'application/pdf')

expect(result.ok).toBe(true)
expect(result.ok && result.parsed.text).toContain('John Doe')
})
})

describe('extractResumeText', () => {
it('returns null for null input', () => {
expect(extractResumeText(null)).toBeNull()
Expand Down
Loading