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
57 changes: 47 additions & 10 deletions src/pages/api/grade.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { GRADING_TIMEOUT_MS } from "@/resume-checker/constants";
import {
GRADING_MAX_OUTPUT_TOKENS,
GRADING_THINKING_BUDGET,
GRADING_TIMEOUT_MS,
} from "@/resume-checker/constants";
import {
fetchRemoteResume,
MAX_RESUME_BYTES,
ResumeFetchError,
} from "@/resume-checker/fetch-resume";
import { parseResume } from "@/resume-checker/parse-resume";
import {
InvalidResumePdfError,
parseResume,
} from "@/resume-checker/parse-resume";
import {
exampleResponses,
getSysPrompt,
Expand All @@ -14,7 +21,7 @@ import {
sanitizeCompletion,
} from "@/resume-checker/prompts/grade";
import { GatewayError } from "@ai-sdk/gateway";
import { generateObject } from "ai";
import { generateObject, NoObjectGeneratedError } from "ai";
import type { NextApiRequest, NextApiResponse } from "next";

function isMultipartFormData(req: NextApiRequest) {
Expand All @@ -24,6 +31,10 @@ function isMultipartFormData(req: NextApiRequest) {
);
}

function isGatewayUnavailable(statusCode: number) {
return statusCode === 402 || statusCode === 429 || statusCode >= 500;
}

export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData | { error: string }>,
Expand Down Expand Up @@ -85,12 +96,27 @@ export default async function handler(
messages: messages(pdfBuffer),
schema: ResponseSchema,
abortSignal: gradingSignal,
// A degenerate generation used to run until the 60s deadline; capping
// tokens makes it fail fast instead of holding the request.
maxOutputTokens: GRADING_MAX_OUTPUT_TOKENS,
providerOptions: {
google: { thinkingConfig: { thinkingBudget: GRADING_THINKING_BUDGET } },
},
});

if (!completion) {
throw new Error("GradingError");
}

console.info(
JSON.stringify({
"http.route": "/api/grade",
outcome: "graded",
finishReason: completion.finishReason,
usage: completion.usage,
}),
);

const sanitized = sanitizeCompletion(completion);

res.status(200).json(sanitized);
Expand All @@ -111,11 +137,8 @@ export default async function handler(
return;
}

