Files
resume-agent/backend/app/resume_import_rules.py

322 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Deterministic, reviewable section parsing for resume-import fallbacks."""
from __future__ import annotations
import re
from typing import Any
from .resume_import_models import ImportEvidence, ImportFieldReview, ParsedResumeDraft
from .skill_classifier import classify_skills
_DATE = re.compile(
r"((?:19|20)\d{2}[./-](?:0?[1-9]|1[0-2]))\s*(?:-|~|\u2014|\u2013|\u81f3)\s*"
r"((?:19|20)\d{2}[./-](?:0?[1-9]|1[0-2])|\u81f3\u4eca|present)",
re.I,
)
_EMAIL = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
_PHONE = re.compile(r"(?<!\d)(1[3-9]\d{9})(?!\d)")
_CITY = re.compile(r"(?:\u6240\u5728\u5730|\u73b0\u5c45\u5730|\u5730\u5740)\s*[:\uff1a]\s*([^|,\uff0c\n]{2,48})")
_BULLET = re.compile(r"^(?:[\u2022\u00b7\-*\u2023]|\d+[.)\u3001])\s*")
_HEADING_ALIASES: dict[str, tuple[str, str]] = {
"\u6559\u80b2\u7ecf\u5386": ("education", "\u6559\u80b2\u7ecf\u5386"),
"\u6559\u80b2\u80cc\u666f": ("education", "\u6559\u80b2\u80cc\u666f"),
"education": ("education", "\u6559\u80b2\u7ecf\u5386"),
"educationexperience": ("education", "\u6559\u80b2\u7ecf\u5386"),
"\u5de5\u4f5c\u7ecf\u5386": ("work_experience", "\u5de5\u4f5c\u7ecf\u5386"),
"workexperience": ("work_experience", "\u5de5\u4f5c\u7ecf\u5386"),
"\u5b9e\u4e60\u7ecf\u5386": ("internship_experience", "\u5b9e\u4e60\u7ecf\u5386"),
"internshipexperience": ("internship_experience", "\u5b9e\u4e60\u7ecf\u5386"),
"\u9879\u76ee\u7ecf\u5386": ("project_experience", "\u9879\u76ee\u7ecf\u5386"),
"projectexperience": ("project_experience", "\u9879\u76ee\u7ecf\u5386"),
"projects": ("project_experience", "\u9879\u76ee\u7ecf\u5386"),
"\u6821\u56ed\u7ecf\u5386": ("campus_experience", "\u6821\u56ed\u7ecf\u5386"),
"campusexperience": ("campus_experience", "\u6821\u56ed\u7ecf\u5386"),
"\u7ade\u8d5b\u83b7\u5956": ("competition", "\u7ade\u8d5b\u83b7\u5956"),
"\u83b7\u5956\u7ecf\u5386": ("competition", "\u7ade\u8d5b\u83b7\u5956"),
"competition": ("competition", "\u7ade\u8d5b\u83b7\u5956"),
"\u8bc1\u4e66": ("certificates", "\u8bc1\u4e66"),
"certifications": ("certificates", "\u8bc1\u4e66"),
"\u4e13\u4e1a\u6280\u80fd": ("skills", "\u4e13\u4e1a\u6280\u80fd"),
"\u6280\u80fd": ("skills", "\u4e13\u4e1a\u6280\u80fd"),
"\u6280\u672f\u6808": ("skills", "\u4e13\u4e1a\u6280\u80fd"),
"skills": ("skills", "\u4e13\u4e1a\u6280\u80fd"),
"technicalskills": ("skills", "\u4e13\u4e1a\u6280\u80fd"),
"\u81ea\u6211\u8bc4\u4ef7": ("profile_summary", "\u81ea\u6211\u8bc4\u4ef7"),
"\u4e2a\u4eba\u603b\u7ed3": ("profile_summary", "\u4e2a\u4eba\u603b\u7ed3"),
"\u4e2a\u4eba\u4ecb\u7ecd": ("profile_summary", "\u4e2a\u4eba\u603b\u7ed3"),
"personalsummary": ("profile_summary", "个人总结"),
"selfevaluation": ("profile_summary", "自我评价"),
"profile": ("profile_summary", "个人总结"),
"\u4e2a\u4eba\u4eae\u70b9": ("profile_highlights", "\u4e2a\u4eba\u4eae\u70b9"),
}
def parse_resume_text(*, text: str, source_name: str) -> ParsedResumeDraft:
"""Extract explicit resume fields without treating layout or footer contact data as experience."""
lines = [line.strip() for line in text.splitlines() if line.strip()]
groups = _split_sections(lines)
basics = _parse_basics(lines)
sections: list[dict[str, Any]] = []
reviews: list[ImportFieldReview] = []
skills: list[str] = []
summaries: list[tuple[str, str]] = []
for kind, heading, body in groups:
if kind == "skills":
skills.extend(_parse_skills(body))
continue
if kind in {"profile_summary", "profile_highlights"}:
content = _summary_content(body)
if content:
summaries.append((kind, content))
continue
items = _parse_items(kind, body)
if not items:
continue
section_index = len(sections)
sections.append({"kind": kind, "heading": heading, "items": items})
for item_index, item in enumerate(items):
for field, value in item.items():
reviews.append(_review(f"sections[{section_index}].items[{item_index}].{field}", value, text))
profile_summary = _profile_summary(summaries)
for field, value in basics.items():
reviews.append(_review(f"basics.{field}", value, text))
if profile_summary:
reviews.append(_review("profile_summary.content", profile_summary["content"], text))
skill_groups = classify_skills(skills)
for group_index, group in enumerate(skill_groups):
for skill_index, skill in enumerate(group["skills"]):
reviews.append(_review(f"skill_groups[{group_index}].skills[{skill_index}]", skill, text))
if not sections and lines:
description = "\n".join(lines)
sections = [{
"kind": "additional_experience",
"heading": "\u5bfc\u5165\u5185\u5bb9",
"items": [{"title": source_name, "description": description, "provenance": "imported"}],
}]
reviews.append(_review("sections[0].items[0].description", description, text))
document: dict[str, Any] = {
"schema_version": 3,
"basics": basics,
"target": {},
"sections": sections,
"skill_groups": skill_groups,
"import_metadata": {"parse_status": "fallback_partial"},
}
if profile_summary:
document["profile_summary"] = profile_summary
return ParsedResumeDraft(document=document, field_reviews=reviews)
def _normalize_heading(value: str) -> str:
return re.sub(r"[\s:\uff1a\-\u2014\u2013_()\uff08\uff09]", "", value).casefold()
def _heading(line: str) -> tuple[str, str] | None:
return _HEADING_ALIASES.get(_normalize_heading(line))
def _split_sections(lines: list[str]) -> list[tuple[str, str, list[str]]]:
groups: list[tuple[str, str, list[str]]] = []
current: tuple[str, str, list[str]] | None = None
for line in lines:
heading = _heading(line)
if heading:
if current:
groups.append(current)
current = (heading[0], heading[1], [])
elif current:
current[2].append(line)
if current:
groups.append(current)
return groups
def _parse_basics(lines: list[str]) -> dict[str, str]:
source = "\n".join(lines)
basics: dict[str, str] = {}
email = _EMAIL.search(source)
phone = _PHONE.search(source)
city = _CITY.search(source)
if email:
basics["email"] = email.group(0)
if phone:
basics["phone"] = phone.group(1)
if city:
basics["city"] = city.group(1).strip().rstrip(" |\uff5c")
explicit_name = re.search(r"(?:\u59d3\u540d|name)\s*[:\uff1a]?\s*([A-Za-z\u4e00-\u9fff][A-Za-z\u4e00-\u9fff .'-]{1,39})", source, re.I)
if explicit_name:
basics["name"] = explicit_name.group(1).strip()
return basics
candidates = [line for line in lines if _is_name_candidate(line)]
contact_index = next((index for index, line in enumerate(lines) if _EMAIL.search(line) or _PHONE.search(line)), -1)
if contact_index >= 0:
nearby = [line for line in lines[max(0, contact_index - 2):contact_index + 1] if _is_name_candidate(line)]
if nearby:
basics["name"] = nearby[-1]
return basics
if candidates:
basics["name"] = candidates[0]
return basics
def _is_name_candidate(value: str) -> bool:
if _heading(value) or _EMAIL.search(value) or _PHONE.search(value):
return False
normalized = value.strip()
return bool(re.fullmatch(r"[\u4e00-\u9fff]{2,4}|[A-Za-z][A-Za-z .'-]{1,39}", normalized))
def _parse_items(kind: str, body: list[str]) -> list[dict[str, str]]:
clean_body = [line for line in body if not _looks_like_footer(line)]
if not clean_body:
return []
if kind == "project_experience":
return _parse_projects(clean_body)
blocks = _split_item_blocks(clean_body)
return [item for block in blocks if (item := _item_from_block(kind, block))]
def _split_item_blocks(lines: list[str]) -> list[list[str]]:
blocks: list[list[str]] = []
current: list[str] = []
for line in lines:
starts_new = bool(current) and ("|" in line or bool(_DATE.search(line))) and not _BULLET.match(line)
if starts_new:
blocks.append(current)
current = [line]
else:
current.append(line)
if current:
blocks.append(current)
return blocks
def _parse_projects(lines: list[str]) -> list[dict[str, str]]:
starts = [0]
for index in range(1, len(lines)):
line = lines[index]
next_line = lines[index + 1].casefold() if index + 1 < len(lines) else ""
# Project titles are immediately followed by a repository link in the imported layout.
# The line after that link is the project role, not another project.
if ("github" in next_line or "gitlab" in next_line) and not _BULLET.match(line):
starts.append(index)
elif _DATE.search(line) and not _BULLET.match(line):
starts.append(index)
starts = sorted(set(starts))
blocks = [lines[start:(starts[offset + 1] if offset + 1 < len(starts) else len(lines))] for offset, start in enumerate(starts)]
return [item for block in blocks if (item := _project_from_block(block))]
def _item_from_block(kind: str, block: list[str]) -> dict[str, str] | None:
header = block[0]
parts = [part.strip() for part in re.split(r"\s*(?:\||\uff5c)\s*", header) if part.strip()]
date_value = next((part for part in parts if _DATE.search(part)), header if _DATE.search(header) else "")
item: dict[str, str] = {}
if date_value:
start, end = _date_fields(date_value)
item["start_date"] = start
item["end_date_or_present"] = end
before_date = _DATE.sub("", header).strip(" |\uff5c\u00b7-\u2014\u2013")
header_parts = [part.strip() for part in re.split(r"\s*(?:\||\uff5c)\s*|\s{2,}", before_date) if part.strip()]
if len(parts) > 1:
header_parts = [part for part in parts if part != date_value]
keys = {
"education": ("school", "major", "degree"),
"work_experience": ("company", "position"),
"internship_experience": ("company", "position"),
"campus_experience": ("organization", "role"),
"competition": ("name", "award"),
"certificates": ("value",),
}.get(kind, ("title",))
for key, value in zip(keys, header_parts):
item[key] = value
description = "\n".join(block[1:]).strip()
if description:
item["description"] = description
return item or None
def _project_from_block(block: list[str]) -> dict[str, str] | None:
if not block:
return None
header_parts = [part.strip() for part in re.split(r"\s*(?:\||)\s*", block[0]) if part.strip()]
item: dict[str, str] = {"project_name": header_parts[0]}
if len(header_parts) > 1 and not _DATE.search(header_parts[1]):
item["project_role"] = header_parts[1]
for value in header_parts[1:]:
if _DATE.search(value):
start, end = _date_fields(value)
item["start_date"] = start
item["end_date_or_present"] = end
break
body = block[1:]
if body and ("github" in body[0].casefold() or "gitlab" in body[0].casefold()):
body = body[1:]
if body and "project_role" not in item and not _BULLET.match(body[0]) and ("·" in body[0] or "&" in body[0]):
role, _, tools = body[0].partition("·")
item["project_role"] = role.strip()
body = ([tools.strip()] if tools.strip() else []) + body[1:]
description = "\n".join(body).strip()
if description:
item["description"] = description
return item
def _date_fields(value: str) -> tuple[str, str]:
match = _DATE.search(value)
assert match is not None
start = match.group(1).replace("/", "-").replace(".", "-")
end = match.group(2).replace("/", "-").replace(".", "-")
return start, "present" if end.casefold() in {"present", "\u81f3\u4eca"} else end
def _parse_skills(lines: list[str]) -> list[str]:
values: list[str] = []
for line in lines:
values.extend(part.strip() for part in re.split(r"[,\uff0c\u3001;\uff1b|\uff5c/]", line))
return [value for value in values if value and not _looks_like_footer(value)]
def _looks_like_footer(line: str) -> bool:
return bool(_EMAIL.search(line) or _PHONE.search(line) or re.search(r"(?:\u90ae\u7bb1|\u624b\u673a)\s*[:\uff1a]", line, re.I))
def _summary_content(lines: list[str]) -> str:
values: list[str] = []
for line in lines:
if _looks_like_footer(line) or _looks_like_summary_footer(line, has_content=bool(values)):
break
values.append(line)
return "\n".join(values).strip()
def _looks_like_summary_footer(line: str, *, has_content: bool) -> bool:
normalized = line.strip()
if re.search(r"github|linkedin|portfolio|求职意向", normalized, re.I):
return True
return has_content and bool(re.fullmatch(r"[\u4e00-\u9fff]{2,4}", normalized))
def _profile_summary(summaries: list[tuple[str, str]]) -> dict[str, Any] | None:
if not summaries:
return None
preferred = next((content for kind, content in reversed(summaries) if kind == "profile_summary"), None)
content = preferred or summaries[-1][1]
return {"content": content, "source": "user_edited", "generated_at": None, "stale": False}
def _review(field_path: str, value: Any, source: str) -> ImportFieldReview:
text = str(value).strip()
evidence = text if text and text in source else source[:500] or "\u5bfc\u5165\u6587\u6863"
return ImportFieldReview(
field_path=field_path,
value=value,
confidence=0.55,
evidence=[ImportEvidence(page=1, paragraph=1, text=evidence[:500])],
)