Skip to content

Commit 0abc313

Browse files
mcherifclaude
andcommitted
Fix Greenhouse education/employment form filling
Employment history: - Rewrite _fill_last to collect both <input> and <select> elements in one JS round-trip; add _fill_select_last for month dropdowns - Parse date strings (e.g. "Feb 2022") into month + year components and fill them separately into SELECT and text inputs - Move company-name rules before generic "name" rule in _TEXT_RULES so "Company name" label resolves to current_company, not personal name - Guard "start date year/month" labels in _resolve_text_value so they return "" instead of matching the "available_from" availability rule - Fix employment-context pre-pass to handle combined labels like "employment company name" (context prepended by fill_form) Education: - Add school/degree/field rules to _TEXT_RULES - Fix combobox fallback search: for multi-word values strip leading articles and try suffixes from the back ("British Columbia", then "Columbia") so school name dropdowns filter correctly Resume parser: - Fix education key: store as "school" (was "institution") to match profile.yaml schema and form-filling rules - Auto-populate current_company/current_title from first work entry - Increase text truncation limit 6k->12k chars; update LLM schema to request "school" field name Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0e1cb7f commit 0abc313

3 files changed

Lines changed: 224 additions & 78 deletions

File tree

utils/form_filler.py

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@
3030
# The FIRST rule whose keywords all appear in the normalised label wins.
3131
# ---------------------------------------------------------------------------
3232
_TEXT_RULES: list[tuple[list[str], Any]] = [
33+
# Company / employer name rules MUST come before the generic "name" rule
34+
# so "company name" label hits the right rule first.
35+
(["current company"], lambda p, j: p.get("personal", {}).get("current_company", "")),
36+
(["current employer"], lambda p, j: p.get("personal", {}).get("current_company", "")),
37+
(["company name"], lambda p, j: p.get("personal", {}).get("current_company", "")),
38+
(["organization"], lambda p, j: p.get("personal", {}).get("current_company", "")),
3339
# First/last name rules must come before the generic "name" rule.
3440
(["first name"], lambda p, j: (
3541
p.get("personal", {}).get("name", "") or "").split()[0]),
@@ -143,11 +149,6 @@
143149
(["current position"], lambda p, j: p.get("personal", {}).get("current_title", "")),
144150
(["current role"], lambda p, j: p.get("personal", {}).get("current_title", "")),
145151
(["job title"], lambda p, j: p.get("personal", {}).get("current_title", "")),
146-
# Current employer / company name.
147-
(["current company"], lambda p, j: p.get("personal", {}).get("current_company", "")),
148-
(["current employer"], lambda p, j: p.get("personal", {}).get("current_company", "")),
149-
(["company name"], lambda p, j: p.get("personal", {}).get("current_company", "")),
150-
(["organization"], lambda p, j: p.get("personal", {}).get("current_company", "")),
151152
# "org" is Lever's name attribute for the current company field.
152153
(["org"], lambda p, j: p.get("personal", {}).get("current_company", "")),
153154
# Cover letter field — use the pre-generated cover letter.
@@ -194,6 +195,17 @@
194195
(["speak fluently"], lambda p, j: ", ".join(p.get("languages", []))),
195196
(["languages do you speak"], lambda p, j: ", ".join(p.get("languages", []))),
196197
(["spoken language"], lambda p, j: ", ".join(p.get("languages", []))),
198+
# Education — school / degree from the first education entry in the profile.
199+
(["school"], lambda p, j: (p.get("education") or [{}])[0].get("school", "")),
200+
(["university"], lambda p, j: (p.get("education") or [{}])[0].get("school", "")),
201+
(["college"], lambda p, j: (p.get("education") or [{}])[0].get("school", "")),
202+
(["institution"], lambda p, j: (p.get("education") or [{}])[0].get("school", "")),
203+
(["degree"], lambda p, j: (p.get("education") or [{}])[0].get("degree", "")),
204+
(["highest degree"], lambda p, j: (p.get("education") or [{}])[0].get("degree", "")),
205+
(["highest level", "education"], lambda p, j: (p.get("education") or [{}])[0].get("degree", "")),
206+
(["field of study"], lambda p, j: (p.get("education") or [{}])[0].get("field", "")),
207+
(["major"], lambda p, j: (p.get("education") or [{}])[0].get("field", "")),
208+
(["discipline"], lambda p, j: (p.get("education") or [{}])[0].get("field", "")),
197209
]
198210

