"""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"]