feat: AI answer generation for unknown questions + Easy Apply only mode
This commit is contained in:
@@ -17,11 +17,15 @@ import { acquireLock } from './lib/lock.mjs';
|
||||
import { createBrowser } from './lib/browser.mjs';
|
||||
import { FormFiller } from './lib/form_filler.mjs';
|
||||
import { applyToJob, supportedTypes } from './lib/apply/index.mjs';
|
||||
import { sendTelegram, formatApplySummary, formatUnknownQuestion } from './lib/notify.mjs';
|
||||
import { sendTelegram, formatApplySummary } from './lib/notify.mjs';
|
||||
import { generateAnswer } from './lib/ai_answer.mjs';
|
||||
import {
|
||||
APPLY_BETWEEN_DELAY_BASE, APPLY_BETWEEN_DELAY_JITTER, DEFAULT_MAX_RETRIES
|
||||
} from './lib/constants.mjs';
|
||||
|
||||
// Which apply types are currently enabled
|
||||
const ENABLED_APPLY_TYPES = ['easy_apply'];
|
||||
|
||||
const isPreview = process.argv.includes('--preview');
|
||||
|
||||
// Priority order — Easy Apply first, then by ATS volume (data-driven later)
|
||||
@@ -37,6 +41,7 @@ async function main() {
|
||||
const formFiller = new FormFiller(profile, answers);
|
||||
const maxApps = settings.max_applications_per_run || Infinity;
|
||||
const maxRetries = settings.max_retries ?? DEFAULT_MAX_RETRIES;
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY || settings.anthropic_api_key;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const results = {
|
||||
@@ -65,14 +70,16 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get + sort jobs by apply_type priority
|
||||
// Get + sort jobs — only enabled apply types
|
||||
const allJobs = getJobsByStatus(['new', 'needs_answer'])
|
||||
.filter(j => ENABLED_APPLY_TYPES.includes(j.apply_type))
|
||||
.sort((a, b) => {
|
||||
const ap = APPLY_PRIORITY.indexOf(a.apply_type ?? 'unknown_external');
|
||||
const bp = APPLY_PRIORITY.indexOf(b.apply_type ?? 'unknown_external');
|
||||
return (ap === -1 ? 99 : ap) - (bp === -1 ? 99 : bp);
|
||||
});
|
||||
const jobs = allJobs.slice(0, maxApps);
|
||||
console.log(`Enabled types: ${ENABLED_APPLY_TYPES.join(', ')}\n`);
|
||||
results.total = jobs.length;
|
||||
|
||||
if (jobs.length === 0) { console.log('Nothing to apply to. Run job_searcher.mjs first.'); return; }
|
||||
@@ -123,7 +130,7 @@ async function main() {
|
||||
|
||||
try {
|
||||
const result = await applyToJob(browser.page, job, formFiller);
|
||||
await handleResult(job, result, results, settings);
|
||||
await handleResult(job, result, results, settings, profile, apiKey);
|
||||
} catch (e) {
|
||||
console.error(` ❌ Error: ${e.message}`);
|
||||
if (e.stack) console.error(` Stack: ${e.stack.split('\n').slice(1, 3).join(' | ').trim()}`);
|
||||
@@ -162,7 +169,7 @@ async function main() {
|
||||
return results;
|
||||
}
|
||||
|
||||
async function handleResult(job, result, results, settings) {
|
||||
async function handleResult(job, result, results, settings, profile, apiKey) {
|
||||
const { status, meta, pending_question, externalUrl, ats_platform } = result;
|
||||
const title = meta?.title || job.title || '?';
|
||||
const company = meta?.company || job.company || '?';
|
||||
@@ -175,13 +182,34 @@ async function handleResult(job, result, results, settings) {
|
||||
results.submitted++;
|
||||
break;
|
||||
|
||||
case 'needs_answer':
|
||||
console.log(` 💬 Unknown question — sending to Telegram`);
|
||||
updateJobStatus(job.id, 'needs_answer', { title, company, pending_question });
|
||||
appendLog({ ...job, title, company, status: 'needs_answer', pending_question });
|
||||
await sendTelegram(settings, formatUnknownQuestion(job, pending_question?.label || pending_question));
|
||||
case 'needs_answer': {
|
||||
const questionText = pending_question?.label || pending_question || 'Unknown question';
|
||||
console.log(` 💬 Unknown question — asking Claude: "${questionText}"`);
|
||||
|
||||
const aiAnswer = await generateAnswer(questionText, profile, apiKey, { title, company });
|
||||
|
||||
updateJobStatus(job.id, 'needs_answer', {
|
||||
title, company, pending_question,
|
||||
ai_suggested_answer: aiAnswer || null,
|
||||
});
|
||||
appendLog({ ...job, title, company, status: 'needs_answer', pending_question, ai_suggested_answer: aiAnswer });
|
||||
|
||||
const msg = [
|
||||
`❓ *New question* — ${company} / ${title}`,
|
||||
``,
|
||||
`*Question:* ${questionText}`,
|
||||
``,
|
||||
aiAnswer
|
||||
? `*AI answer:*\n${aiAnswer}`
|
||||
: `_AI could not generate an answer._`,
|
||||
``,
|
||||
`Reply with your answer to store it, or reply *ACCEPT* to use the AI answer.`,
|
||||
].join('\n');
|
||||
|
||||
await sendTelegram(settings, msg);
|
||||
results.needs_answer++;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'skipped_recruiter_only':
|
||||
console.log(` 🚫 Recruiter-only`);
|
||||
|
||||
104
lib/ai_answer.mjs
Normal file
104
lib/ai_answer.mjs
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* ai_answer.mjs — AI-powered answer generation for unknown job application questions
|
||||
* Called when form_filler hits a question it can't answer from profile/answers.json
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
|
||||
const ANTHROPIC_API = 'https://api.anthropic.com/v1/messages';
|
||||
|
||||
/**
|
||||
* Generate an answer to an unknown application question using Claude.
|
||||
* @param {string} question - The question label/text from the form
|
||||
* @param {object} profile - Candidate profile (profile.json)
|
||||
* @param {string} apiKey - Anthropic API key
|
||||
* @param {object} job - Job context (title, company)
|
||||
* @returns {Promise<string|null>} - Suggested answer, or null on failure
|
||||
*/
|
||||
export async function generateAnswer(question, profile, apiKey, job = {}) {
|
||||
if (!apiKey) return null;
|
||||
|
||||
// Read resume text if available
|
||||
let resumeText = '';
|
||||
if (profile.resume_path && existsSync(profile.resume_path)) {
|
||||
try {
|
||||
// Try to read as text — PDF will be garbled but still useful for key facts
|
||||
// If pdftotext is available, use it; otherwise skip
|
||||
const { execSync } = await import('child_process');
|
||||
try {
|
||||
resumeText = execSync(`pdftotext "${profile.resume_path}" -`, { timeout: 5000 }).toString().slice(0, 4000);
|
||||
} catch {
|
||||
// pdftotext not available — skip resume text
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const candidateSummary = buildCandidateSummary(profile, resumeText);
|
||||
|
||||
const systemPrompt = `You are helping a job candidate fill out application forms. Your job is to write answers that sound like a real person wrote them -- natural, direct, and specific to their background.
|
||||
|
||||
Rules:
|
||||
- Use first person
|
||||
- Be specific -- pull real details from the candidate's experience when relevant
|
||||
- Keep answers concise but complete. For yes/no or short-answer fields, be brief. For behavioral questions, aim for 3-5 sentences.
|
||||
- Do not use em dashes, the word "leverage", "delve", "utilize", "streamline", or phrases that sound like AI output
|
||||
- Never make up facts. If you don't know something specific, answer honestly and generally
|
||||
- Write like someone who is confident but not arrogant`;
|
||||
|
||||
const userPrompt = `Candidate applying for: ${job.title || 'a sales role'} at ${job.company || 'a tech company'}
|
||||
|
||||
Candidate background:
|
||||
${candidateSummary}
|
||||
|
||||
Application question:
|
||||
"${question}"
|
||||
|
||||
Write the best answer for this question. Just the answer text -- no preamble, no explanation.`;
|
||||
|
||||
try {
|
||||
const res = await fetch(ANTHROPIC_API, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-sonnet-4-6',
|
||||
max_tokens: 512,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.content?.[0]?.text?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildCandidateSummary(profile, resumeText) {
|
||||
const lines = [];
|
||||
|
||||
const name = [profile.name?.first, profile.name?.last].filter(Boolean).join(' ');
|
||||
if (name) lines.push(`Name: ${name}`);
|
||||
if (profile.location?.city) lines.push(`Location: ${profile.location.city}, ${profile.location.state}`);
|
||||
if (profile.years_experience) lines.push(`Years of experience: ${profile.years_experience}`);
|
||||
if (profile.desired_salary) lines.push(`Target salary: $${profile.desired_salary.toLocaleString()}`);
|
||||
if (profile.work_authorization?.authorized) lines.push(`Work authorization: US authorized, no sponsorship required`);
|
||||
if (profile.willing_to_relocate === false) lines.push(`Relocation: not willing to relocate, remote only`);
|
||||
if (profile.linkedin_url) lines.push(`LinkedIn: ${profile.linkedin_url}`);
|
||||
|
||||
if (profile.cover_letter) {
|
||||
lines.push(`\nBackground summary:\n${profile.cover_letter}`);
|
||||
}
|
||||
|
||||
if (resumeText) {
|
||||
lines.push(`\nResume:\n${resumeText}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user