diff --git a/src/pages/api/grade.ts b/src/pages/api/grade.ts index fa18896..855d24e 100644 --- a/src/pages/api/grade.ts +++ b/src/pages/api/grade.ts @@ -59,6 +59,7 @@ export default async function handler( } const gradingSignal = AbortSignal.timeout(GRADING_TIMEOUT_MS); + let unparseablePdf = false; try { let pdfBuffer: Buffer; @@ -87,12 +88,21 @@ export default async function handler( pdfBuffer = await fetchRemoteResume(resumeUrl); } - const parsed = await parseResume(pdfBuffer); + // pdf.js only supplies the author; Gemini reads the raw bytes itself, so + // a PDF pdf.js chokes on can still be graded. + let author: string | undefined; + try { + author = (await parseResume(pdfBuffer))?.info?.Author; + } catch (e) { + if (!(e instanceof InvalidResumePdfError)) throw e; + console.warn(e.cause); + unparseablePdf = true; + } const completion = await generateObject({ model: "google/gemini-2.5-flash", temperature: 0, - system: getSysPrompt(parsed?.info?.Author), + system: getSysPrompt(author), messages: messages(pdfBuffer), schema: ResponseSchema, abortSignal: gradingSignal, @@ -137,8 +147,14 @@ export default async function handler( return; } - if (e instanceof InvalidResumePdfError) { - console.warn(e.cause); + // Only a document both pdf.js and the model reject counts as unreadable; + // a 400 on a PDF that parsed is a bug in this request, not the file. + if ( + unparseablePdf && + GatewayError.isInstance(e) && + e.statusCode === 400 + ) { + console.warn(e); res.status(400).json({ error: "InvalidPDFException", }); diff --git a/tests/api/grade.test.ts b/tests/api/grade.test.ts index 17e75fe..f4dc30d 100644 --- a/tests/api/grade.test.ts +++ b/tests/api/grade.test.ts @@ -1,9 +1,10 @@ import handler from "@/pages/api/grade"; import { GatewayInternalServerError, + GatewayInvalidRequestError, GatewayRateLimitError, } from "@ai-sdk/gateway"; -import { exampleResponses } from "@/resume-checker/prompts/grade"; +import { exampleResponses, getSysPrompt } from "@/resume-checker/prompts/grade"; import { generateObject, NoObjectGeneratedError } from "ai"; import type { NextApiRequest, NextApiResponse } from "next"; import type { IncomingHttpHeaders } from "node:http"; @@ -208,36 +209,58 @@ describe("/api/grade", () => { expect(pdf).not.toHaveBeenCalled(); }); - it("answers 400 to an unreadable PDF", async () => { + /* pdf.js only supplies the author; Gemini reads the raw bytes on its own. */ + it("grades a PDF that pdf.js cannot parse", async () => { vi.mocked(pdf).mockRejectedValueOnce( - new Error("InvalidPDFException: nope"), + Object.assign(new Error("Command token too long: 128"), { + name: "UnknownErrorException", + details: "FormatError: Command token too long: 128", + }), ); + vi.mocked(generateObject).mockResolvedValueOnce({ + object: { grade: "B", red_flags: [], yellow_flags: [] }, + } as never); const res = await call( - request({ method: "POST", headers: MULTIPART, body: "not-a-pdf" }), + request({ method: "POST", headers: MULTIPART, body: "garbled-pdf" }), ); - expect(res.status).toBe(400); - expect(res.body).toEqual({ error: "InvalidPDFException" }); - expect(generateObject).not.toHaveBeenCalled(); + expect(res.status).toBe(200); + expect(res.body).toEqual({ grade: "B", red_flags: [], yellow_flags: [] }); + expect(vi.mocked(generateObject).mock.calls[0][0].system).toBe( + getSysPrompt(undefined), + ); }); - /* pdf.js wraps lexer failures such as a malformed command token this way. */ - it("answers 400 to any PDF the parser rejects", async () => { - vi.mocked(pdf).mockRejectedValueOnce( - Object.assign(new Error("Command token too long: 128"), { - name: "UnknownErrorException", - details: "FormatError: Command token too long: 128", + /* The candidate-portal cron stores a placeholder grade on this exact code. */ + it("answers 400 when the model also rejects a PDF pdf.js cannot parse", async () => { + vi.mocked(pdf).mockRejectedValueOnce(new Error("Invalid PDF structure")); + vi.mocked(generateObject).mockRejectedValueOnce( + new GatewayInvalidRequestError({ + message: "The document has no pages.", }), ); const res = await call( - request({ method: "POST", headers: MULTIPART, body: "garbled-pdf" }), + request({ method: "POST", headers: MULTIPART, body: "not-a-pdf" }), ); expect(res.status).toBe(400); expect(res.body).toEqual({ error: "InvalidPDFException" }); - expect(generateObject).not.toHaveBeenCalled(); + }); + + it("keeps a gateway 400 on a PDF that parsed as a grading error", async () => { + vi.mocked(pdf).mockResolvedValueOnce({ text: "cv" } as never); + vi.mocked(generateObject).mockRejectedValueOnce( + new GatewayInvalidRequestError({ message: "Invalid schema" }), + ); + + const res = await call( + request({ method: "POST", headers: MULTIPART, body: "pdf-bytes" }), + ); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: "GradingError" }); }); /* The client renders this straight into a badge, so it must stay a code. */