#!/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 { randomUUID } from 'crypto'; import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs'; import { join, dirname, basename } from 'path'; import { fileURLToPath } from 'url'; import { getCareerOpsRoot, getProfilePath } from './path-resolver.mjs'; import { loadYaml } from './lib/yaml-document.mjs'; import { validateLockedSections, sectionKey } from './generate-pdf.mjs'; import { readLockedSections } from './theme-style.mjs'; import { redactIfEnabled, restoreIfEnabled } from './utils/privacy-filter.mjs'; import { assertLlmRequestAllowed } from './lib/llm-outbound.mjs'; import { installPiiInterceptor } from './lib/pii-interceptor.mjs'; import { endpointHeaders } from './lib/endpoint-headers.mjs'; // Ambient guard over global fetch (idempotent); the explicit gate below is the // primary layer. installPiiInterceptor(); try { const { config } = await import('dotenv'); // The data root's .env first: doctor.mjs, remember-cli.mjs, scan.mjs and the // plugin engine all read it there. The working directory's .env, the only one // read before, still supplies what the data root's leaves unset. config({ path: [join(getCareerOpsRoot(), '.env'), join(process.cwd(), '.env')] }); } catch { /* dotenv optional */ } const ROOT = dirname(fileURLToPath(import.meta.url)); const DATA_ROOT = getCareerOpsRoot(); // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- const PATHS = { shared: join(ROOT, 'modes', '_shared.md'), writing: join(ROOT, 'modes', '_writing.md'), pdfMode: join(ROOT, 'modes', 'pdf.md'), cv: join(DATA_ROOT, 'cv.md'), profile: getProfilePath(), template: join(ROOT, 'templates', 'cv-template.html'), output: join(DATA_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 CAREER_OPS_MAX_TOKENS Output cap per tailored CV (default 32768). Keeps a gateway like OpenRouter from reserving credit for the model's whole output window (HTTP 402 on a low balance). 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(); // The report filename identifies the application being tailored. const reportFilename = basename(reportPath); // --------------------------------------------------------------------------- // 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); // Writing guardrails (Voice DNA / Writing Style / Professional Writing) live in // _writing.md since #1710 — a CV-tailoring script needs them, unlike the eval // engines that read the eval-core _shared.md alone. const writingContext = readFile(PATHS.writing, 'modes/_writing.md', false); const pdfModeLogic = readFile(PATHS.pdfMode, 'modes/pdf.md', false); // Opt-in privacy filter (privacy.redact_contact_info in config/profile.yml): // keep name/contact details out of the prompt sent to the model. const profileYmlRaw = readFile(PATHS.profile, 'config/profile.yml', true); const cvContent = redactIfEnabled(readFile(PATHS.cv, 'cv.md', true), profileYmlRaw); const profileContent = redactIfEnabled(profileYmlRaw, profileYmlRaw); 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} ═══════════════════════════════════════════════════════ WRITING GUARDRAILS (_writing.md) ═══════════════════════════════════════════════════════ ${writingContext} ═══════════════════════════════════════════════════════ 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 .`; // --------------------------------------------------------------------------- // Prompt caching (#1709, closing the gap in #2432) — same shape as // openai-eval.mjs. This system prompt (shared + writing + pdf mode + HTML // template + cv + profile, ~15K+ tokens) is byte-identical across every // offer, yet was re-sent and re-billed each call. // // Host-gated on purpose: OpenAI-compatible gateways (OpenRouter, DeepSeek, …) // honor an ephemeral `cache_control` breakpoint on the prefix and reuse it // across back-to-back calls within the cache TTL. api.openai.com instead caches // long prefixes automatically and may reject the non-standard field, so it gets // a plain-string system message. Either way the prompt TEXT is unchanged. export function buildSystemMessage(prompt, host) { if (host === 'api.openai.com') return { role: 'system', content: prompt }; return { role: 'system', content: [{ type: 'text', text: prompt, cache_control: { type: 'ephemeral' } }], }; } // --------------------------------------------------------------------------- // Output cap, as in openai-eval.mjs. With no limit in the request, a gateway // budgets the call for the model's whole output window: OpenRouter reserves // prompt + max output at the model's price before generating anything, and // answers HTTP 402 when the balance cannot cover that reservation, even when // the tailored CV costs cents. The variable and its default are openai-eval's, // so one setting covers both steps of the budget workflow. const DEFAULT_MAX_TOKENS = 32768; // Same host gate as buildSystemMessage: api.openai.com's reasoning models // reject `max_tokens` and take `max_completion_tokens`, which its other chat // models accept as well; gateways and local servers read `max_tokens`. function buildTokenLimit(limit, host) { return host === 'api.openai.com' ? { max_completion_tokens: limit } : { max_tokens: limit }; } // --------------------------------------------------------------------------- // Call the OpenAI-compatible endpoint // --------------------------------------------------------------------------- // Digits only (parseInt read "300s" as a 300 ms timeout) and at most 2^31-1 ms: // AbortSignal.timeout() fires a longer delay after 1 ms, and throws past 2^32-1. const timeoutRaw = (process.env.OPENAI_TIMEOUT_MS || '300000').trim(); const timeoutMs = /^\d+$/.test(timeoutRaw) ? Number(timeoutRaw) : NaN; if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) { console.error(`❌ Invalid OPENAI_TIMEOUT_MS: "${process.env.OPENAI_TIMEOUT_MS}" — must be a positive integer of at most 2147483647 (milliseconds).`); process.exit(1); } // Empty means unset, as for every other variable here. Digits only, so "8k" // is refused instead of being read as 8. const maxTokensEnv = process.env.CAREER_OPS_MAX_TOKENS?.trim(); const maxTokens = maxTokensEnv ? (/^\d+$/.test(maxTokensEnv) ? Number(maxTokensEnv) : NaN) : DEFAULT_MAX_TOKENS; if (!(maxTokens > 0)) { console.error(`❌ Invalid CAREER_OPS_MAX_TOKENS: "${process.env.CAREER_OPS_MAX_TOKENS}" — must be a positive integer (output tokens).`); process.exit(1); } console.log(`\n🔒 Privacy: your cv.md + JD will be sent to ${endpointHost}.`); console.log(`🤖 Calling ${modelName} via ${endpointHost} (output cap ${maxTokens} tokens)... this may take a minute.\n`); const headers = { 'Content-Type': 'application/json' }; if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`; // OpenCode Go answers HTTP 400 MissingSessionID to a request with no session // id, and its docs ask for an own user agent: the same headers openai-eval.mjs // sends (#5825), for opencode.ai only. One tailoring run is one conversation. // https://opencode.ai/v2/docs/console/go/#where-can-i-use-it let careerOpsVersion; // VERSION may carry a release-please marker ("1.38.0 # x-release-please-version"). try { careerOpsVersion = readFileSync(join(ROOT, 'VERSION'), 'utf-8').trim().split(/\s+/)[0] || undefined; } catch { /* default User-Agent version */ } Object.assign(headers, endpointHeaders(endpointHost, randomUUID(), careerOpsVersion)); let tailoredHtml; try { // Privacy gate: the full serialized tailoring request is inspected before the // network call. Nothing is sent when a protected value is present. const requestBody = JSON.stringify({ model: modelName, messages: [ buildSystemMessage(systemPrompt, endpointHost), { 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, ...buildTokenLimit(maxTokens, endpointHost), }); assertLlmRequestAllowed(endpoint, requestBody); const res = await fetch(endpoint, { method: 'POST', headers, body: requestBody, 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)}`); if (res.status === 402) { console.error(` → Not enough credit at ${endpointHost} for the prompt plus the ${maxTokens}-token output cap.`); console.error(` Top up, or lower CAREER_OPS_MAX_TOKENS.`); } process.exit(1); } const data = await res.json(); tailoredHtml = data.choices?.[0]?.message?.content?.trim(); // Before the empty check: a thinking model can spend the whole cap on // reasoning, and then this is the only explanation the user gets. A reply // cut off at the cap is a CV without its last sections, and saving it would // hand that to generate-pdf as the tailored CV, so nothing is saved. if (data.choices?.[0]?.finish_reason === 'length') { console.error(`❌ The reply stopped at the ${maxTokens}-token output cap, so the tailored CV is cut short. Nothing was saved.`); console.error(` Raise CAREER_OPS_MAX_TOKENS to allow a longer reply.`); process.exit(1); } 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*$/, ''); // The model only saw placeholders when redaction is on; put the real name and // contact details back so the tailored CV never ships with "Candidate Name". tailoredHtml = restoreIfEnabled(tailoredHtml, profileYmlRaw); // --------------------------------------------------------------------------- // Save tailored HTML // --------------------------------------------------------------------------- try { if (!existsSync(PATHS.output)) { mkdirSync(PATHS.output, { recursive: true }); } let candidateName = 'candidate'; try { const profile = loadYaml(profileYmlRaw); 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, ''); // Reports identify applications; one employer can have several requisitions // with the same title. Re-tailoring one report may replace its own artifact. const artifactStem = `cv-${candidateName}-${reportFilename.replace(/\.md$/i, '')}`; const filename = `${artifactStem}.html`; const htmlPath = join(PATHS.output, filename); const lockedKeys = readLockedSections(PATHS.profile, sectionKey); const cvMarkdown = readFileSync(PATHS.cv, 'utf-8'); validateLockedSections(tailoredHtml, cvMarkdown, lockedKeys); writeFileSync(htmlPath, tailoredHtml, 'utf-8'); console.log(`\n✅ Tailored HTML saved: ${htmlPath}`); // Print next steps const pdfFilename = `${artifactStem}.pdf`; const reportNumMatch = reportFilename.match(/^(\d+)-/); const reportNum = reportNumMatch ? reportNumMatch[1] : '001'; const pdfPaths = [`output/${filename}`, `output/${pdfFilename}`] .map(path => /^[A-Za-z0-9._@+/-]+$/.test(path) ? path : `'${path.replace(/'/g, "'\\''")}'`) .join(' '); console.log(`\n📄 Next step (generate PDF):\n node generate-pdf.mjs ${pdfPaths} --format=letter --report=${reportNum}\n`); } catch (err) { console.warn(`⚠️ Could not save HTML: ${err.message}`); process.exit(1); }