diff --git a/app/ai/model_config.py b/app/ai/model_config.py index 8f82b51..a631e60 100644 --- a/app/ai/model_config.py +++ b/app/ai/model_config.py @@ -40,7 +40,7 @@ class NovaChatModel: class ResumeExtractorModel: """简历解析模块""" - # 简历结构化提取:两阶段并行提取简历文本为JSON结构 + # 简历结构化提取:并行提取简历文本为JSON结构 PARSE = LLM.DOUBAO_PRO_32K.create(temperature=0) diff --git a/app/ai/resume_extractor_v2/__init__.py b/app/ai/resume_extractor_v2/__init__.py new file mode 100644 index 0000000..139597f --- /dev/null +++ b/app/ai/resume_extractor_v2/__init__.py @@ -0,0 +1,2 @@ + + diff --git a/app/ai/resume_extractor_v2/extractor.py b/app/ai/resume_extractor_v2/extractor.py new file mode 100644 index 0000000..dd306fe --- /dev/null +++ b/app/ai/resume_extractor_v2/extractor.py @@ -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 diff --git a/app/ai/resume_extractor_v2/prompts.py b/app/ai/resume_extractor_v2/prompts.py new file mode 100644 index 0000000..fe7c94c --- /dev/null +++ b/app/ai/resume_extractor_v2/prompts.py @@ -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 diff --git a/app/services/resume_service.py b/app/services/resume_service.py index 756ddcc..32e1d6c 100644 --- a/app/services/resume_service.py +++ b/app/services/resume_service.py @@ -8,7 +8,7 @@ import shortuuid from sqlalchemy.ext.asyncio import AsyncSession -from app.ai.resume_extractor.extractor import extract_all +from app.ai.resume_extractor_v2.extractor import extract_all from app.ai.resume_polisher.polisher import polish_paragraphs from app.core.logger import log from app.models.user_resume import UserResume