mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
fix(ai): handle markdown-fenced JSON in analyzeResume response (#3142)
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.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<ResumeAnalysis> {
|
||||
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 = {
|
||||
|
||||
Reference in New Issue
Block a user