diff --git a/packages/api/src/features/ai/router.ts b/packages/api/src/features/ai/router.ts index e04a8a2f7..014ff2a29 100644 --- a/packages/api/src/features/ai/router.ts +++ b/packages/api/src/features/ai/router.ts @@ -23,8 +23,9 @@ function isCredentialEncryptionUnavailable(error: unknown): boolean { return error instanceof Error && error.message === "AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE"; } -function throwAiProviderGatewayError(): never { - throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider." }); +/** Throws a BAD_GATEWAY ORPCError, preserving the original cause for upstream error reporters. */ +function throwAiProviderGatewayError(cause?: unknown): never { + throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider.", cause }); } function throwAiProviderConfigError(): never { @@ -85,7 +86,7 @@ export const aiRouter = { } catch (error) { if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable(); if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); - if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(); + if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error); if (error instanceof ZodError) throwResumeStructureError(error); throw error; } @@ -131,7 +132,7 @@ export const aiRouter = { } catch (error) { if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable(); if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); - if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(); + if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error); if (error instanceof ZodError) throwResumeStructureError(error); throw error; } @@ -174,7 +175,7 @@ export const aiRouter = { } catch (error) { if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable(); if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); - if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(); + if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error); throw error; } }), @@ -228,7 +229,7 @@ export const aiRouter = { } catch (error) { if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable(); if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); - if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(); + if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error); if (error instanceof ZodError) { throw new ORPCError("BAD_REQUEST", { message: "Invalid resume analysis structure", diff --git a/packages/api/src/features/ai/service.ts b/packages/api/src/features/ai/service.ts index 6e61eb368..c1f98e023 100644 --- a/packages/api/src/features/ai/service.ts +++ b/packages/api/src/features/ai/service.ts @@ -28,7 +28,7 @@ import { } from "@reactive-resume/ai/tools/patch-proposal"; import { aiProviderSchema } from "@reactive-resume/ai/types"; import { applyResumePatches } from "@reactive-resume/resume/patch"; -import { resumeAnalysisOutputSchema, resumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis"; +import { resumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis"; import { supportsProviderNativeWebSearch } from "./capabilities"; import { resolveAiBaseUrl } from "./url-policy"; @@ -248,28 +248,38 @@ 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, - output: Output.object({ schema: resumeAnalysisOutputSchema }), messages: [ { role: "system", content: systemPrompt }, { role: "user", content: - "Analyze this resume and return a structured report with scorecard, overall score, strengths, and actionable suggestions.", + "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.", }, ], }); - if (result.output == null) { + 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."); } - return resumeAnalysisSchema.parse(result.output); + const jsonString = candidate.substring(firstBrace, lastBrace + 1); + const parsed = JSON.parse(jsonString); + + return resumeAnalysisSchema.parse(parsed); } export const aiService = {