Ctrl K
ComunidadGuías…

openai-eval.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
/**
 * openai-eval.mjs — OpenAI-compatible Job Offer Evaluator for career-ops
 *
 * Evaluate job offers with ANY OpenAI-compatible chat endpoint instead of Claude.
 * Works with OpenAI, OpenRouter, Requesty, Cheaper Inference, API Route, Runcrate, CloudGPU, cheapaimodel, UberLLM, Standard Compute, Together, Groq, DeepSeek, Zhipu GLM,
 * MiniMax, Fireworks, and local servers that speak the OpenAI API (LM Studio, llama.cpp,
 * vLLM, Ollama's /v1). Point it at a base URL + model + key and go.
 *
 * Reads evaluation logic from modes/oferta.md + modes/_shared.md, reads the
 * user's resume from cv.md, and evaluates a Job Description passed inline or
 * via --file. Mirrors ollama-eval.mjs / gemini-eval.mjs.
 *
 * Usage:
 *   node openai-eval.mjs "Paste full JD text here"
 *   node openai-eval.mjs --file ./jds/my-job.txt
 *   node openai-eval.mjs --url https://openrouter.ai/api/v1 --model meta-llama/llama-3.3-70b-instruct --file ./jds/job.txt
 *
 * Requires (for hosted endpoints):
 *   OPENAI_API_KEY (or --key)   — your provider key
 *   OPENAI_BASE_URL (or --url)  — the provider's OpenAI-compatible base, e.g.
 *                                 https://openrouter.ai/api/v1
 *   OPENAI_MODEL (or --model)   — the model id
 *
 * Privacy: your cv.md + the full JD are sent to the configured endpoint. Pick a
 * provider you trust; for fully local/private use, run a local server and point
 * --url at http://localhost:... (or use ollama-eval.mjs).
 */

import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { randomUUID } from 'crypto';
import { getCareerOpsRoot, getProfilePath } from './path-resolver.mjs';
import { localToday } from './lib/local-today.mjs';
import { withJdArchiveSection } from './lib/jd-archive-section.mjs';
import { TSV_ADDITION_HEADER } from './tracker-parse.mjs';
import { outputLanguageInstruction, parseOutputLanguage } from './profile-language.mjs';
import {
  formatReportNumber, releaseReportNumbers, reserveReportNumbers,
} from './reserve-report-num.mjs';
import { TokenAccumulator, formatBreakdown, normalizeOpenAIUsage } from './utils/token-tracker.mjs';
import { buildBudgetedPrompt } from './lib/context-budget.mjs';
import { endpointHeaders } from './lib/endpoint-headers.mjs';
import { assertLlmRequestAllowed } from './lib/llm-outbound.mjs';
import { installPiiInterceptor } from './lib/pii-interceptor.mjs';
import {
  isPostingUrl, normalizedTrackerScore, parseScoreSummary, slugifyCompany, tsvSafe,
} from './lib/tracker-addition.mjs';

// Ambient guard over global fetch (idempotent); the explicit gate below is the
// primary layer, this covers anything the script hands to fetch directly.
installPiiInterceptor();

const tracker = new TokenAccumulator();
tracker.recordZeroToken('scan');
tracker.recordZeroToken('pdf payload');

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'),
  oferta:  join(ROOT, 'modes', 'oferta.md'),
  cv:        join(DATA_ROOT, 'cv.md'),
  profileYml: getProfilePath(),
  profile:    join(DATA_ROOT, 'modes', '_profile.md'),
  custom:     join(DATA_ROOT, 'modes', '_custom.md'),
  reports:    join(DATA_ROOT, 'reports'),
  // CAREER_OPS_ADDITIONS mirrors merge-tracker.mjs:43. Writing under DATA_ROOT
  // regardless would drop the addition somewhere the merge it instructs never
  // looks, so the evaluation would sit there unread.
  trackerAdditions: process.env.CAREER_OPS_ADDITIONS
    ? process.env.CAREER_OPS_ADDITIONS
    : join(DATA_ROOT, 'batch', 'tracker-additions'),
};

