feat: AI job filter — score jobs 0-10 with Claude Haiku before applying
- lib/filter.mjs: batch scoring engine (10 jobs/call, Claude Haiku) - job_filter.mjs: standalone CLI with --dry-run and --stats flags - Threshold configurable globally + per-search in search_config.json (filter_min_score, default 5) - Job profiles (gtm/ae) passed as context via settings.filter.job_profiles - Filtered jobs get status='filtered' with filter_score + filter_reason - Filter errors pass jobs through (never block applications) - status.mjs: added 'AI filtered' line to report
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"_note": "Configure your job searches here. Each search runs on both listed platforms.",
|
"_note": "Configure your job searches here. Each search runs on both listed platforms.",
|
||||||
"first_run_days": 90,
|
"first_run_days": 90,
|
||||||
|
"filter_min_score": 5,
|
||||||
"searches": [
|
"searches": [
|
||||||
{
|
{
|
||||||
"name": "Founding GTM",
|
"name": "Founding GTM",
|
||||||
"track": "gtm",
|
"track": "gtm",
|
||||||
|
"filter_min_score": 5,
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"founding account executive",
|
"founding account executive",
|
||||||
"first sales hire",
|
"first sales hire",
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
{
|
{
|
||||||
"name": "Enterprise AE",
|
"name": "Enterprise AE",
|
||||||
"track": "ae",
|
"track": "ae",
|
||||||
|
"filter_min_score": 5,
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"enterprise account executive SaaS remote",
|
"enterprise account executive SaaS remote",
|
||||||
"senior account executive technical SaaS remote",
|
"senior account executive technical SaaS remote",
|
||||||
|
|||||||
127
job_filter.mjs
Normal file
127
job_filter.mjs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { loadEnv } from './lib/env.mjs';
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* job_filter.mjs — claw-apply AI Job Filter
|
||||||
|
* Scores all queued 'new' jobs 0-10 against candidate profile using Claude Haiku
|
||||||
|
* Jobs below filter_min_score (default 5, configurable per-search in search_config.json)
|
||||||
|
* are marked 'filtered' and skipped by the applier
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node job_filter.mjs — filter all new jobs
|
||||||
|
* node job_filter.mjs --dry-run — score without writing status changes
|
||||||
|
* node job_filter.mjs --stats — show filter stats only (no re-filter)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { dirname, resolve } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
import { getJobsByStatus, updateJobStatus, loadConfig } from './lib/queue.mjs';
|
||||||
|
import { acquireLock } from './lib/lock.mjs';
|
||||||
|
import { runFilter } from './lib/filter.mjs';
|
||||||
|
|
||||||
|
const isDryRun = process.argv.includes('--dry-run');
|
||||||
|
const isStats = process.argv.includes('--stats');
|
||||||
|
|
||||||
|
async function showStats() {
|
||||||
|
const all = getJobsByStatus(['new', 'filtered']);
|
||||||
|
const filtered = all.filter(j => j.status === 'filtered');
|
||||||
|
const scored = all.filter(j => j.filter_score != null);
|
||||||
|
|
||||||
|
console.log(`📊 Filter Stats\n`);
|
||||||
|
console.log(` Filtered (blocked): ${filtered.length}`);
|
||||||
|
console.log(` New (passed/unscored): ${all.length - filtered.length}`);
|
||||||
|
console.log(` Total scored: ${scored.length}\n`);
|
||||||
|
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
console.log(`Sample filtered jobs:`);
|
||||||
|
filtered.slice(0, 10).forEach(j => {
|
||||||
|
console.log(` [${j.filter_score}/10] ${j.title} @ ${j.company} — ${j.filter_reason}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (isStats) {
|
||||||
|
await showStats();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
console.error('❌ ANTHROPIC_API_KEY not set — filter requires Anthropic API');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lock = acquireLock('filter', resolve(__dir, 'data'));
|
||||||
|
|
||||||
|
const settings = loadConfig(resolve(__dir, 'config/settings.json'));
|
||||||
|
const searchConfig = loadConfig(resolve(__dir, 'config/search_config.json'));
|
||||||
|
const candidateProfile = loadConfig(resolve(__dir, 'config/profile.json'));
|
||||||
|
|
||||||
|
const jobs = getJobsByStatus('new');
|
||||||
|
const globalMin = searchConfig.filter_min_score ?? 5;
|
||||||
|
|
||||||
|
console.log(`🔍 claw-apply: AI Job Filter${isDryRun ? ' (DRY RUN)' : ''}\n`);
|
||||||
|
console.log(` Jobs to score: ${jobs.length}`);
|
||||||
|
console.log(` Default threshold: ${globalMin}/10\n`);
|
||||||
|
|
||||||
|
if (jobs.length === 0) {
|
||||||
|
console.log('Nothing to filter.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let passed = 0, filtered = 0, errors = 0;
|
||||||
|
const filterLog = [];
|
||||||
|
|
||||||
|
const results = await runFilter(jobs, searchConfig, settings, candidateProfile, apiKey, {
|
||||||
|
onProgress: (done, total, track) => {
|
||||||
|
process.stdout.write(`\r [${track}] ${done}/${total} scored...`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n');
|
||||||
|
|
||||||
|
for (const { job, score, reason, pass, minScore } of results) {
|
||||||
|
if (score === null) {
|
||||||
|
errors++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
filterLog.push({ id: job.id, title: job.title, company: job.company, score, reason, pass, minScore });
|
||||||
|
|
||||||
|
if (pass) {
|
||||||
|
passed++;
|
||||||
|
if (!isDryRun) {
|
||||||
|
updateJobStatus(job.id, 'new', { filter_score: score, filter_reason: reason });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filtered++;
|
||||||
|
if (!isDryRun) {
|
||||||
|
updateJobStatus(job.id, 'filtered', { filter_score: score, filter_reason: reason });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Filter complete${isDryRun ? ' (no changes written)' : ''}`);
|
||||||
|
console.log(` ✅ Passed: ${passed}`);
|
||||||
|
console.log(` 🚫 Filtered: ${filtered}`);
|
||||||
|
console.log(` ⚠️ Errors: ${errors} (passed through)`);
|
||||||
|
console.log(` 📊 Pass rate: ${jobs.length > 0 ? Math.round((passed / jobs.length) * 100) : 0}%\n`);
|
||||||
|
|
||||||
|
if (isDryRun && filterLog.length > 0) {
|
||||||
|
console.log(`Sample scores:`);
|
||||||
|
filterLog.slice(0, 20).forEach(j => {
|
||||||
|
const icon = j.pass ? '✅' : '🚫';
|
||||||
|
console.log(` ${icon} [${j.score}/10] ${j.title} @ ${j.company} — ${j.reason}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Fatal:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
171
lib/filter.mjs
Normal file
171
lib/filter.mjs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* filter.mjs — AI job relevance filter
|
||||||
|
* Scores queued jobs 0-10 against candidate profile + job profiles using Claude Haiku
|
||||||
|
* Jobs below filter_min_score are marked 'filtered' and skipped by the applier
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
|
||||||
|
const BATCH_SIZE = 10;
|
||||||
|
const DESC_MAX_CHARS = 800;
|
||||||
|
|
||||||
|
function loadProfile(profilePath) {
|
||||||
|
if (!profilePath || !existsSync(profilePath)) return null;
|
||||||
|
try { return JSON.parse(readFileSync(profilePath, 'utf8')); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSystemPrompt(jobProfile, candidateProfile) {
|
||||||
|
const tr = jobProfile.target_role;
|
||||||
|
const exp = jobProfile.experience || {};
|
||||||
|
const highlights = (exp.highlights || []).map(h => `- ${h}`).join('\n');
|
||||||
|
|
||||||
|
return `You are a job relevance scorer. Score each job 0-10 based on how well it matches this candidate.
|
||||||
|
|
||||||
|
## Candidate
|
||||||
|
- Name: ${candidateProfile.name?.first} ${candidateProfile.name?.last}
|
||||||
|
- Location: ${candidateProfile.location?.city}, ${candidateProfile.location?.state} (remote only, will not relocate)
|
||||||
|
- Years in sales: ${candidateProfile.years_experience}
|
||||||
|
- Desired salary: $${(candidateProfile.desired_salary || 0).toLocaleString()}
|
||||||
|
- Background: ${(candidateProfile.cover_letter || '').substring(0, 300)}
|
||||||
|
|
||||||
|
## Target Role Criteria
|
||||||
|
- Titles: ${(tr.titles || []).join(', ')}
|
||||||
|
- Industries: ${(exp.industries || []).join(', ')}
|
||||||
|
- Company stage: ${(tr.company_stage || []).join(', ') || 'any'}
|
||||||
|
- Company size: ${tr.company_size || 'any'}
|
||||||
|
- Salary minimum: $${(tr.salary_min || 0).toLocaleString()}
|
||||||
|
- Remote only: ${tr.remote ? 'Yes' : 'No'}
|
||||||
|
- Excluded keywords: ${(tr.exclude_keywords || []).join(', ')}
|
||||||
|
|
||||||
|
## Experience Highlights
|
||||||
|
${highlights}
|
||||||
|
|
||||||
|
## Scoring Guide
|
||||||
|
10 = Perfect match (exact title, right company stage, right industry, right salary range)
|
||||||
|
7-9 = Strong match (right role type, maybe slightly off industry or stage)
|
||||||
|
5-6 = Borderline (relevant but some mismatches — wrong industry, wrong seniority, or vague posting)
|
||||||
|
3-4 = Weak match (mostly off target but some overlap)
|
||||||
|
0-2 = Not relevant (wrong role type, wrong industry, recruiter spam, part-time, etc.)
|
||||||
|
|
||||||
|
Penalize heavily for:
|
||||||
|
- Part-time roles
|
||||||
|
- Wrong industry (insurance, healthcare PR, construction, retail, K-12 education, utilities)
|
||||||
|
- Wrong role type (SDR/BDR, customer success, partnerships, marketing, coordinator)
|
||||||
|
- Junior or entry-level
|
||||||
|
- Staffing agency spam where no real company is named
|
||||||
|
- Salary clearly below minimum`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUserPrompt(jobs) {
|
||||||
|
const jobList = jobs.map((j, i) => {
|
||||||
|
const desc = (j.description || '').substring(0, DESC_MAX_CHARS).replace(/\s+/g, ' ').trim();
|
||||||
|
return `JOB ${i + 1}
|
||||||
|
Title: ${j.title}
|
||||||
|
Company: ${j.company || 'Unknown'}
|
||||||
|
Location: ${j.location || 'Unknown'}
|
||||||
|
Description: ${desc}`;
|
||||||
|
}).join('\n\n---\n\n');
|
||||||
|
|
||||||
|
return `Score each of the following ${jobs.length} jobs. Return ONLY a JSON array with one object per job in order:
|
||||||
|
[{"score": 7, "reason": "one line explaining score"}, ...]
|
||||||
|
|
||||||
|
${jobList}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filterBatch(jobs, jobProfile, candidateProfile, apiKey) {
|
||||||
|
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'anthropic-version': '2023-06-01'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'claude-3-haiku-20240307',
|
||||||
|
max_tokens: 1024,
|
||||||
|
system: buildSystemPrompt(jobProfile, candidateProfile),
|
||||||
|
messages: [{ role: 'user', content: buildUserPrompt(jobs) }]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Anthropic API error: ${res.status} ${res.statusText}`);
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) throw new Error(data.error.message);
|
||||||
|
|
||||||
|
const text = data.content[0].text.trim();
|
||||||
|
const clean = text.replace(/```json\n?|\n?```/g, '').trim();
|
||||||
|
return JSON.parse(clean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* runFilter — score all new jobs and return results
|
||||||
|
* @param {Array} jobs - jobs with status 'new'
|
||||||
|
* @param {Object} searchConfig - search_config.json
|
||||||
|
* @param {Object} settings - settings.json (needs settings.filter.job_profiles)
|
||||||
|
* @param {Object} candidateProfile - profile.json
|
||||||
|
* @param {string} apiKey - Anthropic API key
|
||||||
|
* @param {Object} opts - { onProgress }
|
||||||
|
* @returns {Array} [{ job, score, reason, pass, minScore }]
|
||||||
|
*/
|
||||||
|
export async function runFilter(jobs, searchConfig, settings, candidateProfile, apiKey, { onProgress } = {}) {
|
||||||
|
const globalMin = searchConfig.filter_min_score ?? 5;
|
||||||
|
|
||||||
|
// Group jobs by track
|
||||||
|
const byTrack = {};
|
||||||
|
for (const job of jobs) {
|
||||||
|
const track = job.track || 'ae';
|
||||||
|
if (!byTrack[track]) byTrack[track] = [];
|
||||||
|
byTrack[track].push(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const [track, trackJobs] of Object.entries(byTrack)) {
|
||||||
|
const searchEntry = (searchConfig.searches || []).find(s => s.track === track);
|
||||||
|
const minScore = searchEntry?.filter_min_score ?? globalMin;
|
||||||
|
|
||||||
|
const profilePath = settings.filter?.job_profiles?.[track];
|
||||||
|
const jobProfile = loadProfile(profilePath);
|
||||||
|
|
||||||
|
if (!jobProfile) {
|
||||||
|
console.warn(`⚠️ No job profile configured for track "${track}" — passing ${trackJobs.length} jobs through unfiltered`);
|
||||||
|
for (const job of trackJobs) {
|
||||||
|
results.push({ job, score: null, reason: 'no_profile', pass: true, minScore });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let done = 0;
|
||||||
|
for (let i = 0; i < trackJobs.length; i += BATCH_SIZE) {
|
||||||
|
const batch = trackJobs.slice(i, i + BATCH_SIZE);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scores = await filterBatch(batch, jobProfile, candidateProfile, apiKey);
|
||||||
|
|
||||||
|
for (let j = 0; j < batch.length; j++) {
|
||||||
|
const job = batch[j];
|
||||||
|
const result = scores[j] || { score: 5, reason: 'parse_error' };
|
||||||
|
results.push({
|
||||||
|
job,
|
||||||
|
score: result.score,
|
||||||
|
reason: result.reason,
|
||||||
|
pass: result.score >= minScore,
|
||||||
|
minScore
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`\n Filter batch error (track: ${track}, batch ${i}–${i + batch.length}): ${err.message}`);
|
||||||
|
// On error, pass jobs through — don't block applications
|
||||||
|
for (const job of batch) {
|
||||||
|
results.push({ job, score: null, reason: 'filter_error', pass: true, minScore });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
done += batch.length;
|
||||||
|
if (onProgress) onProgress(done, trackJobs.length, track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -111,6 +111,7 @@ function buildStatus() {
|
|||||||
queue: {
|
queue: {
|
||||||
total: queue.length,
|
total: queue.length,
|
||||||
new: byStatus['new'] || 0,
|
new: byStatus['new'] || 0,
|
||||||
|
filtered: byStatus['filtered'] || 0,
|
||||||
applied: byStatus['applied'] || 0,
|
applied: byStatus['applied'] || 0,
|
||||||
failed: byStatus['failed'] || 0,
|
failed: byStatus['failed'] || 0,
|
||||||
needs_answer: byStatus['needs_answer'] || 0,
|
needs_answer: byStatus['needs_answer'] || 0,
|
||||||
@@ -209,6 +210,7 @@ function formatReport(s) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lines.push(
|
lines.push(
|
||||||
|
` 🚫 AI filtered: ${q.filtered || 0}`,
|
||||||
` ✅ Applied: ${q.applied}`,
|
` ✅ Applied: ${q.applied}`,
|
||||||
` 🔁 Already applied: ${q.already_applied || 0}`,
|
` 🔁 Already applied: ${q.already_applied || 0}`,
|
||||||
` 💬 Needs your answer: ${q.needs_answer}`,
|
` 💬 Needs your answer: ${q.needs_answer}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user