Files
group_xinghuo_jinrong/tests/test_risk_chat_tools.py
T
zhanghongyu_0626 3ad244add0 feat(analyst): Enhance dashboard metrics and customer service interactions
- Updated the `dashboard` function in `analyst.py` to include additional metrics for different user roles, improving data visibility for analysts, customers, advisors, and risk officers.
- Introduced a new `prepare_customer_stream` function in `customer_service.py` to facilitate streaming responses for customer interactions, enhancing the chat experience.
- Added new API endpoints in `analyst.ts` for fetching dashboard metrics and managing analyst assets, streamlining data handling and user interactions.
- Updated frontend components to support new dashboard features and asset management, ensuring a cohesive user experience across the application.

This update significantly improves the functionality and usability of the analyst and customer service features, providing users with enhanced tools for data analysis and interaction.
2026-09-09 21:32:59 +08:00

397 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, seed_suitability_matrix
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()
seed_suitability_matrix(engine) # AL-05:check_suitability 以矩阵表为 L0 权威
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, expires_at)"
" VALUES ('CUST-9527', 'C1', '2026-08-01 10:00:00', '2027-08-01'),"
" ('CUST-1001', 'C5', '2026-08-01 10:00:00', '2027-08-01')")
)
# 产品: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_agent_behavior") 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["match_result"] == "allowed" # AL-06 main 契约:C1×R1 矩阵 allowed
assert d["block_response_code"] == "SUIT_OK"
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["match_result"] == "forbidden" and d["mismatch_type"] == "risk_level"
assert d["block_response_code"] == "SUIT_RISK_MISMATCH"
assert d["rule_refs"] == ["JR-AST-012"]
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"