2026-01-22 22:57:37 +00:00

237 lines
7.9 KiB
TypeScript

/**
* Service for generating PDF resumes using RxResume automation.
*/
import { join } from 'path';
import { readFile, writeFile, mkdir, access } from 'fs/promises';
import { existsSync } from 'fs';
import { spawn } from 'child_process';
import { createId } from '@paralleldrive/cuid2';
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';
const OUTPUT_DIR = join(getDataDir(), 'pdfs');
const RESUME_GEN_DIR = process.env.RESUME_GEN_DIR || join(getDataDir(), '..', 'resume-generator');
export interface PdfResult {
success: boolean;
pdfPath?: string;
error?: string;
}
export interface TailoredPdfContent {
summary?: string | null;
headline?: string | null;
skills?: any | null; // Accept any for flexibility, expected to be items array or parsed JSON
}
/**
* Generate a tailored PDF resume for a job using RxResume automation.
*/
export async function generatePdf(
jobId: string,
tailoredContent: TailoredPdfContent,
jobDescription: string,
baseResumePath?: string,
selectedProjectIds?: string | null
): Promise<PdfResult> {
console.log(`📄 Generating PDF for job ${jobId}...`);
try {
// Ensure output directory exists
if (!existsSync(OUTPUT_DIR)) {
await mkdir(OUTPUT_DIR, { recursive: true });
}
// Read base resume
const baseResume = baseResumePath
? JSON.parse(await readFile(baseResumePath, 'utf-8'))
: JSON.parse(JSON.stringify(await getProfile()));
// 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) => ({
...skill,
id: skill.id || createId(),
visible: skill.visible ?? true,
// Zod schema requires string, default to empty string if missing
description: skill.description ?? '',
level: skill.level ?? 1,
keywords: skill.keywords || [],
}));
}
// Inject tailored summary
if (tailoredContent.summary) {
if (baseResume.sections?.summary) {
baseResume.sections.summary.content = tailoredContent.summary;
} else if (baseResume.basics?.summary) {
baseResume.basics.summary = tailoredContent.summary;
}
}
// Inject tailored headline
if (tailoredContent.headline) {
if (baseResume.basics) {
baseResume.basics.headline = tailoredContent.headline;
baseResume.basics.label = tailoredContent.headline;
}
}
// Inject tailored skills
if (tailoredContent.skills) {
const newSkills = Array.isArray(tailoredContent.skills)
? tailoredContent.skills
: typeof tailoredContent.skills === 'string'
? JSON.parse(tailoredContent.skills)
: null;
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) => {
// 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 || createId(),
visible: newSkill.visible !== undefined ? newSkill.visible : (existing?.visible ?? true),
name: newSkill.name || existing?.name || '',
description: newSkill.description !== undefined ? newSkill.description : (existing?.description || ''),
level: newSkill.level !== undefined ? newSkill.level : (existing?.level ?? 1),
keywords: newSkill.keywords || existing?.keywords || [],
};
});
baseResume.sections.skills.items = skillsWithSchema;
}
}
// Select projects and set visibility
try {
let selectedSet: Set<string>;
if (selectedProjectIds) {
selectedSet = new Set(selectedProjectIds.split(',').map(s => s.trim()).filter(Boolean));
} else {
const { catalog, selectionItems } = extractProjectsFromProfile(baseResume);
const overrideResumeProjectsRaw = await getSetting('resumeProjects');
const { resumeProjects } = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
const locked = resumeProjects.lockedProjectIds;
const desiredCount = Math.max(0, resumeProjects.maxProjects - locked.length);
const eligibleSet = new Set(resumeProjects.aiSelectableProjectIds);
const eligibleProjects = selectionItems.filter((p) => eligibleSet.has(p.id));
const picked = await pickProjectIdsForJob({
jobDescription,
eligibleProjects,
desiredCount,
});
selectedSet = new Set([...locked, ...picked]);
}
const projectsSection = baseResume.sections?.projects;
const projectItems = projectsSection?.items;
if (Array.isArray(projectItems)) {
for (const item of projectItems) {
if (!item || typeof item !== 'object') continue;
const id = typeof (item as any).id === 'string' ? (item as any).id : '';
if (!id) continue;
(item as any).visible = selectedSet.has(id);
}
projectsSection.visible = selectedSet.size > 0;
}
} catch (err) {
console.warn(` ⚠️ Project visibility step failed for job ${jobId}:`, err);
}
// Write modified resume to temp file
const tempResumePath = join(RESUME_GEN_DIR, `temp_resume_${jobId}.json`);
await writeFile(tempResumePath, JSON.stringify(baseResume, null, 2));
// Generate PDF using Python script - output directly to our data folder
const outputFilename = `resume_${jobId}.pdf`;
const outputPath = join(OUTPUT_DIR, outputFilename);
await runPythonPdfGenerator(tempResumePath, outputFilename, OUTPUT_DIR);
// Cleanup temp file
try {
const { unlink } = await import('fs/promises');
await unlink(tempResumePath);
} catch {
// Ignore cleanup errors
}
console.log(`✅ PDF generated: ${outputPath}`);
return { success: true, pdfPath: outputPath };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`❌ PDF generation failed: ${message}`);
return { success: false, error: message };
}
}
/**
* Run the Python RXResume automation script.
*/
async function runPythonPdfGenerator(
jsonPath: string,
outputFilename: string,
outputDir: string
): Promise<void> {
return new Promise((resolve, reject) => {
// Use the virtual environment's Python (or system python in Docker)
const pythonPath = process.env.PYTHON_PATH || join(RESUME_GEN_DIR, '.venv', 'bin', 'python');
const child = spawn(pythonPath, ['rxresume_automation.py'], {
cwd: RESUME_GEN_DIR,
env: {
...process.env,
RESUME_JSON_PATH: jsonPath,
OUTPUT_FILENAME: outputFilename,
OUTPUT_DIR: outputDir,
},
stdio: 'inherit',
});
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Python script exited with code ${code}`));
}
});
child.on('error', reject);
});
}
/**
* Check if a PDF exists for a job.
*/
export async function pdfExists(jobId: string): Promise<boolean> {
const pdfPath = join(OUTPUT_DIR, `resume_${jobId}.pdf`);
try {
await access(pdfPath);
return true;
} catch {
return false;
}
}
/**
* Get the path to a job's PDF.
*/
export function getPdfPath(jobId: string): string {
return join(OUTPUT_DIR, `resume_${jobId}.pdf`);
}