Files
santifer--career-ops/openai-tailor.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

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);
}