添加V2版本简历提取
This commit is contained in:
@@ -40,7 +40,7 @@ class NovaChatModel:
|
||||
|
||||
class ResumeExtractorModel:
|
||||
"""简历解析模块"""
|
||||
# 简历结构化提取:两阶段并行提取简历文本为JSON结构
|
||||
# 简历结构化提取:并行提取简历文本为JSON结构
|
||||
PARSE = LLM.DOUBAO_PRO_32K.create(temperature=0)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""个人信息路结果 → 主表字段 dict(skills 最多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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user