Commit 6248f275 authored by 李文光's avatar 李文光

fix: 简历解析真正改为大模型优先提取,失败才回退正则

parent 07310dd8
......@@ -218,8 +218,8 @@ def sanitize_candidate_for_llm(candidate: dict[str, Any]) -> dict[str, Any]:
# 简历结构化提取:LLM 返回的字段集合(与 llm_resume_fields 的 prompt 保持一致)。
# 其中姓名/手机/邮箱/出生年月/年龄等 PII 会被本地脱敏,LLM 无法看到真实值,
# 这些字段最终以正则解析结果为准(见 resume_parser._merge_parse_result)。
# 其中姓名/手机/邮箱/出生年月/年龄/城市等 PII 会被本地脱敏,LLM 无法看到真实值,
# 这些字段始终以正则预扫描结果为准(见 resume_parser_core.extract_pii_fields)。
LLM_RESUME_FIELDS = (
"name",
"jobTitle",
......@@ -239,8 +239,8 @@ LLM_RESUME_FIELDS = (
async def llm_resume_fields(text: str, hints: dict[str, Any] | None = None) -> dict[str, Any] | None:
"""用大模型从简历文本中提取结构化字段;未配置 Key 或调用失败时返回 None(调用方回退正则)。
发送前用正则解析结果对姓名/手机/邮箱/城市/出生年月/年龄做本地脱敏,
被脱敏的字段 LLM 一律返回空值,最终以正则结果为准。
发送前用正则预扫描结果对姓名/手机/邮箱/城市/出生年月/年龄做本地脱敏,
被脱敏的字段 LLM 一律返回空值,最终以正则预扫描结果为准。
"""
settings = get_settings()
if not settings.effective_llm_api_key:
......
......@@ -3,7 +3,19 @@ from tempfile import TemporaryDirectory
from backend.app.services.file_storage import sanitize_filename
from backend.app.services.llm import LLM_RESUME_FIELDS, llm_resume_fields
from backend.app.services.resume_parser_core import parse_resume, school_tags
from backend.app.services.resume_parser_core import (
extract_pii_fields,
infer_source,
parse_resume,
parse_resume_text,
read_resume_text,
school_tags,
)
# LLM 看不到真实值的 PII 字段:发送前已本地脱敏,始终以正则预扫描结果为准。
_PII_KEYS = {"name", "phone", "email", "birthDate", "age", "city", "resumeName"}
# 交给大模型提取的内容字段(大模型非空值优先采用,正则仅作整体兜底)。
_LLM_CONTENT_KEYS = tuple(key for key in LLM_RESUME_FIELDS if key not in _PII_KEYS)
def parse_resume_path(path: Path) -> dict:
......@@ -19,10 +31,21 @@ def parse_uploaded_bytes(content: bytes, original_name: str = "resume.bin") -> d
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)
"""LLM 优先、正则兜底的简历解析入口(API/CLI 均走这里)。
读取简历文本后,先把内容交给大模型理解并提取结构化字段;只有大模型不可用
或提取失败时才整体回退到纯正则解析(parse_resume_text)。姓名/手机/邮箱/
出生年月/年龄/城市等 PII 发往大模型前必须先本地脱敏(见
llm.sanitize_resume_for_llm),因此这类字段始终以 extract_pii_fields 的
正则预扫描结果为准,不采用大模型返回值。
"""
text = read_resume_text(path)
filename = path.name
pii = extract_pii_fields(text, filename)
llm_result = await llm_resume_fields(text, pii)
if not _has_llm_content(llm_result):
return parse_resume_text(text, filename)
return _compose_llm_result(text, filename, pii, llm_result)
async def parse_uploaded_bytes_async(content: bytes, original_name: str = "resume.bin") -> dict:
......@@ -32,22 +55,46 @@ async def parse_uploaded_bytes_async(content: bytes, original_name: str = "resum
return await parse_resume_path_async(path)
def _merge_parse_result(rule_result: dict, llm_result: dict | None) -> dict:
"""LLM 结果优先、正则结果兜底:LLM 字段非空则覆盖,否则保留正则值。
def _has_llm_content(result: dict | None) -> bool:
"""大模型是否提取到可用内容字段;返回空字典/全空时视为提取失败,回退正则。"""
if not isinstance(result, dict):
return False
for key in _LLM_CONTENT_KEYS:
value = result.get(key)
if isinstance(value, list):
if any(str(item).strip() for item in value):
return True
elif value not in (None, "", [], {}):
return True
return False
技能取并集去重(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
def _clean_text_value(value: object) -> str:
if isinstance(value, str):
return value.strip()
if value is None:
return ""
return str(value).strip()
def _compose_llm_result(text: str, filename: str, pii: dict, llm_result: dict) -> dict:
"""大模型提取成功时的结果组装:PII 取正则预扫描值,其余内容字段取大模型值。"""
school = _clean_text_value(llm_result.get("school"))
skills = [
str(item).strip()
for item in (llm_result.get("skills") or [])
if str(item).strip()
]
return {
**pii,
"jobTitle": _clean_text_value(llm_result.get("jobTitle")),
"years": _clean_text_value(llm_result.get("years")),
"education": _clean_text_value(llm_result.get("education")),
"school": school,
"major": _clean_text_value(llm_result.get("major")),
"schoolTags": school_tags(school),
"skills": list(dict.fromkeys(skills)),
"source": infer_source(text, filename),
"resumeText": text,
"textLength": len(text),
}
......@@ -431,7 +431,8 @@ def school_tags(school: str):
return tags
def parse_resume(path: Path):
def read_resume_text(path: Path) -> str:
"""读取 PDF/DOCX/TXT 简历文本并做统一规范化,供 LLM 优先解析与正则解析共用。"""
suffix = path.suffix.lower()
if suffix == ".pdf":
text = read_pdf(path)
......@@ -439,42 +440,56 @@ def parse_resume(path: Path):
text = read_docx(path)
else:
text = read_text(path)
text = normalize(text)
filename = path.name
return normalize(text)
# 电话:支持多种格式 13812345678 / 138 1234 5678 / 138-1234-5678 / +86 13812345678
def extract_pii_fields(text: str, filename: str) -> dict:
"""正则预扫描候选人 PII 字段(脱敏依据,不随正文发往大模型)。
姓名/手机/邮箱/出生年月/年龄/城市 等敏感字段在解析链路中始终以本地正则
结果为准:发送给大模型前必须先把它们替换为占位符(见
llm.sanitize_resume_for_llm),大模型看不到真实值,最终取值也以这里的
正则结果为准。
"""
phone = first_match([
r"((?:\+?86[-\s]?)?1[3-9]\d[-\s]?\d{4}[-\s]?\d{4})",
r"电话[::\s]*((?:\+?86[-\s]?)?1[3-9]\d{9})",
], text)
email = first_match([r"([A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})"], text)
years = infer_work_years(f"{text}\n{filename}")
birth_date = infer_birth_date(text)
education = infer_education(text)
school = infer_school(text)
major = infer_major(text)
city = first_match([r"(?:现居|所在地|城市|地点)[::\s]+([一-龥]{2,12})"], text)
result = {
return {
"name": infer_name(text, filename),
"jobTitle": infer_job(text),
"phone": phone,
"email": email,
"birthDate": birth_date,
"age": infer_explicit_age(text) or age_from_birth_date(birth_date),
"years": years,
"education": education,
"city": first_match([r"(?:现居|所在地|城市|地点)[::\s]+([一-龥]{2,12})"], text),
"resumeName": filename,
}
def parse_resume_text(text: str, filename: str) -> dict:
"""对已读取的简历文本做纯正则结构化提取(无大模型时的兜底/调试路径)。"""
pii = extract_pii_fields(text, filename)
school = infer_school(text)
return {
**pii,
"jobTitle": infer_job(text),
"years": infer_work_years(f"{text}\n{filename}"),
"education": infer_education(text),
"school": school,
"major": major,
"major": infer_major(text),
"schoolTags": school_tags(school),
"city": city,
"source": infer_source(text, filename),
"skills": extract_skills(text),
"resumeText": text,
"resumeName": filename,
"textLength": len(text),
}
return result
def parse_resume(path: Path) -> dict:
"""纯正则解析入口(无大模型时的兜底/调试路径):读取文件并提取字段。"""
return parse_resume_text(read_resume_text(path), path.name)
def infer_education(text: str) -> str:
......
......@@ -2,7 +2,7 @@ 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
from backend.app.services.resume_parser import _compose_llm_result, parse_resume_path_async
@pytest.fixture()
......@@ -18,73 +18,130 @@ def llm_disabled(monkeypatch):
config.get_settings.cache_clear()
def test_merge_parse_result_prefers_llm_values():
rule = {
@pytest.fixture()
def stub_llm(monkeypatch):
"""把 parse_resume_path_async 引用的 llm_resume_fields 替换为固定返回的异步桩。"""
def install(result):
async def fake_llm(text, hints=None):
return result
monkeypatch.setattr("backend.app.services.resume_parser.llm_resume_fields", fake_llm)
return install
def _write_resume(tmp_path, name: str = "张晓燕.txt") -> str:
resume = tmp_path / name
resume.write_text(
"张晓燕\n性别:女年龄:28\n电话:18734915261 邮箱:1822742034@qq.com\n销售支持专员\n教育经历\n"
"2017.09—2021.07 甘肃农业大学\n农林经济管理\n"
"主修课程:市场营销、管理学原理、人力资源管理、企业经营战略、财务管理、会计学原理、区域经济学",
encoding="utf-8",
)
return str(resume)
def test_compose_llm_result_keeps_regex_pii_and_uses_llm_content():
pii = {
"name": "张晓燕",
"jobTitle": "",
"school": "甘肃农业大学",
"major": "人力资源管理",
"skills": ["市场营销"],
"schoolTags": ["非985/211"],
}
llm = {
"name": "",
"jobTitle": "销售支持专员",
"phone": "",
"email": "",
"phone": "18734915261",
"email": "1822742034@qq.com",
"birthDate": "",
"age": "",
"years": "",
"education": "",
"school": "",
"major": "农林经济管理",
"age": "28",
"city": "",
"skills": ["销售支持", "客户管理"],
"resumeName": "张晓燕.txt",
}
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": "张三",
llm = {
"name": "大模型幻觉姓名",
"phone": "13900000000",
"email": "fake@llm.com",
"birthDate": "1990-01-01",
"age": "35",
"city": "太原",
"jobTitle": "销售支持专员",
"years": "2年",
"education": "本科",
"school": "甘肃农业大学",
"major": "农林经济管理",
"skills": [],
"schoolTags": ["非985/211"],
}
llm = {
"name": "",
"jobTitle": "",
"phone": "",
"email": "",
"birthDate": "",
"age": "",
"years": "",
"education": "",
"school": "",
"major": "",
"city": "",
"skills": [],
"skills": ["客户管理", "客户管理"],
}
merged = _merge_parse_result(rule, llm)
assert merged["name"] == "张三"
text = "简历正文"
merged = _compose_llm_result(text, "张晓燕.txt", pii, llm)
# PII 字段不采用大模型幻觉值,始终以正则预扫描结果为准
assert merged["name"] == "张晓燕"
assert merged["phone"] == "18734915261"
assert merged["email"] == "1822742034@qq.com"
assert merged["birthDate"] == ""
assert merged["age"] == "28"
assert merged["city"] == ""
# 内容字段采用大模型值,schoolTags 依据最终 school 重新推导
assert merged["jobTitle"] == "销售支持专员"
assert merged["years"] == "2年"
assert merged["education"] == "本科"
assert merged["school"] == "甘肃农业大学"
assert merged["major"] == "农林经济管理"
assert merged["skills"] == []
assert merged["skills"] == ["客户管理"]
assert merged["schoolTags"] == ["非985/211"]
assert merged["resumeText"] == text
def test_merge_parse_result_recomputes_school_tags():
merged = _merge_parse_result(
{"school": "甘肃农业大学", "schoolTags": ["非985/211"]},
{"school": "清华大学"},
def test_async_parse_prefers_llm_content_and_keeps_regex_pii(tmp_path, stub_llm):
_write_resume(tmp_path)
stub_llm(
{
"name": "大模型幻觉姓名",
"jobTitle": "销售支持专员",
"phone": "13900000000",
"email": "",
"birthDate": "",
"age": "",
"years": "2年",
"education": "本科",
"school": "甘肃农业大学",
"major": "农林经济管理",
"city": "太原",
"skills": ["客户管理"],
}
)
assert merged["school"] == "清华大学"
assert merged["schoolTags"] == ["985", "211"]
parsed = asyncio.run(parse_resume_path_async(tmp_path / "张晓燕.txt"))
assert parsed["name"] == "张晓燕"
assert parsed["phone"] == "18734915261"
assert parsed["jobTitle"] == "销售支持专员"
assert parsed["major"] == "农林经济管理"
assert parsed["years"] == "2年"
assert parsed["education"] == "本科"
assert parsed["city"] == ""
assert parsed["skills"] == ["客户管理"]
assert parsed["resumeName"] == "张晓燕.txt"
assert parsed["textLength"] > 0
def test_async_parse_skips_regex_when_llm_usable(tmp_path, stub_llm, monkeypatch):
"""LLM 提取到内容时不应再跑正则兜底(正则仅在大模型不可用/失败时执行)。"""
def boom(*args, **kwargs):
raise AssertionError("LLM 可用且提取到内容时不应调用正则兜底 parse_resume_text")
monkeypatch.setattr("backend.app.services.resume_parser.parse_resume_text", boom)
_write_resume(tmp_path)
stub_llm({"jobTitle": "销售支持专员", "school": "甘肃农业大学", "major": "农林经济管理"})
parsed = asyncio.run(parse_resume_path_async(tmp_path / "张晓燕.txt"))
assert parsed["name"] == "张晓燕"
assert parsed["major"] == "农林经济管理"
def test_async_parse_falls_back_to_regex_when_llm_returns_empty(tmp_path, stub_llm):
"""大模型返回空内容视为提取失败,回退纯正则解析。"""
_write_resume(tmp_path)
stub_llm({})
parsed = asyncio.run(parse_resume_path_async(tmp_path / "张晓燕.txt"))
assert parsed["name"] == "张晓燕"
assert parsed["phone"] == "18734915261"
assert parsed["school"] == "甘肃农业大学"
assert parsed["major"] == "农林经济管理"
assert parsed["jobTitle"] == "销售支持专员"
assert parsed["resumeText"]
def test_llm_resume_fields_returns_none_without_key(llm_disabled):
......
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