2026-09-10 21:03:44 +08:00
|
|
|
"""风控 Agent 的只读工具处理器。"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from collections import Counter
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
from app.core.contracts import RequestContext
|
|
|
|
|
from app.core.risk_contracts import RiskAlertEvidenceQuery, RiskAlertQuery
|
|
|
|
|
from app.infrastructure.db import SessionFactory
|
|
|
|
|
from app.repository.fund_query_repository import PageRequest
|
|
|
|
|
from app.repository.risk_repository import RiskRepository
|
|
|
|
|
from app.service.risk_judgement_service import (
|
|
|
|
|
assess_alert_detail,
|
|
|
|
|
assess_alert_list_item,
|
|
|
|
|
)
|
|
|
|
|
from app.service.risk_query_service import scope_from_context
|
|
|
|
|
|
2026-09-11 19:37:26 +08:00
|
|
|
FIELD_MEANINGS: dict[str, str] = {
|
|
|
|
|
"alert_no": "预警编号",
|
|
|
|
|
"customer_id": "客户编号",
|
|
|
|
|
"customer_no": "客户编号",
|
|
|
|
|
"customer_name": "客户姓名(脱敏)",
|
|
|
|
|
"product_code": "产品代码",
|
|
|
|
|
"product_name": "产品名称",
|
|
|
|
|
"alert_type": "预警类型",
|
|
|
|
|
"risk_level": "风险等级",
|
|
|
|
|
"rule_codes": "命中规则编号",
|
|
|
|
|
"triggered_rules": "命中规则编号",
|
|
|
|
|
"evidence_summary": "证据摘要",
|
|
|
|
|
"event_status": "事件状态",
|
|
|
|
|
"status": "业务状态",
|
|
|
|
|
"ack_status": "预警确认状态",
|
|
|
|
|
"created_at": "创建时间",
|
|
|
|
|
"updated_at": "更新时间",
|
|
|
|
|
"due_at": "处理截止时间",
|
|
|
|
|
"ack_at": "确认时间",
|
|
|
|
|
"escalated_at": "升级时间",
|
|
|
|
|
"priority_score": "优先级分数",
|
|
|
|
|
"is_escalated": "是否已升级",
|
|
|
|
|
"evidence_archived": "证据是否已归档",
|
|
|
|
|
"close_reason": "关闭原因",
|
|
|
|
|
"disposition_hint": "列表级只读研判建议",
|
|
|
|
|
"disposition_assessment": "详情级只读研判草案",
|
|
|
|
|
"name": "客户姓名(脱敏)",
|
|
|
|
|
"birth_date": "出生日期",
|
|
|
|
|
"occupation": "职业",
|
|
|
|
|
"mobile_masked": "手机号(脱敏)",
|
|
|
|
|
"investor_type": "投资者风险承受等级",
|
|
|
|
|
"investment_horizon": "投资期限",
|
|
|
|
|
"preferred_asset_class": "偏好资产类别",
|
|
|
|
|
"trading_frequency": "交易频率",
|
|
|
|
|
"total_asset": "总资产",
|
|
|
|
|
"behavior_score": "行为分",
|
|
|
|
|
"risk_tags": "风险标签",
|
|
|
|
|
"trade_account": "交易账号",
|
|
|
|
|
"product_category": "产品类别",
|
|
|
|
|
"risk_disclosure_required": "是否要求风险揭示",
|
|
|
|
|
"second_confirmation_required": "是否要求二次确认",
|
|
|
|
|
"recording_required": "是否要求双录",
|
|
|
|
|
"transaction_no": "交易编号",
|
|
|
|
|
"order_side": "买卖方向",
|
|
|
|
|
"transaction_type": "交易类型",
|
|
|
|
|
"amount": "金额",
|
|
|
|
|
"channel": "交易渠道",
|
|
|
|
|
"trade_status": "交易状态",
|
|
|
|
|
"risk_disclosure_signed": "风险揭示签署状态",
|
|
|
|
|
"second_confirmation": "二次确认状态",
|
|
|
|
|
"confirmed_at": "确认时间",
|
|
|
|
|
"executed_at": "成交时间",
|
|
|
|
|
"flow_no": "资金流水编号",
|
|
|
|
|
"flow_type": "资金流水类型",
|
|
|
|
|
"settled_at": "到账时间",
|
|
|
|
|
"occurred_at": "发生时间",
|
|
|
|
|
"source_type": "资金来源",
|
|
|
|
|
"match_status": "资金匹配状态",
|
|
|
|
|
"shares": "持有份额",
|
|
|
|
|
"cost_amount": "持仓成本",
|
|
|
|
|
"current_value": "当前市值",
|
|
|
|
|
"profit_loss": "浮动盈亏",
|
|
|
|
|
"profit_loss_ratio": "盈亏比例",
|
|
|
|
|
"holding_days": "持有天数",
|
|
|
|
|
"holding_ratio": "持仓占资产比例",
|
|
|
|
|
"login_at": "登录时间",
|
|
|
|
|
"login_result": "登录结果",
|
|
|
|
|
"ip_region": "登录地区",
|
|
|
|
|
"device_id": "登录设备标识",
|
|
|
|
|
"is_common_device": "是否常用设备",
|
|
|
|
|
"failure_reason": "失败原因",
|
|
|
|
|
"notification_no": "通知编号",
|
|
|
|
|
"send_status": "发送状态",
|
|
|
|
|
"receiver_email": "接收邮箱",
|
|
|
|
|
"sent_at": "发送时间",
|
|
|
|
|
"handler_id": "处理人编号",
|
|
|
|
|
"related_transaction_id": "关联交易编号",
|
|
|
|
|
"related_work_order_id": "关联工单编号",
|
|
|
|
|
"evidence_truncated": "被截断的证据类型",
|
|
|
|
|
"data_truncated": "数据是否被截断",
|
|
|
|
|
"truncated": "结果是否被截断",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def with_field_meanings(value: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
"""附加当前结果实际出现字段的中文含义,供模型转换后回答。"""
|
|
|
|
|
names: set[str] = set()
|
|
|
|
|
|
|
|
|
|
def collect(item: object) -> None:
|
|
|
|
|
if isinstance(item, dict):
|
|
|
|
|
for key, child in item.items():
|
|
|
|
|
names.add(str(key))
|
|
|
|
|
collect(child)
|
|
|
|
|
elif isinstance(item, (list, tuple)):
|
|
|
|
|
for child in item:
|
|
|
|
|
collect(child)
|
|
|
|
|
|
|
|
|
|
collect(value)
|
|
|
|
|
meanings = {
|
|
|
|
|
name: FIELD_MEANINGS[name]
|
|
|
|
|
for name in sorted(names)
|
|
|
|
|
if name in FIELD_MEANINGS
|
|
|
|
|
}
|
|
|
|
|
return {**value, "field_meanings": meanings}
|
|
|
|
|
|
2026-09-10 21:03:44 +08:00
|
|
|
|
|
|
|
|
async def search_risk_alerts_tool(
|
|
|
|
|
arguments: RiskAlertQuery,
|
|
|
|
|
context: RequestContext,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
async with SessionFactory() as session:
|
|
|
|
|
repository = RiskRepository(session, scope=scope_from_context(context))
|
|
|
|
|
items: list[dict[str, Any]] = []
|
|
|
|
|
offset = 0
|
|
|
|
|
while True:
|
|
|
|
|
page = await repository.list_alerts(
|
|
|
|
|
customer_no=arguments.customer_no,
|
|
|
|
|
product_code=arguments.product_code,
|
|
|
|
|
product_name=arguments.product_name,
|
|
|
|
|
risk_level=arguments.risk_level,
|
|
|
|
|
rule_code=arguments.rule_code,
|
|
|
|
|
start_time=arguments.start_time,
|
|
|
|
|
end_time=arguments.end_time,
|
|
|
|
|
page=PageRequest(limit=10, offset=offset),
|
|
|
|
|
)
|
|
|
|
|
for item in page.items:
|
|
|
|
|
record = item.to_dict()
|
|
|
|
|
record["disposition_hint"] = assess_alert_list_item(record)
|
|
|
|
|
items.append(record)
|
|
|
|
|
if page.next_offset is None:
|
|
|
|
|
return build_alert_search_result(
|
|
|
|
|
items,
|
|
|
|
|
arguments.model_dump(mode="json", exclude_none=True),
|
|
|
|
|
)
|
|
|
|
|
offset = page.next_offset
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_alert_search_result(
|
|
|
|
|
items: list[dict[str, Any]],
|
|
|
|
|
filters: dict[str, Any] | None = None,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""构建完整分组汇总和精简预警明细。"""
|
|
|
|
|
compact_items = [_compact_alert_item(item) for item in items]
|
|
|
|
|
rule_counts: Counter[str] = Counter()
|
|
|
|
|
disposition_counts: Counter[str] = Counter()
|
|
|
|
|
for item in compact_items:
|
|
|
|
|
rule_counts.update(item.get("rule_codes") or ())
|
|
|
|
|
hint = item.get("disposition_hint") or {}
|
|
|
|
|
if isinstance(hint, dict) and hint.get("verdict"):
|
|
|
|
|
disposition_counts[str(hint["verdict"])] += 1
|
2026-09-11 19:37:26 +08:00
|
|
|
return with_field_meanings({
|
2026-09-10 21:03:44 +08:00
|
|
|
"total": len(compact_items),
|
|
|
|
|
"filters": filters or {},
|
|
|
|
|
"summary": {
|
|
|
|
|
"customer_groups": _customer_groups(compact_items),
|
|
|
|
|
"product_groups": _product_groups(compact_items),
|
|
|
|
|
"risk_level_counts": dict(sorted(Counter(
|
|
|
|
|
str(item.get("risk_level") or "未知")
|
|
|
|
|
for item in compact_items
|
|
|
|
|
).items())),
|
|
|
|
|
"rule_counts": dict(sorted(rule_counts.items())),
|
|
|
|
|
"disposition_counts": dict(sorted(disposition_counts.items())),
|
|
|
|
|
"complete": True,
|
|
|
|
|
},
|
|
|
|
|
"items": compact_items,
|
2026-09-11 19:37:26 +08:00
|
|
|
})
|
2026-09-10 21:03:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _compact_alert_item(item: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
fields = (
|
|
|
|
|
"alert_no",
|
|
|
|
|
"customer_no",
|
|
|
|
|
"customer_name",
|
|
|
|
|
"product_code",
|
|
|
|
|
"product_name",
|
|
|
|
|
"alert_type",
|
|
|
|
|
"risk_level",
|
|
|
|
|
"rule_codes",
|
|
|
|
|
"evidence_summary",
|
|
|
|
|
"status",
|
|
|
|
|
"event_status",
|
|
|
|
|
"created_at",
|
|
|
|
|
"disposition_hint",
|
|
|
|
|
)
|
|
|
|
|
return {field: item.get(field) for field in fields if field in item}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _customer_groups(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
|
|
|
groups: dict[str, dict[str, Any]] = {}
|
|
|
|
|
for item in items:
|
|
|
|
|
customer_no = item.get("customer_no")
|
|
|
|
|
if not customer_no:
|
|
|
|
|
continue
|
|
|
|
|
key = str(customer_no)
|
|
|
|
|
group = groups.setdefault(
|
|
|
|
|
key,
|
|
|
|
|
{
|
|
|
|
|
"customer_no": key,
|
|
|
|
|
"customer_name": item.get("customer_name"),
|
|
|
|
|
"alert_count": 0,
|
|
|
|
|
"risk_levels": set(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
group["alert_count"] += 1
|
|
|
|
|
if item.get("risk_level"):
|
|
|
|
|
group["risk_levels"].add(str(item["risk_level"]))
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
**group,
|
|
|
|
|
"risk_levels": sorted(group["risk_levels"]),
|
|
|
|
|
}
|
|
|
|
|
for group in sorted(
|
|
|
|
|
groups.values(),
|
|
|
|
|
key=lambda value: (-value["alert_count"], value["customer_no"]),
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _product_groups(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
|
|
|
groups: dict[str, dict[str, Any]] = {}
|
|
|
|
|
for item in items:
|
|
|
|
|
product_code = item.get("product_code")
|
|
|
|
|
product_name = item.get("product_name")
|
|
|
|
|
key = str(product_code or product_name or "")
|
|
|
|
|
if not key:
|
|
|
|
|
continue
|
|
|
|
|
group = groups.setdefault(
|
|
|
|
|
key,
|
|
|
|
|
{
|
|
|
|
|
"product_code": product_code,
|
|
|
|
|
"product_name": product_name,
|
|
|
|
|
"alert_count": 0,
|
|
|
|
|
"risk_levels": set(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
group["alert_count"] += 1
|
|
|
|
|
if item.get("risk_level"):
|
|
|
|
|
group["risk_levels"].add(str(item["risk_level"]))
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
**group,
|
|
|
|
|
"risk_levels": sorted(group["risk_levels"]),
|
|
|
|
|
}
|
|
|
|
|
for group in sorted(
|
|
|
|
|
groups.values(),
|
|
|
|
|
key=lambda value: (
|
|
|
|
|
-value["alert_count"],
|
|
|
|
|
str(value["product_code"] or value["product_name"]),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_risk_overview_tool(
|
|
|
|
|
arguments: RiskAlertQuery,
|
|
|
|
|
context: RequestContext,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
del arguments
|
|
|
|
|
async with SessionFactory() as session:
|
2026-09-11 19:37:26 +08:00
|
|
|
data = await RiskRepository(session, scope=scope_from_context(context)).overview()
|
|
|
|
|
return with_field_meanings(data)
|
2026-09-10 21:03:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_alert_evidence_tool(
|
|
|
|
|
arguments: RiskAlertEvidenceQuery,
|
|
|
|
|
context: RequestContext,
|
|
|
|
|
) -> dict[str, Any] | None:
|
|
|
|
|
async with SessionFactory() as session:
|
|
|
|
|
record = await RiskRepository(
|
|
|
|
|
session,
|
|
|
|
|
scope=scope_from_context(context),
|
|
|
|
|
).get_alert_detail(arguments.alert_no)
|
|
|
|
|
if record is None:
|
|
|
|
|
return None
|
|
|
|
|
detail = record.to_dict()
|
|
|
|
|
detail["disposition_assessment"] = assess_alert_detail(detail)
|
2026-09-11 19:37:26 +08:00
|
|
|
return with_field_meanings(detail)
|