AI fallback for unknown form fields: ask Claude before Telegram

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 11:52:39 -08:00
parent 3fc5c38df7
commit 7c9de1af4a
2 changed files with 126 additions and 16 deletions

View File

@@ -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 <select> with case-insensitive, trimmed matching.
* Tries: exact label → case-insensitive label → substring match → value match.
@@ -274,7 +354,12 @@ export class FormFiller {
const existing = await inp.inputValue().catch(() => '');
if (existing?.trim()) continue;
if (this.isHoneypot(lbl)) return [{ label: lbl, honeypot: true }];
const answer = this.answerFor(lbl);
let answer = this.answerFor(lbl);
// AI fallback for unknown required fields
if (!answer && await this.isRequired(inp)) {
answer = await this.aiAnswerFor(lbl);
if (answer) this.saveAnswer(lbl, answer);
}
if (answer && answer !== this.profile.cover_letter) {
await inp.fill(String(answer)).catch(() => {});
// Handle city/location autocomplete dropdowns
@@ -283,7 +368,7 @@ export class FormFiller {
await this.selectAutocomplete(page, container);
}
} else {
// No answer, or answer is cover letter (too long for a text input) — check if required
// No answer from profile, custom answers, or AI — check if required
if (await this.isRequired(inp)) unknown.push(lbl);
}
}
@@ -294,7 +379,11 @@ export class FormFiller {
const lbl = await this.getLabel(ta);
const existing = await ta.inputValue().catch(() => '');
if (existing?.trim()) continue;
const answer = this.answerFor(lbl);
let answer = this.answerFor(lbl);
if (!answer && await this.isRequired(ta)) {
answer = await this.aiAnswerFor(lbl);
if (answer) this.saveAnswer(lbl, answer);
}
if (answer) {
await ta.fill(answer).catch(() => {});
} else {
@@ -308,10 +397,19 @@ export class FormFiller {
if (!leg) continue;
const anyChecked = await fs.$('input:checked');
if (anyChecked) continue;
const answer = this.answerFor(leg);
// Collect option labels for AI context
const labels = await fs.$$('label');
const optionTexts = [];
for (const lbl of labels) {
const t = (await lbl.textContent().catch(() => '') || '').trim();
if (t) optionTexts.push(t);
}
let answer = this.answerFor(leg);
if (!answer) {
answer = await this.aiAnswerFor(leg, { options: optionTexts });
if (answer) this.saveAnswer(leg, answer);
}
if (answer) {
// Find label within this fieldset that matches the answer text
const labels = await fs.$$('label');
for (const lbl of labels) {
const text = await lbl.textContent().catch(() => '');
if (text.trim().toLowerCase() === answer.toLowerCase()) {
@@ -331,10 +429,8 @@ export class FormFiller {
const existing = await sel.inputValue().catch(() => '');
// "Select an option" is LinkedIn's placeholder — treat as unfilled
if (existing && !/^select an? /i.test(existing)) continue;
const answer = this.answerFor(lbl);
if (answer) {
await this.selectOptionFuzzy(sel, answer);
} else {
let answer = this.answerFor(lbl);
if (!answer) {
// EEO/voluntary fields — default to "Prefer not to disclose"
const ll = lbl.toLowerCase();
if (ll.includes('race') || ll.includes('ethnicity') || ll.includes('gender') ||
@@ -347,17 +443,28 @@ export class FormFiller {
break;
}
}
} else if (await this.isRequired(sel)) {
// Non-EEO required select with no answer — report as unknown with options
continue;
}
// AI fallback for required selects
if (await this.isRequired(sel)) {
const opts = await sel.$$('option');
const options = [];
for (const opt of opts) {
const text = (await opt.textContent().catch(() => '') || '').trim();
if (text && !/^select/i.test(text)) options.push(text);
}
unknown.push({ label: lbl, type: 'select', options });
answer = await this.aiAnswerFor(lbl, { options });
if (answer) {
this.saveAnswer(lbl, answer);
} else {
unknown.push({ label: lbl, type: 'select', options });
continue;
}
}
}
if (answer) {
await this.selectOptionFuzzy(sel, answer);
}
}
// Checkboxes — "mark as top choice" and similar opt-ins