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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user