"""T-04 对话 Tool:意图匹配 / 归属校验 / agent_tool_call 落库 / 图节点注入。 单测层:run_tool 直调(sqlite 注入,success/blocked/error 三态与落库字段、 落库降级);图集成:FakeLLM 捕获注入的 [工具查询结果] 上下文、降级回复 带摘要、无会话上下文空转。全链路(TestClient 真路由栈)见 test_chat.py。 """ from __future__ import annotations import datetime as _dt import logging import pytest from langchain_core.messages import AIMessage from sqlalchemy import text from _ddl import create_sqlite_engine from app.config.settings import settings from app.repository.core_ro import CoreReadOnlyRepository from app.repository.session_repository import SessionRepository from app.service import agent_service, tool_service 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) class FakeRiskRepo: """合规审计仓储替身(T-04 评审 P1-2):记录越权双写,避免测试写真库。""" def __init__(self): self.audit_logs: list[dict] = [] self.guard_logs: list[dict] = [] def insert_audit_log(self, payload: dict) -> None: self.audit_logs.append(payload) def insert_input_guard_log(self, **kwargs) -> None: self.guard_logs.append(kwargs) 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"} ACTOR_ANALYST = {"actor_id": "STAFF-40001", "roles": ["analyst"], "token_type": "staff"} @pytest.fixture() def tool_env(monkeypatch): engine = create_sqlite_engine() session_repo = SessionRepository(engine=engine) core_ro = CoreReadOnlyRepository(engine=engine) with engine.begin() as conn: conn.execute( text( "INSERT INTO core_customer (customer_id, display_name, age, occupation, is_active)" " VALUES ('CUST-9527', '张三', 45, '工程师', 1)" ) ) conn.execute( text( "INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at)" " VALUES ('CUST-9527', 'A', '2026-08-01 10:00:00')" ) ) conn.execute( text( "INSERT INTO core_product (product_id, product_name, min_risk_code, product_type)" " VALUES ('P-001', '稳健一号', 'A', 'fund')" ) ) conn.execute( text( "INSERT INTO core_holding (customer_id, product_id, market_value, qty," " cost_amount, pnl_pct, as_of)" " VALUES ('CUST-9527', 'P-001', 50000.00, 100, 50000.00, 0, '2026-09-04')" ) ) conn.execute( text( "INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type, amount," " trade_status, traded_at) VALUES ('TRD-001', 'CUST-9527', 'P-001', 'subscribe'," " 10000.00, 'confirmed', :ts)" ), {"ts": _dt.datetime.now()}, ) conn.execute( text( "INSERT INTO core_customer_advisor (advisor_id, customer_id, rel_status)" " VALUES ('STAFF-10086', 'CUST-9527', 'active')," " ('STAFF-99999', 'CUST-9527', 'inactive')" ) ) monkeypatch.setattr(tool_service, "_session_repo", lambda: session_repo) monkeypatch.setattr(tool_service, "_core_ro", lambda: core_ro) risk_repo = FakeRiskRepo() monkeypatch.setattr(tool_service, "_risk_repo", lambda: risk_repo) yield {"engine": engine, "session_repo": session_repo, "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_match_intent_hits(): assert tool_service.match_intent("customer", "查一下我的持仓") == "query_holdings" assert tool_service.match_intent("customer", "我的风险测评结果是什么") == "query_customer_profile" assert tool_service.match_intent("advisor", "看看客户交易记录") == "query_recent_trades" def test_match_intent_miss_or_agent(): assert tool_service.match_intent("customer", "你好呀") is None assert tool_service.match_intent("risk", "查一下我的持仓") is None # risk 分支归 C1 assert tool_service.match_intent("analyst", "交易流水") is None # ---------- run_tool:success / blocked / error 与落库 ---------- def test_run_tool_holdings_success_and_audit(tool_env): record = tool_service.run_tool( tool_name="query_holdings", agent_type="customer", actor=ACTOR_CUSTOMER, customer_id="CUST-9527", session_id="sess-t1", trace_id="trace-t1", ) assert record["status"] == "success" and record["error_code"] is None assert record["data"]["total_count"] == 1 assert record["data"]["sum_market_value"] == 50000.0 assert record["data"]["items"][0]["product_name"] == "稳健一号" # JOIN 生效 rows = _tool_rows(tool_env["engine"]) assert len(rows) == 1 row = rows[0] assert (row["session_id"], row["trace_id"], row["tool_name"], row["status"]) == ( "sess-t1", "trace-t1", "query_holdings", "success", ) assert row["error_code"] is None and row["latency_ms"] is not None assert '"customer_id": "CUST-9527"' in row["tool_input"] assert '"total_count": 1' in row["tool_output"] def test_run_tool_customer_profile(tool_env): record = tool_service.run_tool( tool_name="query_customer_profile", agent_type="customer", actor=ACTOR_CUSTOMER, customer_id="CUST-9527", session_id="sess-t2", ) assert record["status"] == "success" assert record["data"]["found"] is True assert record["data"]["risk_code"] == "A" def test_run_tool_recent_trades_params(tool_env): record = tool_service.run_tool( tool_name="query_recent_trades", agent_type="customer", actor=ACTOR_CUSTOMER, customer_id="CUST-9527", tool_input={"days": 7}, session_id="sess-t3", ) assert record["status"] == "success" assert record["data"]["days"] == 7 and record["data"]["total_count"] == 1 def test_run_tool_blocked_not_owner(tool_env): """customer 查他人 → blocked(AUTH_403_NOT_OWNER,与 deps 同码)+ 留痕。""" record = tool_service.run_tool( tool_name="query_holdings", agent_type="customer", actor=ACTOR_CUSTOMER, customer_id="CUST-1001", session_id="sess-b1", ) assert record["status"] == "blocked" and record["error_code"] == "AUTH_403_NOT_OWNER" assert record["data"] is None row = _tool_rows(tool_env["engine"])[0] assert (row["status"], row["error_code"]) == ("blocked", "AUTH_403_NOT_OWNER") def test_run_tool_blocked_advisor_not_assigned(tool_env): record = tool_service.run_tool( tool_name="query_holdings", agent_type="advisor", actor=ACTOR_ADVISOR, customer_id="CUST-1010", session_id="sess-b2", ) assert record["status"] == "blocked" and record["error_code"] == "AUTH_403_NOT_ASSIGNED" def test_run_tool_blocked_scope(tool_env): """analyst 等未授权角色 fail-closed(AUTH_403_SCOPE)。""" record = tool_service.run_tool( tool_name="query_holdings", agent_type="customer", actor=ACTOR_ANALYST, customer_id="CUST-9527", session_id="sess-b3", ) assert record["status"] == "blocked" and record["error_code"] == "AUTH_403_SCOPE" def test_run_tool_risk_officer_full_access(tool_env): """risk_officer 全量(对齐 assert_customer_access 口径)。""" record = tool_service.run_tool( tool_name="query_holdings", agent_type="risk", actor=ACTOR_RISK, customer_id="CUST-9527", session_id="sess-r1", ) assert record["status"] == "success" def test_run_tool_blocked_no_customer(tool_env): record = tool_service.run_tool( tool_name="query_holdings", agent_type="customer", actor=ACTOR_CUSTOMER, customer_id="", session_id="sess-n1", ) assert record["status"] == "blocked" and record["error_code"] == "TOOL_BLOCKED_NO_CUSTOMER" def test_run_tool_unknown_tool(tool_env): record = tool_service.run_tool( tool_name="drop_database", agent_type="customer", actor=ACTOR_CUSTOMER, customer_id="CUST-9527", session_id="sess-u1", ) assert record["status"] == "blocked" and record["error_code"] == "TOOL_UNKNOWN" def test_run_tool_error_swallowed_and_audited(tool_env, monkeypatch): """Tool 执行异常 → error 落痕且不向对话链路抛。""" from app.tool import core_tools original = core_tools.TOOL_REGISTRY["query_holdings"]["func"] def boom(customer_id, core_ro): raise RuntimeError("db exploded") core_tools.TOOL_REGISTRY["query_holdings"]["func"] = boom try: record = tool_service.run_tool( tool_name="query_holdings", agent_type="customer", actor=ACTOR_CUSTOMER, customer_id="CUST-9527", session_id="sess-e1", ) finally: core_tools.TOOL_REGISTRY["query_holdings"]["func"] = original assert record["status"] == "error" and record["error_code"] == "TOOL_ERROR" row = _tool_rows(tool_env["engine"])[0] assert (row["status"], row["error_code"]) == ("error", "TOOL_ERROR") def test_run_tool_audit_insert_degrades(tool_env, monkeypatch): """留痕失败降级 warning(不阻塞对话,口径同 T-02 审计降级)。""" def broken_repo(): raise RuntimeError("repo down") monkeypatch.setattr(tool_service, "_session_repo", broken_repo) record = tool_service.run_tool( tool_name="query_holdings", agent_type="customer", actor=ACTOR_CUSTOMER, customer_id="CUST-9527", session_id="sess-d1", ) assert record["status"] == "success" # 对话链路不受留痕故障影响 # ---------- 图集成:tool 节点注入与降级 ---------- @pytest.fixture() def fake_llm(monkeypatch): llm = FakeLLM() monkeypatch.setattr(agent_service, "_llm", llm) monkeypatch.setattr(settings, "deepseek_api_key", "test-key") yield llm agent_service.reset_cache() def test_graph_tool_result_injected_into_llm(fake_llm, tool_env): out = agent_service.chat( "customer", [], "查一下我的持仓", session_id="sess-g1", trace_id="trace-g1", actor=ACTOR_CUSTOMER, customer_id="CUST-9527", ) msgs = fake_llm.calls[0] assert msgs[1].__class__.__name__ == "SystemMessage" assert "[工具查询结果]" in msgs[1].content assert "合计市值 50000" in msgs[1].content assert '"total_count": 1' in msgs[1].content # 数据 JSON 一并注入 assert len(_tool_rows(tool_env["engine"])) == 1 assert out["tool_results"][0]["status"] == "success" def test_graph_no_intent_skips_tool(fake_llm, tool_env): agent_service.chat( "customer", [], "你好", session_id="sess-g2", actor=ACTOR_CUSTOMER, customer_id="CUST-9527", ) tool_msgs = [ m for m in fake_llm.calls[0] if m.__class__.__name__ == "SystemMessage" and "[工具查询结果]" in m.content ] assert tool_msgs == [] assert _tool_rows(tool_env["engine"]) == [] def test_graph_blocked_result_visible_to_llm(fake_llm, tool_env): """归属拒绝以 blocked 结果注入(对话内呈现,非 403)。""" agent_service.chat( "customer", [], "查一下我的持仓", session_id="sess-g3", actor=ACTOR_CUSTOMER, customer_id="CUST-1001", ) content = fake_llm.calls[0][1].content assert "拒绝" in content and "AUTH_403_NOT_OWNER" in content def test_graph_without_session_context_no_tool(fake_llm, tool_env): """无会话上下文(旧调用方式)Tool 空转——T-07 兼容。""" out = agent_service.chat("customer", [], "查一下我的持仓") assert out["tool_results"] == [] assert "[工具查询结果]" not in fake_llm.calls[0][1].content assert _tool_rows(tool_env["engine"]) == [] def test_graph_degraded_reply_includes_summary(tool_env, monkeypatch): """无 key 降级:回复携带 Tool 查询摘要(查询不白跑)。""" monkeypatch.setattr(settings, "deepseek_api_key", "") agent_service.reset_cache() out = agent_service.chat( "customer", [], "查一下我的持仓", session_id="sess-g4", actor=ACTOR_CUSTOMER, customer_id="CUST-9527", ) assert "LLM 未配置" in out["reply"] assert "合计市值 50000" in out["reply"] assert out["has_disclaimer"] is True # 降级回复同样经 guard # ---------- summarize / context_text ---------- def test_summarize_variants(tool_env): ok = tool_service.run_tool( tool_name="query_customer_profile", agent_type="customer", actor=ACTOR_CUSTOMER, customer_id="CUST-9527", session_id="sess-s1", ) text = tool_service.summarize(ok) assert "张三" in text and "A" in text blocked = tool_service.run_tool( tool_name="query_holdings", agent_type="customer", actor=ACTOR_CUSTOMER, customer_id="CUST-1001", session_id="sess-s2", ) assert "无权访问" in tool_service.summarize(blocked) not_found = tool_service.run_tool( tool_name="query_customer_profile", agent_type="risk", actor=ACTOR_RISK, customer_id="CUST-NOPE", session_id="sess-s3", ) assert "未找到" in tool_service.summarize(not_found) def test_context_text_empty(): assert tool_service.context_text([]) == "" # ---------- T-04 评审补测:入参白名单与边界钳制 ---------- def _run_days(tool_env, days): return tool_service.run_tool( tool_name="query_recent_trades", agent_type="customer", actor=ACTOR_CUSTOMER, customer_id="CUST-9527", tool_input={"days": days}, session_id="sess-p1", ) @pytest.mark.parametrize( "raw,expected", [(0, 1), (-30, 1), (400, 365), (365, 365), (1, 1), ("7", 7)], ) def test_days_clamped(tool_env, raw, expected): """越界天数钳制到 [1,365],非整数字符串可转换——防 LLM 传参拉爆查询。""" record = _run_days(tool_env, raw) assert record["status"] == "success" assert record["data"]["days"] == expected @pytest.mark.parametrize("raw", [7.5, "abc", True, None, [7]]) def test_days_invalid_blocked(tool_env, raw): record = _run_days(tool_env, raw) assert record["status"] == "blocked" and record["error_code"] == "TOOL_BAD_PARAM" @pytest.mark.parametrize("extra", [{"foo": 1}, {"customer_id": "CUST-1001"}, {"core_ro": None}]) def test_unknown_param_blocked(tool_env, extra): """白名单外入参(含注入参数名)一律拒,避免重复关键字 TypeError 被吞。""" record = tool_service.run_tool( tool_name="query_recent_trades", agent_type="customer", actor=ACTOR_CUSTOMER, customer_id="CUST-9527", tool_input=extra, session_id="sess-p2", ) assert record["status"] == "blocked" and record["error_code"] == "TOOL_BAD_PARAM" def test_tool_without_params_rejects_any_input(tool_env): record = tool_service.run_tool( tool_name="query_holdings", agent_type="customer", actor=ACTOR_CUSTOMER, customer_id="CUST-9527", tool_input={"days": 7}, session_id="sess-p3", ) assert record["status"] == "blocked" and record["error_code"] == "TOOL_BAD_PARAM" # ---------- T-04 评审补测:归属与留痕 ---------- def test_run_tool_blocked_advisor_inactive_rel(tool_env): """rel_status='inactive'(非缺失)同样拒绝。""" record = tool_service.run_tool( tool_name="query_holdings", agent_type="advisor", actor={"actor_id": "STAFF-99999", "roles": ["advisor"], "token_type": "staff"}, customer_id="CUST-9527", session_id="sess-b5", ) assert record["status"] == "blocked" and record["error_code"] == "AUTH_403_NOT_ASSIGNED" def test_run_tool_blocked_writes_authz_audit(tool_env): """P1-2:对话内越权进全局鉴权台账(audit_log + input_guard_log 双写)。""" record = tool_service.run_tool( tool_name="query_holdings", agent_type="customer", actor=ACTOR_CUSTOMER, customer_id="CUST-1001", session_id="sess-b6", ) assert record["error_code"] == "AUTH_403_NOT_OWNER" audit = tool_env["risk_repo"].audit_logs guard = tool_env["risk_repo"].guard_logs assert len(audit) == 1 assert audit[0]["decision"] == "forbidden" assert audit[0]["customer_id"] == "CUST-1001" assert audit[0]["input_summary"]["code"] == "AUTH_403_NOT_OWNER" assert len(guard) == 1 assert guard[0]["agent_type"] == "customer" assert guard[0]["raw_excerpt"] == "AUTH_403_NOT_OWNER" def test_non_authz_block_skips_authz_audit(tool_env): """工具层自身拒绝(TOOL_*)不污染鉴权台账。""" tool_service.run_tool( tool_name="drop_database", agent_type="customer", actor=ACTOR_CUSTOMER, customer_id="CUST-9527", session_id="sess-b7", ) assert tool_env["risk_repo"].audit_logs == [] assert tool_env["risk_repo"].guard_logs == [] def test_audit_degrade_logs_error_with_context(tool_env, monkeypatch, caplog): """P1-1:留痕失败仍降级不阻塞,但必须 error 级留底且带定位字段。""" def broken_repo(): raise RuntimeError("repo down") monkeypatch.setattr(tool_service, "_session_repo", broken_repo) with caplog.at_level(logging.ERROR, logger="app.service.tool_service"): record = tool_service.run_tool( tool_name="query_holdings", agent_type="customer", actor=ACTOR_CUSTOMER, customer_id="CUST-9527", session_id="sess-d2", trace_id="trace-d2", ) assert record["status"] == "success" entries = [r.getMessage() for r in caplog.records if "agent_tool_call insert failed" in r.getMessage()] assert entries and "sess-d2" in entries[0] and "trace-d2" in entries[0] def test_authz_audit_failure_does_not_break_tool(tool_env, monkeypatch): """越权留痕自身故障不影响 blocked 语义。""" def broken_repo(): raise RuntimeError("audit repo down") monkeypatch.setattr(tool_service, "_risk_repo", broken_repo) record = tool_service.run_tool( tool_name="query_holdings", agent_type="customer", actor=ACTOR_CUSTOMER, customer_id="CUST-1001", session_id="sess-b8", ) assert record["status"] == "blocked" and record["error_code"] == "AUTH_403_NOT_OWNER" # ---------- T-04 评审补测:上下文与摘要边界 ---------- def test_context_text_truncates_long_payload(): big = {"items": [{"name": "x" * 100} for _ in range(50)]} record = {"tool_name": "query_holdings", "status": "success", "data": big} text = tool_service.context_text([record]) assert "…(truncated)" in text assert len(text) < tool_service._RESULT_CONTEXT_MAX_CHARS + 500 def test_summarize_holdings_shows_first_five_only(): record = { "tool_name": "query_holdings", "status": "success", "data": { "total_count": 25, "sum_market_value": 250.0, "truncated": False, "items": [{"product_name": f"P{i}", "market_value": 10.0} for i in range(25)], }, } text = tool_service.summarize(record) assert "共 25 笔" in text assert text.count("市值") == 6 # 合计 1 次 + 明细 5 条 def test_summarize_holdings_truncated_note(): record = { "tool_name": "query_holdings", "status": "success", "data": {"total_count": 500, "sum_market_value": 1.0, "truncated": True, "items": []}, } assert "已达拉取上限" in tool_service.summarize(record) def test_summarize_tool_rejection_not_authz_wording(): record = {"tool_name": "query_holdings", "status": "blocked", "error_code": "TOOL_BAD_PARAM"} text = tool_service.summarize(record) assert "无权访问" not in text and "TOOL_BAD_PARAM" in text # ---------- C5 · FR-9 query_overdue_alerts Tool(直接调函数;需真实 RiskRepository) ---------- def test_query_overdue_alerts_filters_by_hours(sqlite_engine, backdated_alert): from app.repository.risk_repository import RiskRepository from app.service.risk.chat_tools import query_overdue_alerts backdated_alert("ALT-OV-1", "C1", hours_ago=5) # 超 4h → 命中 backdated_alert("ALT-OV-2", "C1", hours_ago=2) # 未达 4h → 不命中 repo = RiskRepository(engine=sqlite_engine) # 缺省 hours = settings.risk_escalation_l1_hours(4)→ 仅 1 张 data = query_overdue_alerts(None, core_ro=None, risk_repo=repo) assert data["overdue_count"] == 1 assert data["items"][0]["alert_id"] == "ALT-OV-1" assert data["items"][0]["overdue_hours"] >= 4 # 显式 hours=1 → 两张都超期 data2 = query_overdue_alerts(None, core_ro=None, risk_repo=repo, hours=1) assert data2["overdue_count"] == 2 # 按超期时长降序 assert data2["items"][0]["overdue_hours"] >= data2["items"][1]["overdue_hours"] def test_query_overdue_alerts_intent_match_and_summarize(sqlite_engine, backdated_alert): """意图词命中 query_overdue_alerts(置于 alert_query 之前)+ summarize 摘要。""" from app.repository.risk_repository import RiskRepository from app.service.risk.chat_tools import query_overdue_alerts backdated_alert("ALT-OV-3", "C1", hours_ago=6) repo = RiskRepository(engine=sqlite_engine) data = query_overdue_alerts(None, core_ro=None, risk_repo=repo, hours=1) assert tool_service.match_intent("risk", "这些预警超时多久没处理了") == "query_overdue_alerts" record = {"tool_name": "query_overdue_alerts", "status": "success", "data": data} text = tool_service.summarize(record) assert "超期预警" in text assert "ALT-OV-3" in text