fix: harden resume section detection — anchor patterns to full line, expand header synonyms, fix name heuristic for hyphenated/middle-initial names, add parse diagnostics UI

This commit is contained in:
pyr0ball 2026-02-26 09:28:31 -08:00
parent d6545cf496
commit 01a341e4c5
2 changed files with 33 additions and 11 deletions

View file

@ -317,14 +317,33 @@ elif step == 4:
else extract_text_from_docx(file_bytes)
)
with st.spinner("Parsing\u2026"):
parsed = structure_resume(raw_text)
if parsed:
parsed, parse_err = structure_resume(raw_text)
# Diagnostic: show raw extraction + detected fields regardless of outcome
with st.expander("🔍 Parse diagnostics", expanded=not bool(parsed and any(
parsed.get(k) for k in ("name", "experience", "skills")
))):
st.caption("**Raw extracted text (first 800 chars)**")
st.code(raw_text[:800] if raw_text else "(empty)", language="text")
if parsed:
st.caption("**Detected fields**")
st.json({k: (v[:3] if isinstance(v, list) else v) for k, v in parsed.items()})
if parsed and any(parsed.get(k) for k in ("name", "experience", "skills")):
st.session_state["_parsed_resume"] = parsed
st.session_state["_raw_resume_text"] = raw_text
_save_yaml({"_raw_resume_text": raw_text[:8000]})
st.success("Parsed! Review the builder tab to edit entries.")
elif parsed:
# Parsed but empty — show what we got and let them proceed or build manually
st.session_state["_parsed_resume"] = parsed
st.warning("Resume text was extracted but no fields were recognised. "
"Check the diagnostics above — the section headers may use unusual labels. "
"You can still fill in the Build tab manually.")
else:
st.warning("Auto-parse failed \u2014 switch to the Build tab and add entries manually.")
if parse_err:
st.caption(f"Reason: {parse_err}")
with tab_builder:
parsed = st.session_state.get("_parsed_resume", {})

View file

@ -22,11 +22,11 @@ log = logging.getLogger(__name__)
# ── Section header detection ──────────────────────────────────────────────────
_SECTION_NAMES = {
"summary": re.compile(r"^(summary|objective|profile|about me|professional summary)", re.I),
"experience": re.compile(r"^(experience|work experience|employment|work history|professional experience)", re.I),
"education": re.compile(r"^(education|academic|qualifications|degrees?)", re.I),
"skills": re.compile(r"^(skills?|technical skills?|core competencies|competencies|expertise)", re.I),
"achievements": re.compile(r"^(achievements?|accomplishments?|awards?|honors?|certifications?)", re.I),
"summary": re.compile(r"^(summary|objective|profile|about me|professional summary|career summary|career objective|personal statement)\s*:?\s*$", re.I),
"experience": re.compile(r"^(experience|work experience|employment|work history|professional experience|career history|relevant experience|professional history|employment history|positions? held)\s*:?\s*$", re.I),
"education": re.compile(r"^(education|academic|qualifications|degrees?|educational background|academic background)\s*:?\s*$", re.I),
"skills": re.compile(r"^(skills?|technical skills?|core competencies|competencies|expertise|areas? of expertise|key skills?|proficiencies|tools? & technologies)\s*:?\s*$", re.I),
"achievements": re.compile(r"^(achievements?|accomplishments?|awards?|honors?|certifications?|publications?|volunteer)\s*:?\s*$", re.I),
}
# Degrees — used to detect education lines
@ -108,17 +108,20 @@ def _parse_header(lines: list[str]) -> dict:
email_m = _EMAIL_RE.search(full_text)
phone_m = _PHONE_RE.search(full_text)
# Name heuristic: first non-empty line that has no @ and no digits-only tokens
# Name heuristic: first non-empty line that looks like a person's name
name = ""
for line in lines[:5]:
if "@" in line or re.match(r"^\d", line.strip()):
continue
# Skip lines that look like city/state/zip
if re.search(r"\b[A-Z]{2}\b\s*\d{5}", line):
# Skip lines that look like city/state/zip or URLs
if re.search(r"\b[A-Z]{2}\b\s*\d{5}", line) or re.search(r"https?://|linkedin|github", line, re.I):
continue
# Strip separators and credential suffixes (MBA, PhD, etc.) for the alpha check
candidate = re.sub(r"[|•·,]+", " ", line).strip()
candidate = re.sub(r"\s{2,}", " ", candidate)
if 2 <= len(candidate.split()) <= 5 and candidate.replace(" ", "").isalpha():
# Normalise: remove periods, hyphens for the alpha-only check
alpha_check = re.sub(r"[.\-'\u2019]", "", candidate.replace(" ", ""))
if 2 <= len(candidate.split()) <= 5 and alpha_check.isalpha():
name = candidate
break