Files
resume-agent/backend/tests/test_chat_intent_rescue.py
T

179 lines
8.3 KiB
Python

"""LLM rescue for messages the keyword routing drops to the generic fallback (问题1b)."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
from app.builder_conversation.rescue import llm_intent_rescue
from app.chat_intents import ChatTurnClassification
from app.settings import Settings
from builder_flow_helpers import confirm_card, create_builder_session, finish_education, send_message, start_education
from test_api import active_component
RESUME = {
"sections": [
{
"kind": "project_experience",
"items": [{"id": "e1", "project_name": "AI Career Copilot", "description": "全栈求职助手平台。"}],
}
]
}
class _StubClassifier:
def __init__(self, result: ChatTurnClassification | None = None, exc: Exception | None = None) -> None:
self.result = result
self.exc = exc
def classify(self, message: str, *, state_summary: dict[str, Any]) -> ChatTurnClassification:
if self.exc:
raise self.exc
assert self.result is not None
return self.result
def _agent(classifier: Any) -> Any:
return SimpleNamespace(expander=SimpleNamespace(expand=lambda entry, *, context: {}), _chat_intent_classifier=classifier)
def _components(transition: Any) -> list[dict[str, Any]]:
return [block["data"] for block in transition.turn["blocks"] if block.get("type") == "component"]
def test_rescue_off_mode_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"app.builder_conversation.rescue.load_settings",
lambda: Settings(llm_provider="rule", intent_router_mode="off"),
)
agent = SimpleNamespace()
assert llm_intent_rescue(agent, {"job_type": "campus"}, "帮我重新优化描述", RESUME) is None
def test_rescue_revise_regenerates_candidate_card() -> None:
agent = _agent(_StubClassifier(ChatTurnClassification(
intent="revise_proposal", confidence=0.9,
target_entry_hint="AI Career Copilot", revision_instruction="重新优化",
)))
profile: dict[str, Any] = {"job_type": "campus"}
transition = llm_intent_rescue(agent, profile, "帮我重新优化AI Career Copilot描述内容", RESUME)
assert transition is not None
card = next(data for data in _components(transition) if data.get("component_name") == "ExperienceConfirmCard")
assert card["ai_proposal"]["optimized_description"] == "全栈求职助手平台。"
state = transition.profile["builder"]
assert state["editing_entry_id"] == "e1"
assert state["pending_entry"]["_proposal"]
def test_rescue_edit_entry_begins_edit_flow() -> None:
agent = _agent(_StubClassifier(ChatTurnClassification(
intent="edit_entry", confidence=0.8, target_entry_hint="AI Career Copilot",
)))
transition = llm_intent_rescue(agent, {"job_type": "campus"}, "帮我改下AI Career Copilot这段", RESUME)
assert transition is not None
assert "我找到了这段" in transition.turn["content"]
assert transition.profile["builder"]["editing_entry_id"] == "e1"
def test_rescue_edit_prefers_named_section_over_recent_entry() -> None:
"""点名板块的修改必须落到该板块条目,而不是最近确认条目(教育→校园 错位根因)。"""
resume = {
"sections": [
{"kind": "campus_experience", "items": [{"id": "c1", "organization": "学生会", "description": "招新宣传。"}]},
{"kind": "education", "items": [{"id": "e9", "school": "Example University", "description": "主修课程。"}]},
]
}
agent = _agent(_StubClassifier(ChatTurnClassification(
intent="edit_entry", confidence=0.9, target_section="education",
)))
profile: dict[str, Any] = {"job_type": "campus", "builder": {"last_confirmed_entry": {"entry_id": "c1"}}}
transition = llm_intent_rescue(agent, profile, "帮我重新优化教育经历", resume)
assert transition is not None
assert transition.profile["builder"]["editing_entry_id"] == "e9"
def test_rescue_edit_derives_section_from_message_when_classifier_omits_it() -> None:
"""分类器没给 target_section 时,消息里的板块名必须确定性生效(项目→教育 错位根因)。"""
resume = {
"sections": [
{"kind": "education", "items": [{"id": "e9", "school": "Example University", "description": "主修课程。"}]},
{"kind": "project_experience", "items": [{"id": "p1", "project_name": "AI Career Copilot", "description": "全栈平台。"}]},
]
}
agent = _agent(_StubClassifier(ChatTurnClassification(intent="edit_entry", confidence=0.9)))
profile: dict[str, Any] = {"job_type": "campus", "builder": {"last_confirmed_entry": {"entry_id": "e9"}}}
transition = llm_intent_rescue(agent, profile, "帮我重新优化项目经历", resume)
assert transition is not None
assert transition.profile["builder"]["editing_entry_id"] == "p1"
def test_rescue_edit_normalizes_chinese_section_label() -> None:
"""分类器把 target_section 填成中文板块名时,先归一化到内部 kind 再定位。"""
resume = {
"sections": [
{"kind": "education", "items": [{"id": "e9", "school": "Example University", "description": "主修课程。"}]},
{"kind": "project_experience", "items": [{"id": "p1", "project_name": "AI Career Copilot", "description": "全栈平台。"}]},
]
}
agent = _agent(_StubClassifier(ChatTurnClassification(
intent="edit_entry", confidence=0.9, target_section="项目经历",
)))
profile: dict[str, Any] = {"job_type": "campus", "builder": {"last_confirmed_entry": {"entry_id": "e9"}}}
transition = llm_intent_rescue(agent, profile, "帮我重新优化项目经历", resume)
assert transition is not None
assert transition.profile["builder"]["editing_entry_id"] == "p1"
def test_rescue_new_entry_offers_section_card() -> None:
agent = _agent(_StubClassifier(ChatTurnClassification(
intent="new_entry", confidence=0.8, target_section="internship_experience",
)))
transition = llm_intent_rescue(agent, {"job_type": "campus"}, "我还想补一段实习", RESUME)
assert transition is not None
assert "实习经历" in transition.turn["content"]
assert any(data.get("component_name") == "RecordFields" for data in _components(transition))
def test_rescue_declines_low_confidence_and_failures() -> None:
low = _agent(_StubClassifier(ChatTurnClassification(intent="edit_entry", confidence=0.4, target_entry_hint="AI Career Copilot")))
assert llm_intent_rescue(low, {"job_type": "campus"}, "改下AI Career Copilot", RESUME) is None
failing = _agent(_StubClassifier(exc=RuntimeError("boom")))
assert llm_intent_rescue(failing, {"job_type": "campus"}, "随便一句", RESUME) is None
unknown = _agent(_StubClassifier(ChatTurnClassification(intent="unclear", confidence=0.9)))
assert llm_intent_rescue(unknown, {"job_type": "campus"}, "嗯", RESUME) is None
def test_bottom_fallback_unchanged_without_opt_in(client: Any) -> None:
session_id, body = create_builder_session(client)
card = start_education(client, session_id, body)
proposal = finish_education(client, session_id, card, "完成数据库课程项目。GPA: 4.3/5.0。")
confirm_card(client, session_id, proposal)
reply = send_message(client, session_id, "帮我重新优化Example University这段经历的描述")
assert reply["turn"]["content"].startswith("可以。")
def test_bottom_fallback_rescued_by_llm_classifier(client: Any, monkeypatch: pytest.MonkeyPatch) -> None:
agent = client.app.state.resume_agent
stub = _StubClassifier(ChatTurnClassification(
intent="revise_proposal", confidence=0.92,
target_entry_hint="Example University", revision_instruction="重新优化描述",
))
monkeypatch.setattr(agent, "_chat_intent_classifier", stub, raising=False)
session_id, body = create_builder_session(client)
card = start_education(client, session_id, body)
proposal = finish_education(client, session_id, card, "完成数据库课程项目。GPA: 4.3/5.0。")
confirm_card(client, session_id, proposal)
reply = send_message(client, session_id, "帮我重新优化Example University这段经历的描述")
assert active_component(reply)["data"]["component"] == "experience_confirm_card"
assert "重新" in reply["turn"]["content"]