代理人异常行为链识别(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)。
489 lines
22 KiB
Python
489 lines
22 KiB
Python
"""对话 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 ""
|