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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user