diff --git a/app/service/agent/implementations/risk_agent.py b/app/service/agent/implementations/risk_agent.py index 19b5c45..489a972 100644 --- a/app/service/agent/implementations/risk_agent.py +++ b/app/service/agent/implementations/risk_agent.py @@ -362,7 +362,15 @@ def _agent_system_prompt(message: str) -> str: "8. 查询结果包含 summary 时,客户、产品和规则数量必须依据完整 summary," "不能因为 items 被截断就回答只覆盖部分记录。\n" "9. 最终回答使用中文,简洁说明结论、依据和剩余风险,并提醒由风控专员人工复核。\n" - f"{context}\n{filter_context}" + f"{context}\n{filter_context}\n{_truncation_instruction()}" + ) + + +def _truncation_instruction() -> str: + return ( + "10. 工具结果出现 data_truncated=true、evidence_truncated 非空或 " + "truncated=true 时,必须明确说明当前证据不完整,不能按全量证据下结论;" + "只有结果明确完整时,才能表述为覆盖全部记录。" ) diff --git a/app/service/risk_notification_service.py b/app/service/risk_notification_service.py index fe326bd..9cbddf3 100644 --- a/app/service/risk_notification_service.py +++ b/app/service/risk_notification_service.py @@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.schemas.risk import RiskNotificationPageQuery from app.core.contracts import RequestContext from app.core.risk_cursor import decode_offset_cursor +from app.core.timeutil import from_local from app.model.fund import FundRiskAlert, FundRiskNotification from app.repository.fund_query_repository import PageRequest from app.repository.risk_repository import RiskRepository @@ -38,8 +39,8 @@ class RiskNotificationService: ).list_notifications( keyword=query.keyword, send_status=query.send_status, - start_time=query.start_time, - end_time=query.end_time, + start_time=from_local(query.start_time) if query.start_time else None, + end_time=from_local(query.end_time) if query.end_time else None, page=PageRequest( limit=query.limit, offset=decode_offset_cursor(query.cursor, binding=binding), diff --git a/app/service/risk_query_service.py b/app/service/risk_query_service.py index b72bff7..114f447 100644 --- a/app/service/risk_query_service.py +++ b/app/service/risk_query_service.py @@ -112,6 +112,8 @@ class RiskQueryService: offset=decode_offset_cursor(query.cursor, binding=binding), ) repository = self._repository(context) + start_time = from_local(query.start_time) if query.start_time else None + end_time = from_local(query.end_time) if query.end_time else None if source == "customers": page = await repository.list_customers( keyword=query.keyword, @@ -123,15 +125,15 @@ class RiskQueryService: elif source == "transactions": page = await repository.list_transactions( keyword=query.keyword, - start_time=query.start_time, - end_time=query.end_time, + start_time=start_time, + end_time=end_time, page=page_request, ) elif source == "capital_flows": page = await repository.list_capital_flows( keyword=query.keyword, - start_time=query.start_time, - end_time=query.end_time, + start_time=start_time, + end_time=end_time, page=page_request, ) elif source == "holdings": @@ -139,8 +141,8 @@ class RiskQueryService: elif source == "login_records": page = await repository.list_login_records( keyword=query.keyword, - start_time=query.start_time, - end_time=query.end_time, + start_time=start_time, + end_time=end_time, page=page_request, ) elif source == "alerts": @@ -150,8 +152,8 @@ class RiskQueryService: page = await repository.list_notifications( keyword=query.keyword, send_status=query.send_status, - start_time=query.start_time, - end_time=query.end_time, + start_time=start_time, + end_time=end_time, page=page_request, ) else: diff --git a/config/risk_agent_intents.json b/config/risk_agent_intents.json new file mode 100644 index 0000000..c1d3f53 --- /dev/null +++ b/config/risk_agent_intents.json @@ -0,0 +1,59 @@ +{ + "agent_type": "risk", + "version": 1, + "status": "active", + "priority": 100, + "confidence_threshold": "0.6500", + "max_clarification_rounds": 2, + "transfer_on_failure": true, + "classifier_instruction": "只用于只读工具查询、研判草案和边界说明,不执行人工处置。", + "intents": [ + { + "intent_code": "risk_overview", + "intent_name": "风险概览", + "description": "奶龙风控智能助手:风险概览", + "examples": [ + "查看当前风险概览", + "当前有多少高风险预警" + ], + "allowed_tools": [ + "get_risk_overview" + ] + }, + { + "intent_code": "risk_search", + "intent_name": "风险查询", + "description": "奶龙风控智能助手:风险查询", + "examples": [ + "查询高风险预警", + "查看命中 RW-007 的预警" + ], + "allowed_tools": [ + "search_risk_alerts" + ] + }, + { + "intent_code": "risk_evidence", + "intent_name": "预警证据", + "description": "奶龙风控智能助手:预警证据", + "examples": [ + "查询预警编号 ALERT-001 的证据", + "查看这条预警的证据链" + ], + "allowed_tools": [ + "get_alert_evidence" + ] + }, + { + "intent_code": "general", + "intent_name": "通用风控查询", + "description": "奶龙风控智能助手:通用风控查询", + "examples": [ + "你能做什么", + "说明你的功能边界" + ], + "allowed_tools": [] + } + ] +} + diff --git a/config/risk_agent_tools.json b/config/risk_agent_tools.json new file mode 100644 index 0000000..36803d4 --- /dev/null +++ b/config/risk_agent_tools.json @@ -0,0 +1,26 @@ +{ + "release_no": "risk-agent-local-v1", + "namespace": "agent_tools", + "schema_version": "1", + "configs": { + "risk:risk_overview": { + "allowed_tools": [ + "get_risk_overview" + ] + }, + "risk:risk_search": { + "allowed_tools": [ + "search_risk_alerts" + ] + }, + "risk:risk_evidence": { + "allowed_tools": [ + "get_alert_evidence" + ] + }, + "risk:general": { + "allowed_tools": [] + } + } +} + diff --git a/docs/风控业务演示文档/02-主项目接入清单.md b/docs/风控业务演示文档/02-主项目接入清单.md index 0e47340..a227d20 100644 --- a/docs/风控业务演示文档/02-主项目接入清单.md +++ b/docs/风控业务演示文档/02-主项目接入清单.md @@ -31,7 +31,7 @@ | 允许角色 | `risk_operator`、`admin` | | 允许入口 | `api` | | 只读工具 | 风险概览、预警查询、预警证据 | -| 权限 | `agent:run`、`risk:alert:read`、`risk:alert:write`、`risk:alert:scan` | +| 权限 | `agent:run`、`risk:alert:read`、`risk:alert:write`、`risk:alert:scan`、`risk:report:mail` | ## 数据库依赖 @@ -48,8 +48,8 @@ - 主项目路由能够访问本模块只读查询接口。 - 风控账号拥有角色和对应权限。 +- 风控写接口和扫描接口支持并携带 `Idempotency-Key`。 - 风控账号具有有效的客户归属数据。 - Agent Run 能够调用本模块工具。 - 工具调用、处置和权限拒绝能够写入审计。 - Redis 或 Milvus 不可用时,结构化风控功能仍可用。 - diff --git a/docs/风控业务演示文档/06-模块接口与字段映射.md b/docs/风控业务演示文档/06-模块接口与字段映射.md index b56316f..53d22a2 100644 --- a/docs/风控业务演示文档/06-模块接口与字段映射.md +++ b/docs/风控业务演示文档/06-模块接口与字段映射.md @@ -8,11 +8,25 @@ - 路由前缀为 `/api/v1/risk`。 - 响应使用主项目 `{data, meta}` 信封。 +- 列表接口的 `data` 为数组,`next_cursor` 和 `has_more` 放在 `meta`。 - 时间和日期使用 RFC 3339 或主项目约定格式。 - 金额、数量和主键按主项目字段映射返回字符串。 - 预警队列每页最多 5 条,其他证据列表每页最多 10 条。 - 未授权请求返回主项目统一权限错误。 +列表响应格式: + +```json +{ + "data": [], + "meta": { + "trace_id": "trace-id", + "next_cursor": "opaque-cursor", + "has_more": false + } +} +``` + ## 只读接口 | 方法 | 路径 | 功能 | 权限 | @@ -122,3 +136,9 @@ | `customer_id` | 兼容字段,值与 `customer_no` 相同,仅供既有前端继续使用 | 内部 `fin_customer_profile.customer_id` 和 `sys_user.id` 不向预警详情接口暴露。 + +## 时间参数 + +- 带时区的时间按自身时区解释。 +- 不带时区的 REST 时间参数按 `Asia/Shanghai` 解释,再转换为 UTC 查询。 +- 客户端不能继续假设裸时间是 UTC。 diff --git a/docs/风控业务演示文档/07-权限与数据范围说明.md b/docs/风控业务演示文档/07-权限与数据范围说明.md index bea59dc..742267d 100644 --- a/docs/风控业务演示文档/07-权限与数据范围说明.md +++ b/docs/风控业务演示文档/07-权限与数据范围说明.md @@ -36,6 +36,7 @@ JWT 身份 | `risk:alert:read` | 概览、预警、详情、证据、通知、日报、Agent 只读工具 | | `risk:alert:write` | 确认、调查、误报、结案、升级、证据归档 | | `risk:alert:scan` | 手动或受控规则扫描 | +| `risk:report:mail` | 发送日报邮件 | ## 客户数据范围 @@ -55,6 +56,17 @@ WHERE employee_id = 当前用户ID - 只能访问有效分配客户的预警和相关证据。 - 权限决定功能访问,客户归属决定数据访问。 - 权限拒绝必须追加审计。 +- 写接口和扫描接口必须同时携带有效 `Idempotency-Key`。 + +## 当前数据范围风险 + +公共身份层当前取用户所有权限中的最高 `data_scope`: + +```text +任意权限为 all -> context.data_scope=all +``` + +这可能导致一个权限的 `all` 范围扩散到其他资源。主项目合并后应改为按权限码分别应用数据范围,避免风控客户范围被其他资源的全量权限绕过。 ## 管理角色 @@ -69,4 +81,3 @@ WHERE employee_id = 当前用户ID - 未分配客户不能通过直接请求越权访问。 - 缺少写入权限时不能执行处置。 - 权限或客户归属失效后,下一次请求立即生效。 - diff --git a/docs/风控业务演示文档/08-数据库依赖与读写边界.md b/docs/风控业务演示文档/08-数据库依赖与读写边界.md index 0d4f4c1..8c7fed0 100644 --- a/docs/风控业务演示文档/08-数据库依赖与读写边界.md +++ b/docs/风控业务演示文档/08-数据库依赖与读写边界.md @@ -74,3 +74,9 @@ - 证据归档先写文件,成功后更新证据快照;失败不得留下业务状态脏数据。 - 审计只允许追加,不允许普通业务接口修改或删除。 +## 查询上限与索引 + +- `fin_risk_alert.trigger_rule_codes` 增加 JSON 多值索引,用于规则编号筛选。 +- 预警详情中的资金流、持仓和登录记录分别最多返回 200 条。 +- 日报每组预警最多读取 5000 条。 +- 超限时通过 `evidence_truncated` 或 `data_truncated` 显式标记,不能伪装为全量结果。 diff --git a/docs/风控业务演示文档/10-Agent工具与调用流程.md b/docs/风控业务演示文档/10-Agent工具与调用流程.md index 1ab9d9b..1eedc60 100644 --- a/docs/风控业务演示文档/10-Agent工具与调用流程.md +++ b/docs/风控业务演示文档/10-Agent工具与调用流程.md @@ -49,6 +49,16 @@ - 累计最多 6 次工具调用。 - 单次工具结果超过限制时截断,但完整分组汇总优先保留。 +模型端点按任务能力筛选: + +- `risk_agent_chat` +- `risk_analysis` +- `risk_script` +- `risk_summary` +- `daily_report_suggestion` + +以上任务均要求文本生成能力,不能落回 embedding 端点。未登记的任务类型会告警并退回全部 active 端点。 + ## 完整回答原则 - 查询结果包含 `summary` 时,客户、产品和规则数量以完整汇总为准。 @@ -81,4 +91,3 @@ - 成功、失败或拒绝状态。 - 拒绝原因或执行结果摘要。 - `trace_id`。 - diff --git a/docs/风控业务演示文档/11-证据来源与脱敏说明.md b/docs/风控业务演示文档/11-证据来源与脱敏说明.md index a8d64d4..95fc69c 100644 --- a/docs/风控业务演示文档/11-证据来源与脱敏说明.md +++ b/docs/风控业务演示文档/11-证据来源与脱敏说明.md @@ -45,6 +45,8 @@ 当前业务状态发生变化时,应重新读取当前证据复核,不能只依赖历史摘要。 +资金流、持仓和登录记录单次最多返回 200 条。`evidence_truncated` 非空时,表示对应证据被截断,不能当作完整证据链。 + ## 脱敏要求 - 客户姓名只保留首字,其余用 `*`。 @@ -52,6 +54,7 @@ - 日志、模型输入和审计摘要不得记录明文密码或密钥。 - 敏感编号只保留业务所需最小信息。 - 文件归档不得覆盖其他预警证据。 +- 归档审计必须写入 `created_at`,否则数据库非空约束会让上传返回 500。 ## 文件归档 diff --git a/docs/风控业务演示文档/12-日报通知与邮件规则.md b/docs/风控业务演示文档/12-日报通知与邮件规则.md index b2eec4a..8189d5f 100644 --- a/docs/风控业务演示文档/12-日报通知与邮件规则.md +++ b/docs/风控业务演示文档/12-日报通知与邮件规则.md @@ -16,6 +16,9 @@ 历史未闭环不受分页限制。 +- 日界按 `Asia/Shanghai` 计算,再转换为 UTC 查询。 +- 每组预警最多读取 5000 条;超限时返回 `data_truncated=true`。 + ## 九段式模板 1. 当日预警数量。 @@ -40,12 +43,15 @@ - 高风险预警可以生成站内或邮件通知记录。 - 通知内容包含预警编号,便于定位原始预警。 - 通知发送失败不能回滚已经生成的预警。 +- 扫描结果通过 `notification_failure` 暴露通知创建失败原因,不能再把失败与无需通知都显示成 `0`。 - 通知查询按主项目分页和权限规则执行。 - 数据库保留 `read_at`、`acknowledged_at` 基线字段,但当前未实现写入逻辑。 - 通知接口和前端不返回或展示阅读时间、确认时间。 ## 邮件开关 +发送日报邮件需要 `risk:report:mail` 权限。 + 日报邮件默认不发送真实邮件。主要配置项: - `RISK_DAILY_REPORT_MAIL_ENABLED` diff --git a/docs/风控业务演示文档/14-审计与追溯映射.md b/docs/风控业务演示文档/14-审计与追溯映射.md index d829d75..0e284eb 100644 --- a/docs/风控业务演示文档/14-审计与追溯映射.md +++ b/docs/风控业务演示文档/14-审计与追溯映射.md @@ -31,6 +31,8 @@ | `risk_evidence_archived` | 证据归档 | 预警编号、文件信息 | | `risk_ai_analysis_generated` | 生成研判、话术或摘要 | 输出类型和来源 | | `risk_daily_report_generated` | 生成日报 | 报表统计摘要 | +| `risk_scan_scheduled_succeeded` | 定时扫描成功 | 扫描计数、尝试次数 | +| `risk_scan_scheduled_failed` | 定时扫描失败 | 尝试次数、错误类型 | ## Agent 和权限审计 @@ -42,6 +44,13 @@ | `agent.access_denied` | 创建运行时权限拒绝 | | `permission.denied` | Service 权限校验失败 | +风控写接口还会写入 `api_request_receipt`: + +- 绑定用户、路径、幂等键和请求摘要。 +- 重复请求返回首次结果。 +- 同键不同请求返回 `409 IDEMPOTENCY_CONFLICT`。 +- 证据上传目前依靠“同一预警只能归档一次”实现业务防重。 + ## 追踪建议 排查一次 Agent 对话时,可以按以下顺序关联: @@ -69,4 +78,3 @@ alert_no - 敏感原文不直接写入详情。 - 工具参数只保留必要摘要。 - 权限拒绝也属于审计事件。 - diff --git a/docs/风控业务演示文档/15-模块验收与演示清单.md b/docs/风控业务演示文档/15-模块验收与演示清单.md index 092a0eb..641e0d4 100644 --- a/docs/风控业务演示文档/15-模块验收与演示清单.md +++ b/docs/风控业务演示文档/15-模块验收与演示清单.md @@ -28,6 +28,8 @@ 3. 风控账号访问未分配客户,应失败关闭。 4. 缺少写权限时,人工处置接口应拒绝。 5. 权限拒绝应写入审计。 +6. `risk_operator` 应具备 `risk:alert:read`、`risk:alert:write`、`risk:alert:scan` 和 `risk:report:mail`。 +7. 风控写接口和扫描接口应携带有效 `Idempotency-Key`。 ## 业务验收 @@ -37,6 +39,7 @@ - 定时扫描配置开启后按周期执行。 - 定时扫描默认关闭,多个 Worker 同时运行时不重复执行。 - 定时扫描成功和失败写入系统审计。 +- 扫描返回 `notification_failure` 时,页面必须区分成功与通知失败。 - 重复扫描不重复生成同一交易和规则的预警。 - 多规则命中时正确合并。 @@ -51,7 +54,10 @@ ### 证据和归档 - 八类证据可以筛选和分页。 +- 列表接口使用 `data` 数组,分页字段位于 `meta.next_cursor`、`meta.has_more`。 +- 游标必须绑定当前用户、查询条件和证据路径。 - 预警详情聚合交易、产品、工单、资金、持仓和登录证据。 +- `evidence_truncated` 非空时,页面和 Agent 必须提示证据被截断。 - 合法文件归档成功。 - 非法文件和重复归档被拒绝。 @@ -59,8 +65,10 @@ - 日报包含九段式内容。 - 历史未闭环不受分页限制。 +- 日报日界按北京时间计算。 +- `data_truncated=true` 时不得把计数当作全量。 - 误报、处置结果和规则效果正确统计。 -- 邮件开关关闭时不发送真实邮件。 +- 邮件接口要求 `risk:report:mail`,开关关闭时不发送真实邮件。 ## Agent 验收 diff --git a/docs/风控业务演示文档/16-已知限制与待办.md b/docs/风控业务演示文档/16-已知限制与待办.md index d43ccaa..e7f03c1 100644 --- a/docs/风控业务演示文档/16-已知限制与待办.md +++ b/docs/风控业务演示文档/16-已知限制与待办.md @@ -51,7 +51,9 @@ ## 后续建议 - 主项目合并完成后对齐统一 RBAC 和客户数据范围。 +- 修复公共 `data_scope` 最高权限跨资源扩散问题。 +- 确认幂等回执与业务 Action 内部提交的事务边界。 +- 私有前端最后再适配 `data/meta` 列表信封和写接口 `Idempotency-Key`。 - 根据合规要求确定会话保留期、脱敏和归档策略。 - 正式前端接入前完成接口字段最终冻结。 - 在网络可用时保留 Ruff、MyPy 和结构审计结果作为合并证据。 - diff --git a/docs/风控业务演示文档/17-前端合并提示词与验收约束.md b/docs/风控业务演示文档/17-前端合并提示词与验收约束.md index 2c26414..744bede 100644 --- a/docs/风控业务演示文档/17-前端合并提示词与验收约束.md +++ b/docs/风控业务演示文档/17-前端合并提示词与验收约束.md @@ -86,6 +86,9 @@ - 所有风控 REST 请求使用 `/api/v1/risk`。 - 使用主项目统一响应信封和错误处理。 +- 列表接口的 `data` 必须是数组,游标和 `has_more` 从 `meta` 读取。 +- 风控写接口和扫描接口必须生成并携带唯一 `Idempotency-Key`。 +- `Idempotency-Key` 只用于写请求,证据上传暂不要求。 - Agent 对话使用 `/api/v1/agent-runs` 和 SSE 事件。 - 不在前端实现另一套 Agent 对话协议。 - SSE 需要处理 `start`、`tools`、`delta`、`replace`、`done` 和 `error`。 @@ -157,6 +160,8 @@ error.message ### 交互要求 - 操作进行中禁用重复点击,并显示进行中状态。 +- 写请求失败时不得复用同一个 `Idempotency-Key` 提交不同内容。 +- 写请求超时后可使用同一请求体和同一键重试,以获取首次结果。 - 成功或失败必须使用主项目统一的消息、Toast 或通知组件。 - 不能只用控制台日志代替用户提示。 - 弹窗关闭前必须明确操作结果。 diff --git a/docs/风控业务演示文档/18-当前项目完成进度.md b/docs/风控业务演示文档/18-当前项目完成进度.md index f54ff54..4f8d256 100644 --- a/docs/风控业务演示文档/18-当前项目完成进度.md +++ b/docs/风控业务演示文档/18-当前项目完成进度.md @@ -8,10 +8,10 @@ | 项目 | 当前状态 | |---|---| -| 统计日期 | 2026-09-10 | +| 统计日期 | 2026-09-11 | | 当前分支 | `RM2_develop` | -| 当前提交 | `a94d5c7` | -| 提交信息 | `feat: 迁移奶龙风控业务模块与演示文档` | +| 当前合并基线 | `origin/qyqy_develop` 主项目风控修复批次 | +| 代码状态 | 已完成主项目风控修复合并,全量测试通过 | | 已推送分支 | `origin/RM2_develop` | | 已合并分支 | `origin/qyqy_develop` | | 私有前端 | `private_frontend/`,未提交、未推送 | @@ -20,7 +20,7 @@ | 范围 | 完成度 | 说明 | |---|---:|---| -| 后端业务模块 | 96% | 主要业务功能和定时规则扫描调度均已完成 | +| 后端业务模块 | 98% | 主要业务功能和主项目风控修复均已合并,仍有少量后端加固项 | | 私有验证前端 | 90% | 可用于本地功能验证,但不作为公共正式前端 | | 主项目正式前端 | 10% | 尚未按主项目设计系统和正式页面结构合并 | | 主项目联调与验收 | 60% | 代码已合并,仍待主项目环境完整联调和正式前端接入 | @@ -93,7 +93,7 @@ | 检查项 | 结果 | |---|---| -| 全量测试 | `567 passed, 1 skipped` | +| 全量测试 | `697 passed, 1 skipped` | | Ruff 静态检查 | 通过 | | 风控专项测试 | 通过 | | 真实 Agent Run 验收 | 三类业务对话通过 | @@ -103,6 +103,15 @@ | 依赖检查 | `pip check` 通过 | | 依赖声明 | 已补充 `python-dotenv`、`python-multipart`、`greenlet`、`tzdata` | +主项目新增并已合入: + +- 列表信封、游标绑定和 JSON 多值索引。 +- 风控写接口幂等和 SSE 内容协商。 +- 邮件权限、RBAC 权限补齐脚本。 +- 通知失败可观测、扫描脏数据隔离和调度重启执行。 +- RW-007 豁免额度、RW-012、RW-015、RW-018 研判修复。 +- 日报北京时间、误报原因和截断标记。 + ## 未完成和暂缓事项 ### 对话历史与长期留存 @@ -152,9 +161,11 @@ | 阻塞项 | 影响 | 处理方式 | |---|---|---| | 正式前端未合并 | 无法按主项目正式界面演示 | 按前端提示词文档执行合并 | +| 私有前端尚未适配新信封和幂等请求头 | 预警列表、分页和写操作会失败 | 后端处理完成后统一改造 | | 主项目完整联调未完成 | 跨模块权限、导航和接口仍需验证 | 在 qyqy_develop 环境联调 | | 对话历史暂缓 | 跨轮长期记忆能力有限 | 迁移完成后单独实施 | -| 公共底座 MyPy 例外 | 全量类型检查无法完全通过 | 保持例外或由主项目后续处理 | +| 数据范围最高权限扩散 | 任意 `all` 权限可能绕过风控客户范围 | 改为按 permission_code 应用 scope | +| 幂等回执与业务提交原子性 | 内部 commit 后回执提交前崩溃时无法回放结果 | 评估统一事务边界 | ## 下一阶段建议 diff --git a/docs/风控业务演示文档/20-Agent工具白名单与意图配置.md b/docs/风控业务演示文档/20-Agent工具白名单与意图配置.md new file mode 100644 index 0000000..da54f89 --- /dev/null +++ b/docs/风控业务演示文档/20-Agent工具白名单与意图配置.md @@ -0,0 +1,128 @@ +# Agent 工具白名单与意图配置 + +## 文档功能 + +本文档用于向主项目方交付奶龙风控智能助手的工具白名单和意图配置,说明实际生效值、配置表位置、工具权限和一致性要求。 + +## Agent 基础声明 + +| 项目 | 值 | +|---|---| +| `agent_type` | `risk` | +| 展示名称 | 奶龙风控智能助手 | +| 允许角色 | `risk_operator`、`admin` | +| 允许入口 | `api` | +| 运行权限 | `agent:run` | +| 工具权限 | `risk:alert:read` | + +## 工具白名单 + +当前 active 发布: + +```text +release_no=risk-agent-local-v1 +namespace=agent_tools +schema_version=1 +``` + +| 配置键 | 允许工具 | +|---|---| +| `risk:risk_overview` | `get_risk_overview` | +| `risk:risk_search` | `search_risk_alerts` | +| `risk:risk_evidence` | `get_alert_evidence` | +| `risk:general` | 无 | + +JSON 文件: + +- `config/risk_agent_tools.json` + +## 意图配置 + +| 意图 | 名称 | 示例 | 允许工具 | +|---|---|---|---| +| `risk_overview` | 风险概览 | 查看当前风险概览;当前有多少高风险预警 | `get_risk_overview` | +| `risk_search` | 风险查询 | 查询高风险预警;查看命中 RW-007 的预警 | `search_risk_alerts` | +| `risk_evidence` | 预警证据 | 查询预警编号 ALERT-001 的证据;查看这条预警的证据链 | `get_alert_evidence` | +| `general` | 通用风控查询 | 你能做什么;说明你的功能边界 | 无 | + +统一参数: + +```text +confidence_threshold=0.6500 +max_clarification_rounds=2 +transfer_on_failure=true +priority=100 +version=1 +status=active +``` + +JSON 文件: + +- `config/risk_agent_intents.json` + +## 配置表映射 + +### 工具白名单 + +写入 `platform_config_item`: + +- `release_id`:当前 active 配置发布。 +- `namespace`:`agent_tools`。 +- `config_key`:`risk:`。 +- `value_json`:`{"allowed_tools":[...]}`。 + +### 意图 + +写入 `agent_intent_config`: + +- `agent_type=risk` +- `intent_code` +- `intent_name` +- `description` +- `examples` +- `classifier_instruction` +- `confidence_threshold` +- `max_clarification_rounds` +- `transfer_on_failure` +- `allowed_tools` +- `priority` +- `version` +- `status` + +## 一致性要求 + +- `platform_config_item` 的工具白名单是实际执行边界。 +- `agent_intent_config.allowed_tools` 必须与工具白名单保持一致。 +- 工具名称必须同时存在于代码的 `AgentDefinition.allowed_tools` 和工具注册表中。 +- 不得只配置意图而遗漏工具白名单。 +- 不得把写操作、处置操作或修改交易数据的工具放入白名单。 + +## 工具权限 + +三个工具均为只读工具: + +| 工具 | 角色 | 权限 | +|---|---|---| +| `get_risk_overview` | `risk_operator/admin` | `risk:alert:read` | +| `search_risk_alerts` | `risk_operator/admin` | `risk:alert:read` | +| `get_alert_evidence` | `risk_operator/admin` | `risk:alert:read` | + +## 主项目接入检查 + +- 创建或复用 active `config_release`。 +- 导入 4 条 `agent_tools` 配置。 +- 导入 4 条 risk 意图配置。 +- 确认配置已审核并激活。 +- 确认 `risk_operator` 拥有 `agent:run` 和 `risk:alert:read`。 +- 确认 `risk` Agent 已注册到 `AgentFactory`。 +- 发起一次风险概览和一次预警搜索验证工具调用。 + +## 发布脚本 + +主项目已提供: + +```text +tools/publish_risk_agent_config.py +``` + +该脚本用于发布工具白名单、意图配置和风险 Agent 所需权限。部署时应优先使用脚本,避免手工写入配置表造成工具白名单和意图配置不一致。 diff --git a/docs/风控业务演示文档/21-主项目合并后后端必改清单.md b/docs/风控业务演示文档/21-主项目合并后后端必改清单.md new file mode 100644 index 0000000..8e75e98 --- /dev/null +++ b/docs/风控业务演示文档/21-主项目合并后后端必改清单.md @@ -0,0 +1,85 @@ +# 风控模块合并后自身待办清单 + +## 文档定位 + +本文只记录风控模块自身在合并后需要处理的问题和联调动作。 + +公共底座、公共事务能力、公共数据范围、公共鉴权基座和公共 SSE 协商等问题不纳入本文, +由主项目统一修复和发布。 + +## 本轮已完成 + +### 1. 证据查询时间口径统一 + +已完成: + +- `/api/v1/risk/evidence/transactions` +- `/api/v1/risk/evidence/capital_flows` +- `/api/v1/risk/evidence/login_records` +- `/api/v1/risk/evidence/notifications` + +以上入口收到不带时区的开始时间和结束时间时,按北京时间解释,再转换为 UTC +数据库查询时间,避免误查前后 8 小时的数据。 + +### 2. 通知记录查询时间口径统一 + +`/api/v1/risk/notifications` 已与本模块其他 REST 查询保持一致: + +- 客户端裸时间按北京时间解释。 +- 查询前统一转换为 UTC。 +- 分页游标继续绑定用户、筛选条件和路径。 + +### 3. Agent 截断提示已补齐 + +奶龙风控智能助手在工具结果出现以下标记时,必须明确说明当前证据不完整: + +- `data_truncated=true` +- `evidence_truncated` 非空 +- `truncated=true` + +Agent 不能把截断结果表述成覆盖全部数据,也不能据此给出确定性的全量结论。 + +### 4. 风控验收脚本密钥路径统一 + +以下脚本已改为读取 `JWT_PRIVATE_KEY_PATH`: + +- `tools/risk_agent_e2e.py` +- `tools/risk_agent_business_e2e.py` + +不再硬编码 `config/jwt/jwt-private.pem`。 + +### 5. 权限初始化脚本说明修正 + +`tools/grant_risk_permissions.py` 的文档字符串已改为实际文件名, +避免执行人员按错误路径操作。 + +## 联调前需要执行的动作 + +以下内容属于环境准备或数据初始化,不是代码缺陷: + +1. 执行 `python tools/grant_risk_permissions.py`,确认风控角色具备: + - `risk:alert:read` + - `risk:alert:write` + - `risk:alert:scan` + - `risk:report:mail` +2. 执行 `python tools/publish_risk_agent_config.py`,确认奶龙风控智能助手的工具白名单和意图配置已发布。 +3. 按主项目发布的迁移流程执行 Alembic 升级,确认 `trigger_rule_codes` 多值索引已生效。 +4. 演示前准备足够的客户、交易、资金、持仓、登录和预警数据。 +5. 使用前确认日报邮件开关、SMTP 配置和收件人范围符合演示要求。 + +## 当前保留限制 + +- 私有前端适配不纳入本文,等后端事项稳定后单独处理。 +- `read_at` 和 `acknowledged_at` 只保留数据库能力,通知接口不返回,前端不展示。 +- 定时扫描 Worker 启动后的首轮执行语义与 `RISK_SCAN_RUN_IMMEDIATELY` 的旧说明存在差异, + 当前代码优先保证“开关启用后不会永不扫描”。如后续要恢复严格的首次执行开关语义, + 需要单独设计执行时间的持久化和恢复方案。 + +## 验证要求 + +合并或联调前至少完成: + +1. 风控专项测试通过。 +2. Ruff 检查通过。 +3. 全量测试无新增失败。 +4. Agent 实际对话、证据查询、通知查询和日报邮件分别完成一次人工联调。 diff --git a/docs/风控业务演示文档/README.md b/docs/风控业务演示文档/README.md index 9bbab4b..7106c7b 100644 --- a/docs/风控业务演示文档/README.md +++ b/docs/风控业务演示文档/README.md @@ -40,6 +40,8 @@ | 17 | 前端合并提示词与验收约束 | 指导后续模型识别风控功能并合并前端 | | 18 | 当前项目完成进度 | 汇总当前完成度、验证结果和剩余任务 | | 19 | 风控模块配置项清单 | 单独说明风控专用和公共依赖配置 | +| 20 | Agent工具白名单与意图配置 | 交付主项目方的工具和意图配置 | +| 21 | 主项目合并后后端必改清单 | 列出合并后必须处理的后端事项 | ## 推荐阅读顺序 @@ -51,3 +53,5 @@ 6. 17:主项目前端合并时直接提供给模型。 7. 18:项目汇报、进度同步和下一阶段安排。 8. 19:合并部署和联调时核对环境变量。 +9. 20:主项目方导入 Agent 工具白名单和意图配置。 +10. 21:主项目代码合并后的后端整改与验收。 diff --git a/tests/contract/test_risk_agent_contract.py b/tests/contract/test_risk_agent_contract.py index 0ff2fa2..e4020c6 100644 --- a/tests/contract/test_risk_agent_contract.py +++ b/tests/contract/test_risk_agent_contract.py @@ -439,3 +439,10 @@ async def test_general_list_question_uses_complete_search_summary() -> None: assert "CUST-002" in text assert "稳健一号" in text assert "全部命中记录" in text + + +def test_agent_prompt_requires_truncation_disclosure() -> None: + prompt = public_risk_agent._agent_system_prompt("查看当前预警") + + assert "data_truncated=true" in prompt + assert "证据不完整" in prompt diff --git a/tests/unit/service/test_risk_notification_service.py b/tests/unit/service/test_risk_notification_service.py index eeb8577..5a754c9 100644 --- a/tests/unit/service/test_risk_notification_service.py +++ b/tests/unit/service/test_risk_notification_service.py @@ -1,5 +1,7 @@ from datetime import datetime +import pytest + from app.model.fund import FundRiskAlert from app.service.risk_notification_service import RiskNotificationService @@ -77,3 +79,47 @@ def test_high_risk_batch_creates_in_app_and_mail_records() -> None: assert len(records) == 2 assert {record.channel for record in records} == {"站内提醒", "邮件"} + + +@pytest.mark.asyncio +async def test_notification_list_interprets_time_as_local_time(monkeypatch) -> None: + from app.api.schemas.risk import RiskNotificationPageQuery + from app.core.contracts import RequestContext + from app.repository.fund_query_repository import FundPage + + captured: dict = {} + + class StubRepository: + def __init__(self, _session, *, scope) -> None: + self.scope = scope + + async def list_notifications(self, **kwargs) -> FundPage: + captured.update(kwargs) + return FundPage( + entity="risk_notification", + items=(), + limit=10, + offset=0, + next_offset=None, + ) + + monkeypatch.setattr( + "app.service.risk_notification_service.RiskRepository", + StubRepository, + ) + service = RiskNotificationService(object()) + query = RiskNotificationPageQuery( + start_time=datetime(2026, 9, 10, 12, 0), + end_time=datetime(2026, 9, 10, 13, 0), + ) + context = RequestContext( + user_id="990000002", + trace_id="trace", + permissions=("risk:alert:read",), + data_scope="all", + ) + + await service.list_notifications(context, query) + + assert captured["start_time"] == datetime(2026, 9, 10, 4, 0) + assert captured["end_time"] == datetime(2026, 9, 10, 5, 0) diff --git a/tests/unit/service/test_risk_query_service.py b/tests/unit/service/test_risk_query_service.py index 8b29e60..f2df772 100644 --- a/tests/unit/service/test_risk_query_service.py +++ b/tests/unit/service/test_risk_query_service.py @@ -52,6 +52,28 @@ class FakeRepository: return None +class RecordingRepository(FakeRepository): + def __init__(self) -> None: + super().__init__() + self.calls: list[tuple[str, dict]] = [] + + async def list_transactions(self, **kwargs) -> FundPage: + self.calls.append(("transactions", kwargs)) + return self.page + + async def list_capital_flows(self, **kwargs) -> FundPage: + self.calls.append(("capital_flows", kwargs)) + return self.page + + async def list_login_records(self, **kwargs) -> FundPage: + self.calls.append(("login_records", kwargs)) + return self.page + + async def list_notifications(self, **kwargs) -> FundPage: + self.calls.append(("notifications", kwargs)) + return self.page + + def context(**updates) -> RequestContext: values = { "user_id": "990000002", @@ -163,3 +185,23 @@ async def test_missing_alert_detail_is_hidden() -> None: with pytest.raises(GenericResourceNotFoundError): await service.get_alert_detail(context(), "ALERT-NOT-FOUND") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "source", + ("transactions", "capital_flows", "login_records", "notifications"), +) +async def test_evidence_time_filters_are_interpreted_as_local_time(source: str) -> None: + repository = RecordingRepository() + service = RiskQueryService(None, repository=repository) + query = RiskEvidencePageQuery( + start_time=datetime(2026, 9, 10, 12, 0), + end_time=datetime(2026, 9, 10, 13, 0), + ) + + await service.list_evidence(context(), source, query) + + assert repository.calls[0][0] == source + assert repository.calls[0][1]["start_time"] == datetime(2026, 9, 10, 4, 0) + assert repository.calls[0][1]["end_time"] == datetime(2026, 9, 10, 5, 0) diff --git a/tools/grant_risk_permissions.py b/tools/grant_risk_permissions.py index 9685b13..63fd123 100644 --- a/tools/grant_risk_permissions.py +++ b/tools/grant_risk_permissions.py @@ -30,7 +30,7 @@ 幂等:权限与绑定都已存在时直接跳过。 -用法:python tools/grant_risk_alert_read_permission.py +用法:python tools/grant_risk_permissions.py """ import asyncio diff --git a/tools/risk_agent_business_e2e.py b/tools/risk_agent_business_e2e.py index edc49af..5a14bfd 100644 --- a/tools/risk_agent_business_e2e.py +++ b/tools/risk_agent_business_e2e.py @@ -18,7 +18,7 @@ from app.infrastructure.db import SessionFactory from app.main import create_app from app.worker.runtime import WorkerRuntime -PRIVATE_KEY = Path("config/jwt/jwt-private.pem").read_text(encoding="utf-8") +PRIVATE_KEY = Path(get_settings().jwt_private_key_path).read_text(encoding="utf-8") RISK_USER = "9002" AGENT_TYPE = "risk" diff --git a/tools/risk_agent_e2e.py b/tools/risk_agent_e2e.py index 362ca75..248de1f 100644 --- a/tools/risk_agent_e2e.py +++ b/tools/risk_agent_e2e.py @@ -17,7 +17,7 @@ from app.infrastructure.db import SessionFactory from app.main import create_app from app.worker.runtime import WorkerRuntime -PRIVATE_KEY = Path("config/jwt/jwt-private.pem").read_text(encoding="utf-8") +PRIVATE_KEY = Path(get_settings().jwt_private_key_path).read_text(encoding="utf-8") RISK_USER = "9002" AGENT_TYPE = "risk" INTENT = "risk_overview"