2026-09-10 22:43:18 +08:00
|
|
|
"""客服适当性出口的单元测试。
|
|
|
|
|
|
|
|
|
|
这个出口会给出「能不能买」的结论,所以每条分支都必须锁住——写错一条就是误导客户。
|
|
|
|
|
用例里的数字取自真实链路:南方季季盈90天是 R2,来自产品手册的「风险等级」行。
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from typing import Any, cast
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from app.core.contracts import AgentRequest, ConversationTurn, RequestContext
|
|
|
|
|
from app.service.agent.implementations.customer_service import CustomerServiceAgent
|
|
|
|
|
|
|
|
|
|
FALLBACK = "抱歉,这个问题我暂时无法给出准确答复。建议您拨打客服热线 400-XXX-XXXX 转人工客服咨询。"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _decision(**overrides: Any) -> dict[str, Any]:
|
|
|
|
|
base: dict[str, Any] = {
|
|
|
|
|
"allowed": True, "reason_code": "MATCHED", "required_disclosure": False,
|
|
|
|
|
"requires_confirmation": False, "requires_recording": False,
|
|
|
|
|
"customer_risk_level": 2, "assessment_valid_until": None,
|
|
|
|
|
}
|
|
|
|
|
base.update(overrides)
|
|
|
|
|
return base
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _agent() -> CustomerServiceAgent:
|
|
|
|
|
"""跳过 __init__ 造实例:本组用例只碰话术与 call_tool 替身。"""
|
|
|
|
|
return object.__new__(CustomerServiceAgent)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _request(*history: tuple[str, str]) -> AgentRequest:
|
|
|
|
|
turns = tuple(
|
|
|
|
|
ConversationTurn(role=cast(Any, role), content=content) for role, content in history
|
|
|
|
|
)
|
|
|
|
|
return AgentRequest(
|
|
|
|
|
agent_type="customer_service", session_id="s", message="c1客户能买它吗",
|
|
|
|
|
idempotency_key="1234567890123456", history=turns,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _hit(title: str, content: str) -> dict[str, Any]:
|
|
|
|
|
return {"doc_id": "PROD-007-03", "title": title, "content": content, "score": 0.9}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 话术 ----
|
|
|
|
|
|
|
|
|
|
def test_allowed_reply_names_product_level_and_customer_level() -> None:
|
|
|
|
|
text = CustomerServiceAgent._suitability_text("南方季季盈90天", 2, _decision())
|
|
|
|
|
|
|
|
|
|
assert "南方季季盈90天" in text
|
|
|
|
|
assert "R2(中低风险)" in text
|
|
|
|
|
assert "可以购买" in text
|
|
|
|
|
assert "C2" in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_disclosure_and_recording_are_disclosed_when_required() -> None:
|
|
|
|
|
text = CustomerServiceAgent._suitability_text(
|
|
|
|
|
"南方稳健增利180天", 4, _decision(required_disclosure=True, requires_recording=True)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert "风险揭示书" in text
|
|
|
|
|
assert "双录" in text
|
|
|
|
|
|
|
|
|
|
|
2026-09-11 14:47:08 +08:00
|
|
|
def test_one_level_up_is_not_described_as_within_capacity() -> None:
|
|
|
|
|
"""C1 买 R2 是矩阵允许的,但不能说成"在您的风险承受能力范围内"——它超出了等级。
|
|
|
|
|
|
|
|
|
|
这句错话会让客户以为自己的测评等级本来就覆盖 R2,下次买 R3 时就更难解释为什么不行。
|
|
|
|
|
"""
|
|
|
|
|
text = CustomerServiceAgent._suitability_text(
|
|
|
|
|
"南方季季盈90天", 2, _decision(customer_risk_level=1)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert "在您的风险承受能力范围内" not in text
|
|
|
|
|
assert "匹配矩阵允许购买的范围内" in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_disclosure_tier_explains_the_condition() -> None:
|
|
|
|
|
"""C3 买 R4 属豁免档:要说清"高于您的等级"以及"签揭示书后可买"这个条件。"""
|
|
|
|
|
text = CustomerServiceAgent._suitability_text(
|
|
|
|
|
"南方稳健增利180天",
|
|
|
|
|
4,
|
|
|
|
|
_decision(
|
|
|
|
|
customer_risk_level=3,
|
|
|
|
|
reason_code="SUITABLE_WITH_DISCLOSURE",
|
|
|
|
|
required_disclosure=True,
|
|
|
|
|
requires_recording=True,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert "高于您的风险承受能力等级" in text
|
|
|
|
|
assert "签署产品风险揭示书后可以购买" in text
|
|
|
|
|
assert "在您的风险承受能力范围内" not in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_same_level_still_uses_the_capacity_wording() -> None:
|
|
|
|
|
text = CustomerServiceAgent._suitability_text(
|
|
|
|
|
"南方季季盈90天", 2, _decision(customer_risk_level=2)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert "在您的风险承受能力范围内" in text
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 22:43:18 +08:00
|
|
|
def test_rejection_does_not_assert_a_reason() -> None:
|
|
|
|
|
"""拒绝的原因可能是等级不匹配、测评过期或未测评,不能一律说成"超出承受能力"。"""
|
|
|
|
|
text = CustomerServiceAgent._suitability_text(
|
|
|
|
|
"南方季季盈90天", 3, _decision(allowed=False)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert "暂时无法购买" in text
|
|
|
|
|
assert "超出" not in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_assessment_is_said_in_plain_words() -> None:
|
|
|
|
|
"""档案里没有测评结果时不能写"您的等级为未记录"——客户看不懂。"""
|
|
|
|
|
text = CustomerServiceAgent._suitability_text(
|
|
|
|
|
"南方季季盈90天", 2, _decision(allowed=False, customer_risk_level=None)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert "没有在有效期内的风险测评结果" in text
|
|
|
|
|
assert "未记录" not in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validity_is_shortened_to_a_plain_date() -> None:
|
|
|
|
|
text = CustomerServiceAgent._suitability_text(
|
|
|
|
|
"南方季季盈90天", 2, _decision(assessment_valid_until="2027-01-15T00:00:00+00:00")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert "2027-01-15" in text
|
|
|
|
|
assert "T00:00" not in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unknown_level_falls_back_to_the_code() -> None:
|
|
|
|
|
text = CustomerServiceAgent._suitability_text("某产品", 9, _decision())
|
|
|
|
|
|
|
|
|
|
assert "R9" in text
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 22:48:56 +08:00
|
|
|
# ---- 客户自述等级 ----
|
|
|
|
|
|
|
|
|
|
def test_self_claimed_level_is_detected_case_insensitively() -> None:
|
|
|
|
|
"""客户自述等级只用来在回答里说明判断依据,绝不参与裁决。"""
|
|
|
|
|
assert CustomerServiceAgent._self_claimed_level("C3 客户能买它吗") == "C3"
|
|
|
|
|
assert CustomerServiceAgent._self_claimed_level("我是c1客户,能买吗") == "C1"
|
|
|
|
|
assert CustomerServiceAgent._self_claimed_level("那我能买它吗") == ""
|
|
|
|
|
|
|
|
|
|
|
2026-09-10 22:43:18 +08:00
|
|
|
# ---- 产品名来源 ----
|
|
|
|
|
|
|
|
|
|
def test_previous_topic_takes_the_subject_of_the_last_answer() -> None:
|
|
|
|
|
topic = CustomerServiceAgent._previous_topic(_request(
|
|
|
|
|
("user", "季季盈90天起投多少"),
|
|
|
|
|
("assistant", "南方季季盈90天:起投金额 1万元"),
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
assert topic == "南方季季盈90天"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_previous_topic_ignores_user_turns() -> None:
|
|
|
|
|
"""客户的问句不是可信的产品名来源,只能从回答里取。"""
|
|
|
|
|
assert CustomerServiceAgent._previous_topic(
|
|
|
|
|
_request(("user", "季季盈90天起投多少"))
|
|
|
|
|
) == ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_previous_topic_is_empty_after_a_fallback_answer() -> None:
|
|
|
|
|
"""上一轮是兜底话术时取不出主语,调用方据此转人工(而不是瞎猜一个产品)。"""
|
|
|
|
|
assert CustomerServiceAgent._previous_topic(
|
|
|
|
|
_request(("user", "随便问点什么"), ("assistant", FALLBACK))
|
|
|
|
|
) == ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- 产品风险等级 ----
|
|
|
|
|
|
|
|
|
|
async def _level(hits: list[dict[str, Any]]) -> int | None:
|
|
|
|
|
agent = _agent()
|
|
|
|
|
|
|
|
|
|
async def fake_call_tool(name: str, arguments: dict[str, Any], **kwargs: Any) -> Any:
|
|
|
|
|
return {"hits": hits, "degraded": False}
|
|
|
|
|
|
|
|
|
|
agent.call_tool = fake_call_tool # type: ignore[method-assign]
|
|
|
|
|
return await agent._product_risk_level(
|
|
|
|
|
"南方季季盈90天", RequestContext(user_id="9001", trace_id="t")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_level_is_read_from_the_risk_level_row() -> None:
|
|
|
|
|
level = await _level([
|
|
|
|
|
_hit("手册 · 2.1 南方季季盈90天 · 风险等级", "南方季季盈90天:风险等级 R2(中低风险)")
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
assert level == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_row_about_another_product_is_rejected() -> None:
|
|
|
|
|
"""命中别的产品的等级行时必须放弃,否则会拿别人的等级去给客户做裁决。"""
|
|
|
|
|
level = await _level([
|
|
|
|
|
_hit("手册 · 3.1 南方稳健增利180天 · 风险等级", "南方稳健增利180天:风险等级 R3(中风险)")
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
assert level is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_enumeration_row_is_rejected() -> None:
|
|
|
|
|
"""C1 的 FAQ 里写着"可购买 R1、R2"这种列举,那不是产品等级。"""
|
|
|
|
|
level = await _level([
|
|
|
|
|
_hit(
|
|
|
|
|
"C1 客户能买什么产品?",
|
|
|
|
|
"C1(保守型)客户可以购买 R1(低风险)、R2(中低风险)等级的产品。",
|
|
|
|
|
)
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
assert level is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_degraded_search_yields_no_level() -> None:
|
|
|
|
|
"""检索降级时不能返回一个等级——那会让裁决建立在"其实没查到"之上。"""
|
|
|
|
|
agent = _agent()
|
|
|
|
|
|
|
|
|
|
async def fake_call_tool(name: str, arguments: dict[str, Any], **kwargs: Any) -> Any:
|
|
|
|
|
return {"hits": [], "degraded": True, "reason": "search_failed"}
|
|
|
|
|
|
|
|
|
|
agent.call_tool = fake_call_tool # type: ignore[method-assign]
|
|
|
|
|
|
|
|
|
|
assert await agent._product_risk_level(
|
|
|
|
|
"南方季季盈90天", RequestContext(user_id="9001", trace_id="t")
|
|
|
|
|
) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_tool_failure_yields_no_level() -> None:
|
|
|
|
|
"""工具抛异常时返回 None(由调用方转人工),不能让异常冒到主链路。"""
|
|
|
|
|
agent = _agent()
|
|
|
|
|
|
|
|
|
|
async def fake_call_tool(name: str, arguments: dict[str, Any], **kwargs: Any) -> Any:
|
|
|
|
|
raise RuntimeError("milvus 挂了")
|
|
|
|
|
|
|
|
|
|
agent.call_tool = fake_call_tool # type: ignore[method-assign]
|
|
|
|
|
|
|
|
|
|
assert await agent._product_risk_level(
|
|
|
|
|
"南方季季盈90天", RequestContext(user_id="9001", trace_id="t")
|
|
|
|
|
) is None
|