Files
XingHuo/app/service/risk/chat_tools.py
T
GaoYiYuan_0626 930ea9db56 feat: 阶段C 风控对话线(C1 四只读 Tool + C2 risk 分支意图 + C3 A-6 验收与边界)
- 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)
2026-09-07 10:26:54 +08:00

237 lines
9.7 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(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={},
),
}