Fix PDF generation failures from malformed AI responses

- Add OpenRouter response-healing plugin to auto-fix malformed JSON
- Add stream: false to all OpenRouter API calls (required for plugin)
- Add Zod schema validation before PDF generation with AI repair fallback
- Fix CUID2 ID generation for skills (was generating invalid "skill-0")
- Update idSchema to validate CUID2 format matching RXResume's validation
- Save failed JSON and screenshots to data/errors/ for debugging
- Add tests for CUID2 validation to prevent regression
This commit is contained in:
Anas Syed
2026-01-22 20:07:50 +00:00
parent 1ca459ec34
commit 322feb8b67
8 changed files with 485 additions and 668 deletions
@@ -2,6 +2,9 @@
* Shared OpenRouter API helper for structured JSON responses.
*/
import { z } from 'zod';
import { getSetting } from '../repositories/settings.js';
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
export interface JsonSchemaDefinition {
@@ -75,6 +78,7 @@ export async function callOpenRouter<T>(
body: JSON.stringify({
model,
messages,
stream: false,
response_format: {
type: 'json_schema',
json_schema: {
@@ -83,6 +87,7 @@ export async function callOpenRouter<T>(
schema: jsonSchema.schema,
},
},
plugins: [{ id: 'response-healing' }],
}),
});
@@ -165,3 +170,118 @@ export function parseJsonContent<T>(content: string, jobId?: string): T {
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Validate JSON against a Zod schema and repair with AI if invalid.
*
* @param data - The JSON object to validate
* @param schema - Zod schema to validate against
* @param context - Optional context for logging (e.g., job ID)
* @returns The validated (and possibly repaired) data
*/
export async function validateAndRepairJson<T>(
data: unknown,
schema: z.ZodSchema<T>,
context?: string
): Promise<{ success: true; data: T; repaired: boolean } | { success: false; error: string }> {
const label = context ?? 'unknown';
// First attempt: validate as-is
const result = schema.safeParse(data);
if (result.success) {
return { success: true, data: result.data, repaired: false };
}
// Validation failed - attempt AI repair
console.warn(`⚠️ [${label}] Schema validation failed, attempting AI repair...`);
const errors = result.error.issues.map((issue: z.ZodIssue) => ({
path: issue.path.join('.'),
message: issue.message,
code: issue.code,
}));
console.warn(` Validation errors:`, errors.slice(0, 5)); // Log first 5 errors
// Check if API key is available
if (!process.env.OPENROUTER_API_KEY) {
return {
success: false,
error: `Schema validation failed and no API key for repair: ${errors.map((e: { path: string; message: string }) => `${e.path}: ${e.message}`).join('; ')}`
};
}
const [overrideModel] = await Promise.all([getSetting('model')]);
const model = overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
const repairPrompt = buildRepairPrompt(data, errors);
try {
const response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'JobOps',
'X-Title': 'JobOpsSchemaRepair',
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content: repairPrompt }],
stream: false,
plugins: [{ id: 'response-healing' }],
}),
});
if (!response.ok) {
const errorBody = await response.text().catch(() => 'No error body');
return { success: false, error: `AI repair request failed: ${response.status} - ${errorBody}` };
}
const responseData = await response.json();
const content = responseData.choices?.[0]?.message?.content;
if (!content) {
return { success: false, error: 'AI repair returned no content' };
}
// Parse the repaired JSON
const repaired = parseJsonContent<unknown>(content, label);
// Validate the repaired version
const repairedResult = schema.safeParse(repaired);
if (repairedResult.success) {
console.log(`✅ [${label}] AI successfully repaired the JSON`);
return { success: true, data: repairedResult.data, repaired: true };
}
// Still invalid after repair
const newErrors = repairedResult.error.issues.slice(0, 3).map((i: z.ZodIssue) => `${i.path.join('.')}: ${i.message}`).join('; ');
return { success: false, error: `AI repair did not fix all issues: ${newErrors}` };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: `AI repair failed: ${message}` };
}
}
function buildRepairPrompt(data: unknown, errors: Array<{ path: string; message: string; code: string }>): string {
const errorList = errors.slice(0, 10).map(e => `- Path "${e.path}": ${e.message}`).join('\n');
return `You are fixing a JSON object that failed schema validation.
VALIDATION ERRORS:
${errorList}
ORIGINAL JSON (may be truncated):
${JSON.stringify(data, null, 2).slice(0, 15000)}
INSTRUCTIONS:
1. Fix ONLY the validation errors listed above
2. Do NOT remove or modify data that isn't causing errors
3. For missing required fields, add them with sensible defaults (empty strings, empty arrays, etc.)
4. For type mismatches, convert to the correct type
5. Preserve all existing valid data
Return ONLY the fixed JSON object, no explanation.`;
}
@@ -61,6 +61,14 @@ vi.mock('./resumeProjects.js', () => ({
})
}));
vi.mock('./openrouter.js', () => ({
validateAndRepairJson: vi.fn().mockImplementation(async (data: unknown) => ({
success: true,
data,
repaired: false
}))
}));
vi.mock('child_process', () => ({
spawn: vi.fn().mockImplementation(() => ({
stdout: { on: vi.fn() },
@@ -128,7 +136,7 @@ describe('PDF Service Skills Validation', () => {
});
it('should sanitize base resume even if no skills are tailored', async () => {
// Mock profile has an invalid skill (missing visible/description in the raw json implied,
// Mock profile has an invalid skill (missing visible/description in the raw json implied,
// though our mock above has them. Let's make a truly invalid one locally)
const invalidProfile = {
...mockProfile,
@@ -157,4 +165,101 @@ describe('PDF Service Skills Validation', () => {
expect(item.description).toBe('');
expect(item.id).toBeDefined();
});
it('should generate CUID2-compatible IDs for skills without IDs', async () => {
// Profile with skills missing IDs (common when AI generates them)
const profileWithoutIds = {
...mockProfile,
sections: {
...mockProfile.sections,
skills: {
items: [
{ name: 'Skill 1', keywords: ['a'] },
{ name: 'Skill 2', keywords: ['b'] },
{ name: 'Skill 3', keywords: ['c'] }
]
}
}
};
mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithoutIds));
await generatePdf('job-cuid2-test', {}, 'Job Desc', 'dummy.json');
expect(mocks.writeFile).toHaveBeenCalled();
const callArgs = mocks.writeFile.mock.calls[0];
const savedResumeJson = JSON.parse(callArgs[1] as string);
const skillItems = savedResumeJson.sections.skills.items;
// All skills should have IDs
skillItems.forEach((skill: any, index: number) => {
expect(skill.id).toBeDefined();
expect(typeof skill.id).toBe('string');
expect(skill.id.length).toBeGreaterThanOrEqual(20);
// CUID2 format: starts with a letter, lowercase alphanumeric
expect(skill.id).toMatch(/^[a-z][a-z0-9]+$/);
});
// IDs should be unique
const ids = skillItems.map((s: any) => s.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
it('should NOT generate IDs like "skill-0" which are invalid CUID2', async () => {
const profileWithoutIds = {
...mockProfile,
sections: {
...mockProfile.sections,
skills: {
items: [
{ name: 'Skill Without ID', keywords: ['test'] }
]
}
}
};
mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithoutIds));
await generatePdf('job-no-skill-prefix', {}, 'Job Desc', 'dummy.json');
expect(mocks.writeFile).toHaveBeenCalled();
const callArgs = mocks.writeFile.mock.calls[0];
const savedResumeJson = JSON.parse(callArgs[1] as string);
const skill = savedResumeJson.sections.skills.items[0];
// ID should NOT be in the old invalid format
expect(skill.id).not.toMatch(/^skill-\d+$/);
// Should be valid CUID2 format
expect(skill.id).toMatch(/^[a-z][a-z0-9]+$/);
});
it('should preserve existing valid IDs and not regenerate them', async () => {
const validCuid2Id = 'ck9w4ygzq0000xmn5h0jt7l5c';
const profileWithValidId = {
...mockProfile,
sections: {
...mockProfile.sections,
skills: {
items: [
{ id: validCuid2Id, name: 'Skill With Valid ID', keywords: ['test'], visible: true, description: '', level: 1 }
]
}
}
};
mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithValidId));
await generatePdf('job-preserve-id', {}, 'Job Desc', 'dummy.json');
expect(mocks.writeFile).toHaveBeenCalled();
const callArgs = mocks.writeFile.mock.calls[0];
const savedResumeJson = JSON.parse(callArgs[1] as string);
const skill = savedResumeJson.sections.skills.items[0];
// Should preserve the original valid ID
expect(skill.id).toBe(validCuid2Id);
});
});
+41 -5
View File
@@ -8,15 +8,38 @@ import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { readFile, writeFile, mkdir, access, unlink } from 'fs/promises';
import { existsSync } from 'fs';
import crypto from 'node:crypto';
import { getSetting } from '../repositories/settings.js';
import { pickProjectIdsForJob } from './projectSelection.js';
import { extractProjectsFromProfile, resolveResumeProjectsSettings } from './resumeProjects.js';
import { getDataDir } from '../config/dataDir.js';
import { getProfile } from './profile.js';
import { validateAndRepairJson } from './openrouter.js';
import { resumeDataSchema } from '../../shared/rxresume-schema.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* Generate a CUID2-compatible ID for RXResume.
* CUID2 format: starts with a letter, lowercase alphanumeric, ~24 chars
*/
function generateCuid2(): string {
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
const letters = 'abcdefghijklmnopqrstuvwxyz';
const bytes = crypto.randomBytes(24);
// First char must be a letter
let result = letters[bytes[0] % letters.length];
// Rest can be alphanumeric
for (let i = 1; i < 24; i++) {
result += alphabet[bytes[i] % alphabet.length];
}
return result;
}
// Paths - can be overridden via env for Docker
const RESUME_GEN_DIR = process.env.RESUME_GEN_DIR || join(__dirname, '../../../../resume-generator');
const OUTPUT_DIR = join(getDataDir(), 'pdfs');
@@ -67,9 +90,9 @@ export async function generatePdf(
// Sanitize skills: Ensure all skills have required schema fields (visible, description, id, level, keywords)
// This fixes issues where the base JSON uses a shorthand format (missing required fields)
if (baseResume.sections?.skills?.items && Array.isArray(baseResume.sections.skills.items)) {
baseResume.sections.skills.items = baseResume.sections.skills.items.map((skill: any, index: number) => ({
baseResume.sections.skills.items = baseResume.sections.skills.items.map((skill: any) => ({
...skill,
id: skill.id || `skill-${index}`,
id: skill.id || generateCuid2(),
visible: skill.visible ?? true,
// Zod schema requires string, default to empty string if missing
description: skill.description ?? '',
@@ -107,12 +130,12 @@ export async function generatePdf(
if (newSkills && baseResume.sections?.skills) {
// Ensure each skill item has required schema fields
const existingSkills = baseResume.sections.skills.items || [];
const skillsWithSchema = newSkills.map((newSkill: any, index: number) => {
const skillsWithSchema = newSkills.map((newSkill: any) => {
// Try to find matching existing skill to preserve id and other fields
const existing = existingSkills.find((s: any) => s.name === newSkill.name);
return {
id: newSkill.id || existing?.id || `skill-${index}`,
id: newSkill.id || existing?.id || generateCuid2(),
visible: newSkill.visible !== undefined ? newSkill.visible : (existing?.visible ?? true),
name: newSkill.name || existing?.name || '',
description: newSkill.description !== undefined ? newSkill.description : (existing?.description || ''),
@@ -165,9 +188,22 @@ export async function generatePdf(
console.warn(` ⚠️ Project visibility step failed for job ${jobId}:`, err);
}
// Validate and repair the resume JSON before PDF generation
const validationResult = await validateAndRepairJson(baseResume, resumeDataSchema, `pdf-${jobId}`);
if (!validationResult.success) {
console.error(`❌ [Job ${jobId}] Resume validation failed: ${validationResult.error}`);
return { success: false, error: `Resume validation failed: ${validationResult.error}` };
}
if (validationResult.repaired) {
console.log(`🔧 [Job ${jobId}] Resume JSON was repaired by AI`);
}
const validatedResume = validationResult.data;
// Write modified resume to temp file
const tempResumePath = join(RESUME_GEN_DIR, `temp_resume_${jobId}.json`);
await writeFile(tempResumePath, JSON.stringify(baseResume, null, 2));
await writeFile(tempResumePath, JSON.stringify(validatedResume, null, 2));
// Generate PDF using Python script - output directly to our data folder
const outputFilename = `resume_${jobId}.pdf`;
@@ -0,0 +1,151 @@
import { describe, it, expect } from 'vitest';
import { idSchema, skillSchema, resumeDataSchema } from './rxresume-schema.js';
describe('RxResume Schema Validation', () => {
describe('idSchema (CUID2)', () => {
it('should accept valid CUID2 IDs', () => {
const validIds = [
'ck9w4ygzq0000xmn5h0jt7l5c',
'clh2h3j4k0000abcd1234efgh',
'abc123def456ghi789jkl012m',
];
validIds.forEach(id => {
const result = idSchema.safeParse(id);
expect(result.success, `ID "${id}" should be valid`).toBe(true);
});
});
it('should reject invalid IDs like "skill-0"', () => {
const invalidIds = [
'skill-0',
'skill-1',
'skill-123',
'item_1',
'123abc', // starts with number
'ABC123', // uppercase
'', // empty
];
invalidIds.forEach(id => {
const result = idSchema.safeParse(id);
expect(result.success, `ID "${id}" should be invalid`).toBe(false);
});
});
});
describe('skillSchema', () => {
it('should accept valid skill with CUID2 ID', () => {
const validSkill = {
id: 'ck9w4ygzq0000xmn5h0jt7l5c',
visible: true,
name: 'JavaScript',
description: '',
level: 3,
keywords: ['ES6', 'TypeScript'],
};
const result = skillSchema.safeParse(validSkill);
expect(result.success).toBe(true);
});
it('should reject skill with invalid ID format', () => {
const invalidSkill = {
id: 'skill-0', // Invalid CUID2
visible: true,
name: 'JavaScript',
description: '',
level: 3,
keywords: ['ES6'],
};
const result = skillSchema.safeParse(invalidSkill);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toContain('id');
expect(result.error.issues[0].message).toContain('cuid2');
}
});
});
describe('resumeDataSchema', () => {
it('should reject resume with invalid skill IDs', () => {
const resumeWithInvalidIds = {
basics: {
name: 'John Doe',
headline: 'Developer',
email: 'john@example.com',
phone: '',
location: '',
url: { label: '', href: '' },
customFields: [],
picture: {
url: '',
size: 64,
aspectRatio: 1,
borderRadius: 0,
effects: { hidden: false, border: false, grayscale: false },
},
},
sections: {
summary: { id: 'summary', name: 'Summary', columns: 1, separateLinks: true, visible: true, content: '' },
skills: {
id: 'skills',
name: 'Skills',
columns: 1,
separateLinks: true,
visible: true,
items: [
{
id: 'skill-0', // Invalid!
visible: true,
name: 'JavaScript',
description: '',
level: 1,
keywords: [],
},
],
},
// Minimal required sections
awards: { id: 'awards', name: 'Awards', columns: 1, separateLinks: true, visible: true, items: [] },
certifications: { id: 'certifications', name: 'Certifications', columns: 1, separateLinks: true, visible: true, items: [] },
education: { id: 'education', name: 'Education', columns: 1, separateLinks: true, visible: true, items: [] },
experience: { id: 'experience', name: 'Experience', columns: 1, separateLinks: true, visible: true, items: [] },
volunteer: { id: 'volunteer', name: 'Volunteer', columns: 1, separateLinks: true, visible: true, items: [] },
interests: { id: 'interests', name: 'Interests', columns: 1, separateLinks: true, visible: true, items: [] },
languages: { id: 'languages', name: 'Languages', columns: 1, separateLinks: true, visible: true, items: [] },
profiles: { id: 'profiles', name: 'Profiles', columns: 1, separateLinks: true, visible: true, items: [] },
projects: { id: 'projects', name: 'Projects', columns: 1, separateLinks: true, visible: true, items: [] },
publications: { id: 'publications', name: 'Publications', columns: 1, separateLinks: true, visible: true, items: [] },
references: { id: 'references', name: 'References', columns: 1, separateLinks: true, visible: true, items: [] },
custom: {},
},
metadata: {
template: 'rhyhorn',
layout: [[['summary'], ['skills']]],
css: { value: '', visible: false },
page: { margin: 18, format: 'a4', options: { breakLine: true, pageNumbers: true } },
theme: { background: '#ffffff', text: '#000000', primary: '#dc2626' },
typography: {
font: { family: 'IBM Plex Serif', subset: 'latin', variants: ['regular'], size: 14 },
lineHeight: 1.5,
hideIcons: false,
underlineLinks: true,
},
notes: '',
},
};
const result = resumeDataSchema.safeParse(resumeWithInvalidIds);
expect(result.success).toBe(false);
if (!result.success) {
// Should have error about the skill ID
const idError = result.error.issues.find(
issue => issue.path.join('.').includes('skills.items') && issue.path.includes('id')
);
expect(idError).toBeDefined();
}
});
});
});
+2 -1
View File
@@ -10,7 +10,8 @@ export type FilterKeys<T, Condition> = {
export const idSchema = z
.string()
.describe("Unique identifier for the item");
.cuid2()
.describe("Unique identifier for the item (CUID2 format)");
export const itemSchema = z.object({
id: idSchema,