From 7c9de1af4ab99202f5857cd675a550bf328d4ac3 Mon Sep 17 00:00:00 2001 From: Matthew Jackson Date: Fri, 6 Mar 2026 11:52:39 -0800 Subject: [PATCH] AI fallback for unknown form fields: ask Claude before Telegram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every unknown required field now goes through AI before falling back to Telegram. Claude sees the question + all saved answers + profile, and either recognizes it as a variation of a saved answer or generates a new one. AI answers are auto-saved to answers.json so the same question is a free pattern match next time. Telegram is now last resort (no API key). Flow: pattern match (free) → AI (smart) → auto-save → Telegram (human) Co-Authored-By: Claude Opus 4.6 --- job_applier.mjs | 5 +- lib/form_filler.mjs | 137 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 126 insertions(+), 16 deletions(-) diff --git a/job_applier.mjs b/job_applier.mjs index c1e78f7..8c2f2bd 100644 --- a/job_applier.mjs +++ b/job_applier.mjs @@ -39,11 +39,11 @@ async function main() { const profile = loadConfig(resolve(__dir, 'config/profile.json')); const answersPath = resolve(__dir, 'config/answers.json'); const answers = existsSync(answersPath) ? loadConfig(answersPath) : []; - const formFiller = new FormFiller(profile, answers); const maxApps = settings.max_applications_per_run || Infinity; const maxRetries = settings.max_retries ?? DEFAULT_MAX_RETRIES; const enabledTypes = settings.enabled_apply_types || DEFAULT_ENABLED_APPLY_TYPES; const apiKey = process.env.ANTHROPIC_API_KEY || settings.anthropic_api_key; + const formFiller = new FormFiller(profile, answers, { apiKey, answersPath }); const startedAt = Date.now(); const results = { @@ -141,6 +141,9 @@ async function main() { break; } + // Set job context for AI answers + formFiller.jobContext = { title: job.title, company: job.company }; + // Reload answers.json before each job — picks up Telegram replies between jobs try { const freshAnswers = existsSync(answersPath) ? loadConfig(answersPath) : []; diff --git a/lib/form_filler.mjs b/lib/form_filler.mjs index 8d3cc60..f3c419f 100644 --- a/lib/form_filler.mjs +++ b/lib/form_filler.mjs @@ -3,17 +3,39 @@ * Config-driven: answers loaded from answers.json * Returns list of unknown required fields */ +import { writeFileSync, renameSync } from 'fs'; import { DEFAULT_YEARS_EXPERIENCE, DEFAULT_DESIRED_SALARY, MINIMUM_SALARY_FACTOR, DEFAULT_SKILL_RATING, LINKEDIN_EASY_APPLY_MODAL_SELECTOR, FORM_PATTERN_MAX_LENGTH, - AUTOCOMPLETE_WAIT, AUTOCOMPLETE_TIMEOUT + AUTOCOMPLETE_WAIT, AUTOCOMPLETE_TIMEOUT, ANTHROPIC_API_URL } from './constants.mjs'; export class FormFiller { - constructor(profile, answers) { + constructor(profile, answers, opts = {}) { this.profile = profile; this.answers = answers || []; // [{ pattern, answer }] + this.apiKey = opts.apiKey || null; + this.answersPath = opts.answersPath || null; // path to answers.json for saving + this.jobContext = opts.jobContext || {}; // { title, company } + } + + /** + * Save a new answer to answers.json and in-memory cache. + * Skips if pattern already exists. + */ + saveAnswer(pattern, answer) { + if (!pattern || !answer) return; + const existing = this.answers.findIndex(a => a.pattern === pattern); + if (existing >= 0) return; // already saved + this.answers.push({ pattern, answer }); + if (this.answersPath) { + try { + const tmp = this.answersPath + '.tmp'; + writeFileSync(tmp, JSON.stringify(this.answers, null, 2)); + renameSync(tmp, this.answersPath); + } catch { /* best effort */ } + } } // Find answer for a label — checks custom answers first, then built-ins @@ -178,6 +200,64 @@ export class FormFiller { }).catch(() => false); } + /** + * Ask AI to answer an unknown question. Passes all saved answers so AI can + * recognize variations of previously answered questions. + * Returns the answer string, or null if AI can't help. + */ + async aiAnswerFor(label, opts = {}) { + if (!this.apiKey) return null; + + const savedAnswers = this.answers.map(a => `Q: "${a.pattern}" → A: "${a.answer}"`).join('\n'); + const optionsHint = opts.options?.length ? `\nAvailable options: ${opts.options.join(', ')}` : ''; + + const systemPrompt = `You are helping a job candidate fill out application forms. You have access to their profile and previously answered questions. + +Rules: +- If this question is a variation of a previously answered question, return the SAME answer +- For yes/no or multiple choice, return ONLY the exact option text +- For short-answer fields, be brief and direct (1 line) +- Use first person +- Never make up facts +- Just the answer text — no preamble, no explanation, no quotes`; + + const userPrompt = `Candidate: ${this.profile.name?.first} ${this.profile.name?.last} +Location: ${this.profile.location?.city}, ${this.profile.location?.state} +Years experience: ${this.profile.years_experience || 7} +Applying for: ${this.jobContext.title || 'a role'} at ${this.jobContext.company || 'a company'} + +Previously answered questions: +${savedAnswers || '(none yet)'} + +New question: "${label}"${optionsHint} + +Answer:`; + + try { + const res = await fetch(ANTHROPIC_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-sonnet-4-6', + max_tokens: 256, + system: systemPrompt, + messages: [{ role: 'user', content: userPrompt }], + }), + }); + if (!res.ok) return null; + const data = await res.json(); + const answer = data.content?.[0]?.text?.trim() || null; + if (answer) console.log(` [AI] "${label}" → "${answer}"`); + return answer; + } catch { + return null; + } + } + /** * Select an option from a