#!/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 "" node openai-eval.mjs --file ./jds/my-job.txt node openai-eval.mjs --url --model --file ./jds/job.txt OPTIONS --file Read JD from a file instead of inline text --model Model id (env OPENAI_MODEL; MiniMax direct default MiniMax-M3.1-Flash, otherwise gpt-4o-mini) --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) --posting-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 (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 "" `); 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 . (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: ROLE: SCORE: ARCHETYPE: LEGITIMACY: ---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\n'); thinkOpen = true; } parts.push(d.reasoning_content); } else { if (thinkOpen) { parts.push('\n\n\n'); thinkOpen = false; } if (d.content) parts.push(d.content); } } } if (thinkOpen) parts.push('\n\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'));