Files
Reactive-Resume/src/integrations/orpc/router/ai.ts
T
Platinum1154 a4e7d6680d feat: add Chinese font options (#2905)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-04-26 00:32:42 +02:00

314 lines
10 KiB
TypeScript

import { ORPCError } from "@orpc/client";
import { type } from "@orpc/server";
import { AISDKError, type UIMessage } from "ai";
import z, { flattenError, ZodError } from "zod";
import { jobResultSchema } from "@/schema/jobs";
import { resumeAnalysisSchema, storedResumeAnalysisSchema } from "@/schema/resume/analysis";
import { type ResumeData, resumeDataSchema } from "@/schema/resume/data";
import { tailorOutputSchema } from "@/schema/tailor";
import { protectedProcedure } from "../context";
import { aiRequestRateLimit } from "../rate-limit";
import { aiCredentialsSchema, aiService, fileInputSchema } from "../services/ai";
import { resumeService } from "../services/resume";
type AIProvider = z.infer<typeof aiCredentialsSchema.shape.provider>;
function isInvalidAiBaseUrlError(error: unknown): boolean {
return error instanceof Error && error.message === "INVALID_AI_BASE_URL";
}
function isAiProviderGatewayError(error: unknown): boolean {
return error instanceof AISDKError;
}
function throwAiProviderGatewayError(): never {
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider." });
}
function throwAiProviderConfigError(): never {
throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." });
}
function throwResumeStructureError(error: ZodError): never {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid resume data structure",
cause: flattenError(error),
});
}
export const aiRouter = {
testConnection: protectedProcedure
.route({
method: "POST",
path: "/ai/test-connection",
tags: ["AI"],
operationId: "testAiConnection",
summary: "Test AI provider connection",
description:
"Validates the connection to an AI provider by sending a simple test prompt. Requires the provider type, model name, API key, and an optional base URL. Supported providers: OpenAI, Anthropic, Google Gemini, Ollama, and Vercel AI Gateway. Requires authentication.",
successDescription: "The AI provider connection was successful.",
})
.input(
z.object({
...aiCredentialsSchema.shape,
}),
)
.use(aiRequestRateLimit)
.errors({
BAD_GATEWAY: {
message: "The AI provider returned an error or is unreachable.",
status: 502,
},
BAD_REQUEST: {
message: "Invalid AI provider configuration.",
status: 400,
},
})
.handler(async ({ input }) => {
try {
return await aiService.testConnection(input);
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
throw error;
}
}),
parsePdf: protectedProcedure
.route({
method: "POST",
path: "/ai/parse-pdf",
tags: ["AI"],
operationId: "parseResumePdf",
summary: "Parse a PDF file into resume data",
description:
"Extracts structured resume data from a PDF file using the specified AI provider. The file should be sent as a base64-encoded string along with AI provider credentials. Returns a complete ResumeData object. Requires authentication.",
successDescription: "The PDF was successfully parsed into structured resume data.",
})
.input(
z.object({
...aiCredentialsSchema.shape,
file: fileInputSchema,
}),
)
.use(aiRequestRateLimit)
.errors({
BAD_GATEWAY: {
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 {
return await aiService.parsePdf(input);
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) throwResumeStructureError(error);
throw error;
}
}),
parseDocx: protectedProcedure
.route({
method: "POST",
path: "/ai/parse-docx",
tags: ["AI"],
operationId: "parseResumeDocx",
summary: "Parse a DOCX file into resume data",
description:
"Extracts structured resume data from a DOCX or DOC file using the specified AI provider. The file should be sent as a base64-encoded string along with AI provider credentials and the document's media type. Returns a complete ResumeData object. Requires authentication.",
successDescription: "The DOCX was successfully parsed into structured resume data.",
})
.input(
z.object({
...aiCredentialsSchema.shape,
file: fileInputSchema,
mediaType: z.enum([
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
]),
}),
)
.use(aiRequestRateLimit)
.errors({
BAD_GATEWAY: {
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 {
return await aiService.parseDocx(input);
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) throwResumeStructureError(error);
throw error;
}
}),
chat: protectedProcedure
.route({
method: "POST",
path: "/ai/chat",
tags: ["AI"],
operationId: "aiChat",
summary: "Chat with AI to modify resume",
description:
"Streams a chat response from the configured AI provider. The LLM can call the patch_resume tool to generate JSON Patch operations that modify the resume. Requires authentication and AI provider credentials.",
})
.input(
type<{
provider: AIProvider;
model: string;
apiKey: string;
baseURL: string;
messages: UIMessage[];
resumeData: ResumeData;
}>(),
)
.use(aiRequestRateLimit)
.handler(async ({ input }) => {
try {
return await aiService.chat(input);
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
throw error;
}
}),
tailorResume: protectedProcedure
.route({
method: "POST",
path: "/ai/tailor-resume",
tags: ["AI"],
operationId: "tailorResume",
summary: "Auto-tailor resume for a job posting",
description:
"Uses AI to automatically tailor a resume for a specific job posting. Rewrites the summary, adjusts experience descriptions, and curates skills for ATS optimization. Returns structured modifications as a simplified output object. Requires authentication and AI credentials.",
successDescription: "Structured tailoring output returned successfully.",
})
.input(
z.object({
...aiCredentialsSchema.shape,
resumeData: resumeDataSchema,
job: jobResultSchema,
}),
)
.use(aiRequestRateLimit)
.output(tailorOutputSchema)
.errors({
BAD_GATEWAY: {
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 {
return await aiService.tailorResume(input);
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid resume data structure",
cause: flattenError(error),
});
}
throw error;
}
}),
analyzeResume: protectedProcedure
.route({
method: "POST",
path: "/ai/analyze-resume",
tags: ["AI"],
operationId: "analyzeResume",
summary: "Analyze resume and persist latest analysis",
description:
"Uses AI to analyze the current resume and returns a structured analysis with scorecard, strengths, and improvement suggestions. The latest analysis is persisted and can be fetched later. Requires authentication and AI credentials.",
successDescription: "Structured resume analysis returned and persisted successfully.",
})
.input(
z.object({
...aiCredentialsSchema.shape,
resumeId: z.string(),
resumeData: resumeDataSchema,
}),
)
.use(aiRequestRateLimit)
.output(storedResumeAnalysisSchema)
.errors({
BAD_GATEWAY: {
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 ({ context, input }) => {
try {
const analysis = resumeAnalysisSchema.parse(
await aiService.analyzeResume({
provider: input.provider,
model: input.model,
apiKey: input.apiKey,
baseURL: input.baseURL,
resumeData: input.resumeData,
}),
);
return await resumeService.analysis.upsert({
id: input.resumeId,
userId: context.user.id,
analysis: {
...analysis,
updatedAt: new Date(),
modelMeta: {
provider: input.provider,
model: input.model,
},
},
});
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid resume analysis structure",
cause: flattenError(error),
});
}
throw error;
}
}),
};