优化简历提取相关

This commit is contained in:
zk
2026-06-24 16:16:37 +08:00
parent 556302816b
commit 62a0801a22
5 changed files with 253 additions and 106 deletions
+128 -47
View File
@@ -1,11 +1,13 @@
"""简历两阶段并行提取 """简历两阶段并行提取
第一阶段:5路并行提取主表短字段 + 各子表标识名(极快,输出极短)。 第一阶段:5路并行提取主表短字段 + 各子表标识名(极快,输出极短)。
第二阶段:N+1路并行提取每条子表记录的详情(含description原文)+ 个人信息补充(skills/certificates/summary 第二阶段:N+1路并行提取每条子表记录的短字段 + description/summary 的「行号区间」
最终组装为与原方案完全一致的 dict 结构,上下游无感知 description/summary 不再由 AI 照抄原文,AI 只返回行号区间字符串,由代码从原文单元数组切片还原
最终组装为与原方案完全一致的 dict 结构(description 为 list[str]),上下游无感知。
""" """
import asyncio import asyncio
import re
import time import time
from langchain_core.output_parsers import StrOutputParser from langchain_core.output_parsers import StrOutputParser
@@ -41,6 +43,80 @@ async def _safe_invoke(chain, inp: dict, label: str):
return None 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) _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) _overview_competition_chain = _build_chain(OVERVIEW_COMPETITION_PROMPT)
async def _extract_overview(text: str) -> dict: async def _extract_overview(numbered_text: str) -> dict:
"""第一阶段:5路并行提取概览信息""" """第一阶段:5路并行提取概览信息"""
inp = {"text": text} inp = {"text": numbered_text}
profile, edu_names, work_names, proj_names, comp_names = await asyncio.gather( profile, edu_names, work_names, proj_names, comp_names = await asyncio.gather(
_safe_invoke(_overview_profile_chain, inp, "概览-个人信息"), _safe_invoke(_overview_profile_chain, inp, "概览-个人信息"),
_safe_invoke(_overview_education_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}""" """单条子表记录详情提取:用 name 替换 prompt 中的 {name}"""
prompt = prompt_tpl.replace("{name}", name) chain = _build_chain(prompt_tpl.replace("{name}", name))
chain = _build_chain(prompt) return await _safe_invoke(chain, {"text": numbered_text}, label)
return await _safe_invoke(chain, {"text": 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路并行提取所有子表记录详情 + 个人信息补充""" """第二阶段:根据概览结果,N+1路并行提取所有子表记录详情 + 个人信息补充"""
tasks: list = [] # 第 0 路固定为 profile 补充(skills/certificates/summaryRange),其余按子表记录展开
task_meta: list[tuple[str, int]] = [] tasks = [_extract_detail(DETAIL_PROFILE_PROMPT, "", numbered_text, "详情-个人信息补充")]
task_modules = ["profile_extra"]
# profile 补充:skills/certificates/summary for module, prompt_tpl, label in _DETAIL_MODULES:
tasks.append(_extract_detail(DETAIL_PROFILE_PROMPT, "", text, "详情-个人信息补充")) for name in overview[module]:
task_meta.append(("profile_extra", 0)) tasks.append(_extract_detail(prompt_tpl, name, numbered_text, f"详情-{label}-{name}"))
task_modules.append(module)
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))
results = await asyncio.gather(*tasks) results = await asyncio.gather(*tasks)
details: dict[str, list] = {"profile_extra": [], "education": [], "work": [], "internship": [], "project": [], "competition": []} details: dict[str, list] = {"profile_extra": [], **{m: [] for m in _SUB_MODULES}}
for (module, _idx), result in zip(task_meta, results): for module, result in zip(task_modules, results):
details[module].append(result if isinstance(result, dict) else {}) details[module].append(result if isinstance(result, dict) else {})
return details return details
# ==================== 组装 ==================== # ==================== 组装 ====================
def _assemble(overview: dict, details: dict) -> dict: def _assemble(overview: dict, details: dict, segments: list[str]) -> dict:
"""将两阶段结果组装为与原方案一致的 dict 结构""" """将两阶段结果组装为与原方案一致的 dict 结构description 还原为 list[str]"""
profile = overview["profile"] profile = overview["profile"]
profile_extra = details.get("profile_extra", [{}])[0] if details.get("profile_extra") else {} 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["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) result = dict(profile)
for module in ("education", "work", "internship", "project", "competition"): for module in _SUB_MODULES:
items = [] items = []
for item in details.get(module, []): for item in details.get(module, []):
if not item.get("description"): item["description"] = _slice_ranges(item.get("descriptionRange"), segments)
item["description"] = [] item.pop("descriptionRange", None)
items.append(item) items.append(item)
result[module] = items result[module] = items
return result 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路并行概览提取") log.info("第一阶段:5路并行概览提取")
overview = await _extract_overview(text) overview = await _extract_overview(numbered_text)
log.info(f"概览完成 - 教育:{len(overview['education'])} 工作:{len(overview['work'])} 实习:{len(overview['internship'])} 项目:{len(overview['project'])} 竞赛:{len(overview['competition'])}") 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}路并行详情提取") 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("两阶段提取完成,数据组装完毕") log.info("两阶段提取完成,数据组装完毕")
return result return result
+42 -25
View File
@@ -1,42 +1,55 @@
"""简历两阶段提取 Prompt """简历两阶段提取 Prompt
第一阶段(概览):5路并行,只提取主表短字段和子表标识名。 第一阶段(概览):5路并行,只提取主表短字段和子表标识名。
第二阶段(详情):N路并行,每条子表记录单独提取全部字段description 直接输出原文 第二阶段(详情):N路并行,每条子表记录单独提取字段description/summary 不照抄原文
只返回「行号区间字符串」,由代码按行号从原文单元数组切片还原。
输入文本格式:每行形如 `[行号] 内容`,行号从 0 开始连续递增。
行号区间字符串格式:`"起-止,起-止"`0 开始、闭区间(含两端)。
- 逗号分隔的每一段代表「一个段落」。
- 单行可简写为 `"5"`(等价 `"5-5"`)。
- 没有内容则返回空字符串 ""
- 行号必须是上面输入文本中真实出现过的编号,严禁编造不存在的行号。
花括号用 {{ }} 转义,避免被 ChatPromptTemplate 当作变量。{name} 为运行时替换的记录标识名。 花括号用 {{ }} 转义,避免被 ChatPromptTemplate 当作变量。{name} 为运行时替换的记录标识名。
""" """
# ==================== 第一阶段:概览提取 ==================== # ==================== 第一阶段:概览提取 ====================
OVERVIEW_PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造,没有的填null。 OVERVIEW_PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造,没有的填null。
从中仅提取个人基本信息(不含技能、证书、自我评价),输出JSON: 输入为带行号的简历文本(每行 `[行号] 内容`)。从中仅提取个人基本信息(不含技能、证书、自我评价),输出JSON:
```json ```json
{{ "name": "姓名", "email": "邮箱", "mobileNumber": "手机号", "city": "所在城市", "wechatNumber": "微信号", "portfolioUrl": "作品集链接" }} {{ "name": "姓名", "email": "邮箱", "mobileNumber": "手机号", "city": "所在城市", "wechatNumber": "微信号", "portfolioUrl": "作品集链接" }}
``` ```
规则:只提取以上6个字段,不提取skills/certificates/summary/经历。没有的填null。只输出JSON。""" 规则:只提取以上6个字段,不提取skills/certificates/summary/经历。没有的填null。只输出JSON。"""
OVERVIEW_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。 OVERVIEW_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。
从中仅提取教育经历的学校名称列表,输出JSON数组: 输入为带行号的简历文本。从中仅提取教育经历的学校名称列表,输出JSON数组:
```json ```json
["北京大学", "清华大学"] ["北京大学", "清华大学"]
``` ```
规则:只提取学校名称,不提取其他字段。没有输出[]。只输出JSON。""" 规则:只提取学校名称,不提取其他字段。没有输出[]。只输出JSON。"""
OVERVIEW_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造。 OVERVIEW_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造。
从中仅提取工作经历和实习经历的公司名称列表,输出JSON: 输入为带行号的简历文本。从中仅提取工作经历和实习经历的公司名称列表,输出JSON:
```json ```json
{{ "work": ["阿里巴巴", "腾讯"], "internship": ["字节跳动"] }} {{ "work": ["阿里巴巴", "腾讯"], "internship": ["字节跳动"] }}
``` ```
规则:标注"实习"的归internship,其余归work。只提取公司名称。没有填[]。只输出JSON。""" 规则:标注"实习"的归internship,其余归work。只提取公司名称。没有填[]。只输出JSON。"""
OVERVIEW_PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编造。 OVERVIEW_PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编造。
从中仅提取项目经历的项目名称列表,输出JSON数组: 输入为带行号的简历文本。从中仅提取项目经历的项目名称列表,输出JSON数组:
```json ```json
["订单系统重构", "支付网关"] ["订单系统重构", "支付网关"]
``` ```
规则:只提取项目名称,不提取其他字段。没有输出[]。只输出JSON。""" 规则:
1. 只提取作为**独立项目条目标题**出现的项目名(通常单独成行、常带"项目X"前缀或【】标题、并配有自己的起止时间/角色/描述)。
2. **绝对不要**把出现在某段职责/产品描述句子内部、被"如/包括/例如/【】"等列举的功能点、模块、子功能当作项目(这些是描述内容,不是项目)。
3. 没有输出[]。只输出JSON。"""
OVERVIEW_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。 OVERVIEW_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。
从中仅提取竞赛/获奖经历的竞赛名称列表,输出JSON数组: 输入为带行号的简历文本。从中仅提取竞赛/获奖经历的竞赛名称列表,输出JSON数组:
```json ```json
["ACM区域赛", "数学建模大赛"] ["ACM区域赛", "数学建模大赛"]
``` ```
@@ -44,44 +57,48 @@ OVERVIEW_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测
# ==================== 第二阶段:详情提取 ==================== # ==================== 第二阶段:详情提取 ====================
DETAIL_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。 DETAIL_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。
请提取"{name}"这条教育经历的详细信息,输出JSON 请提取"{name}"这条教育经历的详细信息,输出JSON
```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 请提取"{name}"这条工作经历的详细信息,输出JSON
```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 请提取"{name}"这条实习经历的详细信息,输出JSON
```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 请提取"{name}"这条项目经历的详细信息,输出JSON
```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: 请提取"{name}"这条竞赛/获奖经历的详细信息,输出JSON:
```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:
```json ```json
{{ "skills": ["技能1"], "certificates": ["证书1"], "summary": "自我评价原文" }} {{ "skills": ["技能1"], "certificates": ["证书1"], "summaryRange": "起-止,起-止" }}
``` ```
规则:skills优先提取简历中明确标注的"技能"/"专业技能"等模块内容,原文有什么填什么;如果简历中没有专门的技能模块,则从全文归纳核心技能,最多8个。certificates填证书数组。summary填自我评价/个人概述原文。没有的填null,数组填[]。只输出JSON。""" 规则:
- skills:仅当简历中有明确的"技能"/"专业技能"等独立模块时才提取,原文有什么填什么,最多5个;如果简历没有专门的技能模块,填[],不要从经历中归纳。
- certificates:填证书数组,没有填[]。
- summaryRange:填自我评价/个人概述**正文**所在的行号区间字符串(0开始、闭区间,行号必须真实存在),不要包含板块标题行(如"个人优势""自我评价"等),排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间,不同段落用逗号分开(每个逗号段是一个独立段落,段间会以换行连接),没有填""
只输出JSON。"""
+5 -5
View File
@@ -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_internship import UserResumeInternship
from app.models.user_resume_project import UserResumeProject from app.models.user_resume_project import UserResumeProject
from app.models.user_resume_work import UserResumeWork 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 from app.tool.snowflake import next_id
@@ -28,13 +28,13 @@ class ResumeService:
async def parse_and_extract(self, filename: str, content: bytes) -> dict: async def parse_and_extract(self, filename: str, content: bytes) -> dict:
"""文件解析 + AI 两阶段并行结构化,不涉及数据库操作""" """文件解析 + AI 两阶段并行结构化,不涉及数据库操作"""
log.info(f"开始解析简历文件: {filename}") log.info(f"开始解析简历文件: {filename}")
text = await asyncio.to_thread(parse_to_text, filename, content) segments = await asyncio.to_thread(parse_to_segments, filename, content)
if not text or not text.strip(): if not segments:
raise ValueError("文件内容为空,无法解析") raise ValueError("文件内容为空,无法解析")
log.info(f"文件解析完成,文本长度: {len(text)}") log.info(f"文件解析完成,文本单元数: {len(segments)}")
log.info("开始AI两阶段并行结构化提取") log.info("开始AI两阶段并行结构化提取")
parsed = await extract_all(text) parsed = await extract_all(segments)
log.info("AI两阶段并行结构化提取完成") log.info("AI两阶段并行结构化提取完成")
return parsed return parsed
+77 -28
View File
@@ -1,66 +1,115 @@
"""文件解析工具 """文件解析工具
将上传的简历文件(PDF / Word / TXT)转换为纯文本字符串。 将上传的简历文件(PDF / Word / TXT)转换为「文本单元数组」或纯文本字符串。
PDF 使用 PyMuPDF (fitz) 按文本块提取,保持段落边界和阅读顺序 - PDF使用 LiteParse(JSON 模式)按文本块的阅读顺序提取,每个文本块作为一个单元
关闭 OCR —— 文本型简历无需 OCR,且 OCR 默认会联网下载字库导致严重阻塞。
- Word:按段落(paragraph)切分,表格行用 \t 拼成一个单元。
- TXT:按换行切分。
单元数组用于 AI 提取时按行号定位 description/summary,避免 AI 照抄长文本。
另提供 parse_to_markdown:基于 LiteParse 的 Markdown 结构化输出(保留标题/列表/加粗等
语义层级),可用于需要整篇结构化文本的 AI 场景。
""" """
import io import io
from collections import Counter
import fitz
from docx import Document from docx import Document
from liteparse import LiteParse
from app.core.logger import log from app.core.logger import log
# LiteParse 解析器复用实例(关闭 OCR、静默日志)。线程安全由调用方的 to_thread 串行保证。
def parse_pdf(content: bytes) -> str: _PDF_SEGMENT_PARSER = LiteParse(output_format="json", ocr_enabled=False, quiet=True)
"""解析 PDF 文件,按文本块提取,过滤图片块,保持阅读顺序""" _PDF_MARKDOWN_PARSER = LiteParse(output_format="markdown", ocr_enabled=False, quiet=True)
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)
def parse_docx(content: bytes) -> str: def _drop_repeated(segments: list[str], threshold: int = 3) -> list[str]:
"""解析 Word (.docx) 文件,提取段落和表格文本""" """过滤重复出现的行(水印/页眉页脚特征):完全相同且出现次数 >= 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: try:
doc = Document(io.BytesIO(content)) doc = Document(io.BytesIO(content))
except Exception: except Exception:
raise ValueError("无法解析该 Word 文件,如果是旧版 .doc 格式,请另存为 .docx 后重试") raise ValueError("无法解析该 Word 文件,如果是旧版 .doc 格式,请另存为 .docx 后重试")
text_parts: list[str] = [] segments: list[str] = []
for para in doc.paragraphs: for para in doc.paragraphs:
text = para.text.strip() text = para.text.strip()
if text: if text:
text_parts.append(text) segments.append(text)
for table in doc.tables: for table in doc.tables:
for row in table.rows: for row in table.rows:
row_text = "\t".join(cell.text.strip() for cell in row.cells) row_text = "\t".join(cell.text.strip() for cell in row.cells)
if row_text.strip(): if row_text.strip():
text_parts.append(row_text) segments.append(row_text)
return "\n".join(text_parts) return segments
def parse_txt(content: bytes) -> str: def _txt_segments(content: bytes) -> list[str]:
"""解析 TXT 文件,自动检测编码""" """解析 TXT,自动检测编码,按换行切分为单元"""
text = None
for encoding in ("utf-8", "gbk", "gb2312", "latin-1"): for encoding in ("utf-8", "gbk", "gb2312", "latin-1"):
try: try:
return content.decode(encoding) text = content.decode(encoding)
break
except (UnicodeDecodeError, LookupError): except (UnicodeDecodeError, LookupError):
continue 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 "" suffix = filename[filename.rfind("."):].lower() if "." in filename else ""
log.info(f"解析文件: {filename},类型: {suffix}") log.info(f"解析文件: {filename},类型: {suffix}")
if suffix == ".pdf": if suffix == ".pdf":
return parse_pdf(content) segments = _pdf_segments(content)
elif suffix in (".docx", ".doc"): elif suffix in (".docx", ".doc"):
return parse_docx(content) segments = _docx_segments(content)
elif suffix == ".txt": elif suffix == ".txt":
return parse_txt(content) segments = _txt_segments(content)
else: else:
raise ValueError(f"不支持的文件类型: {suffix},支持: .pdf, .docx, .doc, .txt") 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")
+1 -1
View File
@@ -43,7 +43,7 @@ python-multipart>=0.0.9
python-dotenv>=1.0.0 python-dotenv>=1.0.0
# 文件解析 # 文件解析
pymupdf>=1.24.0 liteparse>=2.1.0
python-docx>=1.1.0 python-docx>=1.1.0
# 雪花ID # 雪花ID