添加V2版本简历提取

This commit is contained in:
zk
2026-08-07 15:43:24 +08:00
parent b50eb98653
commit f98b87379f
5 changed files with 295 additions and 2 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ class NovaChatModel:
class ResumeExtractorModel:
"""简历解析模块"""
# 简历结构化提取:两阶段并行提取简历文本为JSON结构
# 简历结构化提取:并行提取简历文本为JSON结构
PARSE = LLM.DOUBAO_PRO_32K.create(temperature=0)
+2
View File
@@ -0,0 +1,2 @@
+122
View File
@@ -0,0 +1,122 @@
"""简历一步并发提取
6 路并发(个人信息 / 教育 / 工作 / 实习 / 项目 / 竞赛),每路独立负责一个模块,
各自基于简历全文一次性输出该模块的全部内容(短字段 + description),无第二阶段。
相比两阶段方案:少一个串行轮次、全文只 prefill 6 次、无标识名定位环节。
description/summary 由 AI 直接按原文结构输出为字符串数组,代码只做类型兜底与清理。
最终组装为与两阶段方案完全一致的 dict 结构(description 为 list[str]summary 为 str),上下游无感知。
任意模块提取失败:记录日志后丢弃该模块(主表字段退化为空、子表退化为空数组),不影响其余模块。
"""
import asyncio
import time
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from app.ai.model_config import ResumeExtractorModel
from app.ai.resume_extractor_v2.prompts import (
PROFILE_PROMPT, EDUCATION_PROMPT, WORK_PROMPT,
INTERNSHIP_PROMPT, PROJECT_PROMPT, COMPETITION_PROMPT,
)
from app.core.logger import log
from app.tool.json_helper import parse_llm_json
# ==================== LLM 调用工具 ====================
def _build_chain(prompt: str):
"""构建提取链:prompt → LLM → 文本输出"""
return ChatPromptTemplate.from_messages([("system", prompt), ("human", "{text}")]) | ResumeExtractorModel.PARSE | StrOutputParser()
async def _safe_invoke(chain, inp: dict, label: str):
"""单个链调用,记录耗时,失败返回空"""
start = time.perf_counter()
try:
raw = await chain.ainvoke(inp)
log.info(f"AI提取[{label}]完成,耗时: {time.perf_counter() - start:.2f}s")
return parse_llm_json(raw)
except Exception as e:
log.warning(f"AI提取[{label}]失败已丢弃,耗时: {time.perf_counter() - start:.2f}s,错误: {e}")
return None
# ==================== 结果清理 ====================
def _clean_str_list(value) -> list[str]:
"""将 AI 返回值规整为字符串数组:过滤非字符串与空白元素,去除首尾空白"""
if not isinstance(value, list):
return []
return [s.strip() for s in value if isinstance(s, str) and s.strip()]
def _clean_records(value, label: str) -> list[dict]:
"""将 AI 返回的模块结果规整为记录数组:过滤非 dict 元素,并清理每条记录的 description"""
if not isinstance(value, list):
if value is not None:
log.warning(f"AI提取[{label}]返回类型异常已丢弃: {type(value).__name__}")
return []
records = []
for item in value:
if not isinstance(item, dict):
continue
item["description"] = _clean_str_list(item.get("description"))
records.append(item)
return records
# ==================== 模块配置 ====================
# 子表模块统一配置(单一数据源):(模块键, prompt, 日志标签)
# 并发调度、结果组装、日志统计均复用此清单,新增子表只需在此追加一行。
_SUB_MODULES: tuple[tuple[str, str, str], ...] = (
("education", EDUCATION_PROMPT, "教育"),
("work", WORK_PROMPT, "工作"),
("internship", INTERNSHIP_PROMPT, "实习"),
("project", PROJECT_PROMPT, "项目"),
("competition", COMPETITION_PROMPT, "竞赛"),
)
# 无运行时 prompt 替换,6 条链在模块加载时一次性建好,请求期只做 ainvoke
_profile_chain = _build_chain(PROFILE_PROMPT)
_sub_chains = tuple((module, _build_chain(prompt), label) for module, prompt, label in _SUB_MODULES)
# ==================== 组装 ====================
def _assemble_profile(profile) -> dict:
"""个人信息路结果 → 主表字段 dictskills 最多5个,summary 段落合并为字符串)"""
result = dict(profile) if isinstance(profile, dict) else {}
result["skills"] = _clean_str_list(result.get("skills"))[:5]
result["certificates"] = _clean_str_list(result.get("certificates"))
summary_paras = _clean_str_list(result.get("summary"))
result["summary"] = "\n".join(summary_paras) if summary_paras else None
return result
# ==================== 入口 ====================
async def extract_all(text: str) -> dict:
"""一步6路并发提取简历,返回与两阶段方案完全一致的结构化数据
text: 简历纯文本全文(PyMuPDF/docx/txt 提取,保留原始换行)。
"""
log.info(f"一步6路并发提取开始,文本字符数: {len(text)}")
start = time.perf_counter()
inp = {"text": text}
profile, *sub_results = await asyncio.gather(
_safe_invoke(_profile_chain, inp, "个人信息"),
*(_safe_invoke(chain, inp, label) for _, chain, label in _sub_chains),
)
result = _assemble_profile(profile)
for (module, _, label), raw in zip(_sub_chains, sub_results):
result[module] = _clean_records(raw, label)
log.info(
f"一步6路并发提取完成,总耗时: {time.perf_counter() - start:.2f}s - "
+ " ".join(f"{label}:{len(result[module])}" for module, _, label in _sub_chains)
)
return result
+169
View File
@@ -0,0 +1,169 @@
"""简历一步并发提取 Prompt
6 路并发,每路独立负责一个模块(个人信息 / 教育 / 工作 / 实习 / 项目 / 竞赛),
各自基于简历全文一次性输出该模块的**全部内容**(短字段 + description),无第二阶段。
与两阶段方案的差异:
- 不再有「先提标识名、再按名提详情」的过程,因此不需要标识名唯一性约束。
- 每路要一次输出该模块的多条记录,对「不偷懒」的要求更高(见下方记录完整性铁律)。
输入文本格式:简历纯文本全文(PyMuPDF/docx/txt 提取,保留原始换行)。
description/summary 输出格式:字符串数组 list[str],每个元素代表原文中的「一条 bullet / 一个段落」,
由前端按元素换行展示。规则:
- 忠实还原原文措辞,按原文的分条/分段拆成数组元素,一条 bullet 一个元素。
- 允许轻度清理:去除水印/页眉页脚/乱码碎片,把被 PDF 折断的同一句话拼回完整。
- 不主动改写、润色、扩写、编造内容;没有内容则返回空数组 []。
模块归属总原则(防同一内容被多个模块重复收录):
- 原文中的**每一段经历只能归属一个模块**。一条内容要么是工作、要么是实习、要么是项目、要么是竞赛,不能同时出现在两个模块。
- 工作/实习互为补集:明确标注实习的只归实习模块,未标注实习的只归工作模块,两边都不重复收。
- 工作/实习条目内部所描述的职责、成果、子项目,属于该**工作/实习**记录本身的描述,**不要**再把它们抽成独立的"项目"记录。
- 只有出现在**独立"项目/项目经历"板块**下、有自己标题的条目,才算项目记录。
- "做了作品/项目 + 参加比赛获奖"这类既像项目又像竞赛的条目(如机器人竞赛、创新创业大赛作品),**只归竞赛**,不要在项目里重复。
各模块通用铁律(防跨记录串扰/重复):
- 每条记录只装真正属于它本身的内容;即使原文中相邻的内容属于**另一条**记录
(另一家公司、另一个项目、另一段学历、校园/社团/实习/竞赛等),也**绝不纳入**。
- 同一段原文只应属于一条记录:不要为了描述完整而把其它记录已包含的内容再抄一遍,避免多条记录重复。
- 宁可少选,不确定归属的内容一律不选。
description/summary 输出的**分条铁律**(必须遵守):
- description 是**字符串数组**,原文中的**每一条 bullet / 每一个段落**各自作为**一个独立数组元素**。
- **严禁**把多条 bullet 合并进同一个元素;**严禁**用 " / """""、换行符等把多条内容拼接成一个长字符串。
- 正例:["负责A模块开发", "主导B方案落地"];反例(禁止):["负责A模块开发 / 主导B方案落地"]。
**记录完整性铁律**(一步方案新增,一次输出多条记录时最易违反):
- 按**原文出现顺序**输出记录,顺序即前端展示顺序,不要打乱。
- **每一条记录都必须完整输出**,不得因为记录条数多就简写、省略、截断后面的记录。
- 严禁合并记录,严禁用"同上""""等等""其余类似"之类的措辞代指任何内容。
- 记录条数由原文决定:不足不要凑数,多也不要砍。
短字段准确性铁律:
- 时间:起止时间按原文如实填写,**开始时间不得晚于结束时间**,不要把起止写反;无法确定的填 null,不要臆造精确年月。
- 手机号:完整照抄,不要漏位、截断或改写(通常 11 位);微信号只填到 wechatNumber,绝不当手机号。
- 角色/职位等:只填原文明确写出的,原文没有就填 null,**不要臆造或按常见值默认补全**。
花括号用 {{ }} 转义,避免被 ChatPromptTemplate 当作变量。本方案无运行时占位替换,prompt 中不含任何单花括号变量。
"""
# ==================== 通用尾注 ====================
# description 数组输出规范 + 记录归属/完整性规范。5 个子模块 prompt 复用。
_DESC_RULE = """description 输出规范:为字符串数组,**每个元素只装原文中的一条 bullet 或一个段落**:忠实还原原文措辞;**严禁把多条 bullet 合并进一个元素,严禁用 " / """、换行等符号把多条内容拼成一个长字符串**(正例 ["做了A","做了B"];反例 ["做了A / 做了B"])。允许轻度清理(去除水印/页眉页脚/乱码碎片、把被折断的同一句话拼回完整),但不要改写、润色、扩写或编造;没有描述则填[]。
记录归属规范:每条记录的 description 只装属于**该条记录本身**的内容。即使原文中相邻的内容属于另一条记录(另一家公司、另一个项目、另一段学历、社团/实习/竞赛等),也绝不纳入;同一段原文只应归属一条记录,不要为了描述完整而把其它记录已包含的内容再抄一遍。宁可少选,不确定归属的内容一律不选。
记录完整性规范:按**原文出现顺序**输出记录;**每一条记录都必须完整输出**,不得因记录条数多而简写、省略、截断或合并记录,也不得用"同上""""等等"代指任何内容;记录条数由原文决定,不足不要凑数,多也不要砍。
只输出JSON,不要输出任何解释文字。"""
# ==================== 个人信息 ====================
PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造,没有的填null。
输入为简历纯文本全文。从中提取个人基本信息、技能标签、证书和自我评价,输出JSON:
```json
{{ "name": "姓名", "email": "邮箱", "mobileNumber": "手机号", "city": "所在城市", "wechatNumber": "微信号", "portfolioUrl": "作品集链接", "skills": ["技能1"], "certificates": ["证书1"], "summary": ["自我评价段落1", "自我评价段落2"] }}
```
规则:
- 只提取以上9个字段,**不要提取任何经历**(教育/工作/实习/项目/竞赛由其它模块单独负责)。前6个字段没有的填null,后3个数组字段没有的填[]。
- name:填完整姓名,原文怎么写就怎么填,不要截断或只取一个字。
- mobileNumber:只填**一个**手机号(通常11位数字),若原文出现多个手机号只取第一个,绝不能把多个号码拼接或用逗号/顿号等连接填入;微信号必须填到wechatNumber,绝不能把微信号当手机号。
- portfolioUrl:只填作品集/个人主页/GitHub 等链接。
- skills:仅当简历中有明确的"技能"/"专业技能"等**独立模块**时才提取,按原文逐字填,最多5个;如果没有专门的技能模块,填[]。**不要**从工作/项目/自我评价里归纳技能,尤其不要把"团队沟通""抗压能力""责任心"等软素质描述当作技能。**证书/语言等级类(如CET-4/6、雅思、托福、GRE、计算机等级、教师资格证等)一律不计入 skills,它们只归 certificates。**
- certificates:填真正的**证书/证件/标准化考试成绩**,包括语言与等级类(如CET-4/6、雅思、托福、GRE、计算机等级、教师资格证、驾照等)。**不要**把竞赛奖项、荣誉称号、名次、奖学金,或工作描述里顺带提到的"认证"句子当作证书;没有填[]。
- summary:自我评价/个人概述**正文**的字符串数组,按原文分段拆成数组元素,一个段落一个元素。不要包含板块标题(如"个人优势""自我评价"),不要把技能列表、兴趣爱好、经历内容纳入;允许轻度清理(去除乱码/水印碎片),不要改写或编造;没有填[]。
只输出JSON,不要输出任何解释文字。"""
# ==================== 教育 ====================
EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
从中提取**全部学历教育**经历,按原文出现顺序输出JSON数组,每个元素为一条教育记录:
```json
[{{ "school": "学校", "major": "专业", "degree": "学历", "studyType": "全日制/非全日制", "startDate": "2020.09", "endDate": "2024.06", "description": ["描述段落1", "描述段落2"] }}]
```
记录选取规则:
1. 每一个**实际就读或已获得学历**的**就读阶段**输出一条,不同阶段(本科/硕士/博士)分别输出,一段都不能漏。
2. **不要过度拆分**(这是最常见的错误,务必克制):
- 联合培养、交换、访学、海外学期、双学位/主辅修,都属于对应主学历就读的一部分,**并入该主学历那一条**,不要单列成独立教育记录。
- 同一所学校**同一就读阶段**只能有一条,不要因为专业不同、学院不同、或原文重复出现就拆成/重复成多条。
- 一个人的教育条目数 = 其真实就读的学历阶段数(通常本科1条、硕士1条…),不要多。
3. 仅"录取通知/offer/拟就读但尚未入学"的,**不算**教育经历,不要输出。
4. 培训机构、实习/工作单位、科研机构、社团不算学历教育,不要输出。
5. 没有则输出[]。
字段规则:短字段直接填值,时间格式YYYY.MM,没有的填null。
description 只填该校**学业本身**的额外正文(主修课程、绩点/排名、在校获得的学术成就);学校/专业/学历/时间已有独立字段,不要重复纳入。
**严禁纳入以下内容**(它们属于其它模块、会被单独提取,即使在原文中紧跟这条教育经历也不要选):实习、工作、项目、社团/学生组织、校园活动、志愿服务、竞赛获奖、科研/论文经历。宁可少选,不确定是否属于本校学业的内容一律不选。
""" + _DESC_RULE
# ==================== 工作 ====================
WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
从中提取**全部正式工作经历**,按原文出现顺序输出JSON数组,每个元素为一条工作记录:
```json
[{{ "companyName": "公司", "position": "职位", "startDate": "2024.07", "endDate": "2025.03", "description": ["描述段落1", "描述段落2"] }}]
```
记录选取规则:
1. 只收**正式工作**经历。凡原文明确标注"实习/实习生/intern/Internship"的条目,**一律排除**,它们由实习模块单独负责,这里绝不收录。
2. 未明确标注实习的任职经历,都归本模块,不要因为"看起来像实习"就漏掉。
3. 每一段真实的任职经历输出一条,不要遗漏;同一段经历不要重复输出。若同一公司有多段不同岗位/时间的经历,按段分别输出多条。
4. **不要**把公司内部的具体项目当作独立的工作条目。
5. 社团/学生组织职务、校园活动、志愿服务不算工作经历,不要输出。
6. 没有则输出[]。
字段规则:短字段直接填值,时间格式YYYY.MM,在职/至今则 endDate 填null,没有的填null。
description 填直接描述该公司/岗位**整体职责或概述**的正文。若某段内容明显是某个**独立项目的明细**(且该项目会由项目模块单独提取),不要纳入,避免与项目模块重复;但公司层面的职责概述应当保留,不要整条留空导致内容丢失。
""" + _DESC_RULE
# ==================== 实习 ====================
INTERNSHIP_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
从中提取**全部实习经历**,按原文出现顺序输出JSON数组,每个元素为一条实习记录:
```json
[{{ "companyName": "公司", "position": "职位", "startDate": "2023.06", "endDate": "2023.09", "description": ["描述段落1", "描述段落2"] }}]
```
记录选取规则:
1. 只收原文**明确标注**"实习/实习生/intern/Internship"的经历(标注可出现在岗位名、公司行、板块标题上)。
2. 未明确标注实习的任职经历,视为正式工作,由工作模块负责,这里**不要收**,避免两个模块重复。
3. 每一段真实的实习经历输出一条,不要遗漏;同一段经历不要重复输出。若同一公司有多段不同岗位/时间的实习,按段分别输出多条。
4. **不要**把实习期间的具体项目当作独立的实习条目。
5. 社团/学生组织职务、校园活动、志愿服务、竞赛不算实习经历,不要输出。
6. 没有则输出[]。
字段规则:短字段直接填值,时间格式YYYY.MM,至今则 endDate 填null,没有的填null。
description 填直接描述该公司/岗位**整体职责或概述**的正文。若某段内容明显是某个**独立项目的明细**(且该项目会由项目模块单独提取),不要纳入,避免与项目模块重复;但岗位层面的职责概述应保留,不要整条留空导致内容丢失。
""" + _DESC_RULE
# ==================== 项目 ====================
PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
从中提取**全部项目经历**,按原文出现顺序输出JSON数组,每个元素为一条项目记录:
```json
[{{ "companyName": "所属公司", "projectName": "项目名", "role": "角色名称", "startDate": "2023.03", "endDate": "2023.12", "description": ["描述段落1", "描述段落2"] }}]
```
记录选取规则:
1. 只提取出现在**独立"项目/项目经历"板块**下、有自己标题的项目条目(通常单独成行、常带"项目X"前缀或【】标题、并配有自己的起止时间/角色/描述)。
2. **绝对不要**把"工作经历/实习经历"条目内部描述的职责、成果、子项目当作独立项目——它们属于对应的工作/实习记录,不要在项目模块重复收录。
3. **绝对不要**把出现在某段职责/产品描述句子内部、被"如/包括/例如/【】"等列举的功能点、模块、子功能当作项目(这些是描述内容,不是项目)。
4. **绝对不要**把社团/学生组织、校园活动、志愿服务、任职/职务、兴趣爱好当作项目——这些不是项目经历。
5. **绝对不要**把竞赛/比赛/大赛条目当作项目(如"XX大赛""挑战杯""创新创业大赛""职业规划大赛"等,以参赛/获奖为核心的条目,即使参赛做了作品)——这些归竞赛模块。
6. 同一个项目只输出一条。
7. 没有则输出[]。
字段规则:短字段直接填值,时间格式YYYY.MM,没有的填null。
role 只填原文中**明确写出**的简短角色名;若原文没有写明该项目的角色,填null,**不要臆造或默认**(如不要凭空填"Developer""开发工程师""团队成员")。
companyName 只填原文明确写出的所属公司/学校,没有写就填null。
description 填该项目的描述内容。
""" + _DESC_RULE
# ==================== 竞赛 ====================
COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
从中提取**全部竞赛/获奖经历**,按原文出现顺序输出JSON数组,每个元素为一条竞赛记录:
```json
[{{ "competitionName": "竞赛名", "award": "获奖情况", "awardDate": "2023.07", "description": ["描述段落1", "描述段落2"] }}]
```
记录选取规则:
1. 只提取真正的**竞赛/比赛/获奖**条目(以报名参赛、比赛名次/获奖为核心的条目,如"XX大赛""挑战杯""数学建模竞赛")。
2. 同一个竞赛只输出一条,即使它拿了多个奖项,也不要按奖项拆成多条——多个奖项写进同一条的 award 字段。
3. **绝对不要**把社团活动、职务、示例文字、描述里顺带提到的活动当作竞赛。
4. **绝对不要**把工作/实习/研究/开发**项目**当作竞赛——即使该项目获过奖,它仍归项目模块,不要在这里重复输出(判断依据:条目本质是"做了一个项目/产品/研究"还是"参加了一场比赛")。
5. 奖学金、荣誉称号、考试成绩不属于竞赛,不要输出。
6. 没有则输出[]。
字段规则:短字段直接填值,时间格式YYYY.MM,没有的填null。
description 填该竞赛/获奖的额外描述内容。
""" + _DESC_RULE