feat: builder 简历生成 + 轻度优化 + 简历导入交付副本

自内部仓库剥离深度优化与 RAG 知识库后的交付版本:
- Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强)
- 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护
- 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏
- PostgreSQL 运行时 + Alembic 迁移链

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hyp
2026-08-05 11:08:30 +08:00
co-authored by Claude
commit ae2d9b128d
191 changed files with 27719 additions and 0 deletions
+321
View File
@@ -0,0 +1,321 @@
"""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])],
)