diff --git a/nuxt.config.ts b/nuxt.config.ts index b5022039..5e15e8ac 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -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 = @@ -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"], diff --git a/server/api/candidates/extract-cv.post.ts b/server/api/candidates/extract-cv.post.ts index 386a7da0..5c132775 100644 --- a/server/api/candidates/extract-cv.post.ts +++ b/server/api/candidates/extract-cv.post.ts @@ -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' @@ -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({ diff --git a/server/utils/resume-parser.ts b/server/utils/resume-parser.ts index 1dc41f4a..685dca66 100644 --- a/server/utils/resume-parser.ts +++ b/server/utils/resume-parser.ts @@ -61,30 +61,47 @@ 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 { +): Promise { + 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) { @@ -92,8 +109,37 @@ export async function parseDocument( 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 { + const result = await parseDocumentDetailed(buffer, mimeType) + return result.ok ? result.parsed : null } // ─── PDF Parser ─────────────────────────────────────────────────── @@ -106,7 +152,11 @@ async function parsePdf(buffer: Buffer): Promise { 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) { diff --git a/tests/unit/resume-parser.test.ts b/tests/unit/resume-parser.test.ts index 90fd5a29..861ff630 100644 --- a/tests/unit/resume-parser.test.ts +++ b/tests/unit/resume-parser.test.ts @@ -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. @@ -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') @@ -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()