"""风控对话 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={}, ), }