#!/usr/bin/env node /** * openai-tailor.mjs — OpenAI-compatible CV Tailoring for career-ops * * Tailor your CV (HTML) with ANY OpenAI-compatible chat endpoint instead of Claude. * This is the headless companion to openai-eval.mjs. It takes an evaluation report * and the job description, applies anti-fabrication rules, and outputs a filled * cv-template.html ready to be turned into a PDF. * * Usage: * node openai-tailor.mjs --jd ./jds/my-job.txt --report reports/001-company-2026.md * * Requires (for hosted endpoints): * OPENAI_API_KEY (or --key) — your provider key * OPENAI_BASE_URL (or --url) — the provider's OpenAI-compatible base * OPENAI_MODEL (or --model) — the model id */ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs'; import { join, dirname, basename } from 'path'; import { fileURLToPath } from 'url'; import yaml from 'js-yaml'; try { const { config } = await import('dotenv'); config(); } catch { /* dotenv optional */ } const ROOT = dirname(fileURLToPath(import.meta.url)); // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- const PATHS = { shared: join(ROOT, 'modes', '_shared.md'), pdfMode: join(ROOT, 'modes', 'pdf.md'), cv: join(ROOT, 'cv.md'), profile: join(ROOT, 'config', 'profile.yml'), template: join(ROOT, 'templates', 'cv-template.html'), output: join(ROOT, 'output'), }; // --------------------------------------------------------------------------- // CLI argument parsing // --------------------------------------------------------------------------- const args = process.argv.slice(2); if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { console.log(` ╔══════════════════════════════════════════════════════════════════╗ ║ career-ops — OpenAI-compatible CV Tailoring (Headless) ║ ╚══════════════════════════════════════════════════════════════════╝ Tailor your CV with any OpenAI-compatible API to output a filled HTML file. USAGE node openai-tailor.mjs --jd --report node openai-tailor.mjs --url --model --jd --report OPTIONS --jd Path to the Job Description text file --report Path to the evaluation report generated by openai-eval.mjs --model Model id (env OPENAI_MODEL, default gpt-4o) --url OpenAI-compatible base URL, including any /v1 (env OPENAI_BASE_URL, default https://api.openai.com/v1) --key API key (env OPENAI_API_KEY) --help Show this help ENV OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL, OPENAI_TIMEOUT_MS EXAMPLES OPENAI_API_KEY=sk-... node openai-tailor.mjs --jd ./jds/job.txt --report reports/001-company-2026-01-01.md `); process.exit(0); } // Parse flags let jdPath = ''; let reportPath = ''; let modelName = process.env.OPENAI_MODEL || 'gpt-4o'; // Tailoring needs a smarter model default than eval let baseUrl = (process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1').replace(/\/$/, ''); let apiKey = process.env.OPENAI_API_KEY || ''; for (let i = 0; i < args.length; i++) { if (args[i] === '--jd' && args[i + 1]) { jdPath = args[++i]; } else if (args[i] === '--report' && args[i + 1]) { reportPath = args[++i]; } else if (args[i] === '--model' && args[i + 1]) { modelName = args[++i]; } else if (args[i] === '--url' && args[i + 1]) { baseUrl = args[++i].replace(/\/$/, ''); } else if (args[i] === '--key' && args[i + 1]) { apiKey = args[++i]; } } if (!jdPath || !reportPath) { console.error('❌ Both --jd and --report are required. Run with --help for usage.'); process.exit(1); } if (!existsSync(jdPath)) { console.error(`❌ JD file not found: ${jdPath}`); process.exit(1); } if (!existsSync(reportPath)) { console.error(`❌ Report file not found: ${reportPath}`); process.exit(1); } const jdText = readFileSync(jdPath, 'utf-8').trim(); const reportText = readFileSync(reportPath, 'utf-8').trim(); // Attempt to parse company slug and candidate name const reportFilename = basename(reportPath); const match = reportFilename.match(/^\d+-([a-z0-9-]+)-\d{4}-\d{2}-\d{2}\.md$/); const companySlug = match ? match[1] : 'unknown-company'; // --------------------------------------------------------------------------- // Endpoint + security guard. // --------------------------------------------------------------------------- let endpointHost; { let parsed; try { parsed = new URL(baseUrl); } catch { console.error(`❌ Invalid OPENAI_BASE_URL: "${baseUrl}"`); process.exit(1); } endpointHost = parsed.hostname; const isLoopback = endpointHost === 'localhost' || endpointHost === '127.0.0.1' || endpointHost === '::1'; if (!isLoopback && parsed.protocol !== 'https:') { console.error(` ❌ Refusing to use a non-HTTPS remote endpoint: ${baseUrl} Your data and API key would be sent in cleartext. Use an https:// endpoint, or http://localhost:... for a local server. `); process.exit(1); } if (!isLoopback && !apiKey) { console.error(` ❌ No API key for ${endpointHost}. Set one and re-run: OPENAI_API_KEY=your_key node openai-tailor.mjs ... `); process.exit(1); } } const endpoint = `${baseUrl}/chat/completions`; // --------------------------------------------------------------------------- // File helpers // --------------------------------------------------------------------------- function readFile(path, label, required = false) { if (!existsSync(path)) { if (required) { console.error(`❌ Required context file not found: ${label} at ${path}`); process.exit(1); } console.warn(`⚠️ ${label} not found at: ${path}`); return `[${label} not found — skipping]`; } return readFileSync(path, 'utf-8').trim(); } // --------------------------------------------------------------------------- // Load context files // --------------------------------------------------------------------------- console.log('\\n📂 Loading context files...'); const sharedContext = readFile(PATHS.shared, 'modes/_shared.md', false); const pdfModeLogic = readFile(PATHS.pdfMode, 'modes/pdf.md', false); const cvContent = readFile(PATHS.cv, 'cv.md', true); const profileContent = readFile(PATHS.profile, 'config/profile.yml', true); const templateHtml = readFile(PATHS.template, 'templates/cv-template.html', true); // --------------------------------------------------------------------------- // Build system prompt // --------------------------------------------------------------------------- const systemPrompt = `You are career-ops, an AI-powered CV tailoring engine. You read a candidate's base CV, profile, an evaluation report, and a Job Description. Your job is to apply strict anti-fabrication tailoring rules to fill in an HTML template. ═══════════════════════════════════════════════════════ SYSTEM CONTEXT (_shared.md) ═══════════════════════════════════════════════════════ ${sharedContext} ═══════════════════════════════════════════════════════ PDF TAILORING MODE (pdf.md) ═══════════════════════════════════════════════════════ ${pdfModeLogic} ═══════════════════════════════════════════════════════ HTML TEMPLATE (cv-template.html) ═══════════════════════════════════════════════════════ ${templateHtml} ═══════════════════════════════════════════════════════ CANDIDATE BASE CV & PROFILE ═══════════════════════════════════════════════════════ [cv.md] ${cvContent} [config/profile.yml] ${profileContent} ═══════════════════════════════════════════════════════ IMPORTANT OPERATING RULES FOR THIS SESSION ═══════════════════════════════════════════════════════ 1. NEVER invent skills, metrics, or experience the candidate does not have. 2. Inject keywords naturally by reformulating the real experience using JD vocabulary. 3. Apply the 6-second clarity gate: strongest matching evidence first. 4. Replace all {{PLACEHOLDERS}} in the HTML Template exactly as instructed. 5. Your final output MUST be the complete, raw, tailored HTML document. 6. Do NOT include markdown formatting like \`\`\`html or conversational filler. Output the raw HTML starting with and ending with .`; // --------------------------------------------------------------------------- // Call the OpenAI-compatible endpoint // --------------------------------------------------------------------------- const timeoutMs = parseInt(process.env.OPENAI_TIMEOUT_MS || '300000', 10); if (Number.isNaN(timeoutMs) || timeoutMs <= 0) { console.error(`❌ Invalid OPENAI_TIMEOUT_MS: "${process.env.OPENAI_TIMEOUT_MS}" — must be a positive integer (milliseconds).`); process.exit(1); } console.log(`\n🔒 Privacy: your cv.md + JD will be sent to ${endpointHost}.`); console.log(`🤖 Calling ${modelName} via ${endpointHost}... this may take a minute.\n`); const headers = { 'Content-Type': 'application/json' }; if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`; let tailoredHtml; try { const res = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ model: modelName, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: `EVALUATION REPORT:\n\n${reportText}\n\nJOB DESCRIPTION:\n\n${jdText}\n\nNow, generate and output the fully filled HTML CV matching the rules above. Output ONLY raw HTML.` }, ], stream: false, temperature: 0.2, }), signal: AbortSignal.timeout(timeoutMs), }); if (!res.ok) { const body = await res.text(); console.error(`❌ API error: HTTP ${res.status}`); console.error(` ${body.slice(0, 300)}`); process.exit(1); } const data = await res.json(); tailoredHtml = data.choices?.[0]?.message?.content?.trim(); if (!tailoredHtml) { console.error('❌ The endpoint returned an empty response.'); process.exit(1); } } catch (err) { console.error(`❌ API call failed: ${err.message}`); process.exit(1); } // Clean up markdown block wrapping if the LLM adds it despite instructions tailoredHtml = tailoredHtml.replace(/^\s*```(html)?\s*/i, '').replace(/\s*```\s*$/, ''); // --------------------------------------------------------------------------- // Save tailored HTML // --------------------------------------------------------------------------- try { if (!existsSync(PATHS.output)) { mkdirSync(PATHS.output, { recursive: true }); } let candidateName = 'candidate'; try { const profile = yaml.load(profileContent); if (profile && profile.name) { candidateName = profile.name; } } catch (err) { console.warn(`⚠️ Failed to parse profile.yml: ${err.message}`); } candidateName = candidateName .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); const filename = `cv-${candidateName}-${companySlug}.html`; const htmlPath = join(PATHS.output, filename); writeFileSync(htmlPath, tailoredHtml, 'utf-8'); console.log(`\n✅ Tailored HTML saved: ${htmlPath}`); // Print next steps const pdfFilename = `cv-${candidateName}-${companySlug}-${new Date().toISOString().split('T')[0]}.pdf`; const reportNumMatch = reportFilename.match(/^(\d+)-/); const reportNum = reportNumMatch ? reportNumMatch[1] : '001'; console.log(`\n📄 Next step (generate PDF):\n node generate-pdf.mjs output/${filename} output/${pdfFilename} --format=letter --report=${reportNum}\n`); } catch (err) { console.warn(`⚠️ Could not save HTML: ${err.message}`); process.exit(1); }