mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
Some providers (notably Anthropic via proxies) wrap JSON output in markdown code fences (```json ... ```), causing Output.object to throw NoObjectGeneratedError / JSONParseError. Replace Output.object with manual JSON boundary extraction that works regardless of fencing. Also propagate the original AISDKError as cause in throwAiProviderGatewayError for better diagnostics.
292 lines
9.7 KiB
TypeScript
292 lines
9.7 KiB
TypeScript
import type { AIProvider } from "@reactive-resume/ai/types";
|
|
import type { ResumeAnalysis } from "@reactive-resume/schema/resume/analysis";
|
|
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
|
import type { ModelMessage, UIMessage } from "ai";
|
|
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
|
import { createOpenAI } from "@ai-sdk/openai";
|
|
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
import { streamToEventIterator } from "@orpc/server";
|
|
import { convertToModelMessages, createGateway, generateText, Output, stepCountIs, streamText, tool } from "ai";
|
|
import { createOllama } from "ollama-ai-provider-v2";
|
|
import { match } from "ts-pattern";
|
|
import { z } from "zod";
|
|
import {
|
|
analyzeResumeSystemPrompt as analyzeResumeSystemPromptTemplate,
|
|
chatSystemPromptTemplate,
|
|
docxParserSystemPrompt,
|
|
docxParserUserPrompt,
|
|
pdfParserSystemPrompt,
|
|
pdfParserUserPrompt,
|
|
} from "@reactive-resume/ai/prompts";
|
|
import { buildAiExtractionTemplate } from "@reactive-resume/ai/resume/extraction-template";
|
|
import { sanitizeAndParseResumeJson } from "@reactive-resume/ai/resume/sanitize";
|
|
import {
|
|
normalizeResumePatchProposals,
|
|
resumePatchProposalToolInputSchema,
|
|
resumePatchProposalToolOutputSchema,
|
|
} from "@reactive-resume/ai/tools/patch-proposal";
|
|
import { aiProviderSchema } from "@reactive-resume/ai/types";
|
|
import { applyResumePatches } from "@reactive-resume/resume/patch";
|
|
import { resumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
|
|
import { supportsProviderNativeWebSearch } from "./capabilities";
|
|
import { resolveAiBaseUrl } from "./url-policy";
|
|
|
|
const aiExtractionTemplate = buildAiExtractionTemplate();
|
|
|
|
function logAndRethrow(context: string, error: unknown): never {
|
|
if (error instanceof Error) {
|
|
console.error(`${context}:`, error);
|
|
throw error;
|
|
}
|
|
|
|
console.error(`${context}:`, error);
|
|
throw new Error(`An unknown error occurred during ${context}.`);
|
|
}
|
|
|
|
function parseAndValidateResumeJson(resultText: string): ResumeData {
|
|
const { data, diagnostics } = sanitizeAndParseResumeJson(resultText);
|
|
|
|
if (diagnostics.coercions.length === 0 && diagnostics.droppedSectionItems.length === 0) return data;
|
|
|
|
const droppedBySection = diagnostics.droppedSectionItems.reduce<Record<string, number>>((acc, item) => {
|
|
acc[item.section] = (acc[item.section] ?? 0) + 1;
|
|
return acc;
|
|
}, {});
|
|
|
|
console.info("AI resume sanitization diagnostics", {
|
|
coercions: diagnostics.coercions.length,
|
|
droppedBySection,
|
|
salvageApplied: diagnostics.salvageApplied,
|
|
});
|
|
|
|
return data;
|
|
}
|
|
|
|
type GetModelInput = {
|
|
provider: AIProvider;
|
|
model: string;
|
|
apiKey: string;
|
|
baseURL?: string;
|
|
};
|
|
|
|
const MAX_AI_FILE_BYTES = 10 * 1024 * 1024; // 10MB
|
|
const MAX_AI_FILE_BASE64_CHARS = Math.ceil((MAX_AI_FILE_BYTES * 4) / 3) + 4;
|
|
|
|
export function getModel(input: GetModelInput) {
|
|
const { provider, model, apiKey } = input;
|
|
const baseURL = resolveAiBaseUrl(input);
|
|
|
|
return match(provider)
|
|
.with("openai", () => createOpenAI({ apiKey, baseURL }).chat(model))
|
|
.with("anthropic", () => createAnthropic({ apiKey, baseURL }).languageModel(model))
|
|
.with("gemini", () => createGoogleGenerativeAI({ apiKey, baseURL }).languageModel(model))
|
|
.with("vercel-ai-gateway", () => createGateway({ apiKey, baseURL }).languageModel(model))
|
|
.with("openrouter", () => createOpenAICompatible({ name: "openrouter", apiKey, baseURL }).languageModel(model))
|
|
.with("openai-compatible", () =>
|
|
createOpenAICompatible({ name: "openai-compatible", apiKey, baseURL }).languageModel(model),
|
|
)
|
|
.with("ollama", () => {
|
|
const ollama = createOllama({
|
|
name: "ollama",
|
|
baseURL,
|
|
...(apiKey ? { headers: { Authorization: `Bearer ${apiKey}` } } : {}),
|
|
});
|
|
|
|
return ollama.languageModel(model);
|
|
})
|
|
.exhaustive();
|
|
}
|
|
|
|
export function getAgentModel(input: GetModelInput) {
|
|
if (!supportsProviderNativeWebSearch(input)) return getModel(input);
|
|
|
|
return createOpenAI({ apiKey: input.apiKey, baseURL: resolveAiBaseUrl(input) }).responses(input.model);
|
|
}
|
|
|
|
const aiCredentialsSchema = z.object({
|
|
provider: aiProviderSchema,
|
|
model: z.string().trim().min(1),
|
|
apiKey: z.string().trim().min(1),
|
|
baseURL: z.string().optional().default(""),
|
|
});
|
|
|
|
export const fileInputSchema = z.object({
|
|
name: z.string(),
|
|
data: z.string().max(MAX_AI_FILE_BASE64_CHARS, "File is too large. Maximum size is 10MB."),
|
|
});
|
|
|
|
type TestConnectionInput = z.infer<typeof aiCredentialsSchema>;
|
|
|
|
export async function testConnection(input: TestConnectionInput): Promise<boolean> {
|
|
const RESPONSE_OK = "1";
|
|
|
|
const result = await generateText({
|
|
model: getModel(input),
|
|
output: Output.choice({ options: [RESPONSE_OK] }),
|
|
messages: [{ role: "user", content: `Respond only with JSON Object: { "result": "${RESPONSE_OK}" }` }],
|
|
});
|
|
|
|
return result.output === RESPONSE_OK;
|
|
}
|
|
|
|
type ParsePdfInput = z.infer<typeof aiCredentialsSchema> & {
|
|
file: z.infer<typeof fileInputSchema>;
|
|
};
|
|
|
|
type BuildResumeParsingMessagesInput = {
|
|
systemPrompt: string;
|
|
userPrompt: string;
|
|
file: z.infer<typeof fileInputSchema>;
|
|
mediaType: string;
|
|
};
|
|
|
|
function buildResumeParsingMessages({
|
|
systemPrompt,
|
|
userPrompt,
|
|
file,
|
|
mediaType,
|
|
}: BuildResumeParsingMessagesInput): ModelMessage[] {
|
|
return [
|
|
{
|
|
role: "system",
|
|
content: `${systemPrompt}\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: userPrompt },
|
|
{ type: "file", data: file.data, mediaType, filename: file.name },
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
async function parsePdf(input: ParsePdfInput): Promise<ResumeData> {
|
|
const model = getModel(input);
|
|
|
|
const result = await generateText({
|
|
model,
|
|
messages: buildResumeParsingMessages({
|
|
systemPrompt: pdfParserSystemPrompt,
|
|
userPrompt: pdfParserUserPrompt,
|
|
file: input.file,
|
|
mediaType: "application/pdf",
|
|
}),
|
|
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
|
|
|
|
return parseAndValidateResumeJson(result.text);
|
|
}
|
|
|
|
type ParseDocxInput = z.infer<typeof aiCredentialsSchema> & {
|
|
file: z.infer<typeof fileInputSchema>;
|
|
mediaType: "application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
};
|
|
|
|
async function parseDocx(input: ParseDocxInput): Promise<ResumeData> {
|
|
const model = getModel(input);
|
|
|
|
const result = await generateText({
|
|
model,
|
|
messages: buildResumeParsingMessages({
|
|
systemPrompt: docxParserSystemPrompt,
|
|
userPrompt: docxParserUserPrompt,
|
|
file: input.file,
|
|
mediaType: input.mediaType,
|
|
}),
|
|
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
|
|
|
|
return parseAndValidateResumeJson(result.text);
|
|
}
|
|
|
|
function buildChatSystemPrompt(resumeData: ResumeData): string {
|
|
return chatSystemPromptTemplate.replace("{{RESUME_DATA}}", JSON.stringify(resumeData, null, 2));
|
|
}
|
|
|
|
type ChatInput = z.infer<typeof aiCredentialsSchema> & {
|
|
messages: UIMessage[];
|
|
resumeData: ResumeData;
|
|
resumeUpdatedAt: Date;
|
|
};
|
|
|
|
async function chat(input: ChatInput) {
|
|
const model = getModel(input);
|
|
const systemPrompt = buildChatSystemPrompt(input.resumeData);
|
|
|
|
const result = streamText({
|
|
model,
|
|
system: systemPrompt,
|
|
messages: await convertToModelMessages(input.messages),
|
|
tools: {
|
|
propose_resume_patches: tool({
|
|
description:
|
|
"Return one or more cohesive resume change proposals. Each proposal must include a title, optional summary, and valid JSON Patch operations against the current resume data. The tool validates but does not apply changes.",
|
|
inputSchema: resumePatchProposalToolInputSchema,
|
|
outputSchema: resumePatchProposalToolOutputSchema,
|
|
execute: async (toolInput) => {
|
|
const proposals = normalizeResumePatchProposals(toolInput, input.resumeUpdatedAt);
|
|
|
|
for (const proposal of proposals) {
|
|
applyResumePatches(input.resumeData, proposal.operations);
|
|
}
|
|
|
|
return { proposals };
|
|
},
|
|
}),
|
|
},
|
|
stopWhen: stepCountIs(3),
|
|
});
|
|
|
|
return streamToEventIterator(result.toUIMessageStream());
|
|
}
|
|
|
|
type AnalyzeResumeInput = z.infer<typeof aiCredentialsSchema> & {
|
|
resumeData: ResumeData;
|
|
};
|
|
|
|
function buildAnalyzeResumeSystemPrompt(resumeData: ResumeData): string {
|
|
return `${analyzeResumeSystemPromptTemplate}\n\n## Resume Data\n\n${JSON.stringify(resumeData, null, 2)}`;
|
|
}
|
|
|
|
/** Sends resume data to the AI provider and returns a structured analysis, parsing raw JSON from the response text. */
|
|
async function analyzeResume(input: AnalyzeResumeInput): Promise<ResumeAnalysis> {
|
|
const model = getModel(input);
|
|
const systemPrompt = buildAnalyzeResumeSystemPrompt(input.resumeData);
|
|
|
|
const result = await generateText({
|
|
model,
|
|
messages: [
|
|
{ role: "system", content: systemPrompt },
|
|
{
|
|
role: "user",
|
|
content:
|
|
"Analyze this resume and return a structured report with scorecard, overall score, strengths, and actionable suggestions. Return ONLY raw JSON, no markdown fences or explanations.",
|
|
},
|
|
],
|
|
});
|
|
|
|
const text = result.text;
|
|
const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
|
const candidate = fenceMatch?.[1] ?? text;
|
|
|
|
const firstBrace = candidate.indexOf("{");
|
|
const lastBrace = candidate.lastIndexOf("}");
|
|
|
|
if (firstBrace === -1 || lastBrace === -1 || lastBrace < firstBrace) {
|
|
throw new Error("AI returned no structured analysis output.");
|
|
}
|
|
|
|
const jsonString = candidate.substring(firstBrace, lastBrace + 1);
|
|
const parsed = JSON.parse(jsonString);
|
|
|
|
return resumeAnalysisSchema.parse(parsed);
|
|
}
|
|
|
|
export const aiService = {
|
|
analyzeResume,
|
|
chat,
|
|
parseDocx,
|
|
parsePdf,
|
|
testConnection,
|
|
};
|