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:
2026-09-11 16:42:29 +08:00
18 changed files with 704 additions and 68 deletions
@@ -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(
+44 -1
View File
@@ -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)