Skip to content

Commit 5a6c5eb

Browse files
mcherifclaude
andcommitted
Parallel LLM radio picks + Workable early resume upload
Parallel radio LLM picks (form_filler.py): Pre-scan all radio groups needing LLM before the fill loop and run them concurrently with asyncio.gather. Previously 8 YES/NO qualification questions × ~2s sequential = ~16s; now all run in parallel = ~2-3s. The fill loop reads from the pre-built cache; cache misses fall back to a direct call so correctness is preserved. Workable early resume upload (form_prefill.py): When ATS is Workable, upload the resume before fill_form runs. Workable processes the PDF and auto-populates name, email, work history, and education fields. A 3s wait after upload lets the pre-fill settle before the field scanner re-runs. This avoids manually filling fields that Workable would have filled from the resume anyway. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a64c267 commit 5a6c5eb

2 files changed

Lines changed: 55 additions & 6 deletions

File tree

utils/form_filler.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,38 @@ def _log(msg: str) -> None:
360360
# Build DOM context for anonymous fields once (expensive JS call).
361361
context_map = await _build_context_map(page, fields)
362362

363+
# Pre-scan all radio groups that will need an LLM pick and run them in
364+
# parallel BEFORE the fill loop starts. Sequential LLM calls (one per
365+
# group) are the dominant cost for qualification YES/NO questions (8 groups
366+
# × ~2 s each = 16 s sequential vs ~2-3 s parallel).
367+
_radio_llm_cache: dict[str, str | None] = {}
368+
if not dry_run:
369+
from utils.form_answers import pick_option as _pick_opt_pre
370+
_pre_handled: set[str] = set()
371+
_pending_picks: list[tuple[str, str, list[str]]] = [] # (group_key, question, options)
372+
for _pi, _pf in enumerate(fields):
373+
if _pf.get("type") != "radio":
374+
continue
375+
_pgk = _pf.get("name") or f"radio-group-{_pi}"
376+
if _pgk in _pre_handled:
377+
continue
378+
_pre_handled.add(_pgk)
379+
_pgfs = [f for f in fields if f.get("type") == "radio"
380+
and (f.get("name") == _pf.get("name") if _pf.get("name") else f is _pf)]
381+
_popts = [_effective_label(f, context_map.get(fields.index(f), "")).lower()
382+
for f in _pgfs]
383+
_plbl = _effective_label(_pf, context_map.get(_pi, "")).lower()
384+
if _pick_radio(_popts, _plbl, profile, job) is None:
385+
_pdisp = context_map.get(_pi, _pgk) or _pgk
386+
_pending_picks.append((_pgk, _pdisp, _popts))
387+
if _pending_picks:
388+
_llm_results = await asyncio.gather(
389+
*[_pick_opt_pre(q, opts, profile, job) for _, q, opts in _pending_picks],
390+
return_exceptions=True,
391+
)
392+
for (_pgk, _, _), _res in zip(_pending_picks, _llm_results):
393+
_radio_llm_cache[_pgk] = None if isinstance(_res, Exception) else _res # type: ignore[assignment]
394+
363395
# Phone number: if the form has a separate country/country-code field, fill
364396
# only the local number in the phone field; otherwise use the full
365397
# international number (country code + local).
@@ -584,14 +616,17 @@ def _is_anonymous_text(f: dict) -> bool:
584616
group_display = context_map.get(idx, group_key) or group_key
585617

586618
# LLM fallback for radio groups that no rule matched.
619+
# Results are pre-fetched in parallel above (_radio_llm_cache).
587620
if chosen is None and not dry_run and option_labels:
588621
try:
589-
from utils.form_answers import pick_option as _pick_opt
590-
_log(
591-
f'LLM picking radio for "{(group_display or label_lower)[:60]}"')
592-
chosen_text = await _pick_opt(
593-
group_display or label_lower, option_labels, profile, job
594-
)
622+
chosen_text = _radio_llm_cache.get(group_key)
623+
if chosen_text is None:
624+
# Cache miss (shouldn't normally happen) — fall back to direct call.
625+
from utils.form_answers import pick_option as _pick_opt
626+
_log(f'LLM picking radio for "{(group_display or label_lower)[:60]}"')
627+
chosen_text = await _pick_opt(
628+
group_display or label_lower, option_labels, profile, job
629+
)
595630
if chosen_text:
596631
chosen_lower = chosen_text.lower()
597632
for i, opt in enumerate(option_labels):

utils/form_prefill.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,20 @@ def _log(msg: str) -> None:
913913
except Exception:
914914
pass
915915

916+
# Workable pre-fill: upload resume first so Workable auto-populates
917+
# name, email, work history, etc. before we fill remaining fields.
918+
if result.get("ats") == "workable" and fields:
919+
try:
920+
_log("Workable detected — uploading resume first to enable profile pre-fill…")
921+
early = await try_upload_resume(fill_target, profile, job, log_fn=_log)
922+
_log(f"Early resume upload: {early}")
923+
if early and "uploaded" in early:
924+
_log("Waiting for Workable to process resume pre-fill…")
925+
await fill_target.wait_for_timeout(3000)
926+
fields, fill_target = await _scan_with_frame_fallback(page)
927+
except Exception as exc:
928+
_log(f"Early Workable resume upload error: {exc}")
929+
916930
cl_file_uploaded = False
917931
resume_uploaded = False
918932
if fields:

0 commit comments

Comments
 (0)