313 lines
13 KiB
JavaScript
313 lines
13 KiB
JavaScript
#!/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 <path> --report <path>
|
|
node openai-tailor.mjs --url <base> --model <id> --jd <path> --report <path>
|
|
|
|
OPTIONS
|
|
--jd <path> Path to the Job Description text file
|
|
--report <path> Path to the evaluation report generated by openai-eval.mjs
|
|
--model <id> Model id (env OPENAI_MODEL, default gpt-4o)
|
|
--url <base> OpenAI-compatible base URL, including any /v1
|
|
(env OPENAI_BASE_URL, default https://api.openai.com/v1)
|
|
--key <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 <!DOCTYPE html> and ending with </html>.`;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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);
|
|
}
|