diff --git a/config/search_config.example.json b/config/search_config.example.json index 674cafc..5f13c40 100644 --- a/config/search_config.example.json +++ b/config/search_config.example.json @@ -1,10 +1,12 @@ { "_note": "Configure your job searches here. Each search runs on both listed platforms.", "first_run_days": 90, + "filter_min_score": 5, "searches": [ { "name": "Founding GTM", "track": "gtm", + "filter_min_score": 5, "keywords": [ "founding account executive", "first sales hire", @@ -23,6 +25,7 @@ { "name": "Enterprise AE", "track": "ae", + "filter_min_score": 5, "keywords": [ "enterprise account executive SaaS remote", "senior account executive technical SaaS remote", diff --git a/job_filter.mjs b/job_filter.mjs new file mode 100644 index 0000000..b9a1804 --- /dev/null +++ b/job_filter.mjs @@ -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); +}); diff --git a/lib/filter.mjs b/lib/filter.mjs new file mode 100644 index 0000000..36034b8 --- /dev/null +++ b/lib/filter.mjs @@ -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; +} diff --git a/status.mjs b/status.mjs index fb2fea1..d0cf77a 100644 --- a/status.mjs +++ b/status.mjs @@ -111,6 +111,7 @@ function buildStatus() { queue: { total: queue.length, new: byStatus['new'] || 0, + filtered: byStatus['filtered'] || 0, applied: byStatus['applied'] || 0, failed: byStatus['failed'] || 0, needs_answer: byStatus['needs_answer'] || 0, @@ -209,6 +210,7 @@ function formatReport(s) { } lines.push( + ` 🚫 AI filtered: ${q.filtered || 0}`, ` ✅ Applied: ${q.applied}`, ` 🔁 Already applied: ${q.already_applied || 0}`, ` 💬 Needs your answer: ${q.needs_answer}`,