// ---------------------------------------------------------------------------
// 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 Evaluator (any endpoint)     ║
╚══════════════════════════════════════════════════════════════════╝

  Evaluate a job offer with any OpenAI-compatible chat API instead of Claude.

  USAGE
    node openai-eval.mjs "<JD text>"
    node openai-eval.mjs --file ./jds/my-job.txt
    node openai-eval.mjs --url <base> --model <id> --file ./jds/job.txt

  OPTIONS
    --file <path>    Read JD from a file instead of inline text
    --model <id>     Model id            (env OPENAI_MODEL; MiniMax direct default
                                         MiniMax-M3.1-Flash, otherwise gpt-4o-mini)
    --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)
    --posting-url <url>  Posting URL, recorded in the report header and
                     used as the tracker's dedup key
    --no-save        Do not save report to reports/ directory
    --no-compress    Skip token budget compression (full context injection)
    --help           Show this help

  ENV
    OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL, OPENAI_TIMEOUT_MS
    CAREER_OPS_MAX_TOKENS  Output cap per evaluation (default 32768). Keeps a
                     gateway like OpenRouter from reserving credit for the
                     model's whole output window (HTTP 402 on a low balance).

  PROVIDER EXAMPLES (cheap / free-tier friendly — addresses token cost)
    OpenRouter:  --url https://openrouter.ai/api/v1   --model deepseek/deepseek-chat
    Requesty:    --url https://router.requesty.ai/v1  --model deepseek/deepseek-chat
    Cheaper Inference: --url https://api.cheaperinference.com/v1  --model gpt-5.4-mini
    API Route:   --url https://global.api-route.com/v1  --model gpt-5.5
    Runcrate:    --url https://api.runcrate.ai/v1  --model deepseek-ai/DeepSeek-V4.1-Flash
    CloudGPU:    --url https://cloudgpu.app/v1  --model deepseek-v4.1-flash
    cheapaimodel: --url https://api.cheapaimodel.ai/v1  --model deepseek-v4-1-flash
    UberLLM:     --url https://api.uberllm.dev/v1  --model deepseek/deepseek-v4-flash
    Standard Compute: --url https://api.stdcmpt.com/v1  --model StandardCompute
    Together:    --url https://api.together.xyz/v1     --model meta-llama/Llama-3.3-70B-Instruct-Turbo
    Groq:        --url https://api.groq.com/openai/v1  --model llama-3.3-70b-versatile
    DeepSeek:    --url https://api.deepseek.com/v1     --model deepseek-chat
    Zhipu GLM:   --url https://open.bigmodel.cn/api/paas/v4  --model glm-4-flash
    MiniMax:     --url https://api.minimax.io/v1       --model MiniMax-M3.1-Flash
                 --url https://api.minimax.cn/v1       --model MiniMax-M3
    LM Studio:   --url http://localhost:1234/v1        --model <loaded-model>   (no key)

  EXAMPLES
    OPENAI_API_KEY=sk-... node openai-eval.mjs --file ./jds/job.txt
    node openai-eval.mjs --url http://localhost:1234/v1 --model local "<JD text>"
`);
  process.exit(0);
}

// Parse flags
let jdText     = '';
let postingUrl = '';
let modelName  = process.env.OPENAI_MODEL || '';
let baseUrl    = (process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1').replace(/\/$/, '');
let apiKey     = process.env.OPENAI_API_KEY || '';
let saveReport = true;
let noCompress = false;

for (let i = 0; i < args.length; i++) {
  if (args[i] === '--file' && args[i + 1]) {
    const filePath = args[++i];
    if (!existsSync(filePath)) {
      console.error(`❌  File not found: ${filePath}`);
      process.exit(1);
    }
    try {
      jdText = readFileSync(filePath, 'utf-8').trim();
    } catch (err) {
      console.error(`❌  Could not read file: ${filePath}`);
      console.error(`    ${err.message}`);
      process.exit(1);
    }
  } 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];
  } else if (args[i] === '--posting-url' && args[i + 1]) {
    postingUrl = args[++i];
  } else if (args[i] === '--no-save') {
    saveReport = false;
  } else if (args[i] === '--no-compress') {
    noCompress = true;
  } else if (!args[i].startsWith('--')) {
    jdText += (jdText ? '\n' : '') + args[i];
  }
}

if (!jdText) {
  console.error('❌  No Job Description provided. Run with --help for usage.');
  process.exit(1);
}

// A posting URL is the tracker's deterministic dedup key, so it is taken only in
// a form that can actually become one. Parsed, not prefix-matched: `https://`
// satisfies a prefix test and merge-tracker.mjs:697 would then classify it as
// the URL extra, but normalizeUrl yields no key for it -- so it would sit in the
// URL column looking like a key while deduping nothing. A placeholder written
// there would be worse still, handing every such row the same key.
if (postingUrl && !isPostingUrl(postingUrl)) {
  console.error(`❌  --posting-url must be a complete http(s) URL: "${postingUrl}"`);
  process.exit(1);
}

