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

fix(ai): LLM 直连超时/异常时回退本地结构化,不再 500

generate_jd_draft / analyze_resume / parse_pasted_jd / generate_v2_analysis 在调用 _chat_json
超时(如 'Request timed out.')时原先会直接抛异常,导致 JD 生成/简历分析/粘贴解析/富报告
报错。现在统一捕获 LLM 调用异常并回退本地结构化/规则/小节切分,补充超时回退单测。
parent cac52e1c
...@@ -159,7 +159,11 @@ async def generate_v2_analysis(candidate_data: dict[str, Any], job_data: dict[st ...@@ -159,7 +159,11 @@ async def generate_v2_analysis(candidate_data: dict[str, Any], job_data: dict[st
"""生成 v2 富报告对象 R(基础评估 + 富报告字段)。""" """生成 v2 富报告对象 R(基础评估 + 富报告字段)。"""
payload = {"candidate": candidate_data, "job": job_data} payload = {"candidate": candidate_data, "job": job_data}
base = await analyze_resume(payload) base = await analyze_resume(payload)
enriched = await llm_v2_fields(payload, base) try:
enriched = await llm_v2_fields(payload, base)
except Exception:
# LLM 直连超时/异常(如 "Request timed out.")时回退本地富报告字段,不 500。
enriched = None
if enriched is None: if enriched is None:
enriched = local_v2_fields(base, job_data, job_criteria(job_data)) enriched = local_v2_fields(base, job_data, job_criteria(job_data))
result = {**base, **enriched} result = {**base, **enriched}
......
...@@ -302,7 +302,11 @@ async def _parse_pasted_jd_llm(text: str, settings: Any) -> dict[str, Any] | Non ...@@ -302,7 +302,11 @@ async def _parse_pasted_jd_llm(text: str, settings: Any) -> dict[str, Any] | Non
async def parse_pasted_jd(text: str) -> dict[str, Any]: async def parse_pasted_jd(text: str) -> dict[str, Any]:
"""粘贴 JD 结构化解析:LLM 优先,失败 / 无 Key 回退本地小节切分。""" """粘贴 JD 结构化解析:LLM 优先,失败 / 无 Key 回退本地小节切分。"""
settings = get_settings() settings = get_settings()
parsed = await _parse_pasted_jd_llm(text, settings) try:
parsed = await _parse_pasted_jd_llm(text, settings)
except Exception:
# LLM 直连超时/异常(如 "Request timed out.")时回退本地小节切分,不 500。
parsed = None
if parsed is None: if parsed is None:
parsed = parse_pasted_jd_local(text) parsed = parse_pasted_jd_local(text)
return parsed return parsed
...@@ -340,7 +340,12 @@ async def llm_resume_analysis(payload: dict[str, Any]) -> dict[str, Any] | None: ...@@ -340,7 +340,12 @@ async def llm_resume_analysis(payload: dict[str, Any]) -> dict[str, Any] | None:
async def analyze_resume(payload: dict[str, Any]) -> dict[str, Any]: async def analyze_resume(payload: dict[str, Any]) -> dict[str, Any]:
return await llm_resume_analysis(payload) or local_resume_analysis(payload) # LLM 直连超时/异常(如 "Request timed out.")时回退本地规则,不 500。
try:
llm_result = await llm_resume_analysis(payload)
except Exception:
llm_result = None
return llm_result or local_resume_analysis(payload)
def local_jd_draft(payload: dict[str, Any]) -> dict[str, Any]: def local_jd_draft(payload: dict[str, Any]) -> dict[str, Any]:
...@@ -481,4 +486,9 @@ async def generate_jd_draft(payload: dict[str, Any], *, force: bool = False) -> ...@@ -481,4 +486,9 @@ async def generate_jd_draft(payload: dict[str, Any], *, force: bool = False) ->
qwenpaw_draft = await qwenpaw_jd_draft(safe_payload, force=force) qwenpaw_draft = await qwenpaw_jd_draft(safe_payload, force=force)
if qwenpaw_draft: if qwenpaw_draft:
return qwenpaw_draft return qwenpaw_draft
return await llm_jd_draft(safe_payload) or local_jd_draft(payload) try:
llm_draft = await llm_jd_draft(safe_payload)
except Exception:
# LLM 直连超时/异常(如 "Request timed out.")时回退本地结构化,不 500。
llm_draft = None
return llm_draft or local_jd_draft(payload)
...@@ -333,3 +333,42 @@ def test_analysis_prompt_constrains_risks_to_fixed_dimensions(llm_enabled, monke ...@@ -333,3 +333,42 @@ def test_analysis_prompt_constrains_risks_to_fixed_dimensions(llm_enabled, monke
# 合并的固定维度画像也随报告返回 # 合并的固定维度画像也随报告返回
assert len(result["riskProfile"]) == 4 assert len(result["riskProfile"]) == 4
assert result["riskProfile"][0]["dimension"] == "jobHopping" assert result["riskProfile"][0]["dimension"] == "jobHopping"
def test_generate_jd_falls_back_to_local_on_llm_timeout(llm_enabled, monkeypatch):
"""LLM 直连超时/异常(如 "Request timed out.")时,JD 生成回退本地结构化,不 500。"""
async def fake_chat_json(settings, system, user, *, temperature=0): # noqa: ARG001
raise TimeoutError("Request timed out.")
monkeypatch.setattr("backend.app.services.llm._chat_json", fake_chat_json)
from backend.app.services.llm import generate_jd_draft
result = asyncio.run(
generate_jd_draft({"job": {"title": "销售经理", "department": "销售部"}, "mode": "generate"})
)
assert result["provider"] == "local-structured"
assert result["jd"]
assert result["responsibilities"]
def test_analyze_resume_falls_back_to_local_on_llm_timeout(llm_enabled, monkeypatch):
"""LLM 直连超时/异常(如 "Request timed out.")时,简历分析回退本地规则,不 500。"""
async def fake_chat_json(settings, system, user, *, temperature=0): # noqa: ARG001
raise TimeoutError("Request timed out.")
monkeypatch.setattr("backend.app.services.llm._chat_json", fake_chat_json)
from backend.app.services.llm import analyze_resume
result = asyncio.run(
analyze_resume(
{
"candidate": {"name": "王五", "resumeText": "10年销售经验,负责大客户拓展,业绩增长30%"},
"job": {"title": "大客户销售", "mustHave": "大客户", "matchKeywords": "沟通"},
}
)
)
assert result["provider"] == "local-structured"
assert result["score"] is not None
...@@ -212,3 +212,19 @@ def test_requirement_template_route(client): ...@@ -212,3 +212,19 @@ def test_requirement_template_route(client):
assert "售电交易员" in template assert "售电交易员" in template
assert "对内约束" in template assert "对内约束" in template
assert "粘贴 JD 自动结构化" in template assert "粘贴 JD 自动结构化" in template
def test_parse_pasted_jd_falls_back_to_local_on_llm_timeout(llm_enabled, monkeypatch):
"""LLM 直连超时/异常(如 "Request timed out.")时,粘贴 JD 解析回退本地小节切分,不 500。"""
async def fake_chat_json(settings, system, user, *, temperature=0): # noqa: ARG001
raise TimeoutError("Request timed out.")
monkeypatch.setattr("backend.app.services.jd_import._chat_json", fake_chat_json)
from backend.app.services.jd_import import parse_pasted_jd
result = asyncio.run(parse_pasted_jd(TEMPLATE_FILLED))
assert result["provider"] == "local-structured"
assert result["title"] == "大客户销售经理"
assert result["nonCompete"] == "需签"
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