Files
offerpai_python_ai/app/ai/resume_extractor/extractor.py
T
2026-07-01 18:41:51 +08:00

159 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""简历两阶段并行提取
第一阶段:5路并行提取主表短字段 + 各子表标识名(极快,输出极短)。
第二阶段:N+1路并行提取每条子表记录的短字段 + description,以及 profile 补充(skills/certificates/summary)。
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.prompts import (
OVERVIEW_PROFILE_PROMPT, OVERVIEW_EDUCATION_PROMPT, OVERVIEW_WORK_PROMPT,
OVERVIEW_PROJECT_PROMPT, OVERVIEW_COMPETITION_PROMPT,
DETAIL_PROFILE_PROMPT, DETAIL_EDUCATION_PROMPT, DETAIL_WORK_PROMPT,
DETAIL_INTERNSHIP_PROMPT, DETAIL_PROJECT_PROMPT, DETAIL_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()]
# ==================== 第一阶段:概览 ====================
_overview_profile_chain = _build_chain(OVERVIEW_PROFILE_PROMPT)
_overview_education_chain = _build_chain(OVERVIEW_EDUCATION_PROMPT)
_overview_work_chain = _build_chain(OVERVIEW_WORK_PROMPT)
_overview_project_chain = _build_chain(OVERVIEW_PROJECT_PROMPT)
_overview_competition_chain = _build_chain(OVERVIEW_COMPETITION_PROMPT)
async def _extract_overview(text: str) -> dict:
"""第一阶段:5路并行提取概览信息"""
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, "概览-教育"),
_safe_invoke(_overview_work_chain, inp, "概览-工作实习"),
_safe_invoke(_overview_project_chain, inp, "概览-项目"),
_safe_invoke(_overview_competition_chain, inp, "概览-竞赛"),
)
return {
"profile": profile if isinstance(profile, dict) else {},
"education": edu_names if isinstance(edu_names, list) else [],
"work": work_names.get("work", []) if isinstance(work_names, dict) else [],
"internship": work_names.get("internship", []) if isinstance(work_names, dict) else [],
"project": proj_names if isinstance(proj_names, list) else [],
"competition": comp_names if isinstance(comp_names, list) else [],
}
# ==================== 第二阶段:详情 ====================
# 子表模块统一配置(单一数据源):(模块键, 详情 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, text: str, label: str) -> dict | None:
"""单条子表记录详情提取:用 name 替换 prompt 中的 {name}"""
chain = _build_chain(prompt_tpl.replace("{name}", name))
return await _safe_invoke(chain, {"text": text}, label)
async def _extract_all_details(overview: dict, text: str) -> dict:
"""第二阶段:根据概览结果,N+1路并行提取所有子表记录详情 + 个人信息补充"""
# 第 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, text, f"详情-{label}-{name}"))
task_modules.append(module)
results = await asyncio.gather(*tasks)
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 结构(description 为 list[str]summary 为 str"""
profile = overview["profile"]
profile_extra = details.get("profile_extra", [{}])[0] if details.get("profile_extra") else {}
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"] = _clean_str_list(item.get("description"))
items.append(item)
result[module] = items
return result
# ==================== 入口 ====================
async def extract_all(text: str) -> dict:
"""两阶段并行提取简历,返回与原方案完全一致的结构化数据
text: 简历纯文本全文(PyMuPDF/docx/txt 提取,保留原始换行)。
"""
log.info("第一阶段:5路并行概览提取")
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, text)
result = _assemble(overview, details)
log.info("两阶段提取完成,数据组装完毕")
return result