// ---------------------------------------------------------------------------
// Endpoint + security guard.
// cv.md + the full JD (and the API key) are sent to this endpoint, so:
//   - Non-loopback endpoints MUST use HTTPS (never leak credentials/data in
//     cleartext); plain http is allowed only for localhost dev servers.
//   - Hosted (non-loopback) endpoints require an API key.
// ---------------------------------------------------------------------------
let endpointHost;
{
  let parsed;
  try {
    parsed = new URL(baseUrl);
  } catch {
    console.error(`❌  Invalid OPENAI_BASE_URL: "${baseUrl}"`);
    process.exit(1);
  }
  endpointHost = parsed.hostname;
  // Resolve the default after CLI flags, without replacing saved/custom models.
  // Limit this to the direct API; subscription and gateway routes are separate.
  if (!modelName) {
    const isMiniMax = ['https://api.minimax.io', 'https://api.minimax.cn'].includes(parsed.origin)
      && parsed.pathname === '/v1';
    modelName = isMiniMax ? 'MiniMax-M3.1-Flash' : 'gpt-4o-mini';
  }
  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 CV, the job description, and your 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-eval.mjs ...
   or pass --key <key>. (Local servers at localhost may not need one.)
`);
    process.exit(1);
  }
}

// Build the chat-completions endpoint from the base URL (which already includes
// any provider version segment, e.g. ".../v1"), matching the OpenAI SDK convention.
const endpoint = `${baseUrl}/chat/completions`;

// ---------------------------------------------------------------------------
// File helpers
// ---------------------------------------------------------------------------
/**
 * Read a file and return its trimmed contents, or a placeholder if missing.
 * @param {string} path - Absolute path to the file.
 * @param {string} label - Human-readable label used in the warning and placeholder.
 * @returns {string} File contents or a "[label not found]" placeholder.
 */
function readFile(path, label) {
  if (!existsSync(path)) {
    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');
const ofertaLogic   = readFile(PATHS.oferta,     'modes/oferta.md');
const cvContent     = readFile(PATHS.cv,         'cv.md');
const profileYml    = readFile(PATHS.profileYml, 'config/profile.yml');
const profileContent = readFile(PATHS.profile,   'modes/_profile.md');
// _custom.md is optional: an absent file is a valid end state, so no warning.
const customContent = existsSync(PATHS.custom) ? readFileSync(PATHS.custom, 'utf-8').trim() : '';
const languageInstruction = outputLanguageInstruction(parseOutputLanguage(profileYml));

// ---------------------------------------------------------------------------
// Build system prompt with token budget management
// ---------------------------------------------------------------------------
const { contextBody, budgetReport } = buildBudgetedPrompt({
  sharedContent: sharedContext,
  ofertaContent: ofertaLogic,
  cvContent,
  profileYml,
  profileContent,
  customContent,
  jdText,
  jdInContext: false, // The user message carries the JD; still budget it once.
  noCompress,
  modelName,
});

// Log token budget info
if (budgetReport.compressed) {
  console.log(`📊  Token budget: ${budgetReport.beforeTokens} → ${budgetReport.afterTokens} tokens (saved ${budgetReport.beforeTokens - budgetReport.afterTokens})`);
  console.log(`    Trimmed sections: ${budgetReport.removed.join(', ')}`);
  if (budgetReport.overBudget) {
    console.log(`    ⚠️  Still ${budgetReport.afterTokens - budgetReport.budget} tokens over budget after compression`);
  }
} else if (budgetReport.overBudget) {
  console.log(`⚠️  Token budget: ${budgetReport.totalTokens} tokens exceeds ${budgetReport.budget} limit by ${budgetReport.totalTokens - budgetReport.budget}`);
} else {
  console.log(`📊  Token budget: ${budgetReport.totalTokens} tokens (within ${budgetReport.budget} limit)`);
}

const systemPrompt = `You are career-ops, an AI-powered job search assistant.
You evaluate job offers against the user's CV using a structured A-G scoring system.

Your evaluation methodology is defined below. Follow it exactly.

${contextBody}

═══════════════════════════════════════════════════════
IMPORTANT OPERATING RULES FOR THIS SESSION
═══════════════════════════════════════════════════════
1. You do NOT have access to WebSearch, Playwright, or file writing tools.
   - Block D (Comp research): use training-data salary estimates; note them as estimates.
   - Block G (Legitimacy): analyze JD text only; skip URL/page freshness checks.
   - Post-evaluation file saving is handled by the script, not by you.
2. ${languageInstruction}
3. Generate Blocks A through G in full.
4. At the very end, output this exact machine-readable block:

---SCORE_SUMMARY---
COMPANY: <company name or "Unknown">
ROLE: <role title>
SCORE: <global score as decimal, e.g. 3.8>
ARCHETYPE: <detected archetype>
LEGITIMACY: <High Confidence | Proceed with Caution | Suspicious>
---END_SUMMARY---
`;

// ---------------------------------------------------------------------------
// Prompt caching (#1709) — engine 2 of the four from #1709, same shape as the
// OpenRouter runner. The static prefix (shared + oferta + cv, ~12K tokens) is
// byte-identical across every offer, yet was re-sent and re-billed each call.
//
// Host-gated on purpose: OpenAI-compatible gateways (OpenRouter, Requesty, 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. 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 report costs cents.
//
// 32768 holds a full A-G report (about 9K tokens at the 99th percentile) plus
// the reasoning a thinking model spends before it (up to ~26K seen), so the cap
// bounds the reservation without cutting a normal evaluation short.
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`);

