feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
2
.env
2
.env
@@ -27,7 +27,7 @@ SERVER_BLOCKING_STARTUP_TIMEOUT=12
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
VITE_AUTH_IDLE_TIMEOUT_MINUTES=30
|
||||
ONLYOFFICE_ENABLED=true
|
||||
ONLYOFFICE_PUBLIC_URL=http://10.10.10.122:8082
|
||||
ONLYOFFICE_PUBLIC_URL=http://www.caoxiaozhu.com:8082
|
||||
ONLYOFFICE_BACKEND_URL=http://main:8000
|
||||
ONLYOFFICE_JWT_SECRET=change-me-onlyoffice
|
||||
HERMES_AGENT_SHARED_TOKEN=change-me-hermes
|
||||
|
||||
25
AGENTS.md
25
AGENTS.md
@@ -32,8 +32,25 @@
|
||||
- 前端大型 Vue 页面:优先拆分 composable、view model、样式分片、业务工具函数和子组件。
|
||||
- 算法/规则模块:优先拆分输入解析、规则匹配、评分策略、结果解释和异常处理。
|
||||
|
||||
## 验证规范
|
||||
## 容器与运行环境(必读)
|
||||
|
||||
- 后端改动优先在 Docker 容器 `x-financial-main` 中运行验证。
|
||||
- 单元测试设置合理超时,避免长时间卡死。
|
||||
- 每次重构后至少运行对应服务的定向测试;涉及公共协议时补充端到端或接口测试。
|
||||
本项目代码是 Docker 容器 `x-financial-main`(镜像 `x-financial-dev:latest`)的源码映射。
|
||||
|
||||
- **容器映射**:宿主机 `D:\Code\Project\X-Financial` ↔ 容器内 `/app`(`docker-compose.yml` 中 `volumes: - .:/app`,`working_dir: /app`)。
|
||||
- **后端 venv**:容器内位于 `/tmp/x-financial-server-venv`(环境变量 `SERVER_VENV_DIR`),不要假设宿主机上有相同的 venv。
|
||||
- **外部依赖**:Qdrant(`x-financial-qdrant`)、OnlyOffice(`x-financial-onlyoffice`)也在同一 compose 网络里。
|
||||
|
||||
## 验证规范(硬性约束)
|
||||
|
||||
> 本项目代码与运行环境以容器为唯一事实来源。所有后端测试、集成测试、依赖了 Qdrant / OnlyOffice / venv 的验证,都必须在 `x-financial-main` 容器内执行,**不要在宿主机上直接跑 pytest / pip / python**。
|
||||
|
||||
- **进入容器跑命令**(最常用):
|
||||
```bash
|
||||
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main <cmd>
|
||||
```
|
||||
- 跑后端测试:`docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q <path>`
|
||||
- 交互式排查:`docker exec -it -w /app x-financial-main bash`(登录后默认已在 `/app`)
|
||||
- **容器不可用时**(未启动、健康检查失败、镜像丢失):先 `docker compose up -d main` 恢复,再继续验证;不要绕开容器在宿主机另装 venv。
|
||||
- **单元测试设置合理超时**,避免长时间卡死。涉及外部服务(Qdrant / OnlyOffice / LLM)的测试要么 mock,要么确认 compose 网络中依赖服务在线。
|
||||
- **每次重构后至少运行对应服务的定向测试**;涉及公共协议时补充端到端或接口测试。
|
||||
- **修改 docker-compose / start.sh / venv 路径相关代码**时,自己也要回容器里跑一次确认改动生效,不要只改文件就声称完成。
|
||||
|
||||
132
document/development/Agent链路追踪中心/CONCEPT.md
Normal file
132
document/development/Agent链路追踪中心/CONCEPT.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Agent链路追踪中心 概念文档
|
||||
|
||||
## 功能一句话
|
||||
|
||||
为 Orchestrator 全链路运行提供统一 trace 采集、查询和前端回放入口,让管理员能按 `run_id` 或 `conversation_id` 还原一次 Agent 对话从意图识别到最终回复的全过程。
|
||||
|
||||
## 背景与问题
|
||||
|
||||
- 当前现状:系统已有 `agent_runs`、`agent_tool_calls`、`semantic_parse_logs` 和对话消息,但它们分散在运行记录、工具调用、语义解析与系统日志中。
|
||||
- 用户痛点:线上 Agent 回答异常时,只能看局部日志或 Hermes 工作记录,难以判断问题出在意图路由、知识检索、规则引擎、数字员工任务还是回复生成。
|
||||
- 业务影响:Agent 链路越长,排障成本越高;没有可重放视图会影响交付、演示和运维可信度。
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
### 目标
|
||||
|
||||
- [G1] 后端沉淀统一的 Agent trace 事件模型,按运行顺序记录关键阶段输入、输出、状态、耗时和错误。
|
||||
- [G2] 提供 trace 查询接口,支持按 `run_id` 查看单次运行,按 `conversation_id` 查看多轮会话链路。
|
||||
- [G3] 前端新增 Agent Trace Center 入口,展示运行时间线、工具调用、语义解析、路由上下文和最终回复。
|
||||
- [G4] 保留现有 Agent Run / ToolCall 数据结构,避免破坏数字员工工作记录和系统日志页面。
|
||||
|
||||
### 非目标
|
||||
|
||||
- [NG1] 本轮不做重新执行真实业务动作的“调试重跑”,只做历史重放。
|
||||
- [NG2] 本轮不接入 OpenTelemetry、Jaeger 等外部分布式追踪系统。
|
||||
- [NG3] 本轮不改造总账、预算、报销审批等业务语义。
|
||||
- [NG4] 本轮不做跨服务链路采样策略和海量归档策略。
|
||||
|
||||
## 用户与场景
|
||||
|
||||
- 目标用户:系统管理员、财务系统运维、Agent 能力开发者、实施顾问。
|
||||
- 使用入口:系统日志详情、数字员工工作记录、报销助手消息中的 `run_id`、新增 Trace Center 页面。
|
||||
- 核心场景:
|
||||
- 管理员打开某次异常回复,查看每一步输入输出和耗时。
|
||||
- 实施人员按会话查看多轮上下文,判断上下文是否被错误继承。
|
||||
- 开发者定位工具调用失败、语义识别降级或路由选错 Agent 的原因。
|
||||
- 异常场景:
|
||||
- 运行失败但没有工具调用时,仍展示已记录的 orchestration 阶段。
|
||||
- 旧数据没有 trace event 时,接口回退展示 `agent_runs`、`semantic_parse`、`tool_calls`。
|
||||
|
||||
## 功能能力
|
||||
|
||||
- [C1] 输入能力:接收 `run_id`、`conversation_id`、Agent、状态、来源、关键字等查询条件。
|
||||
- [C2] 采集能力:记录 `received`、`context_hydrated`、`semantic_parsed`、`agent_selected`、`capability_selected`、`tool_invoked`、`response_built`、`conversation_updated`、`failed` 等事件。
|
||||
- [C3] 输出能力:返回 trace 摘要、事件时间线、工具调用、语义解析、路由 JSON、最终回复和关联会话消息。
|
||||
- [C4] 状态与权限:复用现有登录与页面权限,管理员/可访问设置页用户可查看全量 trace。
|
||||
- [C5] 边界与降级:旧运行没有 trace events 时,按现有 run/tool/semantic 数据合成最小时间线。
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 前端
|
||||
|
||||
- 页面/组件:
|
||||
- 新增 `AgentTraceCenterView` 或设置页内 Trace Center 分区。
|
||||
- 新增 trace 详情组件,复用现有日志详情的直角企业级视觉。
|
||||
- 从日志详情、数字员工工作记录、报销助手操作反馈中可跳转到 trace 详情。
|
||||
- 交互状态:
|
||||
- 列表支持关键字、状态、Agent、来源筛选。
|
||||
- 详情展示左侧时间线、右侧输入输出 JSON、顶部摘要。
|
||||
- 支持加载、空态、错误态和刷新。
|
||||
- 展示规则:
|
||||
- 事件按 `started_at`、`sequence` 升序展示。
|
||||
- 失败事件突出错误信息。
|
||||
- 大 JSON 使用可滚动代码块,避免撑破页面。
|
||||
|
||||
### 后端
|
||||
|
||||
- 接口/服务:
|
||||
- `GET /api/v1/agent-traces`:查询 trace 列表。
|
||||
- `GET /api/v1/agent-traces/{run_id}`:读取单次运行 trace。
|
||||
- `GET /api/v1/agent-traces/conversations/{conversation_id}`:读取会话 trace。
|
||||
- 权限与校验:
|
||||
- 复用当前 API 依赖和系统登录态。
|
||||
- 不允许通过 trace 接口修改业务数据。
|
||||
- 持久化:
|
||||
- 新增 `agent_trace_events` 表。
|
||||
- 通过 `AgentTraceService` 封装记录、查询、合成旧数据时间线。
|
||||
|
||||
### 算法与规则
|
||||
|
||||
- 规则输入:Agent 运行阶段、工具调用结果、语义解析结果和会话消息。
|
||||
- 规则流程:
|
||||
- 采集阶段按固定事件名记录。
|
||||
- 查询阶段按事件序列合并 run、semantic、tool、conversation。
|
||||
- 无事件时从历史 run 数据合成 fallback timeline。
|
||||
- 结果解释:
|
||||
- 每个事件输出 `title`、`summary`、`status`、`duration_ms`、`input_json`、`output_json`、`error_message`。
|
||||
|
||||
## 算法与公式
|
||||
|
||||
当前功能不涉及评分、预算或风控公式,只涉及耗时统计:
|
||||
|
||||
$$
|
||||
duration\_ms = finished\_at - started\_at
|
||||
$$
|
||||
|
||||
变量说明:
|
||||
|
||||
- $duration\_ms$:阶段耗时,单位毫秒。
|
||||
- $finished\_at$:阶段结束时间。
|
||||
- $started\_at$:阶段开始时间。
|
||||
|
||||
## 测试方案
|
||||
|
||||
- 单元测试:覆盖 `AgentTraceService` 记录事件、查询详情、旧 run fallback 时间线。
|
||||
- 接口测试:覆盖 trace 列表、单 run 详情、会话详情。
|
||||
- 前端交互测试:覆盖 trace 数据归一化、状态文案、空态和错误态。
|
||||
- 端到端测试:通过一次 Orchestrator 用户消息生成 `run_id`,验证详情接口能返回语义解析、路由和至少一个事件。
|
||||
- 回归测试:确认原 `agent-runs` 接口、数字员工工作记录、系统日志详情不破坏。
|
||||
- 手工验证:在浏览器打开 Trace Center,检查列表、详情和 JSON 展示。
|
||||
|
||||
## 指标与验收
|
||||
|
||||
- [A1] 功能验收:一次 Orchestrator 调用后,能通过 `run_id` 查询到完整 trace 详情。
|
||||
- [A2] 性能指标:单次 trace 详情查询在常规数据量下不引入明显慢查询;默认列表限制数量。
|
||||
- [A3] 质量指标:后端定向测试在 Docker `x-financial-main` 容器内 60s 超时内通过。
|
||||
- [A4] 安全/权限指标:trace 接口只读,不触发业务动作或副作用。
|
||||
- [A5] 可观测性:失败运行也能看到最后成功事件和失败事件。
|
||||
|
||||
## 风险与开放问题
|
||||
|
||||
- 风险:当前工作树已有大量未提交改动,本轮实现必须避免覆盖既有业务改动。
|
||||
- 已处理依赖:新增 trace 模型已纳入 `Base` 导入,`AgentTraceService.ensure_storage_ready()` 会按需创建 trace 事件表。
|
||||
- 待确认:后续是否需要接 OpenTelemetry、跨容器 trace 或长期归档策略。
|
||||
- 降级策略:没有 trace event 的旧 run 通过 `semantic_parse`、`tool_calls` 和 `route_json` 合成只读时间线。
|
||||
|
||||
## 本轮实现记录
|
||||
|
||||
- 后端已完成 `AgentTraceEvent`、`AgentTraceService` 和 `/api/v1/agent-traces` 只读接口。
|
||||
- Orchestrator 已在接收请求、会话补全、语义识别、路由、工具调用、会话写回、最终回复和失败路径写入 trace event。
|
||||
- 前端已在系统设置中新增 Agent Trace Center,并从日志详情、数字员工工作记录跳转到指定 `run_id`。
|
||||
- 本轮保持非目标不变:不做真实业务重跑、不接 OpenTelemetry、不处理 GL/总账体系和前端统一状态管理。
|
||||
55
document/development/Agent链路追踪中心/TODO.md
Normal file
55
document/development/Agent链路追踪中心/TODO.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Agent链路追踪中心 开发 TODO
|
||||
|
||||
## 使用规则
|
||||
|
||||
- 每个 TODO 必须对应 `CONCEPT.md` 中的目标、能力或验收点。
|
||||
- 只有完成并验证后,才能把 `[ ]` 改成 `[x]`。
|
||||
- 勾选时在任务后补充简短证据,例如文件、接口、命令或验证结果。
|
||||
- 如果需求发生变化,先更新 `CONCEPT.md`,再调整本 TODO。
|
||||
|
||||
## 1. 调研与边界
|
||||
|
||||
- [x] [CONCEPT: 背景与问题] 阅读相关页面、接口、服务、测试和历史文档,记录当前实现事实。证据:已确认 `agent_runs`、`agent_tool_calls`、`semantic_parse_logs`、`LogDetailView`、`DigitalEmployeeWorkRecords` 现状。
|
||||
- [x] [CONCEPT: 目标与非目标] 确认本轮开发范围,写清楚不做项。证据:`CONCEPT.md` 明确只做历史重放,不做调试重跑和 OpenTelemetry。
|
||||
- [x] [CONCEPT: 风险与开放问题] 标记无法立即确认的依赖、风险和假设。证据:`CONCEPT.md` 风险章节记录脏工作树和数据库初始化依赖。
|
||||
|
||||
## 2. 契约与设计
|
||||
|
||||
- [x] [CONCEPT: 功能能力] 定义输入、输出、状态、权限和边界条件。证据:`CONCEPT.md` 功能能力章节。
|
||||
- [x] [CONCEPT: 方案设计] 明确前端、后端、算法、数据的职责边界。证据:`CONCEPT.md` 方案设计章节。
|
||||
- [x] [CONCEPT: 算法与公式] 补全耗时公式和变量解释。证据:`CONCEPT.md` 算法与公式章节。
|
||||
- [x] [CONCEPT: 指标与验收] 把验收标准转成可验证的检查点。证据:`CONCEPT.md` 指标与验收章节。
|
||||
|
||||
## 3. 后端实现
|
||||
|
||||
- [x] [CONCEPT: 后端] 新增 trace 事件模型、schema、repository/service。证据:`AgentTraceEvent`、`agent_trace.py`、`AgentTraceService`。
|
||||
- [x] [CONCEPT: 后端] 新增 `agent-traces` 只读接口和路由注册。证据:`agent_traces.py` endpoint 与 `router.py` 注册。
|
||||
- [x] [CONCEPT: 后端] 在 Orchestrator 关键节点写入 trace event。证据:`orchestrator.py` 记录接收、会话、语义、路由、回复、失败事件;`orchestrator_execution.py` 记录工具调用事件。
|
||||
- [x] [CONCEPT: 数据] 实现旧 run fallback 时间线,避免旧数据详情为空。证据:`AgentTraceService.get_trace()` 在无事件时由 `AgentRun`、`SemanticParseLog`、`AgentToolCall` 合成只读时间线。
|
||||
|
||||
## 4. 算法/规则实现
|
||||
|
||||
- [x] [CONCEPT: 算法与规则] 实现 trace 事件排序、耗时计算和状态归一化。证据:`AgentTraceService._next_sequence()`、`_resolve_duration_ms()`、`agentTraceViewModel.js`。
|
||||
- [x] [CONCEPT: 结果解释] 输出可读事件标题、摘要、输入输出和错误信息。证据:Trace event schema 与 `AgentTraceCenterView.vue` 详情面板。
|
||||
|
||||
## 5. 前端实现
|
||||
|
||||
- [x] [CONCEPT: 前端] 新增 trace 服务 API 和数据归一化工具。证据:`agentTraces.js`、`agentTraceViewModel.js`。
|
||||
- [x] [CONCEPT: 前端] 新增 Trace Center 列表与详情视图。证据:`AgentTraceCenterView.vue`。
|
||||
- [x] [CONCEPT: 前端] 从现有日志详情和工作记录补充 trace 跳转入口。证据:`LogDetailView.vue`、`DigitalEmployeeWorkRecords.vue`。
|
||||
- [x] [CONCEPT: 前端] 实现加载、空态、错误态和刷新。证据:`AgentTraceCenterView.vue` 列表/详情状态与刷新按钮。
|
||||
- [x] [CONCEPT: 前端] 对齐现有企业级直角、低饱和、密集信息风格。证据:`agent-trace-center-view.css` 使用面板、表格、状态徽标和紧凑信息布局。
|
||||
|
||||
## 6. 测试与验证
|
||||
|
||||
- [x] [CONCEPT: 测试方案] 补充后端 service/API 定向测试。证据:`test_agent_trace_service.py` 覆盖事件记录、fallback、接口列表和详情。
|
||||
- [x] [CONCEPT: 测试方案] 补充前端数据归一化测试或可构建验证。证据:`npm.cmd --prefix web run build` 通过。
|
||||
- [x] [CONCEPT: 测试方案] 在 60s 超时内运行 Docker 后端定向验证。证据:`docker exec ... pytest -q server/tests/test_agent_trace_service.py server/tests/test_agent_runs_service.py`,7 passed。
|
||||
- [x] [CONCEPT: 测试方案] 运行 `npm.cmd --prefix web run build`。证据:Vite build 成功。
|
||||
- [x] [CONCEPT: 指标与验收] 记录验证命令、结果和未覆盖风险。证据:后端测试 7 passed、Vite build 成功、重启后 `/api/v1/agent-traces/{run_id}` live 返回 8 个 fallback 事件;浏览器插件后续不可用,未完成最终截图巡检。
|
||||
|
||||
## 7. 文档收尾
|
||||
|
||||
- [x] [CONCEPT: 指标与验收] 回看所有验收点,确认均有实现或验证证据。证据:后端 service/API 测试、前端构建、入口接入均已完成。
|
||||
- [x] [CONCEPT: 风险与开放问题] 更新剩余风险、后续任务和明确不做项。证据:`CONCEPT.md` 保留 OpenTelemetry、跨容器 trace、长期归档为后续待定。
|
||||
- [x] [CONCEPT: 功能一句话] 确认最终实现没有偏离原始目标。证据:本轮只做 Agent Trace Center,未处理 GL/前端状态管理两项待定问题。
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
## 1. 功能一句话
|
||||
|
||||
以数字员工为后台执行入口,持续把财务业务数据转成行为画像、制度语义和风险观察,再通过图谱证据链、单据详情和风险看板提供可解释的风险判断。
|
||||
以数字员工为后台分析入口,持续把财务业务数据转成行为画像、制度语义和风险观察,再通过图谱证据链、单据详情和风险看板提供可解释的风险判断。
|
||||
|
||||
规则中心、审批和报销主流程仍由外层智能体流程负责调度;数字员工只消费事实、规则命中和反馈结果,生成后台分析、报告、知识库材料和待复核线索。
|
||||
|
||||
## 2. 背景与问题
|
||||
|
||||
@@ -16,7 +18,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识
|
||||
- 分析看板如果只从散表拼统计图,会成为展示型页面,不能反映算法效果。
|
||||
- 数字员工如果只做定时任务,会变成调度工具,不能形成核心壁垒。
|
||||
|
||||
本方案把核心能力定义为“财务行为图谱风险引擎”。它不是单一模型,而是一套闭环:事件沉淀、实体建图、画像基线、风险推理、人工反馈、规则发现。
|
||||
本方案把核心能力定义为“财务行为图谱风险引擎”。它不是单一模型,而是一套闭环:事件沉淀、实体建图、画像基线、风险推理、人工反馈、待复核线索归集。
|
||||
|
||||
## 3. 核心判断
|
||||
|
||||
@@ -78,7 +80,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识
|
||||
算法版本
|
||||
```
|
||||
|
||||
这会形成自有训练集、评测集和规则优化样本。竞品能复制算法框架,但复制不了这些被真实审批和财务人员校准过的样本。
|
||||
这会形成自有训练集、评测集和规则执行校准样本。竞品能复制算法框架,但复制不了这些被真实审批和财务人员校准过的样本。
|
||||
|
||||
第四层壁垒:人机共审行为数据。
|
||||
|
||||
@@ -92,7 +94,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识
|
||||
补件
|
||||
升级审批
|
||||
标记误报
|
||||
生成候选规则
|
||||
归集待复核线索
|
||||
```
|
||||
|
||||
这些反馈会反向影响规则质量、抽审比例、自动化门控和数字员工能力考核。越使用越贴近企业自己的财务控制风格。
|
||||
@@ -139,10 +141,10 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识
|
||||
### 4.2 非目标
|
||||
|
||||
- 第一版不做独立“大图谱中心”,避免做成展示型页面。
|
||||
- 不让大模型直接决定风险等级,大模型只参与语义抽取、解释生成和候选规则发现。
|
||||
- 不让大模型直接决定风险等级,大模型只参与语义抽取、解释生成和事实线索整理;不参与规则生成、改写或发布。
|
||||
- 不用画像自动惩罚员工,不给员工永久贴标签。
|
||||
- 不在员工技能详情中展示知识归集图谱;图谱结果只进入工作记录详情、单据风险详情或画像详情。
|
||||
- 不让数字员工自动上线规则;规则候选必须经过管理员审核。
|
||||
- 不让数字员工生成、改写或自动上线规则;规则变更只能由管理员在规则中心维护。
|
||||
|
||||
## 5. 用户与场景
|
||||
|
||||
@@ -178,7 +180,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识
|
||||
|
||||
- 数字员工运行是否成功。
|
||||
- 本次分析处理了多少数据、产出多少风险观察。
|
||||
- 候选规则是否有足够证据。
|
||||
- 待复核风险线索是否有足够事实和证据。
|
||||
- 是否需要调整技能、规则、制度知识或调度配置。
|
||||
|
||||
### 5.4 管理层
|
||||
@@ -201,7 +203,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识
|
||||
- 制度整理员工:把公司财务制度整理成条款、适用范围、费用类型、触发条件和引用关系。
|
||||
- 风险扫描员工:定期扫描新增单据、票据、供应商、审批链和画像偏离,生成风险观察。
|
||||
- 画像更新员工:定期更新员工、部门、供应商、费用类型画像和同类基线。
|
||||
- 规则发现员工:从历史退回、误报、漏报、制度变化和高频异常中生成候选规则。
|
||||
- 风险线索归集员工:从申请、报销、规则命中和人工反馈中归集待复核线索,不生成规则。
|
||||
|
||||
### 6.2 图谱体现方式
|
||||
|
||||
@@ -259,7 +261,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识
|
||||
- 风险分布:按部门、费用类型、风险类型、供应商、员工职级分布。
|
||||
- 风险趋势:7 天 / 30 天风险走势、高风险占比、重复触发趋势、处理完成率。
|
||||
- 异常排行:风险最多部门、偏离基线最大员工、高频异常供应商、高频触发规则。
|
||||
- 算法效果:规则命中数、图谱异常命中数、人工确认率、误报率、候选规则数。
|
||||
- 算法效果:规则命中数、图谱异常命中数、人工确认率、误报率、待复核线索数。
|
||||
|
||||
风险看板必须从 `RiskObservation` 聚合,不直接从散表临时拼图表。
|
||||
|
||||
@@ -437,7 +439,7 @@ confirmed_by_feedback 由人工反馈确认
|
||||
图谱引擎:canonical node + whitelisted edge 构造证据路径
|
||||
风险观察:ontology fields + evidence path 输出可解释结论
|
||||
风险看板:按 ontology scenario / expense_type / risk_signal 聚合
|
||||
数字员工:只能产出本体可识别的候选风险信号和候选规则
|
||||
数字员工:只能产出本体可识别的事实、规则命中、待复核风险线索和证据引用
|
||||
```
|
||||
|
||||
当本体置信度不足时,风险图谱必须降级:
|
||||
@@ -602,7 +604,7 @@ $$
|
||||
|
||||
### 8.4 人工反馈校准
|
||||
|
||||
人工反馈不直接覆盖算法,但会影响后续权重和候选规则优先级:
|
||||
人工反馈不直接覆盖算法,但会影响后续权重和待复核线索优先级:
|
||||
|
||||
$$
|
||||
confirmed\_rate = \frac{confirmed}{confirmed + false\_positive + ignored}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
- [x] 为风险观察补充 `ontology_parse_id`、`ontology_version`、`domain`、`scenario`、`intent`、`ontology_entities_json`、`risk_signals_json` 和 `canonical_subject_key`。[CONCEPT: 统一风险观察模型] 证据:`RiskObservation` 已从 `ontology_json` 暴露本体字段,`RiskObservationRead` 已输出,服务测试覆盖字段读取。
|
||||
- [x] 为风险观察增加必要索引:主体、单据、风险类型、等级、状态、来源、创建时间。[CONCEPT: 技术验收] 证据:`RiskObservation.__table_args__` 与字段索引覆盖主体、单据、等级、状态、信号、来源和时间。
|
||||
- [x] 设计对象中心财务事件日志模型,把申请、预算占用、票据上传、审批、退回、付款、归档、复盘统一为可回放事件。[CONCEPT: 不可复制壁垒设计] 证据:`process_mining.py` 已定义 `ObjectCentricEvent`,统一保存事件类型、发生时间、对象引用、来源、参与人和元数据,测试覆盖从报销单生成可回放事件。
|
||||
- [x] 设计风险观察反馈池字段,记录人工采纳、驳回、改写、退回、补件、升级审批、误报和候选规则来源。[CONCEPT: 不可复制壁垒设计] 证据:`RiskObservationFeedback` 已通过兼容属性暴露 `decision/candidate_rule_source/confidence_score/escalation_target/supplement_required`,测试覆盖候选规则反馈元数据。
|
||||
- [x] 设计风险观察反馈池字段,记录人工采纳、驳回、改写、退回、补件、升级审批、误报和线索来源。[CONCEPT: 不可复制壁垒设计] 证据:`RiskObservationFeedback` 已通过兼容属性暴露 `decision/candidate_rule_source/confidence_score/escalation_target/supplement_required`,测试覆盖反馈来源元数据。
|
||||
- [x] 设计算法回放集模型,绑定历史单据、本体版本、规则版本、算法版本和反馈标签。[CONCEPT: 不可复制壁垒设计] 证据:`replay.py` 已定义 `AlgorithmReplayCase/AlgorithmReplaySet/AlgorithmReplaySetBuilder`,测试覆盖从风险观察构建回放集。
|
||||
|
||||
## 3. 后端服务
|
||||
@@ -98,7 +98,7 @@
|
||||
- [x] 将风险扫描和员工画像巡检注册到数字员工的员工技能列表。[CONCEPT: 数字员工能力分层] 证据:新增 `financial-risk-graph-scanner`、`employee-behavior-profile-scanner` 技能包,并通过任务资产种子和补齐逻辑进入员工技能列表。
|
||||
- [x] 员工技能详情的立即运行按技能类型调用真实后端任务。[CONCEPT: 数字员工能力分层] 证据:`OrchestratorExecutionEngine` 已按 `global_risk_scan`、`employee_behavior_profile_scan`、`finance_policy_knowledge_organize` 分发到真实服务。
|
||||
- [x] 新增或扩展画像更新员工,定期更新员工、部门、供应商、费用类型基线。[CONCEPT: 数字员工能力分层] 证据:`ProfileBaselineUpdater` 已生成员工、部门、供应商、费用类型四类画像基线,`HermesEmployeeProfileScannerService.scan_employee_profiles()` 已返回 `baseline_summary`;`pytest --ignore=.venv-ocr312 tests/test_risk_graph_profile_baselines.py tests/test_hermes_employee_profile_baselines.py -q` 通过。
|
||||
- [x] 新增规则发现员工候选输出,候选规则必须带证据、来源和置信度。[CONCEPT: 数字员工能力分层] 证据:`rule_discovery.py` 已定义 `CandidateRiskRuleDiscovery` 和 `CandidateRiskRule`,输出包含证据、来源、置信度和候选状态;数字员工任务与技能已注册为“风险规则候选发现”。
|
||||
- [x] 新增风险线索归集员工输出,线索必须带事实、规则命中、证据来源和人工复核标记。[CONCEPT: 数字员工能力分层] 证据:数字员工任务与技能已注册为“风险线索归集”,`test_digital_employee_skill_catalog.py` 已锁定不输出候选规则或自动发布语义。
|
||||
- [x] 数字员工运行完成后写入处理范围、处理数量、风险观察数量和失败原因。[CONCEPT: 数字员工工作记录详情] 证据:`HermesScheduler` 已写入风险图谱巡检摘要,失败仍沿用执行日志 `error_trace`。
|
||||
- [x] 确认 UI 上继续使用“数字员工 / 员工技能 / 工作记录”等业务命名,不在普通用户界面暴露内部实现名。[CONCEPT: 用户与场景] 证据:`DigitalEmployeesView.vue` 页签文案为“员工技能 / 工作记录”,普通界面未展示内部 Hermes 名称。
|
||||
|
||||
@@ -125,16 +125,16 @@
|
||||
- [x] 增加风险分布图:部门、费用类型、风险类型、供应商、员工职级。[CONCEPT: 分析看板风险看板] 证据:`RiskObservationDashboard.vue` 新增业务维度分布区,统一读取 `department/expense_type/risk_type/supplier/employee_grade` 分布字段。
|
||||
- [x] 增加风险趋势图:7 天 / 30 天走势、高风险占比、处理完成率。[CONCEPT: 分析看板风险看板] 证据:`RiskDailyTrendChart.vue` 已展示风险观察与高风险趋势;风险看板时间窗口支持 7/30/90 天切换,处理完成率由闭环效果区承接。
|
||||
- [x] 增加异常排行:部门、员工、供应商、规则、费用类型。[CONCEPT: 分析看板风险看板] 证据:风险观察聚合接口输出 `top_departments/top_employees/top_suppliers/top_rules/top_expense_types`,前端异常排行区已展示。
|
||||
- [x] 增加算法效果:规则命中数、图谱异常命中数、人工确认率、误报率、候选规则数。[CONCEPT: 分析看板风险看板] 证据:风险看板已展示平均风险分、人工确认数、误报样本和候选规则数,规则/图谱来源通过来源分布体现。
|
||||
- [x] 增加算法效果:规则命中数、图谱异常命中数、人工确认率、误报率、待复核线索数。[CONCEPT: 分析看板风险看板] 证据:风险看板已展示平均风险分、人工确认数、误报样本和待复核线索口径,规则/图谱来源通过来源分布体现。
|
||||
- [x] 风险看板所有数据通过风险观察聚合接口读取,不直接拼接业务散表。[CONCEPT: 技术验收] 证据:后端已提供 `/api/v1/risk-observations/dashboard` 作为统一聚合源。
|
||||
|
||||
## 9. 规则与反馈闭环
|
||||
|
||||
- [x] 规则中心执行结果写入风险观察池或与风险观察建立关联。[CONCEPT: 统一风险观察模型] 证据:`RiskObservationService.upsert_platform_risk_flags()` 已接收规则中心风险命中,报销提交预审会同步写入风险观察池。
|
||||
- [x] 风险观察支持人工确认、误报、忽略、已处理等反馈。[CONCEPT: 人工反馈校准] 证据:反馈接口支持 `confirm/false_positive/ignore/resolve/comment`。
|
||||
- [x] 规则发现员工根据反馈生成候选规则,不直接上线。[CONCEPT: 非目标] 证据:`CandidateRiskRuleDiscovery.discover_from_feedback()` 只输出 `status == "candidate_review"` 的候选规则,技能文件要求 `auto_publish=false`,测试覆盖不直接上线。
|
||||
- [x] 风险线索归集员工根据反馈整理待复核线索,不生成、不改写、不上线规则。[CONCEPT: 非目标] 证据:技能配置统一写入 `writes_rules=false`、`role_boundary` 和 `allowed_outputs`,目录测试覆盖不再注册候选规则技能名或规则优化输出格式。
|
||||
- [x] 风险观察详情展示反馈历史和当前处理状态。[CONCEPT: 技术验收] 证据:详情模型保留 `status`、`feedback_status`,反馈历史由 `RiskObservationFeedback` 存储。
|
||||
- [x] 风险看板展示人工确认率、误报率和候选规则数量。[CONCEPT: 分析看板风险看板] 证据:聚合接口已输出 `confirmation_rate` 和 `false_positive_rate`;候选规则数待规则发现员工接入后补充。
|
||||
- [x] 风险看板展示人工确认率、误报率和待复核线索数量。[CONCEPT: 分析看板风险看板] 证据:聚合接口已输出 `confirmation_rate` 和 `false_positive_rate`;待复核线索口径由风险观察与人工复核状态聚合。
|
||||
|
||||
## 10. 测试与验证
|
||||
|
||||
|
||||
@@ -526,6 +526,32 @@ docker exec x-financial-main sh -lc "cd /app/server && pytest <target> --timeout
|
||||
docker exec x-financial-main sh -lc "cd /app/web && npm run build"
|
||||
```
|
||||
|
||||
### 本轮落地结果
|
||||
|
||||
已落地接口:
|
||||
|
||||
- `GET /agent-assets/risk-rules/templates`:返回预算、票据、差旅、招待、采购/AP、企业卡、通用模板分组,包含默认自然语言、字段清单、附件要求和 DSL 样例。
|
||||
- `PATCH /agent-assets/{asset_id}/risk-rules/draft`:编辑未上线风险规则草稿。
|
||||
- `POST /agent-assets/{asset_id}/risk-rules/revisions`:为已上线规则创建修订草稿。
|
||||
- `POST /agent-assets/{asset_id}/risk-rules/regenerate`:重新生成 DSL、流程图、风险评分和业务说明。
|
||||
- `POST /agent-assets/{asset_id}/risk-rules/feedback`、`GET /agent-assets/{asset_id}/risk-rules/feedback`:记录和查看误判/漏判反馈。
|
||||
|
||||
关键文件:
|
||||
|
||||
- 后端模板库与契约:`risk_rule_template_catalog.py`、`agent_asset.py`、`agent_asset_risk_rules.py`。
|
||||
- 后端生成、修订、发布、反馈、仿真:`risk_rule_generation*`、`agent_asset_risk_rule_revision.py`、`agent_asset_risk_rule_regeneration.py`、`agent_asset_risk_rule_publish.py`、`agent_asset_risk_rule_feedback.py`、`agent_asset_risk_rule_simulation.py`。
|
||||
- 前端新建、详情、测试:`AuditRuleDialogs.vue`、`AuditJsonRiskRuleDetail.vue`、`RiskRuleFlowDiagram.vue`、`RiskRuleTestDialog.vue`、`auditViewDetailTopBar.js`、`useAuditRiskRuleActions.js`、`useAuditAssetData.js`。
|
||||
- 测试:`test_risk_rule_template_catalog.py`、`test_risk_rule_feedback.py`、`test_risk_rule_revision_endpoints.py`、`test_risk_rule_explainability.py`、`risk-rule-detail-experience.test.mjs`。
|
||||
|
||||
已执行验证命令:
|
||||
|
||||
```bash
|
||||
docker exec x-financial-main bash -lc "cd /app/server && timeout 60 /tmp/x-financial-server-venv/bin/python -m pytest tests/test_risk_rule_template_catalog.py tests/test_openapi_schema.py -q"
|
||||
docker exec x-financial-main bash -lc "cd /app/server && timeout 60 /tmp/x-financial-server-venv/bin/python -m pytest tests/test_risk_rule_feedback.py tests/test_risk_rule_revision_endpoints.py tests/test_openapi_schema.py -q"
|
||||
docker exec x-financial-main bash -lc "cd /app/web && timeout 60 node --test tests/risk-rule-detail-experience.test.mjs"
|
||||
docker exec x-financial-main bash -lc "cd /app/web && timeout 60 npm run build"
|
||||
```
|
||||
|
||||
## 指标与验收
|
||||
|
||||
- [A1] 新建复杂差旅规则后,详情页流程解释不能出现“检查是否包含风险关键词”这类错误表达。
|
||||
@@ -544,3 +570,12 @@ docker exec x-financial-main sh -lc "cd /app/web && npm run build"
|
||||
- 老规则没有 `semantic_plan` 或 `flow_model`,需要兼容展示并允许重新生成。
|
||||
- 常见规则模板要避免写成定制逻辑。模板只能提供默认文本、字段和 DSL 样例,最终仍走通用生成链路。
|
||||
|
||||
当前仍需持续演进的点:
|
||||
|
||||
- 企业卡、采购/AP、预算场景的字段本体还偏少,后续应补充企业卡交易流水、供应商、采购订单、合同、预算期间等字段。
|
||||
- 复杂规则的准确性仍依赖 Hermes 语义计划质量,执行前必须继续保留 DSL validator、执行器 dry-run 和仿真测试。
|
||||
- 模板库只作为规则编写入口的业务参考,不作为规则执行捷径;新增模板时必须同时提供 DSL 样例和 validator 测试。
|
||||
|
||||
## 实现确认
|
||||
|
||||
当前实现仍围绕“解释图和执行逻辑一致”推进:自然语言先经字段本体和语义计划形成受控 JSON DSL,详情页流程图、文字流程解释、测试 trace、上线版本均围绕同一份 DSL 展示和执行,没有新增流程图编辑器或绕过规则执行器的判断链路。
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
|
||||
## 1. 调研与边界
|
||||
|
||||
- [ ] [CONCEPT: 背景与问题] 梳理当前风险规则生成链路,记录 `risk_rule_generation.py` 到 `risk_rule_template_executor.py` 的真实调用关系。
|
||||
- [ ] [CONCEPT: 前端设计] 梳理详情页、新建弹窗、测试弹窗当前字段来源,记录 `AuditRuleDialogs.vue`、`AuditJsonRiskRuleDetail.vue`、`RiskRuleTestDialog.vue` 的改造点。
|
||||
- [ ] [CONCEPT: 数据设计] 确认 `AgentAssetRead`、版本内容、`config_json` 中已有字段,确定 `semantic_plan`、`flow_model`、`flow_diagram_svg` 的落点。
|
||||
- [ ] [CONCEPT: 非目标] 明确本期不做流程图编辑器,不增加拖拽、缩放、节点编辑能力。
|
||||
- [x] [CONCEPT: 背景与问题] 梳理当前风险规则生成链路,记录 `risk_rule_generation.py` 到 `risk_rule_template_executor.py` 的真实调用关系。证据:`CONCEPT.md` 后端设计与本轮落地结果记录生成、DSL validator、执行器、流程图、仿真测试链路。
|
||||
- [x] [CONCEPT: 前端设计] 梳理详情页、新建弹窗、测试弹窗当前字段来源,记录 `AuditRuleDialogs.vue`、`AuditJsonRiskRuleDetail.vue`、`RiskRuleTestDialog.vue` 的改造点。证据:`CONCEPT.md` 本轮落地结果列出三个组件及对应职责,`risk-rule-detail-experience.test.mjs` 覆盖关键接线。
|
||||
- [x] [CONCEPT: 数据设计] 确认 `AgentAssetRead`、版本内容、`config_json` 中已有字段,确定 `semantic_plan`、`flow_model`、`flow_diagram_svg` 的落点。证据:`AgentAssetRead` 返回 `latest_test_summary`,版本 JSON metadata 保存 `semantic_plan`/`flow_model`/`flow_diagram_svg`,生成测试覆盖。
|
||||
- [x] [CONCEPT: 非目标] 明确本期不做流程图编辑器,不增加拖拽、缩放、节点编辑能力。证据:`RiskRuleFlowDiagram.vue` 只渲染静态 SVG/文字说明,无编辑、拖拽、缩放入口;前端回归测试断言不存在 zoom 按钮。
|
||||
|
||||
## 2. 语义计划与 DSL 契约
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
- [x] [CONCEPT: 总体链路] 调整 `risk_rule_generation_prompt.py`,要求 Hermes 先输出 `semantic_plan`,再输出 DSL。证据:提示词 `required_json_shape` 改为 `{ semantic_plan, dsl }`,`test_prompt_requires_semantic_plan_then_dsl` 验证。
|
||||
- [x] [CONCEPT: C2] 在提示词中明确:城市、日期、金额、票据关系必须用结构化比较,不允许用关键词替代。证据:`risk_rule_generation_prompt.py` 补充 `numeric_compare` 和预算金额不得关键词匹配的 guardrail。
|
||||
- [ ] [CONCEPT: 后端设计] 在 `risk_rule_generation_semantics.py` 或解释层补齐语义计划解析与错误返回。进度:已新增 `risk_rule_generation_semantic_plan.py` 解析 `{ semantic_plan, dsl }` 包装,并在生成 payload 的 `metadata.model_semantic_plan` 中保留模型语义计划;错误详情返回仍待补齐。
|
||||
- [x] [CONCEPT: 后端设计] 在 `risk_rule_generation_semantics.py` 或解释层补齐语义计划解析与错误返回。证据:`risk_rule_generation_semantic_plan.py` 解析 `{ semantic_plan, dsl }` 包装并保留 `metadata.model_semantic_plan`;后台生成失败写入 `generation_error` 和 `last_operation=generation_failed`,容器内 `test_risk_rule_generation_failure.py` 与语义计划测试通过。
|
||||
- [x] [CONCEPT: 后端设计] 在 `risk_rule_generation_interpreter.py` 中从 `semantic_plan` 生成标准 DSL。证据:新增 `build_dsl_from_semantic_plan`,当 Hermes 仅返回 `semantic_plan` 时生成 `composite_rule_v1` 草稿,再由 DSL validator 基于字段本体规范成受控条件;`test_semantic_plan_only_response_can_generate_standard_dsl` 通过。
|
||||
- [x] [CONCEPT: 指标与验收] 增加复杂差旅规则生成测试,确认判断依据不是关键词匹配。证据:`test_generate_complex_travel_route_rule_uses_formula_not_keyword_match` 验证复杂差旅规则生成后为结构化城市一致性规则,且 `condition_summary` 不含“风险关键词”;容器内 `test_risk_rule_generation.py` 通过。
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
## 5. 执行器 trace 与仿真测试
|
||||
|
||||
- [x] [CONCEPT: C5] 修改 `RiskRuleTemplateExecutor`,输出每个判断节点的 trace。证据:新增 `evaluate_with_trace`,仿真测试返回 `trace.steps` 和 `path_node_ids`。
|
||||
- [ ] [CONCEPT: C5] 仿真测试统一在“用户点击运行”后处理附件和文本,不允许上传后立即判断。
|
||||
- [ ] [CONCEPT: C5] 测试结果中展示 OCR 原始字段、Hermes 规范化字段、执行器实际输入字段。
|
||||
- [x] [CONCEPT: C5] 仿真测试统一在“用户点击运行”后处理附件和文本,不允许上传后立即判断。证据:`RiskRuleTestDialog.vue` 的 `handleFileChange` 只把附件加入待发送列表,`sendMessage` 才调用 `recognizeTemporaryFiles` 与 `simulateRiskRuleTest`;容器内 `npm run build` 通过。
|
||||
- [x] [CONCEPT: C5] 测试结果中展示 OCR 原始字段、Hermes 规范化字段、执行器实际输入字段。证据:`AgentAssetRiskRuleSimulationRead` 新增 `ocr_raw_fields`、`hermes_normalized_fields`、`executor_input_fields`,`RiskRuleTestDialog.vue` 展示字段流水线;容器内 `test_risk_rule_explainability.py` 与 `test_risk_rule_generation.py` 通过。
|
||||
- [x] [CONCEPT: C5] 测试弹窗展示命中路径、未命中原因和最终风险动作。证据:`RiskRuleTestDialog.vue` 展示“执行路径”,`riskRuleTestDialogDisplay.js` 格式化 trace。
|
||||
- [x] [CONCEPT: C5] trace 中的 `node_id` 必须能映射到流程图节点。证据:`flow_model` 使用条件 id 作为节点 id,`risk_rule_execution_trace.py` 输出同名 `node_id`。
|
||||
|
||||
@@ -50,40 +50,40 @@
|
||||
|
||||
- [x] [CONCEPT: C6] 未上线规则支持编辑标题、费用领域、附件要求和自然语言描述。证据:新增 `AgentAssetRiskRuleRevisionService.update_unpublished_draft` 与 `PATCH /agent-assets/{asset_id}/risk-rules/draft`,容器内 `test_risk_rule_revision_endpoints.py` 覆盖返回字段。
|
||||
- [x] [CONCEPT: C6] 已上线规则新增“创建修订版本”,不直接覆盖 active 版本。证据:新增 `AgentAssetRiskRuleRevisionService.create_revision_draft` 与 `POST /agent-assets/{asset_id}/risk-rules/revisions`,测试验证 `published_version` 保持不变且 `working_version` 进入修订版本。
|
||||
- [ ] [CONCEPT: C6] 修订版本保存后重新生成 DSL、流程图、风险评分和业务说明。
|
||||
- [ ] [CONCEPT: C6] 发布修订版本时归档旧版本,并记录修改人、修改原因和测试报告。
|
||||
- [ ] [CONCEPT: C6] 普通用户误判/漏判反馈进入规则反馈记录,不直接修改规则。
|
||||
- [x] [CONCEPT: C6] 修订版本保存后重新生成 DSL、流程图、风险评分和业务说明。证据:新增 `AgentAssetRiskRuleRegenerationService` 与 `POST /agent-assets/{asset_id}/risk-rules/regenerate`,草稿/修订草稿都会重新生成 JSON DSL、`flow_diagram_svg`、风险评分和版本 Markdown;容器内 `test_regenerate_revision_draft_keeps_active_document_unchanged` 通过。
|
||||
- [x] [CONCEPT: C6] 发布修订版本时归档旧版本,并记录修改人、修改原因和测试报告。证据:新增 `AgentAssetRiskRulePublishMixin`,发布修订时将旧 `rule_document` 写入 `revision_history.previous_rule_document`,切换新 JSON 文件并写入 `last_operation=publish_revision`;容器内 `test_publish_regenerated_revision_replaces_online_document` 通过。
|
||||
- [x] [CONCEPT: C6] 普通用户误判/漏判反馈进入规则反馈记录,不直接修改规则。证据:新增 `agent_asset_rule_feedback` 模型、`POST/GET /agent-assets/{asset_id}/risk-rules/feedback`、前端服务方法;容器内 `test_risk_rule_feedback.py`、规则回归和 `npm run build` 通过。
|
||||
|
||||
## 7. 常见费控规则模板库
|
||||
|
||||
- [ ] [CONCEPT: C1] 增加“从常见规则模板创建”入口。
|
||||
- [ ] [CONCEPT: C1] 模板按预算、票据、差旅、招待、采购/AP、企业卡、通用分组。
|
||||
- [ ] [CONCEPT: C3] 每个模板提供默认自然语言、字段清单、附件要求和 DSL 样例。
|
||||
- [ ] [CONCEPT: 非目标] 模板不得绕过通用生成链路,不写定制校准器。
|
||||
- [x] [CONCEPT: C1] 增加“从常见规则模板创建”入口。证据:`AuditRuleDialogs.vue` 新建风险规则弹窗新增常见规则模板选择,选择后预填标题、附件要求、业务环节、费用领域和自然语言。
|
||||
- [x] [CONCEPT: C1] 模板按预算、票据、差旅、招待、采购/AP、企业卡、通用分组。证据:新增 `risk_rule_template_catalog.py`,`GET /agent-assets/risk-rules/templates` 返回 7 个分组;容器内 `test_risk_rule_template_catalog.py` 通过。
|
||||
- [x] [CONCEPT: C3] 每个模板提供默认自然语言、字段清单、附件要求和 DSL 样例。证据:模板接口返回 `natural_language`、`fields`、`requires_attachment`、`dsl_example`;容器内测试逐个调用 DSL validator 验证通过。
|
||||
- [x] [CONCEPT: 非目标] 模板不得绕过通用生成链路,不写定制校准器。证据:前端模板只预填 `riskRuleCreateForm`,提交仍走 `generateRiskRuleAsset`;无新增定制校准器,容器内 `npm run build` 通过。
|
||||
|
||||
## 8. 前端详情与交互
|
||||
|
||||
- [ ] [CONCEPT: 前端设计] 详情页 topbar 展示规则标题、状态、风险分数、风险等级、上线/启用状态。
|
||||
- [ ] [CONCEPT: C4] 判断流程区域改成左侧文字流程解释、右侧流程图。
|
||||
- [ ] [CONCEPT: C4] 流程图标题固定为“流程图”,高度与“流程解释”标题对齐。
|
||||
- [ ] [CONCEPT: C5] 测试弹窗展示字段识别结果、规范化字段、判断路径和测试报告。
|
||||
- [x] [CONCEPT: 前端设计] 详情页 topbar 展示规则标题、状态、风险分数、风险等级、上线/启用状态。证据:`auditViewDetailTopBar.js` 为风险规则详情输出风险分、风险等级、规则状态、上线状态、启用状态 KPI;容器内 `npm run build` 通过。
|
||||
- [x] [CONCEPT: C4] 判断流程区域改成左侧文字流程解释、右侧流程图。证据:`RiskRuleFlowDiagram.vue` 使用左侧 `risk-rule-flow-explainer` 和右侧 `risk-rule-flow-visual` 的两栏布局;容器内 `npm run build` 通过。
|
||||
- [x] [CONCEPT: C4] 流程图标题固定为“流程图”,高度与“流程解释”标题对齐。证据:`RiskRuleFlowDiagram.vue` 使用统一 `risk-rule-section-title`,右侧标题固定为“流程图”;容器内 `npm run build` 通过。
|
||||
- [x] [CONCEPT: C5] 测试弹窗展示字段识别结果、规范化字段、判断路径和测试报告。证据:`RiskRuleTestDialog.vue` 展示字段流水线、执行路径和右侧测试报告摘要;容器内 `cd /app/web && npm run build` 通过。
|
||||
- [x] [CONCEPT: C6] 已上线规则详情展示“创建修订版本”,草稿规则展示“编辑规则”。证据:`AuditView.vue` 底部动作区按规则状态展示按钮,`AuditRuleDialogs.vue` 提供编辑/修订弹窗,`useAuditRiskRuleActions.js` 调用草稿编辑与修订接口;容器内 `cd /app/web && npm run build` 通过。
|
||||
- [ ] [CONCEPT: 指标与验收] 列表和详情状态刷新不能造成页面闪烁。
|
||||
- [x] [CONCEPT: 指标与验收] 列表和详情状态刷新不能造成页面闪烁。证据:`useAuditAssetData.loadSelectedAssetDetail` 增加 `{ silent: true }` 静默刷新,规则保存、送审、审核、上线、回退和版本操作均改为静默刷新;容器内 `npm run build` 通过。
|
||||
|
||||
## 9. 后端接口与权限
|
||||
|
||||
- [x] [CONCEPT: 接口设计] 实现或调整 `POST /agent-assets/{asset_id}/risk-rules/revisions`。证据:新增独立路由 `agent_asset_risk_rules.py`,容器内 `test_create_risk_rule_revision_endpoint_keeps_active_version` 通过。
|
||||
- [x] [CONCEPT: 接口设计] 实现或调整 `PATCH /agent-assets/{asset_id}/risk-rules/draft`。证据:新增独立路由 `agent_asset_risk_rules.py`,容器内 `test_update_risk_rule_draft_endpoint_updates_unpublished_rule` 与已上线阻断用例通过。
|
||||
- [ ] [CONCEPT: 接口设计] `POST /agent-assets/{asset_id}/risk-rules/regenerate` 返回生成状态和错误详情。
|
||||
- [x] [CONCEPT: 接口设计] `POST /agent-assets/{asset_id}/risk-rules/regenerate` 返回生成状态和错误详情。证据:独立路由 `agent_asset_risk_rules.py` 已接入重生成接口,成功返回 `AgentAssetRead.config_json.generation_status`/`revision_draft.generation_status`,接口用例 `test_regenerate_risk_rule_endpoint_returns_updated_detail` 通过。
|
||||
- [x] [CONCEPT: 接口设计] 仿真测试接口返回 `recognized_fields`、`normalized_fields`、`execution_result`、`trace`。证据:`AgentAssetRiskRuleSimulationRead` 新增 `normalized_fields` 和 `trace`,仿真测试覆盖返回值。
|
||||
- [ ] [CONCEPT: 用户与场景] 普通财务人员只能编辑未上线/修订草稿,admin 才能删除和测试,管理员按现有权限上线/下线。
|
||||
- [ ] [CONCEPT: 数据设计] 所有操作写入 `last_operation`,用于详情页“最后操作”展示。
|
||||
- [x] [CONCEPT: 用户与场景] 普通财务人员只能编辑未上线/修订草稿,admin 才能删除和测试,管理员按现有权限上线/下线。证据:路由依赖使用 `RuleEditorUser`、`RuleReviewerUser`、`PlatformAdminUser` 分层,`test_risk_rule_revision_endpoints.py` 覆盖 finance 新建/测试阻断、manager 删除阻断和 manager 启停入口。
|
||||
- [x] [CONCEPT: 数据设计] 所有操作写入 `last_operation`,用于详情页“最后操作”展示。证据:生成、后台生成、草稿编辑、创建修订、重新生成、发布/下线、测试确认等风险规则服务均写入 `config_json.last_operation`,前端 `AuditJsonRiskRuleDetail.vue` 展示 `lastOperationLabel`。
|
||||
|
||||
## 10. 测试与验证
|
||||
|
||||
- [x] [CONCEPT: 测试方案] 后端补充语义计划、DSL validator、执行器 trace、流程图转换单元测试。证据:`test_risk_rule_explainability.py` 覆盖语义计划、flow_model、trace;`test_risk_rule_dsl_validator.py` 覆盖 DSL validator 与 `numeric_compare` 执行;容器内相关测试通过。
|
||||
- [ ] [CONCEPT: 测试方案] 后端补充修订版本接口和发布替换接口测试。进度:已补草稿编辑与创建修订版本服务/接口测试,发布替换接口测试仍待补齐。
|
||||
- [ ] [CONCEPT: 测试方案] 前端补充详情页流程展示、测试弹窗字段展示、修订版本按钮状态测试。
|
||||
- [x] [CONCEPT: 测试方案] 后端补充修订版本接口和发布替换接口测试。证据:`test_risk_rule_revision_service.py` 覆盖草稿编辑、创建修订、修订重生成和发布替换;`test_risk_rule_revision_endpoints.py` 覆盖草稿编辑、创建修订和重生成接口;容器内相关测试通过。
|
||||
- [x] [CONCEPT: 测试方案] 前端补充详情页流程展示、测试弹窗字段展示、修订版本按钮状态测试。证据:新增 `risk-rule-detail-experience.test.mjs` 覆盖 topbar KPI、左文右图流程、字段流水线和修订按钮;容器内 `node --test tests/risk-rule-detail-experience.test.mjs` 通过。
|
||||
- [x] [CONCEPT: 容器验证] 在容器执行后端定向测试,单个命令设置 60s 超时。证据:`/tmp/x-financial-server-venv/bin/python -m pytest tests/test_risk_rule_explainability.py -q`、`test_risk_rule_composite_generation.py -q`、`test_risk_rule_generation.py -q` 均通过。
|
||||
- [x] [CONCEPT: 容器验证] 在容器执行 `cd /app/web && npm run build`。证据:容器 `/app/web` 构建通过。
|
||||
- [x] [CONCEPT: 指标与验收] 用“武汉到上海票据 + 北京出差 3 天”样例验证城市不一致规则必须命中或给出明确不命中原因。证据:`test_simulation_returns_execution_trace_for_ticket_city_mismatch` 验证命中并返回 trace。
|
||||
@@ -91,6 +91,6 @@
|
||||
|
||||
## 11. 文档收尾
|
||||
|
||||
- [ ] [CONCEPT: 指标与验收] 开发完成后补充实际接口、文件和测试命令结果。
|
||||
- [ ] [CONCEPT: 风险与开放问题] 记录暂未解决的字段本体缺口和复杂规则降级策略。
|
||||
- [ ] [CONCEPT: 功能一句话] 确认最终实现没有偏离“解释图和执行逻辑一致”的核心目标。
|
||||
- [x] [CONCEPT: 指标与验收] 开发完成后补充实际接口、文件和测试命令结果。证据:`CONCEPT.md` 新增“本轮落地结果”,列出接口、关键文件和容器验证命令。
|
||||
- [x] [CONCEPT: 风险与开放问题] 记录暂未解决的字段本体缺口和复杂规则降级策略。证据:`CONCEPT.md` 风险与开放问题补充企业卡、采购/AP、预算字段本体缺口和 DSL validator/dry-run/仿真兜底策略。
|
||||
- [x] [CONCEPT: 功能一句话] 确认最终实现没有偏离“解释图和执行逻辑一致”的核心目标。证据:`CONCEPT.md` 新增“实现确认”,明确自然语言、字段本体、JSON DSL、流程图、测试 trace 和上线版本围绕同一 DSL。
|
||||
|
||||
151
document/development/数字员工工作看板/CONCEPT.md
Normal file
151
document/development/数字员工工作看板/CONCEPT.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# 数字员工工作看板概念文档
|
||||
|
||||
## 功能一句话
|
||||
|
||||
在分析看板中新增“数字员工看板”,让用户用一个统一视角看到数字员工每天执行了哪些后台分析、整理、积累和评估工作,以及这些工作产生了什么业务结果。
|
||||
|
||||
## 背景与问题
|
||||
|
||||
当前数字员工已经有“员工技能”和“工作记录”页面,但工作记录偏运行明细,适合追溯单次任务。管理者在分析看板中缺少一个汇总视角,无法快速回答:
|
||||
|
||||
- 今天数字员工是否真的在工作。
|
||||
- 哪些技能执行最多。
|
||||
- 成功、失败、运行中的任务分别是多少。
|
||||
- 风险图谱、风险线索、员工画像和知识整理分别产出了什么。
|
||||
- 最近失败或异常的后台任务是否需要处理。
|
||||
|
||||
新增看板后,分析看板承担“经营和运行洞察”入口,数字员工页面继续承担“技能配置、工作记录详情和人工操作”入口。
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
### 目标
|
||||
|
||||
- 在分析看板顶部切换项中新增“数字员工看板”。
|
||||
- 用真实 `AgentRun` 和 `AgentToolCall` 数据聚合数字员工工作,不使用演示数据伪装真实结果。
|
||||
- 展示最近 N 天的工作总数、成功数、失败数、运行中数量、产出量和日趋势。
|
||||
- 区分技能类型:积累、升级、整理、评估。
|
||||
- 展示最近工作记录,用户能直观看到每天做了什么和产出了什么。
|
||||
|
||||
### 非目标
|
||||
|
||||
- 不替代数字员工页面的“员工技能”和“工作记录”详情。
|
||||
- 不让数字员工执行规则中心主流程,也不让数字员工定义、发布或确认风险规则。
|
||||
- 不展示内部实现名称或技术代号,页面文案统一使用“数字员工”。
|
||||
- 不在本期新增新的算法执行器,只消费已有执行结果做分析看板聚合。
|
||||
|
||||
## 用户与场景
|
||||
|
||||
- 财务负责人:查看数字员工每天是否持续产出知识整理、风险观察、画像快照和线索。
|
||||
- 风控与审计人员:查看评估、升级类任务的失败与产出情况,判断是否需要复核。
|
||||
- 系统管理员:观察后台任务是否运行稳定,识别失败任务和数据异常。
|
||||
|
||||
## 功能能力
|
||||
|
||||
### 输入
|
||||
|
||||
- `agent_runs`:数字员工运行记录。
|
||||
- `agent_tool_calls`:每次运行中的工具调用与响应摘要。
|
||||
- `route_json` / `request_json` / `response_json`:用于识别任务类型、任务编码、报告类型和产出指标。
|
||||
|
||||
### 输出
|
||||
|
||||
- KPI 指标:工作总数、成功数量、失败数量、运行中数量、业务产出、成功率。
|
||||
- 每日工作趋势:按日期聚合总数、成功、失败和主要产出量。
|
||||
- 技能类型分布:积累、升级、整理、评估。
|
||||
- 工作模块排行:财务风险图谱巡检、员工行为画像巡检、风险线索归集、知识制度整理等。
|
||||
- 最近工作记录:任务名称、状态、开始时间、耗时、摘要和关键指标。
|
||||
|
||||
### 状态
|
||||
|
||||
- 成功:`succeeded`、`success`、`completed`、`done`。
|
||||
- 失败:`failed`、`failure`、`error`、`errored`。
|
||||
- 运行中:`running`、`pending`。
|
||||
- 其他状态统一归入“其他”,但不丢弃记录。
|
||||
|
||||
### 权限与边界
|
||||
|
||||
- 本期沿用分析看板已有访问控制,不新增独立权限。
|
||||
- 看板只读,不提供运行、定时、编辑技能等操作。
|
||||
- 单次运行详情仍在数字员工工作记录页面处理。
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 后端
|
||||
|
||||
新增 `DigitalEmployeeDashboardService`:
|
||||
|
||||
- 从 `AgentRun` 查询最近 `days` 天数据,最多取 `limit` 条。
|
||||
- 通过 `agent == "hermes"`、`source == "schedule"`、`route_json` 任务字段、工具名 `digital_employee.*` 和知识整理任务类型识别数字员工工作。
|
||||
- 从工具响应中提取业务产出指标,例如风险观察数、风险线索数、画像快照数、知识文档数。
|
||||
- 返回稳定结构,前端只负责展示,不重复推断核心聚合逻辑。
|
||||
|
||||
新增接口:
|
||||
|
||||
```http
|
||||
GET /api/v1/analytics/digital-employee-dashboard?days=7&limit=300
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
新增 `DigitalEmployeeDashboard.vue`:
|
||||
|
||||
- 复用现有 `OverviewView` 的 KPI 卡片、`dashboard-card`、`BarChart` 和企业级直角视觉。
|
||||
- 使用两列到多列的看板网格,避免新增营销化卡片风格。
|
||||
- 状态、空数据和加载错误保持与风险看板一致。
|
||||
|
||||
接入点:
|
||||
|
||||
- `TopBar.vue` 增加“数字员工看板”切换项。
|
||||
- `OverviewView.vue` 新增 `activeDashboard === "digitalEmployee"` 分支。
|
||||
- `useOverviewView.js` 新增数据加载、KPI 映射、趋势行和排行行。
|
||||
- `analytics.js` 新增接口调用和字段归一化。
|
||||
|
||||
## 算法与公式
|
||||
|
||||
### 成功率
|
||||
|
||||
$$
|
||||
success\_rate = \frac{success\_runs}{max(total\_runs, 1)}
|
||||
$$
|
||||
|
||||
### 失败率
|
||||
|
||||
$$
|
||||
failure\_rate = \frac{failed\_runs}{max(total\_runs, 1)}
|
||||
$$
|
||||
|
||||
### 业务产出量
|
||||
|
||||
$$
|
||||
business\_outputs = risk\_observations + risk\_clues + profile\_snapshots + knowledge\_documents
|
||||
$$
|
||||
|
||||
### 日工作负载
|
||||
|
||||
$$
|
||||
daily\_workload_d = total\_runs_d + business\_outputs_d
|
||||
$$
|
||||
|
||||
以上公式只用于看板展示和排序,不参与规则中心决策。
|
||||
|
||||
## 测试方案
|
||||
|
||||
- 后端单元测试:构造数字员工运行、普通智能体运行、失败运行和工具响应,验证聚合结果。
|
||||
- 接口测试:验证 `/analytics/digital-employee-dashboard` 返回字段结构和空数据行为。
|
||||
- 前端静态测试:验证切换项、接口地址、组件分支和核心文案存在。
|
||||
- 构建验证:运行前端构建,确保新增 Vue 组件可编译。
|
||||
- 容器验证:在 `x-financial-main` 中运行后端定向测试,并调用真实接口确认返回 JSON。
|
||||
|
||||
## 指标与验收
|
||||
|
||||
- 分析看板切换中出现“数字员工看板”。
|
||||
- 选择该看板后页面显示 KPI、每日工作、技能类型分布、任务排行和最近工作。
|
||||
- 没有真实数据时显示空状态,不使用伪造业务数。
|
||||
- 接口返回 `has_real_data`,前端可据此判断真实数据状态。
|
||||
- 后端定向测试和前端定向测试通过。
|
||||
|
||||
## 风险与开放问题
|
||||
|
||||
- 旧版 `hermes_task_execution_logs` 中的日志没有完整工具响应,本期优先以 `AgentRun` 为准;如需兼容旧日志,可后续做补充。
|
||||
- 部分新增技能当前可能只有定义,未必已有真实执行结果,看板会显示为 0 或不出现。
|
||||
- 如果后续新增数字员工技能,需要同步更新任务类型映射,避免看板归类为“其他”。
|
||||
29
document/development/数字员工工作看板/TODO.md
Normal file
29
document/development/数字员工工作看板/TODO.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 数字员工工作看板 TODO
|
||||
|
||||
## 调研与边界
|
||||
|
||||
- [x] 梳理分析看板切换入口和现有数据流。[CONCEPT: 方案设计] 证据:`TopBar.vue`、`OverviewView.vue`、`useOverviewView.js`。
|
||||
- [x] 梳理数字员工工作记录数据来源。[CONCEPT: 功能能力] 证据:`AgentRun`、`AgentToolCall`、`digitalEmployeeWorkRecordsModel.js`。
|
||||
- [x] 明确本期非目标:不替代数字员工详情、不执行规则中心主流程、不使用演示数据。[CONCEPT: 目标与非目标]
|
||||
|
||||
## 契约与后端
|
||||
|
||||
- [x] 新增数字员工看板响应 schema。[CONCEPT: 后端] 证据:`DigitalEmployeeDashboardRead`。
|
||||
- [x] 新增 `DigitalEmployeeDashboardService` 聚合运行记录、任务分布、日趋势和最近工作。[CONCEPT: 后端] 证据:`digital_employee_dashboard.py`。
|
||||
- [x] 新增 `/analytics/digital-employee-dashboard` 接口。[CONCEPT: 后端] 证据:`analytics.py` 路由和容器接口返回。
|
||||
- [x] 补后端定向测试覆盖成功、失败、非数字员工过滤和业务产出统计。[CONCEPT: 测试方案] 证据:`server/tests/test_digital_employee_dashboard_service.py`,2 passed。
|
||||
|
||||
## 前端
|
||||
|
||||
- [x] 在分析看板切换项中增加“数字员工看板”。[CONCEPT: 前端] 证据:`TopBar.vue`。
|
||||
- [x] 在 `analytics.js` 新增接口调用和字段归一化。[CONCEPT: 前端] 证据:`fetchDigitalEmployeeDashboard` 和 `normalizeDigitalEmployeeDashboardPayload`。
|
||||
- [x] 在 `useOverviewView.js` 接入加载状态、KPI、趋势和排行数据。[CONCEPT: 前端] 证据:`useOverviewView.js` 与 `overviewDigitalEmployeeDashboardModel.js`。
|
||||
- [x] 新增 `DigitalEmployeeDashboard.vue`,复用现有企业看板风格。[CONCEPT: 前端] 证据:看板组件和 ECharts 日趋势组件。
|
||||
- [x] 在 `OverviewView.vue` 增加数字员工看板分支。[CONCEPT: 前端] 证据:`activeDashboard === "digitalEmployee"`。
|
||||
|
||||
## 验证与验收
|
||||
|
||||
- [x] 运行后端定向测试,超时不超过 60s。[CONCEPT: 测试方案] 证据:`timeout 60s ... pytest server/tests/test_digital_employee_dashboard_service.py -q`,2 passed。
|
||||
- [x] 运行前端定向测试或构建验证。[CONCEPT: 测试方案] 证据:`node --test web/tests/digital-employee-dashboard.test.mjs`,3 passed;`npm.cmd --prefix web run build` 通过。
|
||||
- [x] 在 Docker 容器中调用真实接口验证 JSON 返回。[CONCEPT: 指标与验收] 证据:`GET /api/v1/analytics/digital-employee-dashboard?days=7&limit=300` 返回 `True 1 1 知识制度整理`。
|
||||
- [x] 更新本 TODO 的完成证据。[CONCEPT: 指标与验收] 证据:本文档已更新。
|
||||
133
document/development/数字员工能力库扩展/CONCEPT.md
Normal file
133
document/development/数字员工能力库扩展/CONCEPT.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# 数字员工能力库扩展概念文档
|
||||
|
||||
更新日期:2026-05-31
|
||||
|
||||
## 功能一句话
|
||||
|
||||
把数字员工从少量后台任务扩展为覆盖事实抽取、规则命中分析、资产积累、报告生成和人工复核辅助的企业级后台分析能力库。
|
||||
|
||||
## 背景与问题
|
||||
|
||||
当前员工技能数量偏少,只有制度整理、风险图谱巡检、员工画像巡检和少量复核辅助能力。页面观感更像技术演示,不像完整的财务数字员工能力矩阵。
|
||||
|
||||
需要把已有风险图谱、制度知识、画像基线、反馈池、回放评测等算法资产拆成用户能理解的员工技能,让列表规模、分类结构和详情内容都更完整。
|
||||
|
||||
同时必须收敛数字员工边界:数字员工不是风险专家,也不是规则制定者。风险口径、规则内容、制度解释和最终判断由人负责;规则中心执行归属外层智能体流程,数字员工只负责读取事实、规则命中和反馈结果,生成后台分析、报告、知识库材料和待人工复核线索。
|
||||
|
||||
## 目标
|
||||
|
||||
- 员工技能数量扩展到不少于 16 个。
|
||||
- 保持四类技能:积累、升级、整理、评估。
|
||||
- 每个技能都有名称、描述、技能包、分类、执行场景、输入、输出、是否定时、是否写入工作记录。
|
||||
- 新增技能进入资产种子和运行时补齐逻辑,已有数据库启动后也能自动补齐。
|
||||
- 新增技能包落在 `server/src/app/skills/domain`,便于后续同步到数字员工运行侧。
|
||||
- 明确技能边界:输出事实、规则命中和待人工确认线索,不输出正式规则结论或规则变更裁判。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 本轮不引入新的数据库结构变更。
|
||||
- 本轮不要求所有新增技能都接入真实执行器。
|
||||
- 本轮不复制竞品术语或页面包装,只做 X-Financial 自有能力命名。
|
||||
- 本轮不让数字员工总结风险规则、发明新规则、修改规则中心或替代人工确认风险。
|
||||
|
||||
## 用户与场景
|
||||
|
||||
- 风控管理员:查看评估类和升级类技能,理解规则命中分析、异常线索、人工复核样本和回放评测能力。
|
||||
- 财务制度管理员:查看整理类技能,维护制度条款、政策口径和规则命中样本。
|
||||
- 数据治理人员:查看积累类技能,理解员工、部门、供应商和反馈样本如何沉淀。
|
||||
- 系统管理员:配置定时计划、查看工作记录和执行结果。
|
||||
|
||||
## 功能能力
|
||||
|
||||
完整员工技能库按四类组织:
|
||||
|
||||
- 整理:财务制度、制度条款、政策口径、规则命中样本。
|
||||
- 积累:员工画像、部门基线、供应商画像、误报样本、反馈样本。
|
||||
- 评估:风险图谱、多凭证一致性、时空一致性、预算超标、供应商异常关系。
|
||||
- 升级:风险线索归集、算法回放、制度引用缺口提示和人工复核材料整理。
|
||||
|
||||
每个技能需要提供:
|
||||
|
||||
- `skill_name`:技能包目录名。
|
||||
- `skill_category`:积累、升级、整理、评估之一。
|
||||
- `task_type`:由任务 code 派生。
|
||||
- `schedule` / `cron_expression`:默认定时计划。
|
||||
- `input_sources`:输入来源。
|
||||
- `output_format`:产出格式。
|
||||
- `writes_work_record`:是否产出工作记录。
|
||||
- `execution_strategy`:真实执行、复用现有扫描器或定义先行。
|
||||
- `role_boundary`:规则由人定义、风险由人确认、主流程由外层智能体执行,数字员工只做后台分析、报告生成和知识沉淀。
|
||||
- `allowed_outputs`:只允许输出 `facts`、`rule_hits`、`risk_clues`、`evidence_refs`、`human_review_required` 等受控字段。
|
||||
|
||||
## 数字员工边界
|
||||
|
||||
数字员工允许做三件事:
|
||||
|
||||
- 事实抽取:从申请单、报销单、票据、附件、审批记录中抽取金额、时间、地点、人员、供应商、票据号、申请关系等事实。
|
||||
- 规则命中分析:读取外层智能体流程已经产生的规则命中结果、字段依据和原始证据,用于后台报告与复核材料整理。
|
||||
- 线索归集:基于事实和规则命中输出“待人工复核”的潜在线索,不能把线索升级为正式风险结论。
|
||||
|
||||
数字员工禁止做四件事:
|
||||
|
||||
- 不总结或发明风险规则。
|
||||
- 不修改、发布、删除规则中心规则。
|
||||
- 不把潜在线索判定为最终违规结论。
|
||||
- 不替代财务、风控或管理员进行制度解释和风险确认。
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 后端
|
||||
|
||||
- 在 `agent_foundation_constants.py` 增加新增任务 code 和分类映射。
|
||||
- 在 `agent_foundation_digital_employee_tasks.py` 增加运行时任务规格。
|
||||
- 在初始种子流程完成基础任务 flush 后,调用运行时补齐逻辑,保证新库完整落库。
|
||||
- 新增技能包目录和 `SKILL.md`,内容包含功能说明、执行时机、输入输出和边界。
|
||||
- 将容易越权的“规则发现、规则模板整理、制度缺口优化”收敛为“风险线索归集、规则命中样本整理、制度引用缺口提示”。
|
||||
|
||||
### 前端
|
||||
|
||||
前端列表已按资产接口读取任务类资产,不需要新增页面结构。新增任务落库后会自动进入员工技能列表,并使用已有筛选、分类和详情展示。
|
||||
|
||||
### 算法与公式
|
||||
|
||||
本轮主要扩展能力目录和角色边界,不新增评分公式。后续每个技能接入真实算法时,再在对应算法文档中补充公式。
|
||||
|
||||
数字员工输出的线索置信度只能作为排序依据,不能作为最终风险裁判:
|
||||
|
||||
$$
|
||||
risk\_clue = f(facts, rule\_hits, evidence\_quality)
|
||||
$$
|
||||
|
||||
其中 `facts` 来自申请与报销事实,`rule_hits` 来自外层智能体流程或规则中心已经产生的命中结果,`evidence_quality` 表示证据完整度。数字员工不触发规则主流程,最终是否构成风险由人工复核或规则中心既有处置流程决定。
|
||||
|
||||
### 后台分析闭环
|
||||
|
||||
风险线索归集不是规则生产流程,而是后台分析闭环的一环:
|
||||
|
||||
- 工作记录详情展示本次归集的事实、规则命中、待复核线索和近期反馈样本。
|
||||
- 风险看板展示待复核线索数和反馈样本数,用于观察后台分析是否形成可复盘资产。
|
||||
- 人工反馈仍写入风险观察反馈池,数字员工只读取反馈池做线索排序、复核材料整理和后续报告生成。
|
||||
|
||||
## 测试方案
|
||||
|
||||
- 单元测试:校验数字员工运行时任务规格数量、分类覆盖、技能包目录存在、任务 code 唯一。
|
||||
- 配置测试:校验每个任务配置都包含 `skill_name`、`output_format`、`skill_category_options`。
|
||||
- 容器验证:在 `x-financial-main:/app/server` 运行定向测试。
|
||||
- 手工验收:进入数字员工员工技能列表,确认技能数量和分类明显完整。
|
||||
- 接口验收:风险看板接口返回 `risk_clue_count` 和 `feedback_sample_count`,工作记录详情能展示风险线索归集的反馈样本摘要。
|
||||
|
||||
## 指标与验收
|
||||
|
||||
- 员工技能总数不少于 17 个。
|
||||
- 四类分类都有技能。
|
||||
- 新增技能包全部存在 `SKILL.md`。
|
||||
- 定向测试通过。
|
||||
- 风险看板不再展示候选规则指标,改为待复核线索和反馈样本。
|
||||
- 不引入数据库迁移和破坏性变更。
|
||||
|
||||
## 风险与开放问题
|
||||
|
||||
- 新增技能中部分为“定义先行”,立即运行时需要后续逐步接入真实执行器。
|
||||
- 如果用户希望每个技能都能立即产出真实结果,需要继续拆分执行服务和工作记录产物。
|
||||
- 已接入风险线索归集真实执行器,后续应继续把多凭证、时空、预算、供应商异常从风险图谱主引擎中拆成独立算法模块。
|
||||
- 若技能命名或说明再次出现“数字员工承担规则主流程、规则发现、规则优化、自动总结风险”等表述,应优先改为读取规则命中结果、事实、线索、复核材料等受控表述。
|
||||
56
document/development/数字员工能力库扩展/TODO.md
Normal file
56
document/development/数字员工能力库扩展/TODO.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 数字员工能力库扩展 TODO
|
||||
|
||||
更新日期:2026-05-31
|
||||
|
||||
## 1. 调研与契约
|
||||
|
||||
- [x] 复核当前员工技能数量、分类和技能包目录。[CONCEPT: 背景与问题] 证据:当前已有基础技能包:制度整理、风险图谱巡检、员工画像巡检、风险线索归集。
|
||||
- [x] 定义完整能力矩阵,覆盖积累、升级、整理、评估四类。[CONCEPT: 功能能力] 证据:`CONCEPT.md` 已列出 17 个目标技能。
|
||||
|
||||
## 2. 后端资产
|
||||
|
||||
- [x] 增加新增数字员工任务 code 和分类映射。[CONCEPT: 后端] 证据:`agent_foundation_constants.py` 已新增 13 个任务 code,`DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP` 覆盖四类分类。
|
||||
- [x] 增加运行时任务规格,保证已有数据库可自动补齐新增员工技能。[CONCEPT: 后端] 证据:`agent_foundation_digital_employee_tasks.py` 已扩展到 16 个运行时任务规格,新增技能均包含 `skill_name/input_sources/output_format/execution_strategy`。
|
||||
- [x] 调整初始种子流程,保证空库初始化时也能落齐完整员工技能库。[CONCEPT: 后端] 证据:`agent_foundation_asset_seed.py` 在基础资产 `flush` 后调用 `_upsert_runtime_digital_employee_tasks()`,空库初始化会补齐完整运行时技能。
|
||||
|
||||
## 3. 技能包
|
||||
|
||||
- [x] 新增制度条款、政策口径、规则命中样本等整理类技能包。[CONCEPT: 功能能力] 证据:已新增 `finance-policy-clause-extractor`、`expense-policy-alignment`、`rule-execution-case-organizer` 技能包。
|
||||
- [x] 新增部门基线、供应商画像、误报样本、反馈样本等积累类技能包。[CONCEPT: 功能能力] 证据:已新增 `department-expense-baseline-accumulator`、`supplier-risk-profile-accumulator`、`false-positive-sample-accumulator`、`risk-feedback-sample-accumulator` 技能包。
|
||||
- [x] 新增多凭证、时空、预算、供应商关系等评估类技能包。[CONCEPT: 功能能力] 证据:已新增 `multi-evidence-consistency-evaluator`、`travel-spatiotemporal-consistency-evaluator`、`budget-overrun-precontrol-evaluator`、`supplier-abnormal-relation-evaluator` 技能包。
|
||||
- [x] 新增回放评测、制度引用缺口提示等升级类技能包。[CONCEPT: 功能能力] 证据:已新增 `risk-algorithm-replay-evaluator`、`policy-reference-gap-hinter` 技能包。
|
||||
|
||||
## 4. 测试与验收
|
||||
|
||||
- [x] 增加数字员工技能目录测试,校验任务 code 唯一、分类覆盖、技能包存在。[CONCEPT: 测试方案] 证据:新增 `tests/test_digital_employee_skill_catalog.py` 覆盖任务数量、分类、配置和技能包。
|
||||
- [x] 在 Docker 容器 `x-financial-main:/app` 运行定向测试,60s 内完成。[CONCEPT: 测试方案] 证据:`docker exec x-financial-main bash -lc "cd /app && timeout 60s /tmp/x-financial-server-venv/bin/python -m pytest server/tests/test_digital_employee_skill_catalog.py -q"` 通过,3 个测试通过。
|
||||
- [x] 确认最终员工技能总数不少于 17 个,四类分类都有技能。[CONCEPT: 指标与验收] 证据:测试断言运行时 16 个技能加 `整理公司财务知识制度` 共 17 个,分类覆盖积累、升级、整理、评估。
|
||||
|
||||
## 5. 边界收敛
|
||||
|
||||
- [x] 调整概念文档,明确数字员工不总结风险规则、不发明规则、不替代人工确认风险。[CONCEPT: 数字员工边界] 证据:`CONCEPT.md` 和 `hermes-risk-graph-algorithm/CONCEPT.md` 已把数字员工边界收敛为事实抽取、规则命中结果读取、后台分析和待复核线索归集。
|
||||
- [x] 将“风险规则候选发现、风险规则模板整理、制度缺口与规则变更建议”收敛为事实、规则命中和人工复核辅助类技能。[CONCEPT: 功能能力] 证据:运行时技能已改为 `risk-clue-collector`、`rule-execution-case-organizer`、`policy-reference-gap-hinter`。
|
||||
- [x] 在技能配置中增加 `role_boundary` 和 `allowed_outputs`,约束输出只能是事实、规则命中、线索和证据引用。[CONCEPT: 数字员工边界] 证据:`agent_foundation_digital_employee_tasks.py` 为运行时技能配置写入 `role_boundary`、`allowed_outputs` 和 `writes_rules=false`。
|
||||
- [x] 更新技能包 Markdown,禁止数字员工发布、改写、总结规则,风险线索必须待人工复核。[CONCEPT: 后端] 证据:`risk-clue-collector`、`rule-execution-case-organizer`、`policy-reference-gap-hinter` 及兼容别名技能包均已声明禁止生成、改写或发布规则。
|
||||
- [x] 增加目录测试,防止数字员工技能重新出现自动发布、规则变更、候选规则生成等越权语义。[CONCEPT: 测试方案] 证据:`test_digital_employee_skills_do_not_cross_rule_governance_boundary` 已断言旧技能名和危险输出格式不再进入数字员工目录。
|
||||
|
||||
## 7. 流程边界收敛
|
||||
|
||||
- [x] 明确规则中心命中结果归属外层智能体流程,数字员工只消费规则命中结果。[CONCEPT: 数字员工边界] 证据:`CONCEPT.md` 已改为“规则命中分析”,并声明数字员工不触发规则主流程。
|
||||
- [x] 更新技能与配置文案,禁止数字员工被描述为规则主流程处理器。[CONCEPT: 后端] 证据:`agent_foundation_digital_employee_tasks.py`、`risk-clue-collector`、`rule-execution-case-organizer` 及兼容别名技能包均已改为后台分析和复核材料口径。
|
||||
- [x] 增加测试,防止 `role_boundary` 再次出现规则主流程越界表述。[CONCEPT: 测试方案] 证据:`test_digital_employee_runtime_specs_build_display_ready_config` 已覆盖主流程归属和禁止数字员工承担规则主流程职责。
|
||||
|
||||
## 6. 风险线索归集真实执行器
|
||||
|
||||
- [x] 新增 `HermesRiskClueCollectorService`,读取申请/报销事实、规则命中、风险观察和人工反馈,输出 `risk_clue_review_packet`。[CONCEPT: 算法与公式] 证据:`hermes_risk_clue_collector.py` 输出 `facts/rule_hits/risk_clues/evidence_refs/human_review_required`。
|
||||
- [x] 将 `risk_clue_collect` 接入数字员工立即运行分发。[CONCEPT: 后端] 证据:`orchestrator_execution.py` 已新增 `digital_employee.risk_clue.collect` 工具调用,`test_schedule_digital_employee_task_runs_real_service` 覆盖分发。
|
||||
- [x] 将 `risk_clue_collect` 接入 Hermes 定时调度。[CONCEPT: 后端] 证据:`hermes_scheduler.py` 已新增 `risk_clue_collect` 分支并写入执行摘要。
|
||||
- [x] 工作记录详情识别风险线索归集产物,展示事实、规则命中、待复核线索和证据引用计数。[CONCEPT: 前端] 证据:`digitalEmployeeWorkRecordsModel.js` 和 `DigitalEmployeeRunProducts.vue` 已支持 `risk_clue` 产物,前端测试覆盖。
|
||||
- [x] 增加执行器测试,验证不写规则、不输出候选规则、线索必须待人工复核。[CONCEPT: 测试方案] 证据:`test_hermes_risk_clue_collector.py` 通过,断言 `writes_rules=false`、`human_review_required=true` 和无 `candidate_risk_rules/auto_publish`。
|
||||
|
||||
## 8. 后台分析闭环
|
||||
|
||||
- [x] 风险线索归集产物补充观察键、反馈状态和近期反馈样本摘要,方便工作记录详情定位复核上下文。[CONCEPT: 后台分析闭环] 证据:`hermes_risk_clue_collector.py` 输出 `observation_key/feedback_status/next_action/feedback_summary`,`DigitalEmployeeRunProducts.vue` 展示反馈样本。
|
||||
- [x] 风险看板聚合接口补充 `risk_clue_count` 与 `feedback_sample_count`,把数字员工后台分析结果接入看板指标。[CONCEPT: 后台分析闭环] 证据:`RiskObservationDashboardRead` 与 `RiskObservationService.summarize_dashboard()` 已输出线索数和反馈样本数。
|
||||
- [x] 风险看板前端移除“候选规则”指标,改为“待复核线索”和“反馈样本”。[CONCEPT: 指标与验收] 证据:`RiskObservationDashboard.vue` 的算法闭环效果区已展示 `待复核线索/反馈样本`,前端测试断言不再出现候选规则。
|
||||
- [x] 增加后端与前端定向测试,并在 Docker 容器内验证核心后端测试通过。[CONCEPT: 测试方案] 证据:`pytest` 定向测试 8 个通过,`node --test` 前端定向测试 8 个通过。
|
||||
127
document/development/申请交通费用自动预估/CONCEPT.md
Normal file
127
document/development/申请交通费用自动预估/CONCEPT.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# 申请交通费用自动预估概念文档
|
||||
|
||||
## 功能一句话
|
||||
|
||||
在费用申请预览阶段,系统自动生成交通票价 mock 估算,并叠加现有住宿与补助标准,形成申请预估总费用,替代用户手动填写预估金额。
|
||||
|
||||
## 背景与问题
|
||||
|
||||
当前申请流程要求用户补充“用户预估费用”。对差旅申请来说,用户在申请阶段往往只能确认出行方式、目的地和天数,交通票价又暂时缺少正式票务接口支持,因此让用户手填金额会降低流程顺畅度。
|
||||
|
||||
本期先用系统估算补齐申请金额:
|
||||
|
||||
- 交通费用:按火车、飞机、轮船三类 mock 票价生成稳定估算。
|
||||
- 住宿与补助:前端申请预览继续调用现有差旅规则测算,复用住宿上限和补助标准。
|
||||
- 后端对话:当没有前端预览上下文时,用同口径的兜底估算生成金额,避免选择交通方式后继续追问金额。
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
目标:
|
||||
|
||||
- 申请预览不再要求用户手动填写预估费用。
|
||||
- 用户选择火车、飞机或轮船后,系统能生成交通费用估算。
|
||||
- 预估总费用 = mock 交通费 + 住宿标准小计 + 补助标准小计。
|
||||
- 预览、提交文本和后端对话统一展示“系统预估费用”。
|
||||
- 保留用户明确输入金额时的兼容能力,不破坏历史提交链路。
|
||||
|
||||
非目标:
|
||||
|
||||
- 本期不接入真实机票、火车票、船票接口。
|
||||
- 本期不做票价实时波动、余票、舱等、席别、折扣和路线中转算法。
|
||||
- 本期不把 mock 估算作为最终报销金额,报销阶段仍应结合真实票据复核。
|
||||
|
||||
## 用户与场景
|
||||
|
||||
主要用户:
|
||||
|
||||
- 员工:在“申请/事前审批”环节快速发起差旅费用申请。
|
||||
- 财务/审批人:查看申请金额是否由系统按标准测算生成。
|
||||
|
||||
典型场景:
|
||||
|
||||
1. 用户输入“去上海出差 3 天,火车”。
|
||||
2. 系统识别地点、天数、出行方式。
|
||||
3. 系统 mock 生成火车往返票价。
|
||||
4. 系统调用现有差旅测算拿到住宿与补助小计。
|
||||
5. 系统在申请预览中展示系统预估费用,并允许进入确认提交。
|
||||
|
||||
## 功能能力
|
||||
|
||||
输入:
|
||||
|
||||
- 地点:用于差旅规则匹配和交通 mock 城市层级判断。
|
||||
- 天数:用于住宿与补助小计。
|
||||
- 出行方式:火车、飞机、轮船。
|
||||
- 职级:沿用现有差旅测算接口的标准匹配入参。
|
||||
|
||||
输出:
|
||||
|
||||
- 交通费用口径:说明当前是 mock 票价估算,报销阶段按真实票据复核。
|
||||
- 规则测算参考:展示交通、住宿、补助拆分与合计。
|
||||
- 系统预估费用:写入申请金额字段,用于后续申请提交。
|
||||
- 估算来源字段:记录 mock 交通估算和规则测算结果,便于后续审计解释。
|
||||
|
||||
边界:
|
||||
|
||||
- 缺少地点或天数时,仍不能完成住宿与补助测算,需要继续补齐基础字段。
|
||||
- 缺少出行方式时,仍需用户选择火车、飞机或轮船。
|
||||
- 后端纯对话流程没有前端规则测算结果时,使用保守的 mock 住宿/补助兜底。
|
||||
|
||||
## 方案设计
|
||||
|
||||
前端:
|
||||
|
||||
- 新增申请估算工具模块,集中维护交通 mock 票价、金额格式化和总额合成。
|
||||
- `expenseApplicationPreview` 在差旅规则测算返回后,将交通 mock 金额叠加到住宿与补助小计。
|
||||
- 将 `amount` 字段改为“系统预估费用”,并设为非手动必填字段。
|
||||
- 申请提交文本使用系统生成的金额。
|
||||
|
||||
后端:
|
||||
|
||||
- 新增申请系统预估服务模块,避免继续向已经超过 800 行的 `user_agent_application.py` 堆业务算法。
|
||||
- 后端对话在基础字段和出行方式齐全时自动补 `amount`、`transport_policy`、`policy_estimate`。
|
||||
- 缺失字段追问只保留基础字段和出行方式,不再追问预估金额。
|
||||
|
||||
数据与接口:
|
||||
|
||||
- 不新增数据库字段。
|
||||
- 不新增外部接口。
|
||||
- 申请详情仍通过现有 `risk_flags_json.application_detail` 保存展示字段。
|
||||
|
||||
## 算法与公式
|
||||
|
||||
交通费用采用稳定 mock:
|
||||
|
||||
$$
|
||||
transport\_amount = round\_10(base(mode, location) \times 2)
|
||||
$$
|
||||
|
||||
其中 `mode` 为火车、飞机、轮船,`location` 用于判断一线/远途/沿海等粗粒度场景,默认按往返估算。
|
||||
|
||||
总费用:
|
||||
|
||||
$$
|
||||
estimated\_total = transport\_amount + lodging\_amount + allowance\_amount
|
||||
$$
|
||||
|
||||
前端的 `lodging_amount` 和 `allowance_amount` 来自现有差旅规则测算结果;后端兜底时按 mock 标准生成。
|
||||
|
||||
## 测试方案
|
||||
|
||||
- 前端单测:验证交通 mock、规则测算合计、行标签和提交文本。
|
||||
- 后端单测:验证选择交通方式后不再追问金额,而是直接生成预览。
|
||||
- 编排流测试:验证申请会话从补充出行方式直接进入确认,再提交成功。
|
||||
- 回归测试:用户明确输入金额时仍能提交,并保留兼容链路。
|
||||
|
||||
## 指标与验收
|
||||
|
||||
- 用户选择出行方式后,系统不再提示“用户预估费用”缺失。
|
||||
- 申请预览中出现“系统预估费用”。
|
||||
- 规则测算参考包含交通、住宿、补助三项拆分。
|
||||
- 前端定向测试和后端申请流程测试通过。
|
||||
|
||||
## 风险与开放问题
|
||||
|
||||
- mock 票价不代表真实票价,只适合作为申请阶段预算参考。
|
||||
- 后端兜底住宿/补助不能完全替代规则中心,前端有规则测算结果时应优先使用规则中心。
|
||||
- 后续接入真实票务接口后,应替换交通 mock 模块,不改变申请预览和提交的数据契约。
|
||||
34
document/development/申请交通费用自动预估/TODO.md
Normal file
34
document/development/申请交通费用自动预估/TODO.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 申请交通费用自动预估开发 TODO
|
||||
|
||||
## 调研与契约
|
||||
|
||||
- [x] 确认申请预览当前金额字段仍为用户手填。证据:`expenseApplicationPreview.js` 中 `amount` 标签为“用户预估费用”。
|
||||
- [x] 确认住宿与补助已有规则测算入口。证据:前端申请预览调用 `calculateTravelReimbursement` 并读取 `hotel_amount`、`allowance_amount`。
|
||||
- [x] 确认后端对话仍追问金额。证据:`user_agent_application.py` 的缺失字段包含 `amount`。
|
||||
|
||||
## 算法
|
||||
|
||||
- [x] 新增前端申请估算工具模块,提供火车、飞机、轮船 mock 交通费。证据:`expenseApplicationEstimate.js`。
|
||||
- [x] 将前端规则测算结果合成为交通 + 住宿 + 补助总额。证据:`expenseApplicationPreview.js` 的规则测算合成逻辑和前端定向测试。
|
||||
- [x] 新增后端申请估算工具模块,提供无前端上下文时的兜底估算。证据:`application_system_estimate.py`。
|
||||
|
||||
## 前端
|
||||
|
||||
- [x] 将申请预览金额标签改为“系统预估费用”。证据:`expenseApplicationPreview.js` 字段定义。
|
||||
- [x] 将 `amount` 改为系统估算字段,不再作为用户必填项阻塞。证据:`amount` 字段 `required: false` 且 `editable: false`。
|
||||
- [x] 更新交通费用口径文案,明确是模拟票价估算。证据:`buildTransportPolicyText` 输出模拟票价口径。
|
||||
- [x] 在规则测算成功后写入系统预估总费用。证据:前端测试 `application preview merges rule center travel estimate into highlighted rows`。
|
||||
|
||||
## 后端
|
||||
|
||||
- [x] 选择出行方式后自动生成系统预估费用。证据:后端测试 `test_user_agent_application_builds_system_estimate_after_transport_choice`。
|
||||
- [x] 缺失字段追问不再包含 `amount`。证据:后端申请流程定向测试。
|
||||
- [x] 后端预览和提交摘要统一展示“系统预估费用”。证据:`user_agent_application.py` 摘要表字段。
|
||||
|
||||
## 测试与验证
|
||||
|
||||
- [x] 更新前端申请预览定向测试。证据:`expense-application-fast-preview.test.mjs`。
|
||||
- [x] 更新后端用户 Agent 申请流程测试。证据:`test_user_agent_service.py`。
|
||||
- [x] 更新编排流申请提交测试。证据:`test_orchestrator_review_flow.py`。
|
||||
- [x] 运行前端定向测试,记录结果。证据:`node --test web/tests/expense-application-fast-preview.test.mjs`,14 passed。
|
||||
- [x] 在 `x-financial-main` 容器内运行后端定向测试,记录结果。证据:申请相关 7 个 UserAgent 用例通过、2 个 Orchestrator 用例通过;整包定向存在无关查询动作测试失败。
|
||||
106
document/development/费用审批动态路由/CONCEPT.md
Normal file
106
document/development/费用审批动态路由/CONCEPT.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 费用审批动态路由概念文档
|
||||
|
||||
## 功能一句话
|
||||
|
||||
让费用申请和报账在直属领导审批后,按预算风险、规则风险和员工历史风险动态决定是否进入预算管理者复核。
|
||||
|
||||
## 背景与问题
|
||||
|
||||
当前申请单默认进入预算管理者审批,报账单默认进入财务审批,审批路径偏固定。业务上更合理的方式是:预算充足、当前无风险、历史画像正常的单据减少审批层级;存在超预算、规则命中、超标或历史风险异常的单据交给预算管理者做二次确认。
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
目标:
|
||||
|
||||
- 申请环节:低风险且预算充足时,直属领导审批后直接完成申请并生成报销草稿。
|
||||
- 申请环节:超预算、预算预警、当前风险或历史风险异常时,进入预算管理者审批。
|
||||
- 报账环节:低风险且预算充足时,直属领导审批后进入财务审批。
|
||||
- 报账环节:超预算、超标、当前风险或历史风险异常时,先进入预算管理者审批,再进入财务审批。
|
||||
- 审批记录中保留路由决策依据,便于追溯。
|
||||
|
||||
非目标:
|
||||
|
||||
- 不改预算占用、释放、核销的资金动作语义。
|
||||
- 不引入新的审批流配置页面。
|
||||
- 不让大模型参与最终审批路由裁判。
|
||||
|
||||
## 用户与场景
|
||||
|
||||
- 普通员工:提交费用申请或报账。
|
||||
- 直属领导:确认业务必要性。
|
||||
- 预算管理者:只复核有预算或风险关注项的单据。
|
||||
- 财务人员:处理报账财务终审和付款前流程。
|
||||
|
||||
## 功能能力
|
||||
|
||||
路由决策输入:
|
||||
|
||||
- 单据基本信息:金额、费用类型、发生时间、部门、项目、申请人。
|
||||
- 预算测算:预算池匹配、可用余额、预算使用率、预警阈值、超预算金额。
|
||||
- 当前风险:预算标记、规则中心风险、提交预审风险、票据/附件风险、超标风险。
|
||||
- 历史风险:同一员工近一段时间内的实质风险记录。
|
||||
|
||||
路由决策输出:
|
||||
|
||||
- `requires_budget_review`:是否需要预算管理者复核。
|
||||
- `route`:下一环节建议。
|
||||
- `reasons`:触发预算复核或跳过的原因。
|
||||
- `budget_result`:预算模型摘要。
|
||||
- `current_risk_count`、`historical_risk_count`:当前和历史风险计数。
|
||||
|
||||
## 方案设计
|
||||
|
||||
后端新增独立审批路由决策模块,避免在审批主流程中堆条件。
|
||||
|
||||
直属领导审批时:
|
||||
|
||||
1. 调用预算服务计算当前单据预算影响。
|
||||
2. 读取当前单据风险标记,过滤审批记录等非风险事件。
|
||||
3. 查询同一员工近期历史单据,统计实质风险记录。
|
||||
4. 生成路由决策标记并写入 `risk_flags_json`。
|
||||
5. 根据结果决定下一环节:
|
||||
- 申请单:预算复核或审批完成。
|
||||
- 报账单:预算复核或财务审批。
|
||||
|
||||
预算管理者审批时:
|
||||
|
||||
- 申请单进入审批完成,并生成报销草稿。
|
||||
- 报账单进入财务审批。
|
||||
|
||||
## 算法与公式
|
||||
|
||||
路由决策不是单一分数,而是规则化闸口:
|
||||
|
||||
$$
|
||||
requires\_budget\_review =
|
||||
budget\_risk \lor current\_risk \lor historical\_risk
|
||||
$$
|
||||
|
||||
其中:
|
||||
|
||||
- `budget_risk = rating in {block, review, caution} or risk_level in {medium, high, critical}`
|
||||
- `current_risk = 当前单据存在 medium/high/critical 实质风险标记`
|
||||
- `historical_risk = 同一员工近期存在实质风险记录`
|
||||
|
||||
实质风险标记排除审批通过、退回、付款、路由说明等流程记录,只保留预算、规则、AI 预审、附件、政策超标等风险来源。
|
||||
|
||||
## 测试方案
|
||||
|
||||
- 单元测试:低风险申请跳过预算管理者并生成报销草稿。
|
||||
- 单元测试:高风险报账进入预算管理者审批,预算审批后进入财务审批。
|
||||
- 回归测试:原有风险规则生成、申请提交、阶段化风险规则执行继续通过。
|
||||
- 容器验证:在 `x-financial-main:/app/server` 内运行定向 pytest。
|
||||
|
||||
## 指标与验收
|
||||
|
||||
- 低风险申请不会固定进入预算管理者审批。
|
||||
- 风险报账会进入预算管理者审批。
|
||||
- 报账经过预算管理者审批后仍需进入财务终审。
|
||||
- 每次动态路由都有可追溯的 `approval_routing` 标记。
|
||||
- 预算资金动作仍由原有提交、退回、财务终审链路处理。
|
||||
|
||||
## 风险与开放问题
|
||||
|
||||
- 历史风险的口径会影响预算管理者工作量,当前一期采用“存在实质风险即复核”的严格口径。
|
||||
- 缺失预算池时是否全部进入预算复核,当前按预算风险处理。
|
||||
- 后续如要支持可配置路由阈值,应新增配置表或策略服务,而不是继续改审批流分支。
|
||||
9
document/development/费用审批动态路由/TODO.md
Normal file
9
document/development/费用审批动态路由/TODO.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# 费用审批动态路由 TODO
|
||||
|
||||
- [x] 调研现有审批流、预算模型和风险标记结构。[CONCEPT: 方案设计] 证据:已梳理 `expense_claim_approval_flow.py`、`budget.py`、`budget_expense_control.py`、`expense_claim_risk_review.py`。
|
||||
- [x] 新增审批路由决策模块,统一输出是否需要预算复核。[CONCEPT: 功能能力] 证据:新增 `expense_claim_approval_routing.py`。
|
||||
- [x] 接入申请单直属领导审批后的动态路由。[CONCEPT: 方案设计] 证据:`ExpenseClaimApprovalFlowMixin.approve_claim` 根据路由结果完成或进入预算审批。
|
||||
- [x] 接入报账单直属领导审批后的动态路由,并允许报账经过预算管理者后进入财务审批。[CONCEPT: 方案设计] 证据:报账单预算审批后进入 `FINANCE_APPROVAL_STAGE`。
|
||||
- [x] 审批记录写入 `approval_routing` 决策标记。[CONCEPT: 指标与验收] 证据:审批通过时同时写入路由标记和 `route_decision` 摘要。
|
||||
- [x] 补充低风险申请跳过预算、高风险报账进入预算的测试。[CONCEPT: 测试方案] 证据:新增 `test_expense_claim_approval_routing.py`。
|
||||
- [x] 在 `x-financial-main:/app/server` 运行 60s 内定向验证。[CONCEPT: 测试方案] 证据:`uv run --with pytest python -m pytest ... -q`,6 passed。
|
||||
188
document/development/预算中心列表化改造/CONCEPT.md
Normal file
188
document/development/预算中心列表化改造/CONCEPT.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# 预算中心列表化改造概念文档
|
||||
|
||||
## 功能一句话
|
||||
|
||||
将预算中心从看板式监控页改造成单据中心式预算列表,让预算的正式额度、待审核草案和历史归档有清晰入口。
|
||||
|
||||
## 背景与问题
|
||||
|
||||
当前预算中心以预算概览、部门切换、预算明细表和图表为主,适合查看执行情况,但不适合承载预算编制后的审核流程。预算从 AI 分析、部门编制、提交审核、高级财务审核、发布生效到归档,天然是对象生命周期,不应该把审核入口硬塞进看板区域。
|
||||
|
||||
本次改造采用类似单据中心的列表结构,把预算对象按状态域分成三个入口:
|
||||
|
||||
- 全部预算:查看已发布并生效的部门预算。
|
||||
- 预算审核:查看各部门提交的预算草案,由高级财务人员审核。
|
||||
- 归档预算:查看历史版本、已驳回、已失效或被新版本替换的预算。
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
### 目标
|
||||
|
||||
- 将预算中心主界面改为列表形态。
|
||||
- 提供三个 switch/tab:全部预算、预算审核、归档预算。
|
||||
- 全部预算按部门展示正式预算,并在详情中展示年度、季度、月度预算。
|
||||
- 预算审核按部门展示已提交预算草案,并提供进入审核助手的入口。
|
||||
- 归档预算展示历史预算版本和审核痕迹。
|
||||
- 保留预算监控员、高级财务人员、admin 的预算可见性边界。
|
||||
- 前端 demo 阶段仅覆盖差旅、通信、招待费、办公用品四类预算。
|
||||
|
||||
### 非目标
|
||||
|
||||
- 本阶段不直接完成后端预算草案表、审核表和发布接口。
|
||||
- 本阶段不实现真实审核通过或驳回的数据库写入。
|
||||
- 本阶段不改变报销单据的预算占用和核销逻辑。
|
||||
- 本阶段不扩大普通员工的预算可见范围。
|
||||
|
||||
## 用户与场景
|
||||
|
||||
- 预算监控员:查看本部门正式预算、提交草案状态和历史归档。
|
||||
- 高级财务人员:查看所有部门预算、审核各部门提交的预算草案。
|
||||
- admin:查看所有预算数据并兜底处理异常。
|
||||
- 普通员工:不进入预算中心,不需要关注预算。
|
||||
|
||||
## 功能能力
|
||||
|
||||
### 全部预算
|
||||
|
||||
输入:
|
||||
|
||||
- 年度
|
||||
- 季度
|
||||
- 状态
|
||||
- 关键词
|
||||
|
||||
输出:
|
||||
|
||||
- 预算编号
|
||||
- 部门
|
||||
- 预算周期
|
||||
- 年度预算
|
||||
- 季度预算
|
||||
- 月度预算
|
||||
- 已发生
|
||||
- 已占用
|
||||
- 剩余可用
|
||||
- 风险状态
|
||||
- 更新时间
|
||||
|
||||
点击行进入预算详情,详情展示:
|
||||
|
||||
- 年度预算、季度预算、月度预算
|
||||
- 四类费用预算明细:差旅、通信、招待费、办公用品
|
||||
- 已发生、已占用、剩余可用和使用率
|
||||
- 提醒阈值、告警阈值、风险阈值
|
||||
|
||||
### 预算审核
|
||||
|
||||
输入:
|
||||
|
||||
- 年度
|
||||
- 季度
|
||||
- 审核状态
|
||||
- 关键词
|
||||
|
||||
输出:
|
||||
|
||||
- 草案编号
|
||||
- 提交部门
|
||||
- 编制人
|
||||
- 提交时间
|
||||
- 预算周期
|
||||
- 申请预算
|
||||
- 较上一版变化
|
||||
- AI 分析分
|
||||
- 风险状态
|
||||
- 审核状态
|
||||
|
||||
高级财务人员和 admin 可以通过“进入审核”打开预算编制助手,带入当前部门草案上下文。
|
||||
|
||||
### 归档预算
|
||||
|
||||
输入:
|
||||
|
||||
- 年度
|
||||
- 季度
|
||||
- 归档状态
|
||||
- 关键词
|
||||
|
||||
输出:
|
||||
|
||||
- 归档编号
|
||||
- 部门
|
||||
- 预算周期
|
||||
- 版本
|
||||
- 归档类型
|
||||
- 原预算额
|
||||
- 审核人
|
||||
- 归档时间
|
||||
- 状态
|
||||
|
||||
## 方案设计
|
||||
|
||||
### 前端
|
||||
|
||||
- `BudgetCenterView.vue` 改成列表页结构。
|
||||
- 复用单据中心的 `status-tabs`、`table-wrap`、`list-foot` 视觉结构。
|
||||
- 保留 `EnterpriseSelect` 作为年度、季度、状态和分页大小控件。
|
||||
- 使用通用详情页承载预算详情,和票据夹等列表详情页保持同一交互结构。
|
||||
- 使用预算助手入口处理编制和审核上下文。
|
||||
- 抽出预算列表 demo 数据和转换逻辑到 `budgetCenterListModel.js`,避免主脚本继续变大。
|
||||
|
||||
### 后端
|
||||
|
||||
本阶段不改后端。后续应新增预算草案、预算审核和预算发布接口,并将已发布预算写入正式预算池。
|
||||
|
||||
### 权限
|
||||
|
||||
- 预算监控员:只能看到本部门预算和本部门提交记录。
|
||||
- 高级财务人员:可以看到全部部门预算,并审核预算草案。
|
||||
- admin:可以看到全部预算并兜底处理。
|
||||
|
||||
## 算法与公式
|
||||
|
||||
预算使用率:
|
||||
|
||||
$$
|
||||
usageRate = \frac{usedAmount + occupiedAmount}{budgetAmount} \times 100
|
||||
$$
|
||||
|
||||
剩余可用预算:
|
||||
|
||||
$$
|
||||
availableAmount = budgetAmount - usedAmount - occupiedAmount
|
||||
$$
|
||||
|
||||
风险分层:
|
||||
|
||||
$$
|
||||
risk =
|
||||
\begin{cases}
|
||||
风险, & usageRate \ge riskThreshold \\
|
||||
告警, & usageRate \ge alertThreshold \\
|
||||
提醒, & usageRate \ge reminderThreshold \\
|
||||
正常, & usageRate < reminderThreshold
|
||||
\end{cases}
|
||||
$$
|
||||
|
||||
## 测试方案
|
||||
|
||||
- 静态检查:预算中心脚本 `node --check`。
|
||||
- 前端构建:`npm.cmd --prefix web run build`。
|
||||
- 交互验证:切换全部预算、预算审核、归档预算,检查筛选、分页、通用详情页和预算助手入口。
|
||||
- 权限验证:使用 admin、高级财务人员、预算监控员分别检查可见范围。
|
||||
- 响应式验证:检查笔记本宽度下列表横向滚动、通用详情页和按钮尺寸。
|
||||
|
||||
## 指标与验收
|
||||
|
||||
- 预算中心首屏为列表,而不是原看板。
|
||||
- 三个 switch/tab 可切换:全部预算、预算审核、归档预算。
|
||||
- 全部预算详情能看到年度、季度、月度预算。
|
||||
- 预算审核列表有进入审核入口。
|
||||
- 预算监控员不出现跨部门审核能力。
|
||||
- 构建通过,无新增运行时引用错误。
|
||||
|
||||
## 风险与开放问题
|
||||
|
||||
- 后端预算草案和审核表尚未落库,本阶段使用前端 demo 数据表达流程。
|
||||
- 后续需要明确“审核通过”是自动发布,还是高级财务人员审核后再点击发布。
|
||||
- 归档预算的触发条件需要后续和预算发布版本模型一起设计。
|
||||
37
document/development/预算中心列表化改造/TODO.md
Normal file
37
document/development/预算中心列表化改造/TODO.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# 预算中心列表化改造 TODO
|
||||
|
||||
## 调研
|
||||
|
||||
- [x] 阅读预算中心现有页面结构和脚本。证据:`BudgetCenterView.vue`、`BudgetCenterView.js`。
|
||||
- [x] 阅读单据中心列表结构。证据:`DocumentsCenterView.vue`、`document-list-shared.css`。
|
||||
- [x] 确认预算中心 UI 规范。证据:`x-financial-enterprise-ui-style` 技能。
|
||||
|
||||
## 契约与数据
|
||||
|
||||
- [x] 定义预算中心三个页签:全部预算、预算审核、归档预算。证据:`CONCEPT.md` 功能能力。
|
||||
- [x] 定义前端 demo 阶段的预算列表字段。证据:`CONCEPT.md` 功能能力。
|
||||
- [x] 抽出预算列表数据模型与格式化逻辑到独立脚本。证据:`budgetCenterListModel.js`。`[CONCEPT: 方案设计]`
|
||||
|
||||
## 前端实现
|
||||
|
||||
- [x] 将预算中心主界面改成单据中心式列表结构。证据:`BudgetCenterView.vue` 使用 `status-tabs`、`table-wrap`、`list-foot`。`[CONCEPT: 前端]`
|
||||
- [x] 增加全部预算、预算审核、归档预算三个 switch/tab。证据:Playwright 验证 `全部预算6 / 预算审核6 / 归档预算6`。`[CONCEPT: 功能能力]`
|
||||
- [x] 增加关键词、年度、季度、状态筛选。证据:`BudgetCenterView.vue` 工具栏筛选控件。`[CONCEPT: 全部预算]`
|
||||
- [x] 增加分页和空状态。证据:`BudgetCenterView.vue` 分页脚与 `TableEmptyState`。`[CONCEPT: 测试方案]`
|
||||
- [x] 增加预算通用详情页,展示年度、季度、月度预算。证据:Playwright 验证详情含年度预算、季度预算、月度预算。`[CONCEPT: 全部预算]`
|
||||
- [x] 增加预算审核入口,带上下文进入预算助手。证据:预算审核列表操作列显示“进入审核”。`[CONCEPT: 预算审核]`
|
||||
- [x] 按权限限制预算监控员和高级财务人员可见范围。证据:Playwright 验证预算监控员仅 1 条技术部记录,审核操作为“查看进度”。`[CONCEPT: 权限]`
|
||||
- [x] 将预算工作区纳入单据中心同一外层触底布局。证据:`app.css` 增加 `budget-main` 与 `budget-workarea` 高度规则。`[CONCEPT: 前端]`
|
||||
|
||||
## 验证
|
||||
|
||||
- [x] 运行 `node --check web/src/views/scripts/BudgetCenterView.js`。证据:命令通过。`[CONCEPT: 测试方案]`
|
||||
- [x] 运行 `node --check web/src/views/scripts/budgetCenterListModel.js`。证据:命令通过。`[CONCEPT: 测试方案]`
|
||||
- [x] 运行 `npm.cmd --prefix web run build`。证据:构建通过,仅剩既有 VueUse 注释和 chunk 体积 warning。`[CONCEPT: 验收]`
|
||||
- [x] 做预算页基础渲染验证,确认三个页签、通用详情页、审核入口可用。证据:浏览器验证预算列表 1366×768 视口下触底,详情页无 `ElDrawer`,详情四类费用和图表渲染,console 无新增错误。`[CONCEPT: 验收]`
|
||||
|
||||
## 后续阶段
|
||||
|
||||
- [ ] 设计后端预算草案表、预算审核表和发布接口。`[CONCEPT: 后端]`
|
||||
- [ ] 将审核通过后的预算发布到正式预算池。`[CONCEPT: 后端]`
|
||||
- [ ] 将报销预算占用、费用控制和预算发布版本打通。`[CONCEPT: 风险与开放问题]`
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.application.large_expense_without_preapproval",
|
||||
"name": "大额费用未事前申请",
|
||||
"description": "达到财务制度中大额标准的费用,未找到有效事前申请即进入报销。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "申请前置",
|
||||
"ontology_signal": "application_required",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "finance.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "application.id",
|
||||
"label": "申请单",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.status",
|
||||
"label": "申请状态",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.approved_amount",
|
||||
"label": "申请审批金额",
|
||||
"type": "number",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.expense_type",
|
||||
"label": "申请费用类型",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.department_name",
|
||||
"label": "申请部门",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"application.id",
|
||||
"application.status",
|
||||
"application.approved_amount",
|
||||
"application.expense_type",
|
||||
"application.department_name"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"大额费用",
|
||||
"未申请",
|
||||
"先申请后报销"
|
||||
],
|
||||
"condition_summary": "金额达到大额阈值且缺少已通过申请单时触发。",
|
||||
"finance_rule_code": "finance.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "high",
|
||||
"action": "manual_review",
|
||||
"risk_score": 86
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.805274+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 86,
|
||||
"risk_level": "high",
|
||||
"rule_title": "大额费用未事前申请",
|
||||
"finance_rule_code": "finance.preapproval.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 86,
|
||||
"risk_level": "high"
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"rule_code": "risk.application.marketing_without_campaign",
|
||||
"name": "市场推广费无活动申请",
|
||||
"description": "市场活动、投放、展会等推广费用,缺少已审批的活动申请或投放方案。",
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "申请前置",
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.application.meal_high_value_without_preapproval",
|
||||
"name": "大额业务招待未申请",
|
||||
"description": "业务招待金额或人均金额超过制度阈值但未事前审批。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "申请前置",
|
||||
"ontology_signal": "application_required",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"meal"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"meal"
|
||||
],
|
||||
"business_stages": [
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "application.id",
|
||||
"label": "申请单",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.status",
|
||||
"label": "申请状态",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.approved_amount",
|
||||
"label": "申请审批金额",
|
||||
"type": "number",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.expense_type",
|
||||
"label": "申请费用类型",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.department_name",
|
||||
"label": "申请部门",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "material.attendee_list_uploaded",
|
||||
"label": "参与人清单已上传",
|
||||
"type": "boolean",
|
||||
"source": "material"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"application.id",
|
||||
"application.status",
|
||||
"application.approved_amount",
|
||||
"application.expense_type",
|
||||
"application.department_name",
|
||||
"material.attendee_list_uploaded"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"业务招待",
|
||||
"人均超标",
|
||||
"未申请"
|
||||
],
|
||||
"condition_summary": "业务招待金额超过申请阈值且没有通过申请时触发。",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"meal"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "high",
|
||||
"action": "manual_review",
|
||||
"risk_score": 84
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.818641+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 84,
|
||||
"risk_level": "high",
|
||||
"rule_title": "大额业务招待未申请",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"meal"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 84,
|
||||
"risk_level": "high"
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.application.office_bulk_without_purchase",
|
||||
"name": "办公用品大额采购未申请",
|
||||
"description": "批量办公用品或设备采购达到阈值但未走采购申请。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "申请前置",
|
||||
"ontology_signal": "application_required",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"office"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"office"
|
||||
],
|
||||
"business_stages": [
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "application.id",
|
||||
"label": "申请单",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.status",
|
||||
"label": "申请状态",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.approved_amount",
|
||||
"label": "申请审批金额",
|
||||
"type": "number",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.expense_type",
|
||||
"label": "申请费用类型",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.department_name",
|
||||
"label": "申请部门",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"application.id",
|
||||
"application.status",
|
||||
"application.approved_amount",
|
||||
"application.expense_type",
|
||||
"application.department_name"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"办公采购",
|
||||
"大额办公用品",
|
||||
"采购申请"
|
||||
],
|
||||
"condition_summary": "办公用品单次金额达到采购阈值且缺少采购申请时触发。",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"office"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "medium",
|
||||
"action": "manual_review",
|
||||
"risk_score": 78
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.811910+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 78,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "办公用品大额采购未申请",
|
||||
"finance_rule_code": "expense.application.policy",
|
||||
"finance_rule_sheet": "费用申请前置规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"office"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 78,
|
||||
"risk_level": "medium"
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.application.travel_large_without_preapproval",
|
||||
"name": "大额差旅未申请",
|
||||
"description": "多人出差、长周期出差或高金额差旅报销缺少出差申请。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "申请前置",
|
||||
"ontology_signal": "application_required",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"travel"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"travel"
|
||||
],
|
||||
"business_stages": [
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "application.id",
|
||||
"label": "申请单",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.status",
|
||||
"label": "申请状态",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.approved_amount",
|
||||
"label": "申请审批金额",
|
||||
"type": "number",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.expense_type",
|
||||
"label": "申请费用类型",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.department_name",
|
||||
"label": "申请部门",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"application.id",
|
||||
"application.status",
|
||||
"application.approved_amount",
|
||||
"application.expense_type",
|
||||
"application.department_name"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"差旅申请",
|
||||
"大额差旅",
|
||||
"未申请"
|
||||
],
|
||||
"condition_summary": "差旅金额达到大额阈值且缺少有效出差申请时触发。",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"travel"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "high",
|
||||
"action": "manual_review",
|
||||
"risk_score": 82
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.826264+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 82,
|
||||
"risk_level": "high",
|
||||
"rule_title": "大额差旅未申请",
|
||||
"finance_rule_code": "rule.expense.company_travel_expense_reimbursement",
|
||||
"finance_rule_sheet": "差旅住宿费标准",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"travel"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 82,
|
||||
"risk_level": "high"
|
||||
}
|
||||
@@ -18,17 +18,7 @@
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"travel",
|
||||
"hotel",
|
||||
"transport",
|
||||
"meal",
|
||||
"meeting",
|
||||
"marketing",
|
||||
"office",
|
||||
"training",
|
||||
"software",
|
||||
"communication",
|
||||
"welfare"
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
@@ -36,17 +26,7 @@
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"travel",
|
||||
"hotel",
|
||||
"transport",
|
||||
"meal",
|
||||
"meeting",
|
||||
"marketing",
|
||||
"office",
|
||||
"training",
|
||||
"software",
|
||||
"communication",
|
||||
"welfare"
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
@@ -68,17 +48,65 @@
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "budget.line_id",
|
||||
"label": "预算行",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.available_amount",
|
||||
"label": "预算可用金额",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.used_rate",
|
||||
"label": "预算使用率",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.status",
|
||||
"label": "预算状态",
|
||||
"type": "enum",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.department_name",
|
||||
"label": "预算部门",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.quarter",
|
||||
"label": "预算季度",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.project_code",
|
||||
"label": "预算项目",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -117,17 +145,7 @@
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"travel",
|
||||
"hotel",
|
||||
"transport",
|
||||
"meal",
|
||||
"meeting",
|
||||
"marketing",
|
||||
"office",
|
||||
"training",
|
||||
"software",
|
||||
"communication",
|
||||
"welfare"
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
@@ -146,7 +164,7 @@
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-30T00:00:00Z",
|
||||
"created_at": "2026-05-31T00:10:41.751292+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 88,
|
||||
"risk_level": "high",
|
||||
@@ -159,17 +177,7 @@
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"travel",
|
||||
"hotel",
|
||||
"transport",
|
||||
"meal",
|
||||
"meeting",
|
||||
"marketing",
|
||||
"office",
|
||||
"training",
|
||||
"software",
|
||||
"communication",
|
||||
"welfare"
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
|
||||
187
server/rules/risk-rules/risk.budget.consume_without_release.json
Normal file
187
server/rules/risk-rules/risk.budget.consume_without_release.json
Normal file
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.budget.consume_without_release",
|
||||
"name": "预算占用未释放",
|
||||
"description": "申请取消、退回或驳回后,预算占用未释放导致后续可用预算失真。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "预算管控",
|
||||
"ontology_signal": "budget_over_limit",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "budget.line_id",
|
||||
"label": "预算行",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.available_amount",
|
||||
"label": "预算可用金额",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.used_rate",
|
||||
"label": "预算使用率",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.status",
|
||||
"label": "预算状态",
|
||||
"type": "enum",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.department_name",
|
||||
"label": "预算部门",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.quarter",
|
||||
"label": "预算季度",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.project_code",
|
||||
"label": "预算项目",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"budget.line_id",
|
||||
"budget.available_amount",
|
||||
"budget.used_rate",
|
||||
"budget.status",
|
||||
"budget.department_name",
|
||||
"budget.quarter",
|
||||
"budget.project_code"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"占用未释放",
|
||||
"退回未释放",
|
||||
"预算释放"
|
||||
],
|
||||
"condition_summary": "申请非有效状态但仍存在预算占用时触发。",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "medium",
|
||||
"action": "manual_review",
|
||||
"risk_score": 72
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.798394+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 72,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "预算占用未释放",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 72,
|
||||
"risk_level": "medium"
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.budget.cross_department_without_authorization",
|
||||
"name": "跨部门预算未授权",
|
||||
"description": "报销部门与预算归属部门不一致,且没有跨部门预算授权。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "预算管控",
|
||||
"ontology_signal": "budget_over_limit",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "budget.line_id",
|
||||
"label": "预算行",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.available_amount",
|
||||
"label": "预算可用金额",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.used_rate",
|
||||
"label": "预算使用率",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.status",
|
||||
"label": "预算状态",
|
||||
"type": "enum",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.department_name",
|
||||
"label": "预算部门",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.quarter",
|
||||
"label": "预算季度",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.project_code",
|
||||
"label": "预算项目",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"budget.line_id",
|
||||
"budget.available_amount",
|
||||
"budget.used_rate",
|
||||
"budget.status",
|
||||
"budget.department_name",
|
||||
"budget.quarter",
|
||||
"budget.project_code"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"跨部门预算",
|
||||
"部门不一致",
|
||||
"未授权"
|
||||
],
|
||||
"condition_summary": "单据部门与预算部门不一致且无授权说明时触发。",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "high",
|
||||
"action": "manual_review",
|
||||
"risk_score": 86
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.774598+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 86,
|
||||
"risk_level": "high",
|
||||
"rule_title": "跨部门预算未授权",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 86,
|
||||
"risk_level": "high"
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.budget.cross_quarter_without_explanation",
|
||||
"name": "跨季度预算未说明",
|
||||
"description": "单据发生期间与预算季度不一致,且缺少跨季度使用说明。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "预算管控",
|
||||
"ontology_signal": "budget_over_limit",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "budget.line_id",
|
||||
"label": "预算行",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.available_amount",
|
||||
"label": "预算可用金额",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.used_rate",
|
||||
"label": "预算使用率",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.status",
|
||||
"label": "预算状态",
|
||||
"type": "enum",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.department_name",
|
||||
"label": "预算部门",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.quarter",
|
||||
"label": "预算季度",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.project_code",
|
||||
"label": "预算项目",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"budget.line_id",
|
||||
"budget.available_amount",
|
||||
"budget.used_rate",
|
||||
"budget.status",
|
||||
"budget.department_name",
|
||||
"budget.quarter",
|
||||
"budget.project_code"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"跨季度预算",
|
||||
"季度不一致",
|
||||
"未说明"
|
||||
],
|
||||
"condition_summary": "发生季度与预算季度不一致且未说明时触发。",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "medium",
|
||||
"action": "manual_review",
|
||||
"risk_score": 76
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.779201+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 76,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "跨季度预算未说明",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 76,
|
||||
"risk_level": "medium"
|
||||
}
|
||||
187
server/rules/risk-rules/risk.budget.duplicate_reserve.json
Normal file
187
server/rules/risk-rules/risk.budget.duplicate_reserve.json
Normal file
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.budget.duplicate_reserve",
|
||||
"name": "重复占用预算",
|
||||
"description": "同一申请、项目或合同已占用预算,本次单据再次占用同一预算口径。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "预算管控",
|
||||
"ontology_signal": "budget_over_limit",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "budget.line_id",
|
||||
"label": "预算行",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.available_amount",
|
||||
"label": "预算可用金额",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.used_rate",
|
||||
"label": "预算使用率",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.status",
|
||||
"label": "预算状态",
|
||||
"type": "enum",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.department_name",
|
||||
"label": "预算部门",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.quarter",
|
||||
"label": "预算季度",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.project_code",
|
||||
"label": "预算项目",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"budget.line_id",
|
||||
"budget.available_amount",
|
||||
"budget.used_rate",
|
||||
"budget.status",
|
||||
"budget.department_name",
|
||||
"budget.quarter",
|
||||
"budget.project_code"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"重复占用",
|
||||
"预算锁定",
|
||||
"重复申请"
|
||||
],
|
||||
"condition_summary": "相同业务标识存在未释放预算占用时触发。",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "medium",
|
||||
"action": "manual_review",
|
||||
"risk_score": 74
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.791584+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 74,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "重复占用预算",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 74,
|
||||
"risk_level": "medium"
|
||||
}
|
||||
187
server/rules/risk-rules/risk.budget.frozen_or_closed_used.json
Normal file
187
server/rules/risk-rules/risk.budget.frozen_or_closed_used.json
Normal file
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.budget.frozen_or_closed_used",
|
||||
"name": "使用冻结或关闭预算",
|
||||
"description": "单据引用了已冻结、已关闭或已作废的预算行。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "预算管控",
|
||||
"ontology_signal": "budget_over_limit",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "budget.line_id",
|
||||
"label": "预算行",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.available_amount",
|
||||
"label": "预算可用金额",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.used_rate",
|
||||
"label": "预算使用率",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.status",
|
||||
"label": "预算状态",
|
||||
"type": "enum",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.department_name",
|
||||
"label": "预算部门",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.quarter",
|
||||
"label": "预算季度",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.project_code",
|
||||
"label": "预算项目",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"budget.line_id",
|
||||
"budget.available_amount",
|
||||
"budget.used_rate",
|
||||
"budget.status",
|
||||
"budget.department_name",
|
||||
"budget.quarter",
|
||||
"budget.project_code"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"冻结预算",
|
||||
"关闭预算",
|
||||
"预算作废"
|
||||
],
|
||||
"condition_summary": "预算状态不是启用时触发。",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "high",
|
||||
"action": "block_submit",
|
||||
"risk_score": 90
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.765154+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 90,
|
||||
"risk_level": "high",
|
||||
"rule_title": "使用冻结或关闭预算",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 90,
|
||||
"risk_level": "high"
|
||||
}
|
||||
187
server/rules/risk-rules/risk.budget.missing_budget_line.json
Normal file
187
server/rules/risk-rules/risk.budget.missing_budget_line.json
Normal file
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.budget.missing_budget_line",
|
||||
"name": "缺少预算口径",
|
||||
"description": "需要预算管控的费用未关联年度、季度、部门、项目或费用类型预算。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "预算管控",
|
||||
"ontology_signal": "budget_over_limit",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "budget.line_id",
|
||||
"label": "预算行",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.available_amount",
|
||||
"label": "预算可用金额",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.used_rate",
|
||||
"label": "预算使用率",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.status",
|
||||
"label": "预算状态",
|
||||
"type": "enum",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.department_name",
|
||||
"label": "预算部门",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.quarter",
|
||||
"label": "预算季度",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.project_code",
|
||||
"label": "预算项目",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"budget.line_id",
|
||||
"budget.available_amount",
|
||||
"budget.used_rate",
|
||||
"budget.status",
|
||||
"budget.department_name",
|
||||
"budget.quarter",
|
||||
"budget.project_code"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"无预算",
|
||||
"预算口径缺失",
|
||||
"未关联预算"
|
||||
],
|
||||
"condition_summary": "费用类型要求预算管控但预算行为空时触发。",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "high",
|
||||
"action": "manual_review",
|
||||
"risk_score": 82
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.770132+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 82,
|
||||
"risk_level": "high",
|
||||
"rule_title": "缺少预算口径",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 82,
|
||||
"risk_level": "high"
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.budget.project_department_mismatch",
|
||||
"name": "项目预算与部门不匹配",
|
||||
"description": "单据引用的项目预算不属于当前部门或当前成本中心。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "预算管控",
|
||||
"ontology_signal": "budget_over_limit",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "budget.line_id",
|
||||
"label": "预算行",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.available_amount",
|
||||
"label": "预算可用金额",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.used_rate",
|
||||
"label": "预算使用率",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.status",
|
||||
"label": "预算状态",
|
||||
"type": "enum",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.department_name",
|
||||
"label": "预算部门",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.quarter",
|
||||
"label": "预算季度",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.project_code",
|
||||
"label": "预算项目",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"budget.line_id",
|
||||
"budget.available_amount",
|
||||
"budget.used_rate",
|
||||
"budget.status",
|
||||
"budget.department_name",
|
||||
"budget.quarter",
|
||||
"budget.project_code"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"项目预算",
|
||||
"成本中心不匹配",
|
||||
"部门不匹配"
|
||||
],
|
||||
"condition_summary": "项目预算归属与报销部门不一致时触发。",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "high",
|
||||
"action": "manual_review",
|
||||
"risk_score": 84
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.785760+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 84,
|
||||
"risk_level": "high",
|
||||
"rule_title": "项目预算与部门不匹配",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 84,
|
||||
"risk_level": "high"
|
||||
}
|
||||
187
server/rules/risk-rules/risk.budget.usage_over_100.json
Normal file
187
server/rules/risk-rules/risk.budget.usage_over_100.json
Normal file
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.budget.usage_over_100",
|
||||
"name": "预算使用率超过 100% 管控",
|
||||
"description": "报销或申请通过后,预算使用率超过 100%,需要阻断或升级审批。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "预算管控",
|
||||
"ontology_signal": "budget_over_limit",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "budget.line_id",
|
||||
"label": "预算行",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.available_amount",
|
||||
"label": "预算可用金额",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.used_rate",
|
||||
"label": "预算使用率",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.status",
|
||||
"label": "预算状态",
|
||||
"type": "enum",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.department_name",
|
||||
"label": "预算部门",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.quarter",
|
||||
"label": "预算季度",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.project_code",
|
||||
"label": "预算项目",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"budget.line_id",
|
||||
"budget.available_amount",
|
||||
"budget.used_rate",
|
||||
"budget.status",
|
||||
"budget.department_name",
|
||||
"budget.quarter",
|
||||
"budget.project_code"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"预算超支",
|
||||
"超过100%",
|
||||
"禁止提交"
|
||||
],
|
||||
"condition_summary": "预算使用率超过 100% 时触发。",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "critical",
|
||||
"action": "block_submit",
|
||||
"risk_score": 96
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.760043+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 96,
|
||||
"risk_level": "high",
|
||||
"rule_title": "预算使用率超过 100% 管控",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "critical",
|
||||
"risk_score": 96,
|
||||
"risk_level": "high"
|
||||
}
|
||||
187
server/rules/risk-rules/risk.budget.usage_warning_80.json
Normal file
187
server/rules/risk-rules/risk.budget.usage_warning_80.json
Normal file
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.budget.usage_warning_80",
|
||||
"name": "预算使用率达到 80% 预警",
|
||||
"description": "报销或申请通过后,部门/项目/费用类型预算使用率达到 80% 以上。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "预算管控",
|
||||
"ontology_signal": "budget_over_limit",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "budget.line_id",
|
||||
"label": "预算行",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.available_amount",
|
||||
"label": "预算可用金额",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.used_rate",
|
||||
"label": "预算使用率",
|
||||
"type": "number",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.status",
|
||||
"label": "预算状态",
|
||||
"type": "enum",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.department_name",
|
||||
"label": "预算部门",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.quarter",
|
||||
"label": "预算季度",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
},
|
||||
{
|
||||
"key": "budget.project_code",
|
||||
"label": "预算项目",
|
||||
"type": "text",
|
||||
"source": "budget"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"budget.line_id",
|
||||
"budget.available_amount",
|
||||
"budget.used_rate",
|
||||
"budget.status",
|
||||
"budget.department_name",
|
||||
"budget.quarter",
|
||||
"budget.project_code"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"预算预警",
|
||||
"80%",
|
||||
"使用率过高"
|
||||
],
|
||||
"condition_summary": "预算使用率大于等于 80% 且低于 100% 时触发。",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "medium",
|
||||
"action": "warning",
|
||||
"risk_score": 70
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.755636+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 70,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "预算使用率达到 80% 预警",
|
||||
"finance_rule_code": "budget.execution.policy",
|
||||
"finance_rule_sheet": "预算执行规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement",
|
||||
"budget_execution"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 70,
|
||||
"risk_level": "medium"
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.reimbursement.amount_over_application",
|
||||
"name": "报销金额超过申请金额",
|
||||
"description": "报销总金额超过已审批申请金额,需要按偏差规则复核。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "报销偏差",
|
||||
"ontology_signal": "amount_over_application",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "application.id",
|
||||
"label": "申请单",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.status",
|
||||
"label": "申请状态",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.approved_amount",
|
||||
"label": "申请审批金额",
|
||||
"type": "number",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.expense_type",
|
||||
"label": "申请费用类型",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.department_name",
|
||||
"label": "申请部门",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"application.id",
|
||||
"application.status",
|
||||
"application.approved_amount",
|
||||
"application.expense_type",
|
||||
"application.department_name"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"超过申请金额",
|
||||
"报销偏差",
|
||||
"申请金额"
|
||||
],
|
||||
"condition_summary": "报销金额大于申请审批金额时触发。",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "medium",
|
||||
"action": "manual_review",
|
||||
"risk_score": 76
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.832940+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 76,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "报销金额超过申请金额",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 76,
|
||||
"risk_level": "medium"
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.reimbursement.amount_over_application_10pct",
|
||||
"name": "报销金额超过申请金额 10%",
|
||||
"description": "报销金额比申请审批金额高出 10% 以上,需要升级审批或禁止提交。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "报销偏差",
|
||||
"ontology_signal": "amount_over_application",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "application.id",
|
||||
"label": "申请单",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.status",
|
||||
"label": "申请状态",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.approved_amount",
|
||||
"label": "申请审批金额",
|
||||
"type": "number",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.expense_type",
|
||||
"label": "申请费用类型",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.department_name",
|
||||
"label": "申请部门",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"application.id",
|
||||
"application.status",
|
||||
"application.approved_amount",
|
||||
"application.expense_type",
|
||||
"application.department_name"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"超过申请10%",
|
||||
"金额偏差",
|
||||
"升级审批"
|
||||
],
|
||||
"condition_summary": "报销金额超过申请审批金额 10% 以上时触发。",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "high",
|
||||
"action": "manual_review",
|
||||
"risk_score": 88
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.839384+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 88,
|
||||
"risk_level": "high",
|
||||
"rule_title": "报销金额超过申请金额 10%",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 88,
|
||||
"risk_level": "high"
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.reimbursement.department_mismatch_application",
|
||||
"name": "报销部门与申请部门不一致",
|
||||
"description": "报销部门、成本中心与关联申请单不一致,且缺少调整说明。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "报销偏差",
|
||||
"ontology_signal": "department_mismatch",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "application.id",
|
||||
"label": "申请单",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.status",
|
||||
"label": "申请状态",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.approved_amount",
|
||||
"label": "申请审批金额",
|
||||
"type": "number",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.expense_type",
|
||||
"label": "申请费用类型",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.department_name",
|
||||
"label": "申请部门",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"application.id",
|
||||
"application.status",
|
||||
"application.approved_amount",
|
||||
"application.expense_type",
|
||||
"application.department_name"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"部门不一致",
|
||||
"成本中心偏差",
|
||||
"申请部门"
|
||||
],
|
||||
"condition_summary": "报销部门与申请部门不一致时触发。",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "medium",
|
||||
"action": "manual_review",
|
||||
"risk_score": 72
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.852823+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 72,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "报销部门与申请部门不一致",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 72,
|
||||
"risk_level": "medium"
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.reimbursement.duplicate_against_application",
|
||||
"name": "同一申请重复报销",
|
||||
"description": "同一申请单或同一合同/项目存在多笔疑似重复报销。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "报销偏差",
|
||||
"ontology_signal": "duplicate_reimbursement",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "application.id",
|
||||
"label": "申请单",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.status",
|
||||
"label": "申请状态",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.approved_amount",
|
||||
"label": "申请审批金额",
|
||||
"type": "number",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.expense_type",
|
||||
"label": "申请费用类型",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.department_name",
|
||||
"label": "申请部门",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"application.id",
|
||||
"application.status",
|
||||
"application.approved_amount",
|
||||
"application.expense_type",
|
||||
"application.department_name"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"重复报销",
|
||||
"同一申请",
|
||||
"重复占用"
|
||||
],
|
||||
"condition_summary": "同一申请的已报销金额与本次金额超过申请金额时触发。",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "high",
|
||||
"action": "manual_review",
|
||||
"risk_score": 86
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.873003+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 86,
|
||||
"risk_level": "high",
|
||||
"rule_title": "同一申请重复报销",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 86,
|
||||
"risk_level": "high"
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.reimbursement.expense_type_mismatch_application",
|
||||
"name": "报销费用类型与申请不一致",
|
||||
"description": "报销单费用类型与关联申请单费用类型不一致。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "报销偏差",
|
||||
"ontology_signal": "expense_type_mismatch",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "application.id",
|
||||
"label": "申请单",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.status",
|
||||
"label": "申请状态",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.approved_amount",
|
||||
"label": "申请审批金额",
|
||||
"type": "number",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.expense_type",
|
||||
"label": "申请费用类型",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.department_name",
|
||||
"label": "申请部门",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"application.id",
|
||||
"application.status",
|
||||
"application.approved_amount",
|
||||
"application.expense_type",
|
||||
"application.department_name"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"费用类型不一致",
|
||||
"申请报销不匹配",
|
||||
"类型偏差"
|
||||
],
|
||||
"condition_summary": "报销费用类型与申请费用类型不一致时触发。",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "medium",
|
||||
"action": "manual_review",
|
||||
"risk_score": 74
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.845894+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 74,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "报销费用类型与申请不一致",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 74,
|
||||
"risk_level": "medium"
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.reimbursement.period_outside_application",
|
||||
"name": "报销发生期间超出申请期间",
|
||||
"description": "费用发生日期不在已审批申请的起止日期范围内。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "报销偏差",
|
||||
"ontology_signal": "period_outside_application",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "application.id",
|
||||
"label": "申请单",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.status",
|
||||
"label": "申请状态",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.approved_amount",
|
||||
"label": "申请审批金额",
|
||||
"type": "number",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.expense_type",
|
||||
"label": "申请费用类型",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.department_name",
|
||||
"label": "申请部门",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.start_date",
|
||||
"label": "申请开始日期",
|
||||
"type": "date",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.end_date",
|
||||
"label": "申请结束日期",
|
||||
"type": "date",
|
||||
"source": "application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"application.id",
|
||||
"application.status",
|
||||
"application.approved_amount",
|
||||
"application.expense_type",
|
||||
"application.department_name",
|
||||
"application.start_date",
|
||||
"application.end_date"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"期间不一致",
|
||||
"超出申请期间",
|
||||
"日期偏差"
|
||||
],
|
||||
"condition_summary": "发生日期超出申请有效期间时触发。",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "medium",
|
||||
"action": "manual_review",
|
||||
"risk_score": 70
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.859728+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 70,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "报销发生期间超出申请期间",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 70,
|
||||
"risk_level": "medium"
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.reimbursement.rejected_application_claimed",
|
||||
"name": "已驳回申请被用于报销",
|
||||
"description": "报销单关联的申请单为驳回、撤回或已取消状态。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "报销偏差",
|
||||
"ontology_signal": "invalid_application_status",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"business_stages": [
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "application.id",
|
||||
"label": "申请单",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.status",
|
||||
"label": "申请状态",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.approved_amount",
|
||||
"label": "申请审批金额",
|
||||
"type": "number",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.expense_type",
|
||||
"label": "申请费用类型",
|
||||
"type": "enum",
|
||||
"source": "application"
|
||||
},
|
||||
{
|
||||
"key": "application.department_name",
|
||||
"label": "申请部门",
|
||||
"type": "text",
|
||||
"source": "application"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"application.id",
|
||||
"application.status",
|
||||
"application.approved_amount",
|
||||
"application.expense_type",
|
||||
"application.department_name"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"申请驳回",
|
||||
"申请撤回",
|
||||
"无效申请"
|
||||
],
|
||||
"condition_summary": "关联申请状态不是已通过或已完成时触发。",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "high",
|
||||
"action": "block_submit",
|
||||
"risk_score": 92
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.865808+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 92,
|
||||
"risk_level": "high",
|
||||
"rule_title": "已驳回申请被用于报销",
|
||||
"finance_rule_code": "application.reimbursement.linkage.policy",
|
||||
"finance_rule_sheet": "申请报销关联规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"all"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 92,
|
||||
"risk_level": "high"
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.standard.communication_account_mismatch",
|
||||
"name": "通信账户归属与报销人不一致",
|
||||
"description": "通信票据、运营商账单或号码归属信息与报销人不一致,且缺少代垫或统一缴费说明。",
|
||||
"enabled": true,
|
||||
"requires_attachment": true,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "费用归属",
|
||||
"ontology_signal": "expense_owner_mismatch",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "expense.communication.policy",
|
||||
"finance_rule_sheet": "通信费报销规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"communication"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"communication"
|
||||
],
|
||||
"business_stages": [
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "material.attachment_count",
|
||||
"label": "附件数量",
|
||||
"type": "number",
|
||||
"source": "material"
|
||||
},
|
||||
{
|
||||
"key": "material.contract_uploaded",
|
||||
"label": "合同已上传",
|
||||
"type": "boolean",
|
||||
"source": "material"
|
||||
},
|
||||
{
|
||||
"key": "material.acceptance_uploaded",
|
||||
"label": "验收材料已上传",
|
||||
"type": "boolean",
|
||||
"source": "material"
|
||||
},
|
||||
{
|
||||
"key": "material.plan_uploaded",
|
||||
"label": "方案已上传",
|
||||
"type": "boolean",
|
||||
"source": "material"
|
||||
},
|
||||
{
|
||||
"key": "material.attendee_list_uploaded",
|
||||
"label": "参与人清单已上传",
|
||||
"type": "boolean",
|
||||
"source": "material"
|
||||
},
|
||||
{
|
||||
"key": "material.invoice_uploaded",
|
||||
"label": "发票已上传",
|
||||
"type": "boolean",
|
||||
"source": "material"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"material.attachment_count",
|
||||
"material.contract_uploaded",
|
||||
"material.acceptance_uploaded",
|
||||
"material.plan_uploaded",
|
||||
"material.attendee_list_uploaded",
|
||||
"material.invoice_uploaded"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"号码归属",
|
||||
"账户不一致",
|
||||
"代垫",
|
||||
"统一缴费",
|
||||
"公共号码"
|
||||
],
|
||||
"condition_summary": "通信账户归属与报销人不一致且没有代垫、统一缴费或部门公共号码说明时触发。",
|
||||
"finance_rule_code": "expense.communication.policy",
|
||||
"finance_rule_sheet": "通信费报销规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"communication"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "high",
|
||||
"action": "manual_review",
|
||||
"risk_score": 82
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.898564+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 82,
|
||||
"risk_level": "high",
|
||||
"rule_title": "通信账户归属与报销人不一致",
|
||||
"finance_rule_code": "expense.communication.policy",
|
||||
"finance_rule_sheet": "通信费报销规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"communication"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "high",
|
||||
"risk_score": 82,
|
||||
"risk_level": "high"
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.standard.communication_amount_over_policy",
|
||||
"name": "通信费金额超过月度标准",
|
||||
"description": "通信费、话费、流量费或宽带费超过公司月度标准,且缺少岗位必要性或专项审批说明。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "费用标准",
|
||||
"ontology_signal": "expense_standard_over_limit",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "expense.communication.policy",
|
||||
"finance_rule_sheet": "通信费报销规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"communication"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"communication"
|
||||
],
|
||||
"business_stages": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "material.invoice_uploaded",
|
||||
"label": "发票已上传",
|
||||
"type": "boolean",
|
||||
"source": "material"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"material.invoice_uploaded"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"通信费",
|
||||
"话费",
|
||||
"流量费",
|
||||
"宽带费",
|
||||
"超标准"
|
||||
],
|
||||
"condition_summary": "通信费金额超过公司标准且没有岗位、项目或专项审批说明时触发。",
|
||||
"finance_rule_code": "expense.communication.policy",
|
||||
"finance_rule_sheet": "通信费报销规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"communication"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "medium",
|
||||
"action": "manual_review",
|
||||
"risk_score": 68
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.891463+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 68,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "通信费金额超过月度标准",
|
||||
"finance_rule_code": "expense.communication.policy",
|
||||
"finance_rule_sheet": "通信费报销规则",
|
||||
"business_stage": [
|
||||
"expense_application",
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"communication"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 68,
|
||||
"risk_level": "medium"
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.standard.meal_participants_missing",
|
||||
"name": "业务招待缺少参与人清单",
|
||||
"description": "业务招待费要求提供客户名称、参与人清单和招待说明。",
|
||||
"enabled": true,
|
||||
"requires_attachment": true,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "材料完整性",
|
||||
"ontology_signal": "material_missing",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "expense.material.policy",
|
||||
"finance_rule_sheet": "材料完整性规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"meal"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"meal"
|
||||
],
|
||||
"business_stages": [
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
},
|
||||
{
|
||||
"key": "material.attachment_count",
|
||||
"label": "附件数量",
|
||||
"type": "number",
|
||||
"source": "material"
|
||||
},
|
||||
{
|
||||
"key": "material.contract_uploaded",
|
||||
"label": "合同已上传",
|
||||
"type": "boolean",
|
||||
"source": "material"
|
||||
},
|
||||
{
|
||||
"key": "material.acceptance_uploaded",
|
||||
"label": "验收材料已上传",
|
||||
"type": "boolean",
|
||||
"source": "material"
|
||||
},
|
||||
{
|
||||
"key": "material.plan_uploaded",
|
||||
"label": "方案已上传",
|
||||
"type": "boolean",
|
||||
"source": "material"
|
||||
},
|
||||
{
|
||||
"key": "material.attendee_list_uploaded",
|
||||
"label": "参与人清单已上传",
|
||||
"type": "boolean",
|
||||
"source": "material"
|
||||
},
|
||||
{
|
||||
"key": "material.invoice_uploaded",
|
||||
"label": "发票已上传",
|
||||
"type": "boolean",
|
||||
"source": "material"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"material.attachment_count",
|
||||
"material.contract_uploaded",
|
||||
"material.acceptance_uploaded",
|
||||
"material.plan_uploaded",
|
||||
"material.attendee_list_uploaded",
|
||||
"material.invoice_uploaded"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"参与人清单",
|
||||
"客户信息",
|
||||
"业务招待"
|
||||
],
|
||||
"condition_summary": "业务招待费缺少参与人清单或客户信息时触发。",
|
||||
"finance_rule_code": "expense.material.policy",
|
||||
"finance_rule_sheet": "材料完整性规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"meal"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "medium",
|
||||
"action": "manual_review",
|
||||
"risk_score": 72
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.879886+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 72,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "业务招待缺少参与人清单",
|
||||
"finance_rule_code": "expense.material.policy",
|
||||
"finance_rule_sheet": "材料完整性规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"meal"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 72,
|
||||
"risk_level": "medium"
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
{
|
||||
"schema_version": "2.0",
|
||||
"rule_code": "risk.standard.office_fixed_asset_as_office",
|
||||
"name": "固定资产伪装为办公用品费",
|
||||
"description": "办公用品费明细疑似包含固定资产、电子设备或应走采购入库的物品。",
|
||||
"enabled": true,
|
||||
"requires_attachment": false,
|
||||
"risk_dimension": "expense_control_demo",
|
||||
"risk_category": "费用标准",
|
||||
"ontology_signal": "expense_type_mismatch",
|
||||
"evaluator": "template_rule",
|
||||
"template_key": "keyword_match_v1",
|
||||
"finance_rule_code": "expense.classification.policy",
|
||||
"finance_rule_sheet": "费用类型归类规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"office"
|
||||
],
|
||||
"budget_required": true,
|
||||
"applies_to": {
|
||||
"domains": [
|
||||
"expense"
|
||||
],
|
||||
"expense_types": [
|
||||
"office"
|
||||
],
|
||||
"business_stages": [
|
||||
"reimbursement"
|
||||
]
|
||||
},
|
||||
"inputs": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "claim.amount",
|
||||
"label": "报销金额",
|
||||
"type": "number",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.expense_type",
|
||||
"label": "费用类型",
|
||||
"type": "enum",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.department_name",
|
||||
"label": "部门",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "claim.reason",
|
||||
"label": "事由",
|
||||
"type": "text",
|
||||
"source": "claim"
|
||||
},
|
||||
{
|
||||
"key": "item.item_reason",
|
||||
"label": "明细说明",
|
||||
"type": "text",
|
||||
"source": "item"
|
||||
}
|
||||
]
|
||||
},
|
||||
"params": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_keys": [
|
||||
"claim.amount",
|
||||
"claim.expense_type",
|
||||
"claim.department_name",
|
||||
"claim.reason",
|
||||
"item.item_reason"
|
||||
],
|
||||
"search_fields": [
|
||||
"claim.reason",
|
||||
"item.item_reason",
|
||||
"claim.expense_type"
|
||||
],
|
||||
"keywords": [
|
||||
"固定资产",
|
||||
"电脑",
|
||||
"显示器",
|
||||
"办公设备"
|
||||
],
|
||||
"condition_summary": "办公用品费包含固定资产关键词或超过采购阈值时触发。",
|
||||
"finance_rule_code": "expense.classification.policy",
|
||||
"finance_rule_sheet": "费用类型归类规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"office"
|
||||
],
|
||||
"budget_required": true
|
||||
},
|
||||
"outcomes": {
|
||||
"pass": {
|
||||
"severity": "none",
|
||||
"action": "continue"
|
||||
},
|
||||
"fail": {
|
||||
"severity": "medium",
|
||||
"action": "manual_review",
|
||||
"risk_score": 52
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"owner": "风控与审计部",
|
||||
"stability": "platform",
|
||||
"source_ref": "费用管控 Demo 风险规则库",
|
||||
"created_at": "2026-05-31T00:10:41.885450+00:00",
|
||||
"created_by": "system",
|
||||
"risk_score": 52,
|
||||
"risk_level": "medium",
|
||||
"rule_title": "固定资产伪装为办公用品费",
|
||||
"finance_rule_code": "expense.classification.policy",
|
||||
"finance_rule_sheet": "费用类型归类规则",
|
||||
"business_stage": [
|
||||
"reimbursement"
|
||||
],
|
||||
"expense_types": [
|
||||
"office"
|
||||
],
|
||||
"budget_required": true,
|
||||
"risk_level_label": "中风险",
|
||||
"risk_score_model": "risk_score_v3",
|
||||
"risk_score_detail": {
|
||||
"score": 52,
|
||||
"level": "medium",
|
||||
"level_label": "中风险",
|
||||
"model": "risk_score_v3",
|
||||
"weights": {
|
||||
"impact": 0.35,
|
||||
"certainty": 0.25,
|
||||
"evidence": 0.15,
|
||||
"exception": 0.1,
|
||||
"action": 0.1,
|
||||
"sensitivity": 0.05
|
||||
},
|
||||
"components": {
|
||||
"impact": 42,
|
||||
"certainty": 58,
|
||||
"evidence": 62,
|
||||
"exception": 74,
|
||||
"action": 35,
|
||||
"sensitivity": 45
|
||||
},
|
||||
"calibration": {
|
||||
"raw_score": 52,
|
||||
"rules": []
|
||||
},
|
||||
"ai_evidence": {},
|
||||
"basis": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_count": 5,
|
||||
"condition_count": 0,
|
||||
"expense_category": null,
|
||||
"expense_category_label": "费用标准",
|
||||
"requires_attachment": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"severity": "medium",
|
||||
"risk_score": 52,
|
||||
"risk_level": "medium",
|
||||
"risk_level_label": "中风险",
|
||||
"risk_score_detail": {
|
||||
"score": 52,
|
||||
"level": "medium",
|
||||
"level_label": "中风险",
|
||||
"model": "risk_score_v3",
|
||||
"weights": {
|
||||
"impact": 0.35,
|
||||
"certainty": 0.25,
|
||||
"evidence": 0.15,
|
||||
"exception": 0.1,
|
||||
"action": 0.1,
|
||||
"sensitivity": 0.05
|
||||
},
|
||||
"components": {
|
||||
"impact": 42,
|
||||
"certainty": 58,
|
||||
"evidence": 62,
|
||||
"exception": 74,
|
||||
"action": 35,
|
||||
"sensitivity": 45
|
||||
},
|
||||
"calibration": {
|
||||
"raw_score": 52,
|
||||
"rules": []
|
||||
},
|
||||
"ai_evidence": {},
|
||||
"basis": {
|
||||
"template_key": "keyword_match_v1",
|
||||
"field_count": 5,
|
||||
"condition_count": 0,
|
||||
"expense_category": null,
|
||||
"expense_category_label": "费用标准",
|
||||
"requires_attachment": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,19 +12,16 @@ SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
RISK_RULE_DIR = SERVER_DIR / "rules" / "risk-rules"
|
||||
|
||||
|
||||
BUDGET_EXPENSE_TYPES = (
|
||||
BUDGET_EXPENSE_TYPES = ("all",)
|
||||
SUPPORTED_DEMO_EXPENSE_TYPES = {
|
||||
"all",
|
||||
"travel",
|
||||
"hotel",
|
||||
"transport",
|
||||
"meal",
|
||||
"meeting",
|
||||
"marketing",
|
||||
"office",
|
||||
"training",
|
||||
"software",
|
||||
"communication",
|
||||
"welfare",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
FIELD_LABELS = {
|
||||
@@ -456,7 +453,7 @@ RULES: tuple[DemoRiskRule, ...] = (
|
||||
"rule.expense.company_travel_expense_reimbursement",
|
||||
"差旅住宿费标准",
|
||||
("reimbursement",),
|
||||
("travel", "hotel", "transport"),
|
||||
("travel",),
|
||||
"差旅金额达到大额阈值且缺少有效出差申请时触发。",
|
||||
("差旅申请", "大额差旅", "未申请"),
|
||||
"high",
|
||||
@@ -688,6 +685,54 @@ RULES: tuple[DemoRiskRule, ...] = (
|
||||
)
|
||||
|
||||
|
||||
COMMUNICATION_RULES: tuple[DemoRiskRule, ...] = (
|
||||
DemoRiskRule(
|
||||
"risk.standard.communication_amount_over_policy",
|
||||
"通信费金额超过月度标准",
|
||||
"通信费、话费、流量费或宽带费超过公司月度标准,且缺少岗位必要性或专项审批说明。",
|
||||
"费用标准",
|
||||
"expense_standard_over_limit",
|
||||
"expense.communication.policy",
|
||||
"通信费报销规则",
|
||||
("expense_application", "reimbursement"),
|
||||
("communication",),
|
||||
"通信费金额超过公司标准且没有岗位、项目或专项审批说明时触发。",
|
||||
("通信费", "话费", "流量费", "宽带费", "超标准"),
|
||||
"medium",
|
||||
"manual_review",
|
||||
68,
|
||||
"medium",
|
||||
field_keys=BASE_FIELDS + ("material.invoice_uploaded",),
|
||||
),
|
||||
DemoRiskRule(
|
||||
"risk.standard.communication_account_mismatch",
|
||||
"通信账户归属与报销人不一致",
|
||||
"通信票据、运营商账单或号码归属信息与报销人不一致,且缺少代垫或统一缴费说明。",
|
||||
"费用归属",
|
||||
"expense_owner_mismatch",
|
||||
"expense.communication.policy",
|
||||
"通信费报销规则",
|
||||
("reimbursement",),
|
||||
("communication",),
|
||||
"通信账户归属与报销人不一致且没有代垫、统一缴费或部门公共号码说明时触发。",
|
||||
("号码归属", "账户不一致", "代垫", "统一缴费", "公共号码"),
|
||||
"high",
|
||||
"manual_review",
|
||||
82,
|
||||
"high",
|
||||
requires_attachment=True,
|
||||
field_keys=MATERIAL_FIELDS,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _is_supported_demo_rule(rule: DemoRiskRule) -> bool:
|
||||
return all(expense_type in SUPPORTED_DEMO_EXPENSE_TYPES for expense_type in rule.expense_types)
|
||||
|
||||
|
||||
RULES = tuple(rule for rule in RULES if _is_supported_demo_rule(rule)) + COMMUNICATION_RULES
|
||||
|
||||
|
||||
def main() -> None:
|
||||
RISK_RULE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for rule in RULES:
|
||||
|
||||
99
server/scripts/repair_stuck_application_budget_approval.py
Normal file
99
server/scripts/repair_stuck_application_budget_approval.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from typing import Iterable
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.db.session import get_session_factory
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_claim_workflow_constants import BUDGET_MANAGER_APPROVAL_STAGE
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
|
||||
|
||||
def _role_codes(employee: Employee) -> list[str]:
|
||||
return [
|
||||
str(role.role_code or "").strip().lower()
|
||||
for role in list(employee.roles or [])
|
||||
if str(role.role_code or "").strip()
|
||||
]
|
||||
|
||||
|
||||
def _is_direct_manager(employee: Employee | None, budget_manager: Employee | None) -> bool:
|
||||
if employee is None or budget_manager is None:
|
||||
return False
|
||||
if employee.manager_id and employee.manager_id == budget_manager.id:
|
||||
return True
|
||||
return employee.manager is not None and employee.manager.id == budget_manager.id
|
||||
|
||||
|
||||
def _budget_manager_context(budget_manager: Employee) -> CurrentUserContext:
|
||||
return CurrentUserContext(
|
||||
username=str(budget_manager.email or budget_manager.employee_no or budget_manager.name or "").strip(),
|
||||
name=str(budget_manager.name or "").strip(),
|
||||
role_codes=_role_codes(budget_manager),
|
||||
is_admin=False,
|
||||
department_name=str(
|
||||
budget_manager.organization_unit.name
|
||||
if budget_manager.organization_unit is not None
|
||||
else ""
|
||||
).strip(),
|
||||
grade=str(budget_manager.grade or "").strip(),
|
||||
employee_no=str(budget_manager.employee_no or "").strip(),
|
||||
)
|
||||
|
||||
|
||||
def _iter_candidates(service: ExpenseClaimService) -> Iterable[tuple[ExpenseClaim, Employee]]:
|
||||
claims = service.db.query(ExpenseClaim).filter(
|
||||
ExpenseClaim.status == "submitted",
|
||||
ExpenseClaim.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
).all()
|
||||
for claim in claims:
|
||||
if not service._is_expense_application_claim(claim):
|
||||
continue
|
||||
budget_manager = service._access_policy.resolve_department_budget_manager(claim)
|
||||
if budget_manager is None:
|
||||
continue
|
||||
if not _is_direct_manager(claim.employee, budget_manager):
|
||||
continue
|
||||
yield claim, budget_manager
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Repair application claims stuck at budget approval when the direct manager is the budget approver.",
|
||||
)
|
||||
parser.add_argument("--apply", action="store_true", help="Apply the repair. Without it, only prints candidates.")
|
||||
args = parser.parse_args()
|
||||
|
||||
session_factory = get_session_factory()
|
||||
with session_factory() as db:
|
||||
service = ExpenseClaimService(db)
|
||||
candidates = list(_iter_candidates(service))
|
||||
print(f"candidates={len(candidates)}")
|
||||
for claim, budget_manager in candidates:
|
||||
print(
|
||||
"candidate "
|
||||
f"claim_no={claim.claim_no} "
|
||||
f"claim_id={claim.id} "
|
||||
f"employee={claim.employee_name} "
|
||||
f"budget_manager={budget_manager.name}"
|
||||
)
|
||||
if not args.apply:
|
||||
continue
|
||||
|
||||
repaired = service.approve_claim(
|
||||
claim.id,
|
||||
_budget_manager_context(budget_manager),
|
||||
opinion="历史流程修复:直属领导与预算审批人为同一人,合并预算审批。",
|
||||
)
|
||||
print(
|
||||
"repaired "
|
||||
f"claim_no={repaired.claim_no if repaired is not None else claim.claim_no} "
|
||||
f"status={repaired.status if repaired is not None else ''} "
|
||||
f"stage={repaired.approval_stage if repaired is not None else ''}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -24,6 +24,10 @@ class CurrentUserContext:
|
||||
is_admin: bool
|
||||
department_name: str = ""
|
||||
cost_center: str = ""
|
||||
position: str = ""
|
||||
grade: str = ""
|
||||
employee_no: str = ""
|
||||
manager_name: str = ""
|
||||
|
||||
|
||||
def get_current_user(
|
||||
@@ -51,6 +55,22 @@ def get_current_user(
|
||||
str | None,
|
||||
Header(description="当前登录人的成本中心。"),
|
||||
] = None,
|
||||
x_auth_position: Annotated[
|
||||
str | None,
|
||||
Header(description="当前登录人的岗位。"),
|
||||
] = None,
|
||||
x_auth_grade: Annotated[
|
||||
str | None,
|
||||
Header(description="当前登录人的职级。"),
|
||||
] = None,
|
||||
x_auth_employee_no: Annotated[
|
||||
str | None,
|
||||
Header(description="当前登录人的员工编号。"),
|
||||
] = None,
|
||||
x_auth_manager_name: Annotated[
|
||||
str | None,
|
||||
Header(description="当前登录人的直属领导。"),
|
||||
] = None,
|
||||
) -> CurrentUserContext:
|
||||
role_codes = [
|
||||
_normalize_role_code(item)
|
||||
@@ -79,6 +99,10 @@ def get_current_user(
|
||||
is_admin=is_admin,
|
||||
department_name=(x_auth_department or "").strip(),
|
||||
cost_center=(x_auth_cost_center or "").strip(),
|
||||
position=(x_auth_position or "").strip(),
|
||||
grade=(x_auth_grade or "").strip(),
|
||||
employee_no=(x_auth_employee_no or "").strip(),
|
||||
manager_name=(x_auth_manager_name or "").strip(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,21 +2,29 @@ from __future__ import annotations
|
||||
|
||||
from typing import Annotated, NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import (
|
||||
CurrentUserContext,
|
||||
get_current_user,
|
||||
get_db,
|
||||
require_rule_editor_user,
|
||||
require_rule_reviewer_user,
|
||||
)
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetRead,
|
||||
AgentAssetRiskRuleDraftUpdate,
|
||||
AgentAssetRiskRuleFeedbackCreate,
|
||||
AgentAssetRiskRuleFeedbackRead,
|
||||
AgentAssetRiskRuleRegenerateRequest,
|
||||
AgentAssetRiskRuleRevisionCreate,
|
||||
AgentAssetRiskRuleTemplateGroupRead,
|
||||
)
|
||||
from app.services.agent_asset_risk_rule_regeneration import AgentAssetRiskRuleRegenerationService
|
||||
from app.services.agent_asset_risk_rule_revision import AgentAssetRiskRuleRevisionService
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.risk_rule_template_catalog import list_risk_rule_template_groups
|
||||
|
||||
router = APIRouter(prefix="/agent-assets")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
@@ -29,6 +37,8 @@ RequestIdHeader = Annotated[
|
||||
Header(description="外部请求 ID,用于串联审计日志和上游调用链。"),
|
||||
]
|
||||
RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)]
|
||||
RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)]
|
||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||
|
||||
|
||||
def _handle_asset_error(exc: Exception) -> NoReturn:
|
||||
@@ -50,6 +60,16 @@ def _read_asset(db: Session, asset_id: str) -> AgentAssetRead:
|
||||
return asset
|
||||
|
||||
|
||||
@router.get(
|
||||
"/risk-rules/templates",
|
||||
response_model=list[AgentAssetRiskRuleTemplateGroupRead],
|
||||
summary="查询常见费控风险规则模板",
|
||||
description="返回模板分组、默认自然语言、字段清单和 DSL 样例;模板只用于预填,不绕过通用生成链路。",
|
||||
)
|
||||
def list_risk_rule_templates(_: CurrentUser) -> list[AgentAssetRiskRuleTemplateGroupRead]:
|
||||
return list_risk_rule_template_groups()
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{asset_id}/risk-rules/draft",
|
||||
response_model=AgentAssetRead,
|
||||
@@ -101,3 +121,80 @@ def create_risk_rule_revision(
|
||||
return _read_asset(db, asset_id)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{asset_id}/risk-rules/regenerate",
|
||||
response_model=AgentAssetRead,
|
||||
summary="重新生成风险规则执行模板",
|
||||
description="把未上线草稿或已上线规则的修订草稿重新解释为 DSL、流程图、风险评分和业务说明。",
|
||||
)
|
||||
def regenerate_risk_rule(
|
||||
asset_id: str,
|
||||
payload: AgentAssetRiskRuleRegenerateRequest,
|
||||
current_user: RuleEditorUser,
|
||||
db: DbSession,
|
||||
x_actor: ActorHeader = None,
|
||||
x_request_id: RequestIdHeader = None,
|
||||
) -> AgentAssetRead:
|
||||
try:
|
||||
AgentAssetRiskRuleRegenerationService(db).regenerate(
|
||||
asset_id,
|
||||
payload,
|
||||
actor=_actor_name(current_user, x_actor),
|
||||
request_id=x_request_id,
|
||||
)
|
||||
return _read_asset(db, asset_id)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{asset_id}/risk-rules/feedback",
|
||||
response_model=AgentAssetRiskRuleFeedbackRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="提交风险规则误判或漏判反馈",
|
||||
description="普通用户可提交规则误判、漏判或改进反馈;该接口只记录反馈,不直接修改规则。",
|
||||
)
|
||||
def create_risk_rule_feedback(
|
||||
asset_id: str,
|
||||
payload: AgentAssetRiskRuleFeedbackCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
x_actor: ActorHeader = None,
|
||||
x_request_id: RequestIdHeader = None,
|
||||
) -> AgentAssetRiskRuleFeedbackRead:
|
||||
try:
|
||||
return AgentAssetService(db).create_risk_rule_feedback(
|
||||
asset_id,
|
||||
payload,
|
||||
actor=_actor_name(current_user, x_actor),
|
||||
request_id=x_request_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{asset_id}/risk-rules/feedback",
|
||||
response_model=list[AgentAssetRiskRuleFeedbackRead],
|
||||
summary="查询风险规则反馈记录",
|
||||
description="高级财务人员或 admin 管理员查看指定风险规则的误判、漏判和改进反馈记录。",
|
||||
)
|
||||
def list_risk_rule_feedback(
|
||||
asset_id: str,
|
||||
_: RuleReviewerUser,
|
||||
db: DbSession,
|
||||
version: Annotated[str | None, Query(max_length=30)] = None,
|
||||
status_value: Annotated[str | None, Query(alias="status", max_length=30)] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
) -> list[AgentAssetRiskRuleFeedbackRead]:
|
||||
try:
|
||||
return AgentAssetService(db).list_risk_rule_feedback(
|
||||
asset_id,
|
||||
version=version,
|
||||
status=status_value,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
84
server/src/app/api/v1/endpoints/agent_traces.py
Normal file
84
server/src/app/api/v1/endpoints/agent_traces.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_admin_user
|
||||
from app.schemas.agent_trace import (
|
||||
AgentConversationTraceRead,
|
||||
AgentTraceDetailRead,
|
||||
AgentTraceListItem,
|
||||
)
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.services.agent_traces import AgentTraceService
|
||||
|
||||
router = APIRouter(prefix="/agent-traces")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=list[AgentTraceListItem],
|
||||
summary="查询 Agent Trace 列表",
|
||||
description="按 Agent、状态、来源、会话或关键字查询 Agent 链路追踪记录。",
|
||||
)
|
||||
def list_agent_traces(
|
||||
db: DbSession,
|
||||
_: Annotated[object, Depends(require_admin_user)],
|
||||
agent: Annotated[str | None, Query(description="Agent 名称过滤。")] = None,
|
||||
status_value: Annotated[
|
||||
str | None,
|
||||
Query(alias="status", description="运行状态过滤。"),
|
||||
] = None,
|
||||
source: Annotated[str | None, Query(description="运行来源过滤。")] = None,
|
||||
conversation_id: Annotated[str | None, Query(description="会话 ID 过滤。")] = None,
|
||||
keyword: Annotated[str | None, Query(description="Run ID、摘要或语义关键字。")] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100, description="返回记录上限。")] = 30,
|
||||
) -> list[AgentTraceListItem]:
|
||||
return AgentTraceService(db).list_traces(
|
||||
agent=agent,
|
||||
status=status_value,
|
||||
source=source,
|
||||
conversation_id=conversation_id,
|
||||
keyword=keyword,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/conversations/{conversation_id}",
|
||||
response_model=AgentConversationTraceRead,
|
||||
summary="读取会话 Agent Trace",
|
||||
description="按 `conversation_id` 返回该会话下多轮运行的 trace 详情。",
|
||||
)
|
||||
def get_conversation_trace(
|
||||
conversation_id: str,
|
||||
db: DbSession,
|
||||
_: Annotated[object, Depends(require_admin_user)],
|
||||
) -> AgentConversationTraceRead:
|
||||
return AgentTraceService(db).get_conversation_trace(conversation_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{run_id}",
|
||||
response_model=AgentTraceDetailRead,
|
||||
summary="读取单次 Agent Trace",
|
||||
description="按 `run_id` 返回运行摘要、事件时间线、语义解析、工具调用和关联会话消息。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
"description": "Trace 运行记录不存在。",
|
||||
}
|
||||
},
|
||||
)
|
||||
def get_agent_trace(
|
||||
run_id: str,
|
||||
db: DbSession,
|
||||
_: Annotated[object, Depends(require_admin_user)],
|
||||
) -> AgentTraceDetailRead:
|
||||
trace = AgentTraceService(db).get_trace(run_id)
|
||||
if trace is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent trace not found")
|
||||
return trace
|
||||
@@ -7,8 +7,10 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead
|
||||
from app.schemas.finance_dashboard import FinanceDashboardRead
|
||||
from app.schemas.system_dashboard import SystemDashboardRead
|
||||
from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService
|
||||
from app.services.finance_dashboard import FinanceDashboardService
|
||||
from app.services.system_dashboard import SystemDashboardService
|
||||
|
||||
@@ -32,6 +34,26 @@ def get_system_dashboard(
|
||||
return SystemDashboardService(db).build_dashboard(days=days)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/digital-employee-dashboard",
|
||||
response_model=DigitalEmployeeDashboardRead,
|
||||
summary="查询数字员工工作看板",
|
||||
description="基于数字员工运行记录和工具调用结果聚合每日工作、技能类型、业务产出和近期执行明细。",
|
||||
)
|
||||
def get_digital_employee_dashboard(
|
||||
db: DbSession,
|
||||
days: Annotated[
|
||||
int,
|
||||
Query(ge=1, le=30, description="统计窗口天数。"),
|
||||
] = 7,
|
||||
limit: Annotated[
|
||||
int,
|
||||
Query(ge=1, le=1000, description="窗口内最多读取的运行记录数。"),
|
||||
] = 300,
|
||||
) -> DigitalEmployeeDashboardRead:
|
||||
return DigitalEmployeeDashboardService(db).build_dashboard(days=days, limit=limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/finance-dashboard",
|
||||
response_model=FinanceDashboardRead,
|
||||
|
||||
@@ -5,8 +5,9 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||||
from app.schemas.auth import (
|
||||
AuthUserRead,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
SessionFinishRequest,
|
||||
@@ -39,6 +40,42 @@ def login(payload: LoginRequest, db: DbSession) -> LoginResponse:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=AuthUserRead,
|
||||
summary="读取当前登录用户",
|
||||
description="根据当前会话请求头刷新前端登录态中的员工姓名、部门、岗位和职级。",
|
||||
)
|
||||
def get_current_auth_user(
|
||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
db: DbSession,
|
||||
) -> AuthUserRead:
|
||||
user = AuthService(db).get_user_snapshot(current_user.username)
|
||||
if user is not None:
|
||||
return user
|
||||
|
||||
if current_user.is_admin:
|
||||
name = current_user.name or current_user.username or "系统管理员"
|
||||
return AuthUserRead(
|
||||
username=current_user.username or name,
|
||||
name=name,
|
||||
role="管理员",
|
||||
department=current_user.department_name,
|
||||
departmentName=current_user.department_name,
|
||||
position=current_user.position or "系统管理员",
|
||||
grade=current_user.grade,
|
||||
employeeNo=current_user.employee_no,
|
||||
managerName=current_user.manager_name,
|
||||
costCenter=current_user.cost_center,
|
||||
roleCodes=current_user.role_codes or ["manager"],
|
||||
email=current_user.username if "@" in current_user.username else f"{current_user.username}@local",
|
||||
avatar=name[:1].upper(),
|
||||
isAdmin=True,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="当前登录用户不存在或已停用")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/finish",
|
||||
response_model=SessionFinishResponse,
|
||||
|
||||
@@ -545,6 +545,34 @@ def delete_expense_claim_item_attachment(
|
||||
return ExpenseClaimAttachmentActionResponse(**payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claims/{claim_id}/pre-review",
|
||||
response_model=ExpenseClaimRead,
|
||||
summary="执行报销单 AI 预审",
|
||||
description="只执行 AI 预审并回写风险结果,不提交到审批流程。",
|
||||
responses={
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"model": ErrorResponse,
|
||||
"description": "报销单不存在。",
|
||||
},
|
||||
status.HTTP_400_BAD_REQUEST: {
|
||||
"model": ErrorResponse,
|
||||
"description": "草稿信息不完整或状态不允许预审。",
|
||||
},
|
||||
},
|
||||
)
|
||||
def pre_review_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -> ExpenseClaimRead:
|
||||
service = ExpenseClaimService(db)
|
||||
try:
|
||||
claim = service.pre_review_claim(claim_id, current_user)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
if claim is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
|
||||
return claim
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claims/{claim_id}/submit",
|
||||
response_model=ExpenseClaimRead,
|
||||
|
||||
@@ -4,6 +4,7 @@ from app.api.v1.endpoints.agent_asset_risk_rules import router as agent_asset_ri
|
||||
from app.api.v1.endpoints.agent_assets import router as agent_assets_router
|
||||
from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router
|
||||
from app.api.v1.endpoints.agent_runs import router as agent_runs_router
|
||||
from app.api.v1.endpoints.agent_traces import router as agent_traces_router
|
||||
from app.api.v1.endpoints.analytics import router as analytics_router
|
||||
from app.api.v1.endpoints.audit_logs import router as audit_logs_router
|
||||
from app.api.v1.endpoints.auth import router as auth_router
|
||||
@@ -31,6 +32,7 @@ router.include_router(agent_assets_router, tags=["agent-assets"])
|
||||
router.include_router(agent_asset_risk_rules_router, tags=["agent-assets"])
|
||||
router.include_router(agent_feedback_router, tags=["agent-feedback"])
|
||||
router.include_router(agent_runs_router, tags=["agent-runs"])
|
||||
router.include_router(agent_traces_router, tags=["agent-traces"])
|
||||
router.include_router(analytics_router, tags=["analytics"])
|
||||
router.include_router(audit_logs_router, tags=["audit-logs"])
|
||||
router.include_router(knowledge_router, tags=["knowledge"])
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
from app.db.base_class import Base
|
||||
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetTestRun, AgentAssetVersion
|
||||
from app.models.agent_asset import (
|
||||
AgentAsset,
|
||||
AgentAssetReview,
|
||||
AgentAssetRuleFeedback,
|
||||
AgentAssetTestRun,
|
||||
AgentAssetVersion,
|
||||
)
|
||||
from app.models.agent_feedback import AgentOperationFeedback
|
||||
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
|
||||
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
|
||||
from app.models.approval import ApprovalRecord
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
@@ -34,11 +40,13 @@ __all__ = [
|
||||
"AgentConversationMessage",
|
||||
"AgentAsset",
|
||||
"AgentAssetReview",
|
||||
"AgentAssetRuleFeedback",
|
||||
"AgentAssetTestRun",
|
||||
"AgentAssetVersion",
|
||||
"AgentOperationFeedback",
|
||||
"AgentRun",
|
||||
"AgentToolCall",
|
||||
"AgentTraceEvent",
|
||||
"ApprovalRecord",
|
||||
"AuditLog",
|
||||
"BudgetAllocation",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from app.models.agent_conversation import AgentConversation, AgentConversationMessage
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetRuleFeedback, AgentAssetVersion
|
||||
from app.models.agent_feedback import AgentOperationFeedback
|
||||
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
|
||||
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
|
||||
from app.models.approval import ApprovalRecord
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
@@ -32,10 +32,12 @@ __all__ = [
|
||||
"AgentConversationMessage",
|
||||
"AgentAsset",
|
||||
"AgentAssetReview",
|
||||
"AgentAssetRuleFeedback",
|
||||
"AgentAssetVersion",
|
||||
"AgentOperationFeedback",
|
||||
"AgentRun",
|
||||
"AgentToolCall",
|
||||
"AgentTraceEvent",
|
||||
"ApprovalRecord",
|
||||
"AuditLog",
|
||||
"BudgetAllocation",
|
||||
|
||||
@@ -4,7 +4,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
@@ -52,6 +52,12 @@ class AgentAsset(Base):
|
||||
cascade="all, delete-orphan",
|
||||
order_by="desc(AgentAssetTestRun.created_at)",
|
||||
)
|
||||
rule_feedback_items = relationship(
|
||||
"AgentAssetRuleFeedback",
|
||||
back_populates="asset",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="desc(AgentAssetRuleFeedback.created_at)",
|
||||
)
|
||||
|
||||
|
||||
class AgentAssetVersion(Base):
|
||||
@@ -103,3 +109,34 @@ class AgentAssetTestRun(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
asset = relationship("AgentAsset", back_populates="test_runs")
|
||||
|
||||
|
||||
class AgentAssetRuleFeedback(Base):
|
||||
__tablename__ = "agent_asset_rule_feedback"
|
||||
__table_args__ = (
|
||||
Index("ix_agent_asset_rule_feedback_asset_version", "asset_id", "version"),
|
||||
Index("ix_agent_asset_rule_feedback_type_status", "feedback_type", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
feedback_id: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
unique=True,
|
||||
index=True,
|
||||
default=lambda: f"arf_{uuid.uuid4().hex[:16]}",
|
||||
)
|
||||
asset_id: Mapped[str] = mapped_column(ForeignKey("agent_assets.id"), index=True)
|
||||
version: Mapped[str] = mapped_column(String(30), index=True)
|
||||
feedback_type: Mapped[str] = mapped_column(String(30), index=True)
|
||||
status: Mapped[str] = mapped_column(String(30), default="open", index=True)
|
||||
subject_type: Mapped[str] = mapped_column(String(50), default="", index=True)
|
||||
subject_key: Mapped[str] = mapped_column(String(160), default="", index=True)
|
||||
subject_label: Mapped[str] = mapped_column(String(200), default="")
|
||||
actual_result_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
expected_result_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
comment: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
created_by: Mapped[str] = mapped_column(String(100), default="", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
asset = relationship("AgentAsset", back_populates="rule_feedback_items")
|
||||
|
||||
@@ -4,7 +4,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
@@ -84,3 +84,28 @@ class SemanticParseLog(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
run = relationship("AgentRun", back_populates="semantic_parse_logs")
|
||||
|
||||
|
||||
class AgentTraceEvent(Base):
|
||||
__tablename__ = "agent_trace_events"
|
||||
__table_args__ = (
|
||||
Index("ix_agent_trace_events_run_sequence", "run_id", "sequence"),
|
||||
Index("ix_agent_trace_events_conversation_sequence", "conversation_id", "sequence"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
run_id: Mapped[str] = mapped_column(ForeignKey("agent_runs.run_id"), index=True)
|
||||
conversation_id: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True)
|
||||
sequence: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||
stage: Mapped[str] = mapped_column(String(50), index=True)
|
||||
event_name: Mapped[str] = mapped_column(String(100), index=True)
|
||||
title: Mapped[str] = mapped_column(String(160))
|
||||
summary: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), index=True)
|
||||
input_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
output_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
error_message: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
duration_ms: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
|
||||
from app.models.agent_asset import (
|
||||
AgentAsset,
|
||||
AgentAssetReview,
|
||||
AgentAssetRuleFeedback,
|
||||
AgentAssetTestRun,
|
||||
AgentAssetVersion,
|
||||
)
|
||||
@@ -218,6 +219,36 @@ class AgentAssetRepository:
|
||||
self.db.refresh(test_run)
|
||||
return test_run
|
||||
|
||||
def list_rule_feedback(
|
||||
self,
|
||||
asset_id: str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> list[AgentAssetRuleFeedback]:
|
||||
stmt = (
|
||||
select(AgentAssetRuleFeedback)
|
||||
.where(AgentAssetRuleFeedback.asset_id == asset_id)
|
||||
.order_by(AgentAssetRuleFeedback.created_at.desc())
|
||||
)
|
||||
if version:
|
||||
stmt = stmt.where(AgentAssetRuleFeedback.version == version)
|
||||
if status:
|
||||
stmt = stmt.where(AgentAssetRuleFeedback.status == status)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def create_rule_feedback(
|
||||
self,
|
||||
feedback: AgentAssetRuleFeedback,
|
||||
) -> AgentAssetRuleFeedback:
|
||||
self.db.add(feedback)
|
||||
self.db.commit()
|
||||
self.db.refresh(feedback)
|
||||
return feedback
|
||||
|
||||
def delete_asset(self, asset: AgentAsset) -> None:
|
||||
self.db.delete(asset)
|
||||
self.db.commit()
|
||||
|
||||
@@ -146,6 +146,38 @@ class AgentAssetRiskRuleRegenerateRequest(BaseModel):
|
||||
requires_attachment: bool | None = None
|
||||
|
||||
|
||||
class AgentAssetRiskRuleTemplateFieldRead(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
display: str
|
||||
source: str
|
||||
type: str
|
||||
|
||||
|
||||
class AgentAssetRiskRuleTemplateRead(BaseModel):
|
||||
template_id: str
|
||||
group: str
|
||||
group_label: str
|
||||
title: str
|
||||
description: str = ""
|
||||
business_domain: str = "expense"
|
||||
business_stage: str = "reimbursement"
|
||||
business_stage_label: str = "费用报销"
|
||||
expense_category: str | None = None
|
||||
expense_category_label: str = ""
|
||||
requires_attachment: bool = False
|
||||
natural_language: str
|
||||
fields: list[AgentAssetRiskRuleTemplateFieldRead] = Field(default_factory=list)
|
||||
dsl_example: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentAssetRiskRuleTemplateGroupRead(BaseModel):
|
||||
group: str
|
||||
group_label: str
|
||||
order: int
|
||||
templates: list[AgentAssetRiskRuleTemplateRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentAssetRiskRuleSampleCase(BaseModel):
|
||||
case_id: str | None = Field(default=None, max_length=60)
|
||||
name: str = Field(default="测试样例", min_length=1, max_length=80)
|
||||
@@ -211,6 +243,9 @@ class AgentAssetRiskRuleSimulationRead(BaseModel):
|
||||
trace: dict[str, Any] = Field(default_factory=dict)
|
||||
attachments: list[dict[str, Any]] = Field(default_factory=list)
|
||||
recognized_fields: list[dict[str, Any]] = Field(default_factory=list)
|
||||
ocr_raw_fields: list[dict[str, Any]] = Field(default_factory=list)
|
||||
hermes_normalized_fields: list[dict[str, Any]] = Field(default_factory=list)
|
||||
executor_input_fields: list[dict[str, Any]] = Field(default_factory=list)
|
||||
missing_fields: list[dict[str, Any]] = Field(default_factory=list)
|
||||
recognition_summary: list[dict[str, Any]] = Field(default_factory=list)
|
||||
execution_mode: str = "risk_rule_simulation"
|
||||
@@ -229,6 +264,38 @@ class AgentAssetRiskRuleLevelUpdate(BaseModel):
|
||||
risk_level: str = Field(pattern="^(low|medium|high|critical)$")
|
||||
|
||||
|
||||
class AgentAssetRiskRuleFeedbackCreate(BaseModel):
|
||||
version: str | None = Field(default=None, max_length=30)
|
||||
feedback_type: str = Field(pattern="^(false_positive|false_negative|unclear|improvement)$")
|
||||
subject_type: str | None = Field(default=None, max_length=50)
|
||||
subject_key: str | None = Field(default=None, max_length=160)
|
||||
subject_label: str | None = Field(default=None, max_length=200)
|
||||
actual_result: dict[str, Any] = Field(default_factory=dict)
|
||||
expected_result: dict[str, Any] = Field(default_factory=dict)
|
||||
comment: str = Field(min_length=1, max_length=1000)
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentAssetRiskRuleFeedbackRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
feedback_id: str
|
||||
asset_id: str
|
||||
version: str
|
||||
feedback_type: str
|
||||
status: str
|
||||
subject_type: str = ""
|
||||
subject_key: str = ""
|
||||
subject_label: str = ""
|
||||
actual_result_json: dict[str, Any] = Field(default_factory=dict)
|
||||
expected_result_json: dict[str, Any] = Field(default_factory=dict)
|
||||
comment: str | None = None
|
||||
payload_json: dict[str, Any] = Field(default_factory=dict)
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AgentAssetRiskRuleTestRunRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
63
server/src/app/schemas/agent_trace.py
Normal file
63
server/src/app/schemas/agent_trace.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.schemas.agent_run import AgentRunRead, AgentToolCallRead, SemanticParseRead
|
||||
from app.schemas.orchestrator import ConversationMessageRead
|
||||
|
||||
|
||||
class AgentTraceEventRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
run_id: str
|
||||
conversation_id: str | None = None
|
||||
sequence: int
|
||||
stage: str
|
||||
event_name: str
|
||||
title: str
|
||||
summary: str | None = None
|
||||
status: str
|
||||
input_json: dict[str, Any] = Field(default_factory=dict)
|
||||
output_json: dict[str, Any] = Field(default_factory=dict)
|
||||
error_message: str | None = None
|
||||
started_at: datetime
|
||||
finished_at: datetime | None = None
|
||||
duration_ms: int = 0
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AgentTraceListItem(BaseModel):
|
||||
run_id: str
|
||||
conversation_id: str | None = None
|
||||
agent: str
|
||||
source: str
|
||||
status: str
|
||||
scenario: str | None = None
|
||||
intent: str | None = None
|
||||
title: str
|
||||
summary: str | None = None
|
||||
event_count: int = 0
|
||||
tool_call_count: int = 0
|
||||
failed_tool_call_count: int = 0
|
||||
started_at: datetime
|
||||
finished_at: datetime | None = None
|
||||
duration_ms: int = 0
|
||||
|
||||
|
||||
class AgentTraceDetailRead(BaseModel):
|
||||
run: AgentRunRead
|
||||
conversation_id: str | None = None
|
||||
events: list[AgentTraceEventRead] = Field(default_factory=list)
|
||||
semantic_parse: SemanticParseRead | None = None
|
||||
tool_calls: list[AgentToolCallRead] = Field(default_factory=list)
|
||||
conversation_messages: list[ConversationMessageRead] = Field(default_factory=list)
|
||||
fallback_generated: bool = False
|
||||
|
||||
|
||||
class AgentConversationTraceRead(BaseModel):
|
||||
conversation_id: str
|
||||
runs: list[AgentTraceDetailRead] = Field(default_factory=list)
|
||||
16
server/src/app/schemas/digital_employee_dashboard.py
Normal file
16
server/src/app/schemas/digital_employee_dashboard.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DigitalEmployeeDashboardRead(BaseModel):
|
||||
window_days: int
|
||||
generated_at: str
|
||||
has_real_data: bool
|
||||
totals: dict[str, Any] = Field(default_factory=dict)
|
||||
daily_work: list[dict[str, Any]] = Field(default_factory=list)
|
||||
task_distribution: list[dict[str, Any]] = Field(default_factory=list)
|
||||
category_distribution: list[dict[str, Any]] = Field(default_factory=list)
|
||||
recent_runs: list[dict[str, Any]] = Field(default_factory=list)
|
||||
@@ -130,6 +130,9 @@ class ExpenseClaimRead(BaseModel):
|
||||
employee_position: str | None = None
|
||||
employee_grade: str | None = None
|
||||
manager_name: str | None = None
|
||||
budget_approver_name: str | None = None
|
||||
budget_approver_grade: str | None = None
|
||||
budget_approver_role_code: str | None = None
|
||||
role_labels: list[str] = Field(default_factory=list)
|
||||
project_code: str | None
|
||||
expense_type: str
|
||||
@@ -202,6 +205,7 @@ class ExpenseClaimAttachmentActionResponse(BaseModel):
|
||||
item_location: str | None = None
|
||||
item_amount: Decimal | None = None
|
||||
claim_amount: Decimal | None = None
|
||||
claim_risk_flags: list[Any] = Field(default_factory=list)
|
||||
attachment: ExpenseClaimAttachmentRead | None = None
|
||||
|
||||
|
||||
|
||||
@@ -117,9 +117,11 @@ class RiskObservationDashboardRead(BaseModel):
|
||||
window_days: int
|
||||
total_observations: int
|
||||
pending_count: int
|
||||
risk_clue_count: int = 0
|
||||
high_or_above_count: int
|
||||
confirmed_count: int
|
||||
false_positive_count: int
|
||||
feedback_sample_count: int = 0
|
||||
total_amount: float = 0.0
|
||||
average_score: float
|
||||
level_distribution: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
@@ -17,7 +17,7 @@ class AgentAssetJsonRuleMixin:
|
||||
if rule_library not in RULE_LIBRARY_NAMES:
|
||||
raise ValueError("规则库目录不合法。")
|
||||
|
||||
rule_document = config_json.get("rule_document")
|
||||
rule_document = self._resolve_working_json_risk_rule_document(asset, config_json)
|
||||
if not isinstance(rule_document, dict):
|
||||
raise ValueError("规则资产缺少 rule_document 配置。")
|
||||
|
||||
@@ -26,6 +26,27 @@ class AgentAssetJsonRuleMixin:
|
||||
raise ValueError("规则资产缺少 JSON 文件名。")
|
||||
return rule_library, file_name
|
||||
|
||||
@staticmethod
|
||||
def _resolve_working_json_risk_rule_document(
|
||||
asset: AgentAsset,
|
||||
config_json: dict,
|
||||
) -> dict | None:
|
||||
revision = config_json.get("revision_draft")
|
||||
if isinstance(revision, dict):
|
||||
revision_version = str(revision.get("version") or "").strip()
|
||||
working_version = str(asset.working_version or "").strip()
|
||||
published_version = str(asset.published_version or "").strip()
|
||||
revision_document = revision.get("rule_document")
|
||||
if (
|
||||
revision_version
|
||||
and revision_version == working_version
|
||||
and revision_version != published_version
|
||||
and isinstance(revision_document, dict)
|
||||
and str(revision_document.get("file_name") or "").strip()
|
||||
):
|
||||
return revision_document
|
||||
return config_json.get("rule_document")
|
||||
|
||||
def read_rule_json(self, asset_id: str) -> AgentAssetRuleJsonRead:
|
||||
asset = self.repository.get(asset_id)
|
||||
if asset is None:
|
||||
|
||||
79
server/src/app/services/agent_asset_risk_rule_feedback.py
Normal file
79
server/src/app/services/agent_asset_risk_rule_feedback.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.core.agent_enums import AgentAssetType
|
||||
from app.models.agent_asset import AgentAssetRuleFeedback
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetRiskRuleFeedbackCreate,
|
||||
AgentAssetRiskRuleFeedbackRead,
|
||||
)
|
||||
|
||||
|
||||
class AgentAssetRiskRuleFeedbackMixin:
|
||||
def create_risk_rule_feedback(
|
||||
self,
|
||||
asset_id: str,
|
||||
payload: AgentAssetRiskRuleFeedbackCreate,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> AgentAssetRiskRuleFeedbackRead:
|
||||
asset = self._resolve_asset(asset_id)
|
||||
self._require_json_risk_asset(asset)
|
||||
version = self._resolve_target_version(asset, payload.version)
|
||||
feedback = AgentAssetRuleFeedback(
|
||||
asset_id=asset.id,
|
||||
version=version,
|
||||
feedback_type=str(payload.feedback_type or "").strip(),
|
||||
subject_type=str(payload.subject_type or "").strip(),
|
||||
subject_key=str(payload.subject_key or "").strip(),
|
||||
subject_label=str(payload.subject_label or "").strip(),
|
||||
actual_result_json=self._safe_json_dict(payload.actual_result),
|
||||
expected_result_json=self._safe_json_dict(payload.expected_result),
|
||||
comment=str(payload.comment or "").strip(),
|
||||
payload_json=self._safe_json_dict(payload.payload),
|
||||
created_by=str(actor or "").strip() or "system",
|
||||
)
|
||||
created = self.repository.create_rule_feedback(feedback)
|
||||
self.audit_service.log_action(
|
||||
actor=created.created_by,
|
||||
action="create_risk_rule_feedback",
|
||||
resource_type=AgentAssetType.RULE.value,
|
||||
resource_id=asset.id,
|
||||
before_json=None,
|
||||
after_json={
|
||||
"feedback_id": created.feedback_id,
|
||||
"version": created.version,
|
||||
"feedback_type": created.feedback_type,
|
||||
"subject_type": created.subject_type,
|
||||
"subject_key": created.subject_key,
|
||||
},
|
||||
request_id=request_id,
|
||||
)
|
||||
return AgentAssetRiskRuleFeedbackRead.model_validate(created)
|
||||
|
||||
def list_risk_rule_feedback(
|
||||
self,
|
||||
asset_id: str,
|
||||
*,
|
||||
version: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int | None = None,
|
||||
) -> list[AgentAssetRiskRuleFeedbackRead]:
|
||||
asset = self._resolve_asset(asset_id)
|
||||
self._require_json_risk_asset(asset)
|
||||
target_version = self._resolve_target_version(asset, version) if version else None
|
||||
return [
|
||||
AgentAssetRiskRuleFeedbackRead.model_validate(item)
|
||||
for item in self.repository.list_rule_feedback(
|
||||
asset.id,
|
||||
version=target_version,
|
||||
status=status,
|
||||
limit=limit,
|
||||
)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _safe_json_dict(value: Any) -> dict[str, Any]:
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
215
server/src/app/services/agent_asset_risk_rule_publish.py
Normal file
215
server/src/app/services/agent_asset_risk_rule_publish.py
Normal file
@@ -0,0 +1,215 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.core.agent_enums import AgentAssetStatus, AgentAssetType, AgentReviewStatus
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetReview
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
|
||||
|
||||
|
||||
class AgentAssetRiskRulePublishMixin:
|
||||
"""风险规则发布逻辑,支持普通待审核版本和已上线规则修订版本。"""
|
||||
|
||||
def publish_risk_rule(
|
||||
self,
|
||||
asset_id: str,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> AgentAsset:
|
||||
asset = self._resolve_asset(asset_id)
|
||||
self._require_json_risk_asset(asset)
|
||||
revision = self._resolve_publishable_revision(asset)
|
||||
if revision is not None:
|
||||
return self._publish_revision(asset, revision, actor=actor, request_id=request_id)
|
||||
return self._publish_reviewed_working_version(asset, actor=actor, request_id=request_id)
|
||||
|
||||
def _publish_reviewed_working_version(
|
||||
self,
|
||||
asset: AgentAsset,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None,
|
||||
) -> AgentAsset:
|
||||
version = self._resolve_target_version(asset, None)
|
||||
if asset.status != AgentAssetStatus.REVIEW.value:
|
||||
raise ValueError("只有待审核风险规则可以发布上线。")
|
||||
if not self.get_latest_risk_rule_test_summary(asset, version=version).test_passed:
|
||||
raise PermissionError("当前规则版本尚未完成测试通过确认,不能发布。")
|
||||
|
||||
before = self._asset_snapshot(asset)
|
||||
self._ensure_approved_review(asset, version=version, actor=actor, note="发布上线前审核通过。")
|
||||
asset.reviewer = actor
|
||||
asset.published_version = version
|
||||
asset.status = AgentAssetStatus.ACTIVE.value
|
||||
self.db.add(asset)
|
||||
self.db.commit()
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="publish_agent_asset",
|
||||
resource_type=AgentAssetType.RULE.value,
|
||||
resource_id=asset.id,
|
||||
before_json=before,
|
||||
after_json=self._asset_snapshot(asset),
|
||||
request_id=request_id,
|
||||
)
|
||||
return self._refresh_asset(asset.id)
|
||||
|
||||
def _publish_revision(
|
||||
self,
|
||||
asset: AgentAsset,
|
||||
revision: dict[str, Any],
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None,
|
||||
) -> AgentAsset:
|
||||
version = str(revision.get("version") or "").strip()
|
||||
if not self.get_latest_risk_rule_test_summary(asset, version=version).test_passed:
|
||||
raise PermissionError("当前修订版本尚未完成测试通过确认,不能发布。")
|
||||
|
||||
rule_document = revision.get("rule_document") if isinstance(revision.get("rule_document"), dict) else {}
|
||||
file_name = str(rule_document.get("file_name") or "").strip()
|
||||
if not file_name:
|
||||
raise ValueError("修订版本尚未生成可发布的 JSON 规则文件。")
|
||||
|
||||
before = self._asset_snapshot(asset)
|
||||
manifest = self.rule_library_manager.read_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=file_name,
|
||||
)
|
||||
manifest = normalize_risk_rule_manifest(manifest)
|
||||
manifest["enabled"] = True
|
||||
self.rule_library_manager.write_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=file_name,
|
||||
payload=manifest,
|
||||
)
|
||||
|
||||
config = dict(asset.config_json or {})
|
||||
previous_rule_document = config.get("rule_document") if isinstance(config.get("rule_document"), dict) else {}
|
||||
published_at = datetime.now(UTC).isoformat()
|
||||
history = list(config.get("revision_history") if isinstance(config.get("revision_history"), list) else [])
|
||||
history.insert(
|
||||
0,
|
||||
{
|
||||
"version": version,
|
||||
"base_version": revision.get("base_version"),
|
||||
"change_reason": revision.get("change_reason"),
|
||||
"published_by": actor,
|
||||
"published_at": published_at,
|
||||
"previous_rule_document": previous_rule_document,
|
||||
"rule_document": rule_document,
|
||||
},
|
||||
)
|
||||
config.update(self._config_from_published_manifest(manifest, rule_document))
|
||||
config["revision_history"] = history[:20]
|
||||
config.pop("revision_draft", None)
|
||||
config["last_operation"] = {
|
||||
"action": "publish_revision",
|
||||
"actor": actor,
|
||||
"at": published_at,
|
||||
"target_version": version,
|
||||
}
|
||||
|
||||
asset.name = str(manifest.get("name") or asset.name)
|
||||
asset.description = str(manifest.get("description") or asset.description)
|
||||
risk_category = str(manifest.get("risk_category") or "").strip()
|
||||
if risk_category:
|
||||
asset.scenario_json = [risk_category]
|
||||
asset.config_json = config
|
||||
asset.current_version = version
|
||||
asset.working_version = version
|
||||
asset.published_version = version
|
||||
asset.reviewer = actor
|
||||
asset.status = AgentAssetStatus.ACTIVE.value
|
||||
self._ensure_approved_review(asset, version=version, actor=actor, note="修订版本发布上线。")
|
||||
self.db.add(asset)
|
||||
self.db.commit()
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="publish_risk_rule_revision",
|
||||
resource_type=AgentAssetType.RULE.value,
|
||||
resource_id=asset.id,
|
||||
before_json=before,
|
||||
after_json=self._asset_snapshot(asset),
|
||||
request_id=request_id,
|
||||
)
|
||||
return self._refresh_asset(asset.id)
|
||||
|
||||
def _resolve_publishable_revision(self, asset: AgentAsset) -> dict[str, Any] | None:
|
||||
config = dict(asset.config_json or {})
|
||||
revision = config.get("revision_draft")
|
||||
if not isinstance(revision, dict):
|
||||
return None
|
||||
version = str(revision.get("version") or "").strip()
|
||||
if not version or version != str(asset.working_version or "").strip():
|
||||
return None
|
||||
if version == str(asset.published_version or "").strip():
|
||||
return None
|
||||
if revision.get("generation_status") != "completed":
|
||||
raise ValueError("修订版本尚未重新生成,不能发布上线。")
|
||||
return dict(revision)
|
||||
|
||||
def _ensure_approved_review(
|
||||
self,
|
||||
asset: AgentAsset,
|
||||
*,
|
||||
version: str,
|
||||
actor: str,
|
||||
note: str,
|
||||
) -> None:
|
||||
approved_review = self.repository.get_review(
|
||||
asset.id, version, AgentReviewStatus.APPROVED.value
|
||||
)
|
||||
if approved_review is not None:
|
||||
return
|
||||
self.db.add(
|
||||
AgentAssetReview(
|
||||
asset_id=asset.id,
|
||||
version=version,
|
||||
reviewer=actor,
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note=note,
|
||||
reviewed_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _config_from_published_manifest(
|
||||
manifest: dict[str, Any],
|
||||
rule_document: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||
risk_score_detail = metadata.get("risk_score_detail") if isinstance(metadata.get("risk_score_detail"), dict) else {}
|
||||
risk_level = str(metadata.get("risk_level") or manifest.get("outcomes", {}).get("fail", {}).get("severity") or "medium")
|
||||
risk_score = int(metadata.get("risk_score") or manifest.get("outcomes", {}).get("fail", {}).get("risk_score") or 0)
|
||||
return {
|
||||
"severity": risk_level,
|
||||
"risk_score": risk_score,
|
||||
"risk_level": risk_level,
|
||||
"risk_level_label": metadata.get("risk_level_label"),
|
||||
"risk_score_detail": risk_score_detail,
|
||||
"enabled": True,
|
||||
"requires_attachment": bool(metadata.get("requires_attachment") or manifest.get("requires_attachment")),
|
||||
"detail_mode": "json_risk",
|
||||
"business_stage": metadata.get("business_stage"),
|
||||
"business_stage_label": metadata.get("business_stage_label"),
|
||||
"expense_category": metadata.get("expense_category"),
|
||||
"expense_category_label": metadata.get("expense_category_label"),
|
||||
"risk_category": manifest.get("risk_category"),
|
||||
"rule_library": RISK_RULES_LIBRARY,
|
||||
"rule_document": rule_document,
|
||||
"ontology_signal": manifest.get("ontology_signal"),
|
||||
"evaluator": manifest.get("evaluator"),
|
||||
"generated_by": "natural_language",
|
||||
"source_ref": "自然语言风险规则",
|
||||
"flow_diagram_svg": manifest.get("flow_diagram_svg"),
|
||||
}
|
||||
|
||||
def _refresh_asset(self, asset_id: str) -> AgentAsset:
|
||||
refreshed = self.repository.get(asset_id)
|
||||
if refreshed is None:
|
||||
raise LookupError("Asset not found")
|
||||
return refreshed
|
||||
404
server/src/app/services/agent_asset_risk_rule_regeneration.py
Normal file
404
server/src/app/services/agent_asset_risk_rule_regeneration.py
Normal file
@@ -0,0 +1,404 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetVersion
|
||||
from app.repositories.agent_asset import AgentAssetRepository
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetRiskRuleGenerateRequest,
|
||||
AgentAssetRiskRuleRegenerateRequest,
|
||||
)
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||
from app.services.audit import AuditLogService
|
||||
from app.services.risk_rule_generation import (
|
||||
BUSINESS_DOMAIN_LABELS,
|
||||
EXPENSE_BUSINESS_STAGE_LABELS,
|
||||
EXPENSE_RISK_CATEGORY_LABELS,
|
||||
RiskRuleGenerationService,
|
||||
)
|
||||
from app.services.risk_rule_generation_markdown import build_risk_rule_version_markdown
|
||||
from app.services.risk_rule_dsl_validator import validate_risk_rule_draft
|
||||
from app.services.risk_rule_scoring import apply_risk_score_to_draft, calculate_risk_rule_score
|
||||
from app.services.runtime_chat import RuntimeChatService
|
||||
|
||||
|
||||
class AgentAssetRiskRuleRegenerationService:
|
||||
"""重新把自然语言草稿或修订草稿解释为可执行 JSON 风险规则。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
rule_library_manager: AgentAssetRuleLibraryManager | None = None,
|
||||
runtime_chat_service: RuntimeChatService | None = None,
|
||||
) -> None:
|
||||
self.db = db
|
||||
self.repository = AgentAssetRepository(db)
|
||||
self.rule_library_manager = rule_library_manager or AgentAssetRuleLibraryManager()
|
||||
self.generator = RiskRuleGenerationService(
|
||||
db,
|
||||
rule_library_manager=self.rule_library_manager,
|
||||
runtime_chat_service=runtime_chat_service,
|
||||
)
|
||||
self.audit_service = AuditLogService(db)
|
||||
|
||||
def regenerate(
|
||||
self,
|
||||
asset_id: str,
|
||||
body: AgentAssetRiskRuleRegenerateRequest,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None = None,
|
||||
) -> AgentAsset:
|
||||
asset = self._resolve_json_risk_asset(asset_id)
|
||||
if str(asset.published_version or "").strip():
|
||||
return self._regenerate_revision_draft(
|
||||
asset,
|
||||
body,
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
)
|
||||
return self._regenerate_unpublished_draft(
|
||||
asset,
|
||||
body,
|
||||
actor=actor,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
def _regenerate_unpublished_draft(
|
||||
self,
|
||||
asset: AgentAsset,
|
||||
body: AgentAssetRiskRuleRegenerateRequest,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None,
|
||||
) -> AgentAsset:
|
||||
if asset.status not in {AgentAssetStatus.DRAFT.value, AgentAssetStatus.FAILED.value}:
|
||||
raise ValueError("只有未上线草稿或生成失败规则可以重新生成。")
|
||||
|
||||
before = self._snapshot(asset)
|
||||
config = dict(asset.config_json or {})
|
||||
request = self._build_generation_request(asset, config, body.model_dump(exclude_unset=True))
|
||||
payload, risk_score = self._compile_payload(request, actor=actor, created_at=asset.created_at)
|
||||
rule_code = self._stable_rule_code(asset, payload)
|
||||
payload["rule_code"] = rule_code
|
||||
file_name = f"{rule_code}.json"
|
||||
self.rule_library_manager.write_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=file_name,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
version = str(asset.working_version or asset.current_version or "v0.1.0")
|
||||
now = datetime.now(UTC).isoformat()
|
||||
self._upsert_version(
|
||||
asset,
|
||||
version=version,
|
||||
content=build_risk_rule_version_markdown(payload),
|
||||
change_note="重新生成自然语言风险规则草稿。",
|
||||
actor=actor,
|
||||
)
|
||||
config.update(self._config_from_payload(payload, risk_score=risk_score, request=request))
|
||||
config.update(
|
||||
{
|
||||
"generation_status": "completed",
|
||||
"generation_completed_at": now,
|
||||
"last_operation": {"action": "regenerate", "actor": actor, "at": now},
|
||||
}
|
||||
)
|
||||
asset.code = rule_code
|
||||
asset.name = str(payload["name"])
|
||||
asset.description = str(payload["description"])
|
||||
asset.domain = str(request.get("business_domain") or AgentAssetDomain.EXPENSE.value)
|
||||
asset.scenario_json = [str(payload.get("risk_category") or BUSINESS_DOMAIN_LABELS[asset.domain])]
|
||||
asset.status = AgentAssetStatus.DRAFT.value
|
||||
asset.current_version = version
|
||||
asset.working_version = version
|
||||
asset.config_json = config
|
||||
self.db.add(asset)
|
||||
self.db.flush()
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="regenerate_risk_rule_draft",
|
||||
resource_type=AgentAssetType.RULE.value,
|
||||
resource_id=asset.id,
|
||||
before_json=before,
|
||||
after_json=self._snapshot(asset),
|
||||
request_id=request_id,
|
||||
)
|
||||
return asset
|
||||
|
||||
def _regenerate_revision_draft(
|
||||
self,
|
||||
asset: AgentAsset,
|
||||
body: AgentAssetRiskRuleRegenerateRequest,
|
||||
*,
|
||||
actor: str,
|
||||
request_id: str | None,
|
||||
) -> AgentAsset:
|
||||
revision = self._resolve_revision_draft(asset)
|
||||
revision_version = str(revision.get("version") or "").strip()
|
||||
before = self._snapshot(asset)
|
||||
config = dict(asset.config_json or {})
|
||||
request = self._build_generation_request(
|
||||
asset,
|
||||
config,
|
||||
body.model_dump(exclude_unset=True),
|
||||
base=revision.get("generation_request") if isinstance(revision.get("generation_request"), dict) else {},
|
||||
)
|
||||
payload, risk_score = self._compile_payload(request, actor=actor, created_at=datetime.now(UTC))
|
||||
payload["rule_code"] = str(asset.code or payload["rule_code"]).strip()
|
||||
payload["enabled"] = False
|
||||
payload.setdefault("metadata", {})["revision_version"] = revision_version
|
||||
payload["metadata"]["revision_base_version"] = revision.get("base_version")
|
||||
file_name = f"{payload['rule_code']}.{revision_version.replace('.', '_')}.json"
|
||||
self.rule_library_manager.write_rule_library_json(
|
||||
library=RISK_RULES_LIBRARY,
|
||||
file_name=file_name,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
revision.update(
|
||||
{
|
||||
"status": "generated",
|
||||
"generation_status": "completed",
|
||||
"generation_request": request,
|
||||
"generated_by": actor,
|
||||
"generated_at": now,
|
||||
"rule_code": payload["rule_code"],
|
||||
"rule_document": {
|
||||
"file_name": file_name,
|
||||
"storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}",
|
||||
},
|
||||
"risk_score": risk_score["score"],
|
||||
"risk_level": risk_score["level"],
|
||||
"risk_level_label": risk_score["level_label"],
|
||||
"flow_diagram_svg": payload.get("flow_diagram_svg"),
|
||||
"business_explanation": payload.get("metadata", {}).get("business_explanation"),
|
||||
}
|
||||
)
|
||||
config["revision_draft"] = revision
|
||||
config["last_operation"] = {
|
||||
"action": "regenerate_revision",
|
||||
"actor": actor,
|
||||
"at": now,
|
||||
"target_version": revision_version,
|
||||
}
|
||||
asset.working_version = revision_version
|
||||
asset.config_json = config
|
||||
self._upsert_version(
|
||||
asset,
|
||||
version=revision_version,
|
||||
content=build_risk_rule_version_markdown(payload),
|
||||
change_note=str(revision.get("change_reason") or "重新生成修订草稿"),
|
||||
actor=actor,
|
||||
)
|
||||
self.db.add(asset)
|
||||
self.db.flush()
|
||||
self.audit_service.log_action(
|
||||
actor=actor,
|
||||
action="regenerate_risk_rule_revision",
|
||||
resource_type=AgentAssetType.RULE.value,
|
||||
resource_id=asset.id,
|
||||
before_json=before,
|
||||
after_json=self._snapshot(asset),
|
||||
request_id=request_id,
|
||||
)
|
||||
return asset
|
||||
|
||||
def _compile_payload(
|
||||
self,
|
||||
request: dict[str, Any],
|
||||
*,
|
||||
actor: str,
|
||||
created_at: datetime | None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
body = AgentAssetRiskRuleGenerateRequest.model_validate(request)
|
||||
domain = body.business_domain.value
|
||||
natural_language = self.generator._clean_text(body.natural_language)
|
||||
rule_title = self.generator._clean_text(body.rule_title)
|
||||
requires_attachment = bool(body.requires_attachment)
|
||||
business_stage = self.generator._normalize_business_stage(body.business_stage, domain)
|
||||
business_stage_label = EXPENSE_BUSINESS_STAGE_LABELS.get(business_stage, "费用报销")
|
||||
expense_category = self.generator._normalize_expense_category(body.expense_category, domain)
|
||||
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
|
||||
fields = self.generator._resolve_fields(natural_language, domain=domain)
|
||||
draft = self.generator._compile_with_model(
|
||||
natural_language=natural_language,
|
||||
domain=domain,
|
||||
business_stage=business_stage,
|
||||
business_stage_label=business_stage_label,
|
||||
expense_category=expense_category,
|
||||
expense_category_label=expense_category_label,
|
||||
fields=fields,
|
||||
) or self.generator._build_fallback_draft(
|
||||
natural_language=natural_language,
|
||||
domain=domain,
|
||||
expense_category_label=expense_category_label,
|
||||
risk_level="medium",
|
||||
fields=fields,
|
||||
)
|
||||
draft = validate_risk_rule_draft(draft, fields=fields, natural_language=natural_language)
|
||||
draft = self.generator._align_draft_fields(
|
||||
draft,
|
||||
natural_language=natural_language,
|
||||
risk_level="medium",
|
||||
fields=fields,
|
||||
)
|
||||
draft = validate_risk_rule_draft(draft, fields=fields, natural_language=natural_language)
|
||||
risk_score = calculate_risk_rule_score(
|
||||
natural_language=natural_language,
|
||||
draft=draft,
|
||||
fields=fields,
|
||||
expense_category=expense_category,
|
||||
expense_category_label=expense_category_label,
|
||||
requires_attachment=requires_attachment,
|
||||
)
|
||||
risk_level = str(risk_score["level"])
|
||||
draft = apply_risk_score_to_draft(draft, risk_score)
|
||||
payload = self.generator._build_rule_payload(
|
||||
draft,
|
||||
natural_language=natural_language,
|
||||
domain=domain,
|
||||
business_stage=business_stage,
|
||||
business_stage_label=business_stage_label,
|
||||
expense_category=expense_category,
|
||||
expense_category_label=expense_category_label,
|
||||
risk_level=risk_level,
|
||||
fields=fields,
|
||||
created_at=created_at or datetime.now(UTC),
|
||||
actor=actor,
|
||||
requires_attachment=requires_attachment,
|
||||
rule_title=rule_title,
|
||||
risk_score=risk_score,
|
||||
)
|
||||
return payload, risk_score
|
||||
|
||||
def _resolve_json_risk_asset(self, asset_id: str) -> AgentAsset:
|
||||
asset = self.repository.get(asset_id)
|
||||
if asset is None:
|
||||
raise FileNotFoundError("风险规则不存在。")
|
||||
config = asset.config_json or {}
|
||||
if asset.asset_type != AgentAssetType.RULE.value or config.get("detail_mode") != "json_risk":
|
||||
raise ValueError("当前资产不是自然语言风险规则。")
|
||||
return asset
|
||||
|
||||
def _resolve_revision_draft(self, asset: AgentAsset) -> dict[str, Any]:
|
||||
config = dict(asset.config_json or {})
|
||||
revision = config.get("revision_draft")
|
||||
if not isinstance(revision, dict):
|
||||
raise ValueError("已上线规则需要先创建修订版本,再重新生成。")
|
||||
revision_version = str(revision.get("version") or "").strip()
|
||||
if not revision_version or revision_version != str(asset.working_version or "").strip():
|
||||
raise ValueError("修订草稿版本与当前工作版本不一致。")
|
||||
if revision.get("status") not in {"draft", "generated", "failed"}:
|
||||
raise ValueError("当前修订草稿状态不允许重新生成。")
|
||||
return dict(revision)
|
||||
|
||||
@staticmethod
|
||||
def _build_generation_request(
|
||||
asset: AgentAsset,
|
||||
config: dict[str, Any],
|
||||
updates: dict[str, Any],
|
||||
*,
|
||||
base: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
source = base if isinstance(base, dict) else config.get("generation_request")
|
||||
merged = dict(source if isinstance(source, dict) else {})
|
||||
merged.setdefault("business_domain", asset.domain or AgentAssetDomain.EXPENSE.value)
|
||||
merged.setdefault("business_stage", config.get("business_stage") or "reimbursement")
|
||||
merged.setdefault("expense_category", config.get("expense_category"))
|
||||
merged.setdefault("rule_title", asset.name)
|
||||
merged.setdefault("natural_language", asset.description)
|
||||
merged.setdefault("requires_attachment", bool(config.get("requires_attachment")))
|
||||
for key, value in updates.items():
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
@staticmethod
|
||||
def _stable_rule_code(asset: AgentAsset, payload: dict[str, Any]) -> str:
|
||||
current = str(asset.code or "").strip()
|
||||
if current and ".generating_" not in current:
|
||||
return current
|
||||
return str(payload.get("rule_code") or current).strip()
|
||||
|
||||
@staticmethod
|
||||
def _config_from_payload(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
risk_score: dict[str, Any],
|
||||
request: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
file_name = f"{payload['rule_code']}.json"
|
||||
return {
|
||||
"severity": risk_score["level"],
|
||||
"risk_score": risk_score["score"],
|
||||
"risk_level": risk_score["level"],
|
||||
"risk_level_label": risk_score["level_label"],
|
||||
"risk_score_detail": risk_score,
|
||||
"requires_attachment": bool(request.get("requires_attachment")),
|
||||
"detail_mode": "json_risk",
|
||||
"business_stage": request.get("business_stage"),
|
||||
"business_stage_label": payload.get("metadata", {}).get("business_stage_label"),
|
||||
"expense_category": request.get("expense_category"),
|
||||
"expense_category_label": payload.get("metadata", {}).get("expense_category_label"),
|
||||
"risk_category": payload.get("risk_category"),
|
||||
"rule_library": RISK_RULES_LIBRARY,
|
||||
"rule_document": {
|
||||
"file_name": file_name,
|
||||
"storage_key": f"rules/{RISK_RULES_LIBRARY}/{file_name}",
|
||||
},
|
||||
"ontology_signal": payload.get("ontology_signal"),
|
||||
"evaluator": payload.get("evaluator"),
|
||||
"generated_by": "natural_language",
|
||||
"source_ref": "自然语言风险规则",
|
||||
"generation_request": request,
|
||||
"flow_diagram_svg": payload.get("flow_diagram_svg"),
|
||||
}
|
||||
|
||||
def _upsert_version(
|
||||
self,
|
||||
asset: AgentAsset,
|
||||
*,
|
||||
version: str,
|
||||
content: str,
|
||||
change_note: str,
|
||||
actor: str,
|
||||
) -> None:
|
||||
existing = self.repository.get_version(asset.id, version)
|
||||
if existing is None:
|
||||
self.db.add(
|
||||
AgentAssetVersion(
|
||||
asset_id=asset.id,
|
||||
version=version,
|
||||
content=content,
|
||||
content_type="markdown",
|
||||
change_note=change_note,
|
||||
created_by=actor,
|
||||
)
|
||||
)
|
||||
return
|
||||
existing.content = content
|
||||
existing.change_note = change_note
|
||||
existing.created_by = actor
|
||||
self.db.add(existing)
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(asset: AgentAsset) -> dict[str, Any]:
|
||||
return {
|
||||
"id": asset.id,
|
||||
"code": asset.code,
|
||||
"name": asset.name,
|
||||
"description": asset.description,
|
||||
"status": asset.status,
|
||||
"current_version": asset.current_version,
|
||||
"published_version": asset.published_version,
|
||||
"working_version": asset.working_version,
|
||||
"config_json": asset.config_json or {},
|
||||
}
|
||||
@@ -40,7 +40,20 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
attachments=attachments,
|
||||
)
|
||||
recognition_summary = self._build_recognition_summary(attachments)
|
||||
fields = self._extract_manifest_fields(manifest)
|
||||
ocr_raw_fields = self._build_ocr_raw_fields(attachments)
|
||||
hermes_normalized_fields = self._build_hermes_normalized_fields(
|
||||
fields,
|
||||
field_values,
|
||||
source_map,
|
||||
)
|
||||
required_keys = self._extract_execution_field_keys(manifest)
|
||||
executor_input_fields = self._build_executor_input_fields(
|
||||
fields,
|
||||
field_values,
|
||||
source_map,
|
||||
required_keys,
|
||||
)
|
||||
missing_fields = self._build_missing_fields(
|
||||
manifest,
|
||||
field_values=field_values,
|
||||
@@ -67,6 +80,9 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
normalized_fields=field_values,
|
||||
attachments=attachments,
|
||||
recognized_fields=recognized_fields,
|
||||
ocr_raw_fields=ocr_raw_fields,
|
||||
hermes_normalized_fields=hermes_normalized_fields,
|
||||
executor_input_fields=executor_input_fields,
|
||||
missing_fields=missing_fields,
|
||||
recognition_summary=recognition_summary,
|
||||
created_at=datetime.now(UTC),
|
||||
@@ -108,6 +124,9 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
trace=execution["trace"] if isinstance(execution.get("trace"), dict) else {},
|
||||
attachments=attachments,
|
||||
recognized_fields=recognized_fields,
|
||||
ocr_raw_fields=ocr_raw_fields,
|
||||
hermes_normalized_fields=hermes_normalized_fields,
|
||||
executor_input_fields=executor_input_fields,
|
||||
missing_fields=[],
|
||||
recognition_summary=recognition_summary,
|
||||
created_at=datetime.now(UTC),
|
||||
@@ -565,6 +584,108 @@ class AgentAssetRiskRuleSimulationMixin:
|
||||
if source_map.get(key)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _build_ocr_raw_fields(attachments: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for attachment_index, attachment in enumerate(attachments):
|
||||
attachment_name = str(attachment.get("name") or f"attachment-{attachment_index + 1}")
|
||||
for field_index, field in enumerate(list(attachment.get("document_fields") or [])):
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
value = field.get("value")
|
||||
if not AgentAssetRiskRuleSimulationMixin._has_meaningful_value(value):
|
||||
continue
|
||||
key = str(field.get("key") or f"field_{field_index + 1}").strip()
|
||||
label = str(field.get("label") or key).strip()
|
||||
rows.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": label,
|
||||
"value": value,
|
||||
"source": "ocr",
|
||||
"source_label": "OCR结构字段",
|
||||
"attachment_name": attachment_name,
|
||||
}
|
||||
)
|
||||
for key, label in (("summary", "单据摘要"), ("ocr_text", "OCR原文")):
|
||||
value = attachment.get(key)
|
||||
if AgentAssetRiskRuleSimulationMixin._has_meaningful_value(value):
|
||||
rows.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": label,
|
||||
"value": value,
|
||||
"source": "ocr",
|
||||
"source_label": "OCR文本",
|
||||
"attachment_name": attachment_name,
|
||||
}
|
||||
)
|
||||
return rows[:80]
|
||||
|
||||
@staticmethod
|
||||
def _build_hermes_normalized_fields(
|
||||
fields: list[dict[str, str]],
|
||||
values: dict[str, Any],
|
||||
source_map: dict[str, str],
|
||||
) -> list[dict[str, Any]]:
|
||||
labels = {field["key"]: field["label"] for field in fields}
|
||||
rows: list[dict[str, Any]] = []
|
||||
for key, value in values.items():
|
||||
if not AgentAssetRiskRuleSimulationMixin._has_meaningful_value(value):
|
||||
continue
|
||||
source = source_map.get(key, "")
|
||||
rows.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": labels.get(key, key),
|
||||
"value": value,
|
||||
"source": source,
|
||||
"source_label": AgentAssetRiskRuleSimulationMixin._field_source_label(source),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def _build_executor_input_fields(
|
||||
fields: list[dict[str, str]],
|
||||
values: dict[str, Any],
|
||||
source_map: dict[str, str],
|
||||
required_keys: list[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
labels = {field["key"]: field["label"] for field in fields}
|
||||
required_set = set(required_keys or [])
|
||||
ordered_keys = [*required_keys]
|
||||
for field in fields:
|
||||
key = field["key"]
|
||||
if key not in ordered_keys and key in values:
|
||||
ordered_keys.append(key)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for key in ordered_keys:
|
||||
value = values.get(key)
|
||||
if not AgentAssetRiskRuleSimulationMixin._has_meaningful_value(value):
|
||||
continue
|
||||
source = source_map.get(key, "")
|
||||
rows.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": labels.get(key, key),
|
||||
"value": value,
|
||||
"source": source,
|
||||
"source_label": AgentAssetRiskRuleSimulationMixin._field_source_label(source),
|
||||
"required": key in required_set,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def _field_source_label(source: str) -> str:
|
||||
return {
|
||||
"manual": "用户输入",
|
||||
"ocr": "OCR结构字段",
|
||||
"inferred": "文本推断",
|
||||
"model_refined": "Hermes规范化",
|
||||
}.get(str(source or "").strip(), "未标注来源")
|
||||
|
||||
@staticmethod
|
||||
def _build_recognition_summary(attachments: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
|
||||
@@ -28,7 +28,9 @@ from app.schemas.agent_asset import (
|
||||
)
|
||||
from app.services.agent_asset_json_rules import AgentAssetJsonRuleMixin
|
||||
from app.services.agent_asset_onlyoffice import AgentAssetOnlyOfficeMixin
|
||||
from app.services.agent_asset_risk_rule_feedback import AgentAssetRiskRuleFeedbackMixin
|
||||
from app.services.agent_asset_risk_rule_level import AgentAssetRiskRuleLevelMixin
|
||||
from app.services.agent_asset_risk_rule_publish import AgentAssetRiskRulePublishMixin
|
||||
from app.services.agent_asset_risk_rule_simulation import AgentAssetRiskRuleSimulationMixin
|
||||
from app.services.agent_asset_risk_rule_testing import AgentAssetRiskRuleTestingMixin
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
@@ -47,6 +49,8 @@ class AgentAssetService(
|
||||
AgentAssetOnlyOfficeMixin,
|
||||
AgentAssetSpreadsheetHelperMixin,
|
||||
AgentAssetRiskRuleLevelMixin,
|
||||
AgentAssetRiskRulePublishMixin,
|
||||
AgentAssetRiskRuleFeedbackMixin,
|
||||
AgentAssetRiskRuleTestingMixin,
|
||||
AgentAssetRiskRuleSimulationMixin,
|
||||
AgentAssetTimelineMixin,
|
||||
|
||||
@@ -126,14 +126,31 @@ class AgentFoundationAssetSeedMixin:
|
||||
[
|
||||
"---",
|
||||
"name: risk-rule-discovery",
|
||||
"description: 用于根据风险观察反馈生成候选规则,不直接上线。",
|
||||
"description: 兼容别名。用于归集申请和报销事实中的潜在线索,不生成规则。",
|
||||
"---",
|
||||
"",
|
||||
"# 风险规则候选发现",
|
||||
"# 风险线索归集",
|
||||
"",
|
||||
"## 功能说明",
|
||||
"",
|
||||
"从风险观察、人工反馈和误报复盘中生成带证据、来源和置信度的候选规则。",
|
||||
"从申请、报销、规则命中和人工反馈中整理事实、证据和待复核线索。",
|
||||
],
|
||||
)
|
||||
|
||||
def _risk_clue_collector_skill_markdown(self) -> str:
|
||||
return self._read_domain_skill_markdown(
|
||||
"risk-clue-collector",
|
||||
[
|
||||
"---",
|
||||
"name: risk-clue-collector",
|
||||
"description: 用于归集申请和报销事实中的潜在线索,不生成规则、不发布规则、不替代人工确认。",
|
||||
"---",
|
||||
"",
|
||||
"# 风险线索归集",
|
||||
"",
|
||||
"## 功能说明",
|
||||
"",
|
||||
"从申请、报销、规则命中和人工反馈中整理事实、证据和待复核线索。",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -370,6 +387,15 @@ class AgentFoundationAssetSeedMixin:
|
||||
"folder": "财务制度",
|
||||
"changed_only": True,
|
||||
"output_format": "knowledge_organizing_report",
|
||||
"allowed_outputs": [
|
||||
"facts",
|
||||
"policy_refs",
|
||||
"evidence_refs",
|
||||
"knowledge_items",
|
||||
"human_review_required",
|
||||
],
|
||||
"role_boundary": "规则由人定义,风险由人确认,数字员工只整理人提供的制度和报销事实。",
|
||||
"writes_rules": False,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -452,6 +478,10 @@ class AgentFoundationAssetSeedMixin:
|
||||
]
|
||||
)
|
||||
|
||||
self.db.flush()
|
||||
self._upsert_runtime_digital_employee_tasks(
|
||||
set(self.db.scalars(select(AgentAsset.code)).all())
|
||||
)
|
||||
self.db.flush()
|
||||
|
||||
company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed(
|
||||
@@ -615,22 +645,6 @@ class AgentFoundationAssetSeedMixin:
|
||||
change_note="初始化整理公司财务知识制度能力。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=risk_graph_scan_task,
|
||||
version="v1.0.0",
|
||||
content=self._financial_risk_graph_scan_skill_markdown(),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note="初始化财务风险图谱巡检能力。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=employee_profile_scan_task,
|
||||
version="v1.0.0",
|
||||
content=self._employee_behavior_profile_scan_skill_markdown(),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note="初始化员工行为画像巡检能力。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -614,6 +614,15 @@ class AgentFoundationAssetTopUpMixin:
|
||||
"folder": "财务制度",
|
||||
"changed_only": True,
|
||||
"output_format": "knowledge_organizing_report",
|
||||
"allowed_outputs": [
|
||||
"facts",
|
||||
"policy_refs",
|
||||
"evidence_refs",
|
||||
"knowledge_items",
|
||||
"human_review_required",
|
||||
],
|
||||
"role_boundary": "规则由人定义,风险由人确认,数字员工只整理人提供的制度和报销事实。",
|
||||
"writes_rules": False,
|
||||
}
|
||||
|
||||
if DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE not in existing_codes:
|
||||
@@ -669,6 +678,15 @@ class AgentFoundationAssetTopUpMixin:
|
||||
"folder": existing_config.get("folder") or "财务制度",
|
||||
"changed_only": existing_config.get("changed_only", True),
|
||||
"output_format": "knowledge_organizing_report",
|
||||
"allowed_outputs": [
|
||||
"facts",
|
||||
"policy_refs",
|
||||
"evidence_refs",
|
||||
"knowledge_items",
|
||||
"human_review_required",
|
||||
],
|
||||
"role_boundary": "规则由人定义,风险由人确认,数字员工只整理人提供的制度和报销事实。",
|
||||
"writes_rules": False,
|
||||
**schedule_config,
|
||||
}
|
||||
self.db.add(asset)
|
||||
|
||||
@@ -96,6 +96,32 @@ DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE = "task.hermes.employee_behavior_profile
|
||||
|
||||
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE = "task.hermes.risk_rule_discovery"
|
||||
|
||||
DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE = "task.hermes.finance_policy_clause_extract"
|
||||
|
||||
DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE = "task.hermes.expense_policy_alignment"
|
||||
|
||||
DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE = "task.hermes.risk_rule_template_organize"
|
||||
|
||||
DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE = "task.hermes.department_expense_baseline_accumulate"
|
||||
|
||||
DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE = "task.hermes.supplier_risk_profile_accumulate"
|
||||
|
||||
DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE = "task.hermes.false_positive_sample_accumulate"
|
||||
|
||||
DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE = "task.hermes.risk_feedback_sample_accumulate"
|
||||
|
||||
DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE = "task.hermes.multi_evidence_consistency_evaluate"
|
||||
|
||||
DIGITAL_EMPLOYEE_SPATIOTEMPORAL_TASK_CODE = "task.hermes.travel_spatiotemporal_consistency_evaluate"
|
||||
|
||||
DIGITAL_EMPLOYEE_BUDGET_PRECONTROL_TASK_CODE = "task.hermes.budget_overrun_precontrol_evaluate"
|
||||
|
||||
DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE = "task.hermes.supplier_abnormal_relation_evaluate"
|
||||
|
||||
DIGITAL_EMPLOYEE_ALGORITHM_REPLAY_TASK_CODE = "task.hermes.risk_algorithm_replay_evaluate"
|
||||
|
||||
DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE = "task.hermes.policy_gap_rule_optimization"
|
||||
|
||||
DIGITAL_EMPLOYEE_LEGACY_TASK_CODES = (
|
||||
"task.hermes.daily_risk_scan",
|
||||
"task.hermes.weekly_ar_summary",
|
||||
@@ -107,8 +133,21 @@ DIGITAL_EMPLOYEE_LEGACY_TASK_CODES = (
|
||||
DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP = {
|
||||
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE: "整理",
|
||||
DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE: "评估",
|
||||
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE: "评估",
|
||||
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE: "积累",
|
||||
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE: "升级",
|
||||
DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE: "整理",
|
||||
DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE: "整理",
|
||||
DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE: "整理",
|
||||
DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE: "积累",
|
||||
DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE: "积累",
|
||||
DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE: "积累",
|
||||
DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE: "积累",
|
||||
DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE: "评估",
|
||||
DIGITAL_EMPLOYEE_SPATIOTEMPORAL_TASK_CODE: "评估",
|
||||
DIGITAL_EMPLOYEE_BUDGET_PRECONTROL_TASK_CODE: "评估",
|
||||
DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE: "评估",
|
||||
DIGITAL_EMPLOYEE_ALGORITHM_REPLAY_TASK_CODE: "升级",
|
||||
DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE: "升级",
|
||||
}
|
||||
|
||||
ATTACHMENT_RULE_RUNTIME_CONFIG = {
|
||||
|
||||
@@ -11,16 +11,126 @@ from app.core.agent_enums import (
|
||||
)
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.services.agent_foundation_constants import (
|
||||
DIGITAL_EMPLOYEE_ALGORITHM_REPLAY_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_BUDGET_PRECONTROL_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_SKILL_CATEGORIES,
|
||||
DIGITAL_EMPLOYEE_SPATIOTEMPORAL_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE,
|
||||
DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE,
|
||||
)
|
||||
|
||||
|
||||
DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY = (
|
||||
"规则由人定义,风险由人确认,主流程由外层智能体执行,"
|
||||
"数字员工只读取事实、规则命中和反馈结果,生成后台分析、报告和待复核材料。"
|
||||
)
|
||||
|
||||
|
||||
class AgentFoundationDigitalEmployeeTaskMixin:
|
||||
def _runtime_digital_employee_task_specs(self) -> tuple[dict[str, object], ...]:
|
||||
return (
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE,
|
||||
name="制度条款结构化抽取",
|
||||
description="按计划从财务制度和报销政策中抽取适用范围、限制条件、金额标准、审批要求和证据字段。",
|
||||
scenario_json=["schedule", "knowledge", "policy_clause", "ontology"],
|
||||
owner="财务制度管理组",
|
||||
cron="15 3 * * *",
|
||||
skill_category="整理",
|
||||
skill_name="finance-policy-clause-extractor",
|
||||
output_format="policy_clause_structuring_report",
|
||||
input_sources=["finance_policies", "knowledge_documents", "ontology_parse_logs"],
|
||||
execution_strategy="definition_ready",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE,
|
||||
name="报销政策口径对齐",
|
||||
description="对齐不同制度、规则中心和知识库中的报销口径,发现同义、冲突、缺失和过期条款。",
|
||||
scenario_json=["schedule", "knowledge", "expense_policy", "rule_center"],
|
||||
owner="财务制度管理组",
|
||||
cron="30 3 * * *",
|
||||
skill_category="整理",
|
||||
skill_name="expense-policy-alignment",
|
||||
output_format="policy_alignment_report",
|
||||
input_sources=["finance_policies", "risk_rules", "knowledge_items"],
|
||||
execution_strategy="definition_ready",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE,
|
||||
name="规则命中样本整理",
|
||||
description="把外层智能体流程已经产生的规则命中、制度引用和历史样本整理为字段映射与复核材料,不新增、不改写、不发布规则。",
|
||||
scenario_json=["schedule", "rule_hit", "risk_rule", "policy_ref"],
|
||||
owner="风控与审计部",
|
||||
cron="45 3 * * 1",
|
||||
skill_category="整理",
|
||||
skill_name="rule-execution-case-organizer",
|
||||
output_format="rule_hit_sample_pack",
|
||||
input_sources=["approved_risk_rules", "policy_refs", "rule_hits"],
|
||||
execution_strategy="definition_ready",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE,
|
||||
name="部门费用基线沉淀",
|
||||
description="按部门、费用类型和时间窗口沉淀费用基线,为预算柔性控制和同类对比提供长期参照。",
|
||||
scenario_json=["schedule", "department", "baseline", "expense"],
|
||||
owner="风控与审计部",
|
||||
cron="45 8 * * 1",
|
||||
skill_category="积累",
|
||||
skill_name="department-expense-baseline-accumulator",
|
||||
output_format="department_expense_baseline_snapshot",
|
||||
input_sources=["expense_claims", "expense_items", "profile_baselines"],
|
||||
execution_strategy="reuse_employee_profile_baseline",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE,
|
||||
name="供应商风险画像沉淀",
|
||||
description="沉淀供应商、商户、酒店和收款方的费用频次、金额分布、异常关系和历史风险反馈。",
|
||||
scenario_json=["schedule", "supplier", "baseline", "risk_graph"],
|
||||
owner="风控与审计部",
|
||||
cron="0 8 * * 2",
|
||||
skill_category="积累",
|
||||
skill_name="supplier-risk-profile-accumulator",
|
||||
output_format="supplier_risk_profile_snapshot",
|
||||
input_sources=["expense_claims", "invoice_entities", "risk_observations"],
|
||||
execution_strategy="reuse_profile_baseline",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE,
|
||||
name="历史误报样本沉淀",
|
||||
description="归集被人工标记为误报、忽略或撤销的风险观察,形成算法回放和人工复核校准样本。",
|
||||
scenario_json=["schedule", "false_positive", "feedback", "replay"],
|
||||
owner="风控与审计部",
|
||||
cron="20 10 * * 1",
|
||||
skill_category="积累",
|
||||
skill_name="false-positive-sample-accumulator",
|
||||
output_format="false_positive_sample_pool",
|
||||
input_sources=["risk_observations", "risk_observation_feedback"],
|
||||
execution_strategy="definition_ready",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE,
|
||||
name="风险观察反馈样本沉淀",
|
||||
description="归集确认、补件、升级、改写和人工复核反馈,形成风险观察反馈样本池。",
|
||||
scenario_json=["schedule", "feedback", "risk_observation", "sample_pool"],
|
||||
owner="风控与审计部",
|
||||
cron="40 10 * * 1",
|
||||
skill_category="积累",
|
||||
skill_name="risk-feedback-sample-accumulator",
|
||||
output_format="risk_feedback_sample_pool",
|
||||
input_sources=["risk_observations", "risk_observation_feedback", "agent_runs"],
|
||||
execution_strategy="definition_ready",
|
||||
),
|
||||
{
|
||||
"code": DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE,
|
||||
"name": "财务风险图谱巡检",
|
||||
@@ -43,6 +153,15 @@ class AgentFoundationDigitalEmployeeTaskMixin:
|
||||
],
|
||||
"output_format": "risk_observation_report",
|
||||
"writes_risk_observations": True,
|
||||
"allowed_outputs": [
|
||||
"facts",
|
||||
"rule_hits",
|
||||
"risk_clues",
|
||||
"evidence_refs",
|
||||
"human_review_required",
|
||||
],
|
||||
"role_boundary": DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY,
|
||||
"writes_rules": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -53,7 +172,7 @@ class AgentFoundationDigitalEmployeeTaskMixin:
|
||||
"owner": "风控与审计部",
|
||||
"reviewer": "顾承宇",
|
||||
"cron": "30 8 * * 1",
|
||||
"skill_category": "评估",
|
||||
"skill_category": "积累",
|
||||
"markdown": self._employee_behavior_profile_scan_skill_markdown,
|
||||
"change_note": "初始化员工行为画像巡检能力。",
|
||||
"config": {
|
||||
@@ -66,30 +185,199 @@ class AgentFoundationDigitalEmployeeTaskMixin:
|
||||
],
|
||||
"output_format": "employee_behavior_profile_snapshot",
|
||||
"writes_profile_snapshots": True,
|
||||
"allowed_outputs": [
|
||||
"facts",
|
||||
"profile_snapshots",
|
||||
"baseline_metrics",
|
||||
"evidence_refs",
|
||||
"human_review_required",
|
||||
],
|
||||
"role_boundary": DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY,
|
||||
"writes_rules": False,
|
||||
},
|
||||
},
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE,
|
||||
name="单据多凭证一致性评估",
|
||||
description="比对报销单、费用明细、发票、流水、合同和事前申请之间的金额、数量、主体和时间字段。",
|
||||
scenario_json=["schedule", "expense", "multi_evidence", "risk_observation"],
|
||||
owner="风控与审计部",
|
||||
cron="15 9 * * *",
|
||||
skill_category="评估",
|
||||
skill_name="multi-evidence-consistency-evaluator",
|
||||
output_format="multi_evidence_consistency_report",
|
||||
input_sources=["expense_claims", "expense_items", "invoices", "attachments"],
|
||||
execution_strategy="reuse_financial_risk_graph_scan",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_SPATIOTEMPORAL_TASK_CODE,
|
||||
name="差旅时空一致性评估",
|
||||
description="评估差旅发生时间、提交时间、票据地点、消费地点、行程轨迹和开票地点是否一致。",
|
||||
scenario_json=["schedule", "travel", "spatiotemporal", "risk_observation"],
|
||||
owner="风控与审计部",
|
||||
cron="30 9 * * *",
|
||||
skill_category="评估",
|
||||
skill_name="travel-spatiotemporal-consistency-evaluator",
|
||||
output_format="spatiotemporal_consistency_report",
|
||||
input_sources=["expense_claims", "expense_items", "invoice_locations", "travel_routes"],
|
||||
execution_strategy="reuse_financial_risk_graph_scan",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_BUDGET_PRECONTROL_TASK_CODE,
|
||||
name="预算占用与超标预警",
|
||||
description="评估预算占用、费用标准、历史基线和柔性控制边界,输出提交前或审批前预警建议。",
|
||||
scenario_json=["schedule", "budget", "expense", "precontrol"],
|
||||
owner="预算管理组",
|
||||
cron="45 9 * * *",
|
||||
skill_category="评估",
|
||||
skill_name="budget-overrun-precontrol-evaluator",
|
||||
output_format="budget_precontrol_warning_report",
|
||||
input_sources=["expense_claims", "budget_snapshots", "policy_refs", "profile_baselines"],
|
||||
execution_strategy="definition_ready",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE,
|
||||
name="供应商异常关系评估",
|
||||
description="识别员工、部门、供应商、票据和报销单之间的异常聚集、重复关系和跨部门集中风险。",
|
||||
scenario_json=["schedule", "supplier", "risk_graph", "relationship"],
|
||||
owner="风控与审计部",
|
||||
cron="0 9 * * 2",
|
||||
skill_category="评估",
|
||||
skill_name="supplier-abnormal-relation-evaluator",
|
||||
output_format="supplier_abnormal_relation_report",
|
||||
input_sources=["risk_graph", "expense_claims", "invoice_entities", "entity_registry"],
|
||||
execution_strategy="reuse_financial_risk_graph_scan",
|
||||
),
|
||||
{
|
||||
"code": DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE,
|
||||
"name": "风险规则候选发现",
|
||||
"description": "按计划复盘风险观察和人工反馈,生成带证据、来源和置信度的候选规则,不直接上线。",
|
||||
"scenario_json": ["schedule", "risk_observation", "feedback", "rule_candidate"],
|
||||
"name": "风险线索归集",
|
||||
"description": "按计划复盘申请、报销、规则命中和人工反馈,归集带事实依据的潜在线索,提交人工复核,不生成规则。",
|
||||
"scenario_json": ["schedule", "application", "reimbursement", "risk_clue"],
|
||||
"owner": "风控与审计部",
|
||||
"reviewer": "顾承宇",
|
||||
"cron": "0 10 * * 1",
|
||||
"skill_category": "升级",
|
||||
"markdown": self._risk_rule_discovery_skill_markdown,
|
||||
"change_note": "初始化风险规则候选发现能力。",
|
||||
"markdown": self._risk_clue_collector_skill_markdown,
|
||||
"change_note": "初始化风险线索归集能力。",
|
||||
"config": {
|
||||
"skill_name": "risk-rule-discovery",
|
||||
"task_type": "risk_clue_collect",
|
||||
"skill_name": "risk-clue-collector",
|
||||
"input_sources": [
|
||||
"risk_observations",
|
||||
"expense_applications",
|
||||
"expense_claims",
|
||||
"rule_hits",
|
||||
"risk_observation_feedback",
|
||||
"algorithm_replay_sets",
|
||||
],
|
||||
"output_format": "candidate_risk_rules",
|
||||
"auto_publish": False,
|
||||
"output_format": "risk_clue_review_packet",
|
||||
"allowed_outputs": [
|
||||
"facts",
|
||||
"rule_hits",
|
||||
"risk_clues",
|
||||
"evidence_refs",
|
||||
"human_review_required",
|
||||
],
|
||||
"role_boundary": DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY,
|
||||
"writes_rules": False,
|
||||
"human_review_required": True,
|
||||
},
|
||||
},
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_ALGORITHM_REPLAY_TASK_CODE,
|
||||
name="风险算法回放评测",
|
||||
description="复跑历史风险观察、反馈标签、本体版本和规则版本,评估算法升级前后的误报率和确认率。",
|
||||
scenario_json=["schedule", "algorithm_replay", "evaluation", "feedback"],
|
||||
owner="风控与审计部",
|
||||
cron="30 10 * * 1",
|
||||
skill_category="升级",
|
||||
skill_name="risk-algorithm-replay-evaluator",
|
||||
output_format="algorithm_replay_evaluation_report",
|
||||
input_sources=["algorithm_replay_sets", "risk_observations", "risk_observation_feedback"],
|
||||
execution_strategy="definition_ready",
|
||||
),
|
||||
self._digital_employee_task_spec(
|
||||
code=DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE,
|
||||
name="制度引用缺口提示",
|
||||
description="整理申请、报销、规则命中和人工反馈中缺少制度引用的事实位置,提示人工补齐制度依据,不输出规则变更建议。",
|
||||
scenario_json=["schedule", "policy_reference", "evidence_gap", "human_review"],
|
||||
owner="财务制度管理组",
|
||||
cron="0 11 * * 1",
|
||||
skill_category="升级",
|
||||
skill_name="policy-reference-gap-hinter",
|
||||
output_format="policy_reference_gap_hint_report",
|
||||
input_sources=["policy_refs", "rule_hits", "expense_claims", "risk_feedback_samples"],
|
||||
execution_strategy="definition_ready",
|
||||
),
|
||||
)
|
||||
|
||||
def _digital_employee_task_spec(
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
name: str,
|
||||
description: str,
|
||||
scenario_json: list[str],
|
||||
owner: str,
|
||||
cron: str,
|
||||
skill_category: str,
|
||||
skill_name: str,
|
||||
output_format: str,
|
||||
input_sources: list[str],
|
||||
execution_strategy: str,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"code": code,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"scenario_json": scenario_json,
|
||||
"owner": owner,
|
||||
"reviewer": "顾承宇",
|
||||
"cron": cron,
|
||||
"skill_category": skill_category,
|
||||
"markdown": lambda: self._generic_digital_employee_skill_markdown(
|
||||
skill_name=skill_name,
|
||||
title=name,
|
||||
description=description,
|
||||
),
|
||||
"change_note": f"初始化{name}能力。",
|
||||
"config": {
|
||||
"skill_name": skill_name,
|
||||
"input_sources": input_sources,
|
||||
"output_format": output_format,
|
||||
"writes_work_record": True,
|
||||
"execution_strategy": execution_strategy,
|
||||
"allowed_outputs": [
|
||||
"facts",
|
||||
"rule_hits",
|
||||
"risk_clues",
|
||||
"evidence_refs",
|
||||
"human_review_required",
|
||||
],
|
||||
"role_boundary": DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY,
|
||||
"writes_rules": False,
|
||||
},
|
||||
}
|
||||
|
||||
def _generic_digital_employee_skill_markdown(
|
||||
self,
|
||||
*,
|
||||
skill_name: str,
|
||||
title: str,
|
||||
description: str,
|
||||
) -> str:
|
||||
return self._read_domain_skill_markdown(
|
||||
skill_name,
|
||||
[
|
||||
"---",
|
||||
f"name: {skill_name}",
|
||||
f"description: {description}",
|
||||
"---",
|
||||
"",
|
||||
f"# {title}",
|
||||
"",
|
||||
"## 功能说明",
|
||||
"",
|
||||
description,
|
||||
],
|
||||
)
|
||||
|
||||
def _upsert_runtime_digital_employee_tasks(self, existing_codes: set[str]) -> None:
|
||||
@@ -146,6 +434,7 @@ class AgentFoundationDigitalEmployeeTaskMixin:
|
||||
cron = str(spec["cron"])
|
||||
base = {
|
||||
**self._digital_employee_task_config(code, cron),
|
||||
"skill_category": str(spec["skill_category"]),
|
||||
"schedule": cron,
|
||||
"cron_expression": cron,
|
||||
**dict(spec["config"]),
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.services.agent_foundation_constants import (
|
||||
logger = get_logger("app.services.agent_foundation")
|
||||
|
||||
EXPENSE_TYPE_SCENARIO_LABELS = {
|
||||
"all": "全部",
|
||||
"travel": "差旅费",
|
||||
"hotel": "住宿费",
|
||||
"transport": "交通费",
|
||||
@@ -158,6 +159,10 @@ class AgentFoundationRiskRuleMixin:
|
||||
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||
candidates.extend(_collect(metadata.get("expense_types")))
|
||||
|
||||
all_scope_values = {"all", "*", "overall", "general", "全部", "通用"}
|
||||
if any(str(item or "").strip().lower() in all_scope_values for item in candidates):
|
||||
return ["all"]
|
||||
|
||||
normalized: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in candidates:
|
||||
@@ -170,6 +175,9 @@ class AgentFoundationRiskRuleMixin:
|
||||
|
||||
@staticmethod
|
||||
def _expense_type_scenario_labels(expense_types: list[str]) -> list[str]:
|
||||
if any(str(item or "").strip().lower() in {"all", "*", "overall", "general"} for item in expense_types):
|
||||
return ["全部"]
|
||||
|
||||
labels: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for expense_type in expense_types:
|
||||
|
||||
530
server/src/app/services/agent_traces.py
Normal file
530
server/src/app/services/agent_traces.py
Normal file
@@ -0,0 +1,530 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.db.base import Base
|
||||
from app.models.agent_conversation import AgentConversationMessage
|
||||
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
|
||||
from app.schemas.agent_run import AgentRunRead, AgentToolCallRead, SemanticParseRead
|
||||
from app.schemas.agent_trace import (
|
||||
AgentConversationTraceRead,
|
||||
AgentTraceDetailRead,
|
||||
AgentTraceEventRead,
|
||||
AgentTraceListItem,
|
||||
)
|
||||
from app.schemas.orchestrator import ConversationMessageRead
|
||||
|
||||
logger = get_logger("app.services.agent_traces")
|
||||
|
||||
|
||||
class AgentTraceService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def ensure_storage_ready(self) -> None:
|
||||
Base.metadata.create_all(bind=self.db.get_bind(), tables=[AgentTraceEvent.__table__])
|
||||
|
||||
def record_event(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
stage: str,
|
||||
event_name: str,
|
||||
title: str,
|
||||
status: str = "succeeded",
|
||||
conversation_id: str | None = None,
|
||||
summary: str | None = None,
|
||||
input_json: dict[str, Any] | None = None,
|
||||
output_json: dict[str, Any] | None = None,
|
||||
error_message: str | None = None,
|
||||
started_at: datetime | None = None,
|
||||
finished_at: datetime | None = None,
|
||||
duration_ms: int | None = None,
|
||||
) -> AgentTraceEventRead:
|
||||
self.ensure_storage_ready()
|
||||
started = _normalize_datetime(started_at) or datetime.now(UTC)
|
||||
finished = _normalize_datetime(finished_at)
|
||||
if finished is None and status != "running":
|
||||
finished = started
|
||||
|
||||
event = AgentTraceEvent(
|
||||
run_id=str(run_id or "").strip(),
|
||||
conversation_id=_optional_text(conversation_id),
|
||||
sequence=self._next_sequence(run_id),
|
||||
stage=str(stage or "orchestrator").strip() or "orchestrator",
|
||||
event_name=str(event_name or "").strip() or "event",
|
||||
title=str(title or event_name or "Trace event").strip(),
|
||||
summary=_optional_text(summary),
|
||||
status=str(status or "succeeded").strip() or "succeeded",
|
||||
input_json=_json_safe(input_json or {}),
|
||||
output_json=_json_safe(output_json or {}),
|
||||
error_message=_optional_text(error_message),
|
||||
started_at=started,
|
||||
finished_at=finished,
|
||||
duration_ms=_resolve_duration_ms(started, finished, duration_ms),
|
||||
)
|
||||
self.db.add(event)
|
||||
self.db.commit()
|
||||
self.db.refresh(event)
|
||||
return AgentTraceEventRead.model_validate(event)
|
||||
|
||||
def record_event_safe(self, **kwargs: Any) -> AgentTraceEventRead | None:
|
||||
try:
|
||||
return self.record_event(**kwargs)
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
logger.exception("Failed to record agent trace event run_id=%s", kwargs.get("run_id"))
|
||||
return None
|
||||
|
||||
def record_tool_event_safe(
|
||||
self,
|
||||
run_id: str,
|
||||
tool_type: str,
|
||||
tool_name: str,
|
||||
request_json: dict[str, Any],
|
||||
response_json: dict[str, Any],
|
||||
status: str,
|
||||
duration_ms: int,
|
||||
context_json: dict[str, Any],
|
||||
error_message: str | None = None,
|
||||
) -> AgentTraceEventRead | None:
|
||||
return self.record_event_safe(
|
||||
run_id=run_id,
|
||||
conversation_id=str(context_json.get("conversation_id") or "").strip() or None,
|
||||
stage="tool",
|
||||
event_name="tool_invoked",
|
||||
title=tool_name,
|
||||
status=status,
|
||||
summary=f"{tool_type} / {status}",
|
||||
input_json=request_json,
|
||||
output_json=response_json,
|
||||
error_message=error_message,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
def list_traces(
|
||||
self,
|
||||
*,
|
||||
agent: str | None = None,
|
||||
status: str | None = None,
|
||||
source: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
keyword: str | None = None,
|
||||
limit: int = 30,
|
||||
) -> list[AgentTraceListItem]:
|
||||
self.ensure_storage_ready()
|
||||
normalized_limit = max(1, min(int(limit or 30), 100))
|
||||
fetch_limit = normalized_limit * 4 if keyword else normalized_limit
|
||||
stmt = select(AgentRun)
|
||||
if agent:
|
||||
stmt = stmt.where(AgentRun.agent == agent)
|
||||
if status:
|
||||
stmt = stmt.where(AgentRun.status == status)
|
||||
if source:
|
||||
stmt = stmt.where(AgentRun.source == source)
|
||||
if conversation_id:
|
||||
run_ids = self._conversation_run_ids(conversation_id)
|
||||
if not run_ids:
|
||||
return []
|
||||
stmt = stmt.where(AgentRun.run_id.in_(run_ids))
|
||||
stmt = stmt.order_by(AgentRun.started_at.desc()).limit(fetch_limit)
|
||||
runs = list(self.db.scalars(stmt).all())
|
||||
event_counts = self._event_counts([run.run_id for run in runs])
|
||||
keyword_text = str(keyword or "").strip().lower()
|
||||
items = [self._build_trace_list_item(run, event_counts.get(run.run_id, 0)) for run in runs]
|
||||
if keyword_text:
|
||||
items = [item for item in items if self._matches_keyword(item, keyword_text)]
|
||||
return items[:normalized_limit]
|
||||
|
||||
def get_trace(self, run_id: str) -> AgentTraceDetailRead | None:
|
||||
self.ensure_storage_ready()
|
||||
normalized_run_id = str(run_id or "").strip()
|
||||
if not normalized_run_id:
|
||||
return None
|
||||
|
||||
run = self.db.scalar(select(AgentRun).where(AgentRun.run_id == normalized_run_id))
|
||||
if run is None:
|
||||
return None
|
||||
|
||||
db_events = list(
|
||||
self.db.scalars(
|
||||
select(AgentTraceEvent)
|
||||
.where(AgentTraceEvent.run_id == normalized_run_id)
|
||||
.order_by(AgentTraceEvent.sequence.asc(), AgentTraceEvent.started_at.asc())
|
||||
).all()
|
||||
)
|
||||
conversation_id = self._resolve_conversation_id(run, db_events)
|
||||
events = [AgentTraceEventRead.model_validate(event) for event in db_events]
|
||||
fallback_generated = False
|
||||
if not events:
|
||||
events = self._build_fallback_events(run, conversation_id)
|
||||
fallback_generated = True
|
||||
|
||||
return AgentTraceDetailRead(
|
||||
run=self._serialize_run(run),
|
||||
conversation_id=conversation_id,
|
||||
events=events,
|
||||
semantic_parse=self._serialize_semantic_parse(self._first_semantic_parse(run)),
|
||||
tool_calls=[AgentToolCallRead.model_validate(item) for item in run.tool_calls],
|
||||
conversation_messages=self._conversation_messages(conversation_id),
|
||||
fallback_generated=fallback_generated,
|
||||
)
|
||||
|
||||
def get_conversation_trace(self, conversation_id: str) -> AgentConversationTraceRead:
|
||||
normalized_conversation_id = str(conversation_id or "").strip()
|
||||
run_ids = self._conversation_run_ids(normalized_conversation_id)
|
||||
details = []
|
||||
for run_id in run_ids:
|
||||
detail = self.get_trace(run_id)
|
||||
if detail is not None:
|
||||
details.append(detail)
|
||||
return AgentConversationTraceRead(
|
||||
conversation_id=normalized_conversation_id,
|
||||
runs=details,
|
||||
)
|
||||
|
||||
def _next_sequence(self, run_id: str) -> int:
|
||||
current = self.db.scalar(
|
||||
select(func.max(AgentTraceEvent.sequence)).where(AgentTraceEvent.run_id == run_id)
|
||||
)
|
||||
return int(current or 0) + 1
|
||||
|
||||
def _event_counts(self, run_ids: list[str]) -> dict[str, int]:
|
||||
if not run_ids:
|
||||
return {}
|
||||
rows = self.db.execute(
|
||||
select(AgentTraceEvent.run_id, func.count(AgentTraceEvent.id))
|
||||
.where(AgentTraceEvent.run_id.in_(run_ids))
|
||||
.group_by(AgentTraceEvent.run_id)
|
||||
).all()
|
||||
return {str(run_id): int(count or 0) for run_id, count in rows}
|
||||
|
||||
def _build_trace_list_item(self, run: AgentRun, event_count: int) -> AgentTraceListItem:
|
||||
semantic_parse = self._first_semantic_parse(run)
|
||||
failed_tools = sum(1 for item in run.tool_calls if item.status == "failed")
|
||||
title = self._resolve_run_title(run, semantic_parse)
|
||||
finished_at = _normalize_datetime(run.finished_at)
|
||||
started_at = _normalize_datetime(run.started_at) or datetime.now(UTC)
|
||||
return AgentTraceListItem(
|
||||
run_id=run.run_id,
|
||||
conversation_id=self._resolve_conversation_id(run, []),
|
||||
agent=run.agent,
|
||||
source=run.source,
|
||||
status=run.status,
|
||||
scenario=semantic_parse.scenario if semantic_parse is not None else None,
|
||||
intent=semantic_parse.intent if semantic_parse is not None else None,
|
||||
title=title,
|
||||
summary=run.result_summary,
|
||||
event_count=event_count,
|
||||
tool_call_count=len(run.tool_calls),
|
||||
failed_tool_call_count=failed_tools,
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
duration_ms=_resolve_duration_ms(started_at, finished_at, None),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _matches_keyword(item: AgentTraceListItem, keyword: str) -> bool:
|
||||
corpus = " ".join(
|
||||
str(value or "")
|
||||
for value in (
|
||||
item.run_id,
|
||||
item.conversation_id,
|
||||
item.agent,
|
||||
item.source,
|
||||
item.status,
|
||||
item.scenario,
|
||||
item.intent,
|
||||
item.title,
|
||||
item.summary,
|
||||
)
|
||||
).lower()
|
||||
return keyword in corpus
|
||||
|
||||
def _resolve_conversation_id(
|
||||
self,
|
||||
run: AgentRun,
|
||||
events: list[AgentTraceEvent],
|
||||
) -> str | None:
|
||||
route_value = (run.route_json or {}).get("conversation_id")
|
||||
if route_value:
|
||||
return str(route_value).strip() or None
|
||||
for event in events:
|
||||
if event.conversation_id:
|
||||
return str(event.conversation_id).strip() or None
|
||||
message = self.db.scalar(
|
||||
select(AgentConversationMessage)
|
||||
.where(AgentConversationMessage.run_id == run.run_id)
|
||||
.order_by(AgentConversationMessage.created_at.asc())
|
||||
)
|
||||
return str(message.conversation_id).strip() if message is not None else None
|
||||
|
||||
def _conversation_run_ids(self, conversation_id: str) -> list[str]:
|
||||
normalized = str(conversation_id or "").strip()
|
||||
if not normalized:
|
||||
return []
|
||||
self.ensure_storage_ready()
|
||||
run_ids: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def append_run_id(value: str | None) -> None:
|
||||
run_id = str(value or "").strip()
|
||||
if run_id and run_id not in seen:
|
||||
seen.add(run_id)
|
||||
run_ids.append(run_id)
|
||||
|
||||
messages = list(
|
||||
self.db.scalars(
|
||||
select(AgentConversationMessage)
|
||||
.where(AgentConversationMessage.conversation_id == normalized)
|
||||
.order_by(AgentConversationMessage.created_at.asc())
|
||||
).all()
|
||||
)
|
||||
for message in messages:
|
||||
append_run_id(message.run_id)
|
||||
|
||||
trace_event_run_ids = list(
|
||||
self.db.scalars(
|
||||
select(AgentTraceEvent.run_id)
|
||||
.where(AgentTraceEvent.conversation_id == normalized)
|
||||
.order_by(AgentTraceEvent.created_at.asc(), AgentTraceEvent.sequence.asc())
|
||||
).all()
|
||||
)
|
||||
for run_id in trace_event_run_ids:
|
||||
append_run_id(run_id)
|
||||
|
||||
recent_runs = list(
|
||||
self.db.scalars(
|
||||
select(AgentRun).order_by(AgentRun.started_at.desc()).limit(500)
|
||||
).all()
|
||||
)
|
||||
for run in reversed(recent_runs):
|
||||
if str((run.route_json or {}).get("conversation_id") or "").strip() == normalized:
|
||||
append_run_id(run.run_id)
|
||||
return run_ids
|
||||
|
||||
def _conversation_messages(self, conversation_id: str | None) -> list[ConversationMessageRead]:
|
||||
if not conversation_id:
|
||||
return []
|
||||
messages = list(
|
||||
self.db.scalars(
|
||||
select(AgentConversationMessage)
|
||||
.where(AgentConversationMessage.conversation_id == conversation_id)
|
||||
.order_by(AgentConversationMessage.created_at.asc())
|
||||
.limit(100)
|
||||
).all()
|
||||
)
|
||||
return [
|
||||
ConversationMessageRead(
|
||||
id=item.id,
|
||||
role=item.role,
|
||||
content=item.content,
|
||||
run_id=item.run_id,
|
||||
message_json=item.message_json or {},
|
||||
created_at=item.created_at,
|
||||
)
|
||||
for item in messages
|
||||
]
|
||||
|
||||
def _build_fallback_events(
|
||||
self,
|
||||
run: AgentRun,
|
||||
conversation_id: str | None,
|
||||
) -> list[AgentTraceEventRead]:
|
||||
events: list[AgentTraceEventRead] = []
|
||||
started_at = _normalize_datetime(run.started_at) or datetime.now(UTC)
|
||||
semantic_parse = self._first_semantic_parse(run)
|
||||
|
||||
def append_event(
|
||||
*,
|
||||
stage: str,
|
||||
event_name: str,
|
||||
title: str,
|
||||
status: str,
|
||||
summary: str | None,
|
||||
started: datetime,
|
||||
finished: datetime | None = None,
|
||||
input_json: dict[str, Any] | None = None,
|
||||
output_json: dict[str, Any] | None = None,
|
||||
error_message: str | None = None,
|
||||
) -> None:
|
||||
sequence = len(events) + 1
|
||||
resolved_finished = finished or started
|
||||
events.append(
|
||||
AgentTraceEventRead(
|
||||
id=f"fallback-{run.run_id}-{sequence}",
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation_id,
|
||||
sequence=sequence,
|
||||
stage=stage,
|
||||
event_name=event_name,
|
||||
title=title,
|
||||
summary=summary,
|
||||
status=status,
|
||||
input_json=_json_safe(input_json or {}),
|
||||
output_json=_json_safe(output_json or {}),
|
||||
error_message=error_message,
|
||||
started_at=started,
|
||||
finished_at=resolved_finished,
|
||||
duration_ms=_resolve_duration_ms(started, resolved_finished, None),
|
||||
created_at=started,
|
||||
)
|
||||
)
|
||||
|
||||
append_event(
|
||||
stage="orchestrator",
|
||||
event_name="run_created",
|
||||
title="运行记录",
|
||||
status="succeeded",
|
||||
summary="由历史 AgentRun 合成的 trace 起点。",
|
||||
started=started_at,
|
||||
output_json={"agent": run.agent, "source": run.source, "status": run.status},
|
||||
)
|
||||
if semantic_parse is not None:
|
||||
append_event(
|
||||
stage="semantic",
|
||||
event_name="semantic_parsed",
|
||||
title="语义解析",
|
||||
status="succeeded",
|
||||
summary=f"{semantic_parse.scenario} / {semantic_parse.intent}",
|
||||
started=_normalize_datetime(semantic_parse.created_at) or started_at,
|
||||
input_json={"raw_query": semantic_parse.raw_query},
|
||||
output_json=self._semantic_parse_payload(semantic_parse),
|
||||
)
|
||||
if run.route_json:
|
||||
append_event(
|
||||
stage="route",
|
||||
event_name="route_resolved",
|
||||
title="路由上下文",
|
||||
status="succeeded",
|
||||
summary=str(run.route_json.get("route_reason") or run.route_json.get("stage") or "已记录路由信息"),
|
||||
started=started_at,
|
||||
output_json=run.route_json,
|
||||
)
|
||||
for tool_call in run.tool_calls:
|
||||
append_event(
|
||||
stage="tool",
|
||||
event_name="tool_invoked",
|
||||
title=tool_call.tool_name,
|
||||
status=tool_call.status,
|
||||
summary=f"{tool_call.tool_type} / {tool_call.status}",
|
||||
started=_normalize_datetime(tool_call.created_at) or started_at,
|
||||
finished=_normalize_datetime(tool_call.created_at) or started_at,
|
||||
input_json=tool_call.request_json,
|
||||
output_json=tool_call.response_json,
|
||||
error_message=tool_call.error_message,
|
||||
)
|
||||
append_event(
|
||||
stage="response",
|
||||
event_name="response_built" if run.status != "failed" else "failed",
|
||||
title="最终结果",
|
||||
status=run.status,
|
||||
summary=run.result_summary or run.error_message,
|
||||
started=_normalize_datetime(run.finished_at) or started_at,
|
||||
output_json={"result_summary": run.result_summary},
|
||||
error_message=run.error_message,
|
||||
)
|
||||
return events
|
||||
|
||||
@staticmethod
|
||||
def _resolve_run_title(run: AgentRun, semantic_parse: SemanticParseLog | None) -> str:
|
||||
if semantic_parse is not None:
|
||||
return f"{semantic_parse.scenario} / {semantic_parse.intent}"
|
||||
route_json = run.route_json or {}
|
||||
return str(route_json.get("task_name") or route_json.get("selected_agent") or run.agent)
|
||||
|
||||
@staticmethod
|
||||
def _first_semantic_parse(run: AgentRun) -> SemanticParseLog | None:
|
||||
return run.semantic_parse_logs[0] if run.semantic_parse_logs else None
|
||||
|
||||
@staticmethod
|
||||
def _serialize_semantic_parse(item: SemanticParseLog | None) -> SemanticParseRead | None:
|
||||
return SemanticParseRead.model_validate(item) if item is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _serialize_run(run: AgentRun) -> AgentRunRead:
|
||||
semantic_parse = AgentTraceService._first_semantic_parse(run)
|
||||
return AgentRunRead(
|
||||
id=run.id,
|
||||
run_id=run.run_id,
|
||||
agent=run.agent,
|
||||
source=run.source,
|
||||
user_id=run.user_id,
|
||||
task_id=run.task_id,
|
||||
ontology_json=run.ontology_json,
|
||||
route_json=run.route_json,
|
||||
permission_level=run.permission_level,
|
||||
status=run.status,
|
||||
result_summary=run.result_summary,
|
||||
error_message=run.error_message,
|
||||
started_at=run.started_at,
|
||||
finished_at=run.finished_at,
|
||||
tool_calls=[AgentToolCallRead.model_validate(item) for item in run.tool_calls],
|
||||
semantic_parse=SemanticParseRead.model_validate(semantic_parse)
|
||||
if semantic_parse is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _semantic_parse_payload(item: SemanticParseLog) -> dict[str, Any]:
|
||||
return {
|
||||
"scenario": item.scenario,
|
||||
"intent": item.intent,
|
||||
"confidence": item.confidence,
|
||||
"entities": item.entities_json,
|
||||
"time_range": item.time_range_json,
|
||||
"metrics": item.metrics_json,
|
||||
"constraints": item.constraints_json,
|
||||
"risk_flags": item.risk_flags_json,
|
||||
"permission": item.permission_json,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_duration_ms(
|
||||
started_at: datetime | None,
|
||||
finished_at: datetime | None,
|
||||
duration_ms: int | None,
|
||||
) -> int:
|
||||
if duration_ms is not None:
|
||||
return max(0, int(duration_ms or 0))
|
||||
if started_at is None or finished_at is None:
|
||||
return 0
|
||||
try:
|
||||
return max(0, int((finished_at - started_at).total_seconds() * 1000))
|
||||
except TypeError:
|
||||
return 0
|
||||
|
||||
|
||||
def _normalize_datetime(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value
|
||||
|
||||
|
||||
def _optional_text(value: Any) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [_json_safe(item) for item in value]
|
||||
if hasattr(value, "model_dump"):
|
||||
return _json_safe(value.model_dump())
|
||||
return str(value)
|
||||
231
server/src/app/services/application_system_estimate.py
Normal file
231
server/src/app/services/application_system_estimate.py
Normal file
@@ -0,0 +1,231 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
|
||||
LOCATION_BANDS = {
|
||||
"premium": ("北京", "上海", "广州", "深圳", "杭州", "南京", "苏州", "成都", "重庆", "天津"),
|
||||
"remote": ("新疆", "西藏", "青海", "甘肃", "宁夏", "内蒙古", "海南", "香港", "澳门", "台湾", "海外", "国外"),
|
||||
"coastal": ("上海", "广州", "深圳", "厦门", "福州", "青岛", "大连", "宁波", "舟山", "海口", "三亚", "天津"),
|
||||
}
|
||||
|
||||
TRANSPORT_PRICE_BASE = {
|
||||
"火车": {"default": Decimal("360"), "premium": Decimal("520"), "remote": Decimal("900"), "coastal": Decimal("520")},
|
||||
"飞机": {"default": Decimal("850"), "premium": Decimal("1100"), "remote": Decimal("1800"), "coastal": Decimal("1050")},
|
||||
"轮船": {"default": Decimal("320"), "premium": Decimal("480"), "remote": Decimal("680"), "coastal": Decimal("520")},
|
||||
}
|
||||
|
||||
LODGING_DAILY_BASE = {
|
||||
"default": Decimal("420"),
|
||||
"premium": Decimal("600"),
|
||||
"remote": Decimal("520"),
|
||||
"coastal": Decimal("500"),
|
||||
}
|
||||
|
||||
ALLOWANCE_DAILY_BASE = {
|
||||
"default": Decimal("100"),
|
||||
"premium": Decimal("120"),
|
||||
"remote": Decimal("120"),
|
||||
"coastal": Decimal("110"),
|
||||
}
|
||||
|
||||
|
||||
def parse_application_days(days_text: str) -> int:
|
||||
match = re.search(r"\d+", str(days_text or ""))
|
||||
if not match:
|
||||
return 1
|
||||
return max(1, int(match.group(0)))
|
||||
|
||||
|
||||
def parse_application_money(value: object) -> Decimal:
|
||||
normalized = re.sub(r"[^\d.\-]", "", str(value or "").replace(",", ""))
|
||||
if not normalized:
|
||||
return Decimal("0")
|
||||
try:
|
||||
return Decimal(normalized)
|
||||
except (InvalidOperation, ValueError):
|
||||
return Decimal("0")
|
||||
|
||||
|
||||
def format_application_money(value: Decimal | int | float | str) -> str:
|
||||
amount = parse_application_money(value) if not isinstance(value, Decimal) else value
|
||||
quantized = amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if quantized == quantized.to_integral():
|
||||
return f"{int(quantized):,}"
|
||||
return f"{quantized:,.2f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def normalize_application_transport_mode(value: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if re.search(r"飞机|机票|航班|乘机|坐飞机", text):
|
||||
return "飞机"
|
||||
if re.search(r"轮船|船票|客轮|渡轮|邮轮|坐船", text):
|
||||
return "轮船"
|
||||
if re.search(r"火车|高铁|动车|铁路|列车", text):
|
||||
return "火车"
|
||||
return text if text in TRANSPORT_PRICE_BASE else ""
|
||||
|
||||
|
||||
def resolve_application_location_band(location: str) -> str:
|
||||
text = str(location or "").strip()
|
||||
if any(keyword in text for keyword in LOCATION_BANDS["remote"]):
|
||||
return "remote"
|
||||
if any(keyword in text for keyword in LOCATION_BANDS["premium"]):
|
||||
return "premium"
|
||||
if any(keyword in text for keyword in LOCATION_BANDS["coastal"]):
|
||||
return "coastal"
|
||||
return "default"
|
||||
|
||||
|
||||
def _round_to_ten(value: Decimal) -> Decimal:
|
||||
return (value / Decimal("10")).quantize(Decimal("1"), rounding=ROUND_HALF_UP) * Decimal("10")
|
||||
|
||||
|
||||
def parse_application_start_date(time_text: object) -> str:
|
||||
match = re.search(r"(20\d{2})[年\-/.](\d{1,2})[月\-/.](\d{1,2})", str(time_text or ""))
|
||||
if not match:
|
||||
return ""
|
||||
try:
|
||||
return date(int(match.group(1)), int(match.group(2)), int(match.group(3))).isoformat()
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_ticket_price_factor(query_date: str) -> Decimal:
|
||||
if not query_date:
|
||||
return Decimal("1.00")
|
||||
try:
|
||||
parsed = date.fromisoformat(query_date)
|
||||
except ValueError:
|
||||
return Decimal("1.00")
|
||||
|
||||
factor = Decimal("1.00")
|
||||
if parsed.weekday() == 0:
|
||||
factor += Decimal("0.04")
|
||||
if parsed.weekday() in (4, 6):
|
||||
factor += Decimal("0.08")
|
||||
if parsed.month in {1, 2, 7, 8, 10}:
|
||||
factor += Decimal("0.06")
|
||||
|
||||
jitter = (parsed.year + parsed.month * 13 + parsed.day * 7) % 7 - 3
|
||||
factor += Decimal(jitter) / Decimal("100")
|
||||
if factor < Decimal("0.88"):
|
||||
return Decimal("0.88")
|
||||
if factor > Decimal("1.22"):
|
||||
return Decimal("1.22")
|
||||
return factor
|
||||
|
||||
|
||||
def _resolve_mock_query_latency_ms(query_date: str, mode: str, location_band: str) -> int:
|
||||
try:
|
||||
parsed = date.fromisoformat(query_date) if query_date else None
|
||||
except ValueError:
|
||||
parsed = None
|
||||
seed = len(mode) * 43 + len(location_band) * 29
|
||||
if parsed:
|
||||
seed += parsed.year + parsed.month * 17 + parsed.day * 31
|
||||
return 360 + seed % 420
|
||||
|
||||
|
||||
def build_application_system_estimate(
|
||||
*,
|
||||
transport_mode: str,
|
||||
location: str,
|
||||
days_text: str,
|
||||
time_text: object = "",
|
||||
lodging_amount: object = None,
|
||||
allowance_amount: object = None,
|
||||
) -> dict[str, str]:
|
||||
mode = normalize_application_transport_mode(transport_mode)
|
||||
if not mode:
|
||||
return {}
|
||||
|
||||
days = parse_application_days(days_text)
|
||||
location_band = resolve_application_location_band(location)
|
||||
query_date = parse_application_start_date(time_text)
|
||||
price_factor = _resolve_ticket_price_factor(query_date)
|
||||
simulated_latency_ms = _resolve_mock_query_latency_ms(query_date, mode, location_band)
|
||||
transport_one_way = TRANSPORT_PRICE_BASE[mode].get(location_band, TRANSPORT_PRICE_BASE[mode]["default"])
|
||||
transport_amount = _round_to_ten(transport_one_way * Decimal("2") * price_factor)
|
||||
|
||||
lodging = parse_application_money(lodging_amount)
|
||||
allowance = parse_application_money(allowance_amount)
|
||||
lodging_daily = LODGING_DAILY_BASE.get(location_band, LODGING_DAILY_BASE["default"])
|
||||
allowance_daily = ALLOWANCE_DAILY_BASE.get(location_band, ALLOWANCE_DAILY_BASE["default"])
|
||||
if lodging <= 0:
|
||||
lodging = lodging_daily * days
|
||||
if allowance <= 0:
|
||||
allowance = allowance_daily * days
|
||||
|
||||
total_amount = transport_amount + lodging + allowance
|
||||
transport_display = format_application_money(transport_amount)
|
||||
lodging_display = format_application_money(lodging)
|
||||
allowance_display = format_application_money(allowance)
|
||||
total_display = format_application_money(total_amount)
|
||||
band_label = {
|
||||
"premium": "一线/高频城市",
|
||||
"remote": "远途地区",
|
||||
"coastal": "沿海城市",
|
||||
"default": "普通城市",
|
||||
}[location_band]
|
||||
query_label = query_date or "出行日期待确认"
|
||||
|
||||
return {
|
||||
"amount": f"{total_display}元",
|
||||
"lodging_daily_cap": f"{format_application_money(lodging_daily)}元/天",
|
||||
"subsidy_daily_cap": f"{format_application_money(allowance_daily)}元/天",
|
||||
"transport_policy": (
|
||||
f"已查询 {query_label} {mode}参考票价,按{band_label}往返 {transport_display}元预估"
|
||||
f"(查询耗时 {simulated_latency_ms}ms),报销阶段按真实票据复核"
|
||||
),
|
||||
"policy_estimate": (
|
||||
f"交通 {transport_display}元(按 {query_label} 参考票价) + 住宿 {lodging_display}元"
|
||||
f" + 补贴 {allowance_display}元 = {total_display}元({days}天)"
|
||||
),
|
||||
"matched_city": str(location or "").strip(),
|
||||
"transport_estimated_amount": f"{transport_display}元",
|
||||
"transport_estimate_date": query_date,
|
||||
"transport_query_latency_ms": str(simulated_latency_ms),
|
||||
"policy_total_amount": f"{total_display}元",
|
||||
"estimate_source": "mock_ticket_price_query_v1",
|
||||
"estimate_confidence": "mock",
|
||||
}
|
||||
|
||||
|
||||
def _is_pending_application_amount(value: str) -> bool:
|
||||
normalized = str(value or "").strip()
|
||||
return not normalized or normalized in {"待测算", "待补充", "未知"}
|
||||
|
||||
|
||||
def apply_application_system_estimate_to_facts(facts: dict[str, str]) -> None:
|
||||
estimate = build_application_system_estimate(
|
||||
transport_mode=str(facts.get("transport_mode") or ""),
|
||||
location=str(facts.get("matched_city") or facts.get("location") or ""),
|
||||
days_text=str(facts.get("days") or ""),
|
||||
time_text=facts.get("time") or "",
|
||||
lodging_amount=facts.get("hotel_amount") or None,
|
||||
allowance_amount=facts.get("allowance_amount") or None,
|
||||
)
|
||||
if not estimate:
|
||||
return
|
||||
|
||||
if _is_pending_application_amount(facts.get("amount", "")):
|
||||
facts["amount"] = estimate["amount"]
|
||||
|
||||
field_map = {
|
||||
"lodging_daily_cap": "lodging_daily_cap",
|
||||
"subsidy_daily_cap": "subsidy_daily_cap",
|
||||
"transport_policy": "transport_policy",
|
||||
"policy_estimate": "policy_estimate",
|
||||
"matched_city": "matched_city",
|
||||
"transport_estimated_amount": "transport_estimated_amount",
|
||||
"transport_estimate_date": "transport_estimate_date",
|
||||
"transport_query_latency_ms": "transport_query_latency_ms",
|
||||
"transport_estimate_source": "estimate_source",
|
||||
"transport_estimate_confidence": "estimate_confidence",
|
||||
"policy_total_amount": "policy_total_amount",
|
||||
}
|
||||
for target, source in field_map.items():
|
||||
if not str(facts.get(target) or "").strip() and estimate.get(source):
|
||||
facts[target] = estimate[source]
|
||||
@@ -81,6 +81,20 @@ class AuthService:
|
||||
session = UserSessionMetricService(self.db).start_session(user)
|
||||
return LoginResponse(user=self._serialize_user(user), sessionId=session.session_id)
|
||||
|
||||
def get_user_snapshot(self, identifier: str) -> AuthUserRead | None:
|
||||
normalized = identifier.strip()
|
||||
if not normalized or not self.settings.setup_completed:
|
||||
return None
|
||||
|
||||
employee = self._find_employee_by_email(normalized)
|
||||
if employee is None:
|
||||
EmployeeService(self.db).ensure_directory_ready()
|
||||
employee = self._find_employee_by_email(normalized)
|
||||
if employee is None or employee.employment_status == "停用":
|
||||
return None
|
||||
|
||||
return self._serialize_user(self._build_employee_user(employee))
|
||||
|
||||
def _authenticate_admin(self, identifier: str, password: str) -> AuthenticatedUser | None:
|
||||
record = SettingsService(self.db).verify_admin_login(identifier, password)
|
||||
if record is None:
|
||||
@@ -114,17 +128,7 @@ class AuthService:
|
||||
return None
|
||||
|
||||
EmployeeService(self.db).ensure_directory_ready()
|
||||
|
||||
stmt = (
|
||||
select(Employee)
|
||||
.options(
|
||||
selectinload(Employee.organization_unit),
|
||||
selectinload(Employee.manager),
|
||||
selectinload(Employee.roles),
|
||||
)
|
||||
.where(func.lower(Employee.email) == identifier.lower())
|
||||
)
|
||||
employee = self.db.execute(stmt).scalars().first()
|
||||
employee = self._find_employee_by_email(identifier)
|
||||
|
||||
if employee is None or not employee.password_hash:
|
||||
return None
|
||||
@@ -136,6 +140,21 @@ class AuthService:
|
||||
if not verify_password(password, employee.password_hash):
|
||||
return None
|
||||
|
||||
return self._build_employee_user(employee)
|
||||
|
||||
def _find_employee_by_email(self, identifier: str) -> Employee | None:
|
||||
stmt = (
|
||||
select(Employee)
|
||||
.options(
|
||||
selectinload(Employee.organization_unit),
|
||||
selectinload(Employee.manager),
|
||||
selectinload(Employee.roles),
|
||||
)
|
||||
.where(func.lower(Employee.email) == identifier.lower())
|
||||
)
|
||||
return self.db.execute(stmt).scalars().first()
|
||||
|
||||
def _build_employee_user(self, employee: Employee) -> AuthenticatedUser:
|
||||
sorted_roles = sorted(
|
||||
list(employee.roles),
|
||||
key=lambda item: (ROLE_DISPLAY_ORDER.get(item.role_code, 999), item.name),
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.services.budget_types import (
|
||||
SUPPORTED_BUDGET_SUBJECT_CODES,
|
||||
)
|
||||
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
|
||||
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
|
||||
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
|
||||
|
||||
|
||||
@@ -604,7 +605,12 @@ class BudgetSupportMixin:
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
payload.update(extra or {})
|
||||
return payload
|
||||
return enrich_risk_flag_semantics(
|
||||
payload,
|
||||
risk_domain="budget",
|
||||
visibility_scope="budget_manager",
|
||||
actionability="budget_governance",
|
||||
)
|
||||
|
||||
def _build_operation_flag(
|
||||
self,
|
||||
|
||||
637
server/src/app/services/digital_employee_dashboard.py
Normal file
637
server/src/app/services/digital_employee_dashboard.py
Normal file
@@ -0,0 +1,637 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.core.agent_enums import AgentName, AgentRunSource
|
||||
from app.db.base import Base
|
||||
from app.models.agent_run import AgentRun, AgentToolCall
|
||||
from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead
|
||||
|
||||
SUCCESS_STATUSES = {"success", "succeeded", "ok", "done", "completed"}
|
||||
FAILED_STATUSES = {"failed", "failure", "error", "errored"}
|
||||
RUNNING_STATUSES = {"running", "pending"}
|
||||
|
||||
TASK_CODE_TO_TYPE = {
|
||||
"task.hermes.global_risk_scan": "global_risk_scan",
|
||||
"task.hermes.employee_behavior_profile_scan": "employee_behavior_profile_scan",
|
||||
"task.hermes.risk_rule_discovery": "risk_clue_collect",
|
||||
"task.hermes.finance_policy_knowledge_organize": "finance_policy_knowledge_organize",
|
||||
"task.hermes.finance_policy_clause_extract": "finance_policy_clause_extract",
|
||||
"task.hermes.expense_policy_alignment": "expense_policy_alignment",
|
||||
"task.hermes.risk_rule_template_organize": "risk_rule_template_organize",
|
||||
"task.hermes.department_expense_baseline_accumulate": "department_expense_baseline_accumulate",
|
||||
"task.hermes.supplier_risk_profile_accumulate": "supplier_risk_profile_accumulate",
|
||||
"task.hermes.false_positive_sample_accumulate": "false_positive_sample_accumulate",
|
||||
"task.hermes.risk_feedback_sample_accumulate": "risk_feedback_sample_accumulate",
|
||||
"task.hermes.multi_evidence_consistency_evaluate": "multi_evidence_consistency_evaluate",
|
||||
"task.hermes.travel_spatiotemporal_consistency_evaluate": "travel_spatiotemporal_consistency_evaluate",
|
||||
"task.hermes.budget_overrun_precontrol_evaluate": "budget_overrun_precontrol_evaluate",
|
||||
"task.hermes.supplier_abnormal_relation_evaluate": "supplier_abnormal_relation_evaluate",
|
||||
"task.hermes.risk_algorithm_replay_evaluate": "risk_algorithm_replay_evaluate",
|
||||
"task.hermes.policy_gap_rule_optimization": "policy_gap_rule_optimization",
|
||||
}
|
||||
|
||||
TASK_SPECS: dict[str, dict[str, str]] = {
|
||||
"global_risk_scan": {
|
||||
"label": "财务风险图谱巡检",
|
||||
"category": "评估",
|
||||
"color": "var(--theme-primary)",
|
||||
},
|
||||
"employee_behavior_profile_scan": {
|
||||
"label": "员工行为画像巡检",
|
||||
"category": "积累",
|
||||
"color": "var(--chart-blue)",
|
||||
},
|
||||
"risk_clue_collect": {
|
||||
"label": "风险线索归集",
|
||||
"category": "升级",
|
||||
"color": "var(--chart-amber)",
|
||||
},
|
||||
"finance_policy_knowledge_organize": {
|
||||
"label": "知识制度整理",
|
||||
"category": "整理",
|
||||
"color": "var(--success)",
|
||||
},
|
||||
"knowledge_index_sync": {
|
||||
"label": "知识制度整理",
|
||||
"category": "整理",
|
||||
"color": "var(--success)",
|
||||
},
|
||||
"llm_wiki_sync": {
|
||||
"label": "知识制度整理",
|
||||
"category": "整理",
|
||||
"color": "var(--success)",
|
||||
},
|
||||
"llm_wiki_rule_formation": {
|
||||
"label": "知识制度整理",
|
||||
"category": "整理",
|
||||
"color": "var(--success)",
|
||||
},
|
||||
"finance_policy_clause_extract": {
|
||||
"label": "制度条款结构化抽取",
|
||||
"category": "整理",
|
||||
"color": "var(--success)",
|
||||
},
|
||||
"expense_policy_alignment": {
|
||||
"label": "报销政策口径对齐",
|
||||
"category": "整理",
|
||||
"color": "var(--success)",
|
||||
},
|
||||
"risk_rule_template_organize": {
|
||||
"label": "规则命中样本整理",
|
||||
"category": "整理",
|
||||
"color": "var(--success)",
|
||||
},
|
||||
"department_expense_baseline_accumulate": {
|
||||
"label": "部门费用基线沉淀",
|
||||
"category": "积累",
|
||||
"color": "var(--chart-blue)",
|
||||
},
|
||||
"supplier_risk_profile_accumulate": {
|
||||
"label": "供应商风险画像沉淀",
|
||||
"category": "积累",
|
||||
"color": "var(--chart-blue)",
|
||||
},
|
||||
"false_positive_sample_accumulate": {
|
||||
"label": "历史误报样本沉淀",
|
||||
"category": "积累",
|
||||
"color": "var(--chart-blue)",
|
||||
},
|
||||
"risk_feedback_sample_accumulate": {
|
||||
"label": "风险观察反馈样本沉淀",
|
||||
"category": "积累",
|
||||
"color": "var(--chart-blue)",
|
||||
},
|
||||
"multi_evidence_consistency_evaluate": {
|
||||
"label": "多源证据一致性评估",
|
||||
"category": "评估",
|
||||
"color": "var(--theme-primary)",
|
||||
},
|
||||
"travel_spatiotemporal_consistency_evaluate": {
|
||||
"label": "差旅时空一致性评估",
|
||||
"category": "评估",
|
||||
"color": "var(--theme-primary)",
|
||||
},
|
||||
"budget_overrun_precontrol_evaluate": {
|
||||
"label": "预算超限预警评估",
|
||||
"category": "评估",
|
||||
"color": "var(--theme-primary)",
|
||||
},
|
||||
"supplier_abnormal_relation_evaluate": {
|
||||
"label": "供应商异常关系评估",
|
||||
"category": "评估",
|
||||
"color": "var(--theme-primary)",
|
||||
},
|
||||
"risk_algorithm_replay_evaluate": {
|
||||
"label": "风险算法回放升级",
|
||||
"category": "升级",
|
||||
"color": "var(--chart-amber)",
|
||||
},
|
||||
"policy_gap_rule_optimization": {
|
||||
"label": "制度缺口优化建议",
|
||||
"category": "升级",
|
||||
"color": "var(--chart-amber)",
|
||||
},
|
||||
}
|
||||
|
||||
CATEGORY_SPECS = {
|
||||
"积累": {"color": "var(--chart-blue)", "description": "沉淀画像、基线和反馈样本"},
|
||||
"升级": {"color": "var(--chart-amber)", "description": "输出待复核线索和优化建议"},
|
||||
"整理": {"color": "var(--success)", "description": "整理制度、条款、知识和样本"},
|
||||
"评估": {"color": "var(--theme-primary)", "description": "评估异常、风险和一致性"},
|
||||
}
|
||||
|
||||
|
||||
class DigitalEmployeeDashboardService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def build_dashboard(self, *, days: int = 7, limit: int = 300) -> DigitalEmployeeDashboardRead:
|
||||
window_days = max(1, min(int(days or 7), 30))
|
||||
window_limit = max(1, min(int(limit or 300), 1000))
|
||||
self._ensure_storage_ready()
|
||||
now = datetime.now(UTC)
|
||||
start = now - timedelta(days=window_days - 1)
|
||||
labels = self._date_labels(start.date(), window_days)
|
||||
|
||||
all_runs = self._fetch_runs(start=start, limit=window_limit)
|
||||
runs = [run for run in all_runs if self._is_digital_employee_run(run)]
|
||||
totals = self._build_totals(runs)
|
||||
|
||||
return DigitalEmployeeDashboardRead(
|
||||
window_days=window_days,
|
||||
generated_at=now.isoformat(),
|
||||
has_real_data=bool(runs),
|
||||
totals=totals,
|
||||
daily_work=self._daily_work(labels, runs),
|
||||
task_distribution=self._task_distribution(runs),
|
||||
category_distribution=self._category_distribution(runs),
|
||||
recent_runs=self._recent_runs(runs),
|
||||
)
|
||||
|
||||
def _ensure_storage_ready(self) -> None:
|
||||
Base.metadata.create_all(bind=self.db.get_bind())
|
||||
|
||||
def _fetch_runs(self, *, start: datetime, limit: int) -> list[AgentRun]:
|
||||
stmt = (
|
||||
select(AgentRun)
|
||||
.options(selectinload(AgentRun.tool_calls))
|
||||
.where(
|
||||
AgentRun.started_at >= start,
|
||||
or_(
|
||||
AgentRun.agent == AgentName.HERMES.value,
|
||||
AgentRun.source == AgentRunSource.SCHEDULE.value,
|
||||
),
|
||||
)
|
||||
.order_by(AgentRun.started_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def _build_totals(self, runs: list[AgentRun]) -> dict[str, Any]:
|
||||
metrics = self._sum_metrics(runs)
|
||||
success_runs = sum(1 for run in runs if self._is_success(run.status))
|
||||
failed_runs = sum(1 for run in runs if self._is_failed(run.status))
|
||||
running_runs = sum(1 for run in runs if self._is_running(run.status))
|
||||
total_runs = len(runs)
|
||||
business_outputs = (
|
||||
metrics["risk_observations"]
|
||||
+ metrics["risk_clues"]
|
||||
+ metrics["profile_snapshots"]
|
||||
+ metrics["knowledge_documents"]
|
||||
)
|
||||
|
||||
return {
|
||||
"totalRuns": total_runs,
|
||||
"successRuns": success_runs,
|
||||
"failedRuns": failed_runs,
|
||||
"runningRuns": running_runs,
|
||||
"toolCalls": sum(len(run.tool_calls) for run in runs),
|
||||
"businessOutputs": business_outputs,
|
||||
"riskObservations": metrics["risk_observations"],
|
||||
"riskClues": metrics["risk_clues"],
|
||||
"profileSnapshots": metrics["profile_snapshots"],
|
||||
"knowledgeDocuments": metrics["knowledge_documents"],
|
||||
"successRate": self._percent(success_runs, total_runs),
|
||||
"failureRate": self._percent(failed_runs, total_runs),
|
||||
}
|
||||
|
||||
def _daily_work(self, labels: list[str], runs: list[AgentRun]) -> list[dict[str, Any]]:
|
||||
rows = {
|
||||
label: {
|
||||
"date": label,
|
||||
"total": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"running": 0,
|
||||
"riskObservations": 0,
|
||||
"riskClues": 0,
|
||||
"profileSnapshots": 0,
|
||||
"knowledgeDocuments": 0,
|
||||
"businessOutputs": 0,
|
||||
}
|
||||
for label in labels
|
||||
}
|
||||
|
||||
for run in runs:
|
||||
label = self._date_label(run.started_at)
|
||||
if label not in rows:
|
||||
continue
|
||||
row = rows[label]
|
||||
metrics = self._extract_run_metrics(run)
|
||||
row["total"] += 1
|
||||
if self._is_success(run.status):
|
||||
row["success"] += 1
|
||||
elif self._is_failed(run.status):
|
||||
row["failed"] += 1
|
||||
elif self._is_running(run.status):
|
||||
row["running"] += 1
|
||||
row["riskObservations"] += metrics["risk_observations"]
|
||||
row["riskClues"] += metrics["risk_clues"]
|
||||
row["profileSnapshots"] += metrics["profile_snapshots"]
|
||||
row["knowledgeDocuments"] += metrics["knowledge_documents"]
|
||||
row["businessOutputs"] += (
|
||||
metrics["risk_observations"]
|
||||
+ metrics["risk_clues"]
|
||||
+ metrics["profile_snapshots"]
|
||||
+ metrics["knowledge_documents"]
|
||||
)
|
||||
|
||||
return [rows[label] for label in labels]
|
||||
|
||||
def _task_distribution(self, runs: list[AgentRun]) -> list[dict[str, Any]]:
|
||||
buckets: dict[str, dict[str, Any]] = {}
|
||||
for run in runs:
|
||||
task_type = self._resolve_task_type(run)
|
||||
spec = self._task_spec(task_type)
|
||||
bucket = buckets.setdefault(
|
||||
task_type or "unknown",
|
||||
{
|
||||
"taskType": task_type or "unknown",
|
||||
"name": spec["label"],
|
||||
"category": spec["category"],
|
||||
"count": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"value": 0,
|
||||
"color": spec["color"],
|
||||
},
|
||||
)
|
||||
bucket["count"] += 1
|
||||
bucket["value"] += 1
|
||||
if self._is_success(run.status):
|
||||
bucket["success"] += 1
|
||||
elif self._is_failed(run.status):
|
||||
bucket["failed"] += 1
|
||||
|
||||
return sorted(buckets.values(), key=lambda item: (-item["count"], item["name"]))[:8]
|
||||
|
||||
def _category_distribution(self, runs: list[AgentRun]) -> list[dict[str, Any]]:
|
||||
rows = {
|
||||
category: {
|
||||
"name": category,
|
||||
"value": 0,
|
||||
"count": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"color": spec["color"],
|
||||
"description": spec["description"],
|
||||
}
|
||||
for category, spec in CATEGORY_SPECS.items()
|
||||
}
|
||||
for run in runs:
|
||||
category = self._task_spec(self._resolve_task_type(run))["category"]
|
||||
row = rows.setdefault(
|
||||
category,
|
||||
{
|
||||
"name": category,
|
||||
"value": 0,
|
||||
"count": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"color": "var(--theme-primary)",
|
||||
"description": "其他数字员工工作",
|
||||
},
|
||||
)
|
||||
row["value"] += 1
|
||||
row["count"] += 1
|
||||
if self._is_success(run.status):
|
||||
row["success"] += 1
|
||||
elif self._is_failed(run.status):
|
||||
row["failed"] += 1
|
||||
return list(rows.values())
|
||||
|
||||
def _recent_runs(self, runs: list[AgentRun]) -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for run in sorted(runs, key=lambda item: item.started_at, reverse=True)[:8]:
|
||||
task_type = self._resolve_task_type(run)
|
||||
spec = self._task_spec(task_type)
|
||||
rows.append(
|
||||
{
|
||||
"runId": run.run_id,
|
||||
"taskType": task_type or "unknown",
|
||||
"taskLabel": spec["label"],
|
||||
"category": spec["category"],
|
||||
"status": run.status,
|
||||
"statusLabel": self._status_label(run.status),
|
||||
"statusTone": self._status_tone(run.status),
|
||||
"source": run.source,
|
||||
"sourceLabel": self._source_label(run.source),
|
||||
"startedAt": self._iso(run.started_at),
|
||||
"finishedAt": self._iso(run.finished_at),
|
||||
"durationMs": self._duration_ms(run),
|
||||
"summary": self._summary_text(run),
|
||||
"metrics": self._extract_run_metrics(run),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
def _sum_metrics(self, runs: list[AgentRun]) -> dict[str, int]:
|
||||
totals = self._empty_metrics()
|
||||
for run in runs:
|
||||
metrics = self._extract_run_metrics(run)
|
||||
for key in totals:
|
||||
totals[key] += int(metrics.get(key) or 0)
|
||||
return totals
|
||||
|
||||
def _extract_run_metrics(self, run: AgentRun) -> dict[str, int]:
|
||||
summary = self._extract_run_summary(run)
|
||||
route_json = run.route_json or {}
|
||||
metrics = self._empty_metrics()
|
||||
metrics["risk_observations"] = self._first_int(
|
||||
summary,
|
||||
("risk_observation_count", "risk_observations", "created_observation_count"),
|
||||
)
|
||||
metrics["risk_clues"] = self._first_int(
|
||||
summary,
|
||||
("risk_clue_count", "risk_clues", "created_clue_count"),
|
||||
)
|
||||
metrics["profile_snapshots"] = self._first_int(
|
||||
summary,
|
||||
("snapshot_count", "profile_snapshot_count", "profile_snapshots"),
|
||||
)
|
||||
metrics["knowledge_documents"] = max(
|
||||
self._first_int(
|
||||
summary,
|
||||
("knowledge_document_count", "document_count", "processed_document_count"),
|
||||
),
|
||||
self._list_length(summary, ("document_ids", "requested_document_ids")),
|
||||
self._list_length(route_json, ("document_ids", "requested_document_ids")),
|
||||
)
|
||||
metrics["scanned_claims"] = self._first_int(summary, ("scanned_claim_count", "claim_count"))
|
||||
metrics["target_employees"] = self._first_int(summary, ("target_employee_count", "employee_count"))
|
||||
metrics["rule_hits"] = self._first_int(summary, ("rule_hit_count", "rule_hits"))
|
||||
metrics["facts"] = self._first_int(summary, ("fact_count", "facts"))
|
||||
return metrics
|
||||
|
||||
@staticmethod
|
||||
def _empty_metrics() -> dict[str, int]:
|
||||
return {
|
||||
"risk_observations": 0,
|
||||
"risk_clues": 0,
|
||||
"profile_snapshots": 0,
|
||||
"knowledge_documents": 0,
|
||||
"scanned_claims": 0,
|
||||
"target_employees": 0,
|
||||
"rule_hits": 0,
|
||||
"facts": 0,
|
||||
}
|
||||
|
||||
def _extract_run_summary(self, run: AgentRun) -> dict[str, Any]:
|
||||
task_type = self._resolve_task_type(run)
|
||||
matched_tool = self._matched_tool_call(run, task_type)
|
||||
if matched_tool is None:
|
||||
return run.route_json or {}
|
||||
response = matched_tool.response_json or {}
|
||||
if isinstance(response, dict) and isinstance(response.get("summary"), dict):
|
||||
return response["summary"]
|
||||
return response if isinstance(response, dict) else {}
|
||||
|
||||
def _matched_tool_call(self, run: AgentRun, task_type: str) -> AgentToolCall | None:
|
||||
digital_tools = [
|
||||
tool for tool in run.tool_calls if str(tool.tool_name or "").startswith("digital_employee.")
|
||||
]
|
||||
for tool in run.tool_calls:
|
||||
candidates = [
|
||||
(tool.request_json or {}).get("task_type"),
|
||||
(tool.request_json or {}).get("job_type"),
|
||||
(tool.response_json or {}).get("report_type"),
|
||||
(tool.response_json or {}).get("task_type"),
|
||||
(tool.response_json or {}).get("job_type"),
|
||||
self._task_type_from_tool_name(tool.tool_name),
|
||||
]
|
||||
if task_type and task_type in {self._normalize_task_type(item) for item in candidates}:
|
||||
return tool
|
||||
if digital_tools:
|
||||
return digital_tools[0]
|
||||
return run.tool_calls[0] if run.tool_calls else None
|
||||
|
||||
def _is_digital_employee_run(self, run: AgentRun) -> bool:
|
||||
task_type = self._resolve_task_type(run)
|
||||
if task_type in TASK_SPECS:
|
||||
return True
|
||||
if run.agent == AgentName.HERMES.value:
|
||||
return True
|
||||
if run.source == AgentRunSource.SCHEDULE.value and task_type:
|
||||
return True
|
||||
route_json = run.route_json or {}
|
||||
if str(route_json.get("selected_agent") or "").strip() == AgentName.HERMES.value:
|
||||
return True
|
||||
return any(str(tool.tool_name or "").startswith("digital_employee.") for tool in run.tool_calls)
|
||||
|
||||
def _resolve_task_type(self, run: AgentRun) -> str:
|
||||
route_json = run.route_json or {}
|
||||
route_candidates = [
|
||||
route_json.get("job_type"),
|
||||
route_json.get("task_type"),
|
||||
route_json.get("report_type"),
|
||||
route_json.get("task_code"),
|
||||
route_json.get("code"),
|
||||
]
|
||||
for candidate in route_candidates:
|
||||
normalized = self._normalize_task_type(candidate)
|
||||
if normalized:
|
||||
return normalized
|
||||
|
||||
for tool in run.tool_calls:
|
||||
for candidate in (
|
||||
(tool.request_json or {}).get("task_type"),
|
||||
(tool.request_json or {}).get("job_type"),
|
||||
(tool.response_json or {}).get("report_type"),
|
||||
(tool.response_json or {}).get("task_type"),
|
||||
(tool.response_json or {}).get("job_type"),
|
||||
self._task_type_from_tool_name(tool.tool_name),
|
||||
):
|
||||
normalized = self._normalize_task_type(candidate)
|
||||
if normalized:
|
||||
return normalized
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_task_type(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
text = TASK_CODE_TO_TYPE.get(text, text)
|
||||
if text.startswith("task.hermes."):
|
||||
text = text.removeprefix("task.hermes.")
|
||||
text = text.replace("-", "_").replace(".", "_")
|
||||
if text == "risk_rule_discovery":
|
||||
return "risk_clue_collect"
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _task_type_from_tool_name(value: str | None) -> str:
|
||||
name = str(value or "")
|
||||
if "financial_risk_graph" in name:
|
||||
return "global_risk_scan"
|
||||
if "employee_behavior_profile" in name:
|
||||
return "employee_behavior_profile_scan"
|
||||
if "finance_policy_knowledge" in name:
|
||||
return "finance_policy_knowledge_organize"
|
||||
if "risk_clue" in name:
|
||||
return "risk_clue_collect"
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _task_spec(task_type: str) -> dict[str, str]:
|
||||
return TASK_SPECS.get(
|
||||
task_type,
|
||||
{
|
||||
"label": "数字员工工作",
|
||||
"category": "评估",
|
||||
"color": "var(--theme-primary)",
|
||||
},
|
||||
)
|
||||
|
||||
def _summary_text(self, run: AgentRun) -> str:
|
||||
text = str(run.result_summary or "").strip()
|
||||
if text:
|
||||
return text
|
||||
summary = self._extract_run_summary(run)
|
||||
for key in ("message", "summary", "result_summary"):
|
||||
value = str(summary.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
if run.error_message:
|
||||
return str(run.error_message)
|
||||
return "暂无摘要。"
|
||||
|
||||
@staticmethod
|
||||
def _first_int(payload: Any, keys: tuple[str, ...]) -> int:
|
||||
if isinstance(payload, dict):
|
||||
for key in keys:
|
||||
value = payload.get(key)
|
||||
if isinstance(value, (int, float)) and value > 0:
|
||||
return int(value)
|
||||
for value in payload.values():
|
||||
found = DigitalEmployeeDashboardService._first_int(value, keys)
|
||||
if found:
|
||||
return found
|
||||
if isinstance(payload, list):
|
||||
for value in payload:
|
||||
found = DigitalEmployeeDashboardService._first_int(value, keys)
|
||||
if found:
|
||||
return found
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _list_length(payload: Any, keys: tuple[str, ...]) -> int:
|
||||
if isinstance(payload, dict):
|
||||
for key in keys:
|
||||
value = payload.get(key)
|
||||
if isinstance(value, list):
|
||||
return len(value)
|
||||
for value in payload.values():
|
||||
found = DigitalEmployeeDashboardService._list_length(value, keys)
|
||||
if found:
|
||||
return found
|
||||
if isinstance(payload, list):
|
||||
for value in payload:
|
||||
found = DigitalEmployeeDashboardService._list_length(value, keys)
|
||||
if found:
|
||||
return found
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _percent(value: int | float, total: int | float) -> float:
|
||||
if not total:
|
||||
return 0.0
|
||||
return round((float(value) / float(total)) * 100, 1)
|
||||
|
||||
@staticmethod
|
||||
def _duration_ms(run: AgentRun) -> int:
|
||||
if not run.finished_at:
|
||||
return 0
|
||||
try:
|
||||
finished_at = DigitalEmployeeDashboardService._as_utc(run.finished_at)
|
||||
started_at = DigitalEmployeeDashboardService._as_utc(run.started_at)
|
||||
return max(0, int((finished_at - started_at).total_seconds() * 1000))
|
||||
except TypeError:
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _date_labels(start_date: date, days: int) -> list[str]:
|
||||
return [(start_date + timedelta(days=index)).strftime("%m-%d") for index in range(days)]
|
||||
|
||||
@staticmethod
|
||||
def _date_label(value: datetime | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return DigitalEmployeeDashboardService._as_utc(value).strftime("%m-%d")
|
||||
|
||||
@staticmethod
|
||||
def _iso(value: datetime | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return DigitalEmployeeDashboardService._as_utc(value).isoformat()
|
||||
|
||||
@staticmethod
|
||||
def _as_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
@staticmethod
|
||||
def _is_success(status: str | None) -> bool:
|
||||
return str(status or "").strip().lower() in SUCCESS_STATUSES
|
||||
|
||||
@staticmethod
|
||||
def _is_failed(status: str | None) -> bool:
|
||||
return str(status or "").strip().lower() in FAILED_STATUSES
|
||||
|
||||
@staticmethod
|
||||
def _is_running(status: str | None) -> bool:
|
||||
return str(status or "").strip().lower() in RUNNING_STATUSES
|
||||
|
||||
def _status_label(self, status: str | None) -> str:
|
||||
if self._is_success(status):
|
||||
return "成功"
|
||||
if self._is_failed(status):
|
||||
return "失败"
|
||||
if self._is_running(status):
|
||||
return "运行中"
|
||||
return str(status or "其他")
|
||||
|
||||
def _status_tone(self, status: str | None) -> str:
|
||||
if self._is_success(status):
|
||||
return "success"
|
||||
if self._is_failed(status):
|
||||
return "danger"
|
||||
if self._is_running(status):
|
||||
return "warning"
|
||||
return "neutral"
|
||||
|
||||
@staticmethod
|
||||
def _source_label(source: str | None) -> str:
|
||||
labels = {
|
||||
"schedule": "定时任务",
|
||||
"system_event": "系统事件",
|
||||
"user_message": "用户触发",
|
||||
}
|
||||
text = str(source or "").strip()
|
||||
return labels.get(text, text or "未标记")
|
||||
@@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.algorithem.employee_behavior_profile import (
|
||||
@@ -102,7 +102,8 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
|
||||
commit: bool = True,
|
||||
) -> list[EmployeeBehaviorProfileSnapshot]:
|
||||
self.ensure_storage_ready()
|
||||
employee = self.db.get(Employee, employee_id)
|
||||
requested_employee_id = str(employee_id or "").strip()
|
||||
employee = self._resolve_employee_by_identifier(requested_employee_id)
|
||||
if employee is None:
|
||||
return []
|
||||
|
||||
@@ -161,10 +162,11 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
|
||||
expense_type_scope: str = "overall",
|
||||
) -> EmployeeProfileLatestRead:
|
||||
self.ensure_storage_ready()
|
||||
employee = self.db.get(Employee, employee_id)
|
||||
requested_employee_id = str(employee_id or "").strip()
|
||||
employee = self._resolve_employee_by_identifier(requested_employee_id)
|
||||
if employee is None:
|
||||
return EmployeeProfileLatestRead(
|
||||
employee_id=employee_id,
|
||||
employee_id=requested_employee_id,
|
||||
scene=scene,
|
||||
window_days=window_days,
|
||||
expense_type_scope=expense_type_scope,
|
||||
@@ -172,22 +174,23 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
|
||||
)
|
||||
|
||||
resolved_scope = self._resolve_scope_from_claim(claim_id, expense_type_scope)
|
||||
resolved_employee_id = employee.id
|
||||
rows = self._load_latest_snapshots(
|
||||
employee_id=employee_id,
|
||||
employee_id=resolved_employee_id,
|
||||
window_days=window_days,
|
||||
expense_type_scope=resolved_scope,
|
||||
scene=scene,
|
||||
)
|
||||
if not rows and claim_id:
|
||||
self.refresh_employee_profiles(
|
||||
employee_id=employee_id,
|
||||
employee_id=resolved_employee_id,
|
||||
window_days=(window_days,),
|
||||
expense_type_scope=resolved_scope,
|
||||
source_task_type="api_on_demand",
|
||||
claim_id=claim_id,
|
||||
)
|
||||
rows = self._load_latest_snapshots(
|
||||
employee_id=employee_id,
|
||||
employee_id=resolved_employee_id,
|
||||
window_days=window_days,
|
||||
expense_type_scope=resolved_scope,
|
||||
scene=scene,
|
||||
@@ -201,6 +204,31 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
|
||||
expense_type_scope=resolved_scope,
|
||||
)
|
||||
|
||||
def _resolve_employee_by_identifier(self, identifier: str) -> Employee | None:
|
||||
normalized = str(identifier or "").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
employee = self.db.get(Employee, normalized)
|
||||
if employee is not None:
|
||||
return employee
|
||||
|
||||
normalized_email = normalized.lower()
|
||||
conditions = [
|
||||
Employee.name == normalized,
|
||||
Employee.employee_no == normalized,
|
||||
]
|
||||
if "@" in normalized_email:
|
||||
conditions.append(func.lower(Employee.email) == normalized_email)
|
||||
|
||||
stmt = (
|
||||
select(Employee)
|
||||
.where(or_(*conditions))
|
||||
.order_by(Employee.created_at.asc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.db.scalars(stmt).first()
|
||||
|
||||
def _build_window_context(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -185,10 +185,7 @@ class ExpenseClaimAccessPolicy:
|
||||
return False
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
if "executive" in role_codes:
|
||||
return True
|
||||
return self.is_department_p8_budget_monitor(current_user, claim)
|
||||
return self.is_department_budget_approver(current_user, claim)
|
||||
|
||||
def is_budget_manager_user(self, current_user: CurrentUserContext) -> bool:
|
||||
if current_user.is_admin:
|
||||
@@ -197,13 +194,16 @@ class ExpenseClaimAccessPolicy:
|
||||
return bool(role_codes & BUDGET_APPROVAL_ROLE_CODES)
|
||||
|
||||
def is_department_p8_budget_monitor(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
if BUDGET_MONITOR_ROLE_CODE not in role_codes:
|
||||
return False
|
||||
return self.is_department_budget_approver(current_user, claim)
|
||||
|
||||
def is_department_budget_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
current_employee = self.resolve_current_employee(current_user)
|
||||
if current_employee is None:
|
||||
return False
|
||||
role_codes |= self._collect_employee_role_codes(current_employee)
|
||||
if not role_codes & BUDGET_APPROVAL_ROLE_CODES:
|
||||
return False
|
||||
if not self._employee_has_budget_approval_grade(current_employee):
|
||||
return False
|
||||
|
||||
@@ -224,7 +224,7 @@ class ExpenseClaimAccessPolicy:
|
||||
.options(selectinload(Employee.organization_unit), selectinload(Employee.roles))
|
||||
.where(
|
||||
func.upper(func.coalesce(Employee.grade, "")) == BUDGET_MONITOR_APPROVAL_GRADE,
|
||||
Employee.roles.any(Role.role_code == BUDGET_MONITOR_ROLE_CODE),
|
||||
Employee.roles.any(Role.role_code.in_(BUDGET_APPROVAL_ROLE_CODES)),
|
||||
or_(*department_conditions),
|
||||
)
|
||||
.order_by(Employee.name.asc(), Employee.employee_no.asc())
|
||||
@@ -235,6 +235,37 @@ class ExpenseClaimAccessPolicy:
|
||||
stmt = stmt.where(Employee.id != claim_employee_id)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def resolve_budget_approval_role_code(self, employee: Employee | None) -> str:
|
||||
role_codes = self._collect_employee_role_codes(employee)
|
||||
for role_code in ("budget_monitor", "executive"):
|
||||
if role_code in role_codes:
|
||||
return role_code
|
||||
return BUDGET_MONITOR_ROLE_CODE
|
||||
|
||||
def attach_budget_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None:
|
||||
if claim is None:
|
||||
return None
|
||||
if str(claim.approval_stage or "").strip() != BUDGET_MANAGER_APPROVAL_STAGE:
|
||||
return claim
|
||||
|
||||
budget_manager = self.resolve_department_budget_manager(claim)
|
||||
if budget_manager is None:
|
||||
return claim
|
||||
|
||||
setattr(claim, "budget_approver_name", str(budget_manager.name or "").strip())
|
||||
setattr(claim, "budget_approver_grade", str(budget_manager.grade or "").strip())
|
||||
setattr(
|
||||
claim,
|
||||
"budget_approver_role_code",
|
||||
self.resolve_budget_approval_role_code(budget_manager),
|
||||
)
|
||||
return claim
|
||||
|
||||
def attach_budget_approval_snapshots(self, claims: list[ExpenseClaim]) -> list[ExpenseClaim]:
|
||||
for claim in claims:
|
||||
self.attach_budget_approval_snapshot(claim)
|
||||
return claims
|
||||
|
||||
@staticmethod
|
||||
def normalize_role_codes(current_user: CurrentUserContext) -> set[str]:
|
||||
return {
|
||||
@@ -243,6 +274,16 @@ class ExpenseClaimAccessPolicy:
|
||||
if str(item).strip()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _collect_employee_role_codes(employee: Employee | None) -> set[str]:
|
||||
if employee is None:
|
||||
return set()
|
||||
return {
|
||||
str(role.role_code or "").strip().lower()
|
||||
for role in list(employee.roles or [])
|
||||
if str(role.role_code or "").strip()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _employee_has_budget_approval_grade(employee: Employee) -> bool:
|
||||
return str(employee.grade or "").strip().upper() == BUDGET_MONITOR_APPROVAL_GRADE
|
||||
@@ -293,6 +334,7 @@ class ExpenseClaimAccessPolicy:
|
||||
[
|
||||
str(current_user.username or "").strip(),
|
||||
str(current_user.name or "").strip(),
|
||||
str(current_user.employee_no or "").strip(),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -309,9 +351,10 @@ class ExpenseClaimAccessPolicy:
|
||||
return str(current_user.username or current_user.name or "anonymous").strip() or "anonymous"
|
||||
|
||||
def is_claim_owned_by_current_user(self, claim: ExpenseClaim, current_user: CurrentUserContext) -> bool:
|
||||
claim_employee_id = str(claim.employee_id or "").strip()
|
||||
current_employee = self.resolve_current_employee(current_user)
|
||||
if current_employee is not None:
|
||||
if str(claim.employee_id or "").strip() == current_employee.id:
|
||||
if claim_employee_id == current_employee.id:
|
||||
return True
|
||||
identity_values = {
|
||||
str(current_employee.name or "").strip(),
|
||||
@@ -325,9 +368,12 @@ class ExpenseClaimAccessPolicy:
|
||||
{
|
||||
str(current_user.username or "").strip(),
|
||||
str(current_user.name or "").strip(),
|
||||
str(current_user.employee_no or "").strip(),
|
||||
}
|
||||
)
|
||||
identity_values.discard("")
|
||||
if claim_employee_id and claim_employee_id in identity_values:
|
||||
return True
|
||||
return str(claim.employee_name or "").strip() in identity_values
|
||||
|
||||
@staticmethod
|
||||
@@ -490,8 +536,10 @@ class ExpenseClaimAccessPolicy:
|
||||
add_condition("employee_name", employee.name)
|
||||
else:
|
||||
add_condition("employee_id", username)
|
||||
add_condition("employee_id", str(current_user.employee_no or "").strip())
|
||||
add_condition("employee_name", username)
|
||||
add_condition("employee_name", str(current_user.name or "").strip())
|
||||
add_condition("employee_name", str(current_user.employee_no or "").strip())
|
||||
|
||||
return conditions
|
||||
|
||||
@@ -531,10 +579,10 @@ class ExpenseClaimAccessPolicy:
|
||||
return conditions
|
||||
|
||||
def build_budget_approval_claim_conditions(self, current_user: CurrentUserContext) -> list[Any]:
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
if BUDGET_MONITOR_ROLE_CODE not in role_codes:
|
||||
return []
|
||||
employee = self.resolve_current_employee(current_user)
|
||||
role_codes = self.normalize_role_codes(current_user) | self._collect_employee_role_codes(employee)
|
||||
if not role_codes & BUDGET_APPROVAL_ROLE_CODES:
|
||||
return []
|
||||
if employee is None or not self._employee_has_budget_approval_grade(employee):
|
||||
return []
|
||||
|
||||
@@ -568,7 +616,7 @@ class ExpenseClaimAccessPolicy:
|
||||
|
||||
def apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
if current_user.is_admin or "executive" in role_codes:
|
||||
if current_user.is_admin:
|
||||
return stmt.where(ExpenseClaim.status == "submitted")
|
||||
conditions = []
|
||||
if "finance" in role_codes:
|
||||
|
||||
@@ -15,6 +15,10 @@ from app.services.expense_claim_workflow_constants import (
|
||||
PAYMENT_PENDING_STAGE,
|
||||
PAYMENT_PENDING_STATUS,
|
||||
)
|
||||
from app.services.expense_claim_risk_stage import (
|
||||
risk_business_stage_for_claim,
|
||||
with_risk_business_stage,
|
||||
)
|
||||
|
||||
|
||||
class ExpenseClaimApprovalFlowMixin:
|
||||
@@ -35,41 +39,72 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
|
||||
previous_stage = str(claim.approval_stage or "").strip()
|
||||
is_application_claim = self._is_expense_application_claim(claim)
|
||||
business_stage = risk_business_stage_for_claim(is_application_claim=is_application_claim)
|
||||
next_budget_manager = None
|
||||
merged_budget_approval = False
|
||||
route_decision_flag: dict[str, Any] | None = None
|
||||
if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE:
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
|
||||
approval_source = "manual_approval"
|
||||
event_type = "expense_application_approval" if is_application_claim else "expense_claim_approval"
|
||||
label = "领导审批通过"
|
||||
route_decision_flag = self._build_approval_route_decision(
|
||||
claim,
|
||||
is_application_claim=is_application_claim,
|
||||
)
|
||||
requires_budget_review = bool(route_decision_flag.get("requires_budget_review"))
|
||||
if is_application_claim:
|
||||
merged_budget_approval = self._access_policy.is_department_p8_budget_monitor(current_user, claim)
|
||||
merged_budget_approval = (
|
||||
requires_budget_review
|
||||
and self._access_policy.is_department_p8_budget_monitor(current_user, claim)
|
||||
)
|
||||
if merged_budget_approval:
|
||||
label = "领导及预算审核通过"
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
|
||||
else:
|
||||
elif requires_budget_review:
|
||||
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
|
||||
if next_budget_manager is None:
|
||||
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
|
||||
next_status = "submitted"
|
||||
next_stage = BUDGET_MANAGER_APPROVAL_STAGE
|
||||
default_message = "{operator} 已确认直属领导审核,流转至预算管理者审批。"
|
||||
default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。"
|
||||
else:
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。"
|
||||
else:
|
||||
if requires_budget_review:
|
||||
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
|
||||
if next_budget_manager is None:
|
||||
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
|
||||
next_status = "submitted"
|
||||
next_stage = BUDGET_MANAGER_APPROVAL_STAGE
|
||||
default_message = "{operator} 已审批通过,因预算或风险关注项流转至预算管理者审批。"
|
||||
else:
|
||||
next_status = "submitted"
|
||||
next_stage = FINANCE_APPROVAL_STAGE
|
||||
default_message = "{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}。"
|
||||
elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前预算管理者可以审批通过该单据。")
|
||||
approval_source = "budget_approval"
|
||||
event_type = (
|
||||
"expense_application_budget_approval"
|
||||
if is_application_claim
|
||||
else "expense_claim_budget_approval"
|
||||
)
|
||||
label = "预算管理者审核通过"
|
||||
if is_application_claim:
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。"
|
||||
else:
|
||||
next_status = "submitted"
|
||||
next_stage = FINANCE_APPROVAL_STAGE
|
||||
default_message = "{operator} 已审批通过,流转至{next_stage}。"
|
||||
elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
|
||||
if not is_application_claim:
|
||||
raise ValueError("只有费用申请需要预算管理者审批。")
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前预算管理者可以审批通过该费用申请。")
|
||||
approval_source = "budget_approval"
|
||||
event_type = "expense_application_budget_approval"
|
||||
label = "预算管理者审核通过"
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。"
|
||||
default_message = "{operator} 已完成预算审核,流转至{next_stage}。"
|
||||
elif previous_stage == FINANCE_APPROVAL_STAGE:
|
||||
if is_application_claim:
|
||||
raise ValueError("费用申请需先完成预算管理者审批。")
|
||||
@@ -95,32 +130,35 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
consumed_budget_flag = self._consume_budget_for_finance_approval(claim, current_user)
|
||||
if consumed_budget_flag is not None:
|
||||
budget_flags.append(consumed_budget_flag)
|
||||
approval_flag = {
|
||||
"source": approval_source,
|
||||
"event_type": event_type,
|
||||
"approval_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": label,
|
||||
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
|
||||
"opinion": approval_opinion,
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower()
|
||||
for item in current_user.role_codes
|
||||
if str(item).strip()
|
||||
],
|
||||
"previous_status": str(claim.status or "").strip(),
|
||||
"previous_approval_stage": previous_stage,
|
||||
"next_status": next_status,
|
||||
"next_approval_stage": next_stage,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
approval_flag = with_risk_business_stage(
|
||||
{
|
||||
"source": approval_source,
|
||||
"event_type": event_type,
|
||||
"approval_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": label,
|
||||
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
|
||||
"opinion": approval_opinion,
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower()
|
||||
for item in current_user.role_codes
|
||||
if str(item).strip()
|
||||
],
|
||||
"previous_status": str(claim.status or "").strip(),
|
||||
"previous_approval_stage": previous_stage,
|
||||
"next_status": next_status,
|
||||
"next_approval_stage": next_stage,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
business_stage,
|
||||
)
|
||||
if merged_budget_approval:
|
||||
approval_flag.update(
|
||||
{
|
||||
"budget_approval_merged": True,
|
||||
"budget_approval_merged_reason": "direct_manager_is_department_budget_monitor",
|
||||
"budget_approval_merged_reason": "direct_manager_is_department_budget_approver",
|
||||
}
|
||||
)
|
||||
if next_budget_manager is not None:
|
||||
@@ -129,9 +167,20 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
"next_approver_name": str(next_budget_manager.name or "").strip(),
|
||||
"next_approver_employee_id": next_budget_manager.id,
|
||||
"next_approver_grade": str(next_budget_manager.grade or "").strip(),
|
||||
"next_approver_role_code": "budget_monitor",
|
||||
"next_approver_role_code": self._access_policy.resolve_budget_approval_role_code(
|
||||
next_budget_manager,
|
||||
),
|
||||
}
|
||||
)
|
||||
if route_decision_flag is not None:
|
||||
approval_flag["route_decision"] = {
|
||||
"requires_budget_review": route_decision_flag.get("requires_budget_review"),
|
||||
"route": route_decision_flag.get("route"),
|
||||
"reasons": route_decision_flag.get("reasons", []),
|
||||
"budget_result": route_decision_flag.get("budget_result", {}),
|
||||
"current_risk_count": route_decision_flag.get("current_risk_count", 0),
|
||||
"historical_risk_count": route_decision_flag.get("historical_risk_count", 0),
|
||||
}
|
||||
|
||||
claim.status = next_status
|
||||
claim.approval_stage = next_stage
|
||||
@@ -147,6 +196,13 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
elif merged_budget_approval:
|
||||
approval_flag["leader_opinion"] = approval_opinion
|
||||
approval_flag["budget_opinion"] = approval_opinion
|
||||
elif (
|
||||
previous_stage == DIRECT_MANAGER_APPROVAL_STAGE
|
||||
and route_decision_flag is not None
|
||||
and not route_decision_flag.get("requires_budget_review")
|
||||
):
|
||||
approval_flag["leader_opinion"] = approval_opinion
|
||||
approval_flag["budget_opinion"] = "系统动态路由跳过预算复核"
|
||||
generated_draft = self._create_reimbursement_draft_from_application(
|
||||
application_claim=claim,
|
||||
approval_flag=approval_flag,
|
||||
@@ -162,14 +218,21 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
generated_draft.risk_flags_json = self._append_budget_flags(
|
||||
generated_draft.risk_flags_json,
|
||||
transferred_budget_flag,
|
||||
business_stage="reimbursement",
|
||||
)
|
||||
approval_flags: list[Any] = list(claim.risk_flags_json or [])
|
||||
if route_decision_flag is not None:
|
||||
approval_flags.append(route_decision_flag)
|
||||
approval_flags.append(approval_flag)
|
||||
claim.risk_flags_json = self._append_budget_flags(
|
||||
[*list(claim.risk_flags_json or []), approval_flag],
|
||||
approval_flags,
|
||||
budget_flags,
|
||||
business_stage=business_stage,
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
self._access_policy.attach_budget_approval_snapshot(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
@@ -202,26 +265,29 @@ class ExpenseClaimApprovalFlowMixin:
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = self._access_policy.resolve_current_user_display_name(current_user)
|
||||
previous_stage = str(claim.approval_stage or "").strip()
|
||||
payment_flag = {
|
||||
"source": "payment",
|
||||
"event_type": "expense_claim_payment_completed",
|
||||
"payment_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": "付款完成",
|
||||
"message": f"{operator} 已确认付款,报销单进入已付款。",
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower()
|
||||
for item in current_user.role_codes
|
||||
if str(item).strip()
|
||||
],
|
||||
"previous_status": str(claim.status or "").strip(),
|
||||
"previous_approval_stage": previous_stage,
|
||||
"next_status": PAYMENT_PAID_STATUS,
|
||||
"next_approval_stage": PAYMENT_PAID_STAGE,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
payment_flag = with_risk_business_stage(
|
||||
{
|
||||
"source": "payment",
|
||||
"event_type": "expense_claim_payment_completed",
|
||||
"payment_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": "付款完成",
|
||||
"message": f"{operator} 已确认付款,报销单进入已付款。",
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower()
|
||||
for item in current_user.role_codes
|
||||
if str(item).strip()
|
||||
],
|
||||
"previous_status": str(claim.status or "").strip(),
|
||||
"previous_approval_stage": previous_stage,
|
||||
"next_status": PAYMENT_PAID_STATUS,
|
||||
"next_approval_stage": PAYMENT_PAID_STAGE,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
"reimbursement",
|
||||
)
|
||||
|
||||
claim.status = PAYMENT_PAID_STATUS
|
||||
claim.approval_stage = PAYMENT_PAID_STAGE
|
||||
|
||||
227
server/src/app/services/expense_claim_approval_routing.py
Normal file
227
server/src/app/services/expense_claim_approval_routing.py
Normal file
@@ -0,0 +1,227 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.budget import BudgetService
|
||||
from app.services.expense_claim_constants import AI_REVIEW_LOOKBACK_DAYS
|
||||
from app.services.expense_claim_risk_stage import (
|
||||
risk_business_stage_for_claim,
|
||||
risk_flag_business_stage,
|
||||
with_risk_business_stage,
|
||||
)
|
||||
|
||||
|
||||
class ExpenseClaimApprovalRoutingMixin:
|
||||
_BUDGET_REVIEW_RATINGS = {"block"}
|
||||
_BUDGET_REVIEW_RISK_LEVELS = {"high", "critical"}
|
||||
_ROUTE_RISK_SEVERITIES = {"medium", "high", "critical", "danger"}
|
||||
_ROUTE_RISK_SOURCES = {
|
||||
"attachment_analysis",
|
||||
"budget",
|
||||
"budget_control",
|
||||
"manual_return",
|
||||
"platform_risk",
|
||||
"platform_risk_rule",
|
||||
"policy_review",
|
||||
"risk_rule",
|
||||
"scene_policy",
|
||||
"submission_review",
|
||||
"travel_policy",
|
||||
}
|
||||
_ROUTE_IGNORED_SOURCES = {
|
||||
"application_detail",
|
||||
"application_handoff",
|
||||
"approval_routing",
|
||||
"budget_approval",
|
||||
"finance_approval",
|
||||
"manual_approval",
|
||||
"payment",
|
||||
}
|
||||
_ROUTE_RISK_EVENT_TYPES = {
|
||||
"budget_frozen",
|
||||
"budget_insufficient",
|
||||
"budget_missing",
|
||||
"platform_risk_rule_hit",
|
||||
"risk_rule_hit",
|
||||
}
|
||||
_BUDGET_ROUTE_EVENT_TYPES = {
|
||||
"budget_frozen",
|
||||
"budget_insufficient",
|
||||
"budget_missing",
|
||||
}
|
||||
|
||||
def _build_approval_route_decision(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
is_application_claim: bool,
|
||||
) -> dict[str, Any]:
|
||||
business_stage = risk_business_stage_for_claim(is_application_claim=is_application_claim)
|
||||
budget_result = BudgetService(self.db).analyze_claim_budget(claim)
|
||||
budget_reasons = self._collect_budget_route_reasons(budget_result)
|
||||
current_risk_reasons = self._collect_current_route_risk_reasons(
|
||||
claim.risk_flags_json,
|
||||
business_stage=business_stage,
|
||||
)
|
||||
historical_risk_count = self._count_recent_substantive_risky_claims(claim)
|
||||
historical_risk_reasons = (
|
||||
[f"申请人近 {AI_REVIEW_LOOKBACK_DAYS} 天存在 {historical_risk_count} 笔实质风险记录"]
|
||||
if historical_risk_count > 0
|
||||
else []
|
||||
)
|
||||
reasons = self._dedupe_reasons(
|
||||
[*budget_reasons, *current_risk_reasons, *historical_risk_reasons]
|
||||
)
|
||||
requires_budget_review = bool(reasons)
|
||||
route = (
|
||||
"budget_manager"
|
||||
if requires_budget_review
|
||||
else "approval_done"
|
||||
if is_application_claim
|
||||
else "finance"
|
||||
)
|
||||
label = "需要预算管理者复核" if requires_budget_review else "跳过预算管理者复核"
|
||||
message = (
|
||||
"系统根据预算、当前风险和历史风险判断,该单据需要预算管理者二次确认。"
|
||||
if requires_budget_review
|
||||
else "系统根据预算、当前风险和历史风险判断,该单据可跳过预算管理者复核。"
|
||||
)
|
||||
|
||||
return with_risk_business_stage(
|
||||
{
|
||||
"source": "approval_routing",
|
||||
"event_type": (
|
||||
"expense_application_route_decision"
|
||||
if is_application_claim
|
||||
else "expense_claim_route_decision"
|
||||
),
|
||||
"severity": "medium" if requires_budget_review else "info",
|
||||
"label": label,
|
||||
"message": message,
|
||||
"requires_budget_review": requires_budget_review,
|
||||
"route": route,
|
||||
"reasons": reasons,
|
||||
"budget_result": self._compact_budget_result(budget_result),
|
||||
"current_risk_count": len(current_risk_reasons),
|
||||
"historical_risk_count": historical_risk_count,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
business_stage,
|
||||
)
|
||||
|
||||
def _collect_budget_route_reasons(self, budget_result: dict[str, Any]) -> list[str]:
|
||||
rating = str(budget_result.get("rating") or "").strip().lower()
|
||||
risk_level = str(budget_result.get("risk_level") or "").strip().lower()
|
||||
metrics = budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {}
|
||||
context = (
|
||||
budget_result.get("budget_context")
|
||||
if isinstance(budget_result.get("budget_context"), dict)
|
||||
else {}
|
||||
)
|
||||
reasons: list[str] = []
|
||||
if context.get("budget_applicable") is True and context.get("matched") is False:
|
||||
reasons.append("未匹配到可用预算池")
|
||||
if rating in self._BUDGET_REVIEW_RATINGS:
|
||||
summary = str(budget_result.get("summary") or "").strip()
|
||||
reasons.append(summary or f"预算测算评级为 {rating}")
|
||||
if risk_level in self._BUDGET_REVIEW_RISK_LEVELS:
|
||||
reasons.append(f"预算风险等级为 {risk_level}")
|
||||
over_budget_amount = self._decimal(metrics.get("over_budget_amount"))
|
||||
if over_budget_amount > Decimal("0.00"):
|
||||
reasons.append(f"预计超预算 {over_budget_amount} 元")
|
||||
return self._dedupe_reasons(reasons)
|
||||
|
||||
def _collect_current_route_risk_reasons(
|
||||
self,
|
||||
risk_flags: list[Any] | None,
|
||||
*,
|
||||
business_stage: str,
|
||||
) -> list[str]:
|
||||
reasons: list[str] = []
|
||||
for flag in list(risk_flags or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
flag_stage = risk_flag_business_stage(flag)
|
||||
if flag_stage and flag_stage != business_stage:
|
||||
continue
|
||||
if not self._is_substantive_route_risk_flag(flag):
|
||||
continue
|
||||
label = str(flag.get("label") or flag.get("event_type") or "风险标记").strip()
|
||||
message = str(flag.get("message") or "").strip()
|
||||
reasons.append(f"{label}:{message}" if message else label)
|
||||
return self._dedupe_reasons(reasons)
|
||||
|
||||
def _count_recent_substantive_risky_claims(self, claim: ExpenseClaim) -> int:
|
||||
filters = []
|
||||
if claim.employee_id:
|
||||
filters.append(ExpenseClaim.employee_id == claim.employee_id)
|
||||
elif claim.employee_name:
|
||||
filters.append(ExpenseClaim.employee_name == claim.employee_name)
|
||||
if not filters:
|
||||
return 0
|
||||
|
||||
since = datetime.now(UTC) - timedelta(days=AI_REVIEW_LOOKBACK_DAYS)
|
||||
stmt = (
|
||||
select(ExpenseClaim)
|
||||
.where(or_(*filters))
|
||||
.where(ExpenseClaim.id != claim.id)
|
||||
.where(ExpenseClaim.occurred_at >= since)
|
||||
)
|
||||
return sum(
|
||||
1
|
||||
for item in self.db.scalars(stmt).all()
|
||||
if any(
|
||||
self._is_substantive_route_risk_flag(flag)
|
||||
for flag in list(item.risk_flags_json or [])
|
||||
if isinstance(flag, dict)
|
||||
)
|
||||
)
|
||||
|
||||
def _is_substantive_route_risk_flag(self, flag: dict[str, Any]) -> bool:
|
||||
source = str(flag.get("source") or "").strip().lower()
|
||||
if source in self._ROUTE_IGNORED_SOURCES:
|
||||
return False
|
||||
|
||||
event_type = str(flag.get("event_type") or "").strip().lower()
|
||||
severity = str(flag.get("severity") or "").strip().lower()
|
||||
if source in {"budget", "budget_control"}:
|
||||
return event_type in self._BUDGET_ROUTE_EVENT_TYPES or severity in {"high", "critical", "danger"}
|
||||
if event_type in self._ROUTE_RISK_EVENT_TYPES:
|
||||
return True
|
||||
if severity in self._ROUTE_RISK_SEVERITIES:
|
||||
return source in self._ROUTE_RISK_SOURCES or bool(source)
|
||||
return source in self._ROUTE_RISK_SOURCES and bool(flag.get("triggered"))
|
||||
|
||||
@staticmethod
|
||||
def _compact_budget_result(budget_result: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"score": budget_result.get("score"),
|
||||
"rating": budget_result.get("rating"),
|
||||
"risk_level": budget_result.get("risk_level"),
|
||||
"summary": budget_result.get("summary"),
|
||||
"metrics": budget_result.get("metrics") if isinstance(budget_result.get("metrics"), dict) else {},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _dedupe_reasons(reasons: list[str]) -> list[str]:
|
||||
deduped: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for reason in reasons:
|
||||
text = str(reason or "").strip()
|
||||
if not text or text in seen:
|
||||
continue
|
||||
seen.add(text)
|
||||
deduped.append(text)
|
||||
return deduped
|
||||
|
||||
@staticmethod
|
||||
def _decimal(value: Any) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value if value is not None else "0")).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return Decimal("0.00")
|
||||
@@ -277,6 +277,7 @@ class ExpenseClaimAttachmentOperationsMixin:
|
||||
"item_location": item.item_location,
|
||||
"item_amount": item.item_amount,
|
||||
"claim_amount": claim.amount,
|
||||
"claim_risk_flags": list(claim.risk_flags_json or []),
|
||||
"attachment": self._build_attachment_payload(item),
|
||||
}
|
||||
|
||||
@@ -371,6 +372,7 @@ class ExpenseClaimAttachmentOperationsMixin:
|
||||
"claim_id": claim.id,
|
||||
"item_id": item.id,
|
||||
"invoice_id": item.invoice_id,
|
||||
"claim_risk_flags": list(claim.risk_flags_json or []),
|
||||
"attachment": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.budget import BudgetService
|
||||
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
|
||||
|
||||
|
||||
class ExpenseClaimBudgetFlowMixin:
|
||||
@@ -80,6 +81,8 @@ class ExpenseClaimBudgetFlowMixin:
|
||||
def _append_budget_flags(
|
||||
risk_flags: list[Any] | None,
|
||||
budget_flags: list[dict[str, Any]] | dict[str, Any] | None,
|
||||
*,
|
||||
business_stage: str | None = None,
|
||||
) -> list[Any]:
|
||||
if budget_flags is None:
|
||||
return list(risk_flags or [])
|
||||
@@ -89,7 +92,19 @@ class ExpenseClaimBudgetFlowMixin:
|
||||
next_flags = list(budget_flags or [])
|
||||
if not next_flags:
|
||||
return list(risk_flags or [])
|
||||
return [*list(risk_flags or []), *next_flags]
|
||||
enriched_flags = [
|
||||
enrich_risk_flag_semantics(
|
||||
flag,
|
||||
business_stage=business_stage,
|
||||
risk_domain="budget",
|
||||
visibility_scope="budget_manager",
|
||||
actionability="budget_governance",
|
||||
)
|
||||
if isinstance(flag, dict)
|
||||
else flag
|
||||
for flag in next_flags
|
||||
]
|
||||
return [*list(risk_flags or []), *enriched_flags]
|
||||
|
||||
@staticmethod
|
||||
def _resolve_budget_operator(current_user: CurrentUserContext) -> str:
|
||||
|
||||
@@ -224,6 +224,10 @@ class ExpenseClaimDraftFlowMixin:
|
||||
existing_flags=list(claim.risk_flags_json or []) if claim is not None else [],
|
||||
next_flags=list(ontology.risk_flags),
|
||||
)
|
||||
final_risk_flags = self._merge_application_link_flag(
|
||||
final_risk_flags,
|
||||
context_json=context_json,
|
||||
)
|
||||
if context_documents or attachment_names:
|
||||
document_specs = self._build_context_item_specs(
|
||||
context_documents=context_documents,
|
||||
@@ -347,6 +351,7 @@ class ExpenseClaimDraftFlowMixin:
|
||||
context_json=retry_context,
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
self.db.rollback()
|
||||
raise
|
||||
@@ -374,6 +379,86 @@ class ExpenseClaimDraftFlowMixin:
|
||||
"invoice_count": int(claim.invoice_count or 0),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _merge_application_link_flag(
|
||||
risk_flags: list[Any],
|
||||
*,
|
||||
context_json: dict[str, Any],
|
||||
) -> list[Any]:
|
||||
link_flag = ExpenseClaimDraftFlowMixin._build_application_link_flag(context_json)
|
||||
if link_flag is None:
|
||||
return list(risk_flags or [])
|
||||
|
||||
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
|
||||
for flag in list(risk_flags or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
existing_no = str(
|
||||
flag.get("application_claim_no")
|
||||
or flag.get("applicationClaimNo")
|
||||
or ""
|
||||
).strip()
|
||||
if existing_no and existing_no == application_claim_no:
|
||||
return list(risk_flags or [])
|
||||
return [*list(risk_flags or []), link_flag]
|
||||
|
||||
@staticmethod
|
||||
def _build_application_link_flag(context_json: dict[str, Any]) -> dict[str, Any] | None:
|
||||
review_values = ExpenseClaimDraftFlowMixin._normalize_context_object(
|
||||
context_json.get("review_form_values")
|
||||
)
|
||||
scene_selection = ExpenseClaimDraftFlowMixin._normalize_context_object(
|
||||
context_json.get("expense_scene_selection")
|
||||
)
|
||||
|
||||
def pick(*keys: str) -> str:
|
||||
for source in (review_values, scene_selection, context_json):
|
||||
for key in keys:
|
||||
value = str(source.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
application_claim_no = pick("application_claim_no", "applicationClaimNo")
|
||||
if not application_claim_no:
|
||||
return None
|
||||
|
||||
application_claim_id = pick("application_claim_id", "applicationClaimId")
|
||||
application_amount = pick("application_amount", "applicationAmount")
|
||||
application_amount_label = pick("application_amount_label", "applicationAmountLabel")
|
||||
application_reason = pick("application_reason", "applicationReason", "reason")
|
||||
application_location = pick("application_location", "applicationLocation", "location")
|
||||
application_date = pick("application_date", "applicationDate", "business_time", "time_range")
|
||||
application_status = pick("application_status", "applicationStatus")
|
||||
application_status_label = pick("application_status_label", "applicationStatusLabel")
|
||||
|
||||
return {
|
||||
"source": "application_link",
|
||||
"event_type": "expense_reimbursement_application_linked",
|
||||
"severity": "info",
|
||||
"label": "关联申请单",
|
||||
"message": f"报销草稿已关联申请单 {application_claim_no}。",
|
||||
"application_claim_id": application_claim_id,
|
||||
"application_claim_no": application_claim_no,
|
||||
"application_amount_label": application_amount_label,
|
||||
"application_status": application_status,
|
||||
"application_status_label": application_status_label,
|
||||
"application_detail": {
|
||||
"application_reason": application_reason,
|
||||
"application_location": application_location,
|
||||
"application_amount": application_amount,
|
||||
"application_amount_label": application_amount_label,
|
||||
"application_time": application_date,
|
||||
},
|
||||
"review_form_values": review_values,
|
||||
"expense_scene_selection": scene_selection,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_context_object(value: Any) -> dict[str, Any]:
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
|
||||
def _find_target_claim(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.services.expense_claim_constants import (
|
||||
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
|
||||
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
||||
)
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.expense_rule_runtime import (
|
||||
ExpenseRuleRuntimeService,
|
||||
RuntimeTravelPolicy,
|
||||
@@ -215,6 +216,7 @@ class ExpenseClaimItemSyncMixin:
|
||||
claim.amount = Decimal("0.00")
|
||||
claim.invoice_count = 0
|
||||
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
|
||||
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(claim, [])
|
||||
return
|
||||
|
||||
ordered_items = sorted(
|
||||
@@ -253,6 +255,7 @@ class ExpenseClaimItemSyncMixin:
|
||||
claim,
|
||||
self._build_claim_attachment_risk_flags(ordered_items),
|
||||
)
|
||||
self._refresh_claim_platform_risk_preview_flags(claim)
|
||||
if str(claim.status or "").strip().lower() == "draft":
|
||||
claim.approval_stage = "待提交"
|
||||
|
||||
@@ -359,15 +362,18 @@ class ExpenseClaimItemSyncMixin:
|
||||
analysis.get("label") or ("高风险" if severity == "high" else "中风险")
|
||||
).strip()
|
||||
derived_flags.append(
|
||||
{
|
||||
"source": "attachment_analysis",
|
||||
"item_id": item.id,
|
||||
"severity": severity,
|
||||
"label": label,
|
||||
"message": f"费用明细第 {index} 条:{message_detail}",
|
||||
"summary": summary,
|
||||
"points": points,
|
||||
}
|
||||
with_risk_business_stage(
|
||||
{
|
||||
"source": "attachment_analysis",
|
||||
"item_id": item.id,
|
||||
"severity": severity,
|
||||
"label": label,
|
||||
"message": f"费用明细第 {index} 条:{message_detail}",
|
||||
"summary": summary,
|
||||
"points": points,
|
||||
},
|
||||
"reimbursement",
|
||||
)
|
||||
)
|
||||
return derived_flags
|
||||
|
||||
@@ -412,6 +418,38 @@ class ExpenseClaimItemSyncMixin:
|
||||
]
|
||||
return preserved_flags + attachment_risk_flags
|
||||
|
||||
def _refresh_claim_platform_risk_preview_flags(self, claim: ExpenseClaim) -> None:
|
||||
if str(claim.expense_type or "").strip().lower().endswith("_application"):
|
||||
return
|
||||
evaluator = getattr(self, "evaluate_platform_risk_rules", None)
|
||||
if not callable(evaluator):
|
||||
return
|
||||
try:
|
||||
review = evaluator(claim, business_stage="reimbursement")
|
||||
except Exception:
|
||||
return
|
||||
platform_flags = list(review.get("flags") or []) if isinstance(review, dict) else []
|
||||
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(
|
||||
claim,
|
||||
platform_flags,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _merge_claim_platform_risk_preview_flags(
|
||||
claim: ExpenseClaim,
|
||||
platform_flags: list[dict[str, Any]],
|
||||
) -> list[Any]:
|
||||
preserved_flags = [
|
||||
flag
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
if not (
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "submission_review"
|
||||
and str(flag.get("hit_source") or "").strip() == "rule_center"
|
||||
)
|
||||
]
|
||||
return preserved_flags + platform_flags
|
||||
|
||||
@staticmethod
|
||||
def _format_submission_blocked_message(issues: list[str]) -> str:
|
||||
normalized_issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()]
|
||||
|
||||
@@ -29,7 +29,9 @@ class ExpenseClaimPaginationMixin:
|
||||
ExpenseClaim.occurred_at.desc(),
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
|
||||
return paginate_select(self.db, stmt, page=page, page_size=page_size)
|
||||
result = paginate_select(self.db, stmt, page=page, page_size=page_size)
|
||||
self._access_policy.attach_budget_approval_snapshots(result.items)
|
||||
return result
|
||||
|
||||
def list_approval_claims_page(
|
||||
self,
|
||||
@@ -43,7 +45,9 @@ class ExpenseClaimPaginationMixin:
|
||||
ExpenseClaim.created_at.desc(),
|
||||
)
|
||||
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
|
||||
return paginate_select(self.db, stmt, page=page, page_size=page_size)
|
||||
result = paginate_select(self.db, stmt, page=page, page_size=page_size)
|
||||
self._access_policy.attach_budget_approval_snapshots(result.items)
|
||||
return result
|
||||
|
||||
def list_archived_claims_page(
|
||||
self,
|
||||
|
||||
@@ -15,18 +15,27 @@ from app.services.expense_rule_runtime import (
|
||||
RuntimeTravelPolicy,
|
||||
)
|
||||
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
|
||||
from app.services.expense_claim_platform_risk_flag import build_platform_risk_flag
|
||||
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
|
||||
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||
|
||||
|
||||
class ExpenseClaimPlatformRiskMixin:
|
||||
_DEFAULT_RISK_BUSINESS_STAGE = "reimbursement"
|
||||
_SUPPORTED_RISK_BUSINESS_STAGES = {"expense_application", "reimbursement"}
|
||||
|
||||
def evaluate_platform_risk_rules(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
rule_codes: list[str] | None = None,
|
||||
business_stage: str | None = None,
|
||||
) -> dict[str, list[Any]]:
|
||||
manifests = self._load_platform_risk_rule_manifests(rule_codes=rule_codes)
|
||||
normalized_stage = self._normalize_platform_risk_business_stage(business_stage)
|
||||
manifests = self._load_platform_risk_rule_manifests(
|
||||
rule_codes=rule_codes,
|
||||
business_stage=normalized_stage,
|
||||
)
|
||||
if not manifests:
|
||||
return {"flags": [], "blocking_reasons": []}
|
||||
|
||||
@@ -69,6 +78,7 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
self,
|
||||
*,
|
||||
rule_codes: list[str] | None,
|
||||
business_stage: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
code_filter = {
|
||||
str(code or "").strip() for code in list(rule_codes or []) if str(code or "").strip()
|
||||
@@ -117,7 +127,10 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
manifest_code = str(payload.get("rule_code") or rule_code).strip()
|
||||
if not manifest_code or (code_filter and manifest_code not in code_filter):
|
||||
continue
|
||||
if payload.get("enabled") is False:
|
||||
if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage(
|
||||
payload,
|
||||
business_stage=business_stage,
|
||||
):
|
||||
continue
|
||||
|
||||
payload = dict(payload)
|
||||
@@ -149,7 +162,10 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
continue
|
||||
if code_filter and rule_code not in missing_codes:
|
||||
continue
|
||||
if payload.get("enabled") is False:
|
||||
if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage(
|
||||
payload,
|
||||
business_stage=business_stage,
|
||||
):
|
||||
continue
|
||||
payload = dict(payload)
|
||||
payload["_rule_version"] = "v1.0.0"
|
||||
@@ -157,6 +173,34 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
|
||||
return list(manifests_by_code.values())
|
||||
|
||||
@classmethod
|
||||
def _normalize_platform_risk_business_stage(cls, value: str | None) -> str:
|
||||
normalized = str(value or cls._DEFAULT_RISK_BUSINESS_STAGE).strip().lower()
|
||||
if not normalized or normalized not in cls._SUPPORTED_RISK_BUSINESS_STAGES:
|
||||
return cls._DEFAULT_RISK_BUSINESS_STAGE
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def _risk_manifest_matches_business_stage(
|
||||
cls,
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
business_stage: str | None,
|
||||
) -> bool:
|
||||
if not business_stage:
|
||||
return True
|
||||
applies_to = manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
|
||||
raw_stages = applies_to.get("business_stages")
|
||||
if not isinstance(raw_stages, list):
|
||||
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||
raw_stages = [manifest.get("business_stage") or metadata.get("business_stage") or cls._DEFAULT_RISK_BUSINESS_STAGE]
|
||||
stages = {
|
||||
cls._normalize_platform_risk_business_stage(str(item))
|
||||
for item in raw_stages
|
||||
if str(item or "").strip()
|
||||
}
|
||||
return business_stage in (stages or {cls._DEFAULT_RISK_BUSINESS_STAGE})
|
||||
|
||||
def _risk_manifest_applies_to_claim(
|
||||
self,
|
||||
manifest: dict[str, Any],
|
||||
@@ -187,9 +231,19 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
configured_expense_types = self._normalize_expense_type_values(
|
||||
*[str(value or "") for value in list(applies_to.get("expense_types") or [])]
|
||||
)
|
||||
configured_expense_categories = self._normalize_expense_type_values(
|
||||
*[str(value or "") for value in list(applies_to.get("expense_categories") or [])]
|
||||
)
|
||||
|
||||
if self._is_all_expense_scope(configured_expense_types):
|
||||
configured_expense_types = set()
|
||||
if self._is_all_expense_scope(configured_expense_categories):
|
||||
configured_expense_categories = set()
|
||||
|
||||
if configured_expense_types and not (expense_types & configured_expense_types):
|
||||
return False
|
||||
if configured_expense_categories and not (expense_types & configured_expense_categories):
|
||||
return False
|
||||
if domains and not self._risk_domains_match_claim(
|
||||
domains,
|
||||
expense_types=expense_types,
|
||||
@@ -207,11 +261,19 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
if not raw:
|
||||
continue
|
||||
normalized.add(raw.lower())
|
||||
if raw in {"全部", "通用"}:
|
||||
normalized.add("all")
|
||||
if raw.lower().endswith("_application"):
|
||||
normalized.add(raw.lower().removesuffix("_application"))
|
||||
resolved = resolve_expense_type_code_from_text(raw)
|
||||
if resolved:
|
||||
normalized.add(resolved)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _is_all_expense_scope(values: set[str]) -> bool:
|
||||
return bool(values & {"all", "*", "overall", "general", "全部", "通用"})
|
||||
|
||||
def _risk_domains_match_claim(
|
||||
self,
|
||||
domains: set[str],
|
||||
@@ -634,25 +696,12 @@ class ExpenseClaimPlatformRiskMixin:
|
||||
message: str,
|
||||
evidence: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {}
|
||||
fail_outcome = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
|
||||
severity = str(fail_outcome.get("severity") or "medium").strip().lower() or "medium"
|
||||
default_action = "block" if severity in {"high", "critical"} else "manual_review"
|
||||
action = str(fail_outcome.get("action") or default_action).strip()
|
||||
label = str(manifest.get("name") or manifest.get("rule_code") or "风险规则命中").strip()
|
||||
|
||||
return {
|
||||
"source": "submission_review",
|
||||
"hit_source": "rule_center",
|
||||
"rule_type": "risk",
|
||||
"rule_code": str(manifest.get("rule_code") or "").strip(),
|
||||
"rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(),
|
||||
"severity": severity,
|
||||
"action": action,
|
||||
"label": label,
|
||||
"message": message,
|
||||
"evidence": evidence,
|
||||
}
|
||||
return build_platform_risk_flag(
|
||||
manifest,
|
||||
message=message,
|
||||
evidence=evidence,
|
||||
default_business_stage=self._DEFAULT_RISK_BUSINESS_STAGE,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _count_values(values: list[str]) -> dict[str, int]:
|
||||
|
||||
76
server/src/app/services/expense_claim_platform_risk_flag.py
Normal file
76
server/src/app/services/expense_claim_platform_risk_flag.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.services.expense_claim_risk_stage import (
|
||||
infer_risk_domain,
|
||||
normalize_risk_business_stage,
|
||||
normalize_risk_actionability,
|
||||
normalize_risk_visibility_scope,
|
||||
with_risk_business_stage,
|
||||
)
|
||||
|
||||
|
||||
def build_platform_risk_flag(
|
||||
manifest: dict[str, Any],
|
||||
*,
|
||||
message: str,
|
||||
evidence: dict[str, Any],
|
||||
default_business_stage: str,
|
||||
) -> dict[str, Any]:
|
||||
outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {}
|
||||
fail_outcome = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {}
|
||||
severity = str(fail_outcome.get("severity") or "medium").strip().lower() or "medium"
|
||||
default_action = "block" if severity in {"high", "critical"} else "manual_review"
|
||||
action = str(fail_outcome.get("action") or default_action).strip()
|
||||
label = str(manifest.get("name") or manifest.get("rule_code") or "风险规则命中").strip()
|
||||
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||
applies_to = manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {}
|
||||
raw_stages = applies_to.get("business_stages")
|
||||
applies_to_stage = raw_stages[0] if isinstance(raw_stages, list) and raw_stages else None
|
||||
business_stage = normalize_risk_business_stage(
|
||||
manifest.get("business_stage") or metadata.get("business_stage") or applies_to_stage,
|
||||
default=default_business_stage,
|
||||
)
|
||||
risk_domain = infer_risk_domain(manifest)
|
||||
default_visibility_scope = (
|
||||
"budget_manager"
|
||||
if risk_domain == "budget"
|
||||
else "leader"
|
||||
if business_stage == "expense_application"
|
||||
else "submitter"
|
||||
)
|
||||
default_actionability = (
|
||||
"budget_governance"
|
||||
if risk_domain == "budget"
|
||||
else "review_decision"
|
||||
if business_stage == "expense_application"
|
||||
else "fixable_by_submitter"
|
||||
)
|
||||
visibility_scope = normalize_risk_visibility_scope(
|
||||
metadata.get("visibility_scope") or manifest.get("visibility_scope"),
|
||||
default_visibility_scope,
|
||||
)
|
||||
actionability = normalize_risk_actionability(
|
||||
metadata.get("actionability") or manifest.get("actionability"),
|
||||
default_actionability,
|
||||
)
|
||||
|
||||
return with_risk_business_stage(
|
||||
{
|
||||
"source": "submission_review",
|
||||
"hit_source": "rule_center",
|
||||
"rule_type": "risk",
|
||||
"rule_code": str(manifest.get("rule_code") or "").strip(),
|
||||
"rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(),
|
||||
"severity": severity,
|
||||
"action": action,
|
||||
"label": label,
|
||||
"message": message,
|
||||
"evidence": evidence,
|
||||
"risk_domain": risk_domain,
|
||||
"visibility_scope": visibility_scope,
|
||||
"actionability": actionability,
|
||||
},
|
||||
business_stage,
|
||||
)
|
||||
@@ -27,6 +27,7 @@ from app.services.expense_claim_constants import (
|
||||
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
|
||||
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
||||
)
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.expense_rule_runtime import (
|
||||
ExpenseRuleRuntimeService,
|
||||
RuntimeTravelPolicy,
|
||||
@@ -135,7 +136,7 @@ class ExpenseClaimPolicyReviewMixin:
|
||||
)
|
||||
|
||||
return {
|
||||
"flags": flags,
|
||||
"flags": [with_risk_business_stage(flag, "reimbursement") for flag in flags],
|
||||
"blocking_reasons": list(dict.fromkeys(reason for reason in blocking_reasons if reason)),
|
||||
}
|
||||
|
||||
@@ -393,7 +394,7 @@ class ExpenseClaimPolicyReviewMixin:
|
||||
blocking_reasons.append("交通舱位或席别超出当前职级差标,且未补充例外说明。")
|
||||
|
||||
return {
|
||||
"flags": flags,
|
||||
"flags": [with_risk_business_stage(flag, "reimbursement") for flag in flags],
|
||||
"blocking_reasons": list(dict.fromkeys(reason for reason in blocking_reasons if reason)),
|
||||
}
|
||||
|
||||
|
||||
141
server/src/app/services/expense_claim_pre_review.py
Normal file
141
server/src/app/services/expense_claim_pre_review.py
Normal file
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
|
||||
from app.services.expense_claim_risk_stage import risk_business_stage_for_claim, with_risk_business_stage
|
||||
|
||||
|
||||
class ExpenseClaimPreReviewMixin:
|
||||
def pre_review_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
self._ensure_draft_claim(claim)
|
||||
self._access_policy.backfill_claim_identity_from_current_user(claim, current_user)
|
||||
is_application_claim = self._is_expense_application_claim(claim)
|
||||
if not is_application_claim:
|
||||
self._sync_claim_from_items(claim)
|
||||
missing_fields = (
|
||||
self._validate_application_claim_for_submission(claim)
|
||||
if is_application_claim
|
||||
else self._validate_claim_for_submission(claim)
|
||||
)
|
||||
if missing_fields:
|
||||
raise ExpenseClaimSubmissionBlockedError(missing_fields)
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
reviewed_at = datetime.now(UTC)
|
||||
if is_application_claim:
|
||||
preserved_flags = [
|
||||
flag
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
if not (
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "submission_review"
|
||||
and str(flag.get("hit_source") or "").strip() == "rule_center"
|
||||
)
|
||||
]
|
||||
application_review = self.evaluate_platform_risk_rules(
|
||||
claim,
|
||||
business_stage="expense_application",
|
||||
)
|
||||
review_flags = [*preserved_flags, *list(application_review.get("flags") or [])]
|
||||
blocking_count = self._count_ai_pre_review_blocking_risks(review_flags)
|
||||
passed = blocking_count <= 0
|
||||
else:
|
||||
review_result = self._run_ai_submission_review(claim)
|
||||
review_flags = list(review_result.get("risk_flags") or [])
|
||||
blocking_count = self._count_ai_pre_review_blocking_risks(review_flags)
|
||||
passed = blocking_count <= 0
|
||||
|
||||
claim.risk_flags_json = self._replace_ai_pre_review_flag(
|
||||
review_flags,
|
||||
self._build_ai_pre_review_flag(
|
||||
passed=passed,
|
||||
blocking_count=blocking_count,
|
||||
reviewed_at=reviewed_at,
|
||||
business_stage=risk_business_stage_for_claim(
|
||||
is_application_claim=is_application_claim,
|
||||
),
|
||||
),
|
||||
)
|
||||
claim.approval_stage = "AI预审" if not is_application_claim else claim.approval_stage
|
||||
claim.submitted_at = None
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=current_user.name or current_user.username,
|
||||
action="expense_claim.pre_review",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
return claim
|
||||
|
||||
@staticmethod
|
||||
def _count_ai_pre_review_blocking_risks(risk_flags: list[Any]) -> int:
|
||||
return sum(
|
||||
1
|
||||
for flag in risk_flags
|
||||
if (
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() != "ai_pre_review"
|
||||
and str(flag.get("severity") or "").strip().lower() == "high"
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_ai_pre_review_flag(
|
||||
*,
|
||||
passed: bool,
|
||||
blocking_count: int,
|
||||
reviewed_at: datetime,
|
||||
business_stage: str,
|
||||
) -> dict[str, Any]:
|
||||
if passed:
|
||||
message = "AI预审通过,费用明细和附件可进入下一步提交审批。"
|
||||
else:
|
||||
message = f"AI预审发现 {blocking_count} 条重大风险,请逐条填写原因后再进入下一步。"
|
||||
|
||||
return with_risk_business_stage(
|
||||
{
|
||||
"source": "ai_pre_review",
|
||||
"event_type": "expense_claim_ai_pre_review",
|
||||
"severity": "info" if passed else "high",
|
||||
"label": "AI预审通过" if passed else "AI预审未通过",
|
||||
"message": message,
|
||||
"status": "passed" if passed else "failed",
|
||||
"passed": passed,
|
||||
"blocking_risk_count": blocking_count,
|
||||
"next_action": "next_step" if passed else "risk_explanation_required",
|
||||
"created_at": reviewed_at.isoformat(),
|
||||
},
|
||||
business_stage,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _replace_ai_pre_review_flag(
|
||||
risk_flags: list[Any],
|
||||
next_flag: dict[str, Any],
|
||||
) -> list[Any]:
|
||||
preserved_flags = [
|
||||
flag
|
||||
for flag in list(risk_flags or [])
|
||||
if not (
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() == "ai_pre_review"
|
||||
)
|
||||
]
|
||||
return [*preserved_flags, next_flag]
|
||||
@@ -15,6 +15,7 @@ from app.services.expense_claim_constants import (
|
||||
from app.services.expense_claim_item_sync import ExpenseClaimItemSyncMixin
|
||||
from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin
|
||||
from app.services.expense_claim_policy_review import ExpenseClaimPolicyReviewMixin
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
logger = get_logger("app.services.expense_claim_risk_review")
|
||||
@@ -159,6 +160,8 @@ class ExpenseClaimRiskReviewMixin(
|
||||
},
|
||||
)
|
||||
|
||||
review_flags = [with_risk_business_stage(flag, "reimbursement") for flag in review_flags]
|
||||
|
||||
return {
|
||||
"status": "submitted",
|
||||
"approval_stage": "直属领导审批",
|
||||
|
||||
228
server/src/app/services/expense_claim_risk_stage.py
Normal file
228
server/src/app/services/expense_claim_risk_stage.py
Normal file
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
EXPENSE_APPLICATION_BUSINESS_STAGE = "expense_application"
|
||||
REIMBURSEMENT_BUSINESS_STAGE = "reimbursement"
|
||||
SUPPORTED_RISK_BUSINESS_STAGES = {
|
||||
EXPENSE_APPLICATION_BUSINESS_STAGE,
|
||||
REIMBURSEMENT_BUSINESS_STAGE,
|
||||
}
|
||||
SUPPORTED_RISK_DOMAINS = {
|
||||
"budget",
|
||||
"policy",
|
||||
"invoice",
|
||||
"trip",
|
||||
"amount",
|
||||
"workflow",
|
||||
"profile",
|
||||
}
|
||||
SUPPORTED_RISK_VISIBILITY_SCOPES = {
|
||||
"submitter",
|
||||
"leader",
|
||||
"budget_manager",
|
||||
"finance",
|
||||
"admin",
|
||||
}
|
||||
SUPPORTED_RISK_ACTIONABILITIES = {
|
||||
"fixable_by_submitter",
|
||||
"review_decision",
|
||||
"budget_governance",
|
||||
"finance_check",
|
||||
"system_trace",
|
||||
}
|
||||
_NON_RISK_SYSTEM_SOURCES = {
|
||||
"application_detail",
|
||||
"application_handoff",
|
||||
"application_submission",
|
||||
"approval",
|
||||
"approval_log",
|
||||
"approval_routing",
|
||||
"budget_approval",
|
||||
"expense_claim_approval",
|
||||
"expense_claim_finance_approval",
|
||||
"finance_approval",
|
||||
"manual_approval",
|
||||
"payment",
|
||||
"sla_reminder",
|
||||
"reminder",
|
||||
"urge",
|
||||
}
|
||||
|
||||
|
||||
def normalize_risk_business_stage(value: Any, default: str = REIMBURSEMENT_BUSINESS_STAGE) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized in SUPPORTED_RISK_BUSINESS_STAGES:
|
||||
return normalized
|
||||
return default
|
||||
|
||||
|
||||
def normalize_risk_domain(value: Any, default: str = "policy") -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized in SUPPORTED_RISK_DOMAINS:
|
||||
return normalized
|
||||
return default
|
||||
|
||||
|
||||
def normalize_risk_visibility_scope(value: Any, default: str = "leader") -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized in SUPPORTED_RISK_VISIBILITY_SCOPES:
|
||||
return normalized
|
||||
return default
|
||||
|
||||
|
||||
def normalize_risk_actionability(value: Any, default: str = "review_decision") -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if normalized in SUPPORTED_RISK_ACTIONABILITIES:
|
||||
return normalized
|
||||
return default
|
||||
|
||||
|
||||
def risk_business_stage_for_claim(*, is_application_claim: bool) -> str:
|
||||
return EXPENSE_APPLICATION_BUSINESS_STAGE if is_application_claim else REIMBURSEMENT_BUSINESS_STAGE
|
||||
|
||||
|
||||
def risk_flag_business_stage(flag: dict[str, Any], default: str = "") -> str:
|
||||
return normalize_risk_business_stage(
|
||||
flag.get("business_stage")
|
||||
or flag.get("businessStage")
|
||||
or flag.get("control_stage")
|
||||
or flag.get("controlStage"),
|
||||
default=default,
|
||||
)
|
||||
|
||||
|
||||
def infer_risk_domain(flag: dict[str, Any]) -> str:
|
||||
explicit_domain = (
|
||||
flag.get("risk_domain")
|
||||
or flag.get("riskDomain")
|
||||
or flag.get("domain")
|
||||
)
|
||||
if explicit_domain:
|
||||
return normalize_risk_domain(explicit_domain)
|
||||
|
||||
source = str(flag.get("source") or "").strip().lower()
|
||||
event_type = str(flag.get("event_type") or flag.get("eventType") or "").strip().lower()
|
||||
if source == "budget_control" or "budget" in event_type:
|
||||
return "budget"
|
||||
if source in {"manual_return", "approval_routing"}:
|
||||
return "workflow"
|
||||
if source in {"attachment_analysis"}:
|
||||
return "invoice"
|
||||
if source in {"financial_risk_graph"}:
|
||||
return "profile"
|
||||
|
||||
corpus = " ".join(
|
||||
str(value or "")
|
||||
for value in [
|
||||
flag.get("rule_code"),
|
||||
flag.get("risk_category"),
|
||||
flag.get("ontology_signal"),
|
||||
flag.get("label"),
|
||||
flag.get("name"),
|
||||
flag.get("title"),
|
||||
flag.get("message"),
|
||||
flag.get("summary"),
|
||||
flag.get("description"),
|
||||
]
|
||||
).lower()
|
||||
if any(token in corpus for token in ["预算", "budget"]):
|
||||
return "budget"
|
||||
if any(token in corpus for token in ["发票", "票据", "单据", "附件", "ocr", "invoice", "receipt"]):
|
||||
return "invoice"
|
||||
trip_tokens = [
|
||||
"行程",
|
||||
"城市",
|
||||
"住宿",
|
||||
"交通",
|
||||
"差旅",
|
||||
"酒店",
|
||||
"日期",
|
||||
"时间",
|
||||
"trip",
|
||||
"travel",
|
||||
"city",
|
||||
"hotel",
|
||||
"transport",
|
||||
"period",
|
||||
]
|
||||
if any(token in corpus for token in trip_tokens):
|
||||
return "trip"
|
||||
if any(token in corpus for token in ["金额", "超标", "阈值", "额度", "标准", "amount", "limit", "over"]):
|
||||
return "amount"
|
||||
if any(token in corpus for token in ["历史", "画像", "异常关系", "profile", "baseline"]):
|
||||
return "profile"
|
||||
if any(token in corpus for token in ["审批", "退回", "流程", "付款", "routing", "approval", "return", "payment"]):
|
||||
return "workflow"
|
||||
return "policy"
|
||||
|
||||
|
||||
def infer_risk_semantics(
|
||||
flag: dict[str, Any],
|
||||
*,
|
||||
business_stage: str,
|
||||
) -> tuple[str, str, str]:
|
||||
risk_domain = infer_risk_domain(flag)
|
||||
source = str(flag.get("source") or "").strip().lower()
|
||||
|
||||
if source in _NON_RISK_SYSTEM_SOURCES:
|
||||
return risk_domain, "admin", "system_trace"
|
||||
if risk_domain == "budget":
|
||||
return risk_domain, "budget_manager", "budget_governance"
|
||||
if source == "attachment_analysis":
|
||||
return risk_domain, "submitter", "fixable_by_submitter"
|
||||
if risk_domain == "profile":
|
||||
return risk_domain, "leader", "review_decision"
|
||||
if business_stage == REIMBURSEMENT_BUSINESS_STAGE:
|
||||
if risk_domain in {"policy", "invoice", "trip", "amount"}:
|
||||
return risk_domain, "submitter", "fixable_by_submitter"
|
||||
return risk_domain, "finance", "finance_check"
|
||||
if business_stage == EXPENSE_APPLICATION_BUSINESS_STAGE:
|
||||
return risk_domain, "leader", "review_decision"
|
||||
return risk_domain, "leader", "review_decision"
|
||||
|
||||
|
||||
def enrich_risk_flag_semantics(
|
||||
flag: dict[str, Any],
|
||||
*,
|
||||
business_stage: str | None = None,
|
||||
risk_domain: str | None = None,
|
||||
visibility_scope: str | None = None,
|
||||
actionability: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
stage = normalize_risk_business_stage(
|
||||
business_stage
|
||||
or flag.get("business_stage")
|
||||
or flag.get("businessStage")
|
||||
or flag.get("control_stage")
|
||||
or flag.get("controlStage")
|
||||
)
|
||||
inferred_domain, inferred_scope, inferred_actionability = infer_risk_semantics(
|
||||
flag,
|
||||
business_stage=stage,
|
||||
)
|
||||
domain = normalize_risk_domain(risk_domain or flag.get("risk_domain") or flag.get("riskDomain"), inferred_domain)
|
||||
scope = normalize_risk_visibility_scope(
|
||||
visibility_scope or flag.get("visibility_scope") or flag.get("visibilityScope"),
|
||||
inferred_scope,
|
||||
)
|
||||
action = normalize_risk_actionability(
|
||||
actionability or flag.get("actionability"),
|
||||
inferred_actionability,
|
||||
)
|
||||
|
||||
return {
|
||||
**flag,
|
||||
"business_stage": stage,
|
||||
"businessStage": stage,
|
||||
"risk_domain": domain,
|
||||
"visibility_scope": scope,
|
||||
"actionability": action,
|
||||
}
|
||||
|
||||
|
||||
def with_risk_business_stage(
|
||||
flag: dict[str, Any],
|
||||
business_stage: str,
|
||||
) -> dict[str, Any]:
|
||||
return enrich_risk_flag_semantics(flag, business_stage=business_stage)
|
||||
@@ -36,6 +36,7 @@ from app.services.document_intelligence import build_document_insight
|
||||
from app.services.document_numbering import is_application_claim_no
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
|
||||
from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin
|
||||
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
|
||||
@@ -49,8 +50,10 @@ from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
|
||||
from app.services.expense_claim_draft_persistence import ExpenseClaimDraftPersistenceMixin
|
||||
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
|
||||
from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin
|
||||
from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin
|
||||
from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin
|
||||
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin
|
||||
from app.services.expense_claim_constants import (
|
||||
EXPENSE_TYPE_LABELS,
|
||||
@@ -131,7 +134,9 @@ from app.services.ocr import OcrService
|
||||
class ExpenseClaimService(
|
||||
ExpenseClaimPaginationMixin,
|
||||
ExpenseClaimApprovalFlowMixin,
|
||||
ExpenseClaimApprovalRoutingMixin,
|
||||
ExpenseClaimApplicationHandoffMixin,
|
||||
ExpenseClaimPreReviewMixin,
|
||||
ExpenseClaimBudgetFlowMixin,
|
||||
ExpenseClaimAttachmentOperationsMixin,
|
||||
ExpenseClaimReviewPreviewMixin,
|
||||
@@ -197,7 +202,7 @@ class ExpenseClaimService(
|
||||
.order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc())
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
return self._access_policy.attach_budget_approval_snapshots(list(self.db.scalars(stmt).all()))
|
||||
|
||||
def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
@@ -210,7 +215,7 @@ class ExpenseClaimService(
|
||||
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
|
||||
)
|
||||
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
return self._access_policy.attach_budget_approval_snapshots(list(self.db.scalars(stmt).all()))
|
||||
|
||||
def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
|
||||
stmt = (
|
||||
@@ -236,7 +241,7 @@ class ExpenseClaimService(
|
||||
.where(ExpenseClaim.id == claim_id)
|
||||
)
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
|
||||
return self.db.scalar(stmt)
|
||||
return self._access_policy.attach_budget_approval_snapshot(self.db.scalar(stmt))
|
||||
|
||||
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
|
||||
if claim is None:
|
||||
@@ -468,27 +473,44 @@ class ExpenseClaimService(
|
||||
for flag in list(claim.risk_flags_json or [])
|
||||
if not (
|
||||
isinstance(flag, dict)
|
||||
and str(flag.get("source") or "").strip() in {"submission_review", "attachment_analysis"}
|
||||
and str(flag.get("source") or "").strip()
|
||||
in {"submission_review", "attachment_analysis"}
|
||||
)
|
||||
]
|
||||
submit_flag = {
|
||||
"source": "application_submission",
|
||||
"event_type": "expense_application_submission",
|
||||
"severity": "info",
|
||||
"label": "申请提交",
|
||||
"message": "费用申请已提交至直属领导审批,请等待审核结果。",
|
||||
"previous_status": str(claim.status or "").strip(),
|
||||
"previous_approval_stage": str(claim.approval_stage or "").strip(),
|
||||
"next_status": "submitted",
|
||||
"next_approval_stage": "直属领导审批",
|
||||
"created_at": submitted_at.isoformat(),
|
||||
}
|
||||
platform_review = self.evaluate_platform_risk_rules(
|
||||
claim,
|
||||
business_stage="expense_application",
|
||||
)
|
||||
platform_flags = list(platform_review.get("flags") or [])
|
||||
submit_flag = with_risk_business_stage(
|
||||
{
|
||||
"source": "application_submission",
|
||||
"event_type": "expense_application_submission",
|
||||
"severity": "info",
|
||||
"label": "申请提交",
|
||||
"message": "费用申请已提交至直属领导审批,请等待审核结果。",
|
||||
"previous_status": str(claim.status or "").strip(),
|
||||
"previous_approval_stage": str(claim.approval_stage or "").strip(),
|
||||
"next_status": "submitted",
|
||||
"next_approval_stage": "直属领导审批",
|
||||
"created_at": submitted_at.isoformat(),
|
||||
},
|
||||
"expense_application",
|
||||
)
|
||||
claim.status = "submitted"
|
||||
claim.approval_stage = "直属领导审批"
|
||||
claim.risk_flags_json = self._append_budget_flags([*preserved_flags, submit_flag], budget_flags)
|
||||
claim.risk_flags_json = self._append_budget_flags(
|
||||
[*preserved_flags, submit_flag, *platform_flags],
|
||||
budget_flags,
|
||||
business_stage="expense_application",
|
||||
)
|
||||
claim.submitted_at = submitted_at
|
||||
else:
|
||||
claim.risk_flags_json = self._append_budget_flags(claim.risk_flags_json, budget_flags)
|
||||
claim.risk_flags_json = self._append_budget_flags(
|
||||
claim.risk_flags_json,
|
||||
budget_flags,
|
||||
business_stage="reimbursement",
|
||||
)
|
||||
review_result = self._run_ai_submission_review(claim)
|
||||
|
||||
claim.status = str(review_result.get("status") or "supplement")
|
||||
@@ -681,6 +703,7 @@ class ExpenseClaimService(
|
||||
claim.risk_flags_json = self._append_budget_flags(
|
||||
[*list(claim.risk_flags_json or []), return_flag],
|
||||
budget_flags,
|
||||
business_stage="expense_application" if is_application_claim else "reimbursement",
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
@@ -717,10 +740,6 @@ class ExpenseClaimService(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
402
server/src/app/services/hermes_risk_clue_collector.py
Normal file
402
server/src/app/services/hermes_risk_clue_collector.py
Normal file
@@ -0,0 +1,402 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
||||
from app.services.document_numbering import is_application_claim_no
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
|
||||
class HermesRiskClueCollectorService:
|
||||
"""归集待人工复核线索,不生成、不改写、不发布规则。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def collect_risk_clues(
|
||||
self,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
RiskObservationService(self.db).ensure_storage_ready()
|
||||
safe_limit = max(1, min(int(limit or 100), 200))
|
||||
claims = self._fetch_recent_claims(safe_limit)
|
||||
observations = self._fetch_recent_observations(safe_limit * 2)
|
||||
feedback_items = self._fetch_recent_feedback(safe_limit)
|
||||
|
||||
facts = [self._claim_fact(claim) for claim in claims]
|
||||
claim_rule_hits = self._claim_rule_hits(claims)
|
||||
observation_rule_hits = self._observation_rule_hits(observations)
|
||||
rule_hits = self._dedupe_by_id([*observation_rule_hits, *claim_rule_hits])
|
||||
evidence_refs = self._evidence_refs(observations, claim_rule_hits)
|
||||
risk_clues = self._risk_clues(
|
||||
observations=observations,
|
||||
claim_rule_hits=claim_rule_hits,
|
||||
evidence_refs=evidence_refs,
|
||||
)
|
||||
feedback_summary = self._feedback_summary(feedback_items)
|
||||
|
||||
message = (
|
||||
"风险线索归集完成:"
|
||||
f"读取 {len(facts)} 条申请/报销事实,"
|
||||
f"整理 {len(rule_hits)} 条规则命中,"
|
||||
f"输出 {len(risk_clues)} 条待人工复核线索。"
|
||||
)
|
||||
return {
|
||||
"message": message,
|
||||
"task_type": "risk_clue_collect",
|
||||
"output_format": "risk_clue_review_packet",
|
||||
"run_id": run_id,
|
||||
"fact_count": len(facts),
|
||||
"rule_hit_count": len(rule_hits),
|
||||
"risk_clue_count": len(risk_clues),
|
||||
"evidence_ref_count": len(evidence_refs),
|
||||
"facts": facts,
|
||||
"rule_hits": rule_hits,
|
||||
"risk_clues": risk_clues,
|
||||
"evidence_refs": evidence_refs,
|
||||
"feedback_summary": feedback_summary,
|
||||
"human_review_required": True,
|
||||
"writes_rules": False,
|
||||
"role_boundary": (
|
||||
"规则由人定义,风险由人确认,主流程由外层智能体执行,"
|
||||
"数字员工只读取事实、规则命中和反馈结果,生成后台分析、报告和待复核材料。"
|
||||
),
|
||||
"allowed_outputs": [
|
||||
"facts",
|
||||
"rule_hits",
|
||||
"risk_clues",
|
||||
"evidence_refs",
|
||||
"human_review_required",
|
||||
],
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
def _fetch_recent_claims(self, limit: int) -> list[ExpenseClaim]:
|
||||
stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.desc()).limit(limit)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def _fetch_recent_observations(self, limit: int) -> list[RiskObservation]:
|
||||
stmt = (
|
||||
select(RiskObservation)
|
||||
.options(selectinload(RiskObservation.feedback_items))
|
||||
.order_by(RiskObservation.risk_score.desc(), RiskObservation.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def _fetch_recent_feedback(self, limit: int) -> list[RiskObservationFeedback]:
|
||||
stmt = (
|
||||
select(RiskObservationFeedback)
|
||||
.options(selectinload(RiskObservationFeedback.observation))
|
||||
.order_by(RiskObservationFeedback.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def _claim_fact(self, claim: ExpenseClaim) -> dict[str, Any]:
|
||||
return {
|
||||
"fact_id": f"fact:claim:{claim.id}",
|
||||
"source": "expense_claims",
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"claim_kind": "application" if is_application_claim_no(claim.claim_no) else "reimbursement",
|
||||
"employee_name": claim.employee_name,
|
||||
"department_name": claim.department_name,
|
||||
"expense_type": claim.expense_type,
|
||||
"amount": _decimal_to_float(claim.amount),
|
||||
"currency": claim.currency,
|
||||
"status": claim.status,
|
||||
"approval_stage": claim.approval_stage,
|
||||
"occurred_at": _isoformat(claim.occurred_at),
|
||||
"submitted_at": _isoformat(claim.submitted_at),
|
||||
"risk_flag_count": len(list(claim.risk_flags_json or [])),
|
||||
}
|
||||
|
||||
def _claim_rule_hits(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||
hits: list[dict[str, Any]] = []
|
||||
for claim in claims:
|
||||
for index, flag in enumerate(list(claim.risk_flags_json or [])):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
signal = _text(
|
||||
flag.get("risk_signal")
|
||||
or flag.get("risk_type")
|
||||
or flag.get("rule_code")
|
||||
or flag.get("code")
|
||||
or flag.get("label")
|
||||
)
|
||||
if not signal:
|
||||
continue
|
||||
rule_code = _text(flag.get("rule_code") or flag.get("code") or signal)
|
||||
hits.append(
|
||||
{
|
||||
"hit_id": f"rule_hit:claim:{claim.id}:{rule_code}:{index}",
|
||||
"source": _text(flag.get("source")) or "claim_risk_flags",
|
||||
"rule_code": rule_code,
|
||||
"risk_signal": signal,
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"title": _text(flag.get("label") or flag.get("title")) or signal,
|
||||
"message": _text(flag.get("message") or flag.get("summary") or flag.get("reason")),
|
||||
"severity": _text(flag.get("severity") or flag.get("risk_level")),
|
||||
"metadata": flag,
|
||||
}
|
||||
)
|
||||
return hits
|
||||
|
||||
def _observation_rule_hits(self, observations: list[RiskObservation]) -> list[dict[str, Any]]:
|
||||
hits: list[dict[str, Any]] = []
|
||||
for observation in observations:
|
||||
if not _is_rule_hit_observation(observation):
|
||||
continue
|
||||
rule_code = _text(
|
||||
(observation.decision_trace_json or {}).get("rule_code")
|
||||
or (observation.policy_refs_json or [""])[0]
|
||||
or observation.risk_signal
|
||||
)
|
||||
hits.append(
|
||||
{
|
||||
"hit_id": f"rule_hit:observation:{observation.observation_key}",
|
||||
"source": observation.source or "risk_observation",
|
||||
"rule_code": rule_code,
|
||||
"risk_signal": observation.risk_signal,
|
||||
"claim_id": observation.claim_id,
|
||||
"claim_no": observation.claim_no,
|
||||
"title": observation.title,
|
||||
"message": observation.description,
|
||||
"severity": observation.risk_level,
|
||||
"observation_key": observation.observation_key,
|
||||
}
|
||||
)
|
||||
return hits
|
||||
|
||||
def _evidence_refs(
|
||||
self,
|
||||
observations: list[RiskObservation],
|
||||
claim_rule_hits: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
refs: list[dict[str, Any]] = []
|
||||
for observation in observations:
|
||||
for index, evidence in enumerate(list(observation.evidence_json or [])):
|
||||
if not isinstance(evidence, dict):
|
||||
continue
|
||||
refs.append(
|
||||
{
|
||||
"evidence_id": f"evidence:observation:{observation.observation_key}:{index}",
|
||||
"source": _text(evidence.get("source")) or observation.source or "risk_observation",
|
||||
"title": _text(evidence.get("title") or evidence.get("code")) or observation.title,
|
||||
"detail": _text(
|
||||
evidence.get("detail")
|
||||
or evidence.get("message")
|
||||
or evidence.get("summary")
|
||||
),
|
||||
"claim_id": observation.claim_id,
|
||||
"claim_no": observation.claim_no,
|
||||
"observation_key": observation.observation_key,
|
||||
}
|
||||
)
|
||||
for hit in claim_rule_hits:
|
||||
refs.append(
|
||||
{
|
||||
"evidence_id": f"evidence:{hit['hit_id']}",
|
||||
"source": hit["source"],
|
||||
"title": hit["title"],
|
||||
"detail": hit["message"] or "单据风险标记记录了该规则命中。",
|
||||
"claim_id": hit["claim_id"],
|
||||
"claim_no": hit["claim_no"],
|
||||
"rule_hit_id": hit["hit_id"],
|
||||
}
|
||||
)
|
||||
return refs
|
||||
|
||||
def _risk_clues(
|
||||
self,
|
||||
*,
|
||||
observations: list[RiskObservation],
|
||||
claim_rule_hits: list[dict[str, Any]],
|
||||
evidence_refs: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
clues = [
|
||||
self._observation_clue(observation, evidence_refs)
|
||||
for observation in observations
|
||||
if _needs_human_review(observation)
|
||||
]
|
||||
observed_claim_signals = {
|
||||
(clue.get("claim_id"), clue.get("risk_signal"))
|
||||
for clue in clues
|
||||
if clue.get("claim_id") and clue.get("risk_signal")
|
||||
}
|
||||
for hit in claim_rule_hits:
|
||||
key = (hit.get("claim_id"), hit.get("risk_signal"))
|
||||
if key in observed_claim_signals:
|
||||
continue
|
||||
clues.append(self._claim_flag_clue(hit, evidence_refs))
|
||||
clues.sort(key=lambda item: float(item.get("confidence_score") or 0), reverse=True)
|
||||
return clues[:30]
|
||||
|
||||
def _observation_clue(
|
||||
self,
|
||||
observation: RiskObservation,
|
||||
evidence_refs: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
evidence_ids = [
|
||||
item["evidence_id"]
|
||||
for item in evidence_refs
|
||||
if item.get("observation_key") == observation.observation_key
|
||||
]
|
||||
confidence = _confidence(observation.confidence_score, observation.risk_score)
|
||||
return {
|
||||
"clue_id": f"risk_clue:observation:{observation.observation_key}",
|
||||
"source": "risk_observation",
|
||||
"status": "human_review_required",
|
||||
"observation_key": observation.observation_key,
|
||||
"feedback_status": observation.feedback_status,
|
||||
"claim_id": observation.claim_id,
|
||||
"claim_no": observation.claim_no,
|
||||
"subject_type": observation.subject_type,
|
||||
"subject_key": observation.subject_key,
|
||||
"risk_signal": observation.risk_signal,
|
||||
"risk_level": observation.risk_level,
|
||||
"title": observation.title or observation.risk_signal,
|
||||
"summary": observation.description
|
||||
or f"{observation.claim_no or observation.subject_label} 存在待复核线索。",
|
||||
"confidence_score": confidence,
|
||||
"evidence_refs": evidence_ids,
|
||||
"rule_hits": [
|
||||
f"rule_hit:observation:{observation.observation_key}"
|
||||
]
|
||||
if _is_rule_hit_observation(observation)
|
||||
else [],
|
||||
"fact_refs": [f"fact:claim:{observation.claim_id}"] if observation.claim_id else [],
|
||||
"review_reason": _review_reason(observation),
|
||||
"next_action": "人工复核事实、规则命中和证据来源。",
|
||||
"not_final_conclusion": True,
|
||||
}
|
||||
|
||||
def _claim_flag_clue(
|
||||
self,
|
||||
hit: dict[str, Any],
|
||||
evidence_refs: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
evidence_ids = [
|
||||
item["evidence_id"]
|
||||
for item in evidence_refs
|
||||
if item.get("rule_hit_id") == hit.get("hit_id")
|
||||
]
|
||||
return {
|
||||
"clue_id": f"risk_clue:{hit['hit_id']}",
|
||||
"source": "claim_risk_flags",
|
||||
"status": "human_review_required",
|
||||
"observation_key": "",
|
||||
"feedback_status": "unreviewed",
|
||||
"claim_id": hit.get("claim_id"),
|
||||
"claim_no": hit.get("claim_no"),
|
||||
"subject_type": "expense_claim",
|
||||
"subject_key": f"claim:{hit.get('claim_id')}",
|
||||
"risk_signal": hit.get("risk_signal"),
|
||||
"risk_level": hit.get("severity") or "medium",
|
||||
"title": hit.get("title") or hit.get("risk_signal"),
|
||||
"summary": hit.get("message") or "单据存在规则命中,需要人工复核事实与制度依据。",
|
||||
"confidence_score": 0.72,
|
||||
"evidence_refs": evidence_ids,
|
||||
"rule_hits": [hit["hit_id"]],
|
||||
"fact_refs": [f"fact:claim:{hit.get('claim_id')}"] if hit.get("claim_id") else [],
|
||||
"review_reason": "规则命中尚未形成已确认处置结论。",
|
||||
"next_action": "人工复核该规则命中是否需要补充风险观察或处置反馈。",
|
||||
"not_final_conclusion": True,
|
||||
}
|
||||
|
||||
def _feedback_summary(self, feedback_items: list[RiskObservationFeedback]) -> dict[str, Any]:
|
||||
counts = Counter(item.feedback_type for item in feedback_items)
|
||||
return {
|
||||
"total": len(feedback_items),
|
||||
"by_type": dict(counts),
|
||||
"recent": [
|
||||
{
|
||||
"feedback_id": item.id,
|
||||
"feedback_type": item.feedback_type,
|
||||
"action": item.action,
|
||||
"actor": item.actor,
|
||||
"observation_key": item.observation.observation_key if item.observation else "",
|
||||
"created_at": _isoformat(item.created_at),
|
||||
}
|
||||
for item in feedback_items[:10]
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _dedupe_by_id(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
deduped: dict[str, dict[str, Any]] = {}
|
||||
for item in items:
|
||||
key = _text(item.get("hit_id"))
|
||||
if key and key not in deduped:
|
||||
deduped[key] = item
|
||||
return list(deduped.values())
|
||||
|
||||
|
||||
def _is_rule_hit_observation(observation: RiskObservation) -> bool:
|
||||
if _text(observation.source) == "rule_center":
|
||||
return True
|
||||
if _number((observation.contribution_scores_json or {}).get("S_rule")) > 0:
|
||||
return True
|
||||
for evidence in list(observation.evidence_json or []):
|
||||
if isinstance(evidence, dict) and _text(evidence.get("source")) == "rule_center":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _needs_human_review(observation: RiskObservation) -> bool:
|
||||
status = _text(observation.status)
|
||||
feedback_status = _text(observation.feedback_status)
|
||||
if status in {"confirmed", "false_positive", "ignored", "resolved"}:
|
||||
return False
|
||||
if feedback_status in {"confirmed", "false_positive", "ignored", "resolved"}:
|
||||
return False
|
||||
return observation.risk_score >= 50 or observation.risk_level in {"medium", "high", "critical"}
|
||||
|
||||
|
||||
def _review_reason(observation: RiskObservation) -> str:
|
||||
if not observation.feedback_items:
|
||||
return "尚未记录人工复核反馈。"
|
||||
latest = observation.feedback_items[0]
|
||||
return latest.comment or f"最近反馈类型:{latest.feedback_type},仍需人工复核。"
|
||||
|
||||
|
||||
def _confidence(value: float | None, score: int) -> float:
|
||||
try:
|
||||
parsed = float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
parsed = 0
|
||||
if parsed <= 0:
|
||||
parsed = max(0.35, min(0.92, float(score or 0) / 100))
|
||||
return round(parsed, 2)
|
||||
|
||||
|
||||
def _decimal_to_float(value: Decimal | int | float | None) -> float:
|
||||
if value is None:
|
||||
return 0.0
|
||||
return float(value)
|
||||
|
||||
|
||||
def _number(value: object) -> float:
|
||||
try:
|
||||
return float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _isoformat(value: datetime | None) -> str:
|
||||
return value.isoformat() if value is not None else ""
|
||||
|
||||
|
||||
def _text(value: object) -> str:
|
||||
return str(value or "").strip()
|
||||
@@ -13,6 +13,7 @@ from app.algorithem.risk_graph import (
|
||||
from app.core.logging import get_logger
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.hermes_report import HermesRiskReport
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.risk_observations import RiskObservationService
|
||||
|
||||
logger = get_logger("app.services.hermes_risk_scanner")
|
||||
@@ -110,15 +111,18 @@ class HermesRiskScannerService:
|
||||
@staticmethod
|
||||
def _append_algorithm_flag(claim: ExpenseClaim, observation: dict) -> list:
|
||||
existing = list(claim.risk_flags_json or [])
|
||||
flag = {
|
||||
"source": "financial_risk_graph",
|
||||
"risk_signal": observation.get("risk_signal"),
|
||||
"severity": observation.get("risk_level"),
|
||||
"risk_score": observation.get("risk_score"),
|
||||
"confidence_score": observation.get("confidence_score"),
|
||||
"algorithm_version": observation.get("algorithm_version"),
|
||||
"observation_key": observation.get("observation_key"),
|
||||
}
|
||||
flag = with_risk_business_stage(
|
||||
{
|
||||
"source": "financial_risk_graph",
|
||||
"risk_signal": observation.get("risk_signal"),
|
||||
"severity": observation.get("risk_level"),
|
||||
"risk_score": observation.get("risk_score"),
|
||||
"confidence_score": observation.get("confidence_score"),
|
||||
"algorithm_version": observation.get("algorithm_version"),
|
||||
"observation_key": observation.get("observation_key"),
|
||||
},
|
||||
"reimbursement",
|
||||
)
|
||||
if any(
|
||||
isinstance(item, dict)
|
||||
and item.get("observation_key") == flag["observation_key"]
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.db.session import get_session_factory
|
||||
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
|
||||
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
|
||||
from app.services.hermes_expense_report import HermesExpenseReportService
|
||||
from app.services.hermes_risk_clue_collector import HermesRiskClueCollectorService
|
||||
from app.services.hermes_risk_scanner import HermesRiskScannerService
|
||||
|
||||
logger = get_logger("app.services.hermes_scheduler")
|
||||
@@ -168,6 +169,14 @@ class HermesScheduler:
|
||||
f"生成 {summary.get('snapshot_count', 0)} 条快照,"
|
||||
f"重点关注 {summary.get('high_attention_employee_count', 0)} 人。"
|
||||
)
|
||||
elif config.task_type == "risk_clue_collect":
|
||||
collector = HermesRiskClueCollectorService(db)
|
||||
summary = collector.collect_risk_clues(run_id=log_record.id)
|
||||
log_record.result_summary = (
|
||||
f"风险线索归集完成:读取 {summary.get('fact_count', 0)} 条事实,"
|
||||
f"整理 {summary.get('rule_hit_count', 0)} 条规则命中,"
|
||||
f"输出 {summary.get('risk_clue_count', 0)} 条待复核线索。"
|
||||
)
|
||||
|
||||
log_record.status = "success"
|
||||
log_record.completed_at = datetime.now(UTC)
|
||||
|
||||
@@ -25,9 +25,11 @@ from app.schemas.orchestrator import (
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
from app.services.auth import AuthService
|
||||
from app.services.expense_claims import ExpenseClaimService
|
||||
from app.services.agent_foundation import AgentFoundationService
|
||||
from app.services.agent_runs import AgentRunService
|
||||
from app.services.agent_traces import AgentTraceService
|
||||
from app.services.knowledge import KnowledgeService
|
||||
from app.services.ontology import SemanticOntologyService
|
||||
from app.services.orchestrator_execution import ExecutionOutcome, OrchestratorExecutionEngine
|
||||
@@ -57,6 +59,7 @@ class OrchestratorService:
|
||||
self.expense_claim_service = ExpenseClaimService(db)
|
||||
self.knowledge_service = KnowledgeService(db=db)
|
||||
self.run_service = AgentRunService(db)
|
||||
self.trace_service = AgentTraceService(db)
|
||||
self.ontology_service = SemanticOntologyService(db)
|
||||
self.user_agent_service = UserAgentService(db)
|
||||
self.database_query_builder = OrchestratorDatabaseQueryBuilder(db)
|
||||
@@ -67,11 +70,15 @@ class OrchestratorService:
|
||||
knowledge_service=self.knowledge_service,
|
||||
user_agent_service=self.user_agent_service,
|
||||
database_query_builder=self.database_query_builder,
|
||||
trace_service=self.trace_service,
|
||||
)
|
||||
|
||||
def run(self, payload: OrchestratorRequest) -> OrchestratorResponse:
|
||||
AgentFoundationService(self.db).ensure_foundation_ready()
|
||||
context_json = dict(payload.context_json or {})
|
||||
context_json = self._hydrate_user_context(
|
||||
user_id=payload.user_id,
|
||||
context_json=dict(payload.context_json or {}),
|
||||
)
|
||||
conversation_id = str(payload.conversation_id or "").strip() or None
|
||||
conversation = None
|
||||
if payload.source == AgentRunSource.USER_MESSAGE.value:
|
||||
@@ -87,6 +94,9 @@ class OrchestratorService:
|
||||
context_json=context_json,
|
||||
message=payload.message,
|
||||
)
|
||||
context_json["conversation_id"] = conversation_id
|
||||
elif conversation_id:
|
||||
context_json["conversation_id"] = conversation_id
|
||||
|
||||
route_json: dict[str, Any] = {
|
||||
"orchestrated_by": AgentName.ORCHESTRATOR.value,
|
||||
@@ -105,6 +115,25 @@ class OrchestratorService:
|
||||
status=AgentRunStatus.RUNNING.value,
|
||||
result_summary="Orchestrator 已接收请求。",
|
||||
)
|
||||
self._record_trace_event(
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation_id,
|
||||
stage="orchestrator",
|
||||
event_name="request_received",
|
||||
title="接收用户请求",
|
||||
summary=str(payload.message or payload.task_id or payload.source or "").strip(),
|
||||
input_json={
|
||||
"source": payload.source,
|
||||
"user_id": payload.user_id,
|
||||
"conversation_id": conversation_id,
|
||||
"task_id": payload.task_id,
|
||||
"message": payload.message,
|
||||
},
|
||||
output_json={
|
||||
"run_id": run.run_id,
|
||||
"context_keys": sorted(context_json.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
message, task_asset = self._resolve_message(payload)
|
||||
@@ -120,6 +149,19 @@ class OrchestratorService:
|
||||
"ocr_summary": context_json.get("ocr_summary", ""),
|
||||
},
|
||||
)
|
||||
self._record_trace_event(
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation.conversation_id,
|
||||
stage="conversation",
|
||||
event_name="conversation_hydrated",
|
||||
title="会话上下文补全",
|
||||
summary=f"会话 {conversation.conversation_id} 已写入用户消息。",
|
||||
input_json={"message": message},
|
||||
output_json={
|
||||
"conversation_id": conversation.conversation_id,
|
||||
"context_keys": sorted(context_json.keys()),
|
||||
},
|
||||
)
|
||||
ontology = self.ontology_service.parse_for_run(
|
||||
OntologyParseRequest(
|
||||
query=message,
|
||||
@@ -128,6 +170,16 @@ class OrchestratorService:
|
||||
),
|
||||
run_id=run.run_id,
|
||||
)
|
||||
self._record_trace_event(
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation_id,
|
||||
stage="semantic",
|
||||
event_name="semantic_parsed",
|
||||
title="语义识别完成",
|
||||
summary=f"{ontology.scenario} / {ontology.intent}",
|
||||
input_json={"query": message, "context_keys": sorted(context_json.keys())},
|
||||
output_json=self.execution_engine._build_ontology_json(ontology),
|
||||
)
|
||||
if context_json.get("simulate_orchestrator_exception"):
|
||||
raise RuntimeError("simulated orchestrator exception")
|
||||
selected_agent, route_reason = self._select_agent(payload, ontology)
|
||||
@@ -144,6 +196,25 @@ class OrchestratorService:
|
||||
and not is_expense_review_action
|
||||
and not is_expense_application_context
|
||||
)
|
||||
self._record_trace_event(
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation_id,
|
||||
stage="route",
|
||||
event_name="route_resolved",
|
||||
title="路由与能力选择",
|
||||
summary=route_reason,
|
||||
input_json={
|
||||
"scenario": ontology.scenario,
|
||||
"intent": ontology.intent,
|
||||
"permission_level": ontology.permission.level,
|
||||
},
|
||||
output_json={
|
||||
"selected_agent": selected_agent,
|
||||
"route_reason": route_reason,
|
||||
"selected_capability_codes": selected_capability_codes,
|
||||
"requires_confirmation": requires_confirmation,
|
||||
},
|
||||
)
|
||||
|
||||
route_json = {
|
||||
"orchestrated_by": AgentName.ORCHESTRATOR.value,
|
||||
@@ -337,6 +408,32 @@ class OrchestratorService:
|
||||
},
|
||||
},
|
||||
)
|
||||
self._record_trace_event(
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation_id,
|
||||
stage="conversation",
|
||||
event_name="conversation_updated",
|
||||
title="会话状态写回",
|
||||
summary="助手回复与会话状态已写回。",
|
||||
input_json={"draft_payload_present": isinstance(draft_payload, dict)},
|
||||
output_json={"status": final_status, "message": result_message},
|
||||
)
|
||||
self._record_trace_event(
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation_id,
|
||||
stage="response",
|
||||
event_name="response_built",
|
||||
title="生成最终回复",
|
||||
status=final_status,
|
||||
summary=result_message,
|
||||
input_json={"outcome_status": outcome.status},
|
||||
output_json={
|
||||
"status": response_status,
|
||||
"requires_confirmation": response_requires_confirmation,
|
||||
"trace_summary": trace_summary.model_dump(),
|
||||
"result": outcome.result,
|
||||
},
|
||||
)
|
||||
return OrchestratorResponse(
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation_id,
|
||||
@@ -350,6 +447,17 @@ class OrchestratorService:
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Orchestrator run failed run_id=%s", run.run_id)
|
||||
self._record_trace_event(
|
||||
run_id=run.run_id,
|
||||
conversation_id=conversation_id,
|
||||
stage="orchestrator",
|
||||
event_name="failed",
|
||||
title="Orchestrator 执行失败",
|
||||
status="failed",
|
||||
summary=str(exc),
|
||||
output_json={"route_json": route_json},
|
||||
error_message=str(exc),
|
||||
)
|
||||
self.run_service.update_run(
|
||||
run.run_id,
|
||||
agent=AgentName.ORCHESTRATOR.value,
|
||||
@@ -394,6 +502,40 @@ class OrchestratorService:
|
||||
),
|
||||
)
|
||||
|
||||
def _record_trace_event(self, **kwargs: Any) -> None:
|
||||
self.trace_service.record_event_safe(**kwargs)
|
||||
|
||||
def _hydrate_user_context(self, user_id: str | None, context_json: dict[str, Any]) -> dict[str, Any]:
|
||||
identifier = str(user_id or context_json.get("username") or context_json.get("email") or "").strip()
|
||||
if not identifier:
|
||||
return context_json
|
||||
|
||||
snapshot = AuthService(self.db).get_user_snapshot(identifier)
|
||||
if snapshot is None:
|
||||
return context_json
|
||||
|
||||
values = {
|
||||
"name": snapshot.name,
|
||||
"department": snapshot.department,
|
||||
"department_name": snapshot.departmentName or snapshot.department,
|
||||
"position": snapshot.position,
|
||||
"grade": snapshot.grade,
|
||||
"employee_no": snapshot.employeeNo,
|
||||
"manager_name": snapshot.managerName,
|
||||
"employee_location": snapshot.location,
|
||||
"cost_center": snapshot.costCenter,
|
||||
"finance_owner_name": snapshot.financeOwnerName,
|
||||
"employee_risk_profile": snapshot.riskProfile,
|
||||
"role_codes": snapshot.roleCodes,
|
||||
"is_admin": snapshot.isAdmin,
|
||||
}
|
||||
|
||||
for key, value in values.items():
|
||||
if context_json.get(key) in (None, "", [], {}):
|
||||
context_json[key] = value
|
||||
|
||||
return context_json
|
||||
|
||||
def _resolve_message(
|
||||
self,
|
||||
payload: OrchestratorRequest,
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.schemas.ontology import OntologyParseResult
|
||||
from app.schemas.orchestrator import OrchestratorRequest
|
||||
from app.schemas.user_agent import UserAgentRequest, UserAgentResponse
|
||||
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
|
||||
from app.services.hermes_risk_clue_collector import HermesRiskClueCollectorService
|
||||
from app.services.hermes_risk_scanner import HermesRiskScannerService
|
||||
from app.services.knowledge_sync import KnowledgeSyncDispatchService
|
||||
|
||||
@@ -36,6 +37,7 @@ class OrchestratorExecutionEngine:
|
||||
knowledge_service,
|
||||
user_agent_service,
|
||||
database_query_builder,
|
||||
trace_service=None,
|
||||
) -> None:
|
||||
self.db = db
|
||||
self.run_service = run_service
|
||||
@@ -43,6 +45,7 @@ class OrchestratorExecutionEngine:
|
||||
self.knowledge_service = knowledge_service
|
||||
self.user_agent_service = user_agent_service
|
||||
self.database_query_builder = database_query_builder
|
||||
self.trace_service = trace_service
|
||||
|
||||
def _execute_user_agent(
|
||||
self,
|
||||
@@ -383,6 +386,8 @@ class OrchestratorExecutionEngine:
|
||||
task_asset=task_asset,
|
||||
context_json=context_json,
|
||||
)
|
||||
if task_type == "risk_clue_collect":
|
||||
return self._execute_risk_clue_collect(run_id=run_id, context_json=context_json)
|
||||
return None
|
||||
|
||||
def _execute_risk_graph_scan(self, *, run_id: str, context_json: dict[str, Any]) -> ExecutionOutcome:
|
||||
@@ -502,6 +507,41 @@ class OrchestratorExecutionEngine:
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
def _execute_risk_clue_collect(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
context_json: dict[str, Any],
|
||||
) -> ExecutionOutcome:
|
||||
summary, degraded = self._invoke_tool(
|
||||
run_id=run_id,
|
||||
tool_type=AgentToolType.DATABASE.value,
|
||||
tool_name="digital_employee.risk_clue.collect",
|
||||
request_json={"task_type": "risk_clue_collect"},
|
||||
context_json=context_json,
|
||||
executor=lambda: HermesRiskClueCollectorService(self.db).collect_risk_clues(
|
||||
run_id=run_id
|
||||
),
|
||||
fallback_factory=lambda exc: {
|
||||
"message": f"风险线索归集失败,已保留失败记录:{exc}",
|
||||
"degraded": True,
|
||||
},
|
||||
)
|
||||
message = (
|
||||
str(summary.get("message") or "").strip()
|
||||
or "风险线索归集完成:"
|
||||
f"读取 {summary.get('fact_count', 0)} 条事实,"
|
||||
f"整理 {summary.get('rule_hit_count', 0)} 条规则命中,"
|
||||
f"输出 {summary.get('risk_clue_count', 0)} 条待复核线索。"
|
||||
)
|
||||
return ExecutionOutcome(
|
||||
status=AgentRunStatus.SUCCEEDED.value,
|
||||
result={"message": message, "report_type": "risk_clue_collect", "summary": summary, "degraded": degraded},
|
||||
degraded=degraded,
|
||||
tool_count=1,
|
||||
failed_tool_count=1 if degraded else 0,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_task_type(task_asset: AgentAssetRead | None) -> str:
|
||||
if task_asset is None:
|
||||
@@ -613,6 +653,11 @@ class OrchestratorExecutionEngine:
|
||||
status="succeeded",
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
if self.trace_service:
|
||||
self.trace_service.record_tool_event_safe(
|
||||
run_id, tool_type, tool_name, request_json, response,
|
||||
"succeeded", duration_ms, context_json,
|
||||
)
|
||||
return response, False
|
||||
except Exception as exc:
|
||||
duration_ms = int((perf_counter() - started) * 1000)
|
||||
@@ -627,6 +672,11 @@ class OrchestratorExecutionEngine:
|
||||
duration_ms=duration_ms,
|
||||
error_message=str(exc),
|
||||
)
|
||||
if self.trace_service:
|
||||
self.trace_service.record_tool_event_safe(
|
||||
run_id, tool_type, tool_name, request_json, response,
|
||||
"failed", duration_ms, context_json, str(exc),
|
||||
)
|
||||
return response, True
|
||||
|
||||
@staticmethod
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user