Files
XingHuo/tests/test_risk_chat_tools.py
T

392 lines
16 KiB
Python
Raw Normal View History

"""C1 风控对话 Tool(chat_tools):四只读 Tool 的输出结构 / alert_id 溯源 /
全局 vs 客户维度 / 归属校验 / 入参白名单 / 空客户分支。
单测层:tool_service.run_tool 直调(sqlite 注入,统一注册表分发);仓储用
真实 RiskRepository(chat_tools 走 list_alerts 等原方法,落库走同一 sqlite,
不写真 MySQL)。越权双写复用 run_tool 既有口径(写本库 audit_log/input_guard_log)。
"""
from __future__ import annotations
import datetime as _dt
import pytest
from sqlalchemy import text
from _ddl import create_sqlite_engine
from langchain_core.messages import AIMessage
from sqlalchemy import text
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.repository.session_repository import SessionRepository
from app.service import agent_service, tool_service
ACTOR_CUSTOMER = {"actor_id": "CUST-9527", "roles": ["customer"], "token_type": "customer"}
ACTOR_ADVISOR = {"actor_id": "STAFF-10086", "roles": ["advisor"], "token_type": "staff"}
ACTOR_RISK = {"actor_id": "STAFF-30001", "roles": ["risk_officer"], "token_type": "staff"}
@pytest.fixture()
def risk_env(monkeypatch):
engine = create_sqlite_engine()
session_repo = SessionRepository(engine=engine)
core_ro = CoreReadOnlyRepository(engine=engine)
risk_repo = RiskRepository(engine=engine)
now = _dt.datetime.now()
yesterday = now - _dt.timedelta(days=1)
with engine.begin() as conn:
# 客户:CUST-9527 张三(C1 级,近期测评);CUST-1001 李四(C5 级)
conn.execute(
text("INSERT INTO core_customer (customer_id, display_name, age, occupation, is_active)"
" VALUES ('CUST-9527', '张三', 45, '工程师', 1),"
" ('CUST-1001', '李四', 60, '教师', 1)")
)
conn.execute(
text("INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at)"
" VALUES ('CUST-9527', 'C1', '2026-08-01 10:00:00'),"
" ('CUST-1001', 'C5', '2026-08-01 10:00:00')")
)
# 产品:P-001(R1 级,C1 可购);P-002(R5 级,C1 不可购)
conn.execute(
text("INSERT INTO core_product (product_id, product_name, min_risk_code, product_type)"
" VALUES ('P-001', '稳健一号', 'R1', 'fund'),"
" ('P-002', '高风权益', 'R5', 'fund')")
)
# advisor 归属:STAFF-10086 与 CUST-9527 active(CUST-1001 无归属,验证 NOT_ASSIGNED)
conn.execute(
text("INSERT INTO core_customer_advisor (advisor_id, customer_id, rel_status)"
" VALUES ('STAFF-10086', 'CUST-9527', 'active')")
)
# 预警:CUST-9527 今日 pending(ALERT-001)+ 昨日 pending(ALERT-002)
conn.execute(
text("INSERT INTO risk_alert (alert_id, trace_id, customer_id, trade_id, alert_type,"
" triggered_rules, risk_score, status, payload, created_at)"
" VALUES ('ALERT-001', 't1', 'CUST-9527', 'TRD-001', 'large_amount',"
" '[\"RISK-001\"]', 80, 'pending_review', '{\"summary\":\"大额申购\"}', :now)"),
{"now": now},
)
conn.execute(
text("INSERT INTO risk_alert (alert_id, trace_id, customer_id, trade_id, alert_type,"
" triggered_rules, risk_score, status, payload, created_at)"
" VALUES ('ALERT-002', 't2', 'CUST-9527', 'TRD-002', 'freq_trade',"
" '[\"RISK-002\"]', 60, 'pending_review', '{\"summary\":\"频繁交易\"}', :yest)"),
{"yest": yesterday},
)
# CUST-1001 一条 pending(验证全局计数 > 单客户)
conn.execute(
text("INSERT INTO risk_alert (alert_id, trace_id, customer_id, trade_id, alert_type,"
" triggered_rules, risk_score, status, payload, created_at)"
" VALUES ('ALERT-900', 't9', 'CUST-1001', 'TRD-900', 'large_amount',"
" '[\"RISK-001\"]', 70, 'pending_review', '{\"summary\":\"大额\"}', :now)"),
{"now": now},
)
# AML 名单:full_name='张三' 命中 CUST-9527;'王五' 不命中
conn.execute(
text("INSERT INTO risk_aml_list (list_id, list_type, full_name, match_threshold, source,"
" is_active) VALUES ('AML-1', 'sanction', '张三', 0.9, 'demo', 1),"
" ('AML-2', 'sanction', '王五', 0.9, 'demo', 1)")
)
monkeypatch.setattr(tool_service, "_session_repo", lambda: session_repo)
monkeypatch.setattr(tool_service, "_core_ro", lambda: core_ro)
monkeypatch.setattr(tool_service, "_risk_repo", lambda: risk_repo)
yield {"engine": engine, "session_repo": session_repo, "core_ro": core_ro, "risk_repo": risk_repo}
engine.dispose()
def _tool_rows(engine):
with engine.connect() as conn:
return [dict(r) for r in conn.execute(text("SELECT * FROM agent_tool_call ORDER BY id")).mappings().all()]
# ---------- 统一注册表分发 ----------
def test_registry_combines_core_and_risk():
assert tool_service.get_registered_tool("alert_query") is not None
assert tool_service.get_registered_tool("query_holdings") is not None # core 仍可达
assert tool_service.get_registered_tool("no_such_tool") is None
# ---------- alert_query:客户维度 / 全局(A-6) ----------
def test_alert_query_customer_scope(risk_env):
record = tool_service.run_tool(
tool_name="alert_query", agent_type="customer", actor=ACTOR_CUSTOMER,
customer_id="CUST-9527", session_id="sess-a1", trace_id="tr-a1",
)
assert record["status"] == "success"
d = record["data"]
assert d["scope"] == "customer"
# 今日两条?不:昨日那条不在今日 → pending_count=2, today_pending_count=1
assert d["pending_count"] == 2
assert d["today_pending_count"] == 1
ids = {it["alert_id"] for it in d["items"]}
assert ids == {"ALERT-001", "ALERT-002"}
# alert_id 溯源:落库 tool_output 含 alert_id
row = _tool_rows(risk_env["engine"])[0]
assert '"ALERT-001"' in row["tool_output"]
def test_alert_query_global_scope_risk_officer(risk_env):
"""A-6:risk_officer 不带客户 → 全量待审(CUST-9527 2 + CUST-1001 1 = 3)。"""
record = tool_service.run_tool(
tool_name="alert_query", agent_type="risk", actor=ACTOR_RISK,
customer_id="", session_id="sess-a2", trace_id="tr-a2",
)
assert record["status"] == "success"
d = record["data"]
assert d["scope"] == "all"
assert d["pending_count"] == 3 # 全局待审
assert d["customer_id"] is None
def test_alert_query_global_blocked_for_customer(risk_env):
"""客户不能查全量(缺绑定客户 → 归属拒绝 AUTH_403_NOT_OWNER)。"""
record = tool_service.run_tool(
tool_name="alert_query", agent_type="customer", actor=ACTOR_CUSTOMER,
customer_id="", session_id="sess-a3",
)
assert record["status"] == "blocked" and record["error_code"] == "AUTH_403_NOT_OWNER"
# ---------- customer_context ----------
def test_customer_context_found(risk_env):
record = tool_service.run_tool(
tool_name="customer_context", agent_type="risk", actor=ACTOR_RISK,
customer_id="CUST-9527", session_id="sess-c1",
)
assert record["status"] == "success"
d = record["data"]
assert d["found"] is True
assert d["display_name"] == "张三" and d["risk_code"] == "C1"
assert d["pending_alert_count"] == 2 # 含昨日那条
assert d["pending_alerts"][0]["alert_id"] == "ALERT-001"
def test_customer_context_not_found(risk_env):
record = tool_service.run_tool(
tool_name="customer_context", agent_type="risk", actor=ACTOR_RISK,
customer_id="CUST-NOPE", session_id="sess-c2",
)
assert record["status"] == "success" and record["data"]["found"] is False
def test_customer_context_requires_customer(risk_env):
record = tool_service.run_tool(
tool_name="customer_context", agent_type="risk", actor=ACTOR_RISK,
customer_id="", session_id="sess-c3",
)
assert record["status"] == "blocked" and record["error_code"] == "TOOL_BLOCKED_NO_CUSTOMER"
def test_customer_context_scope_blocked(risk_env):
"""customer 查他人上下文 → AUTH_403_NOT_OWNER。"""
record = tool_service.run_tool(
tool_name="customer_context", agent_type="customer", actor=ACTOR_CUSTOMER,
customer_id="CUST-1001", session_id="sess-c4",
)
assert record["status"] == "blocked" and record["error_code"] == "AUTH_403_NOT_OWNER"
# ---------- suitability_check ----------
def test_suitability_check_matched(risk_env):
record = tool_service.run_tool(
tool_name="suitability_check", agent_type="customer", actor=ACTOR_CUSTOMER,
customer_id="CUST-9527", tool_input={"product_id": "P-001"}, session_id="sess-s1",
)
assert record["status"] == "success"
d = record["data"]
assert d["found"] is True
assert d["is_matched"] is True and d["blocked"] is False
assert d["rule_id"] == "SUIT-PASS"
def test_suitability_check_blocked_not_matched(risk_env):
record = tool_service.run_tool(
tool_name="suitability_check", agent_type="customer", actor=ACTOR_CUSTOMER,
customer_id="CUST-9527", tool_input={"product_id": "P-002"}, session_id="sess-s2",
)
assert record["status"] == "success"
d = record["data"]
assert d["is_matched"] is False and d["blocked"] is True
assert d["rule_id"] == "SUIT-001"
def test_suitability_check_product_not_found(risk_env):
record = tool_service.run_tool(
tool_name="suitability_check", agent_type="customer", actor=ACTOR_CUSTOMER,
customer_id="CUST-9527", tool_input={"product_id": "P-999"}, session_id="sess-s3",
)
assert record["status"] == "success" and record["data"]["found"] is False
def test_suitability_check_unknown_param_blocked(risk_env):
record = tool_service.run_tool(
tool_name="suitability_check", agent_type="customer", actor=ACTOR_CUSTOMER,
customer_id="CUST-9527", tool_input={"foo": 1}, session_id="sess-s4",
)
assert record["status"] == "blocked" and record["error_code"] == "TOOL_BAD_PARAM"
def test_suitability_check_empty_product_id_not_found(risk_env):
"""空 product_id 经白名单放行但产品缺失 → 结构化 found=False(不抛异常污染对话)。"""
record = tool_service.run_tool(
tool_name="suitability_check", agent_type="customer", actor=ACTOR_CUSTOMER,
customer_id="CUST-9527", tool_input={"product_id": ""}, session_id="sess-s5",
)
assert record["status"] == "success" and record["data"]["found"] is False
# ---------- advisor 分支归属(评审 P2:锁死 agent 分支) ----------
def test_advisor_assigned_customer_success(risk_env):
record = tool_service.run_tool(
tool_name="customer_context", agent_type="advisor", actor=ACTOR_ADVISOR,
customer_id="CUST-9527", session_id="sess-ad1",
)
assert record["status"] == "success" and record["data"]["found"] is True
def test_advisor_not_assigned_blocked(risk_env):
record = tool_service.run_tool(
tool_name="customer_context", agent_type="advisor", actor=ACTOR_ADVISOR,
customer_id="CUST-1001", session_id="sess-ad2",
)
assert record["status"] == "blocked" and record["error_code"] == "AUTH_403_NOT_ASSIGNED"
# ---------- aml_lookup ----------
def test_aml_lookup_hit(risk_env):
record = tool_service.run_tool(
tool_name="aml_lookup", agent_type="risk", actor=ACTOR_RISK,
customer_id="CUST-9527", session_id="sess-m1",
)
assert record["status"] == "success"
d = record["data"]
assert d["found"] is True and d["hit"] is True
assert d["active_entry_count"] == 2
assert d["matched_entries"][0]["full_name"] == "张三"
def test_aml_lookup_no_hit(risk_env):
record = tool_service.run_tool(
tool_name="aml_lookup", agent_type="risk", actor=ACTOR_RISK,
customer_id="CUST-1001", session_id="sess-m2",
)
d = record["data"]
assert d["hit"] is False and d["active_entry_count"] == 2
def test_aml_lookup_customer_scope_blocked(risk_env):
record = tool_service.run_tool(
tool_name="aml_lookup", agent_type="customer", actor=ACTOR_CUSTOMER,
customer_id="CUST-1001", session_id="sess-m3",
)
assert record["status"] == "blocked" and record["error_code"] == "AUTH_403_NOT_OWNER"
# ---------- summarize 分支(降级回复可量化) ----------
def test_summarize_risk_tools(risk_env):
alert = tool_service.run_tool(
tool_name="alert_query", agent_type="customer", actor=ACTOR_CUSTOMER,
customer_id="CUST-9527", session_id="sess-z1",
)
assert "待审预警" in tool_service.summarize(alert)
suit = tool_service.run_tool(
tool_name="suitability_check", agent_type="customer", actor=ACTOR_CUSTOMER,
customer_id="CUST-9527", tool_input={"product_id": "P-001"}, session_id="sess-z2",
)
assert "可购" in tool_service.summarize(suit)
aml = tool_service.run_tool(
tool_name="aml_lookup", agent_type="risk", actor=ACTOR_RISK,
customer_id="CUST-9527", session_id="sess-z3",
)
assert "命中在册名单" in tool_service.summarize(aml)
# ---------- C2/C3:risk 分支意图接入 + A-6 验收 + 诱导处置边界 ----------
class FakeLLM:
def __init__(self, reply: str = "模拟回复"):
self.reply = reply
self.calls: list[list] = []
def invoke(self, messages):
self.calls.append(list(messages))
return AIMessage(content=self.reply)
@pytest.fixture()
def fake_llm(monkeypatch):
from app.config.settings import settings as app_settings
llm = FakeLLM()
monkeypatch.setattr(agent_service, "_llm", llm)
monkeypatch.setattr(app_settings, "deepseek_api_key", "test-key")
yield llm
agent_service.reset_cache()
def test_a6_risk_officer_pending_alerts(fake_llm, risk_env):
"""A-6:risk_officer「今天有多少待审预警」→ 触发 alert_query 全局→回复含待审数。"""
out = agent_service.chat(
"risk", [], "今天有多少待审预警",
session_id="sess-a6", trace_id="tr-a6",
actor=ACTOR_RISK, customer_id=None,
)
assert out["tool_results"][0]["tool_name"] == "alert_query"
assert out["tool_results"][0]["status"] == "success"
d = out["tool_results"][0]["data"]
assert d["scope"] == "all" and d["pending_count"] == 3
# 工具结果注入 LLM 上下文([工具查询结果] 段)
tool_msgs = [
m for m in fake_llm.calls[0]
if m.__class__.__name__ == "SystemMessage" and "[工具查询结果]" in m.content
]
assert tool_msgs and "全量待审" in tool_msgs[0].content
assert out["has_disclaimer"] is True # 风控对外口径(guard 附免责声明)
def test_a6_customer_no_intent_still_core_only():
"""C2 隔离:customer 分支不因 risk 关键词误触风控 Tool('预警' 不在 customer 词表)。"""
assert tool_service.match_intent("customer", "我的待审预警") is None
assert tool_service.match_intent("risk", "今天有多少待审预警") == "alert_query"
def test_no_dispose_tool_registered():
"""红线:无任何处置类 Tool,Agent 无法自动处置预警。"""
assert tool_service.get_registered_tool("dispose_alert") is None
def test_risk_system_prompt_forbids_auto_dispose():
"""红线:risk 系统提示明确禁止自动处置预警。"""
assert "不得自动处置预警" in agent_service._SYSTEM_PROMPTS["risk"]
def test_induce_dispose_triggers_read_only(fake_llm, risk_env):
"""边界:诱导"处置预警"→ 命中 alert_query 只读查询,预警状态不变(无处置)。"""
out = agent_service.chat(
"risk", [], "请帮我处置这条预警 ALERT-001",
session_id="sess-d1", trace_id="tr-d1",
actor=ACTOR_RISK, customer_id=None,
)
assert out["tool_results"][0]["tool_name"] == "alert_query"
assert out["tool_results"][0]["status"] == "success"
# 预警状态未被改变(红线:不自动处置)
alert = risk_env["risk_repo"].get_alert("ALERT-001")
assert alert["status"] == "pending_review"