forked from santifer/career-ops
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgemini-eval.mjs
More file actions
319 lines (274 loc) · 12.3 KB
/
Copy pathgemini-eval.mjs
File metadata and controls
319 lines (274 loc) · 12.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
#!/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.0-flash (generous quota, no billing required)
*/
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
// ---------------------------------------------------------------------------
// 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'),
reports: join(ROOT, 'reports'),
tracker: join(ROOT, 'data', 'applications.md'),
};
// ---------------------------------------------------------------------------
// 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.0-flash "<JD text>"
OPTIONS
--file <path> Read JD from a file instead of inline text
--model <name> Gemini model to use (default: gemini-2.0-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.0-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');
}
// 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');
// ---------------------------------------------------------------------------
// 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}
═══════════════════════════════════════════════════════
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) {
console.error('❌ Gemini API error:', err.message);
if (err.message?.includes('API_KEY')) {
console.error(' Check your GEMINI_API_KEY in .env');
} else if (err.message?.includes('quota') || err.message?.includes('rate')) {
console.error(' You may have hit the free-tier rate limit. Wait 60s and retry.');
}
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 m = block.match(new RegExp(`${key}:\\s*(.+)`));
return m ? m[1].trim() : 'unknown';
};
company = extract('COMPANY');
role = extract('ROLE');
score = extract('SCORE');
archetype = extract('ARCHETYPE');
legitimacy = extract('LEGITIMACY');
}
// ---------------------------------------------------------------------------
// Save report
// ---------------------------------------------------------------------------
if (saveReport) {
try {
if (!existsSync(PATHS.reports)) {
mkdirSync(PATHS.reports, { recursive: true });
}
const num = nextReportNumber();
const today = new Date().toISOString().split('T')[0];
const companySlug = company.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const filename = `${num}-${companySlug}-${today}.md`;
const reportPath = join(PATHS.reports, filename);
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');
console.log(`\n✅ Report saved: reports/${filename}`);
// Append tracker entry reminder
console.log(`\n📊 Tracker entry (add to data/applications.md):`);
console.log(` | ${num} | ${today} | ${company} | ${role} | ${score} | Evaluada | ❌ | [${num}](reports/${filename}) |`);
} catch (err) {
console.warn(`⚠️ Could not save report: ${err.message}`);
}
}
console.log('\n' + '─'.repeat(66));
console.log(` Score: ${score}/5 | Archetype: ${archetype} | Legitimacy: ${legitimacy}`);
console.log('─'.repeat(66) + '\n');