- C1: app/service/risk/chat_tools.py 新增 alert_query/customer_context/suitability_check/aml_lookup 四只读 Tool,挂 RISK_TOOL_REGISTRY(与 core_tools.ToolSpec 同构) - C1: tool_service.run_tool 改查 core+risk 统一注册表并恒传 risk_repo;_normalize_params 读 spec 白名单;core_tools 三函数加 risk_repo=None;summarize 补四工具分支 - C2: match_intent 按 agent 分组扩 risk 关键词;agent_service.tool_node 守卫放宽(requires_customer=False 的 Tool 允许无绑定客户运行,支撑 A-6 全局待审) - C3: A-6 验收(risk_officer 问待审预警→alert_query 全量→回复含待审数)+ 诱导处置边界(无处置 Tool、系统提示禁自动处置、诱导只触发只读且预警状态不变) - 测试 tests/test_risk_chat_tools.py(21 例);全量 pytest 342 绿(原 321 + 21)
359 lines
15 KiB
Python
359 lines
15 KiB
Python
"""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')")
|
||
)
|
||
# 预警: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"
|
||
|
||
|
||
# ---------- 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"
|