377 lines
15 KiB
JavaScript
377 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* ollama-eval.mjs — Ollama-powered Job Offer Evaluator for career-ops
|
|
*
|
|
* Local, free, private 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 CLI argument or file.
|
|
*
|
|
* Usage:
|
|
* node ollama-eval.mjs "Paste full JD text here"
|
|
* node ollama-eval.mjs --file ./jds/my-job.txt
|
|
* node ollama-eval.mjs --model qwen2.5:72b --file ./jds/my-job.txt
|
|
*
|
|
* Requires:
|
|
* Ollama running locally — https://ollama.com
|
|
* A model pulled: ollama pull llama3.3
|
|
*
|
|
* Context window guidance:
|
|
* The prompt (cv + modes + JD) is ~10K-15K tokens.
|
|
* Recommended models (32K+ context): llama3.3, mistral-nemo, qwen2.5, gemma3
|
|
* Smaller models (llama3.2:3b, phi3) may produce incomplete evaluations.
|
|
*/
|
|
|
|
import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
|
|
import { join, dirname } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
try {
|
|
const { config } = await import('dotenv');
|
|
config();
|
|
} catch { /* dotenv optional */ }
|
|
|
|
const ROOT = dirname(fileURLToPath(import.meta.url));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Paths
|
|
// ---------------------------------------------------------------------------
|
|
const PATHS = {
|
|
shared: join(ROOT, 'modes', '_shared.md'),
|
|
oferta: join(ROOT, 'modes', 'oferta.md'),
|
|
cv: join(ROOT, 'cv.md'),
|
|
reports: join(ROOT, 'reports'),
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI argument parsing
|
|
// ---------------------------------------------------------------------------
|
|
const args = process.argv.slice(2);
|
|
|
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
console.log(`
|
|
╔══════════════════════════════════════════════════════════════════╗
|
|
║ career-ops — Ollama Evaluator (local / free) ║
|
|
╚══════════════════════════════════════════════════════════════════╝
|
|
|
|
Evaluate a job offer using a local Ollama model instead of Claude.
|
|
|
|
USAGE
|
|
node ollama-eval.mjs "<JD text>"
|
|
node ollama-eval.mjs --file ./jds/my-job.txt
|
|
node ollama-eval.mjs --model qwen2.5:72b "<JD text>"
|
|
|
|
OPTIONS
|
|
--file <path> Read JD from a file instead of inline text
|
|
--model <name> Ollama model to use (default: llama3.3)
|
|
--url <url> Ollama base URL (default: http://localhost:11434)
|
|
--no-save Do not save report to reports/ directory
|
|
--help Show this help
|
|
|
|
SETUP
|
|
1. Install Ollama: https://ollama.com
|
|
2. Pull a model: ollama pull llama3.3
|
|
3. Start server: ollama serve (or it auto-starts)
|
|
4. Run this script
|
|
|
|
EXAMPLES
|
|
node ollama-eval.mjs "We are looking for a Senior AI Engineer..."
|
|
node ollama-eval.mjs --file ./jds/openai-swe.txt
|
|
OLLAMA_MODEL=mistral-nemo node ollama-eval.mjs --file ./jds/job.txt
|
|
`);
|
|
process.exit(0);
|
|
}
|
|
|
|
// Parse flags
|
|
let jdText = '';
|
|
let modelName = process.env.OLLAMA_MODEL || 'llama3.3';
|
|
let baseUrl = (process.env.OLLAMA_BASE_URL || 'http://localhost:11434').replace(/\/$/, '');
|
|
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);
|
|
}
|
|
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] === '--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);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// File helpers
|
|
// ---------------------------------------------------------------------------
|
|
/**
|
|
* Read a file and return its trimmed contents, or a placeholder if missing.
|
|
* Emits a console warning when the file is absent so the user knows context is incomplete.
|
|
* @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();
|
|
}
|
|
|
|
/**
|
|
* Determine the next zero-padded report number based on existing files in reports/.
|
|
* Scans for files whose name starts with one or more digits followed by a hyphen,
|
|
* then returns max + 1 padded to at least 3 digits. Supports report counts above 999
|
|
* without resetting or colliding (avoids the fixed-3-digit slice assumption).
|
|
* @returns {string} Zero-padded report number string, e.g. "042" or "1001".
|
|
*/
|
|
function nextReportNumber() {
|
|
if (!existsSync(PATHS.reports)) return '001';
|
|
const files = readdirSync(PATHS.reports)
|
|
.map(f => { const m = f.match(/^(\d+)-/); return m ? parseInt(m[1], 10) : NaN; })
|
|
.filter(n => !isNaN(n));
|
|
if (files.length === 0) return '001';
|
|
return String(Math.max(...files) + 1).padStart(3, '0');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Loopback guard — cv.md + full JD are sent to this endpoint.
|
|
// A remote URL would silently exfiltrate private data.
|
|
// ---------------------------------------------------------------------------
|
|
{
|
|
let hostname;
|
|
try {
|
|
hostname = new URL(baseUrl).hostname;
|
|
} catch {
|
|
console.error(`❌ Invalid OLLAMA_BASE_URL: "${baseUrl}"`);
|
|
process.exit(1);
|
|
}
|
|
const isLoopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
|
if (!isLoopback && process.env.OLLAMA_ALLOW_REMOTE !== '1') {
|
|
console.error(`
|
|
❌ Remote Ollama endpoint detected: ${baseUrl}
|
|
|
|
Your CV and job description would be sent to a remote server.
|
|
This tool is designed for local use only.
|
|
|
|
If you intentionally want to use a remote endpoint (e.g. tunnelled
|
|
Ollama on a home server), set:
|
|
OLLAMA_ALLOW_REMOTE=1 node ollama-eval.mjs ...
|
|
`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Check Ollama is reachable before burning time on prompt assembly
|
|
// ---------------------------------------------------------------------------
|
|
try {
|
|
const probe = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(5_000) });
|
|
if (!probe.ok) throw new Error(`HTTP ${probe.status}`);
|
|
} catch (err) {
|
|
console.error(`
|
|
❌ Ollama not reachable at ${baseUrl}
|
|
|
|
1. Install Ollama: https://ollama.com
|
|
2. Start server: ollama serve
|
|
3. Pull a model: ollama pull ${modelName}
|
|
`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 system prompt
|
|
// ---------------------------------------------------------------------------
|
|
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 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. Generate Blocks A through G in full.
|
|
3. 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---
|
|
`;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Call Ollama
|
|
// ---------------------------------------------------------------------------
|
|
const endpoint = `${baseUrl}/v1/chat/completions`;
|
|
const timeoutMs = parseInt(process.env.OLLAMA_TIMEOUT_MS || '300000', 10);
|
|
if (Number.isNaN(timeoutMs) || timeoutMs <= 0) {
|
|
console.error(`❌ Invalid OLLAMA_TIMEOUT_MS: "${process.env.OLLAMA_TIMEOUT_MS}" — must be a positive integer (milliseconds).`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`🤖 Calling Ollama (${modelName})... this may take a minute.\n`);
|
|
|
|
let evaluationText;
|
|
try {
|
|
const res = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: modelName,
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: `JOB DESCRIPTION TO EVALUATE:\n\n${jdText}` },
|
|
],
|
|
stream: false,
|
|
temperature: 0.4,
|
|
options: { num_ctx: 32768 },
|
|
}),
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const body = await res.text();
|
|
console.error(`❌ Ollama API error: HTTP ${res.status}`);
|
|
console.error(` ${body.slice(0, 300)}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const data = await res.json();
|
|
evaluationText = data.choices?.[0]?.message?.content?.trim();
|
|
if (!evaluationText) {
|
|
console.error('❌ Ollama 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 OLLAMA_TIMEOUT_MS.`);
|
|
} else {
|
|
console.error(`❌ Ollama API call failed: ${err.message}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Display evaluation
|
|
// ---------------------------------------------------------------------------
|
|
console.log('\n' + '═'.repeat(66));
|
|
console.log(' CAREER-OPS EVALUATION — powered by Ollama (' + modelName + ')');
|
|
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 extract = (key) => {
|
|
const m = summaryMatch[1].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:** Ollama (${modelName})
|
|
|
|
---
|
|
|
|
${evaluationText.replace(/---SCORE_SUMMARY---[\s\S]*?---END_SUMMARY---/, '').trim()}
|
|
`;
|
|
|
|
writeFileSync(reportPath, reportContent, 'utf-8');
|
|
console.log(`\n✅ Report saved: reports/${filename}`);
|
|
|
|
console.log(`\n📊 Tracker entry (add to data/applications.md):`);
|
|
console.log(` | ${num} | ${today} | ${company} | ${role} | ${score}/5 | Evaluated | ❌ | [${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');
|