generated from kgod/ai-review-template
自内部仓库剥离深度优化与 RAG 知识库后的交付版本: - Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强) - 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护 - 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏 - PostgreSQL 运行时 + Alembic 迁移链 Co-Authored-By: Claude <noreply@anthropic.com>
179 lines
8.3 KiB
Python
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"]
|