Files
group_xinghuo_jinrong/app/service/tool_service.py
T
GaoYiYuan_0626 66ec7eba08 feat: C6 FR-10 代理人行为链 RISK-008
代理人异常行为链识别(RISK-008)落地:
- 新增 agent_behavior_service:三条件证据聚合(A 诱导调仓/B AUTH_403_SCOPE 越权试探/C AUTH_403_NOT_OWNER|NOT_ASSIGNED 越权查询),按代理人维度独立出 pattern 单,payload.actor_id 指向代理人,审计仅 INSERT event_type=agent_behavior_detected。
- risk_repository 新增 list_audit_events / find_agent_behavior_alert / merge_agent_behavior_payload(同日同代理人一张单,证据并集)。
- trade_gateway.submit_trade 补 actor_id 透传(代理人发起交易归属发起人,缺省 SYSTEM);simulate 路由传入 auth.actor_id。
- chat_tools 新增 query_agent_behavior 只读 Tool(agent_id 过滤 + 客户脱敏),tool_service 补意图词与摘要。
- scripts/cron/agent_behavior_scan.py 定时扫描脚本。
- 修复 append_alert_event 序列化缺 default=str(C6 evidence 含 datetime 字段)。
- 单测 12 例(_count_induce 边界 / 三条件 / 出单去重 / payload 归属 / Tool 过滤脱敏)。
全量 pytest 482 passed 0 failed(470 基线 + 12 C6)。
2026-09-07 19:53:39 +08:00

