Commit fc0aa5ce authored by 李文光's avatar 李文光

feat: 简历解析去掉脱敏,原文直接交大模型提取全部字段

parent 6248f275
......@@ -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_core.extract_pii_fields)
# 该路径不做本地脱敏:简历原文(含姓名/手机/邮箱等 PII)直接交给大模型提取全部字段
# 大模型不可用或提取失败时由 resume_parser 回退纯正则解析
LLM_RESUME_FIELDS = (
"name",
"jobTitle",
......@@ -236,28 +236,27 @@ LLM_RESUME_FIELDS = (
)
async def llm_resume_fields(text: str, hints: dict[str, Any] | None = None) -> dict[str, Any] | None:
"""用大模型从简历文本中提取结构化字段;未配置 Key 或调用失败时返回 None(调用方回退正则)。
async def llm_resume_fields(text: str) -> dict[str, Any] | None:
"""用大模型直接从简历文本中提取结构化字段;未配置 Key 或调用失败时返回 None(调用方回退正则)。
发送前用正则预扫描结果对姓名/手机/邮箱/城市/出生年月/年龄做本地脱敏
被脱敏的字段 LLM 一律返回空值,最终以正则预扫描结果为准
注意:本路径不做本地脱敏,简历原文(含候选人 PII)会原样发送给大模型
调用方需确保大模型环境可信;如需脱敏,请改用 analyze_resume 的脱敏链路
"""
settings = get_settings()
if not settings.effective_llm_api_key:
return None
sanitized = sanitize_resume_for_llm(text, hints or {})
if not sanitized.strip():
if not (text or "").strip():
return None
user = (
"请从下面的简历文本中提取结构化字段,只返回一个 JSON 对象(不要 Markdown 围栏,不要多余解释)。\n\n"
"字段与规则:\n"
'- name: 姓名(2-4 个中文字符)。姓名已被 [本地脱敏] 占位符替换时返回空字符串 ""。\n'
'- name: 姓名(2-4 个中文字符,如"薛庆霞");没有则返回 ""。\n'
'- jobTitle: 求职意向/应聘岗位,只能是简历中明确写出的岗位名(如"销售支持专员");没有明确写出时返回 "",'
"不要根据教育/技能内容推断岗位。\n"
'- phone: 手机号。已被 [手机号已本地脱敏] 替换或没有时返回 ""。\n'
'- email: 邮箱。已被 [邮箱已本地脱敏] 替换或没有时返回 ""。\n'
'- birthDate: 出生日期,格式 YYYY-MM-DD。已被 [出生年月已本地脱敏] 替换或没有时返回 ""。\n'
'- age: 年龄数字(如 28)。已被 [年龄已本地脱敏] 替换或没有时返回 ""。\n'
'- phone: 手机号(11 位,可含 +86/空格/连字符,如 13812345678);没有则返回 ""。\n'
'- email: 邮箱;没有则返回 ""。\n'
'- birthDate: 出生日期,格式 YYYY-MM-DD;没有则返回 ""。\n'
'- age: 年龄数字(如 28);没有则返回 ""。\n'
'- years: 工作年限,如 "5年" / "3.5年" / "应届生";简历没有明确写工作年限时返回 "",不要从无关时间推算。\n'
"- education: 学历,只能是 博士/硕士/本科/大专/中专/高中/其他 之一;没有明确信息返回 \"\"\n"
'- school: 毕业院校全称(如"甘肃农业大学");没有则返回 ""。\n'
......@@ -265,8 +264,8 @@ async def llm_resume_fields(text: str, hints: dict[str, Any] | None = None) -> d
'独立专业名;没有明确专业返回 ""。\n'
'- city: 现居城市或简历中的城市名(如"太原",不含"市");没有则返回 ""。\n'
"- skills: 技能数组,最多 12 项,只取简历中明确提到的技能。\n\n"
"要求:只提取简历中明确出现的信息,绝不编造或猜测;已被脱敏占位符替换的字段一律返回空字符串/空数组\n\n"
f"简历文本:\n{sanitized[:16000]}"
"要求:只提取简历中明确出现的信息,绝不编造或猜测。\n\n"
f"简历文本:\n{(text or '')[:16000]}"
)
try:
parsed = await _chat_json(
......
......@@ -4,7 +4,6 @@ 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 (
extract_pii_fields,
infer_source,
parse_resume,
parse_resume_text,
......@@ -12,11 +11,6 @@ from backend.app.services.resume_parser_core import (
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:
"""纯正则解析入口(无 LLM 时的兜底/调试路径)。"""
......@@ -33,19 +27,15 @@ def parse_uploaded_bytes(content: bytes, original_name: str = "resume.bin") -> d
async def parse_resume_path_async(path: Path) -> dict:
"""LLM 优先、正则兜底的简历解析入口(API/CLI 均走这里)。
读取简历文本后,先把内容交给大模型理解并提取结构化字段;只有大模型不可用
或提取失败时才整体回退到纯正则解析(parse_resume_text)。姓名/手机/邮箱/
出生年月/年龄/城市等 PII 发往大模型前必须先本地脱敏(见
llm.sanitize_resume_for_llm),因此这类字段始终以 extract_pii_fields 的
正则预扫描结果为准,不采用大模型返回值。
读取简历文本后直接把原文交给大模型理解并提取全部结构化字段(不做本地脱敏);
只有大模型不可用或提取失败时才整体回退到纯正则解析(parse_resume_text)。
"""
text = read_resume_text(path)
filename = path.name
pii = extract_pii_fields(text, filename)
llm_result = await llm_resume_fields(text, pii)
llm_result = await llm_resume_fields(text)
if not _has_llm_content(llm_result):
return parse_resume_text(text, filename)
return _compose_llm_result(text, filename, pii, llm_result)
return _compose_llm_result(text, filename, llm_result)
async def parse_uploaded_bytes_async(content: bytes, original_name: str = "resume.bin") -> dict:
......@@ -56,10 +46,10 @@ async def parse_uploaded_bytes_async(content: bytes, original_name: str = "resum
def _has_llm_content(result: dict | None) -> bool:
"""大模型是否提取到可用内容字段;返回空字典/全空时视为提取失败,回退正则。"""
"""大模型是否返回了可用字段;返回空字典/全空时视为提取失败,回退正则。"""
if not isinstance(result, dict):
return False
for key in _LLM_CONTENT_KEYS:
for key in LLM_RESUME_FIELDS:
value = result.get(key)
if isinstance(value, list):
if any(str(item).strip() for item in value):
......@@ -77,8 +67,8 @@ def _clean_text_value(value: object) -> str:
return str(value).strip()
def _compose_llm_result(text: str, filename: str, pii: dict, llm_result: dict) -> dict:
"""大模型提取成功时的结果组装:PII 取正则预扫描值,其余内容字段取大模型值。"""
def _compose_llm_result(text: str, filename: str, llm_result: dict) -> dict:
"""大模型提取成功时的结果组装:结构化字段全部采用大模型返回值。"""
school = _clean_text_value(llm_result.get("school"))
skills = [
str(item).strip()
......@@ -86,15 +76,21 @@ def _compose_llm_result(text: str, filename: str, pii: dict, llm_result: dict) -
if str(item).strip()
]
return {
**pii,
"name": _clean_text_value(llm_result.get("name")),
"jobTitle": _clean_text_value(llm_result.get("jobTitle")),
"phone": _clean_text_value(llm_result.get("phone")),
"email": _clean_text_value(llm_result.get("email")),
"birthDate": _clean_text_value(llm_result.get("birthDate")),
"age": _clean_text_value(llm_result.get("age")),
"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),
"city": _clean_text_value(llm_result.get("city")),
"skills": list(dict.fromkeys(skills)),
"source": infer_source(text, filename),
"resumeText": text,
"resumeName": filename,
"textLength": len(text),
}
......@@ -432,7 +432,7 @@ def school_tags(school: str):
def read_resume_text(path: Path) -> str:
"""读取 PDF/DOCX/TXT 简历文本并做统一规范化,供 LLM 优先解析与正则解析共用。"""
"""按扩展名读取 PDF/DOCX/TXT 简历原文(不做 normalize),供 LLM 优先解析与正则解析共用。"""
suffix = path.suffix.lower()
if suffix == ".pdf":
text = read_pdf(path)
......@@ -440,16 +440,14 @@ def read_resume_text(path: Path) -> str:
text = read_docx(path)
else:
text = read_text(path)
return normalize(text)
return text
def extract_pii_fields(text: str, filename: str) -> dict:
"""正则预扫描候选人 PII 字段(脱敏依据,不随正文发往大模型)
"""正则提取姓名/手机/邮箱/出生年月/年龄等基础字段,供纯正则解析输出
姓名/手机/邮箱/出生年月/年龄/城市 等敏感字段在解析链路中始终以本地正则
结果为准:发送给大模型前必须先把它们替换为占位符(见
llm.sanitize_resume_for_llm),大模型看不到真实值,最终取值也以这里的
正则结果为准。
说明:简历解析的大模型路径已改为直接把原文交给大模型、不做本地脱敏,
本函数仅服务于 parse_resume_text 的正则兜底结果。
"""
phone = first_match([
r"((?:\+?86[-\s]?)?1[3-9]\d[-\s]?\d{4}[-\s]?\d{4})",
......@@ -463,7 +461,6 @@ def extract_pii_fields(text: str, filename: str) -> dict:
"email": email,
"birthDate": birth_date,
"age": infer_explicit_age(text) or age_from_birth_date(birth_date),
"city": first_match([r"(?:现居|所在地|城市|地点)[::\s]+([一-龥]{2,12})"], text),
"resumeName": filename,
}
......@@ -480,6 +477,7 @@ def parse_resume_text(text: str, filename: str) -> dict:
"school": school,
"major": infer_major(text),
"schoolTags": school_tags(school),
"city": first_match([r"(?:现居|所在地|城市|地点)[::\s]+([一-龥]{2,12})"], text),
"source": infer_source(text, filename),
"skills": extract_skills(text),
"resumeText": text,
......
......@@ -23,7 +23,7 @@ def stub_llm(monkeypatch):
"""把 parse_resume_path_async 引用的 llm_resume_fields 替换为固定返回的异步桩。"""
def install(result):
async def fake_llm(text, hints=None):
async def fake_llm(text):
return result
monkeypatch.setattr("backend.app.services.resume_parser.llm_resume_fields", fake_llm)
......@@ -31,7 +31,7 @@ def stub_llm(monkeypatch):
return install
def _write_resume(tmp_path, name: str = "张晓燕.txt") -> str:
def _write_resume(tmp_path, name: str = "张晓燕.txt") -> None:
resume = tmp_path / name
resume.write_text(
"张晓燕\n性别:女年龄:28\n电话:18734915261 邮箱:1822742034@qq.com\n销售支持专员\n教育经历\n"
......@@ -39,82 +39,65 @@ def _write_resume(tmp_path, name: str = "张晓燕.txt") -> str:
"主修课程:市场营销、管理学原理、人力资源管理、企业经营战略、财务管理、会计学原理、区域经济学",
encoding="utf-8",
)
return str(resume)
def test_compose_llm_result_keeps_regex_pii_and_uses_llm_content():
pii = {
def test_compose_llm_result_uses_llm_values_for_all_fields():
llm = {
"name": "张晓燕",
"jobTitle": "销售支持专员",
"phone": "18734915261",
"email": "1822742034@qq.com",
"birthDate": "",
"birthDate": "1995-04-30",
"age": "28",
"city": "",
"resumeName": "张晓燕.txt",
}
llm = {
"name": "大模型幻觉姓名",
"phone": "13900000000",
"email": "fake@llm.com",
"birthDate": "1990-01-01",
"age": "35",
"city": "太原",
"jobTitle": "销售支持专员",
"years": "2年",
"years": "3年",
"education": "本科",
"school": "甘肃农业大学",
"major": "农林经济管理",
"city": "太原",
"skills": ["客户管理", "客户管理"],
}
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"] == "农林经济管理"
text = "简历原文"
merged = _compose_llm_result(text, "张晓燕.txt", llm)
for key in ("name", "jobTitle", "phone", "email", "birthDate", "age", "years", "education", "school", "major", "city"):
assert merged[key] == llm[key]
assert merged["skills"] == ["客户管理"]
assert merged["schoolTags"] == ["非985/211"]
assert merged["resumeText"] == text
assert merged["resumeName"] == "张晓燕.txt"
assert merged["textLength"] == len(text)
def test_async_parse_prefers_llm_content_and_keeps_regex_pii(tmp_path, stub_llm):
def test_async_parse_uses_llm_extraction_without_sanitize(tmp_path, stub_llm):
"""大模型可用时直接采用其提取结果(含姓名/手机等字段),不本地脱敏、不回退正则。"""
_write_resume(tmp_path)
stub_llm(
{
"name": "大模型幻觉姓名",
"name": "张晓燕",
"jobTitle": "销售支持专员",
"phone": "13900000000",
"email": "",
"phone": "18734915261",
"email": "1822742034@qq.com",
"birthDate": "",
"age": "",
"years": "2年",
"age": "28",
"years": "3年",
"education": "本科",
"school": "甘肃农业大学",
"major": "农林经济管理",
"city": "太原",
"skills": ["客户管理"],
"skills": ["客户管理", "市场营销"],
}
)
parsed = asyncio.run(parse_resume_path_async(tmp_path / "张晓燕.txt"))
assert parsed["name"] == "张晓燕"
assert parsed["phone"] == "18734915261"
assert parsed["email"] == "1822742034@qq.com"
assert parsed["jobTitle"] == "销售支持专员"
assert parsed["major"] == "农林经济管理"
assert parsed["years"] == "2年"
assert parsed["school"] == "甘肃农业大学"
assert parsed["education"] == "本科"
assert parsed["city"] == ""
assert parsed["skills"] == ["客户管理"]
assert parsed["years"] == "3年"
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):
......@@ -125,7 +108,7 @@ def test_async_parse_skips_regex_when_llm_usable(tmp_path, stub_llm, monkeypatch
monkeypatch.setattr("backend.app.services.resume_parser.parse_resume_text", boom)
_write_resume(tmp_path)
stub_llm({"jobTitle": "销售支持专员", "school": "甘肃农业大学", "major": "农林经济管理"})
stub_llm({"name": "张晓燕", "jobTitle": "销售支持专员", "school": "甘肃农业大学", "major": "农林经济管理"})
parsed = asyncio.run(parse_resume_path_async(tmp_path / "张晓燕.txt"))
assert parsed["name"] == "张晓燕"
assert parsed["major"] == "农林经济管理"
......@@ -145,7 +128,7 @@ def test_async_parse_falls_back_to_regex_when_llm_returns_empty(tmp_path, stub_l
def test_llm_resume_fields_returns_none_without_key(llm_disabled):
assert asyncio.run(llm_resume_fields("姓名:张三", {})) is None
assert asyncio.run(llm_resume_fields("姓名:张三")) is None
def test_async_parse_falls_back_without_llm(tmp_path, 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