-
-
Notifications
You must be signed in to change notification settings - Fork 10.8k
Expand file tree
/
Copy pathgemini-eval.mjs
More file actions
449 lines (393 loc) · 17.3 KB
/
Copy pathgemini-eval.mjs
File metadata and controls
449 lines (393 loc) · 17.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
#!/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 } 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 "<JD text>"
node gemini-eval.mjs --file ./jds/my-job.txt
node gemini-eval.mjs --model gemini-2.5-flash "<JD text>"
OPTIONS
--file <path> Read JD from a file instead of inline text
--model <name> 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=<your-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`;
}
// Lazy import — only used when saving
let readdirSync;
try {
({ readdirSync } = await import('fs'));
} catch { /* already imported above via named exports */ }
// Use named import fallback
if (!readdirSync) {
readdirSync = (await import('fs')).readdirSync;
}
// ---------------------------------------------------------------------------
// 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: <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---
`;
// ---------------------------------------------------------------------------
// 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');