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