Skip to content
Draft
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
24 changes: 20 additions & 4 deletions src/pages/api/grade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export default async function handler(
}

const gradingSignal = AbortSignal.timeout(GRADING_TIMEOUT_MS);
let unparseablePdf = false;

try {
let pdfBuffer: Buffer;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
});
Expand Down
53 changes: 38 additions & 15 deletions tests/api/grade.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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. */
Expand Down
Loading