重构简历导入

This commit is contained in:
zk
2026-07-01 18:41:51 +08:00
parent dfe7cca5a4
commit 89d033c9f9
6 changed files with 172 additions and 241 deletions
+26 -97
View File
@@ -1,13 +1,12 @@
"""简历两阶段并行提取
第一阶段:5路并行提取主表短字段 + 各子表标识名(极快,输出极短)。
第二阶段:N+1路并行提取每条子表记录的短字段 + description/summary 的「行号区间」
description/summary 不再由 AI 照抄原文,AI 只返回行号区间字符串,由代码从原文单元数组切片还原
最终组装为与原方案完全一致的 dict 结构(description 为 list[str]),上下游无感知。
第二阶段:N+1路并行提取每条子表记录的短字段 + description,以及 profile 补充(skills/certificates/summary
description/summary 由 AI 直接按原文结构输出为字符串数组(不再返回行号区间),代码只做类型兜底与清理
最终组装为与原方案一致的 dict 结构(description 为 list[str]summary 为 str),上下游无感知。
"""
import asyncio
import re
import time
from langchain_core.output_parsers import StrOutputParser
@@ -43,78 +42,11 @@ async def _safe_invoke(chain, inp: dict, label: str):
return None
def _number_segments(segments: list[str]) -> str:
"""单元数组构造为带行号文本:每行 `[行号] 内容`,行号 0 开始"""
return "\n".join(f"[{i}] {seg}" for i, seg in enumerate(segments))
# 条目/序号行的起始特征:● ○ • · ▪ ■ ‣ ◆ 等项目符号,或 "1." "2、" "3" "(4)" "①" 等序号。
# 视觉行解析下,一条 bullet 常被折成多行;只有「条目起始行」才另起段落,其余行视为折行续接。
_LIST_ITEM_RE = re.compile(
r"^\s*(?:"
r"[●○◦•·∙▪■□‣◆◇►▶*]" # 项目符号
r"|[-–—]\s" # 连字符 + 空格(markdown 风格)
r"|\d+\s*[..、,)]" # 阿拉伯数字 + 标点:1. / 2、 / 3/ 4)
r"|[(]\s*\d+\s*[)]" # 括号数字:(1) 2
r"|[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳]" # 圈数字
r")"
)
def _split_into_paragraphs(lines: list[str]) -> list[str]:
"""将一个行号区间内的多行拆分为段落列表:
- 以项目符号/序号开头的行 → 另起一个新段落;
- 其余行 → 视为上一段落的折行续接,直连拼接(无分隔符)。
这样即使 AI 把多条 bullet 塞进同一个区间,也能按条目正确换行。
"""
paragraphs: list[str] = []
current = ""
for line in lines:
if not current:
current = line
elif _LIST_ITEM_RE.match(line):
paragraphs.append(current)
current = line
else:
current += line
if current:
paragraphs.append(current)
return [p for p in paragraphs if p.strip()]
def _slice_ranges(range_str, segments: list[str]) -> list[str]:
"""按行号区间字符串切片还原段落:逗号分段,段内按条目符号/折行拆分。
range_str 形如 "3-6,7-9" / "5";非法或越界 token 静默跳过/clamp。
返回段落数组(每个逗号段可能因含多条 bullet 而进一步拆成多个段落)。
"""
if not range_str or not isinstance(range_str, str):
def _clean_str_list(value) -> list[str]:
""" AI 返回值规整为字符串数组:过滤非字符串与空白元素,去除首尾空白"""
if not isinstance(value, list):
return []
n = len(segments)
paragraphs: list[str] = []
for token in range_str.split(","):
token = token.strip()
if not token:
continue
if "-" in token:
a, _, b = token.partition("-")
a, b = a.strip(), b.strip()
if not a.isdigit() or not b.isdigit():
continue
start, end = int(a), int(b)
else:
if not token.isdigit():
continue
start = end = int(token)
if start > end:
start, end = end, start
start = max(0, start)
end = min(n - 1, end)
if start > n - 1:
continue
paragraphs.extend(_split_into_paragraphs(segments[start:end + 1]))
return paragraphs
return [s.strip() for s in value if isinstance(s, str) and s.strip()]
# ==================== 第一阶段:概览 ====================
@@ -126,9 +58,9 @@ _overview_project_chain = _build_chain(OVERVIEW_PROJECT_PROMPT)
_overview_competition_chain = _build_chain(OVERVIEW_COMPETITION_PROMPT)
async def _extract_overview(numbered_text: str) -> dict:
async def _extract_overview(text: str) -> dict:
"""第一阶段:5路并行提取概览信息"""
inp = {"text": numbered_text}
inp = {"text": text}
profile, edu_names, work_names, proj_names, comp_names = await asyncio.gather(
_safe_invoke(_overview_profile_chain, inp, "概览-个人信息"),
_safe_invoke(_overview_education_chain, inp, "概览-教育"),
@@ -160,20 +92,20 @@ _DETAIL_MODULES: tuple[tuple[str, str, str], ...] = (
_SUB_MODULES: tuple[str, ...] = tuple(m[0] for m in _DETAIL_MODULES)
async def _extract_detail(prompt_tpl: str, name: str, numbered_text: str, label: str) -> dict | None:
async def _extract_detail(prompt_tpl: str, name: str, text: str, label: str) -> dict | None:
"""单条子表记录详情提取:用 name 替换 prompt 中的 {name}"""
chain = _build_chain(prompt_tpl.replace("{name}", name))
return await _safe_invoke(chain, {"text": numbered_text}, label)
return await _safe_invoke(chain, {"text": text}, label)
async def _extract_all_details(overview: dict, numbered_text: str) -> dict:
async def _extract_all_details(overview: dict, text: str) -> dict:
"""第二阶段:根据概览结果,N+1路并行提取所有子表记录详情 + 个人信息补充"""
# 第 0 路固定为 profile 补充(skills/certificates/summaryRange),其余按子表记录展开
tasks = [_extract_detail(DETAIL_PROFILE_PROMPT, "", numbered_text, "详情-个人信息补充")]
# 第 0 路固定为 profile 补充(skills/certificates/summary),其余按子表记录展开
tasks = [_extract_detail(DETAIL_PROFILE_PROMPT, "", text, "详情-个人信息补充")]
task_modules = ["profile_extra"]
for module, prompt_tpl, label in _DETAIL_MODULES:
for name in overview[module]:
tasks.append(_extract_detail(prompt_tpl, name, numbered_text, f"详情-{label}-{name}"))
tasks.append(_extract_detail(prompt_tpl, name, text, f"详情-{label}-{name}"))
task_modules.append(module)
results = await asyncio.gather(*tasks)
@@ -185,21 +117,20 @@ async def _extract_all_details(overview: dict, numbered_text: str) -> dict:
# ==================== 组装 ====================
def _assemble(overview: dict, details: dict, segments: list[str]) -> dict:
"""将两阶段结果组装为与原方案一致的 dict 结构(description 还原为 list[str]"""
def _assemble(overview: dict, details: dict) -> dict:
"""将两阶段结果组装为与原方案一致的 dict 结构(description 为 list[str]summary 为 str"""
profile = overview["profile"]
profile_extra = details.get("profile_extra", [{}])[0] if details.get("profile_extra") else {}
profile["skills"] = (profile_extra.get("skills") or [])[:5]
profile["certificates"] = profile_extra.get("certificates") or []
summary_paras = _slice_ranges(profile_extra.get("summaryRange"), segments)
profile["skills"] = _clean_str_list(profile_extra.get("skills"))[:5]
profile["certificates"] = _clean_str_list(profile_extra.get("certificates"))
summary_paras = _clean_str_list(profile_extra.get("summary"))
profile["summary"] = "\n".join(summary_paras) if summary_paras else None
result = dict(profile)
for module in _SUB_MODULES:
items = []
for item in details.get(module, []):
item["description"] = _slice_ranges(item.get("descriptionRange"), segments)
item.pop("descriptionRange", None)
item["description"] = _clean_str_list(item.get("description"))
items.append(item)
result[module] = items
return result
@@ -207,23 +138,21 @@ def _assemble(overview: dict, details: dict, segments: list[str]) -> dict:
# ==================== 入口 ====================
async def extract_all(segments: list[str]) -> dict:
async def extract_all(text: str) -> dict:
"""两阶段并行提取简历,返回与原方案完全一致的结构化数据
segments: 文件解析后的「文本单元数组」(PDF按块/docx·txt行)。
text: 简历纯文本全文(PyMuPDF/docx/txt 提取,保留原始换行)。
"""
numbered_text = _number_segments(segments)
log.info("第一阶段:5路并行概览提取")
overview = await _extract_overview(numbered_text)
overview = await _extract_overview(text)
log.info(
"概览完成 - " + " ".join(f"{label}:{len(overview[module])}" for module, _, label in _DETAIL_MODULES)
)
total = sum(len(overview[m]) for m in _SUB_MODULES)
log.info(f"第二阶段:{total + 1}路并行详情提取")
details = await _extract_all_details(overview, numbered_text)
details = await _extract_all_details(overview, text)
result = _assemble(overview, details, segments)
result = _assemble(overview, details)
log.info("两阶段提取完成,数据组装完毕")
return result