// One evaluation is one conversation: a single id for every request in this run.
const SESSION_ID = randomUUID();
// VERSION may carry a release-please marker ("1.38.0 # x-release-please-version").
let careerOpsVersion;
try { careerOpsVersion = readFileSync(join(ROOT, 'VERSION'), 'utf-8').trim().split(/\s+/)[0] || undefined; } catch { /* default User-Agent version */ }

const headers = { 'Content-Type': 'application/json' };
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
// OpenCode Go asks for a session id and an own user agent (observed: HTTP 400 MissingSessionID without it):
// https://opencode.ai/v2/docs/console/go/#where-can-i-use-it
Object.assign(headers, endpointHeaders(endpointHost, SESSION_ID, careerOpsVersion));

let evaluationText;
try {
  // Privacy gate: inspect the complete serialized request before it leaves the
  // machine. Shared boundary so openai-eval and openai-tailor cannot diverge.
  // The retry is a second body, so it is checked on its own.
  // Streaming (SSE): llama.cpp/Unsloth brauchen bei langen Generationen den
  // sofortigen Header; Non-Streaming läuft in Node/undici in den 5-Minuten-
  // Header-Timeout, bevor die erste Zeile ankommt (8 t/s × 22k-Prefill).
  const signal = AbortSignal.timeout(timeoutMs);
  const send = (withUsage) => {
    const requestBody = JSON.stringify({
      model:    modelName,
      messages: [
        buildSystemMessage(systemPrompt, endpointHost),
        { role: 'user', content: `JOB DESCRIPTION TO EVALUATE:\n\n${jdText}` },
      ],
      stream:      true,
      ...(withUsage && { stream_options: { include_usage: true } }),
      temperature: 0.4,
      ...buildTokenLimit(maxTokens, endpointHost),
    });
    assertLlmRequestAllowed(endpoint, requestBody);
    return fetch(endpoint, {
      method: 'POST',
      headers,
      body: requestBody,
      signal,
    });
  };

  let res = await send(true);
  let body = res.ok ? '' : await res.text();
  // Mistral, among others, answers an unknown field with 400/422 and names it.
  if ((res.status === 400 || res.status === 422) && /stream_options|include_usage/.test(body)) {
    res = await send(false);
    body = res.ok ? '' : await res.text();
  }

  if (!res.ok) {
    console.error(`❌  API error: HTTP ${res.status}`);
    console.error(`    ${body.slice(0, 300)}`);
    if (res.status === 401 || res.status === 403) {
      console.error(`    → Check your API key for ${endpointHost}.`);
    } else 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.`);
    } else if (res.status === 404) {
      console.error(`    → Check --url (it should include any /v1 segment) and --model id.`);
    }
    process.exit(1);
  }

  // SSE-Zeilen akkumulieren: content + reasoning_content getrennt
  const parts = [];
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let sseBuf = '';
  let thinkOpen = false;
  let streamUsage;
  let finishReason = null;
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    sseBuf += decoder.decode(value, { stream: true });
    let nl;
    while ((nl = sseBuf.indexOf('\n')) >= 0) {
      const line = sseBuf.slice(0, nl).trim();
      sseBuf = sseBuf.slice(nl + 1);
      if (!line.startsWith('data:')) continue;
      const payload = line.slice(5).trim();
      if (payload === '[DONE]') continue;
      let delta;
      try { delta = JSON.parse(payload); } catch { continue; }
      streamUsage = delta.usage ?? delta.x_groq?.usage ?? streamUsage;
      finishReason = delta.choices?.[0]?.finish_reason ?? finishReason;
      const d = delta.choices?.[0]?.delta ?? {};
      if (d.reasoning_content) {
        if (!thinkOpen) { parts.push('\n<think>\n'); thinkOpen = true; }
        parts.push(d.reasoning_content);
      } else {
        if (thinkOpen) { parts.push('\n</think>\n\n'); thinkOpen = false; }
        if (d.content) parts.push(d.content);
      }
    }
  }
  if (thinkOpen) parts.push('\n</think>\n');
  if (streamUsage) tracker.record('evaluation', normalizeOpenAIUsage(streamUsage));
  evaluationText = parts.join('').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.
  if (finishReason === 'length') {
    console.error(`⚠️   The reply stopped at the ${maxTokens}-token output cap, so the evaluation may be truncated (missing blocks or score summary).`);
    console.error(`    Raise CAREER_OPS_MAX_TOKENS to allow a longer reply.`);
  }
  if (!evaluationText) {
    console.error('❌  The endpoint returned an empty response.');
    process.exit(1);
  }
} catch (err) {
  if (err.name === 'TimeoutError') {
    console.error(`❌  Request timed out after ${Math.round(timeoutMs / 1000)}s.`);
    console.error(`    Try a smaller/faster model, or increase OPENAI_TIMEOUT_MS.`);
  } else {
    console.error(`❌  API call failed: ${err.message}`);
  }
  process.exit(1);
}

// ---------------------------------------------------------------------------
// Display evaluation
// ---------------------------------------------------------------------------
console.log('\n' + '═'.repeat(66));
console.log('  CAREER-OPS EVALUATION — powered by ' + modelName + ' (' + endpointHost + ')');
console.log('═'.repeat(66) + '\n');
console.log(evaluationText);

// ---------------------------------------------------------------------------
// Parse score summary
// ---------------------------------------------------------------------------
const summary    = parseScoreSummary(evaluationText);
const company    = summary?.company    || 'unknown';
const role       = summary?.role       || 'unknown';
const score      = summary?.score      || '?';
const archetype  = summary?.archetype  || 'unknown';
const legitimacy = summary?.legitimacy || 'unknown';
// An addition for company and role "unknown" would merge as a real application.
const trackable  = Boolean(summary?.company && summary.role && summary.role.toLowerCase() !== 'unknown');

// ---------------------------------------------------------------------------
// Save report
// ---------------------------------------------------------------------------
if (saveReport) {
  let reservedNumbers = [];
  let saveStage = 'report';
  try {
    if (!existsSync(PATHS.reports)) {
      mkdirSync(PATHS.reports, { recursive: true });
    }

    reservedNumbers   = await reserveReportNumbers(1, { rootDir: DATA_ROOT, reportsDir: PATHS.reports });
    const num         = formatReportNumber(reservedNumbers[0]);
    // LOCAL calendar day (#3070). This one value becomes three things that have to
    // agree with each other and with the user's calendar: the report FILENAME
    // ({num}-{slug}-{today}.md), the report's own `**Date:**` header, and the date
    // column of the tracker row written for it.
    //
    // On the UTC day an evaluation run on a Sunday evening in the Americas produces
    // 042-acme-2026-08-18.md, dated the 18th, in a tracker row dated the 18th —
    // while every other date the user sees, and every date the other scripts now
    // stamp, says the 17th. The filename is the part that cannot be corrected
    // later: reports are addressed by it.
    const today       = localToday();
    const companySlug = slugifyCompany(company);
    const filename    = `${num}-${companySlug}-${today}.md`;
    const reportPath  = join(PATHS.reports, filename);

    const reportContent = `# Evaluation: ${company} — ${role}

**Date:** ${today}
**Archetype:** ${archetype}
**Score:** ${score}/5
**URL:** ${postingUrl || '(pasted)'}
**Legitimacy:** ${legitimacy}
**PDF:** pending
**Tool:** OpenAI-compatible (${modelName} @ ${endpointHost})

---

${evaluationText.replace(/---SCORE_SUMMARY---[\s\S]*?---END_SUMMARY---/, '').trim()}
`;

    writeFileSync(reportPath, withJdArchiveSection(reportContent, jdText), 'utf-8');
    console.log(`\n✅  Report saved: reports/${filename}`);

    if (!trackable) {
      console.warn(`\n⚠️   The reply has no ${summary ? 'company and role in its SCORE_SUMMARY' : 'SCORE_SUMMARY block'}, so no tracker row was written.`);
      console.warn('    Re-run the evaluation, or use a model that follows the report format.');
    } else {
      // AGENTS.md Pipeline Integrity rule 1: never hand the user a row to paste
      // into data/applications.md. Evaluations persist as a tracker addition and
      // merge-tracker.mjs applies dedup, status validation, report-link
      // normalization and the tracker lock. A pasted literal skipped all of that,
      // and at 8 cells it was also silently dropped by every reader's width guard.
      // Field order is the TSV contract's -- status BEFORE score; merge-tracker
      // swaps them into the tracker's own column order, resolved by name.
      const additionName = `${num}-${companySlug}.tsv`;
      const trackerFields = [
        String(parseInt(num, 10)),
        today,
        tsvSafe(company),
        tsvSafe(role),
        'Evaluated',
        normalizedTrackerScore(score),
        '❌',
        `[${num}](reports/${filename})`,
        tsvSafe(`OpenAI-compatible evaluation (${modelName})`),
      ];
      // Optional tenth field, labelled in the header below so it resolves by name.
      // Pass 0 can then match on it instead of waiting for --backfill-urls.
      if (postingUrl) trackerFields.push(tsvSafe(postingUrl));
      // Header row first (#3517/#3706): merge-tracker resolves the fields by name,
      // so this row cannot be ingested into the wrong columns. The optional URL
      // needs its own label -- values are read BY label, so a tenth field the
      // header does not name is not mis-mapped, it is dropped.
      const additionHeader = postingUrl ? `${TSV_ADDITION_HEADER}\turl` : TSV_ADDITION_HEADER;
      saveStage = 'tracker addition';
      mkdirSync(PATHS.trackerAdditions, { recursive: true });
      writeFileSync(
        join(PATHS.trackerAdditions, additionName),
        `${additionHeader}\n${trackerFields.join('\t')}\n`,
        'utf-8',
      );
      console.log(`\n📊  Tracker addition saved: batch/tracker-additions/${additionName}`);
      console.log('    Run `node merge-tracker.mjs` to merge it into the tracker.');
    }
  } catch (err) {
    console.warn(`⚠️   Could not save evaluation (${saveStage}): ${err.message}`);
    process.exitCode = 1;
  } finally {
    if (reservedNumbers.length > 0) {
      try {
        await releaseReportNumbers(reservedNumbers, { reportsDir: PATHS.reports });
      } catch (err) {
        console.warn(`⚠️   Could not release report reservation: ${err.message}`);
      }
    }
  }
}

console.log('\n' + '─'.repeat(66));
console.log(`  Score: ${score}/5  |  Archetype: ${archetype}  |  Legitimacy: ${legitimacy}`);
console.log('─'.repeat(66) + '\n');

console.log(formatBreakdown(tracker, modelName, 'openai'));