generated from kgod/ai-review-template
自内部仓库剥离深度优化与 RAG 知识库后的交付版本: - Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强) - 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护 - 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏 - PostgreSQL 运行时 + Alembic 迁移链 Co-Authored-By: Claude <noreply@anthropic.com>
322 lines
14 KiB
Python
322 lines
14 KiB
Python
"""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])],
|
||
)
|