|
|
|
@@ -93,15 +93,20 @@ async def test_authority_risk_level_replaces_caller_supplied_level() -> None:
|
|
|
|
|
assert decision.risk_level_source == "fin_risk_assessment"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("forged", [{"customer_risk_level": 5}, {"professional_investor": True},
|
|
|
|
|
{"assessment_expires_at": "2099-01-01T00:00:00Z"}])
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"forged",
|
|
|
|
|
[
|
|
|
|
|
{"customer_risk_level": 5},
|
|
|
|
|
{"professional_investor": True},
|
|
|
|
|
{"assessment_expires_at": "2099-01-01T00:00:00Z"},
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def test_caller_cannot_declare_risk_facts(forged: dict[str, Any]) -> None:
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
query(**forged)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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=3), context(), now=NOW
|
|
|
|
|
)
|
|
|
|
@@ -113,25 +118,36 @@ async def test_insufficient_authority_level_is_denied() -> None:
|
|
|
|
|
@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", 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"),
|
|
|
|
|
("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
|
|
|
|
|
)
|
|
|
|
@@ -140,7 +156,6 @@ async def test_full_matrix_matches_policy_document(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
@@ -269,10 +284,9 @@ async def test_tool_handler_uses_same_service(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
added: list[Any] = []
|
|
|
|
|
sink = added
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"app.service.suitability_service.SessionFactory",
|
|
|
|
|
lambda: FakeSession(authority_row(investor_type="C1"), sink),
|
|
|
|
|
lambda: FakeSession(authority_row(investor_type="C1"), added),
|
|
|
|
|
)
|
|
|
|
|
result = await suitability_tool_handler(query(product_risk_level=5), context())
|
|
|
|
|
assert result["allowed"] is False
|
|
|
|
|