T-0 / T-0b(门禁 · 2026-09-10) - T-0:sqlite 与 MySQL 结构对齐 —— core_holding 统一为 qty/cost_amount/as_of/pnl_pct + PK + UNIQUE(customer_id, product_id);补 core_product_nav;新增建库自校验 _assert_ddl_aligned()(R-g);test_db.py 增 3 条门禁用例(含反向验证门禁失效) - T-0b:DB 账号分离(D20)—— 新增 scripts/core/00-grant.sql(三账号逐表授权); settings.py 增 3 组账号;db.py 改 get_engine(db, role),缓存键改为 (库名, 角色), 账号未配置回退单账号;core_ro→ro / gateway_repository→rw / risk·session_repository→rw; tests/conftest.py 四处显式 role="admin"(R-e) T-1(数据层) - scripts/core/01-ddl.sql:新建 core_fee_rule / core_share_lot / core_convert_lot_detail; core_trade 加 convert_group_id + idx_convert_group;core_product 加 8 列 + fee_rate 补 COMMENT - 新增 07-seed-fee-rule.sql(赎回费 5 档 × 14 产品,按 22 号文 §10)/ 08-seed-share-lot.sql (58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty)/ 09-seed-org.sql(管理人 + TA + 申购费率 + 最低持有余额,v1.1 按「管理人全产品线」重排) - reset.ps1 追加 07/08/09;02-mysql-agent专用.sql 追加 risk_convert_detail - tests/_ddl.py 同步 4 表 + 新增 REQUIRED_CONVERT_TABLES 建库门禁 - 新增 scripts/dev/verify_convert_seed.py(pymysql 等价 reset 流程 + 8 条 DoD 断言, 含断言 ⑧「费率档 ↔ product_type 匹配」,越档即 FAIL) T-2 / T-2b(纯函数包 + 示例实算回填) - 新增 app/service/convert/ 7 文件:__init__ / types / calc / fee / nav / lot_bootstrap / errors (纯函数,不查库、不碰 SQL;所有量化显式 ROUND_HALF_UP;lot_bootstrap 用 zlib.crc32 保证 D18 跨进程同源) - 新增 tests/test_convert_calc.py 93 用例(12 类:HALF_UP 反向自证 / 分档边界 / FIFO 含同 confirmed_at 兜底 / 双口径 / 强制全转与强制赎回 / PRD §5.3 全链自证 / 纯函数零 IO 依赖断言) - 重写 scripts/dev/calc_convert_demo.py:去掉脚本内公式副本,改为调用生产 calc.py, 末尾与 PRD §5.3 逐项比对(不一致即退出码 1),兼作一致性门禁 验证 - pytest 609 passed / 3 skipped(516 → +93,零回归) - verify_convert_seed.py 8/8 PASS;calc_convert_demo.py 15/15 与 PRD §5.3 一致 文档:PRD v0.9.1(费率分类修正)· 架构 §7 签名回填 / §8.3 错误码注 / §15 T-2 完成 · 开发计划 §1.5 新增 R-h + §4.2·§4.3 执行记录 · AGENTS.md · docs/memory
652 lines
22 KiB
Python
652 lines
22 KiB
Python
"""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
|