199211
# Timezone label keyword → profile timezone values that match (lowercase)
@@ -1198,13 +1210,32 @@ def _resolve_text_value(label_lower: str, profile: dict, job: dict) -> str:
11981210
(e.g. "city" substring-matching inside "ethnicity").
11991211
Multi-word keywords (e.g. "first name") are still matched as substrings.
12001212
"""
1201-
# Pre-pass: if the label is a bare "name" inside an employment section context
1202-
# (e.g. Greenhouse "Employment > Name"), return the current company rather than
1203-
# the candidate's personal name.
12041213
label_words_set = set(re.findall(r"\w+", label_lower))
1205-
if label_words_set <= {"name", "company"} or label_lower.strip() in ("name", "company name"):
1206-
if any(word in label_lower for word in _EMPLOYMENT_SECTION_WORDS):
1207-
return profile.get("personal", {}).get("current_company", "") or ""
1214+
1215+
# Pre-pass: if the label is a "company name" / bare "name" field inside an
1216+
# employment section context (e.g. Greenhouse "Employment > Company name"),
1217+
# return the current company rather than the candidate's personal name.
1218+
# Condition: "name" is in the label AND no first/last qualifier AND either
1219+
# "company"/"employer" is also present OR the label (stripped of employment
1220+
# section noise) is just "name".
1221+
_emp_ctx = any(word in label_lower for word in _EMPLOYMENT_SECTION_WORDS)
1222+
_clean_words = label_words_set - frozenset({
1223+
"employment", "employer", "work", "history", "experience",
1224+
"previous", "job", "of",
1225+
})
1226+
if (
1227+
_emp_ctx
1228+
and "name" in label_words_set
1229+
and "first" not in label_words_set
1230+
and "last" not in label_words_set
1231+
and ("company" in label_words_set or _clean_words <= {"name"})
1232+
):
1233+
return profile.get("personal", {}).get("current_company", "") or ""
1234+
1235+
# Pre-pass: "start date year" / "start date month" are employment date
1236+
# sub-fields — do not match the generic "start date" availability rule.
1237+
if "start" in label_words_set and ("year" in label_words_set or "month" in label_words_set):
1238+
return ""
12081239

12091240
# Pre-pass: if the label is a work-auth question that names a specific country,
12101241
# check authorization for THAT country rather than the job's location.
@@ -1920,21 +1951,32 @@ def _query_opts(listbox_id: str) -> str:
19201951
aria_controls = await el.get_attribute("aria-controls") or ""
19211952
opts = await page.evaluate(_query_opts(aria_controls), aria_controls)
19221953

1923-
# If typing the full value (e.g. "Tunis, Tunisia") produced no options,
1924-
# retry with shorter alternatives so the dropdown has something to show:
1925-
# 1. Country part only: "Tunisia" from "Tunis, Tunisia"
1926-
# 2. City/first word: "Tunis"
1927-
# This gives the LLM meaningful options to choose from.
1954+
# If typing the full value produced no options, retry with shorter search
1955+
# terms so the dropdown has something to filter on. Strategy:
1956+
# - Comma-separated ("Tunis, Tunisia"): try country part, then city.
1957+
# - Space-separated multi-word ("The University of British Columbia"):
1958+
# skip leading articles, then try from the back (most distinctive
1959+
# words first): "British Columbia", "Columbia", each significant word.
1960+
_ARTICLES = {"the", "a", "an", "of", "at", "in"}
19281961
if not opts and is_input and ("," in value or " " in value):
1929-
fallback_terms = []
1962+
fallback_terms: list[str] = []
19301963
if "," in value:
19311964
fallback_terms.append(value.split(",")[-1].strip()) # country
19321965
fallback_terms.append(value.split(",")[0].strip()) # city
19331966
else:
1934-
fallback_terms.append(value.split()[0].strip())
1967+
words = value.split()
1968+
# Strip leading articles to get the meaningful part.
1969+
sig = [w for w in words if w.lower() not in _ARTICLES]
1970+
# Try progressively shorter suffixes (e.g. "British Columbia", "Columbia")
1971+
for n in range(min(3, len(sig)), 0, -1):
1972+
fallback_terms.append(" ".join(sig[-n:]))
1973+
# Also try each significant word individually as a last resort.
1974+
fallback_terms.extend(sig)
1975+
seen: set[str] = set()
19351976
for term in fallback_terms:
1936-
if not term:
1977+
if not term or term in seen or term.lower() == value.lower():
19371978
continue
1979+
seen.add(term)
19381980
await el.fill(term)
19391981
await asyncio.sleep(0.5)
19401982
aria_controls = await el.get_attribute("aria-controls") or ""

utils/form_prefill.py

Lines changed: 136 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -483,55 +483,59 @@ def _log(msg: str) -> None:
483483
break
484484

485485
# After clicking, new input fields appear at the bottom of the group list.
486-
# Target the *last* set of inputs matching each label pattern.
487-
# Uses a single JS call to collect placeholder / aria-label / name /
488-
# <label for="id"> text for the last N visible inputs, then fills by id/name.
486+
# Uses a single JS call to collect label/placeholder/name for recent
487+
# visible inputs *and* select elements, then fills by id/name.
488+
489+
_JS_COLLECT = """(maxBack) => {
490+
function getLabel(el) {
491+
if (el.id) {
492+
const lbl = document.querySelector('label[for="' + el.id + '"]');
493+
if (lbl) return lbl.innerText || '';
494+
}
495+
let node = el.parentElement;
496+
for (let d = 0; d < 5; d++) {
497+
if (!node) break;
498+
if (node.tagName === 'LABEL') return node.innerText || '';
499+
const sib = node.previousElementSibling;
500+
if (sib && sib.tagName === 'LABEL') return sib.innerText || '';
501+
node = node.parentElement;
502+
}
503+
return '';
504+
}
505+
const inputSel = "input[type='text'], input:not([type]), " +
506+
"input[type='month'], input[type='date']";
507+
const selectSel = "select";
508+
function visible(el) {
509+
const r = el.getBoundingClientRect();
510+
if (r.width === 0 && r.height === 0) return false;
511+
const s = window.getComputedStyle(el);
512+
return s.display !== 'none' && s.visibility !== 'hidden';
513+
}
514+
const inputs = Array.from(document.querySelectorAll(inputSel)).filter(visible);
515+
const selects = Array.from(document.querySelectorAll(selectSel)).filter(visible);
516+
const inputSlice = inputs.slice(Math.max(0, inputs.length - maxBack)).reverse();
517+
const selectSlice = selects.slice(Math.max(0, selects.length - maxBack)).reverse();
518+
const map = el => ({
519+
tag: el.tagName.toLowerCase(),
520+
id: el.id || '',
521+
name: el.name || '',
522+
placeholder: el.placeholder || '',
523+
ariaLabel: el.getAttribute('aria-label') || '',
524+
labelText: getLabel(el).trim(),
525+
options: el.tagName === 'SELECT'
526+
? Array.from(el.options).map(o => o.text.trim())
527+
: [],
528+
});
529+
return { inputs: inputSlice.map(map), selects: selectSlice.map(map) };
530+
}"""
531+
489532
async def _fill_last(pattern: re.Pattern, value: str) -> bool:
533+
"""Fill the last visible text input matching *pattern* with *value*."""
490534
if not value:
491535
return False
492536
try:
493-
candidates = await page.evaluate("""(maxBack) => {
494-
const sel = "input[type='text'], input:not([type]), " +
495-
"input[type='month'], input[type='date']";
496-
const inputs = Array.from(document.querySelectorAll(sel))
497-
.filter(el => {
498-
const r = el.getBoundingClientRect();
499-
if (r.width === 0 && r.height === 0) return false;
500-
const s = window.getComputedStyle(el);
501-
return s.display !== 'none' && s.visibility !== 'hidden';
502-
});
503-
const slice = inputs.slice(Math.max(0, inputs.length - maxBack));
504-
return slice.reverse().map(inp => {
505-
let labelText = '';
506-
if (inp.id) {
507-
const lbl = document.querySelector('label[for="' + inp.id + '"]');
508-
if (lbl) labelText = lbl.innerText || '';
509-
}
510-
if (!labelText) {
511-
let node = inp.parentElement;
512-
for (let d = 0; d < 5; d++) {
513-
if (!node) break;
514-
if (node.tagName === 'LABEL') {
515-
labelText = node.innerText || ''; break;
516-
}
517-
const sib = node.previousElementSibling;
518-
if (sib && sib.tagName === 'LABEL') {
519-
labelText = sib.innerText || ''; break;
520-
}
521-
node = node.parentElement;
522-
}
523-
}
524-
return {
525-
id: inp.id || '',
526-
name: inp.name || '',
527-
placeholder: inp.placeholder || '',
528-
ariaLabel: inp.getAttribute('aria-label') || '',
529-
labelText: labelText.trim(),
530-
};
531-
});
532-
}""", 20)
533-
534-
for cand in candidates:
537+
data = await page.evaluate(_JS_COLLECT, 20)
538+
for cand in data.get("inputs", []):
535539
hint = " ".join(filter(None, [
536540
cand.get("placeholder", ""),
537541
cand.get("ariaLabel", ""),
@@ -557,10 +561,95 @@ async def _fill_last(pattern: re.Pattern, value: str) -> bool:
557561
pass
558562
return False
559563

564+
async def _fill_select_last(pattern: re.Pattern, value: str) -> bool:
565+
"""Select an option in the last visible <select> matching *pattern*."""
566+
if not value:
567+
return False
568+
val_lower = value.lower()
569+
try:
570+
data = await page.evaluate(_JS_COLLECT, 20)
571+
for cand in data.get("selects", []):
572+
hint = " ".join(filter(None, [
573+
cand.get("ariaLabel", ""),
574+
cand.get("labelText", ""),
575+
cand.get("name", ""),
576+
]))
577+
if not pattern.search(hint):
578+
continue
579+
options = cand.get("options", [])
580+
# Find best option: exact → starts-with → contains.
581+
chosen = next(
582+
(opt for opt in options if opt.lower() == val_lower), None
583+
)
584+
if not chosen:
585+
chosen = next(
586+
(opt for opt in options if opt.lower().startswith(val_lower[:3])),
587+
None,
588+
)
589+
if not chosen:
590+
chosen = next(
591+
(opt for opt in options if val_lower in opt.lower()), None
592+
)
593+
if not chosen:
594+
continue
595+
cand_id = cand.get("id", "")
596+
cand_name = cand.get("name", "")
597+
if cand_id:
598+
el = page.locator(f"#{cand_id}").first
599+
elif cand_name:
600+
el = page.locator(f"[name='{cand_name}']").last
601+
else:
602+
continue
603+
try:
604+
await el.select_option(label=chosen)
605+
return True
606+
except Exception:
607+
try:
608+
await el.select_option(value=chosen)
609+
return True
610+
except Exception:
611+
continue
612+
except Exception:
613+
pass
614+
return False
615+
616+
# Parse "Feb 2022" → month abbreviation + 4-digit year.
617+
def _parse_date(date_str: str):
618+
parts = date_str.split()
619+
month = parts[0] if len(parts) >= 1 else ""
620+
year = parts[1] if len(parts) >= 2 else parts[0] if parts[0].isdigit() else ""
621+
return month, year
622+
623+
from_month, from_year = _parse_date(date_from)
624+
to_month, to_year = _parse_date(date_to if date_to != "present" else "")
625+
626+
_START_MONTH_LABELS = re.compile(r"start.*(month|date)|start\s*date.*month", re.I)
627+
_START_YEAR_LABELS = re.compile(r"start.*(year)|start\s*date.*year", re.I)
628+
_END_MONTH_LABELS = re.compile(r"end.*(month|date)|end\s*date.*month", re.I)
629+
_END_YEAR_LABELS = re.compile(r"end.*(year)|end\s*date.*year", re.I)
630+
560631
filled_company = await _fill_last(_COMPANY_LABELS, company)
561632
filled_title = await _fill_last(_TITLE_LABELS, title)
562-
filled_start = await _fill_last(_START_LABELS, date_from)
563-
filled_end = await _fill_last(_END_LABELS, date_to if date_to != "present" else "")
633+
634+
# Start date: try month SELECT first, then year text input.
635+
filled_start_month = await _fill_select_last(_START_MONTH_LABELS, from_month) if from_month else False
636+
filled_start_year = await _fill_last(_START_YEAR_LABELS, from_year) if from_year else False
637+
# Fallback: if no month-specific SELECT found, try the broad start pattern as SELECT.
638+
if not filled_start_month:
639+
filled_start_month = await _fill_select_last(_START_LABELS, from_month) if from_month else False
640+
if not filled_start_year:
641+
filled_start_year = await _fill_last(_START_LABELS, from_year) if from_year else False
642+
filled_start = filled_start_month or filled_start_year
643+
644+
# End date: same pattern.
645+
filled_end_month = await _fill_select_last(_END_MONTH_LABELS, to_month) if to_month else False
646+
filled_end_year = await _fill_last(_END_YEAR_LABELS, to_year) if to_year else False
647+
if not filled_end_month:
648+
filled_end_month = await _fill_select_last(_END_LABELS, to_month) if to_month else False
649+
if not filled_end_year:
650+
filled_end_year = await _fill_last(_END_LABELS, to_year) if to_year else False
651+
filled_end = filled_end_month or filled_end_year
652+
564653
_log(
565654
f"Employment history: filled {company!r} — "
566655
f"company={filled_company} title={filled_title} "

0 commit comments

Comments
 (0)