Ctrl K
ComunidadGuías…

openai-tailor.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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 <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
    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 <!DOCTYPE html> and ending with </html>.`;

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