Merge pull request '信封补齐、适当性矩阵修正与 Worker 失败原因落库' (#5) from qyqy_develop_1 into qyqy_develop
合并 qyqy_develop_1 第三轮工作:meta 信封补齐与公共化、适当性按第十二条匹配矩阵、Worker 失败原因区分固定文案与异常消息。
This commit was merged in pull request #5.
This commit is contained in:
@@ -108,14 +108,20 @@ async def test_messages_are_scoped_to_current_user(monkeypatch: pytest.MonkeyPat
|
||||
|
||||
# user_id 必须透传:Repository 靠它做归属过滤,漏传就等于不限范围。
|
||||
assert captured["user_id"] == 9001
|
||||
assert captured["limit"] == 20
|
||||
# 21 = limit + 1:service 多取一行判断"还有没有更旧的",用来填 §3.3 的 has_more。
|
||||
# 只看"取满没取满"会把恰好等于 limit 的最后一页说成还有下一页。
|
||||
assert captured["limit"] == 21
|
||||
assert captured["session_id"] == "session-1"
|
||||
# 不带游标时必须传 None,行为与加游标前一致(取最新一页)。
|
||||
assert captured["before"] is None
|
||||
assert result["data"] == [
|
||||
# service 返回内部结构,Controller 用 list_envelope 拆成 {data, meta}(§3.3)。
|
||||
# 一行数据小于 limit,所以没有下一页。
|
||||
assert result["items"] == [
|
||||
{"message_id": "11", "role": "user", "content": "稳健型",
|
||||
"created_at": NOW.isoformat() + "Z"}
|
||||
]
|
||||
assert result["has_more"] is False
|
||||
assert result["next_cursor"] is None
|
||||
|
||||
|
||||
async def test_messages_forward_cursor_to_repository(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -156,7 +162,7 @@ async def test_feedback_writes_feedback_and_audit_with_trace_id(
|
||||
|
||||
result = await ConversationService(session).feedback(11, CONTEXT, 1, "rating", "很有帮助")
|
||||
|
||||
assert result["data"]["status"] == "open"
|
||||
assert result["status"] == "open"
|
||||
kinds = [type(item).__name__ for item in session.added]
|
||||
assert kinds == ["ConversationFeedback", "InteractionAudit"]
|
||||
audit = session.added[1]
|
||||
|
||||
@@ -63,6 +63,45 @@ def test_disclosure_and_recording_are_disclosed_when_required() -> None:
|
||||
assert "双录" in text
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_rejection_does_not_assert_a_reason() -> None:
|
||||
"""拒绝的原因可能是等级不匹配、测评过期或未测评,不能一律说成"超出承受能力"。"""
|
||||
text = CustomerServiceAgent._suitability_text(
|
||||
|
||||
@@ -101,14 +101,57 @@ def test_caller_cannot_declare_risk_facts(forged: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
async def test_insufficient_authority_level_is_denied() -> None:
|
||||
"""低两个等级及以上仍必须拒绝(第十四条第 2、3 款,矩阵里的"❌ 禁止")。"""
|
||||
decision = await service_with_row(authority_row(investor_type="C1")).evaluate(
|
||||
query(product_risk_level=2), context(), now=NOW
|
||||
query(product_risk_level=3), context(), now=NOW
|
||||
)
|
||||
assert decision.allowed is False
|
||||
assert decision.reason_code == "RISK_LEVEL_MISMATCH"
|
||||
assert decision.requires_recording is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("investor_type", "product_level", "allowed", "reason_code"),
|
||||
[
|
||||
# 逐格抄自 knowledge/policy/个人投资者适当性管理指南.md 第十二条矩阵。
|
||||
# 这张表的价值在于:任何一格被改动,都必须是有意为之并在此处说明理由。
|
||||
("C1", 1, True, "SUITABLE"), ("C1", 2, True, "SUITABLE"),
|
||||
("C1", 3, False, "RISK_LEVEL_MISMATCH"), ("C1", 4, False, "RISK_LEVEL_MISMATCH"),
|
||||
("C1", 5, False, "RISK_LEVEL_MISMATCH"),
|
||||
("C2", 1, True, "SUITABLE"), ("C2", 2, True, "SUITABLE"), ("C2", 3, True, "SUITABLE"),
|
||||
("C2", 4, False, "RISK_LEVEL_MISMATCH"), ("C2", 5, False, "RISK_LEVEL_MISMATCH"),
|
||||
("C3", 1, True, "SUITABLE"), ("C3", 2, True, "SUITABLE"), ("C3", 3, True, "SUITABLE"),
|
||||
("C3", 4, True, "SUITABLE_WITH_DISCLOSURE"), ("C3", 5, False, "RISK_LEVEL_MISMATCH"),
|
||||
("C4", 1, True, "SUITABLE"), ("C4", 2, True, "SUITABLE"), ("C4", 3, True, "SUITABLE"),
|
||||
("C4", 4, True, "SUITABLE"), ("C4", 5, True, "SUITABLE_WITH_DISCLOSURE"),
|
||||
("C5", 1, True, "SUITABLE"), ("C5", 2, True, "SUITABLE"), ("C5", 3, True, "SUITABLE"),
|
||||
("C5", 4, True, "SUITABLE"), ("C5", 5, True, "SUITABLE"),
|
||||
],
|
||||
)
|
||||
async def test_full_matrix_matches_policy_document(
|
||||
investor_type: str, product_level: int, allowed: bool, reason_code: str
|
||||
) -> None:
|
||||
"""客服回答必须与知识库里的矩阵一致 —— 这是同一个 Agent 的两条出口。"""
|
||||
decision = await service_with_row(authority_row(investor_type=investor_type)).evaluate(
|
||||
query(product_risk_level=product_level), context(), now=NOW
|
||||
)
|
||||
assert decision.allowed is allowed
|
||||
assert decision.reason_code == reason_code
|
||||
|
||||
|
||||
async def test_disclosure_tier_always_requires_disclosure_and_recording() -> None:
|
||||
"""C3→R4、C4→R5 是第十五条豁免档:可买,但必须揭示、确认、录音。"""
|
||||
for investor_type, product_level in (("C3", 4), ("C4", 5)):
|
||||
decision = await service_with_row(authority_row(investor_type=investor_type)).evaluate(
|
||||
query(product_risk_level=product_level), context(), now=NOW
|
||||
)
|
||||
assert decision.allowed is True
|
||||
assert decision.reason_code == "SUITABLE_WITH_DISCLOSURE"
|
||||
assert decision.required_disclosure is True
|
||||
assert decision.requires_confirmation is True
|
||||
assert decision.requires_recording is True
|
||||
|
||||
|
||||
async def test_expired_assessment_is_denied_even_for_eligible_level() -> None:
|
||||
row = authority_row(investor_type="C5", valid_until=NOW - timedelta(seconds=1))
|
||||
decision = await service_with_row(row).evaluate(query(product_risk_level=1), context(), now=NOW)
|
||||
|
||||
Reference in New Issue
Block a user