489 lines
22 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.
"""对话 Tool 编排(T-04):意图匹配 → 归属校验 → 执行 → agent_tool_call 落库。
分层:Tool 定义在 app/tool/core_tools.py(纯查询);本模块做业务编排
(FRAMEWORK「tool 不写业务流程」),由 agent_service 的 tool 节点调用。
归属校验(A-01 语义 · G-01 同口径):customer 仅本人、advisor 需
customer_advisor_rel active、risk_officer 全量、其余角色 fail-closed 拒绝;
顺序对齐 api/deps.assert_customer_access(risk_officer 优先,customer 分支
先于 advisor)。与 API 层差异:Tool 归属拒绝**不抛 403**,落
agent_tool_call.status='blocked'(error_code 与 deps 同码)后由对话呈现——
agent_tool_call 即留痕位(手册 P-05「403 必经 deny」约束 API 层出口;
对话内拒绝以 blocked 行留痕,注释登记为 T-04 口径)。
customer_id 恒由会话注入(api/chat 归属校验后的值),不来自 LLM/用户输入。
落库失败降级 warning 不阻塞对话(口径同 T-02 审计降级;本地日志留底)。
"""
from __future__ import annotations
import json
import logging
import time
from typing import Any
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.repository.session_repository import SessionRepository
from app.tool import core_tools
from app.utils.authz import AGENT_TYPES, record_authz_denial
from app.utils.exceptions import PermissionDenied
from app.utils.trace import current_trace
logger = logging.getLogger(__name__)
STATUS_SUCCESS = "success"
STATUS_BLOCKED = "blocked"
STATUS_ERROR = "error"
# LLM 上下文中单条 Tool 结果(summary + data JSON)的截断上限
_RESULT_CONTEXT_MAX_CHARS = 2000
# 归属类拒绝码(这些才需要写全局鉴权审计;TOOL_* 属工具层自身拒绝,不污染鉴权维度)
AUTHZ_DENIAL_CODES = frozenset(
{"AUTH_403_NOT_OWNER", "AUTH_403_NOT_ASSIGNED", "AUTH_403_SCOPE"}
)
AUTHZ_DENIAL_PREFIX = "AUTH_403_" # 兜底:新增同前缀码自动纳入(避免静默漏写)
def _session_repo() -> SessionRepository:
"""仓储入口(测试 monkeypatch 点,与 api 模式一致)。"""
return SessionRepository()
def _risk_repo() -> RiskRepository:
"""合规审计仓储入口(越权双写;测试注入点)。"""
return RiskRepository()
def _core_ro() -> CoreReadOnlyRepository:
"""Core 只读仓储入口(Tool 函数共享同一实例,测试注入点)。"""
return CoreReadOnlyRepository()
# ---------- 意图匹配(一期关键词规则;C2 risk 分支接 chat_tools) ----------
# 按 agent 分组的 (tool_name, 关键词);顺序即优先级,命中即停。
# customer/advisor 绑 Core RO(持仓/测评/流水)+ T21 知识库(产品规则,
# 组末尾:Core 词更特异优先;kb 词均为产品域词,与 Core 词无重叠);
# risk 分支接 C1 四只读 Tool(预警/客户上下文/适当性/反洗钱)+ T21 拍板
# 不开放知识检索(kb_product_rules 仅客户+代理人共用,03-milvus-collections §1)。
# 分组避免 risk 误触"持仓"等 Core 关键词(C1 评审口径:分组意图隔离)。
_KB_INTENT = ("search_knowledge", ("基金", "申购", "赎回", "费率", "手续费", "定投", "到账", "产品手册"))
_INTENT_KEYWORDS: dict[str, list[tuple[str, tuple[str, ...]]]] = {
"customer": [
("query_holdings", ("持仓", "持有", "仓位")),
("query_customer_profile", ("风险测评", "风险等级", "测评")),
("query_recent_trades", ("流水", "交易记录", "交易明细", "买卖记录")),
_KB_INTENT,
],
"advisor": [
("query_holdings", ("持仓", "持有", "仓位")),
("query_customer_profile", ("风险测评", "风险等级", "测评")),
("query_recent_trades", ("流水", "交易记录", "交易明细", "买卖记录")),
_KB_INTENT,
],
"risk": [
# FR-9 时效升级查询:置于 alert_query 之前("超时/超期预警"话术不得误命中台账)
("query_overdue_alerts", ("超期", "超时", "逾期", "多久没处理", "处置时效")),
# FR-10 行为链查询:置于 alert_query 之前("代理人预警"应命中行为链而非台账)
("query_agent_behavior", ("代理人", "行为链", "异常行为", "诱导", "越权记录")),
("alert_query", ("预警", "待审", "预警台账", "待审预警")),
("customer_context", ("客户上下文", "监测信息", "风险画像", "客户监测")),
("suitability_check", ("适当性", "能不能买", "可购", "适合买", "购买资格")),
("aml_lookup", ("反洗钱", "名单", "AML")),
],
}
INTENT_AGENT_TYPES = ("customer", "advisor", "risk")
def match_intent(agent_type: str, message: str) -> str | None:
"""消息 → tool_name;不命中返回 None(纯闲聊不查库)。
C2:按 agent 分组匹配;risk 分支命中 C1 四只读 Tool。命中即停(仅取
首命中,多意图不支持,见 T-04 评审 P3)。
"""
keywords = _INTENT_KEYWORDS.get(agent_type)
if not keywords:
return None
for name, kws in keywords:
if any(k in message for k in kws):
return name
return None
def get_registered_tool(name: str) -> dict[str, Any] | None:
"""统一注册表查找(core_tools 优先,其次 risk chat_tools,再次 kb_tools)。
risk 分支 Tool 归 C1 注册、kb Tool 归 T21 注册(懒导入避免加载顺序耦合);
返回 ToolSpec(dict 子类,含 func/description/requires_customer/
param_whitelist/int_bounds[/skip_access_check]),未知 Tool 返回 None
(run_tool 转 TOOL_UNKNOWN)。
"""
spec = core_tools.TOOL_REGISTRY.get(name)
if spec is not None:
return spec
from app.service.risk.chat_tools import RISK_TOOL_REGISTRY
if name in RISK_TOOL_REGISTRY:
return RISK_TOOL_REGISTRY[name]
from app.tool.kb_tools import KB_TOOL_REGISTRY
return KB_TOOL_REGISTRY.get(name)
# ---------- 归属校验(A-01;口径对齐 api/deps.assert_customer_access) ----------
def assert_tool_access(actor: dict[str, Any], customer_id: str, core_ro: CoreReadOnlyRepository) -> None:
"""Tool 层归属断言(纵深防御:api 层已校验,此处防 state 污染/未来 LLM 传参)。
actor = {actor_id, roles, token_type}(api/chat 从 AuthContext 展开)。
失败 raise PermissionDenied(code);由 run_tool 捕获转 blocked 留痕。
"""
roles = actor.get("roles") or []
actor_id = actor.get("actor_id") or ""
if "risk_officer" in roles:
return
if "customer" in roles:
if actor_id == customer_id:
return
raise PermissionDenied("AUTH_403_NOT_OWNER", "tool blocked: not owner")
if "advisor" in roles:
if core_ro.is_advisor_assigned(actor_id, customer_id):
return
raise PermissionDenied("AUTH_403_NOT_ASSIGNED", "tool blocked: customer not assigned")
raise PermissionDenied("AUTH_403_SCOPE", "tool blocked: role not allowed")
# ---------- 入参归一化(白名单 + 边界钳制;T-04 评审 P2) ----------
def _normalize_params(
spec: dict[str, Any] | None, tool_input: dict[str, Any] | None
) -> tuple[bool, str | None, dict[str, Any]]:
"""tool_input → 可安全传给 Tool 函数的参数(未知键一律拒,不静默丢弃)。
spec 为统一注册表条目(core/risk 同构);白名单 param_whitelist 与整数边界
int_bounds 从 spec 读取(C1 起风控 Tool 共用同一校验口径)。spec 为 None
(未知 Tool)直接判非法,由 run_tool 转 TOOL_UNKNOWN。一期 tool_input 由
代码传入(仅 days);C1 起可能来自 LLM 输出,故提前收紧:未在白名单声明的
键(含 customer_id/core_ro 这类注入参数)直接拒绝——否则会撞上重复关键字
TypeError,被 except 吞成语义错误的 TOOL_ERROR。整数参数按边界钳制,防超大窗口。
"""
if spec is None:
return False, "TOOL_UNKNOWN", {}
allowed = tuple(spec.get("param_whitelist") or ())
int_bounds = dict(spec.get("int_bounds") or {})
cleaned: dict[str, Any] = {}
for key, value in dict(tool_input or {}).items():
if key not in allowed:
return False, "TOOL_BAD_PARAM", {}
bounds = int_bounds.get(key)
if bounds is None:
cleaned[key] = value
continue
if isinstance(value, bool) or value is None:
return False, "TOOL_BAD_PARAM", {}
try:
ivalue = int(value)
except (TypeError, ValueError):
return False, "TOOL_BAD_PARAM", {}
if isinstance(value, float) and not value.is_integer():
return False, "TOOL_BAD_PARAM", {}
low, high = bounds
cleaned[key] = max(low, min(high, ivalue))
return True, None, cleaned
def _authz_denial_audit(
actor: dict[str, Any], customer_id: str, code: str, agent_type: str
) -> None:
"""对话内越权留痕(audit_log + input_guard_log,与 deps.deny 同口径)。
agent_type 非四 Agent 时按 platform 记账(input_guard_log 的 ENUM 仅
四 Agent,自动跳过其写入;audit_log 仍留痕)。留痕失败只降级,不影响
对话(T-02 同口径)。
"""
target = agent_type if agent_type in AGENT_TYPES else "platform"
try:
record_authz_denial(
_risk_repo(),
actor_id=(actor or {}).get("actor_id") or "anonymous",
roles=(actor or {}).get("roles") or [],
customer_id=customer_id or None,
code=code,
agent_type=target,
)
except Exception:
logger.exception("tool authz audit failed (degraded): code=%s", code)
# ---------- 执行与落库 ----------
def run_tool(
*,
tool_name: str,
agent_type: str,
actor: dict[str, Any],
customer_id: str,
tool_input: dict[str, Any] | None = None,
session_id: str,
trace_id: str | None = None,
) -> dict[str, Any]:
"""执行单个对话 Tool 并落 agent_tool_call(success/blocked/error 全留痕)。
返回 {tool_name, status, error_code, data, latency_ms};本函数不抛异常
(对话链路不因 Tool 故障中断),归属拒绝转 blocked 而非 403(见模块注释)。
C1:registry 改为 core/risk 统一查找,恒传 core_ro + risk_repo 给 Tool 函数。
"""
trace_id = trace_id or current_trace() or ""
spec = get_registered_tool(tool_name)
params_ok, param_error, params = _normalize_params(spec, tool_input)
started = time.monotonic()
status, error_code, data = STATUS_ERROR, "TOOL_ERROR", None
try:
if spec is None:
status, error_code = STATUS_BLOCKED, "TOOL_UNKNOWN"
logger.warning("tool not in registry: %s", tool_name)
elif not params_ok:
status, error_code = STATUS_BLOCKED, param_error or "TOOL_BAD_PARAM"
logger.warning("tool params rejected: %s %s", tool_name, tool_input)
elif spec.get("requires_customer") and not customer_id:
status, error_code = STATUS_BLOCKED, "TOOL_BLOCKED_NO_CUSTOMER"
else:
core_ro = _core_ro()
# skip_access_check(T21 kb Tool):公开知识检索无客户数据,
# 归属校验无对象;开放范围由意图层控制(仅 customer/advisor 配词)
if not spec.get("skip_access_check"):
assert_tool_access(actor, customer_id, core_ro)
risk_repo = _risk_repo()
data = spec["func"](customer_id=customer_id, core_ro=core_ro, risk_repo=risk_repo, **params)
status, error_code = STATUS_SUCCESS, None
except PermissionDenied as exc:
status, error_code = STATUS_BLOCKED, exc.code
except Exception:
logger.exception("tool execution failed: %s", tool_name)
status, error_code = STATUS_ERROR, "TOOL_ERROR"
code = error_code or ""
if status == STATUS_BLOCKED and (
code in AUTHZ_DENIAL_CODES or code.startswith(AUTHZ_DENIAL_PREFIX)
):
# T-04 评审 P1-2:对话内越权也要进全局鉴权台账(与 deps.deny 同口径)
_authz_denial_audit(actor, customer_id, error_code, agent_type)
latency_ms = int((time.monotonic() - started) * 1000)
record = {
"tool_name": tool_name,
"status": status,
"error_code": error_code,
"data": data,
"latency_ms": latency_ms,
}
_audit_tool_call(
session_id=session_id,
trace_id=trace_id,
tool_name=tool_name,
tool_input={"customer_id": customer_id, **params},
record=record,
)
return record
def _audit_tool_call(
*, session_id: str, trace_id: str, tool_name: str, tool_input: dict, record: dict
) -> None:
"""agent_tool_call 留痕(降级不阻塞对话主链路;T-04 评审 P1-1 强化)。
一期维持"降级"口径(与 T-02 审计一致:拒绝/对话语义优先,不因留痕故障
漂移为 500);但日志级别提为 error 且带全量定位字段(session/trace/
tool/status),保证运维可从日志重建留痕、并据此告警。生产若要
fail-closed(success 落库失败即中断),改此处抛异常即可——已登记待决。
"""
try:
_session_repo().insert_tool_call(
session_id=session_id,
trace_id=trace_id,
tool_name=tool_name,
tool_input=json.dumps(tool_input, ensure_ascii=False, default=str),
tool_output=(
json.dumps(record["data"], ensure_ascii=False, default=str)
if record["data"] is not None
else None
),
status=record["status"],
error_code=record["error_code"],
latency_ms=record["latency_ms"],
)
except Exception:
logger.error(
"agent_tool_call insert failed (degraded): tool=%s session=%s trace=%s status=%s",
tool_name,
session_id,
trace_id,
record.get("status"),
exc_info=True,
)
# ---------- 结果摘要(LLM 上下文与降级回复共用) ----------
def summarize(record: dict[str, Any]) -> str:
"""Tool 结果 → 人读摘要;blocked/error 给出明确口径,供回复直接引用。"""
name = record.get("tool_name", "?")
status = record.get("status")
if status == STATUS_BLOCKED:
code = record.get("error_code")
if code and code.startswith(AUTHZ_DENIAL_PREFIX):
return f"(工具 {name} 拒绝:当前身份无权访问该客户数据,错误码 {code})"
return f"(工具 {name} 未执行:入参不合法或工具不可用,错误码 {code})"
if status == STATUS_ERROR:
return f"(工具 {name} 执行失败,查询未完成,请稍后重试)"
data = record.get("data") or {}
if name == "query_customer_profile":
if not data.get("found"):
return f"(客户档案查询:未找到客户 {data.get('customer_id', '')} 的档案)"
return (
f"(客户档案:{data.get('display_name', '-')}({data.get('customer_id')}),"
f"年龄 {data.get('age', '-')},职业 {data.get('occupation', '-') or '-'},"
f"风险测评等级 {data.get('risk_code', '-') or '-'}"
f"(测评时间 {data.get('risk_evaluated_at', '-') or '无记录'}))"
)
if name == "query_holdings":
items = data.get("items") or []
# 触及 SQL 拉取上限时显式提示:合计为已取部分,非全量口径
trunc_note = "(已达拉取上限,合计为已取部分)" if data.get("truncated") else ""
lines = [
f"(持仓查询:共 {data.get('total_count', 0)} 笔,"
f"合计市值 {data.get('sum_market_value', 0)} 元{trunc_note})"
]
lines += [
f"- {it.get('product_name', it.get('product_id', '-'))}:市值 {it.get('market_value', 0)} 元"
for it in items[:5]
]
return "\n".join(lines)
if name == "query_recent_trades":
return (
f"(近 {data.get('days', '-')} 天交易流水:共 {data.get('total_count', 0)} 笔,"
f"申赎合计 {data.get('sum_amount', 0)} 元)"
)
# ---- C1 风控四只读 Tool 摘要(降级回复/LLM 上下文共用) ----
if name == "query_overdue_alerts":
items = data.get("items") or []
hours = data.get("hours_threshold")
if not items:
return f"(超期预警查询:阈值 {hours} 小时内无超期待审预警)"
lines = [f"(超期预警:共 {len(items)} 条待审超期(阈值 {hours} 小时),按超期时长降序)"]
for it in items[:5]:
lvl = it.get("escalation_level")
lvl_txt = f",已升级至 L{lvl}" if lvl else ""
lines.append(
f"- {it.get('alert_id')}({it.get('alert_type')}):已超期 {it.get('overdue_hours')} 小时{lvl_txt}"
)
return "\n".join(lines)
if name == "alert_query":
scope = data.get("scope")
pending = data.get("pending_count", 0)
today = data.get("today_pending_count", 0)
if scope == "all":
return f"(预警台账:全量待审 {pending} 条,今日新增待审 {today} 条)"
return (
f"(客户 {data.get('customer_id')} 待审预警 {pending} 条,"
f"今日 {today} 条;仅供参考,处置须经风控专员人工完成)"
)
if name == "query_agent_behavior":
items = data.get("items") or []
if not items:
return "(代理人行为链查询:无命中记录)"
lines = [f"(代理人行为链:共 {len(items)} 条记录)"]
for it in items[:5]:
subs = "/".join(it.get("subtypes_hit") or [])
lines.append(f"- 代理人 {it.get('actor_id')}:命中 {subs}({it.get('status')})")
return "\n".join(lines)
if name == "customer_context":
if not data.get("found"):
return f"(客户风控上下文:未找到客户 {data.get('customer_id')})"
l3 = data.get("l3") or {}
base = (
f"(客户 {data.get('customer_id')} {data.get('display_name', '')}:"
f"测评 {data.get('risk_code')},L3 监测档 {l3.get('monitor_tier') or '无'},"
f"待审预警 {data.get('pending_alert_count', 0)} 条"
)
# FR-8:高风险持仓占比(R4+R5);与 RISK-006 判定同源,仅供参考口径
profile = data.get("profile") or {}
ratio = profile.get("concentration_ratio")
if isinstance(ratio, (int, float)):
base += f",高风险持仓占比 {ratio:.0%}(仅供参考)"
if profile.get("holdings_truncated"):
base += "(持仓明细较多,占比为截断口径保守值)"
return base + ")"
if name == "suitability_check":
if not data.get("found"):
return f"(适当性校验:{data.get('error') or '未找到客户或产品'})"
# AL-06 对齐 main 契约:按 match_result/mismatch_type 新枚举重写文案
if data.get("needs_branch_confirm"):
verdict = "不可直接购买,需网点当面确认(FM-01)"
elif data.get("match_result") == "professional_exempt":
verdict = "可购(专业投资者豁免适当性匹配)"
elif data.get("match_result") == "risk_expired":
verdict = "不可购,风险测评已过期(FM-03),须重新测评"
elif data.get("match_result") == "allowed_with_disclosure" and not data.get("blocked"):
verdict = "可购,需签署风险揭示书"
elif data.get("match_result") == "allowed" and not data.get("blocked"):
verdict = "可购"
else:
verdict = "不可购(客户风险等级与产品风险等级不匹配)"
refs = data.get("rule_refs") or []
ref_txt = f";依据 {'、'.join(refs)}" if refs else ""
return (
f"(适当性校验:客户 {data.get('customer_risk_level')} 对产品 "
f"{data.get('product_risk_level')} {verdict}{ref_txt};"
f"仅供参考,最终以持证投顾审核为准)"
)
if name == "aml_lookup":
if not data.get("found", True):
return f"(反洗钱核查:未找到客户 {data.get('customer_id')})"
hit = data.get("hit")
return (
f"(反洗钱核查:{data.get('customer_name') or data.get('customer_id')} "
f"{'命中在册名单' if hit else '未命中在册名单'},"
f"在册名单共 {data.get('active_entry_count', 0)} 条)"
)
# ---- T21 知识库 Tool 摘要(溯源口径:回答须引用 source_doc_id + source_version) ----
if name == "search_knowledge":
results = data.get("results") or []
refs = data.get("source_refs") or []
if not results:
return "(知识库检索:未命中相关产品知识,请据实告知无相关知识,不要编造)"
lines = [f"(知识库检索:命中 {len(results)} 条,来源 {len(refs)} 份文档)"]
for r in results:
chunk = str(r.get("chunk_text", ""))
lines.append(
f"- {r.get('product_name', r.get('product_id', '-'))}"
f"({r.get('doc_type', '-')},相关度 {r.get('score', 0):.2f}):{chunk[:60]}"
+ ("…" if len(chunk) > 60 else "")
)
lines.append(
"(回答须注明来源文档与版本:"
+ ";".join(f"{r.get('source_doc_id')}@{r.get('source_version')}" for r in refs[:3])
+ ")"
)
return "\n".join(lines)
return f"(工具 {name} 完成)"
def context_text(records: list[dict[str, Any]]) -> str:
"""全部 Tool 结果 → 注入 LLM 的上下文块(摘要 + 数据 JSON 截断)。"""
parts: list[str] = []
for record in records:
parts.append(summarize(record))
if record.get("data") is not None:
payload = json.dumps(record["data"], ensure_ascii=False, default=str)
if len(payload) > _RESULT_CONTEXT_MAX_CHARS:
payload = payload[:_RESULT_CONTEXT_MAX_CHARS] + "…(truncated)"
parts.append(payload)
return "[工具查询结果]\n" + "\n".join(parts) if parts else ""