if (
e.message.includes("InvalidPDFException") ||
e.message.includes("Invalid PDF structure")
) {
console.warn(e);
if (e instanceof InvalidResumePdfError) {
console.warn(e.cause);
res.status(400).json({
error: "InvalidPDFException",
});
Expand All @@ -129,13 +152,27 @@ export default async function handler(
}

// The SDK only retries APICallError, so a gateway 5xx surfaces on the
// first attempt. It is an upstream outage, not a bug in this route.
if (GatewayError.isInstance(e) && e.statusCode >= 500) {
// first attempt. It is an upstream outage, not a bug in this route. A 402
// (team budget exceeded) or 429 (rate limited) clears up on its own too.
if (GatewayError.isInstance(e) && isGatewayUnavailable(e.statusCode)) {
console.error(e);
res.status(503).json({ error: "GradingUnavailable" });
return;
}

if (NoObjectGeneratedError.isInstance(e)) {
console.error(
JSON.stringify({
"http.route": "/api/grade",
outcome: "no_object_generated",
finishReason: e.finishReason,
usage: e.usage,
}),
);
res.status(500).json({ error: "GradingError" });
return;
}

// The client renders this string straight into a badge, so it must stay a
// stable code rather than whatever the PDF parser or the model threw.
console.error(e);
Expand Down
2 changes: 2 additions & 0 deletions src/resume-checker/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export const GRADING_TIMEOUT_MS = 60_000;
export const GRADING_MAX_OUTPUT_TOKENS = 8_192;
export const GRADING_THINKING_BUDGET = 4_096;

export const PDFJS_WARNING_PREFIX = "Warning: ";
2 changes: 1 addition & 1 deletion src/resume-checker/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export function Home() {

return (
<>
{error && <ErrorBadge error={error || mutationError} />}
<ErrorBadge error={error || mutationError} />
<Container>
<Heading lvl={1} center>
<span className="text-primary">Resume</span> Checker
Expand Down
20 changes: 19 additions & 1 deletion src/resume-checker/parse-resume.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import pdf from "pdf-parse";
import { PDFJS_WARNING_PREFIX } from "./constants";

export class InvalidResumePdfError extends Error {
constructor(readonly cause: unknown) {
super("InvalidPDFException");
this.name = "InvalidResumePdfError";
}
}

let activeParses = 0;
let restoreConsoleLog = () => {};

Expand Down Expand Up @@ -28,13 +35,24 @@ function forwardPdfjsWarnings() {
};
}

/*
* Grading only reads the PDF metadata. Rendering page text makes pdf.js load
* and sanitize every embedded font, which is where "TT: undefined function"
* warnings come from.
*/
function skipPageText() {
return "";
}

export async function parseResume(buffer: Buffer) {
if (activeParses++ === 0) {
forwardPdfjsWarnings();
}

try {
return await pdf(buffer);
return await pdf(buffer, { pagerender: skipPageText });
} catch (e) {
throw new InvalidResumePdfError(e);
} finally {
if (--activeParses === 0) {
restoreConsoleLog();
Expand Down
2 changes: 1 addition & 1 deletion src/resume-checker/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const ERROR_MESSAGES: Record<string, string> = {
BlockedResumeURL: "Ese link no apunta a un CV que podamos descargar.",
GradingTimeout: "El análisis tardó demasiado. Probá de nuevo en un rato.",
GradingUnavailable:
"El servicio de análisis no está disponible ahora. Probá de nuevo en unos minutos.",
"Perdón, no podemos analizar tu CV en este momento. Probá de nuevo en unos minutos.",
InvalidPDFException: "No pudimos leer el PDF. Probá con otro archivo.",
InvalidResumeURL: "El link no es válido. Tiene que ser un PDF con https.",
InvalidUploadRequest: "No pudimos leer el archivo que subiste.",
Expand Down
102 changes: 98 additions & 4 deletions tests/api/grade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@ import {
GatewayRateLimitError,
} from "@ai-sdk/gateway";
import { exampleResponses } from "@/resume-checker/prompts/grade";
import { generateObject } from "ai";
import { generateObject, NoObjectGeneratedError } from "ai";
import type { NextApiRequest, NextApiResponse } from "next";
import type { IncomingHttpHeaders } from "node:http";
import { Readable } from "node:stream";
import pdf from "pdf-parse";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("pdf-parse", () => ({ default: vi.fn() }));
vi.mock("ai", () => ({ generateObject: vi.fn() }));
vi.mock("ai", async (importOriginal) => ({
...(await importOriginal<typeof import("ai")>()),
generateObject: vi.fn(),
}));

const MULTIPART = { "content-type": "multipart/form-data; boundary=abc" };

Expand Down Expand Up @@ -63,6 +66,7 @@ describe("/api/grade", () => {
vi.resetAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
});

afterEach(() => {
Expand Down Expand Up @@ -97,6 +101,45 @@ describe("/api/grade", () => {
expect(vi.mocked(pdf).mock.calls[0][0]).toEqual(Buffer.from("pdf-bytes"));
});

/* An unbounded generation used to hold the request until the 60s deadline. */
it("bounds the model's output and thinking tokens", async () => {
vi.mocked(pdf).mockResolvedValueOnce({ text: "cv" } as never);
vi.mocked(generateObject).mockResolvedValueOnce({
object: { grade: "B", red_flags: [], yellow_flags: [] },
} as never);

await call(
request({ method: "POST", headers: MULTIPART, body: "pdf-bytes" }),
);

const [options] = vi.mocked(generateObject).mock.calls[0];
expect(options.maxOutputTokens).toBe(8_192);
expect(options.providerOptions).toEqual({
google: { thinkingConfig: { thinkingBudget: 4_096 } },
});
});

it("answers 500 and logs usage when the model returns no object", async () => {
vi.mocked(pdf).mockResolvedValueOnce({ text: "cv" } as never);
vi.mocked(generateObject).mockRejectedValueOnce(
new NoObjectGeneratedError({
response: {} as never,
usage: { inputTokens: 1, outputTokens: 8_192, totalTokens: 8_193 },
finishReason: "length",
}),
);

const res = await call(
request({ method: "POST", headers: MULTIPART, body: "pdf-bytes" }),
);

expect(res.status).toBe(500);
expect(res.body).toEqual({ error: "GradingError" });
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining('"finishReason":"length"'),
);
});

/* Crawlers submit the resume-checker form as a GET, so these must not be 500s. */
it.each([
["no url", {}],
Expand Down Expand Up @@ -179,6 +222,24 @@ describe("/api/grade", () => {
expect(generateObject).not.toHaveBeenCalled();
});

/* 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",
}),
);

const res = await call(
request({ method: "POST", headers: MULTIPART, body: "garbled-pdf" }),
);

expect(res.status).toBe(400);
expect(res.body).toEqual({ error: "InvalidPDFException" });
expect(generateObject).not.toHaveBeenCalled();
});

/* The client renders this straight into a badge, so it must stay a code. */
it("hides the underlying message when grading fails", async () => {
vi.mocked(pdf).mockResolvedValueOnce({ text: "cv" } as never);
Expand Down Expand Up @@ -209,7 +270,25 @@ describe("/api/grade", () => {
expect(res.body).toEqual({ error: "GradingUnavailable" });
});

it("keeps a gateway 4xx as a grading error", async () => {
/* Vercel answers 402 once the team's AI Gateway budget is spent. */
it("answers 503 when the AI gateway budget is exceeded", async () => {
vi.mocked(pdf).mockResolvedValueOnce({ text: "cv" } as never);
vi.mocked(generateObject).mockRejectedValueOnce(
new GatewayInternalServerError({
message: "Team budget exceeded. Current spend: $100.62, limit: $100.00.",
statusCode: 402,
}),
);

const res = await call(
request({ method: "POST", headers: MULTIPART, body: "pdf-bytes" }),
);

expect(res.status).toBe(503);
expect(res.body).toEqual({ error: "GradingUnavailable" });
});

it("answers 503 when the AI gateway rate limits us", async () => {
vi.mocked(pdf).mockResolvedValueOnce({ text: "cv" } as never);
vi.mocked(generateObject).mockRejectedValueOnce(
new GatewayRateLimitError(),
Expand All @@ -219,6 +298,20 @@ describe("/api/grade", () => {
request({ method: "POST", headers: MULTIPART, body: "pdf-bytes" }),
);

expect(res.status).toBe(503);
expect(res.body).toEqual({ error: "GradingUnavailable" });
});

it("keeps any other gateway 4xx as a grading error", async () => {
vi.mocked(pdf).mockResolvedValueOnce({ text: "cv" } as never);
vi.mocked(generateObject).mockRejectedValueOnce(
new GatewayInternalServerError({ statusCode: 400 }),
);

const res = await call(
request({ method: "POST", headers: MULTIPART, body: "pdf-bytes" }),
);

expect(res.status).toBe(500);
expect(res.body).toEqual({ error: "GradingError" });
});
Expand All @@ -242,7 +335,8 @@ describe("/api/grade", () => {
});

it("answers 500 to a thrown non-Error", async () => {
vi.mocked(pdf).mockRejectedValueOnce("boom");
vi.mocked(pdf).mockResolvedValueOnce({ text: "cv" } as never);
vi.mocked(generateObject).mockRejectedValueOnce("boom");

const res = await call(
request({ method: "POST", headers: MULTIPART, body: "pdf-bytes" }),
Expand Down
10 changes: 10 additions & 0 deletions tests/resume-checker/parse-resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ describe("parseResume", () => {
expect(log).not.toHaveBeenCalledWith("Warning: TT: undefined function: 21");
});

/* Rendering page text loads every font, which is what emits "TT:" warnings. */
it("reads the metadata without rendering page text", async () => {
vi.mocked(pdf).mockResolvedValueOnce({ text: "" } as never);

await parseResume(Buffer.from("pdf"));

const [, options] = vi.mocked(pdf).mock.calls[0];
expect(await options?.pagerender?.({})).toBe("");
});

it("restores console.log once the last concurrent parse settles", async () => {
let finishFirst = () => {};
vi.mocked(pdf)
Expand Down
2 changes: 1 addition & 1 deletion tests/resume-checker/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe("getErrorMessage", () => {
await expect(
getErrorMessage(json(503, { error: "GradingUnavailable" })),
).resolves.toBe(
"El servicio de análisis no está disponible ahora. Probá de nuevo en unos minutos.",
"Perdón, no podemos analizar tu CV en este momento. Probá de nuevo en unos minutos.",
);
});

Expand Down
Loading