优化简历提取相关

This commit is contained in:
zk
2026-06-24 16:16:37 +08:00
parent 556302816b
commit 62a0801a22
5 changed files with 253 additions and 106 deletions
+128 -47
View File
@@ -1,11 +1,13 @@
"""简历两阶段并行提取
第一阶段:5路并行提取主表短字段 + 各子表标识名(极快,输出极短)。
第二阶段:N+1路并行提取每条子表记录的详情(含description原文)+ 个人信息补充(skills/certificates/summary
最终组装为与原方案完全一致的 dict 结构,上下游无感知
第二阶段:N+1路并行提取每条子表记录的短字段 + description/summary 的「行号区间」
description/summary 不再由 AI 照抄原文,AI 只返回行号区间字符串,由代码从原文单元数组切片还原
最终组装为与原方案完全一致的 dict 结构(description 为 list[str]),上下游无感知。
"""
import asyncio
import re
import time
from langchain_core.output_parsers import StrOutputParser
@@ -41,6 +43,80 @@ 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):
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
# ==================== 第一阶段:概览 ====================
_overview_profile_chain = _build_chain(OVERVIEW_PROFILE_PROMPT)
@@ -50,9 +126,9 @@ _overview_project_chain = _build_chain(OVERVIEW_PROJECT_PROMPT)
_overview_competition_chain = _build_chain(OVERVIEW_COMPETITION_PROMPT)
async def _extract_overview(text: str) -> dict:
async def _extract_overview(numbered_text: str) -> dict:
"""第一阶段:5路并行提取概览信息"""
inp = {"text": text}
inp = {"text": numbered_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, "概览-教育"),
@@ -72,60 +148,58 @@ async def _extract_overview(text: str) -> dict:
# ==================== 第二阶段:详情 ====================
async def _extract_detail(prompt_tpl: str, name: str, text: str, label: str) -> dict | None:
# 子表模块统一配置(单一数据源):(模块键, 详情 prompt 模板, 日志标签)
# 概览/详情/组装/日志均复用此清单,新增子表只需在此追加一行。
_DETAIL_MODULES: tuple[tuple[str, str, str], ...] = (
("education", DETAIL_EDUCATION_PROMPT, "教育"),
("work", DETAIL_WORK_PROMPT, "工作"),
("internship", DETAIL_INTERNSHIP_PROMPT, "实习"),
("project", DETAIL_PROJECT_PROMPT, "项目"),
("competition", DETAIL_COMPETITION_PROMPT, "竞赛"),
)
_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:
"""单条子表记录详情提取:用 name 替换 prompt 中的 {name}"""
prompt = prompt_tpl.replace("{name}", name)
chain = _build_chain(prompt)
return await _safe_invoke(chain, {"text": text}, label)
chain = _build_chain(prompt_tpl.replace("{name}", name))
return await _safe_invoke(chain, {"text": numbered_text}, label)
async def _extract_all_details(overview: dict, text: str) -> dict:
async def _extract_all_details(overview: dict, numbered_text: str) -> dict:
"""第二阶段:根据概览结果,N+1路并行提取所有子表记录详情 + 个人信息补充"""
tasks: list = []
task_meta: list[tuple[str, int]] = []
# profile 补充:skills/certificates/summary
tasks.append(_extract_detail(DETAIL_PROFILE_PROMPT, "", text, "详情-个人信息补充"))
task_meta.append(("profile_extra", 0))
for i, name in enumerate(overview["education"]):
tasks.append(_extract_detail(DETAIL_EDUCATION_PROMPT, name, text, f"详情-教育-{name}"))
task_meta.append(("education", i))
for i, name in enumerate(overview["work"]):
tasks.append(_extract_detail(DETAIL_WORK_PROMPT, name, text, f"详情-工作-{name}"))
task_meta.append(("work", i))
for i, name in enumerate(overview["internship"]):
tasks.append(_extract_detail(DETAIL_INTERNSHIP_PROMPT, name, text, f"详情-实习-{name}"))
task_meta.append(("internship", i))
for i, name in enumerate(overview["project"]):
tasks.append(_extract_detail(DETAIL_PROJECT_PROMPT, name, text, f"详情-项目-{name}"))
task_meta.append(("project", i))
for i, name in enumerate(overview["competition"]):
tasks.append(_extract_detail(DETAIL_COMPETITION_PROMPT, name, text, f"详情-竞赛-{name}"))
task_meta.append(("competition", i))
# 第 0 路固定为 profile 补充(skills/certificates/summaryRange),其余按子表记录展开
tasks = [_extract_detail(DETAIL_PROFILE_PROMPT, "", numbered_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}"))
task_modules.append(module)
results = await asyncio.gather(*tasks)
details: dict[str, list] = {"profile_extra": [], "education": [], "work": [], "internship": [], "project": [], "competition": []}
for (module, _idx), result in zip(task_meta, results):
details: dict[str, list] = {"profile_extra": [], **{m: [] for m in _SUB_MODULES}}
for module, result in zip(task_modules, results):
details[module].append(result if isinstance(result, dict) else {})
return details
# ==================== 组装 ====================
def _assemble(overview: dict, details: dict) -> dict:
"""将两阶段结果组装为与原方案一致的 dict 结构"""
def _assemble(overview: dict, details: dict, segments: list[str]) -> dict:
"""将两阶段结果组装为与原方案一致的 dict 结构description 还原为 list[str]"""
profile = overview["profile"]
profile_extra = details.get("profile_extra", [{}])[0] if details.get("profile_extra") else {}
profile["skills"] = profile_extra.get("skills") or []
profile["skills"] = (profile_extra.get("skills") or [])[:5]
profile["certificates"] = profile_extra.get("certificates") or []
profile["summary"] = profile_extra.get("summary")
summary_paras = _slice_ranges(profile_extra.get("summaryRange"), segments)
profile["summary"] = "\n".join(summary_paras) if summary_paras else None
result = dict(profile)
for module in ("education", "work", "internship", "project", "competition"):
for module in _SUB_MODULES:
items = []
for item in details.get(module, []):
if not item.get("description"):
item["description"] = []
item["description"] = _slice_ranges(item.get("descriptionRange"), segments)
item.pop("descriptionRange", None)
items.append(item)
result[module] = items
return result
@@ -133,16 +207,23 @@ def _assemble(overview: dict, details: dict) -> dict:
# ==================== 入口 ====================
async def extract_all(text: str) -> dict:
"""两阶段并行提取简历,返回与原方案完全一致的结构化数据"""
async def extract_all(segments: list[str]) -> dict:
"""两阶段并行提取简历,返回与原方案完全一致的结构化数据
segments: 文件解析后的「文本单元数组」(PDF按块/docx·txt按行)。
"""
numbered_text = _number_segments(segments)
log.info("第一阶段:5路并行概览提取")
overview = await _extract_overview(text)
log.info(f"概览完成 - 教育:{len(overview['education'])} 工作:{len(overview['work'])} 实习:{len(overview['internship'])} 项目:{len(overview['project'])} 竞赛:{len(overview['competition'])}")
overview = await _extract_overview(numbered_text)
log.info(
"概览完成 - " + " ".join(f"{label}:{len(overview[module])}" for module, _, label in _DETAIL_MODULES)
)
total = sum(len(overview[m]) for m in ("education", "work", "internship", "project", "competition"))
total = sum(len(overview[m]) for m in _SUB_MODULES)
log.info(f"第二阶段:{total + 1}路并行详情提取")
details = await _extract_all_details(overview, text)
details = await _extract_all_details(overview, numbered_text)
result = _assemble(overview, details)
result = _assemble(overview, details, segments)
log.info("两阶段提取完成,数据组装完毕")
return result
+42 -25
View File
@@ -1,42 +1,55 @@
"""简历两阶段提取 Prompt
第一阶段(概览):5路并行,只提取主表短字段和子表标识名。
第二阶段(详情):N路并行,每条子表记录单独提取全部字段description 直接输出原文
第二阶段(详情):N路并行,每条子表记录单独提取字段description/summary 不照抄原文
只返回「行号区间字符串」,由代码按行号从原文单元数组切片还原。
输入文本格式:每行形如 `[行号] 内容`,行号从 0 开始连续递增。
行号区间字符串格式:`"起-止,起-止"`0 开始、闭区间(含两端)。
- 逗号分隔的每一段代表「一个段落」。
- 单行可简写为 `"5"`(等价 `"5-5"`)。
- 没有内容则返回空字符串 ""
- 行号必须是上面输入文本中真实出现过的编号,严禁编造不存在的行号。
花括号用 {{ }} 转义,避免被 ChatPromptTemplate 当作变量。{name} 为运行时替换的记录标识名。
"""
# ==================== 第一阶段:概览提取 ====================
OVERVIEW_PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造,没有的填null。
从中仅提取个人基本信息(不含技能、证书、自我评价),输出JSON:
输入为带行号的简历文本(每行 `[行号] 内容`)。从中仅提取个人基本信息(不含技能、证书、自我评价),输出JSON:
```json
{{ "name": "姓名", "email": "邮箱", "mobileNumber": "手机号", "city": "所在城市", "wechatNumber": "微信号", "portfolioUrl": "作品集链接" }}
```
规则:只提取以上6个字段,不提取skills/certificates/summary/经历。没有的填null。只输出JSON。"""
OVERVIEW_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。
从中仅提取教育经历的学校名称列表,输出JSON数组:
输入为带行号的简历文本。从中仅提取教育经历的学校名称列表,输出JSON数组:
```json
["北京大学", "清华大学"]
```
规则:只提取学校名称,不提取其他字段。没有输出[]。只输出JSON。"""
OVERVIEW_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造。
从中仅提取工作经历和实习经历的公司名称列表,输出JSON:
输入为带行号的简历文本。从中仅提取工作经历和实习经历的公司名称列表,输出JSON:
```json
{{ "work": ["阿里巴巴", "腾讯"], "internship": ["字节跳动"] }}
```
规则:标注"实习"的归internship,其余归work。只提取公司名称。没有填[]。只输出JSON。"""
OVERVIEW_PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编造。
从中仅提取项目经历的项目名称列表,输出JSON数组:
输入为带行号的简历文本。从中仅提取项目经历的项目名称列表,输出JSON数组:
```json
["订单系统重构", "支付网关"]
```
规则:只提取项目名称,不提取其他字段。没有输出[]。只输出JSON。"""
规则:
1. 只提取作为**独立项目条目标题**出现的项目名(通常单独成行、常带"项目X"前缀或【】标题、并配有自己的起止时间/角色/描述)。
2. **绝对不要**把出现在某段职责/产品描述句子内部、被"如/包括/例如/【】"等列举的功能点、模块、子功能当作项目(这些是描述内容,不是项目)。
3. 没有输出[]。只输出JSON。"""
OVERVIEW_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。
从中仅提取竞赛/获奖经历的竞赛名称列表,输出JSON数组:
输入为带行号的简历文本。从中仅提取竞赛/获奖经历的竞赛名称列表,输出JSON数组:
```json
["ACM区域赛", "数学建模大赛"]
```
@@ -44,44 +57,48 @@ OVERVIEW_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测
# ==================== 第二阶段:详情提取 ====================
DETAIL_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。
DETAIL_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。
请提取"{name}"这条教育经历的详细信息,输出JSON
```json
{{ "school": "学校", "major": "专业", "degree": "学历", "studyType": "全日制/非全日制", "startDate": "2020.09", "endDate": "2024.06", "description": ["原文描述段落1", "原文描述段落2"] }}
{{ "school": "学校", "major": "专业", "degree": "学历", "studyType": "全日制/非全日制", "startDate": "2020.09", "endDate": "2024.06", "descriptionRange": "起-止,起-止" }}
```
规则:description填该经历的描述原文段落数组。时间格式YYYY.MM。没有的填null。只输出JSON。"""
规则:短字段直接填值。descriptionRange 只填该校的额外描述正文(如主修课程、在校成就、排名等成段文字)所在行号区间;学校/专业/学历/时间等已在上面字段提取,不要纳入;若无额外描述正文则填""。区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间(如3-5,会拼接为一段),不同段落/每条bullet之间用逗号分开(如3-5,6,7-9,每个逗号段成为一个独立段落,不要把多条bullet塞进同一个区间)。时间格式YYYY.MM。没有的填null。只输出JSON。"""
DETAIL_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造。
DETAIL_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。
请提取"{name}"这条工作经历的详细信息,输出JSON
```json
{{ "companyName": "公司", "position": "职位", "startDate": "2024.07", "endDate": "2025.03", "description": ["原文描述段落1", "原文描述段落2"] }}
{{ "companyName": "公司", "position": "职位", "startDate": "2024.07", "endDate": "2025.03", "descriptionRange": "起-止,起-止" }}
```
规则:description填该经历的描述原文段落数组。时间格式YYYY.MM。没有的填null。只输出JSON。"""
规则:短字段直接填值。descriptionRange 只填直接描述该公司/岗位的概述性正文行号区间;若该经历下的内容都属于具体项目(项目会单独提取),则填"",不要把项目明细并入工作描述。区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间(如3-5,会拼接为一段),不同段落/每条bullet之间用逗号分开(如3-5,6,7-9,每个逗号段成为一个独立段落,不要把多条bullet塞进同一个区间)。时间格式YYYY.MM。没有的填null。只输出JSON。"""
DETAIL_INTERNSHIP_PROMPT = """严格根据简历原文提取,不要猜测或编造。
DETAIL_INTERNSHIP_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。
请提取"{name}"这条实习经历的详细信息,输出JSON
```json
{{ "companyName": "公司", "position": "职位", "startDate": "2023.06", "endDate": "2023.09", "description": ["原文描述段落1", "原文描述段落2"] }}
{{ "companyName": "公司", "position": "职位", "startDate": "2023.06", "endDate": "2023.09", "descriptionRange": "起-止,起-止" }}
```
规则:description填该经历的描述原文段落数组。时间格式YYYY.MM。没有的填null。只输出JSON。"""
规则:短字段直接填值。descriptionRange 只填直接描述该公司/岗位的概述性正文行号区间;若该经历下的内容都属于具体项目(项目会单独提取),则填"",不要把项目明细并入实习描述。区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间(如3-5,会拼接为一段),不同段落/每条bullet之间用逗号分开(如3-5,6,7-9,每个逗号段成为一个独立段落,不要把多条bullet塞进同一个区间)。时间格式YYYY.MM。没有的填null。只输出JSON。"""
DETAIL_PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编造。
DETAIL_PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。
请提取"{name}"这条项目经历的详细信息,输出JSON
```json
{{ "companyName": "所属公司", "projectName": "项目名", "role": "角色名称", "startDate": "2023.03", "endDate": "2023.12", "description": ["原文描述段落1", "原文描述段落2"] }}
{{ "companyName": "所属公司", "projectName": "项目名", "role": "角色名称", "startDate": "2023.03", "endDate": "2023.12", "descriptionRange": "起-止,起-止" }}
```
规则:role只填简短角色名。description填该经历的描述原文段落数组。时间格式YYYY.MM。没有的填null。只输出JSON。"""
规则:role只填简短角色名。短字段直接填值。descriptionRange 填该项目描述所在的行号区间,没有描述填""。区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间(如3-5,会拼接为一段),不同段落/每条bullet之间用逗号分开(如3-5,6,7-9,每个逗号段成为一个独立段落,不要把多条bullet塞进同一个区间)。时间格式YYYY.MM。没有的填null。只输出JSON。"""
DETAIL_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。
DETAIL_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。
请提取"{name}"这条竞赛/获奖经历的详细信息,输出JSON:
```json
{{ "competitionName": "竞赛名", "award": "获奖情况", "awardDate": "2023.07", "description": ["原文描述段落1"] }}
{{ "competitionName": "竞赛名", "award": "获奖情况", "awardDate": "2023.07", "descriptionRange": "起-止,起-止" }}
```
规则:description填该经历的描述原文段落数组。时间格式YYYY.MM。没有的填null。只输出JSON。"""
规则:短字段直接填值。descriptionRange 填该竞赛/获奖的额外描述所在行号区间,没有描述填""。区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间,不同段落用逗号分开(每个逗号段成为一个独立段落)。时间格式YYYY.MM。没有的填null。只输出JSON。"""
DETAIL_PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造。
DETAIL_PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。
从中提取技能标签、证书和自我评价/个人概述,输出JSON:
```json
{{ "skills": ["技能1"], "certificates": ["证书1"], "summary": "自我评价原文" }}
{{ "skills": ["技能1"], "certificates": ["证书1"], "summaryRange": "起-止,起-止" }}
```
规则:skills优先提取简历中明确标注的"技能"/"专业技能"等模块内容,原文有什么填什么;如果简历中没有专门的技能模块,则从全文归纳核心技能,最多8个。certificates填证书数组。summary填自我评价/个人概述原文。没有的填null,数组填[]。只输出JSON。"""
规则:
- skills:仅当简历中有明确的"技能"/"专业技能"等独立模块时才提取,原文有什么填什么,最多5个;如果简历没有专门的技能模块,填[],不要从经历中归纳。
- certificates:填证书数组,没有填[]。
- summaryRange:填自我评价/个人概述**正文**所在的行号区间字符串(0开始、闭区间,行号必须真实存在),不要包含板块标题行(如"个人优势""自我评价"等),排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间,不同段落用逗号分开(每个逗号段是一个独立段落,段间会以换行连接),没有填""
只输出JSON。"""