halo-agent 1.3.4 → 1.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/filler.js +235 -40
- package/orchestrator.js +35 -7
- package/package.json +1 -1
- package/scanPage.js +77 -3
package/filler.js
CHANGED
|
@@ -259,7 +259,7 @@ async function uploadFile(page, triggerSelector, filePath) {
|
|
|
259
259
|
*
|
|
260
260
|
* Returns true if fill succeeded.
|
|
261
261
|
*/
|
|
262
|
-
async function fillLocator(page, locator, value, label) {
|
|
262
|
+
async function fillLocator(page, locator, value, label, dropdownCtx = null) {
|
|
263
263
|
if (!value) return false;
|
|
264
264
|
|
|
265
265
|
try {
|
|
@@ -271,7 +271,35 @@ async function fillLocator(page, locator, value, label) {
|
|
|
271
271
|
isContentEditable: el.isContentEditable,
|
|
272
272
|
hasOptions: el.tagName === 'SELECT',
|
|
273
273
|
optionCount: el.tagName === 'SELECT' ? el.options.length : 0,
|
|
274
|
-
|
|
274
|
+
// Typeahead heuristics: any of these signals means "type then pick a
|
|
275
|
+
// suggestion." Greenhouse Location, Lever City, Workday locations all
|
|
276
|
+
// use this pattern — a plain text input that mounts a <ul role=listbox>
|
|
277
|
+
// of city matches as you type. Plain .fill() leaves the field
|
|
278
|
+
// visually populated but React clears it on blur because no
|
|
279
|
+
// suggestion was committed.
|
|
280
|
+
isTypeahead: !!(
|
|
281
|
+
el.getAttribute('aria-autocomplete') === 'list' ||
|
|
282
|
+
el.getAttribute('aria-autocomplete') === 'both' ||
|
|
283
|
+
el.getAttribute('aria-haspopup') === 'listbox' ||
|
|
284
|
+
el.getAttribute('aria-haspopup') === 'true' ||
|
|
285
|
+
el.getAttribute('autocomplete') === 'off' && el.getAttribute('aria-expanded') !== null ||
|
|
286
|
+
// Greenhouse + Lever wrap their typeahead in role=combobox parent
|
|
287
|
+
el.closest('[role="combobox"]') ||
|
|
288
|
+
// "Locate me" sibling button is a strong Greenhouse Location signal
|
|
289
|
+
Array.from((el.closest('div') || el.parentElement || document).querySelectorAll('button, a')).some(b => /locate\s*me/i.test(b.textContent || ''))
|
|
290
|
+
),
|
|
291
|
+
})).catch(() => ({ tag: 'input', type: 'text', role: '', isContentEditable: false, hasOptions: false, optionCount: 0, isTypeahead: false }));
|
|
292
|
+
|
|
293
|
+
// ── Typeahead text input (Greenhouse Location, Lever City) ──
|
|
294
|
+
// Check BEFORE the generic combobox path so a plain <input> with
|
|
295
|
+
// typeahead-flagged ancestors still gets the type-then-pick treatment
|
|
296
|
+
// (those inputs aren't role=combobox themselves).
|
|
297
|
+
if (meta.isTypeahead && (meta.tag === 'input' || meta.tag === 'textarea')) {
|
|
298
|
+
const ok = await fillTypeahead(page, locator, value);
|
|
299
|
+
if (ok) return true;
|
|
300
|
+
// Fall through to default text-input path if typeahead pick failed —
|
|
301
|
+
// some forms accept the typed value even without selecting a suggestion.
|
|
302
|
+
}
|
|
275
303
|
|
|
276
304
|
// ── Native <select> ──
|
|
277
305
|
if (meta.tag === 'select' || meta.hasOptions) {
|
|
@@ -293,13 +321,50 @@ async function fillLocator(page, locator, value, label) {
|
|
|
293
321
|
);
|
|
294
322
|
if (fuzzy) ok = await locator.selectOption(fuzzy.v).then(() => true).catch(() => false);
|
|
295
323
|
}
|
|
324
|
+
// 5. AI synonym pick — "Straight" → "Heterosexual", "Asian" →
|
|
325
|
+
// "South Asian" etc. We hand the LLM the user's literal answer
|
|
326
|
+
// plus the EXACT option list and let it pick the equivalent.
|
|
327
|
+
if (!ok && dropdownCtx && opts.length > 0) {
|
|
328
|
+
try {
|
|
329
|
+
const optionTexts = opts.map(o => o.t).filter(Boolean);
|
|
330
|
+
const res = await fetch(`${dropdownCtx.config.apiUrl}/smartfill/field-answer`, {
|
|
331
|
+
method: 'POST',
|
|
332
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${dropdownCtx.config.token}` },
|
|
333
|
+
body: JSON.stringify({
|
|
334
|
+
job_id: dropdownCtx.jobId,
|
|
335
|
+
field_label: label || '',
|
|
336
|
+
field_type: 'select',
|
|
337
|
+
options: optionTexts,
|
|
338
|
+
previously_answered: [{ label: 'user intent', value: String(value) }],
|
|
339
|
+
}),
|
|
340
|
+
});
|
|
341
|
+
if (res.ok) {
|
|
342
|
+
const { value: picked } = await res.json();
|
|
343
|
+
if (picked && picked !== 'SKIP') {
|
|
344
|
+
const pickedV = picked.toLowerCase().trim();
|
|
345
|
+
const match = opts.find(o => o.t.toLowerCase().trim() === pickedV)
|
|
346
|
+
|| opts.find(o => o.t.toLowerCase().includes(pickedV) || pickedV.includes(o.t.toLowerCase()));
|
|
347
|
+
if (match) {
|
|
348
|
+
ok = await locator.selectOption(match.v).then(() => true).catch(() => false);
|
|
349
|
+
if (ok) console.log(`[filler] AI-picked select: "${label}" "${value}" → "${match.t}"`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
} catch (e) {
|
|
354
|
+
console.warn(`[filler] AI native-select pick failed for "${label}": ${e.message}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
296
357
|
return ok;
|
|
297
358
|
}
|
|
298
359
|
|
|
299
360
|
// ── Custom dropdown / combobox (React Select, Ashby, Lever custom selects) ──
|
|
300
361
|
if (meta.role === 'combobox' || meta.role === 'listbox' ||
|
|
301
362
|
meta.tag === 'div' || meta.tag === 'button') {
|
|
302
|
-
return await fillCustomDropdown(page, locator, value
|
|
363
|
+
return await fillCustomDropdown(page, locator, value, dropdownCtx ? {
|
|
364
|
+
config: dropdownCtx.config,
|
|
365
|
+
jobId: dropdownCtx.jobId,
|
|
366
|
+
label,
|
|
367
|
+
} : {});
|
|
303
368
|
}
|
|
304
369
|
|
|
305
370
|
// ── Radio group (native) ──
|
|
@@ -362,49 +427,144 @@ async function fillLocator(page, locator, value, label) {
|
|
|
362
427
|
* Strategy: click trigger → wait for option list → find closest matching option → click it.
|
|
363
428
|
* Handles React Select, Ashby custom select, Lever, Greenhouse custom dropdowns.
|
|
364
429
|
*/
|
|
365
|
-
|
|
430
|
+
/**
|
|
431
|
+
* Type-and-pick handler for autocomplete/typeahead inputs (Greenhouse
|
|
432
|
+
* Location, Lever City, Workday location pickers). Strategy:
|
|
433
|
+
*
|
|
434
|
+
* 1. Click the input to focus
|
|
435
|
+
* 2. Clear any existing text
|
|
436
|
+
* 3. Type the value SLOWLY, character-by-character (suggestions fire
|
|
437
|
+
* on each keystroke; typing too fast can race the network)
|
|
438
|
+
* 4. Wait for a listbox/option container to render
|
|
439
|
+
* 5. Pick the best matching option (exact > startsWith > first)
|
|
440
|
+
* 6. Verify the input now shows a committed value
|
|
441
|
+
*
|
|
442
|
+
* Returns false if no suggestion list appears — caller can fall back to
|
|
443
|
+
* a plain fill.
|
|
444
|
+
*/
|
|
445
|
+
async function fillTypeahead(page, locator, value) {
|
|
446
|
+
try {
|
|
447
|
+
await locator.click({ timeout: 2000 });
|
|
448
|
+
// Clear via select-all + delete (more reliable than .fill('') for
|
|
449
|
+
// autocompletes that have their own state)
|
|
450
|
+
await locator.press('Meta+A').catch(() => locator.press('Control+A')).catch(() => {});
|
|
451
|
+
await locator.press('Delete').catch(() => {});
|
|
452
|
+
// Type the FIRST PART (city name only) — typing "San Francisco, CA"
|
|
453
|
+
// when the typeahead expects "San Francisco" can suppress matches.
|
|
454
|
+
const firstChunk = String(value).split(/[,;]/)[0].trim();
|
|
455
|
+
await page.keyboard.type(firstChunk, { delay: 60 });
|
|
456
|
+
// Wait briefly for suggestions to render
|
|
457
|
+
await page.waitForTimeout(700);
|
|
458
|
+
// Look for a listbox / option container — multiple common patterns
|
|
459
|
+
const optionSel = [
|
|
460
|
+
'[role="option"]',
|
|
461
|
+
'[role="listbox"] li',
|
|
462
|
+
'[role="listbox"] [role="option"]',
|
|
463
|
+
'[class*="option"][class*="select"]',
|
|
464
|
+
'[class*="suggestion"]',
|
|
465
|
+
'ul[class*="autocomplete"] li',
|
|
466
|
+
// Greenhouse-specific
|
|
467
|
+
'.select__option',
|
|
468
|
+
'.select__menu [role="option"]',
|
|
469
|
+
].join(', ');
|
|
470
|
+
const options = page.locator(optionSel);
|
|
471
|
+
const count = await options.count().catch(() => 0);
|
|
472
|
+
if (count === 0) return false;
|
|
473
|
+
// Prefer exact-match option; fall back to "starts with"; else first.
|
|
474
|
+
const allTexts = await options.allTextContents().catch(() => []);
|
|
475
|
+
const v = firstChunk.toLowerCase();
|
|
476
|
+
let pickIdx = allTexts.findIndex(t => t.toLowerCase().trim() === v);
|
|
477
|
+
if (pickIdx === -1) pickIdx = allTexts.findIndex(t => t.toLowerCase().trim().startsWith(v));
|
|
478
|
+
if (pickIdx === -1) pickIdx = 0;
|
|
479
|
+
await options.nth(pickIdx).click({ timeout: 2000 });
|
|
480
|
+
await page.waitForTimeout(200);
|
|
481
|
+
// Verify the input now shows a real value
|
|
482
|
+
const got = await locator.inputValue({ timeout: 1000 }).catch(() => null);
|
|
483
|
+
return !!(got && got.trim());
|
|
484
|
+
} catch {
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function fillCustomDropdown(page, triggerLocator, value, options = {}) {
|
|
366
490
|
try {
|
|
367
491
|
await triggerLocator.click();
|
|
368
492
|
await page.waitForTimeout(350);
|
|
369
493
|
|
|
370
|
-
//
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
return true;
|
|
385
|
-
}
|
|
386
|
-
} catch {}
|
|
494
|
+
// STEP 1: Discover ALL available options (visible after opening). We
|
|
495
|
+
// need this for two reasons:
|
|
496
|
+
// a) substring/exact match locally (cheap, no LLM round trip)
|
|
497
|
+
// b) if no local match, ask the LLM to pick from this exact list —
|
|
498
|
+
// that's how "Straight" maps to "Heterosexual" without us having
|
|
499
|
+
// to hardcode a synonym table for every form
|
|
500
|
+
// We collect texts BEFORE any click attempts so the LLM has the same
|
|
501
|
+
// option universe the user would see.
|
|
502
|
+
const optionLocators = page.locator('[role="option"], [role="menuitem"], .select__option, li[class*="option"]');
|
|
503
|
+
const optionCount = await optionLocators.count().catch(() => 0);
|
|
504
|
+
const optionTexts = [];
|
|
505
|
+
for (let i = 0; i < Math.min(optionCount, 40); i++) {
|
|
506
|
+
const t = (await optionLocators.nth(i).textContent().catch(() => '')).trim();
|
|
507
|
+
if (t) optionTexts.push(t);
|
|
387
508
|
}
|
|
388
509
|
|
|
389
|
-
//
|
|
390
|
-
const
|
|
391
|
-
let
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if (score > bestScore) { bestScore = score; bestMatch = opt; }
|
|
510
|
+
// STEP 2: Local exact match (case-insensitive)
|
|
511
|
+
const v = String(value).toLowerCase().trim();
|
|
512
|
+
let pickIdx = optionTexts.findIndex(t => t.toLowerCase().trim() === v);
|
|
513
|
+
|
|
514
|
+
// STEP 3: Local substring match (either direction)
|
|
515
|
+
if (pickIdx === -1) {
|
|
516
|
+
pickIdx = optionTexts.findIndex(t => {
|
|
517
|
+
const tt = t.toLowerCase().trim();
|
|
518
|
+
return tt.includes(v) || v.includes(tt);
|
|
519
|
+
});
|
|
400
520
|
}
|
|
401
|
-
|
|
402
|
-
|
|
521
|
+
|
|
522
|
+
if (pickIdx !== -1) {
|
|
523
|
+
await optionLocators.nth(pickIdx).click({ timeout: 2000 });
|
|
403
524
|
return true;
|
|
404
525
|
}
|
|
405
526
|
|
|
406
|
-
//
|
|
407
|
-
|
|
527
|
+
// STEP 4: LLM picks from the option universe — the smart-fill path.
|
|
528
|
+
// Sends user's intent ("Straight") + question text + the EXACT list
|
|
529
|
+
// of options Greenhouse is rendering ("Heterosexual", "Gay or
|
|
530
|
+
// Lesbian", "Bisexual", "Prefer not to say"). Backend already has
|
|
531
|
+
// the /smartfill/field-answer endpoint with options[] support.
|
|
532
|
+
if (optionTexts.length > 0 && options.config && options.jobId && options.label) {
|
|
533
|
+
try {
|
|
534
|
+
const res = await fetch(`${options.config.apiUrl}/smartfill/field-answer`, {
|
|
535
|
+
method: 'POST',
|
|
536
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${options.config.token}` },
|
|
537
|
+
body: JSON.stringify({
|
|
538
|
+
job_id: options.jobId,
|
|
539
|
+
field_label: options.label,
|
|
540
|
+
field_type: 'select',
|
|
541
|
+
options: optionTexts,
|
|
542
|
+
// Pass user's stated intent so the LLM picks the closest
|
|
543
|
+
// option that REPRESENTS that intent, not just text similarity.
|
|
544
|
+
previously_answered: [{ label: 'user intent', value: String(value) }],
|
|
545
|
+
}),
|
|
546
|
+
});
|
|
547
|
+
if (res.ok) {
|
|
548
|
+
const { value: picked } = await res.json();
|
|
549
|
+
if (picked && picked !== 'SKIP') {
|
|
550
|
+
// Match the picked text back to an option locator
|
|
551
|
+
const pickedV = picked.toLowerCase().trim();
|
|
552
|
+
const aiIdx = optionTexts.findIndex(t => t.toLowerCase().trim() === pickedV)
|
|
553
|
+
?? optionTexts.findIndex(t => t.toLowerCase().includes(pickedV) || pickedV.includes(t.toLowerCase()));
|
|
554
|
+
if (aiIdx !== -1 && aiIdx >= 0) {
|
|
555
|
+
await optionLocators.nth(aiIdx).click({ timeout: 2000 });
|
|
556
|
+
console.log(`[filler] AI-picked dropdown: "${options.label}" "${value}" → "${optionTexts[aiIdx]}"`);
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
} catch (e) {
|
|
562
|
+
console.warn(`[filler] AI dropdown pick failed for "${options.label}": ${e.message}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Nothing matched — close dropdown gracefully so the next field can fill
|
|
567
|
+
await page.keyboard.press('Escape').catch(() => {});
|
|
408
568
|
return false;
|
|
409
569
|
} catch {
|
|
410
570
|
return false;
|
|
@@ -642,6 +802,12 @@ async function fillFields(page, aep, options = {}) {
|
|
|
642
802
|
const speed = options.speed || 'normal';
|
|
643
803
|
const ctx = options.ctx || null;
|
|
644
804
|
const ats = options.ats || 'generic';
|
|
805
|
+
// dropdownCtx flows to fillLocator → fillCustomDropdown so custom selects
|
|
806
|
+
// can call the backend's /smartfill/field-answer with the question text +
|
|
807
|
+
// available options. That's the "Straight" → "Heterosexual" synonym fix.
|
|
808
|
+
const dropdownCtx = options.config && options.jobId
|
|
809
|
+
? { config: options.config, jobId: options.jobId }
|
|
810
|
+
: null;
|
|
645
811
|
const delay = () => new Promise(r => setTimeout(r, jitter(100 + Math.random() * 150)));
|
|
646
812
|
|
|
647
813
|
let filled = 0, skipped = 0, failed = 0;
|
|
@@ -665,8 +831,37 @@ async function fillFields(page, aep, options = {}) {
|
|
|
665
831
|
continue;
|
|
666
832
|
}
|
|
667
833
|
|
|
668
|
-
// File uploads — handled separately by uploadResume() in
|
|
669
|
-
|
|
834
|
+
// File uploads — resume is handled separately by uploadResume() in the
|
|
835
|
+
// gate handler. Cover letter is its own thing: ATSes like Greenhouse and
|
|
836
|
+
// Ashby render a dedicated cover-letter file input alongside the resume
|
|
837
|
+
// one. When the user compiled a cover letter PDF (in PacketPrep studio),
|
|
838
|
+
// upload it here against the matching field.
|
|
839
|
+
if (source === 'file_upload') {
|
|
840
|
+
if (field.category === 'file:cover_letter' && aep.__coverLetterLocalPath) {
|
|
841
|
+
try {
|
|
842
|
+
// setInputFiles works directly on hidden <input type=file>; no need
|
|
843
|
+
// to traverse to a sibling button when we already have the input.
|
|
844
|
+
const inputLoc = field.selector ? page.locator(field.selector).first() : null;
|
|
845
|
+
if (inputLoc) {
|
|
846
|
+
// Some forms hide the input via CSS — unhide briefly so Playwright
|
|
847
|
+
// doesn't reject the setInputFiles call on a 0x0 element.
|
|
848
|
+
await inputLoc.evaluate(el => {
|
|
849
|
+
el.style.display = 'block'; el.style.opacity = '1'; el.style.visibility = 'visible';
|
|
850
|
+
el.style.width = '1px'; el.style.height = '1px';
|
|
851
|
+
}).catch(() => {});
|
|
852
|
+
await inputLoc.setInputFiles(aep.__coverLetterLocalPath, { timeout: 5000 });
|
|
853
|
+
console.log(`[filler] filled (file): "${labelShort}" = cover-letter.pdf`);
|
|
854
|
+
filled++;
|
|
855
|
+
if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value: 'cover-letter.pdf', pageIndex: ctx.currentPageIndex, source: 'file' });
|
|
856
|
+
await delay();
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
} catch (e) {
|
|
860
|
+
console.warn(`[filler] cover-letter upload failed for "${labelShort}": ${e.message}`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
670
865
|
|
|
671
866
|
// EEO / consent — skip silently (vision handles EEO, user handles consent)
|
|
672
867
|
if (source === 'skip') continue;
|
|
@@ -746,7 +941,7 @@ async function fillFields(page, aep, options = {}) {
|
|
|
746
941
|
await delay();
|
|
747
942
|
} else {
|
|
748
943
|
// Try clicking the locator directly (custom styled radio)
|
|
749
|
-
const ok2 = await fillLocator(page, locator, value, field.label);
|
|
944
|
+
const ok2 = await fillLocator(page, locator, value, field.label, dropdownCtx);
|
|
750
945
|
if (ok2) { filled++; if (ctx && trackKey) ctx.answeredFields.set(trackKey, { value, pageIndex: ctx.currentPageIndex, source }); await delay(); }
|
|
751
946
|
else { skipped++; }
|
|
752
947
|
}
|
|
@@ -768,7 +963,7 @@ async function fillFields(page, aep, options = {}) {
|
|
|
768
963
|
|
|
769
964
|
// All other types — use universal fillLocator
|
|
770
965
|
try {
|
|
771
|
-
const ok = await fillLocator(page, locator, value, field.label);
|
|
966
|
+
const ok = await fillLocator(page, locator, value, field.label, dropdownCtx);
|
|
772
967
|
if (ok) {
|
|
773
968
|
const valShort = String(value).slice(0, 40).replace(/\n/g, ' ');
|
|
774
969
|
console.log(`[filler] filled (${source}): "${labelShort}" = "${valShort}${String(value).length > 40 ? '...' : ''}"`);
|
package/orchestrator.js
CHANGED
|
@@ -68,6 +68,18 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
68
68
|
tempResumeFile = await downloadResume(aep.recommended_resume.pdf_presigned_url);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// Download cover-letter PDF too — separate file because Greenhouse/Ashby
|
|
72
|
+
// have separate file inputs for each. Only present when the user
|
|
73
|
+
// compiled it in PacketPrep (cover_letter_pdf_status='ready').
|
|
74
|
+
let tempCoverLetterFile = null;
|
|
75
|
+
if (aep.cover_letter_pdf?.pdf_presigned_url) {
|
|
76
|
+
tempCoverLetterFile = await downloadResume(aep.cover_letter_pdf.pdf_presigned_url);
|
|
77
|
+
if (tempCoverLetterFile) console.log('[orchestrator] Cover letter PDF downloaded');
|
|
78
|
+
}
|
|
79
|
+
// Expose to fillFields via aep so the file:cover_letter category resolver
|
|
80
|
+
// can hand it to the uploader.
|
|
81
|
+
aep.__coverLetterLocalPath = tempCoverLetterFile;
|
|
82
|
+
|
|
71
83
|
// Check for an existing checkpoint — if a previous run got past page 1
|
|
72
84
|
// before crashing, resume from where it stopped. The fillFields path uses
|
|
73
85
|
// ctx.answeredFields to skip already-completed labels, so restoring the
|
|
@@ -118,7 +130,7 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
118
130
|
console.log(`[orchestrator] Using vision fallback for ${ats_type}`);
|
|
119
131
|
fillResult = await visionFill(page, aep, anthropicKey, { alreadyFilled: ctx.answeredFields });
|
|
120
132
|
} else {
|
|
121
|
-
fillResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
|
|
133
|
+
fillResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
|
|
122
134
|
}
|
|
123
135
|
|
|
124
136
|
// Fetch AI answers for any fields that scanPage identified as needing AI
|
|
@@ -145,7 +157,7 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
145
157
|
}
|
|
146
158
|
}
|
|
147
159
|
// Second fill pass with newly fetched answers
|
|
148
|
-
const retryResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
|
|
160
|
+
const retryResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
|
|
149
161
|
fillResult.filled += retryResult.filled;
|
|
150
162
|
fillResult.skipped = retryResult.skipped;
|
|
151
163
|
console.log(`[orchestrator] Retry fill: +${retryResult.filled} fields filled`);
|
|
@@ -268,7 +280,7 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
268
280
|
ctx.currentPageIndex++;
|
|
269
281
|
|
|
270
282
|
// Fill any new fields that appeared on this page
|
|
271
|
-
const pageResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
|
|
283
|
+
const pageResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
|
|
272
284
|
cumulativeFilled += pageResult.filled || 0;
|
|
273
285
|
|
|
274
286
|
// Fetch AI answers for custom fields scanPage identified on this page
|
|
@@ -285,7 +297,7 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
285
297
|
}
|
|
286
298
|
} catch {}
|
|
287
299
|
}
|
|
288
|
-
const retryPage = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
|
|
300
|
+
const retryPage = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
|
|
289
301
|
cumulativeFilled += retryPage.filled || 0;
|
|
290
302
|
}
|
|
291
303
|
|
|
@@ -424,6 +436,22 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
424
436
|
}
|
|
425
437
|
|
|
426
438
|
if (verdict.submitted === null) {
|
|
439
|
+
// Auto-submit mode means "don't ask me, just submit." If we can't
|
|
440
|
+
// verify but the user opted into hands-off, trust the click and
|
|
441
|
+
// mark DONE (the screenshot is the receipt; user can audit later).
|
|
442
|
+
// Without this, autoSubmit was silently being ignored every time
|
|
443
|
+
// Firecrawl was slow/down — exactly the case user hit.
|
|
444
|
+
const autoSubmit = config.autoSubmit || aep.agent_config?.auto_submit;
|
|
445
|
+
if (autoSubmit) {
|
|
446
|
+
console.log(`[orchestrator] Could not verify (source: ${verdict.source}) — auto-submit ON, trusting click.`);
|
|
447
|
+
await reportStatus('DONE', {
|
|
448
|
+
confirmation_screenshot_r2_key: confirmKey || null,
|
|
449
|
+
fields_filled: cumulativeFilled,
|
|
450
|
+
});
|
|
451
|
+
await clearCheckpoint(config, queueId);
|
|
452
|
+
console.log(`[orchestrator] Done (auto-submit, unverified): ${queueItem.company} - ${queueItem.title}`);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
427
455
|
console.warn(`[orchestrator] Could not verify submission (source: ${verdict.source}). Sending to REVIEWING for your eyeball.`);
|
|
428
456
|
await reportStatus('REVIEWING', {
|
|
429
457
|
review_screenshot_r2_key: confirmKey || null,
|
|
@@ -1018,7 +1046,7 @@ async function runExtensionFill({
|
|
|
1018
1046
|
log(`Vision fill for ${ats_type}`);
|
|
1019
1047
|
fillResult = await visionFill(page, aep, anthropicKey, { alreadyFilled: ctx.answeredFields });
|
|
1020
1048
|
} else {
|
|
1021
|
-
fillResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
|
|
1049
|
+
fillResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
|
|
1022
1050
|
}
|
|
1023
1051
|
|
|
1024
1052
|
reportFillStatus('FILLING', { fieldsFilled: fillResult.filled || 0, message: `Filled ${fillResult.filled || 0} fields...` });
|
|
@@ -1043,7 +1071,7 @@ async function runExtensionFill({
|
|
|
1043
1071
|
}
|
|
1044
1072
|
} catch (e) { log(`AI answer failed for "${f.label}": ${e.message}`); }
|
|
1045
1073
|
}
|
|
1046
|
-
const retry = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
|
|
1074
|
+
const retry = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
|
|
1047
1075
|
fillResult.filled += retry.filled;
|
|
1048
1076
|
fillResult.skipped = retry.skipped;
|
|
1049
1077
|
}
|
|
@@ -1107,7 +1135,7 @@ async function runExtensionFill({
|
|
|
1107
1135
|
}
|
|
1108
1136
|
|
|
1109
1137
|
ctx.currentPageIndex++;
|
|
1110
|
-
const pageResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type });
|
|
1138
|
+
const pageResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
|
|
1111
1139
|
cumulativeFilled += pageResult.filled || 0;
|
|
1112
1140
|
|
|
1113
1141
|
if (anthropicKey && !useVisionForThis && (pageResult.skipped > 2 || pageResult.failed > 0)) {
|
package/package.json
CHANGED
package/scanPage.js
CHANGED
|
@@ -268,6 +268,21 @@ async function scanGreenhouse(page) {
|
|
|
268
268
|
if (f.id && ghSystemIds[f.id]) {
|
|
269
269
|
return { ...f, category: f.id === 'resume' || f.id === 'cover_letter' ? 'file:' + f.id : 'profile:' + ghSystemIds[f.id] };
|
|
270
270
|
}
|
|
271
|
+
// File inputs — Greenhouse's modern job-boards UI labels both file
|
|
272
|
+
// inputs as "Attach" (the visible button text), not "Resume" or
|
|
273
|
+
// "Cover Letter". Distinguish by name= attribute instead.
|
|
274
|
+
// resume: name="resume" or name="job_application[resume]"
|
|
275
|
+
// cover ltr: name="cover_letter" or name="job_application[cover_letter]"
|
|
276
|
+
if (f.inputType === 'file') {
|
|
277
|
+
const n = (f.name || '').toLowerCase();
|
|
278
|
+
if (n.includes('cover_letter') || n.includes('coverletter')) return { ...f, category: 'file:cover_letter' };
|
|
279
|
+
if (n.includes('resume') || n.includes('cv')) return { ...f, category: 'file:resume' };
|
|
280
|
+
// Order on the page: first file input is resume, second is cover letter.
|
|
281
|
+
// Without name/id signals, fall back to ordering. We mark unknowns
|
|
282
|
+
// 'file:resume' here and re-tag the second occurrence below after
|
|
283
|
+
// the map (where we have the whole list).
|
|
284
|
+
return { ...f, category: 'file:unknown' };
|
|
285
|
+
}
|
|
271
286
|
// Custom question fields: id like question_12345678
|
|
272
287
|
if (f.id && /^question_\d+$/.test(f.id)) {
|
|
273
288
|
return { ...f, category: 'custom' };
|
|
@@ -281,7 +296,17 @@ async function scanGreenhouse(page) {
|
|
|
281
296
|
return { ...f, category: 'profile:' + f.id.split('--')[0] };
|
|
282
297
|
}
|
|
283
298
|
return { ...f, category: classifyField(f) };
|
|
284
|
-
}).
|
|
299
|
+
}).reduce((acc, f) => {
|
|
300
|
+
// Position-based resolution for ambiguous file inputs: first unknown
|
|
301
|
+
// file → resume, second → cover_letter. Greenhouse always renders
|
|
302
|
+
// resume above cover letter; the DOM walk preserves that order.
|
|
303
|
+
if (f.category === 'file:unknown') {
|
|
304
|
+
const sawResume = acc.some(p => p.category === 'file:resume');
|
|
305
|
+
f.category = sawResume ? 'file:cover_letter' : 'file:resume';
|
|
306
|
+
}
|
|
307
|
+
acc.push(f);
|
|
308
|
+
return acc;
|
|
309
|
+
}, []).filter(f => f.category !== 'ignore' && f.inputType !== 'hidden');
|
|
285
310
|
}
|
|
286
311
|
|
|
287
312
|
/**
|
|
@@ -597,8 +622,57 @@ function resolveFieldValues(fields, aep) {
|
|
|
597
622
|
return { field, value: null, source: 'file_upload' };
|
|
598
623
|
}
|
|
599
624
|
|
|
600
|
-
//
|
|
601
|
-
if (cat === '
|
|
625
|
+
// Consent checkboxes — user-only (we don't auto-accept terms).
|
|
626
|
+
if (cat === 'consent') {
|
|
627
|
+
return { field, value: null, source: 'skip' };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// EEO — answer from profile self-ID when set, OR infer common
|
|
631
|
+
// implications from what IS set. The profile already collects
|
|
632
|
+
// gender_self_id / race / veteran / disability. We hand those back
|
|
633
|
+
// directly for matching dropdown labels, plus do a small amount of
|
|
634
|
+
// inference for derived questions ("transgender experience?" → No
|
|
635
|
+
// when user is cisgender, "veteran?" → No is the safe default the
|
|
636
|
+
// user can override). Anything we can't infer falls through to
|
|
637
|
+
// 'skip' so it gets reviewed.
|
|
638
|
+
if (cat === 'eeo') {
|
|
639
|
+
const label = (field.label || '').toLowerCase();
|
|
640
|
+
const pf = aep.profile_fill || {};
|
|
641
|
+
// Direct mapping by question text
|
|
642
|
+
if (/gender|sex/.test(label) && !/transgender|trans\s*experience/.test(label)) {
|
|
643
|
+
if (pf.gender) return { field, value: pf.gender, source: 'profile:eeo' };
|
|
644
|
+
}
|
|
645
|
+
if (/race|ethnicit|hispanic/.test(label)) {
|
|
646
|
+
if (pf.race) return { field, value: pf.race, source: 'profile:eeo' };
|
|
647
|
+
}
|
|
648
|
+
if (/veteran|military/.test(label)) {
|
|
649
|
+
if (pf.veteran) return { field, value: pf.veteran, source: 'profile:eeo' };
|
|
650
|
+
// Inference: most users are non-veterans; safer default is "No"
|
|
651
|
+
// (recruiters expect this field answered; blank → required-field
|
|
652
|
+
// error). User can change in profile.
|
|
653
|
+
return { field, value: 'I am not a protected veteran', source: 'profile:eeo-default' };
|
|
654
|
+
}
|
|
655
|
+
if (/disabilit/.test(label)) {
|
|
656
|
+
if (pf.disability) return { field, value: pf.disability, source: 'profile:eeo' };
|
|
657
|
+
return { field, value: "I don't wish to answer", source: 'profile:eeo-default' };
|
|
658
|
+
}
|
|
659
|
+
// Transgender / trans experience: cisgender implication when gender
|
|
660
|
+
// is set to non-trans. Cisgender users almost never want to
|
|
661
|
+
// accidentally self-ID as trans; defaulting to "No" when gender is
|
|
662
|
+
// explicit is safer than skipping (which causes a required-field
|
|
663
|
+
// error). Don't infer when gender is unset — that becomes 'skip'.
|
|
664
|
+
if (/transgender|trans\s*experience/.test(label)) {
|
|
665
|
+
if (pf.gender) return { field, value: 'No', source: 'profile:eeo-inferred' };
|
|
666
|
+
}
|
|
667
|
+
// Sexual orientation — never infer. User must self-ID.
|
|
668
|
+
if (/sexual\s*orientation|orientation\b/.test(label)) {
|
|
669
|
+
return { field, value: "I don't wish to answer", source: 'profile:eeo-default' };
|
|
670
|
+
}
|
|
671
|
+
// Pronouns — derivable from gender
|
|
672
|
+
if (/pronoun/.test(label)) {
|
|
673
|
+
if (pf.gender && /female|woman/i.test(pf.gender)) return { field, value: 'she/her', source: 'profile:eeo-inferred' };
|
|
674
|
+
if (pf.gender && /male|man/i.test(pf.gender)) return { field, value: 'he/him', source: 'profile:eeo-inferred' };
|
|
675
|
+
}
|
|
602
676
|
return { field, value: null, source: 'skip' };
|
|
603
677
|
}
|
|
604
678
|
|