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 { inflateRawSync } from "node:zlib"; import { createAnthropic } from "@ai-sdk/anthropic"; import { createCerebras } from "@ai-sdk/cerebras"; import { createCohere } from "@ai-sdk/cohere"; import { createDeepSeek } from "@ai-sdk/deepseek"; import { createFireworks } from "@ai-sdk/fireworks"; import { createGoogleGenerativeAI } from "@ai-sdk/google"; import { createGroq } from "@ai-sdk/groq"; import { createMistral } from "@ai-sdk/mistral"; import { createOpenAI } from "@ai-sdk/openai"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { createPerplexity } from "@ai-sdk/perplexity"; import { createTogetherAI } from "@ai-sdk/togetherai"; import { createXai } from "@ai-sdk/xai"; import { streamToEventIterator } from "@orpc/server"; import { convertToModelMessages, createGateway, generateText, 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>((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; const TEST_CONNECTION_MAX_OUTPUT_TOKENS = 128; const DOCX_DOCUMENT_XML_PATH = "word/document.xml"; const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50; const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50; const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50; const ZIP_STORED_METHOD = 0; const ZIP_DEFLATED_METHOD = 8; 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("mistral", () => createMistral({ apiKey, baseURL }).languageModel(model)) .with("cohere", () => createCohere({ apiKey, baseURL }).languageModel(model)) .with("xai", () => createXai({ apiKey, baseURL }).languageModel(model)) .with("groq", () => createGroq({ apiKey, baseURL }).languageModel(model)) .with("deepseek", () => createDeepSeek({ apiKey, baseURL }).languageModel(model)) .with("togetherai", () => createTogetherAI({ apiKey, baseURL }).languageModel(model)) .with("fireworks", () => createFireworks({ apiKey, baseURL }).languageModel(model)) .with("cerebras", () => createCerebras({ apiKey, baseURL }).languageModel(model)) .with("perplexity", () => createPerplexity({ 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; export async function testConnection(input: TestConnectionInput): Promise { const RESPONSE_OK = "1"; const result = await generateText({ model: getModel(input), maxOutputTokens: TEST_CONNECTION_MAX_OUTPUT_TOKENS, temperature: 0, messages: [{ role: "user", content: `Respond only with the single character: ${RESPONSE_OK}` }], }); if (result.text.trim() === RESPONSE_OK) return true; if (result.finishReason === "length") throw new Error("The model returned too much text during the provider test."); return false; } type ParsePdfInput = z.infer & { file: z.infer; }; type BuildResumeParsingMessagesInput = { userPrompt: string; file: z.infer; mediaType: string; }; function buildResumeParsingSystemPrompt(systemPrompt: string): string { return `${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)}`; } function buildResumeParsingMessages({ userPrompt, file, mediaType }: BuildResumeParsingMessagesInput): ModelMessage[] { return [ { role: "user", content: [ { type: "text", text: userPrompt }, { type: "file", data: file.data, mediaType, filename: file.name }, ], }, ]; } function buildResumeParsingTextMessages({ userPrompt, text }: { userPrompt: string; text: string }): ModelMessage[] { return [ { role: "user", content: [ { type: "text", text: `${userPrompt}\n\nThe Microsoft Word file has been converted to plain text below.\n\n${text}`, }, ], }, ]; } async function parsePdf(input: ParsePdfInput): Promise { const model = getModel(input); const result = await generateText({ model, system: buildResumeParsingSystemPrompt(pdfParserSystemPrompt), messages: buildResumeParsingMessages({ 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 & { file: z.infer; mediaType: "application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; }; function assertZipRange(buffer: Buffer, offset: number, length: number) { if (offset < 0 || length < 0 || offset + length > buffer.length) throw new Error("Invalid DOCX archive."); } function findEndOfCentralDirectory(buffer: Buffer): number { const minOffset = Math.max(0, buffer.length - 0xffff - 22); for (let offset = buffer.length - 22; offset >= minOffset; offset--) { if (buffer.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) return offset; } throw new Error("Invalid DOCX archive."); } function readZipEntry(buffer: Buffer, entryName: string): Buffer { const eocdOffset = findEndOfCentralDirectory(buffer); assertZipRange(buffer, eocdOffset, 22); const centralDirectorySize = buffer.readUInt32LE(eocdOffset + 12); const centralDirectoryOffset = buffer.readUInt32LE(eocdOffset + 16); assertZipRange(buffer, centralDirectoryOffset, centralDirectorySize); let offset = centralDirectoryOffset; const endOffset = centralDirectoryOffset + centralDirectorySize; while (offset < endOffset) { assertZipRange(buffer, offset, 46); if (buffer.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE) throw new Error("Invalid DOCX archive."); const compressionMethod = buffer.readUInt16LE(offset + 10); const compressedSize = buffer.readUInt32LE(offset + 20); const fileNameLength = buffer.readUInt16LE(offset + 28); const extraFieldLength = buffer.readUInt16LE(offset + 30); const commentLength = buffer.readUInt16LE(offset + 32); const localHeaderOffset = buffer.readUInt32LE(offset + 42); const fileNameOffset = offset + 46; assertZipRange(buffer, fileNameOffset, fileNameLength); const fileName = buffer.toString("utf8", fileNameOffset, fileNameOffset + fileNameLength); if (fileName === entryName) { assertZipRange(buffer, localHeaderOffset, 30); if (buffer.readUInt32LE(localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) { throw new Error("Invalid DOCX archive."); } const localFileNameLength = buffer.readUInt16LE(localHeaderOffset + 26); const localExtraFieldLength = buffer.readUInt16LE(localHeaderOffset + 28); const dataOffset = localHeaderOffset + 30 + localFileNameLength + localExtraFieldLength; assertZipRange(buffer, dataOffset, compressedSize); const compressed = buffer.subarray(dataOffset, dataOffset + compressedSize); if (compressionMethod === ZIP_STORED_METHOD) return compressed; if (compressionMethod === ZIP_DEFLATED_METHOD) return inflateRawSync(compressed); throw new Error("Unsupported DOCX archive compression."); } offset = fileNameOffset + fileNameLength + extraFieldLength + commentLength; } throw new Error("DOCX document content not found."); } function decodeXmlEntities(value: string): string { return value.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (entity, token: string) => { if (token === "amp") return "&"; if (token === "lt") return "<"; if (token === "gt") return ">"; if (token === "quot") return '"'; if (token === "apos") return "'"; if (token.toLowerCase().startsWith("#x")) return String.fromCodePoint(Number.parseInt(token.slice(2), 16)); if (token.startsWith("#")) return String.fromCodePoint(Number.parseInt(token.slice(1), 10)); return entity; }); } function extractDocxText(file: z.infer): string { const documentXml = readZipEntry(Buffer.from(file.data, "base64"), DOCX_DOCUMENT_XML_PATH).toString("utf8"); // ponytail: minimal OOXML body-text extraction; add a DOCX parser dependency if tracked changes matter. const text = decodeXmlEntities( documentXml .replace(/]*\/>/g, "\t") .replace(/]*\/>/g, "\n") .replace(/<\/w:p>/g, "\n") .replace(/<[^>]+>/g, ""), ) .replace(/\r/g, "") .replace(/[ \t]+\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .trim(); if (!text) throw new Error("DOCX document content is empty."); return text; } async function parseDocx(input: ParseDocxInput): Promise { const model = getModel(input); const messages = input.mediaType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ? buildResumeParsingTextMessages({ userPrompt: docxParserUserPrompt, text: extractDocxText(input.file) }) : buildResumeParsingMessages({ userPrompt: docxParserUserPrompt, file: input.file, mediaType: input.mediaType, }); const result = await generateText({ model, system: buildResumeParsingSystemPrompt(docxParserSystemPrompt), messages, }).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 & { 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: (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 & { 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 { const model = getModel(input); const systemPrompt = buildAnalyzeResumeSystemPrompt(input.resumeData); const result = await generateText({ model, system: systemPrompt, messages: [ { 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, };