"""对话 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 ""