#!/usr/bin/env node /** * gemini-eval.mjs — Gemini-powered Job Offer Evaluator for career-ops * * A free-tier alternative to the Claude-based pipeline. * Reads evaluation logic from modes/oferta.md + modes/_shared.md, * reads the user's resume from cv.md, and evaluates a Job Description * passed as a command-line argument. * * Usage: * node gemini-eval.mjs "Paste full JD text here" * node gemini-eval.mjs --file ./jds/my-job.txt * * Requires: * GEMINI_API_KEY in .env (or environment variable) * * Free-tier model: gemini-2.5-flash (generous quota, no billing required) * * Model deprecation reference (per Google AI for Developers, May 2026): * - gemini-2.0-flash deprecated 2026-03-31 (do not use) * - gemini-2.0-flash-lite deprecated 2026-03-31 * - gemini-2.5-flash deprecated 2026-06-17 (current default) * - gemini-2.5-flash-lite deprecated 2026-07-22 * Stable Gemini models follow a 12-month lifecycle from their release date. * Source: https://ai.google.dev/gemini-api/docs/models * * When the current default approaches its deprecation date, bump * `modelName` below and the `--model` examples accordingly. */ import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { execFileSync } from 'child_process'; // --------------------------------------------------------------------------- // Bootstrap: load .env before anything else // --------------------------------------------------------------------------- try { const { config } = await import('dotenv'); config(); } catch { // dotenv is optional — fall back to process.env if not installed } import { GoogleGenerativeAI } from '@google/generative-ai'; // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- const ROOT = dirname(fileURLToPath(import.meta.url)); const PATHS = { // Primary evaluation logic lives in these two mode files shared: join(ROOT, 'modes', '_shared.md'), oferta: join(ROOT, 'modes', 'oferta.md'), // Canonical skill path referenced in Issue #344 evaluate: join(ROOT, '.claude', 'skills', 'career-ops', 'SKILL.md'), cv: join(ROOT, 'cv.md'), profile: join(ROOT, 'modes', '_profile.md'), profileYml: join(ROOT, 'config', 'profile.yml'), reports: join(ROOT, 'reports'), tracker: join(ROOT, 'data', 'applications.md'), trackerAdditions: join(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 — Gemini Evaluator (free-tier) ║ ╚══════════════════════════════════════════════════════════════════╝ Evaluate a job offer using Google Gemini instead of Claude. USAGE node gemini-eval.mjs "" node gemini-eval.mjs --file ./jds/my-job.txt node gemini-eval.mjs --model gemini-2.5-flash "" OPTIONS --file Read JD from a file instead of inline text --model Gemini model to use (default: gemini-2.5-flash) --no-save Do not save report to reports/ directory --help Show this help SETUP 1. Get a free API key at https://aistudio.google.com/apikey 2. Add GEMINI_API_KEY= to .env 3. Run: npm install (installs @google/generative-ai + dotenv) EXAMPLES node gemini-eval.mjs "We are looking for a Senior AI Engineer..." node gemini-eval.mjs --file ./jds/openai-swe.txt `); process.exit(0); } // Parse flags let jdText = ''; let modelName = process.env.GEMINI_MODEL || 'gemini-2.5-flash'; let saveReport = true; 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); } jdText = readFileSync(filePath, 'utf-8').trim(); } else if (args[i] === '--model' && args[i + 1]) { modelName = args[++i]; } else if (args[i] === '--no-save') { saveReport = false; } 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); } // --------------------------------------------------------------------------- // Validate environment // --------------------------------------------------------------------------- const apiKey = process.env.GEMINI_API_KEY; if (!apiKey) { console.error(` ❌ GEMINI_API_KEY not found. 1. Get a free key at https://aistudio.google.com/apikey 2. Add it to .env: GEMINI_API_KEY=your_key_here 3. Or export it: export GEMINI_API_KEY=your_key_here `); process.exit(1); } // --------------------------------------------------------------------------- // File helpers // --------------------------------------------------------------------------- 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(); } function nextReportNumber() { if (!existsSync(PATHS.reports)) return '001'; const files = readdirSync(PATHS.reports) .filter(f => /^\d{3}-/.test(f)) .map(f => parseInt(f.slice(0, 3))) .filter(n => !isNaN(n)); if (files.length === 0) return '001'; return String(Math.max(...files) + 1).padStart(3, '0'); } function validateEvaluationShape(text) { const issues = []; const requiredBlocks = [ ['A', /(?:^|\n)#{1,3}\s*(?:A[).:-]?|Block A\b)/im], ['B', /(?:^|\n)#{1,3}\s*(?:B[).:-]?|Block B\b)/im], ['C', /(?:^|\n)#{1,3}\s*(?:C[).:-]?|Block C\b)/im], ['D', /(?:^|\n)#{1,3}\s*(?:D[).:-]?|Block D\b)/im], ['E', /(?:^|\n)#{1,3}\s*(?:E[).:-]?|Block E\b)/im], ['F', /(?:^|\n)#{1,3}\s*(?:F[).:-]?|Block F\b)/im], ['G', /(?:^|\n)#{1,3}\s*(?:G[).:-]?|Block G\b)/im], ]; for (const [label, pattern] of requiredBlocks) { if (!pattern.test(text)) issues.push(`missing Block ${label}`); } const summary = text.match(/---SCORE_SUMMARY---\s*([\s\S]*?)---END_SUMMARY---/); if (!summary) { issues.push('missing SCORE_SUMMARY block'); } else { const summaryBlock = summary[1]; for (const key of ['COMPANY', 'ROLE', 'ARCHETYPE', 'LEGITIMACY']) { const field = summaryBlock.match(new RegExp(`^\\s*${key}:\\s*(.+)$`, 'mi')); const value = field?.[1]?.trim() ?? ''; if (!value || (key !== 'COMPANY' && value.toLowerCase() === 'unknown')) { issues.push(`SCORE_SUMMARY ${key} is required`); } } const score = summaryBlock.match(/^\s*SCORE:\s*([0-9]+(?:\.[0-9]+)?)/mi); const scoreValue = score ? Number(score[1]) : NaN; if (!Number.isFinite(scoreValue) || scoreValue < 0 || scoreValue > 5) { issues.push('SCORE_SUMMARY score must be a number between 0 and 5'); } } if (issues.length > 0) { throw new Error(`Gemini returned an invalid career-ops report: ${issues.join('; ')}`); } } function slugifyCompany(value) { return String(value || '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') || 'unknown'; } function tsvSafe(value) { return String(value ?? '').replace(/[\t\r\n]+/g, ' ').trim(); } function normalizedTrackerScore(value) { const clean = tsvSafe(value); if (!clean || clean === '?') return 'N/A'; return /\/5$/i.test(clean) ? clean : `${clean}/5`; } // --------------------------------------------------------------------------- // 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 profileContent = readFile(PATHS.profile, 'modes/_profile.md'); const profileYml = readFile(PATHS.profileYml, 'config/profile.yml'); // --------------------------------------------------------------------------- // Build the system prompt (mirrors the Claude skill router logic) // --------------------------------------------------------------------------- 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. ═══════════════════════════════════════════════════════ SYSTEM CONTEXT (_shared.md) ═══════════════════════════════════════════════════════ ${sharedContext} ═══════════════════════════════════════════════════════ EVALUATION MODE (oferta.md) ═══════════════════════════════════════════════════════ ${ofertaLogic} ═══════════════════════════════════════════════════════ CANDIDATE RESUME (cv.md) ═══════════════════════════════════════════════════════ ${cvContent} ═══════════════════════════════════════════════════════ CANDIDATE PROFILE & TARGETS (config/profile.yml) ═══════════════════════════════════════════════════════ ${profileYml} ═══════════════════════════════════════════════════════ USER ARCHETYPES & NARRATIVE (_profile.md) ═══════════════════════════════════════════════════════ ${profileContent} ═══════════════════════════════════════════════════════ IMPORTANT OPERATING RULES FOR THIS CLI SESSION ═══════════════════════════════════════════════════════ 1. You do NOT have access to WebSearch, Playwright, or file writing tools. - For Block D (Comp research): provide salary estimates based on your training data, clearly noted as estimates. - For Block G (Legitimacy): analyze the JD text only; skip URL/page freshness checks. - Post-evaluation file saving is handled by the script, not by you. 2. Generate Blocks A through G in full, in English, unless the JD is in another language. 3. At the very end, output a machine-readable summary block in this exact format: ---SCORE_SUMMARY--- COMPANY: ROLE: SCORE: ARCHETYPE: LEGITIMACY: ---END_SUMMARY--- `; // --------------------------------------------------------------------------- // Call Gemini API // --------------------------------------------------------------------------- console.log(`🤖 Calling Gemini (${modelName})... this may take 30-60 seconds.\n`); const genAI = new GoogleGenerativeAI(apiKey); const model = genAI.getGenerativeModel({ model: modelName, generationConfig: { temperature: 0.4, // deterministic enough for structured evaluation maxOutputTokens: 8192, // full 7-block evaluation }, }); let evaluationText; try { const result = await model.generateContent([ { text: systemPrompt }, { text: `\n\nJOB DESCRIPTION TO EVALUATE:\n\n${jdText}` }, ]); evaluationText = result.response.text(); } catch (err) { const sanitizedMsg = (err.message || '').split(apiKey).join('[REDACTED]'); console.error('❌ Gemini API error:', sanitizedMsg); if (sanitizedMsg.includes('API_KEY')) { console.error(' Check your GEMINI_API_KEY in .env'); } else if (sanitizedMsg.includes('quota') || sanitizedMsg.includes('rate')) { console.error(' You may have hit the free-tier rate limit. Wait 60s and retry.'); } process.exit(1); } try { validateEvaluationShape(evaluationText); } catch (err) { console.error('❌ Gemini output failed validation:', err.message); console.error(' No report was saved. Retry, lower temperature, or use the Claude pipeline for this JD.'); process.exit(1); } // --------------------------------------------------------------------------- // Display evaluation // --------------------------------------------------------------------------- console.log('\n' + '═'.repeat(66)); console.log(' CAREER-OPS EVALUATION — powered by Google Gemini'); console.log('═'.repeat(66) + '\n'); console.log(evaluationText); // --------------------------------------------------------------------------- // Parse score summary // --------------------------------------------------------------------------- const summaryMatch = evaluationText.match( /---SCORE_SUMMARY---\s*([\s\S]*?)---END_SUMMARY---/ ); let company = 'unknown'; let role = 'unknown'; let score = '?'; let archetype = 'unknown'; let legitimacy = 'unknown'; if (summaryMatch) { const block = summaryMatch[1]; const extract = (key) => { const prefix = `${key}:`; const lines = block.split('\n'); for (const line of lines) { const trimmed = line.trimStart(); if (trimmed.startsWith(prefix)) { return trimmed.slice(prefix.length).trim(); } } return 'unknown'; }; company = extract('COMPANY'); role = extract('ROLE'); score = extract('SCORE'); archetype = extract('ARCHETYPE'); legitimacy = extract('LEGITIMACY'); } // --------------------------------------------------------------------------- // Save report // --------------------------------------------------------------------------- if (saveReport) { let reportSaved = false; try { if (!existsSync(PATHS.reports)) { mkdirSync(PATHS.reports, { recursive: true }); } const num = nextReportNumber(); const today = new Date().toISOString().split('T')[0]; const companySlug = slugifyCompany(company); const filename = `${num}-${companySlug}-${today}.md`; const reportPath = join(PATHS.reports, filename); const trackerPath = join(PATHS.trackerAdditions, `${num}-${companySlug}.tsv`); const reportContent = `# Evaluation: ${company} — ${role} **Date:** ${today} **Archetype:** ${archetype} **Score:** ${score}/5 **Legitimacy:** ${legitimacy} **PDF:** pending **Tool:** Gemini (${modelName}) --- ${evaluationText.replace(/---SCORE_SUMMARY---[\s\S]*?---END_SUMMARY---/, '').trim()} `; writeFileSync(reportPath, reportContent, 'utf-8'); mkdirSync(PATHS.trackerAdditions, { recursive: true }); const trackerFields = [ String(parseInt(num, 10)), today, tsvSafe(company), tsvSafe(role), 'Evaluated', normalizedTrackerScore(score), '❌', `[${num}](reports/${filename})`, 'Gemini evaluation', ]; writeFileSync(trackerPath, `${trackerFields.join('\t')}\n`, 'utf-8'); console.log(`\n✅ Report saved: reports/${filename}`); console.log(`📊 Tracker addition saved: batch/tracker-additions/${num}-${companySlug}.tsv`); reportSaved = true; } catch (err) { console.warn(`⚠️ Could not save report: ${err.message}`); process.exitCode = 1; } if (reportSaved) { try { const mergeOutput = execFileSync(process.execPath, [join(ROOT, 'merge-tracker.mjs')], { cwd: ROOT, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], }); if (mergeOutput.trim()) console.log(mergeOutput.trim()); console.log('📊 Tracker merged into data/applications.md.'); } catch (err) { console.warn(`⚠️ Report saved, but could not merge tracker addition into data/applications.md: ${err.message}`); process.exitCode = 1; } } } console.log('\n' + '─'.repeat(66)); console.log(` Score: ${score}/5 | Archetype: ${archetype} | Legitimacy: ${legitimacy}`); console.log('─'.repeat(66) + '\n');