- C1: app/service/risk/chat_tools.py 新增 alert_query/customer_context/suitability_check/aml_lookup 四只读 Tool,挂 RISK_TOOL_REGISTRY(与 core_tools.ToolSpec 同构) - C1: tool_service.run_tool 改查 core+risk 统一注册表并恒传 risk_repo;_normalize_params 读 spec 白名单;core_tools 三函数加 risk_repo=None;summarize 补四工具分支 - C2: match_intent 按 agent 分组扩 risk 关键词;agent_service.tool_node 守卫放宽(requires_customer=False 的 Tool 允许无绑定客户运行,支撑 A-6 全局待审) - C3: A-6 验收(risk_officer 问待审预警→alert_query 全量→回复含待审数)+ 诱导处置边界(无处置 Tool、系统提示禁自动处置、诱导只触发只读且预警状态不变) - 测试 tests/test_risk_chat_tools.py(21 例);全量 pytest 342 绿(原 321 + 21)
237 lines
9.7 KiB
Python
237 lines
9.7 KiB
Python
"""风控对话 Tool(C1 · 开发计划阶段 C):alert_query / customer_context /
|
||
suitability_check / aml_lookup 四个只读 Tool。
|
||
|
||
定位(与 core_tools 同构的 Tool 定义层,纯查询):由 tool_service.run_tool
|
||
经统一注册表分发(C1 复用 T-04 run_tool 基建:意图→归属校验→落库)。func
|
||
统一签名 ``func(customer_id, core_ro, risk_repo, **params)``;run_tool 恒传
|
||
core_ro / risk_repo,未知 Tool 一律 TOOL_UNKNOWN。
|
||
|
||
红线(与 MEMORY 禁止项一致):
|
||
- 只读:不生成预警单、不处置、不改正式风险等级、不写业务表;suitability_check
|
||
复用 suitability.suitability_check 走原审计落库(risk_suitability_log 仅
|
||
INSERT,PRD §7.3 口径),属可追溯审计而非业务写。
|
||
- 归属由 run_tool 的 assert_tool_access 把关(customer 本人 / advisor active /
|
||
risk_officer 全量 / 其余 fail-closed),本层不重复判定。
|
||
- ``alert_query`` 是唯一 ``requires_customer=False`` 的"台账类统计"Tool:session
|
||
注入 customer_id 空缺(risk_officer 查全量待审)时返回全量,否则返回该客户
|
||
预警——正好支撑 A-6「今天有多少待审预警」。
|
||
|
||
返回值:JSON 安全 dict(Decimal→float、datetime→isoformat,落库 tool_output 与
|
||
LLM 上下文共用同一结构,不做第二套序列化,对齐 core_tools 约定)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import datetime as _dt
|
||
from typing import Any, Callable
|
||
|
||
from app.repository.core_ro import CoreReadOnlyRepository
|
||
from app.repository.risk_repository import RiskRepository
|
||
from app.service import suitability
|
||
from app.tool.core_tools import _jsonable
|
||
from app.utils.exceptions import NotFoundError
|
||
|
||
|
||
class RiskToolSpec(dict):
|
||
"""风控 Tool 注册表条目:func / description / requires_customer /
|
||
param_whitelist / int_bounds(与 core_tools.ToolSpec 同构,供 run_tool 复用)。"""
|
||
|
||
|
||
# ---------- 内部工具 ----------
|
||
|
||
|
||
def _day_start() -> _dt.datetime:
|
||
"""本地日界起点(naive 本地时间,与 core_tools._now_naive 同口径;B8 localtime 挂账)。"""
|
||
now = _dt.datetime.now()
|
||
return _dt.datetime(now.year, now.month, now.day)
|
||
|
||
|
||
def _alert_brief(alert: dict[str, Any]) -> dict[str, Any]:
|
||
"""预警精简视图(alert_id 溯源 + 关键字段,避免 payload 全量塞进上下文)。"""
|
||
payload = alert.get("payload") or {}
|
||
summary = payload.get("summary") if isinstance(payload, dict) else None
|
||
return {
|
||
"alert_id": alert.get("alert_id"),
|
||
"customer_id": alert.get("customer_id"),
|
||
"alert_type": alert.get("alert_type"),
|
||
"risk_score": alert.get("risk_score"),
|
||
"status": alert.get("status"),
|
||
"created_at": alert.get("created_at").isoformat()
|
||
if isinstance(alert.get("created_at"), (_dt.datetime, _dt.date))
|
||
else alert.get("created_at"),
|
||
"triggered_rules": alert.get("triggered_rules"),
|
||
"summary": summary,
|
||
}
|
||
|
||
|
||
# ---------- 四个只读 Tool ----------
|
||
|
||
|
||
def alert_query(customer_id: str, core_ro: CoreReadOnlyRepository | None = None,
|
||
risk_repo: RiskRepository | None = None, **params: Any) -> dict[str, Any]:
|
||
"""预警查询(客户维度或全量待审)。
|
||
|
||
customer_id 由 session 注入:有值→该客户 pending_review 预警;空缺
|
||
(risk_officer 查全量)→全部 pending_review 预警,并给出"今日新增"口径
|
||
(created_at >= 本地日界),直接回答 A-6。返回含 alert_id 列表,可溯源。
|
||
"""
|
||
repo = risk_repo or RiskRepository()
|
||
start = _day_start()
|
||
if customer_id:
|
||
rows, total = repo.list_alerts(customer_id=customer_id, status="pending_review", page_size=50)
|
||
_, today_total = repo.list_alerts(
|
||
customer_id=customer_id, status="pending_review", start=start, page_size=50
|
||
)
|
||
scope = "customer"
|
||
else:
|
||
rows, total = repo.list_alerts(status="pending_review", page_size=100)
|
||
_, today_total = repo.list_alerts(status="pending_review", start=start, page_size=100)
|
||
scope = "all"
|
||
return _jsonable(
|
||
{
|
||
"scope": scope,
|
||
"customer_id": customer_id or None,
|
||
"pending_count": total,
|
||
"today_pending_count": today_total,
|
||
"items": [_alert_brief(r) for r in rows],
|
||
}
|
||
)
|
||
|
||
|
||
def customer_context(customer_id: str, core_ro: CoreReadOnlyRepository | None = None,
|
||
risk_repo: RiskRepository | None = None, **params: Any) -> dict[str, Any]:
|
||
"""客户风控上下文(L0 档案 + L3 监测 + 待审预警)。"""
|
||
repo = risk_repo or RiskRepository()
|
||
ro = core_ro or CoreReadOnlyRepository()
|
||
l0 = ro.get_customer_l0(customer_id)
|
||
if l0 is None:
|
||
return {"found": False, "customer_id": customer_id}
|
||
l3 = repo.get_l3(customer_id)
|
||
pending, pending_total = repo.list_alerts(
|
||
customer_id=customer_id, status="pending_review", page_size=20
|
||
)
|
||
return _jsonable(
|
||
{
|
||
"found": True,
|
||
"customer_id": customer_id,
|
||
"display_name": l0.get("display_name"),
|
||
"age": l0.get("age"),
|
||
"risk_code": l0.get("risk_code"),
|
||
"risk_evaluated_at": l0.get("risk_evaluated_at"),
|
||
"l3": {
|
||
"monitor_tier": l3.get("monitor_tier") if l3 else None,
|
||
"monitor_tags": l3.get("monitor_tags") if l3 else None,
|
||
"risk_score": l3.get("risk_score") if l3 else None,
|
||
"computed_at": l3.get("computed_at") if l3 else None,
|
||
},
|
||
"pending_alert_count": pending_total,
|
||
"pending_alerts": [_alert_brief(r) for r in pending],
|
||
}
|
||
)
|
||
|
||
|
||
def suitability_check(customer_id: str, core_ro: CoreReadOnlyRepository | None = None,
|
||
risk_repo: RiskRepository | None = None, **params: Any) -> dict[str, Any]:
|
||
"""适当性校验(SUIT-001~008):给定产品判断客户是否可购(只读核查,落审计日志)。
|
||
|
||
product_id 由 LLM/调用方经白名单传入;复用 suitability.suitability_check
|
||
走原审计落库(risk_suitability_log 仅 INSERT)。NotFound(客户/产品缺失)
|
||
转为结构化 found=False,不抛异常污染对话链路(run_tool 仍记 success 落库)。
|
||
"""
|
||
product_id = params.get("product_id")
|
||
if not product_id:
|
||
return {"found": False, "error": "missing_product_id"}
|
||
ro = core_ro or CoreReadOnlyRepository()
|
||
repo = risk_repo or RiskRepository()
|
||
try:
|
||
result = suitability.suitability_check(customer_id, product_id, core_ro=ro, risk_repo=repo)
|
||
except NotFoundError as exc:
|
||
return {"found": False, "error": str(exc)}
|
||
return _jsonable(
|
||
{
|
||
"found": True,
|
||
"customer_id": customer_id,
|
||
"product_id": product_id,
|
||
"customer_risk_level": result.customer_level,
|
||
"effective_level": result.effective_level,
|
||
"product_risk_level": result.product_level,
|
||
"is_matched": result.is_matched,
|
||
"blocked": result.blocked,
|
||
"rule_id": result.rule_id,
|
||
"block_reason": result.block_reason,
|
||
"reasons": result.reasons,
|
||
}
|
||
)
|
||
|
||
|
||
def aml_lookup(customer_id: str, core_ro: CoreReadOnlyRepository | None = None,
|
||
risk_repo: RiskRepository | None = None, **params: Any) -> dict[str, Any]:
|
||
"""反洗钱名单核查(按客户姓名匹配在册名单)。
|
||
|
||
在册名单以 full_name 为匹配键(Core L0 仅含 display_name,无证件号/卡号,
|
||
按项目数据现状以姓名精确匹配;跨源证件匹配归 R-05/AML 增强)。返回命中项
|
||
与在册总数,供 Agent 溯源与提示,不自动处置。
|
||
"""
|
||
repo = risk_repo or RiskRepository()
|
||
ro = core_ro or CoreReadOnlyRepository()
|
||
l0 = ro.get_customer_l0(customer_id)
|
||
if l0 is None:
|
||
return {"found": False, "customer_id": customer_id, "error": "customer not found"}
|
||
name = (l0.get("display_name") or "").strip()
|
||
entries = repo.list_active_aml_entries()
|
||
matched = [
|
||
{
|
||
"list_id": e.get("list_id"),
|
||
"list_type": e.get("list_type"),
|
||
"full_name": e.get("full_name"),
|
||
"source": e.get("source"),
|
||
}
|
||
for e in entries
|
||
if name and e.get("full_name") and name == e.get("full_name")
|
||
]
|
||
return _jsonable(
|
||
{
|
||
"found": True,
|
||
"customer_id": customer_id,
|
||
"customer_name": name or None,
|
||
"hit": bool(matched),
|
||
"matched_entries": matched,
|
||
"active_entry_count": len(entries),
|
||
}
|
||
)
|
||
|
||
|
||
# ---------- 注册表(供 tool_service.get_registered_tool 分发) ----------
|
||
|
||
|
||
RISK_TOOL_REGISTRY: dict[str, RiskToolSpec] = {
|
||
"alert_query": RiskToolSpec(
|
||
func=alert_query,
|
||
description="查询风控预警(客户维度或全量待审;risk_officer 可不带客户查全量待审)",
|
||
requires_customer=False,
|
||
param_whitelist=(),
|
||
int_bounds={},
|
||
),
|
||
"customer_context": RiskToolSpec(
|
||
func=customer_context,
|
||
description="查询客户风控上下文(L0 档案 + L3 监测 + 待审预警)",
|
||
requires_customer=True,
|
||
param_whitelist=(),
|
||
int_bounds={},
|
||
),
|
||
"suitability_check": RiskToolSpec(
|
||
func=suitability_check,
|
||
description="适当性校验(SUIT-001~008):给定产品判断客户是否可购(仅供参考)",
|
||
requires_customer=True,
|
||
param_whitelist=("product_id",),
|
||
int_bounds={},
|
||
),
|
||
"aml_lookup": RiskToolSpec(
|
||
func=aml_lookup,
|
||
description="反洗钱名单核查(按客户姓名匹配在册名单)",
|
||
requires_customer=True,
|
||
param_whitelist=(),
|
||
int_bounds={},
|
||
),
|
||
}
|