From 62a0801a229a6c33fd874b6a802d014af1551b08 Mon Sep 17 00:00:00 2001 From: zk Date: Wed, 24 Jun 2026 16:16:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=AE=80=E5=8E=86=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E7=9B=B8=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/ai/resume_extractor/extractor.py | 175 ++++++++++++++++++++------- app/ai/resume_extractor/prompts.py | 67 ++++++---- app/services/resume_service.py | 10 +- app/tool/file_parser.py | 105 +++++++++++----- requirements.txt | 2 +- 5 files changed, 253 insertions(+), 106 deletions(-) diff --git a/app/ai/resume_extractor/extractor.py b/app/ai/resume_extractor/extractor.py index da5d3d9..6996461 100644 --- a/app/ai/resume_extractor/extractor.py +++ b/app/ai/resume_extractor/extractor.py @@ -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 diff --git a/app/ai/resume_extractor/prompts.py b/app/ai/resume_extractor/prompts.py index 372130e..6d49668 100644 --- a/app/ai/resume_extractor/prompts.py +++ b/app/ai/resume_extractor/prompts.py @@ -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。""" diff --git a/app/services/resume_service.py b/app/services/resume_service.py index 3765383..83fd03c 100644 --- a/app/services/resume_service.py +++ b/app/services/resume_service.py @@ -19,7 +19,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_text +from app.tool.file_parser import parse_to_segments from app.tool.snowflake import next_id @@ -28,13 +28,13 @@ class ResumeService: async def parse_and_extract(self, filename: str, content: bytes) -> dict: """文件解析 + AI 两阶段并行结构化,不涉及数据库操作""" log.info(f"开始解析简历文件: {filename}") - text = await asyncio.to_thread(parse_to_text, filename, content) - if not text or not text.strip(): + segments = await asyncio.to_thread(parse_to_segments, filename, content) + if not segments: raise ValueError("文件内容为空,无法解析") - log.info(f"文件解析完成,文本长度: {len(text)}") + log.info(f"文件解析完成,文本单元数: {len(segments)}") log.info("开始AI两阶段并行结构化提取") - parsed = await extract_all(text) + parsed = await extract_all(segments) log.info("AI两阶段并行结构化提取完成") return parsed diff --git a/app/tool/file_parser.py b/app/tool/file_parser.py index 6e8a080..ebf3489 100644 --- a/app/tool/file_parser.py +++ b/app/tool/file_parser.py @@ -1,66 +1,115 @@ """文件解析工具 -将上传的简历文件(PDF / Word / TXT)转换为纯文本字符串。 -PDF 使用 PyMuPDF (fitz) 按文本块提取,保持段落边界和阅读顺序。 +将上传的简历文件(PDF / Word / TXT)转换为「文本单元数组」或纯文本字符串。 +- PDF:使用 LiteParse(JSON 模式)按文本块的阅读顺序提取,每个文本块作为一个单元。 + 关闭 OCR —— 文本型简历无需 OCR,且 OCR 默认会联网下载字库导致严重阻塞。 +- Word:按段落(paragraph)切分,表格行用 \t 拼成一个单元。 +- TXT:按换行切分。 +单元数组用于 AI 提取时按行号定位 description/summary,避免 AI 照抄长文本。 + +另提供 parse_to_markdown:基于 LiteParse 的 Markdown 结构化输出(保留标题/列表/加粗等 +语义层级),可用于需要整篇结构化文本的 AI 场景。 """ import io +from collections import Counter -import fitz from docx import Document +from liteparse import LiteParse from app.core.logger import log - -def parse_pdf(content: bytes) -> str: - """解析 PDF 文件,按文本块提取,过滤图片块,保持阅读顺序""" - text_parts: list[str] = [] - with fitz.open(stream=content, filetype="pdf") as doc: - for page in doc: - for b in page.get_text("blocks", sort=True): - if b[6] == 0 and b[4].strip(): # type 0=文本块, 1=图片块 - text_parts.append(b[4].strip()) - return "\n".join(text_parts) +# LiteParse 解析器复用实例(关闭 OCR、静默日志)。线程安全由调用方的 to_thread 串行保证。 +_PDF_SEGMENT_PARSER = LiteParse(output_format="json", ocr_enabled=False, quiet=True) +_PDF_MARKDOWN_PARSER = LiteParse(output_format="markdown", ocr_enabled=False, quiet=True) -def parse_docx(content: bytes) -> str: - """解析 Word (.docx) 文件,提取段落和表格文本""" +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_SEGMENT_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 文件,如果是旧版 .doc 格式,请另存为 .docx 后重试") - text_parts: list[str] = [] + segments: list[str] = [] for para in doc.paragraphs: text = para.text.strip() if text: - text_parts.append(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(): - text_parts.append(row_text) - return "\n".join(text_parts) + segments.append(row_text) + return segments -def parse_txt(content: bytes) -> str: - """解析 TXT 文件,自动检测编码""" +def _txt_segments(content: bytes) -> list[str]: + """解析 TXT,自动检测编码,按换行切分为单元""" + text = None for encoding in ("utf-8", "gbk", "gb2312", "latin-1"): try: - return content.decode(encoding) + text = content.decode(encoding) + break except (UnicodeDecodeError, LookupError): continue - return content.decode("utf-8", errors="replace") + if text is None: + text = content.decode("utf-8", errors="replace") + return [line.strip() for line in text.splitlines() if line.strip()] -def parse_to_text(filename: str, content: bytes) -> str: - """根据文件名后缀自动选择解析方法,返回纯文本""" +def parse_to_segments(filename: str, content: bytes) -> list[str]: + """根据文件名后缀自动选择解析方法,返回「文本单元数组」""" suffix = filename[filename.rfind("."):].lower() if "." in filename else "" log.info(f"解析文件: {filename},类型: {suffix}") if suffix == ".pdf": - return parse_pdf(content) + segments = _pdf_segments(content) elif suffix in (".docx", ".doc"): - return parse_docx(content) + segments = _docx_segments(content) elif suffix == ".txt": - return parse_txt(content) + segments = _txt_segments(content) else: raise ValueError(f"不支持的文件类型: {suffix},支持: .pdf, .docx, .doc, .txt") + return _drop_repeated(segments) + + +def parse_to_text(filename: str, content: bytes) -> str: + """根据文件名后缀自动选择解析方法,返回纯文本(单元用换行拼接)""" + return "\n".join(parse_to_segments(filename, content)) + + +def parse_to_markdown(filename: str, content: bytes) -> str: + """将简历解析为 Markdown 结构化文本(保留标题/列表/加粗等语义层级)。 + + PDF 使用 LiteParse 的 Markdown 输出;Word/TXT 无版式信息,退化为换行拼接的纯文本。 + 适用于需要整篇结构化文本喂给 LLM 的场景。 + """ + suffix = filename[filename.rfind("."):].lower() if "." in filename else "" + log.info(f"解析文件(markdown): {filename},类型: {suffix}") + if suffix == ".pdf": + return _PDF_MARKDOWN_PARSER.parse(content).text + if suffix in (".docx", ".doc"): + return "\n".join(_drop_repeated(_docx_segments(content))) + if suffix == ".txt": + return "\n".join(_drop_repeated(_txt_segments(content))) + raise ValueError(f"不支持的文件类型: {suffix},支持: .pdf, .docx, .doc, .txt") diff --git a/requirements.txt b/requirements.txt index d13cb58..aac8a70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,7 +43,7 @@ python-multipart>=0.0.9 python-dotenv>=1.0.0 # 文件解析 -pymupdf>=1.24.0 +liteparse>=2.1.0 python-docx>=1.1.0 # 雪花ID