fix: resolve multi-page PDF crashes and Gemini API ingestion errors (#2781)

* fix: resolve multi-page PDF crashes and Gemini API ingestion errors

* fix type errors

* refactor: address PR review feedback and prevent call stack recursion in regex payload scanner

---------

Co-authored-by: Ofir <ofir@example.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
This commit is contained in:
obitton
2026-03-16 18:08:40 -04:00
committed by GitHub
parent ea8fd838d9
commit fb61bb4a63
8 changed files with 818 additions and 278 deletions
+11 -7
View File
@@ -62,12 +62,12 @@
"@tanstack/react-router-ssr-query": "^1.166.2",
"@tanstack/react-start": "^1.166.2",
"@tanstack/zod-adapter": "^1.166.2",
"@tiptap/extension-highlight": "^3.20.0",
"@tiptap/extension-table": "^3.20.0",
"@tiptap/extension-text-align": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"@tiptap/extension-highlight": "^3.20.1",
"@tiptap/extension-table": "^3.20.1",
"@tiptap/extension-text-align": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/react": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"ai": "^6.0.116",
"ai-sdk-ollama": "^3.8.0",
"bcrypt": "^6.0.0",
@@ -85,9 +85,12 @@
"immer": "^11.1.4",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"jsonrepair": "^3.13.2",
"mammoth": "^1.11.0",
"monaco-editor": "^0.55.1",
"motion": "^12.35.0",
"nodemailer": "^8.0.1",
"pdf-parse": "^2.4.5",
"pg": "^8.20.0",
"puppeteer-core": "^24.38.0",
"qrcode.react": "^4.2.0",
@@ -156,7 +159,8 @@
"onlyBuiltDependencies": [
"bcrypt",
"esbuild",
"sharp"
"sharp",
"pdf-parse"
]
}
}
+502 -207
View File
File diff suppressed because it is too large Load Diff
+22 -5
View File
@@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DownloadSimpleIcon, FileIcon, UploadSimpleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { toast } from "sonner";
@@ -63,7 +64,21 @@ const formSchema = z.discriminatedUnion("type", [
type FormValues = z.infer<typeof formSchema>;
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// remove data URL prefix (e.g., "data:application/pdf;base64," or "data:application/vnd...;base64,")
resolve(result.split(",")[1]);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
const navigate = useNavigate();
const { enabled: isAIEnabled, provider, model, apiKey, baseURL } = useAIStore();
const closeDialog = useDialogStore((state) => state.closeDialog);
@@ -135,8 +150,8 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
if (!isAIEnabled)
throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`);
const arrayBuffer = await values.file.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
// const arrayBuffer = await values.file.arrayBuffer();
const base64 = await fileToBase64(values.file);
data = await client.ai.parsePdf({
provider,
@@ -151,8 +166,9 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
if (!isAIEnabled)
throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`);
const arrayBuffer = await values.file.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
// const arrayBuffer = await values.file.arrayBuffer();
const base64 = await fileToBase64(values.file);
const mediaType =
values.file.type === "application/msword"
? ("application/msword" as const)
@@ -170,9 +186,10 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
if (!data) throw new Error("No data was returned from the AI provider.");
await importResume({ data });
const id = await importResume({ data });
toast.success(t`Your resume has been imported successfully.`, { id: toastId, description: null });
closeDialog();
navigate({ to: `/builder/$resumeId`, params: { resumeId: id } });
} catch (error: unknown) {
if (error instanceof Error) {
toast.error(error.message, { id: toastId, description: null });
+18 -4
View File
@@ -2,10 +2,10 @@ import { ORPCError } from "@orpc/client";
import { type } from "@orpc/server";
import { AISDKError, type UIMessage } from "ai";
import { OllamaError } from "ai-sdk-ollama";
import z, { ZodError } from "zod";
import z, { flattenError, ZodError } from "zod";
import type { ResumeData } from "@/schema/resume/data";
import { protectedProcedure } from "../context";
import { aiCredentialsSchema, aiProviderSchema, aiService, fileInputSchema, formatZodError } from "../services/ai";
import { aiCredentialsSchema, aiProviderSchema, aiService, fileInputSchema } from "../services/ai";
type AIProvider = z.infer<typeof aiProviderSchema>;
@@ -69,6 +69,10 @@ export const aiRouter = {
message: "The AI provider returned an error or is unreachable.",
status: 502,
},
BAD_REQUEST: {
message: "The AI returned an improperly formatted structure.",
status: 400,
},
})
.handler(async ({ input }): Promise<ResumeData> => {
try {
@@ -79,7 +83,10 @@ export const aiRouter = {
}
if (error instanceof ZodError) {
throw new Error(formatZodError(error));
throw new ORPCError("BAD_REQUEST", {
message: "Invalid resume data structure",
cause: flattenError(error),
});
}
throw error;
}
@@ -111,6 +118,10 @@ export const aiRouter = {
message: "The AI provider returned an error or is unreachable.",
status: 502,
},
BAD_REQUEST: {
message: "The AI returned an improperly formatted structure.",
status: 400,
},
})
.handler(async ({ input }) => {
try {
@@ -121,7 +132,10 @@ export const aiRouter = {
}
if (error instanceof ZodError) {
throw new Error(formatZodError(error));
throw new ORPCError("BAD_REQUEST", {
message: "Invalid resume data structure",
cause: flattenError(error),
});
}
throw error;
@@ -0,0 +1,14 @@
import { extractRawText } from "mammoth";
import { PDFParse } from "pdf-parse";
export async function extractPdfText(base64Data: string): Promise<string> {
const pdfParse = new PDFParse({ data: base64Data });
const data = await pdfParse.getText();
return data.text;
}
export async function extractDocxText(base64Data: string): Promise<string> {
const buffer = Buffer.from(base64Data, "base64");
const data = await extractRawText({ buffer });
return data.value;
}
+225 -42
View File
@@ -13,9 +13,9 @@ import {
type UIMessage,
} from "ai";
import { createOllama } from "ai-sdk-ollama";
import { jsonrepair } from "jsonrepair";
import { match } from "ts-pattern";
import type { ZodError } from "zod";
import z, { flattenError } from "zod";
import z, { flattenError, ZodError } from "zod";
import chatSystemPromptTemplate from "@/integrations/ai/prompts/chat-system.md?raw";
import docxParserSystemPrompt from "@/integrations/ai/prompts/docx-parser-system.md?raw";
import docxParserUserPrompt from "@/integrations/ai/prompts/docx-parser-user.md?raw";
@@ -28,6 +28,204 @@ import {
} from "@/integrations/ai/tools/patch-resume";
import type { ResumeData } from "@/schema/resume/data";
import { defaultResumeData, resumeDataSchema } from "@/schema/resume/data";
import { isObject } from "@/utils/sanitize";
import { extractDocxText, extractPdfText } from "./ai.server";
const aiExtractionTemplate = {
...defaultResumeData,
basics: {
...defaultResumeData.basics,
customFields: [{ id: "", icon: "", text: "", link: "" }],
},
sections: {
...defaultResumeData.sections,
profiles: {
...defaultResumeData.sections.profiles,
items: [{ id: "", hidden: false, icon: "", network: "", username: "", website: { url: "", label: "" } }],
},
experience: {
...defaultResumeData.sections.experience,
items: [
{
id: "",
hidden: false,
company: "",
position: "",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
},
],
},
education: {
...defaultResumeData.sections.education,
items: [
{
id: "",
hidden: false,
school: "",
degree: "",
area: "",
grade: "",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
},
],
},
projects: {
...defaultResumeData.sections.projects,
items: [{ id: "", hidden: false, name: "", period: "", website: { url: "", label: "" }, description: "" }],
},
skills: {
...defaultResumeData.sections.skills,
items: [{ id: "", hidden: false, icon: "", name: "", proficiency: "", level: 0, keywords: [] }],
},
languages: {
...defaultResumeData.sections.languages,
items: [{ id: "", hidden: false, language: "", fluency: "", level: 0 }],
},
interests: {
...defaultResumeData.sections.interests,
items: [{ id: "", hidden: false, icon: "", name: "", keywords: [] }],
},
awards: {
...defaultResumeData.sections.awards,
items: [
{ id: "", hidden: false, title: "", awarder: "", date: "", website: { url: "", label: "" }, description: "" },
],
},
certifications: {
...defaultResumeData.sections.certifications,
items: [
{ id: "", hidden: false, title: "", issuer: "", date: "", website: { url: "", label: "" }, description: "" },
],
},
publications: {
...defaultResumeData.sections.publications,
items: [
{ id: "", hidden: false, title: "", publisher: "", date: "", website: { url: "", label: "" }, description: "" },
],
},
volunteer: {
...defaultResumeData.sections.volunteer,
items: [
{
id: "",
hidden: false,
organization: "",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
},
],
},
references: {
...defaultResumeData.sections.references,
items: [
{ id: "", hidden: false, name: "", position: "", website: { url: "", label: "" }, phone: "", description: "" },
],
},
},
};
/**
* Merges two objects recursively, filling in missing properties in the target object
* with values from the source object, but does not overwrite existing properties in the target
* unless the source provides a defined, non-null value.
*
* Both target and source must be plain objects (Record<string, unknown>).
* This function does not mutate either argument; returns a new object.
*
* @param target - The object to merge into (existing values take precedence)
* @param source - The object providing default values
* @returns The merged object
*/
function mergeDefaults<T extends Record<string, unknown>, S extends Record<string, unknown>>(
target: T,
source: S,
): T & S {
if (!isObject(target) || !isObject(source)) {
// Use source value if defined (non-null, non-undefined), else fallback to target
return (source !== undefined && source !== null ? source : target) as T & S;
}
const output: Record<string, unknown> = { ...target };
for (const key of Object.keys(source)) {
const sourceValue = source[key];
if (sourceValue === undefined || sourceValue === null) {
continue;
}
const targetValue = target[key];
if (isObject(sourceValue) && isObject(targetValue)) {
output[key] = mergeDefaults(targetValue as Record<string, unknown>, sourceValue as Record<string, unknown>);
} else if (isObject(sourceValue) && (targetValue === undefined || targetValue === null)) {
// Fill with source object only if target does not have it
output[key] = sourceValue;
} else if (!isObject(sourceValue)) {
output[key] = sourceValue;
} else if (targetValue === undefined) {
output[key] = sourceValue;
}
}
return output as T & S;
}
function logAndRethrow(context: string, error: unknown): never {
if (error instanceof Error) {
console.error(`${context}:`, error.message);
throw error;
}
console.error(`Unknown error in ${context}:`, error);
throw new Error(`An unknown error occurred during ${context}.`);
}
function parseAndValidateResumeJson(resultText: string): ResumeData {
let jsonString = resultText;
const firstCurly = jsonString.indexOf("{");
const firstSquare = jsonString.indexOf("[");
const lastCurly = jsonString.lastIndexOf("}");
const lastSquare = jsonString.lastIndexOf("]");
let firstIndex = -1;
if (firstCurly !== -1 && firstSquare !== -1) {
firstIndex = Math.min(firstCurly, firstSquare);
} else {
firstIndex = Math.max(firstCurly, firstSquare);
}
const lastIndex = Math.max(lastCurly, lastSquare);
if (firstIndex !== -1 && lastIndex !== -1 && lastIndex >= firstIndex) {
jsonString = jsonString.substring(firstIndex, lastIndex + 1);
}
try {
const repairedJson = jsonrepair(jsonString);
const parsedJson = JSON.parse(repairedJson);
const mergedData = mergeDefaults(defaultResumeData, parsedJson);
return resumeDataSchema.parse({
...mergedData,
customSections: [],
picture: defaultResumeData.picture,
metadata: defaultResumeData.metadata,
});
} catch (error: unknown) {
if (error instanceof ZodError) {
console.error("Zod Validation Errors:", JSON.stringify(flattenError(error), null, 2));
throw error;
}
console.error("Unknown error:", error);
throw new Error("An unknown error occurred while validating the merged resume data.");
}
}
export const aiProviderSchema = z.enum(["ollama", "openai", "gemini", "anthropic", "vercel-ai-gateway"]);
@@ -89,35 +287,28 @@ type ParsePdfInput = z.infer<typeof aiCredentialsSchema> & {
async function parsePdf(input: ParsePdfInput): Promise<ResumeData> {
const model = getModel(input);
const pdfText = await extractPdfText(input.file.data).catch((error: unknown) =>
logAndRethrow("Failed to parse PDF locally", error),
);
const result = await generateText({
model,
output: Output.object({ schema: resumeDataSchema }),
messages: [
{
role: "system",
content: pdfParserSystemPrompt,
content:
pdfParserSystemPrompt +
"\n\nIMPORTANT: You must return ONLY raw valid JSON. Do not return markdown, do not return explanations. Just the JSON object. Use the following JSON as a template and fill in the extracted values. For arrays, you MUST use the exact key names shown in the template (e.g. use 'description' instead of 'summary', 'website' instead of 'url'):\n\n" +
JSON.stringify(aiExtractionTemplate, null, 2),
},
{
role: "user",
content: [
{ type: "text", text: pdfParserUserPrompt },
{
type: "file",
filename: input.file.name,
mediaType: "application/pdf",
data: input.file.data,
},
],
content: `${pdfParserUserPrompt}\n\n--- EXTRACTED RESUME TEXT ---\n${pdfText}\n--- END OF EXTRACTED TEXT ---`,
},
],
});
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
return resumeDataSchema.parse({
...result.output,
customSections: [],
picture: defaultResumeData.picture,
metadata: defaultResumeData.metadata,
});
return parseAndValidateResumeJson(result.text);
}
type ParseDocxInput = z.infer<typeof aiCredentialsSchema> & {
@@ -128,36 +319,28 @@ type ParseDocxInput = z.infer<typeof aiCredentialsSchema> & {
async function parseDocx(input: ParseDocxInput): Promise<ResumeData> {
const model = getModel(input);
const docxText = await extractDocxText(input.file.data).catch((error: unknown) =>
logAndRethrow("Failed to parse DOCX locally", error),
);
const result = await generateText({
model,
output: Output.object({ schema: resumeDataSchema }),
messages: [
{ role: "system", content: docxParserSystemPrompt },
{
role: "system",
content:
docxParserSystemPrompt +
"\n\nIMPORTANT: You must return ONLY raw valid JSON. Do not return markdown, do not return explanations. Just the JSON object. Use the following JSON as a template and fill in the extracted values. For arrays, you MUST use the exact key names shown in the template (e.g. use 'description' instead of 'summary', 'website' instead of 'url'):\n\n" +
JSON.stringify(aiExtractionTemplate, null, 2),
},
{
role: "user",
content: [
{ type: "text", text: docxParserUserPrompt },
{
type: "file",
filename: input.file.name,
mediaType: input.mediaType,
data: input.file.data,
},
],
content: `${docxParserUserPrompt}\n\n--- EXTRACTED RESUME TEXT ---\n${docxText}\n--- END OF EXTRACTED TEXT ---`,
},
],
});
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
return resumeDataSchema.parse({
...result.output,
customSections: [],
picture: defaultResumeData.picture,
metadata: defaultResumeData.metadata,
});
}
export function formatZodError(error: ZodError): string {
return JSON.stringify(flattenError(error));
return parseAndValidateResumeJson(result.text);
}
function buildChatSystemPrompt(resumeData: ResumeData): string {
@@ -47,7 +47,10 @@ export function BuilderSidebarRight() {
<>
<SidebarEdge scrollAreaRef={scrollAreaRef} />
<ScrollArea ref={scrollAreaRef} className="@container h-[calc(100svh-3.5rem)] bg-background overflow-hidden sm:me-12">
<ScrollArea
ref={scrollAreaRef}
className="@container h-[calc(100svh-3.5rem)] overflow-hidden bg-background sm:me-12"
>
<div className="space-y-4 p-4">
{rightSidebarSections.map((section) => (
<Fragment key={section}>
@@ -85,18 +88,18 @@ function SidebarEdge({ scrollAreaRef }: SidebarEdgeProps) {
<BuilderSidebarEdge side="right">
<div className="no-scrollbar min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden">
<div className="flex min-h-full flex-col items-center justify-center gap-y-2">
{rightSidebarSections.map((section) => (
<Button
key={section}
size="icon"
variant="ghost"
title={getSectionTitle(section)}
onClick={() => scrollToSection(section)}
>
{getSectionIcon(section)}
</Button>
))}
</div>
{rightSidebarSections.map((section) => (
<Button
key={section}
size="icon"
variant="ghost"
title={getSectionTitle(section)}
onClick={() => scrollToSection(section)}
>
{getSectionIcon(section)}
</Button>
))}
</div>
</div>
</BuilderSidebarEdge>
);
+10
View File
@@ -109,3 +109,13 @@ export function sanitizeCss(css: string): string {
return sanitized;
}
/**
* Checks if the given value is a plain JSON object (not null, not array, not other types).
*
* @param value - The value to check.
* @returns True if the value is a plain object, false otherwise.
*/
export function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}