Files
group_xinghuo_jinrong/tests/test_chat_tools.py
T

610 lines
21 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.
"""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, quantity)"
" VALUES ('CUST-9527', 'P-001', 50000.00, 100)"
)
)
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