From 89d033c9f91aa8b1ed2151c535e644f0778ce07b Mon Sep 17 00:00:00 2001 From: zk Date: Wed, 1 Jul 2026 18:41:51 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E7=AE=80=E5=8E=86=E5=AF=BC?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/ai/resume_extractor/extractor.py | 123 ++++++--------------------- app/ai/resume_extractor/prompts.py | 87 +++++++++---------- app/services/resume_service.py | 14 ++- app/tool/file_parser.py | 92 -------------------- app/tool/resume_text_extractor.py | 95 +++++++++++++++++++++ requirements.txt | 2 +- 6 files changed, 172 insertions(+), 241 deletions(-) delete mode 100644 app/tool/file_parser.py create mode 100644 app/tool/resume_text_extractor.py diff --git a/app/ai/resume_extractor/extractor.py b/app/ai/resume_extractor/extractor.py index 6996461..be6b244 100644 --- a/app/ai/resume_extractor/extractor.py +++ b/app/ai/resume_extractor/extractor.py @@ -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 diff --git a/app/ai/resume_extractor/prompts.py b/app/ai/resume_extractor/prompts.py index 276d165..8bd7c51 100644 --- a/app/ai/resume_extractor/prompts.py +++ b/app/ai/resume_extractor/prompts.py @@ -1,22 +1,22 @@ """简历两阶段提取 Prompt 第一阶段(概览):5路并行,只提取主表短字段和子表标识名。 -第二阶段(详情):N路并行,每条子表记录单独提取短字段;description/summary 不照抄原文, - 只返回「行号区间字符串」,由代码按行号从原文单元数组切片还原。 +第二阶段(详情):N路并行,每条子表记录单独提取短字段 + description/summary, + AI 直接输出内容(不再返回行号区间),按原文结构分条/分段还原。 -输入文本格式:每行形如 `[行号] 内容`,行号从 0 开始连续递增。 +输入文本格式:简历纯文本全文(PyMuPDF/docx/txt 提取,保留原始换行)。 -行号区间字符串格式:`"起-止,起-止"`,0 开始、闭区间(含两端)。 - - 逗号分隔的每一段代表「一个段落」。 - - 单行可简写为 `"5"`(等价 `"5-5"`)。 - - 没有内容则返回空字符串 ""。 - - 行号必须是上面输入文本中真实出现过的编号,严禁编造不存在的行号。 +description/summary 输出格式:字符串数组 list[str],每个元素代表原文中的「一条 bullet / 一个段落」, + 由前端按元素换行展示。规则: + - 忠实还原原文措辞,按原文的分条/分段拆成数组元素,一条 bullet 一个元素。 + - 允许轻度清理:去除水印/页眉页脚/乱码碎片,把被 PDF 折断的同一句话拼回完整。 + - 不主动改写、润色、扩写、编造内容;没有内容则返回空数组 []。 各详情 prompt 通用铁律(防跨记录串扰/重复): - - 只选取真正属于当前这条记录({name})本身的行;即使原文中相邻的行属于**另一条**记录 + - 只选取真正属于当前这条记录({name})本身的内容;即使原文中相邻的内容属于**另一条**记录 (另一家公司、另一个项目、另一段学历、校园/社团/实习/竞赛等),也**绝不纳入**。 - - 同一段原文只应属于一条记录:不要为了描述完整而把其它记录已包含的行号再选一遍,避免多条记录重复引用同一段。 - - 宁可少选,不确定归属的行一律不选。 + - 同一段原文只应属于一条记录:不要为了描述完整而把其它记录已包含的内容再抄一遍,避免多条记录重复。 + - 宁可少选,不确定归属的内容一律不选。 花括号用 {{ }} 转义,避免被 ChatPromptTemplate 当作变量。{name} 为运行时替换的记录标识名。 """ @@ -24,7 +24,7 @@ # ==================== 第一阶段:概览提取 ==================== OVERVIEW_PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造,没有的填null。 -输入为带行号的简历文本(每行 `[行号] 内容`)。从中仅提取个人基本信息(不含技能、证书、自我评价),输出JSON: +输入为简历纯文本全文。从中仅提取个人基本信息(不含技能、证书、自我评价),输出JSON: ```json {{ "name": "姓名", "email": "邮箱", "mobileNumber": "手机号", "city": "所在城市", "wechatNumber": "微信号", "portfolioUrl": "作品集链接" }} ``` @@ -36,7 +36,7 @@ OVERVIEW_PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编 只输出JSON。""" OVERVIEW_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。 -输入为带行号的简历文本。从中提取所有**学历教育**经历的标识名列表,输出JSON数组: +输入为简历纯文本全文。从中提取所有**学历教育**经历的标识名列表,输出JSON数组: ```json ["北京大学", "剑桥大学-哲学硕士", "剑桥大学-经济学士"] ``` @@ -51,7 +51,7 @@ OVERVIEW_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或 6. 没有输出[]。只输出JSON。""" OVERVIEW_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造。 -输入为带行号的简历文本。从中提取工作经历和实习经历的公司标识名列表,输出JSON: +输入为简历纯文本全文。从中提取工作经历和实习经历的公司标识名列表,输出JSON: ```json {{ "work": ["阿里巴巴", "腾讯"], "internship": ["字节跳动"] }} ``` @@ -63,7 +63,7 @@ OVERVIEW_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造 5. 没有填[]。只输出JSON。""" OVERVIEW_PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编造。 -输入为带行号的简历文本。从中仅提取项目经历的项目名称列表,输出JSON数组: +输入为简历纯文本全文。从中仅提取项目经历的项目名称列表,输出JSON数组: ```json ["订单系统重构", "支付网关"] ``` @@ -76,7 +76,7 @@ OVERVIEW_PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编 6. 没有输出[]。只输出JSON。""" OVERVIEW_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。 -输入为带行号的简历文本。从中仅提取竞赛/获奖经历的竞赛名称列表,输出JSON数组: +输入为简历纯文本全文。从中仅提取竞赛/获奖经历的竞赛名称列表,输出JSON数组: ```json ["ACM区域赛", "数学建模大赛"] ``` @@ -90,62 +90,63 @@ OVERVIEW_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测 # ==================== 第二阶段:详情提取 ==================== -DETAIL_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。 +# 通用尾注:description 数组的输出规范 + 清理规范。各详情 prompt 复用。 +_DESC_RULE = """description 为字符串数组,每个元素是原文中的一条 bullet 或一个段落:忠实还原原文措辞,按原文分条拆成多个数组元素(一条 bullet 一个元素),不要把多条内容塞进同一个元素;允许轻度清理(去除水印/页眉页脚/乱码碎片、把被折断的同一句话拼回完整),但不要改写、润色、扩写或编造;没有描述则填[]。只选属于"{name}"这条记录的内容,不要串入其它公司/项目/教育/竞赛/社团经历的内容,即使它们在原文中相邻。只输出JSON。""" + +DETAIL_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。 请提取"{name}"这条教育经历的详细信息,输出JSON: ```json -{{ "school": "学校", "major": "专业", "degree": "学历", "studyType": "全日制/非全日制", "startDate": "2020.09", "endDate": "2024.06", "descriptionRange": "起-止,起-止" }} +{{ "school": "学校", "major": "专业", "degree": "学历", "studyType": "全日制/非全日制", "startDate": "2020.09", "endDate": "2024.06", "description": ["描述段落1", "描述段落2"] }} ``` 规则:短字段直接填值,时间格式YYYY.MM,没有的填null。 -descriptionRange 只填该校**学业本身**的额外正文(主修课程、绩点/排名、在校获得的学术成就)所在行号区间;学校/专业/学历/时间已在上面字段提取,不要纳入。 -**严禁纳入以下内容**(它们属于其它模块、会被单独提取,即使在原文中紧跟这条教育经历也不要选):实习、工作、项目、社团/学生组织、校园活动、志愿服务、竞赛获奖、科研/论文经历。宁可少选,不确定是否属于本校学业的行一律不选;若无纯学业描述则填""。 -区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间(如3-5,会拼接为一段),不同段落/每条bullet之间用逗号分开(如3-5,6,7-9,每个逗号段成为一个独立段落,不要把多条bullet塞进同一个区间)。只输出JSON。""" +description 只填该校**学业本身**的额外正文(主修课程、绩点/排名、在校获得的学术成就);学校/专业/学历/时间已在上面字段提取,不要纳入。 +**严禁纳入以下内容**(它们属于其它模块、会被单独提取,即使在原文中紧跟这条教育经历也不要选):实习、工作、项目、社团/学生组织、校园活动、志愿服务、竞赛获奖、科研/论文经历。宁可少选,不确定是否属于本校学业的内容一律不选。 +""" + _DESC_RULE -DETAIL_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。 +DETAIL_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。 请提取"{name}"这条工作经历的详细信息,输出JSON: ```json -{{ "companyName": "公司", "position": "职位", "startDate": "2024.07", "endDate": "2025.03", "descriptionRange": "起-止,起-止" }} +{{ "companyName": "公司", "position": "职位", "startDate": "2024.07", "endDate": "2025.03", "description": ["描述段落1", "描述段落2"] }} ``` 规则:短字段直接填值,时间格式YYYY.MM,在职/至今则 endDate 填null,没有的填null。 -descriptionRange 填直接描述该公司/岗位**整体职责或概述**的正文行号区间。若某段内容明显是某个**独立项目的明细**(且该项目会作为项目单独提取),不要纳入,避免与项目模块重复;但公司层面的职责概述应当保留,不要整条留空导致内容丢失。 -只选属于"{name}"这段经历的行,不要选到其它公司、其它项目、教育或校园经历的行,即使它们在原文中相邻。 -区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间(如3-5,会拼接为一段),不同段落/每条bullet之间用逗号分开(如3-5,6,7-9,每个逗号段成为一个独立段落,不要把多条bullet塞进同一个区间)。只输出JSON。""" +description 填直接描述该公司/岗位**整体职责或概述**的正文。若某段内容明显是某个**独立项目的明细**(且该项目会作为项目单独提取),不要纳入,避免与项目模块重复;但公司层面的职责概述应当保留,不要整条留空导致内容丢失。 +""" + _DESC_RULE -DETAIL_INTERNSHIP_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。 +DETAIL_INTERNSHIP_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。 请提取"{name}"这条实习经历的详细信息,输出JSON: ```json -{{ "companyName": "公司", "position": "职位", "startDate": "2023.06", "endDate": "2023.09", "descriptionRange": "起-止,起-止" }} +{{ "companyName": "公司", "position": "职位", "startDate": "2023.06", "endDate": "2023.09", "description": ["描述段落1", "描述段落2"] }} ``` 规则:短字段直接填值,时间格式YYYY.MM,至今则 endDate 填null,没有的填null。 -descriptionRange 填直接描述该公司/岗位**整体职责或概述**的正文行号区间。若某段内容明显是某个**独立项目的明细**(且该项目会作为项目单独提取),不要纳入,避免与项目模块重复;但岗位层面的职责概述应保留,不要整条留空导致内容丢失。 -只选属于"{name}"这段经历的行,不要选到其它公司、其它项目、教育或校园经历的行,即使它们在原文中相邻。 -区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间(如3-5,会拼接为一段),不同段落/每条bullet之间用逗号分开(如3-5,6,7-9,每个逗号段成为一个独立段落,不要把多条bullet塞进同一个区间)。只输出JSON。""" +description 填直接描述该公司/岗位**整体职责或概述**的正文。若某段内容明显是某个**独立项目的明细**(且该项目会作为项目单独提取),不要纳入,避免与项目模块重复;但岗位层面的职责概述应保留,不要整条留空导致内容丢失。 +""" + _DESC_RULE -DETAIL_PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。 +DETAIL_PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。 请提取"{name}"这条项目经历的详细信息,输出JSON: ```json -{{ "companyName": "所属公司", "projectName": "项目名", "role": "角色名称", "startDate": "2023.03", "endDate": "2023.12", "descriptionRange": "起-止,起-止" }} +{{ "companyName": "所属公司", "projectName": "项目名", "role": "角色名称", "startDate": "2023.03", "endDate": "2023.12", "description": ["描述段落1", "描述段落2"] }} ``` 规则:短字段直接填值,时间格式YYYY.MM,没有的填null。 role 只填原文中**明确写出**的简短角色名;若原文没有写明该项目的角色,填null,**不要臆造或默认**(如不要凭空填"Developer""开发工程师""团队成员")。 -descriptionRange 填该项目描述所在的行号区间,没有描述填""。只选属于"{name}"这个项目的行,不要串入其它项目、所属公司的其它经历、教育或竞赛的行,即使它们在原文中相邻。 -区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间(如3-5,会拼接为一段),不同段落/每条bullet之间用逗号分开(如3-5,6,7-9,每个逗号段成为一个独立段落,不要把多条bullet塞进同一个区间)。只输出JSON。""" +description 填该项目的描述内容。 +""" + _DESC_RULE -DETAIL_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。 +DETAIL_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。 请提取"{name}"这条竞赛/获奖经历的详细信息,输出JSON: ```json -{{ "competitionName": "竞赛名", "award": "获奖情况", "awardDate": "2023.07", "descriptionRange": "起-止,起-止" }} +{{ "competitionName": "竞赛名", "award": "获奖情况", "awardDate": "2023.07", "description": ["描述段落1", "描述段落2"] }} ``` 规则:短字段直接填值,时间格式YYYY.MM,没有的填null。award 里可包含该竞赛的多个奖项,不要因为多个奖项而拆成多条。 -descriptionRange 填该竞赛/获奖的额外描述所在行号区间,没有描述填""。只选属于"{name}"这条竞赛的行,不要把社团活动、职务、项目、其它竞赛的内容串入,即使它们在原文中相邻。 -区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间,不同段落用逗号分开(每个逗号段成为一个独立段落)。只输出JSON。""" +description 填该竞赛/获奖的额外描述内容。 +""" + _DESC_RULE -DETAIL_PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。 +DETAIL_PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。 从中提取技能标签、证书和自我评价/个人概述,输出JSON: ```json -{{ "skills": ["技能1"], "certificates": ["证书1"], "summaryRange": "起-止,起-止" }} +{{ "skills": ["技能1"], "certificates": ["证书1"], "summary": ["自我评价段落1", "自我评价段落2"] }} ``` 规则: -- skills:仅当简历中有明确的"技能"/"专业技能"等**独立模块**时才提取,按原文逐字填,最多5个;如果没有专门的技能模块,填[]。**不要**从工作/项目/自我评价里归纳技能,尤其不要把"团队沟通""抗压能力""责任心"等软素质描述当作技能。 +- skills:仅当简历中有明确的"技能"/"专业技能"等**独立模块**时才提取,按原文逐字填,最多5个;如果没有专门的技能模块,填[]。**不要**从工作/项目/自我评价里归纳技能,尤其不要把"团队沟通""抗压能力""责任心"等软素质描述当作技能。**证书/语言等级类(如CET-4/6、雅思、托福、GRE、计算机等级、教师资格证等)一律不计入 skills,它们只归 certificates。** - certificates:填真正的**证书/证件/标准化考试成绩**,包括语言与等级类(如CET-4/6、雅思、托福、GRE、计算机等级、教师资格证、驾照等)。**不要**把竞赛奖项、荣誉称号、名次、奖学金,或工作描述里顺带提到的"认证"句子当作证书;没有填[]。 -- summaryRange:填自我评价/个人概述**正文**所在的行号区间字符串(0开始、闭区间,行号必须真实存在)。不要包含板块标题行(如"个人优势""自我评价"),不要把技能列表、兴趣爱好、经历内容纳入;排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间,不同段落用逗号分开(每个逗号段是一个独立段落,段间会以换行连接),没有填""。 +- summary:自我评价/个人概述**正文**的字符串数组,按原文分段拆成数组元素。不要包含板块标题(如"个人优势""自我评价"),不要把技能列表、兴趣爱好、经历内容纳入;允许轻度清理(去除乱码/水印碎片),不要改写或编造;没有填[]。 只输出JSON。""" diff --git a/app/services/resume_service.py b/app/services/resume_service.py index 83fd03c..756ddcc 100644 --- a/app/services/resume_service.py +++ b/app/services/resume_service.py @@ -1,12 +1,10 @@ """简历 Service 上传简历文件 → 解析为纯文本 → AI 两阶段并行结构化 → 写入数据库。 -依赖:file_parser(文件解析工具)、resume_extractor(AI两阶段并行提取) +依赖:resume_text_extractor(文件文本提取)、resume_extractor(AI两阶段并行提取) 使用表:bg_user_resume(主表)、bg_user_resume_education/work/internship/project/competition(5张子表) """ -import asyncio - import shortuuid from sqlalchemy.ext.asyncio import AsyncSession @@ -19,7 +17,7 @@ from app.models.user_resume_education import UserResumeEducation from app.models.user_resume_internship import UserResumeInternship from app.models.user_resume_project import UserResumeProject from app.models.user_resume_work import UserResumeWork -from app.tool.file_parser import parse_to_segments +from app.tool.resume_text_extractor import extract_text from app.tool.snowflake import next_id @@ -28,13 +26,13 @@ class ResumeService: async def parse_and_extract(self, filename: str, content: bytes) -> dict: """文件解析 + AI 两阶段并行结构化,不涉及数据库操作""" log.info(f"开始解析简历文件: {filename}") - segments = await asyncio.to_thread(parse_to_segments, filename, content) - if not segments: + text = await extract_text(filename, content) + if not text or not text.strip(): raise ValueError("文件内容为空,无法解析") - log.info(f"文件解析完成,文本单元数: {len(segments)}") + log.info(f"文件解析完成,文本字符数: {len(text)}") log.info("开始AI两阶段并行结构化提取") - parsed = await extract_all(segments) + parsed = await extract_all(text) log.info("AI两阶段并行结构化提取完成") return parsed diff --git a/app/tool/file_parser.py b/app/tool/file_parser.py deleted file mode 100644 index 3c76660..0000000 --- a/app/tool/file_parser.py +++ /dev/null @@ -1,92 +0,0 @@ -"""文件解析工具 - -将上传的简历文件解析为「文本单元数组」,供 AI 按行号定位 description/summary,避免照抄长文本。 -- PDF:使用 LiteParse(JSON 模式)按文本块的阅读顺序提取,每个文本块作为一个单元。 - 关闭 OCR —— 文本型简历无需 OCR,且 OCR 默认会联网下载字库导致严重阻塞。 -- Word(.docx):按段落(paragraph)切分,表格行用 \t 拼成一个单元。 -- TXT / Markdown(.md):自动检测编码,按换行切分。 -""" - -import io -from collections import Counter - -from docx import Document -from liteparse import LiteParse - -from app.core.logger import log - -# LiteParse 解析器复用实例(关闭 OCR、静默日志)。线程安全由调用方的 to_thread 串行保证。 -_PDF_PARSER = LiteParse(output_format="json", ocr_enabled=False, quiet=True) - - -def _drop_repeated(segments: list[str], threshold: int = 3) -> list[str]: - """过滤重复出现的行(水印/页眉页脚特征):完全相同且出现次数 >= threshold 的行整体丢弃""" - counts = Counter(segments) - return [s for s in segments if counts[s] < threshold] - - -def _pdf_segments(content: bytes) -> list[str]: - """解析 PDF:使用 LiteParse 按阅读顺序遍历每页文本块,每块作为一个独立单元,过滤空行。 - - 不在预处理阶段合并行——是否合并/分段/排除噪声全部交给 AI 的行号区间决定。 - """ - result = _PDF_PARSER.parse(content) - segments: list[str] = [] - for page in result.pages: - for item in page.text_items: - text = item.text.strip() - if text: - segments.append(text) - return segments - - -def _docx_segments(content: bytes) -> list[str]: - """解析 Word (.docx),按段落切分为单元,表格行用 \t 拼为一个单元""" - try: - doc = Document(io.BytesIO(content)) - except Exception: - raise ValueError("无法解析该 Word 文件,请确认为有效的 .docx 格式") - segments: list[str] = [] - for para in doc.paragraphs: - text = para.text.strip() - if text: - segments.append(text) - for table in doc.tables: - for row in table.rows: - row_text = "\t".join(cell.text.strip() for cell in row.cells) - if row_text.strip(): - segments.append(row_text) - return segments - - -def _txt_segments(content: bytes) -> list[str]: - """解析纯文本 (.txt / .md),自动检测编码,按换行切分为单元""" - text = None - for encoding in ("utf-8", "gbk", "gb2312", "latin-1"): - try: - text = content.decode(encoding) - break - except (UnicodeDecodeError, LookupError): - continue - if text is None: - text = content.decode("utf-8", errors="replace") - return [line.strip() for line in text.splitlines() if line.strip()] - - -# 后缀 → 解析函数。新增格式只需在此登记一行。 -_PARSERS = { - ".pdf": _pdf_segments, - ".docx": _docx_segments, - ".txt": _txt_segments, - ".md": _txt_segments, -} - - -def parse_to_segments(filename: str, content: bytes) -> list[str]: - """根据文件名后缀选择解析器,返回去重后的「文本单元数组」""" - suffix = filename[filename.rfind("."):].lower() if "." in filename else "" - log.info(f"解析文件: {filename},类型: {suffix}") - parser = _PARSERS.get(suffix) - if parser is None: - raise ValueError(f"不支持的文件类型: {suffix},支持: {', '.join(_PARSERS)}") - return _drop_repeated(parser(content)) diff --git a/app/tool/resume_text_extractor.py b/app/tool/resume_text_extractor.py new file mode 100644 index 0000000..1a69b72 --- /dev/null +++ b/app/tool/resume_text_extractor.py @@ -0,0 +1,95 @@ +"""简历文字处理 + +将上传的简历文件提取为纯文本,供后续 AI 重写/结构化使用。 +支持格式:.txt / .md / .pdf / .docx +所有解析均为异步(阻塞解析下沉到线程池,避免阻塞事件循环)。 +""" + +import asyncio +import io + +import fitz # PyMuPDF +from docx import Document + +from app.core.asserts import Assert +from app.core.logger import log + +# 支持的简历文件类型 +SUPPORTED_EXTENSIONS = (".txt", ".md", ".pdf", ".docx") + +# 纯文本解码尝试的编码顺序 +_TEXT_ENCODINGS = ("utf-8", "gbk", "gb2312", "latin-1") + + +async def _parse_txt(content: bytes) -> str: + """解析纯文本 (.txt / .md):自动探测编码,返回全文文本""" + + def _decode() -> str: + for encoding in _TEXT_ENCODINGS: + try: + return content.decode(encoding) + except (UnicodeDecodeError, LookupError): + continue + # 全部失败时以替换字符兜底,保证不抛异常 + return content.decode("utf-8", errors="replace") + + return await asyncio.to_thread(_decode) + + +async def _parse_pdf(content: bytes) -> str: + """解析 PDF:使用 PyMuPDF 逐页 get_text() 拼接为全文文本""" + + def _extract() -> str: + parts: list[str] = [] + with fitz.open(stream=content, filetype="pdf") as doc: + for page in doc: + parts.append(page.get_text()) + return "\n".join(parts) + + return await asyncio.to_thread(_extract) + + +async def _parse_docx(content: bytes) -> str: + """解析 Word (.docx):拼接段落文本,表格行用 \t 连接,返回全文文本""" + + def _extract() -> str: + try: + doc = Document(io.BytesIO(content)) + except Exception: + raise ValueError("无法解析该 Word 文件,请确认为有效的 .docx 格式") + parts: list[str] = [] + for para in doc.paragraphs: + text = para.text.strip() + if text: + parts.append(text) + for table in doc.tables: + for row in table.rows: + row_text = "\t".join(cell.text.strip() for cell in row.cells) + if row_text.strip(): + parts.append(row_text) + return "\n".join(parts) + + return await asyncio.to_thread(_extract) + + +# 后缀 → 解析函数。新增格式只需在此登记一行,并同步 SUPPORTED_EXTENSIONS。 +_PARSERS = { + ".txt": _parse_txt, + ".md": _parse_txt, + ".pdf": _parse_pdf, + ".docx": _parse_docx, +} + + +async def extract_text(filename: str, content: bytes) -> str: + """简历文字提取统一入口:先校验文件类型受支持,再按后缀路由提取全文文本""" + suffix = filename[filename.rfind("."):].lower() if "." in filename else "" + Assert.is_true( + suffix in SUPPORTED_EXTENSIONS, + f"不支持的文件类型: {suffix or '未知'},支持: {', '.join(SUPPORTED_EXTENSIONS)}", + ) + + log.info(f"开始提取简历文本: {filename},类型: {suffix}") + text = await _PARSERS[suffix](content) + log.info(f"简历文本提取完成: {filename},字符数: {len(text)}") + return text diff --git a/requirements.txt b/requirements.txt index aac8a70..d13cb58 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,7 +43,7 @@ python-multipart>=0.0.9 python-dotenv>=1.0.0 # 文件解析 -liteparse>=2.1.0 +pymupdf>=1.24.0 python-docx>=1.1.0 # 雪花ID