Commit 8351ae19 authored by 李文光's avatar 李文光

feat: 简历解析优先用大模型提取字段,失败回退正则

parent 72141584
......@@ -18,7 +18,7 @@ from backend.app.repositories.ingest_repository import (
)
from backend.app.security import verify_ingest_token
from backend.app.services.file_storage import save_resume_bytes
from backend.app.services.resume_parser import parse_resume_path
from backend.app.services.resume_parser import parse_resume_path_async
router = APIRouter()
......@@ -50,7 +50,7 @@ async def ingest(request: Request, db: Session = Depends(get_db)):
content = await download_pdf(pdf_url, raw.get("cookies") or {})
saved_file = save_resume_bytes(content, "platform-resume.pdf", "ingest")
try:
parsed = parse_resume_path(Path(saved_file["storagePath"]))
parsed = await parse_resume_path_async(Path(saved_file["storagePath"]))
except Exception as exc:
parsed = {"parseWarning": str(exc), "resumeText": ""}
except Exception as exc:
......
......@@ -2,7 +2,7 @@ from fastapi import APIRouter, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from backend.app.services.file_storage import resolve_resume_path, save_resume_bytes
from backend.app.services.resume_parser import parse_resume_path, parse_uploaded_bytes
from backend.app.services.resume_parser import parse_resume_path_async, parse_uploaded_bytes_async
router = APIRouter()
......@@ -11,7 +11,7 @@ router = APIRouter()
async def parse_resume_upload(resume: UploadFile = File(...)) -> dict:
content = await resume.read()
try:
return parse_uploaded_bytes(content, resume.filename or "resume.bin")
return await parse_uploaded_bytes_async(content, resume.filename or "resume.bin")
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
......@@ -21,7 +21,7 @@ async def upload_resume(resume: UploadFile = File(...)) -> dict:
content = await resume.read()
saved = save_resume_bytes(content, resume.filename or "resume.bin", "upload")
try:
parsed = parse_resume_path(resolve_resume_path(saved["storedName"]))
parsed = await parse_resume_path_async(resolve_resume_path(saved["storedName"]))
except Exception as exc:
parsed = {"parseWarning": str(exc)}
return {"ok": True, "file": {**saved, "url": f"/api/resumes/{saved['storedName']}"}, "parsed": parsed}
......
......@@ -217,6 +217,71 @@ def sanitize_candidate_for_llm(candidate: dict[str, Any]) -> dict[str, Any]:
}
# 简历结构化提取:LLM 返回的字段集合(与 llm_resume_fields 的 prompt 保持一致)。
# 其中姓名/手机/邮箱/出生年月/年龄等 PII 会被本地脱敏,LLM 无法看到真实值,
# 这些字段最终以正则解析结果为准(见 resume_parser._merge_parse_result)。
LLM_RESUME_FIELDS = (
"name",
"jobTitle",
"phone",
"email",
"birthDate",
"age",
"years",
"education",
"school",
"major",
"city",
"skills",
)
async def llm_resume_fields(text: str, hints: dict[str, Any] | None = None) -> dict[str, Any] | None:
"""用大模型从简历文本中提取结构化字段;未配置 Key 或调用失败时返回 None(调用方回退正则)。
发送前用正则解析结果对姓名/手机/邮箱/城市/出生年月/年龄做本地脱敏,
被脱敏的字段 LLM 一律返回空值,最终以正则结果为准。
"""
settings = get_settings()
if not settings.effective_llm_api_key:
return None
sanitized = sanitize_resume_for_llm(text, hints or {})
if not sanitized.strip():
return None
user = (
"请从下面的简历文本中提取结构化字段,只返回一个 JSON 对象(不要 Markdown 围栏,不要多余解释)。\n\n"
"字段与规则:\n"
'- name: 姓名(2-4 个中文字符)。姓名已被 [本地脱敏] 占位符替换时返回空字符串 ""。\n'
'- jobTitle: 求职意向/应聘岗位,只能是简历中明确写出的岗位名(如"销售支持专员");没有明确写出时返回 "",'
"不要根据教育/技能内容推断岗位。\n"
'- phone: 手机号。已被 [手机号已本地脱敏] 替换或没有时返回 ""。\n'
'- email: 邮箱。已被 [邮箱已本地脱敏] 替换或没有时返回 ""。\n'
'- birthDate: 出生日期,格式 YYYY-MM-DD。已被 [出生年月已本地脱敏] 替换或没有时返回 ""。\n'
'- age: 年龄数字(如 28)。已被 [年龄已本地脱敏] 替换或没有时返回 ""。\n'
'- years: 工作年限,如 "5年" / "3.5年" / "应届生";简历没有明确写工作年限时返回 "",不要从无关时间推算。\n'
"- education: 学历,只能是 博士/硕士/本科/大专/中专/高中/其他 之一;没有明确信息返回 \"\"\n"
'- school: 毕业院校全称(如"甘肃农业大学");没有则返回 ""。\n'
'- major: 专业名称(如"农林经济管理")。主修课程列表里的课程名不是专业,应取"专业"字段或紧邻学校/时间行后的'
'独立专业名;没有明确专业返回 ""。\n'
'- city: 现居城市或简历中的城市名(如"太原",不含"市");没有则返回 ""。\n'
"- skills: 技能数组,最多 12 项,只取简历中明确提到的技能。\n\n"
"要求:只提取简历中明确出现的信息,绝不编造或猜测;已被脱敏占位符替换的字段一律返回空字符串/空数组。\n\n"
f"简历文本:\n{sanitized[:16000]}"
)
try:
parsed = await _chat_json(
settings,
"你是企业招聘系统中的资深简历结构化解析助手。只返回 JSON,不要输出 Markdown。",
user,
temperature=0,
)
except Exception:
return None
if not isinstance(parsed, dict):
return None
return {key: parsed.get(key) for key in LLM_RESUME_FIELDS}
async def llm_resume_analysis(payload: dict[str, Any]) -> dict[str, Any] | None:
settings = get_settings()
if not settings.effective_llm_api_key:
......
......@@ -2,10 +2,12 @@ from pathlib import Path
from tempfile import TemporaryDirectory
from backend.app.services.file_storage import sanitize_filename
from backend.app.services.resume_parser_core import parse_resume
from backend.app.services.llm import LLM_RESUME_FIELDS, llm_resume_fields
from backend.app.services.resume_parser_core import parse_resume, school_tags
def parse_resume_path(path: Path) -> dict:
"""纯正则解析入口(无 LLM 时的兜底/调试路径)。"""
return parse_resume(path)
......@@ -14,3 +16,38 @@ def parse_uploaded_bytes(content: bytes, original_name: str = "resume.bin") -> d
path = Path(temp_dir) / sanitize_filename(original_name)
path.write_bytes(content)
return parse_resume_path(path)
async def parse_resume_path_async(path: Path) -> dict:
"""LLM 优先、正则兜底的简历解析入口(API/CLI 均走这里)。"""
rule_result = parse_resume(path)
llm_result = await llm_resume_fields(rule_result.get("resumeText") or "", rule_result)
return _merge_parse_result(rule_result, llm_result)
async def parse_uploaded_bytes_async(content: bytes, original_name: str = "resume.bin") -> dict:
with TemporaryDirectory(prefix="recruitment-resume-") as temp_dir:
path = Path(temp_dir) / sanitize_filename(original_name)
path.write_bytes(content)
return await parse_resume_path_async(path)
def _merge_parse_result(rule_result: dict, llm_result: dict | None) -> dict:
"""LLM 结果优先、正则结果兜底:LLM 字段非空则覆盖,否则保留正则值。
技能取并集去重(LLM 优先);schoolTags 依据最终 school 重新推导。
"""
result = dict(rule_result)
if not isinstance(llm_result, dict):
return result
for key in LLM_RESUME_FIELDS:
value = llm_result.get(key)
if isinstance(value, str):
value = value.strip()
if value not in (None, "", [], {}):
result[key] = value
llm_skills = [str(item).strip() for item in llm_result.get("skills") or [] if str(item).strip()]
if llm_skills:
result["skills"] = list(dict.fromkeys([*llm_skills, *(rule_result.get("skills") or [])]))
result["schoolTags"] = school_tags(result.get("school") or "")
return result
"""Command-line entry point for the backend resume parser."""
from backend.app.services.resume_parser_core import main
import argparse
import asyncio
import json
from pathlib import Path
from backend.app.services.resume_parser import parse_resume_path_async
async def _parse(path: Path) -> None:
print(json.dumps(await parse_resume_path_async(path), ensure_ascii=False))
def main() -> int:
parser = argparse.ArgumentParser(description="解析 PDF/DOCX/TXT 简历并输出 JSON。")
parser.add_argument("file", help="待解析的简历文件路径")
args = parser.parse_args()
try:
asyncio.run(_parse(Path(args.file)))
return 0
except Exception as exc:
print(json.dumps({"error": str(exc)}, ensure_ascii=False))
return 1
if __name__ == "__main__":
raise SystemExit(main())
import asyncio
import pytest
from backend.app.services.llm import llm_resume_fields
from backend.app.services.resume_parser import _merge_parse_result, parse_resume_path_async
@pytest.fixture()
def llm_disabled(monkeypatch):
"""隔离测试环境:不加载 .env、不配置任何 LLM Key,确保走正则兜底。"""
monkeypatch.setenv("RECRUITMENT_SKIP_ENV_FILES", "1")
for key in ("LLM_API_KEY", "DEEPSEEK_API_KEY", "OPENAI_API_KEY"):
monkeypatch.setenv(key, "")
import backend.app.config as config
config.get_settings.cache_clear()
yield
config.get_settings.cache_clear()
def test_merge_parse_result_prefers_llm_values():
rule = {
"name": "张晓燕",
"jobTitle": "",
"school": "甘肃农业大学",
"major": "人力资源管理",
"skills": ["市场营销"],
"schoolTags": ["非985/211"],
}
llm = {
"name": "",
"jobTitle": "销售支持专员",
"phone": "",
"email": "",
"birthDate": "",
"age": "",
"years": "",
"education": "",
"school": "",
"major": "农林经济管理",
"city": "",
"skills": ["销售支持", "客户管理"],
}
merged = _merge_parse_result(rule, llm)
assert merged["jobTitle"] == "销售支持专员"
assert merged["major"] == "农林经济管理"
assert merged["name"] == "张晓燕"
assert merged["skills"] == ["销售支持", "客户管理", "市场营销"]
def test_merge_parse_result_keeps_rule_when_llm_empty():
rule = {
"name": "张三",
"jobTitle": "销售支持专员",
"school": "甘肃农业大学",
"major": "农林经济管理",
"skills": [],
"schoolTags": ["非985/211"],
}
llm = {
"name": "",
"jobTitle": "",
"phone": "",
"email": "",
"birthDate": "",
"age": "",
"years": "",
"education": "",
"school": "",
"major": "",
"city": "",
"skills": [],
}
merged = _merge_parse_result(rule, llm)
assert merged["name"] == "张三"
assert merged["jobTitle"] == "销售支持专员"
assert merged["major"] == "农林经济管理"
assert merged["skills"] == []
def test_merge_parse_result_recomputes_school_tags():
merged = _merge_parse_result(
{"school": "甘肃农业大学", "schoolTags": ["非985/211"]},
{"school": "清华大学"},
)
assert merged["school"] == "清华大学"
assert merged["schoolTags"] == ["985", "211"]
def test_llm_resume_fields_returns_none_without_key(llm_disabled):
assert asyncio.run(llm_resume_fields("姓名:张三", {})) is None
def test_async_parse_falls_back_without_llm(tmp_path, llm_disabled):
resume = tmp_path / "李四_高级前端工程师.txt"
resume.write_text(
"姓名:李四\n电话:13912345678\n邮箱:lisi@example.com\n本科 北京大学 软件工程\n熟悉 React Python",
encoding="utf-8",
)
parsed = asyncio.run(parse_resume_path_async(resume))
assert parsed["name"] == "李四"
assert parsed["phone"] == "13912345678"
assert parsed["school"] == "北京大学"
assert parsed["major"] == "软件工程"
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment