"""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"