"""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"(? 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])], )