feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造

- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-01 17:07:14 +08:00
parent 7989f3a159
commit 92444e7eae
285 changed files with 25075 additions and 2986 deletions

2
.env
View File

@@ -27,7 +27,7 @@ SERVER_BLOCKING_STARTUP_TIMEOUT=12
VITE_API_BASE_URL=/api/v1 VITE_API_BASE_URL=/api/v1
VITE_AUTH_IDLE_TIMEOUT_MINUTES=30 VITE_AUTH_IDLE_TIMEOUT_MINUTES=30
ONLYOFFICE_ENABLED=true 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_BACKEND_URL=http://main:8000
ONLYOFFICE_JWT_SECRET=change-me-onlyoffice ONLYOFFICE_JWT_SECRET=change-me-onlyoffice
HERMES_AGENT_SHARED_TOKEN=change-me-hermes HERMES_AGENT_SHARED_TOKEN=change-me-hermes

View File

@@ -32,8 +32,25 @@
- 前端大型 Vue 页面:优先拆分 composable、view model、样式分片、业务工具函数和子组件。 - 前端大型 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 路径相关代码**时,自己也要回容器里跑一次确认改动生效,不要只改文件就声称完成。

View 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/总账体系和前端统一状态管理。

View 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/前端状态管理两项待定问题。

View File

@@ -4,7 +4,9 @@
## 1. 功能一句话 ## 1. 功能一句话
以数字员工为后台执行入口,持续把财务业务数据转成行为画像、制度语义和风险观察,再通过图谱证据链、单据详情和风险看板提供可解释的风险判断。 以数字员工为后台分析入口,持续把财务业务数据转成行为画像、制度语义和风险观察,再通过图谱证据链、单据详情和风险看板提供可解释的风险判断。
规则中心、审批和报销主流程仍由外层智能体流程负责调度;数字员工只消费事实、规则命中和反馈结果,生成后台分析、报告、知识库材料和待复核线索。
## 2. 背景与问题 ## 2. 背景与问题
@@ -16,7 +18,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识
- 分析看板如果只从散表拼统计图,会成为展示型页面,不能反映算法效果。 - 分析看板如果只从散表拼统计图,会成为展示型页面,不能反映算法效果。
- 数字员工如果只做定时任务,会变成调度工具,不能形成核心壁垒。 - 数字员工如果只做定时任务,会变成调度工具,不能形成核心壁垒。
本方案把核心能力定义为“财务行为图谱风险引擎”。它不是单一模型,而是一套闭环:事件沉淀、实体建图、画像基线、风险推理、人工反馈、规则发现 本方案把核心能力定义为“财务行为图谱风险引擎”。它不是单一模型,而是一套闭环:事件沉淀、实体建图、画像基线、风险推理、人工反馈、待复核线索归集
## 3. 核心判断 ## 3. 核心判断
@@ -78,7 +80,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识
算法版本 算法版本
``` ```
这会形成自有训练集、评测集和规则优化样本。竞品能复制算法框架,但复制不了这些被真实审批和财务人员校准过的样本。 这会形成自有训练集、评测集和规则执行校准样本。竞品能复制算法框架,但复制不了这些被真实审批和财务人员校准过的样本。
第四层壁垒:人机共审行为数据。 第四层壁垒:人机共审行为数据。
@@ -92,7 +94,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识
补件 补件
升级审批 升级审批
标记误报 标记误报
生成候选规则 归集待复核线索
``` ```
这些反馈会反向影响规则质量、抽审比例、自动化门控和数字员工能力考核。越使用越贴近企业自己的财务控制风格。 这些反馈会反向影响规则质量、抽审比例、自动化门控和数字员工能力考核。越使用越贴近企业自己的财务控制风格。
@@ -139,10 +141,10 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识
### 4.2 非目标 ### 4.2 非目标
- 第一版不做独立“大图谱中心”,避免做成展示型页面。 - 第一版不做独立“大图谱中心”,避免做成展示型页面。
- 不让大模型直接决定风险等级,大模型只参与语义抽取、解释生成和候选规则发现 - 不让大模型直接决定风险等级,大模型只参与语义抽取、解释生成和事实线索整理;不参与规则生成、改写或发布
- 不用画像自动惩罚员工,不给员工永久贴标签。 - 不用画像自动惩罚员工,不给员工永久贴标签。
- 不在员工技能详情中展示知识归集图谱;图谱结果只进入工作记录详情、单据风险详情或画像详情。 - 不在员工技能详情中展示知识归集图谱;图谱结果只进入工作记录详情、单据风险详情或画像详情。
- 不让数字员工自动上线规则;规则候选必须经过管理员审核 - 不让数字员工生成、改写或自动上线规则;规则变更只能由管理员在规则中心维护
## 5. 用户与场景 ## 5. 用户与场景
@@ -178,7 +180,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识
- 数字员工运行是否成功。 - 数字员工运行是否成功。
- 本次分析处理了多少数据、产出多少风险观察。 - 本次分析处理了多少数据、产出多少风险观察。
- 候选规则是否有足够证据。 - 待复核风险线索是否有足够事实和证据。
- 是否需要调整技能、规则、制度知识或调度配置。 - 是否需要调整技能、规则、制度知识或调度配置。
### 5.4 管理层 ### 5.4 管理层
@@ -201,7 +203,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识
- 制度整理员工:把公司财务制度整理成条款、适用范围、费用类型、触发条件和引用关系。 - 制度整理员工:把公司财务制度整理成条款、适用范围、费用类型、触发条件和引用关系。
- 风险扫描员工:定期扫描新增单据、票据、供应商、审批链和画像偏离,生成风险观察。 - 风险扫描员工:定期扫描新增单据、票据、供应商、审批链和画像偏离,生成风险观察。
- 画像更新员工:定期更新员工、部门、供应商、费用类型画像和同类基线。 - 画像更新员工:定期更新员工、部门、供应商、费用类型画像和同类基线。
- 规则发现员工:从历史退回、误报、漏报、制度变化和高频异常中生成候选规则。 - 风险线索归集员工:从申请、报销、规则命中和人工反馈中归集待复核线索,不生成规则。
### 6.2 图谱体现方式 ### 6.2 图谱体现方式
@@ -259,7 +261,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识
- 风险分布:按部门、费用类型、风险类型、供应商、员工职级分布。 - 风险分布:按部门、费用类型、风险类型、供应商、员工职级分布。
- 风险趋势7 天 / 30 天风险走势、高风险占比、重复触发趋势、处理完成率。 - 风险趋势7 天 / 30 天风险走势、高风险占比、重复触发趋势、处理完成率。
- 异常排行:风险最多部门、偏离基线最大员工、高频异常供应商、高频触发规则。 - 异常排行:风险最多部门、偏离基线最大员工、高频异常供应商、高频触发规则。
- 算法效果:规则命中数、图谱异常命中数、人工确认率、误报率、候选规则数。 - 算法效果:规则命中数、图谱异常命中数、人工确认率、误报率、待复核线索数。
风险看板必须从 `RiskObservation` 聚合,不直接从散表临时拼图表。 风险看板必须从 `RiskObservation` 聚合,不直接从散表临时拼图表。
@@ -437,7 +439,7 @@ confirmed_by_feedback 由人工反馈确认
图谱引擎canonical node + whitelisted edge 构造证据路径 图谱引擎canonical node + whitelisted edge 构造证据路径
风险观察ontology fields + evidence path 输出可解释结论 风险观察ontology fields + evidence path 输出可解释结论
风险看板:按 ontology scenario / expense_type / risk_signal 聚合 风险看板:按 ontology scenario / expense_type / risk_signal 聚合
数字员工:只能产出本体可识别的候选风险信号和候选规则 数字员工:只能产出本体可识别的事实、规则命中、待复核风险线索和证据引用
``` ```
当本体置信度不足时,风险图谱必须降级: 当本体置信度不足时,风险图谱必须降级:
@@ -602,7 +604,7 @@ $$
### 8.4 人工反馈校准 ### 8.4 人工反馈校准
人工反馈不直接覆盖算法,但会影响后续权重和候选规则优先级: 人工反馈不直接覆盖算法,但会影响后续权重和待复核线索优先级:
$$ $$
confirmed\_rate = \frac{confirmed}{confirmed + false\_positive + ignored} confirmed\_rate = \frac{confirmed}{confirmed + false\_positive + ignored}

View File

@@ -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] 为风险观察补充 `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: 技术验收] 证据:`RiskObservation.__table_args__` 与字段索引覆盖主体、单据、等级、状态、信号、来源和时间。
- [x] 设计对象中心财务事件日志模型,把申请、预算占用、票据上传、审批、退回、付款、归档、复盘统一为可回放事件。[CONCEPT: 不可复制壁垒设计] 证据:`process_mining.py` 已定义 `ObjectCentricEvent`,统一保存事件类型、发生时间、对象引用、来源、参与人和元数据,测试覆盖从报销单生成可回放事件。 - [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`,测试覆盖从风险观察构建回放集。 - [x] 设计算法回放集模型,绑定历史单据、本体版本、规则版本、算法版本和反馈标签。[CONCEPT: 不可复制壁垒设计] 证据:`replay.py` 已定义 `AlgorithmReplayCase/AlgorithmReplaySet/AlgorithmReplaySetBuilder`,测试覆盖从风险观察构建回放集。
## 3. 后端服务 ## 3. 后端服务
@@ -98,7 +98,7 @@
- [x] 将风险扫描和员工画像巡检注册到数字员工的员工技能列表。[CONCEPT: 数字员工能力分层] 证据:新增 `financial-risk-graph-scanner``employee-behavior-profile-scanner` 技能包,并通过任务资产种子和补齐逻辑进入员工技能列表。 - [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: 数字员工能力分层] 证据:`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: 数字员工能力分层] 证据:`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] 数字员工运行完成后写入处理范围、处理数量、风险观察数量和失败原因。[CONCEPT: 数字员工工作记录详情] 证据:`HermesScheduler` 已写入风险图谱巡检摘要,失败仍沿用执行日志 `error_trace`
- [x] 确认 UI 上继续使用“数字员工 / 员工技能 / 工作记录”等业务命名,不在普通用户界面暴露内部实现名。[CONCEPT: 用户与场景] 证据:`DigitalEmployeesView.vue` 页签文案为“员工技能 / 工作记录”,普通界面未展示内部 Hermes 名称。 - [x] 确认 UI 上继续使用“数字员工 / 员工技能 / 工作记录”等业务命名,不在普通用户界面暴露内部实现名。[CONCEPT: 用户与场景] 证据:`DigitalEmployeesView.vue` 页签文案为“员工技能 / 工作记录”,普通界面未展示内部 Hermes 名称。
@@ -125,16 +125,16 @@
- [x] 增加风险分布图:部门、费用类型、风险类型、供应商、员工职级。[CONCEPT: 分析看板风险看板] 证据:`RiskObservationDashboard.vue` 新增业务维度分布区,统一读取 `department/expense_type/risk_type/supplier/employee_grade` 分布字段。 - [x] 增加风险分布图:部门、费用类型、风险类型、供应商、员工职级。[CONCEPT: 分析看板风险看板] 证据:`RiskObservationDashboard.vue` 新增业务维度分布区,统一读取 `department/expense_type/risk_type/supplier/employee_grade` 分布字段。
- [x] 增加风险趋势图7 天 / 30 天走势、高风险占比、处理完成率。[CONCEPT: 分析看板风险看板] 证据:`RiskDailyTrendChart.vue` 已展示风险观察与高风险趋势;风险看板时间窗口支持 7/30/90 天切换,处理完成率由闭环效果区承接。 - [x] 增加风险趋势图7 天 / 30 天走势、高风险占比、处理完成率。[CONCEPT: 分析看板风险看板] 证据:`RiskDailyTrendChart.vue` 已展示风险观察与高风险趋势;风险看板时间窗口支持 7/30/90 天切换,处理完成率由闭环效果区承接。
- [x] 增加异常排行:部门、员工、供应商、规则、费用类型。[CONCEPT: 分析看板风险看板] 证据:风险观察聚合接口输出 `top_departments/top_employees/top_suppliers/top_rules/top_expense_types`,前端异常排行区已展示。 - [x] 增加异常排行:部门、员工、供应商、规则、费用类型。[CONCEPT: 分析看板风险看板] 证据:风险观察聚合接口输出 `top_departments/top_employees/top_suppliers/top_rules/top_expense_types`,前端异常排行区已展示。
- [x] 增加算法效果:规则命中数、图谱异常命中数、人工确认率、误报率、候选规则数。[CONCEPT: 分析看板风险看板] 证据:风险看板已展示平均风险分、人工确认数、误报样本和候选规则数,规则/图谱来源通过来源分布体现。 - [x] 增加算法效果:规则命中数、图谱异常命中数、人工确认率、误报率、待复核线索数。[CONCEPT: 分析看板风险看板] 证据:风险看板已展示平均风险分、人工确认数、误报样本和待复核线索口径,规则/图谱来源通过来源分布体现。
- [x] 风险看板所有数据通过风险观察聚合接口读取,不直接拼接业务散表。[CONCEPT: 技术验收] 证据:后端已提供 `/api/v1/risk-observations/dashboard` 作为统一聚合源。 - [x] 风险看板所有数据通过风险观察聚合接口读取,不直接拼接业务散表。[CONCEPT: 技术验收] 证据:后端已提供 `/api/v1/risk-observations/dashboard` 作为统一聚合源。
## 9. 规则与反馈闭环 ## 9. 规则与反馈闭环
- [x] 规则中心执行结果写入风险观察池或与风险观察建立关联。[CONCEPT: 统一风险观察模型] 证据:`RiskObservationService.upsert_platform_risk_flags()` 已接收规则中心风险命中,报销提交预审会同步写入风险观察池。 - [x] 规则中心执行结果写入风险观察池或与风险观察建立关联。[CONCEPT: 统一风险观察模型] 证据:`RiskObservationService.upsert_platform_risk_flags()` 已接收规则中心风险命中,报销提交预审会同步写入风险观察池。
- [x] 风险观察支持人工确认、误报、忽略、已处理等反馈。[CONCEPT: 人工反馈校准] 证据:反馈接口支持 `confirm/false_positive/ignore/resolve/comment` - [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: 技术验收] 证据:详情模型保留 `status``feedback_status`,反馈历史由 `RiskObservationFeedback` 存储。
- [x] 风险看板展示人工确认率、误报率和候选规则数量。[CONCEPT: 分析看板风险看板] 证据:聚合接口已输出 `confirmation_rate``false_positive_rate`候选规则数待规则发现员工接入后补充 - [x] 风险看板展示人工确认率、误报率和待复核线索数量。[CONCEPT: 分析看板风险看板] 证据:聚合接口已输出 `confirmation_rate``false_positive_rate`待复核线索口径由风险观察与人工复核状态聚合
## 10. 测试与验证 ## 10. 测试与验证

View File

@@ -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" 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] 新建复杂差旅规则后,详情页流程解释不能出现“检查是否包含风险关键词”这类错误表达。 - [A1] 新建复杂差旅规则后,详情页流程解释不能出现“检查是否包含风险关键词”这类错误表达。
@@ -544,3 +570,12 @@ docker exec x-financial-main sh -lc "cd /app/web && npm run build"
- 老规则没有 `semantic_plan``flow_model`,需要兼容展示并允许重新生成。 - 老规则没有 `semantic_plan``flow_model`,需要兼容展示并允许重新生成。
- 常见规则模板要避免写成定制逻辑。模板只能提供默认文本、字段和 DSL 样例,最终仍走通用生成链路。 - 常见规则模板要避免写成定制逻辑。模板只能提供默认文本、字段和 DSL 样例,最终仍走通用生成链路。
当前仍需持续演进的点:
- 企业卡、采购/AP、预算场景的字段本体还偏少后续应补充企业卡交易流水、供应商、采购订单、合同、预算期间等字段。
- 复杂规则的准确性仍依赖 Hermes 语义计划质量,执行前必须继续保留 DSL validator、执行器 dry-run 和仿真测试。
- 模板库只作为规则编写入口的业务参考,不作为规则执行捷径;新增模板时必须同时提供 DSL 样例和 validator 测试。
## 实现确认
当前实现仍围绕“解释图和执行逻辑一致”推进:自然语言先经字段本体和语义计划形成受控 JSON DSL详情页流程图、文字流程解释、测试 trace、上线版本均围绕同一份 DSL 展示和执行,没有新增流程图编辑器或绕过规则执行器的判断链路。

View File

@@ -9,10 +9,10 @@
## 1. 调研与边界 ## 1. 调研与边界
- [ ] [CONCEPT: 背景与问题] 梳理当前风险规则生成链路,记录 `risk_rule_generation.py``risk_rule_template_executor.py` 的真实调用关系。 - [x] [CONCEPT: 背景与问题] 梳理当前风险规则生成链路,记录 `risk_rule_generation.py``risk_rule_template_executor.py` 的真实调用关系。证据:`CONCEPT.md` 后端设计与本轮落地结果记录生成、DSL validator、执行器、流程图、仿真测试链路。
- [ ] [CONCEPT: 前端设计] 梳理详情页、新建弹窗、测试弹窗当前字段来源,记录 `AuditRuleDialogs.vue``AuditJsonRiskRuleDetail.vue``RiskRuleTestDialog.vue` 的改造点。 - [x] [CONCEPT: 前端设计] 梳理详情页、新建弹窗、测试弹窗当前字段来源,记录 `AuditRuleDialogs.vue``AuditJsonRiskRuleDetail.vue``RiskRuleTestDialog.vue` 的改造点。证据:`CONCEPT.md` 本轮落地结果列出三个组件及对应职责,`risk-rule-detail-experience.test.mjs` 覆盖关键接线。
- [ ] [CONCEPT: 数据设计] 确认 `AgentAssetRead`、版本内容、`config_json` 中已有字段,确定 `semantic_plan``flow_model``flow_diagram_svg` 的落点。 - [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`,生成测试覆盖。
- [ ] [CONCEPT: 非目标] 明确本期不做流程图编辑器,不增加拖拽、缩放、节点编辑能力。 - [x] [CONCEPT: 非目标] 明确本期不做流程图编辑器,不增加拖拽、缩放、节点编辑能力。证据:`RiskRuleFlowDiagram.vue` 只渲染静态 SVG/文字说明,无编辑、拖拽、缩放入口;前端回归测试断言不存在 zoom 按钮。
## 2. 语义计划与 DSL 契约 ## 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: 总体链路] 调整 `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。 - [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: 后端设计] 在 `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` 通过。 - [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 与仿真测试 ## 5. 执行器 trace 与仿真测试
- [x] [CONCEPT: C5] 修改 `RiskRuleTemplateExecutor`,输出每个判断节点的 trace。证据新增 `evaluate_with_trace`,仿真测试返回 `trace.steps``path_node_ids` - [x] [CONCEPT: C5] 修改 `RiskRuleTemplateExecutor`,输出每个判断节点的 trace。证据新增 `evaluate_with_trace`,仿真测试返回 `trace.steps``path_node_ids`
- [ ] [CONCEPT: C5] 仿真测试统一在“用户点击运行”后处理附件和文本,不允许上传后立即判断。 - [x] [CONCEPT: C5] 仿真测试统一在“用户点击运行”后处理附件和文本,不允许上传后立即判断。证据:`RiskRuleTestDialog.vue``handleFileChange` 只把附件加入待发送列表,`sendMessage` 才调用 `recognizeTemporaryFiles``simulateRiskRuleTest`;容器内 `npm run build` 通过。
- [ ] [CONCEPT: C5] 测试结果中展示 OCR 原始字段、Hermes 规范化字段、执行器实际输入字段。 - [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] 测试弹窗展示命中路径、未命中原因和最终风险动作。证据:`RiskRuleTestDialog.vue` 展示“执行路径”,`riskRuleTestDialogDisplay.js` 格式化 trace。
- [x] [CONCEPT: C5] trace 中的 `node_id` 必须能映射到流程图节点。证据:`flow_model` 使用条件 id 作为节点 id`risk_rule_execution_trace.py` 输出同名 `node_id` - [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] 未上线规则支持编辑标题、费用领域、附件要求和自然语言描述。证据:新增 `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` 进入修订版本。 - [x] [CONCEPT: C6] 已上线规则新增“创建修订版本”,不直接覆盖 active 版本。证据:新增 `AgentAssetRiskRuleRevisionService.create_revision_draft``POST /agent-assets/{asset_id}/risk-rules/revisions`,测试验证 `published_version` 保持不变且 `working_version` 进入修订版本。
- [ ] [CONCEPT: C6] 修订版本保存后重新生成 DSL、流程图、风险评分和业务说明。 - [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` 通过。
- [ ] [CONCEPT: C6] 发布修订版本时归档旧版本,并记录修改人、修改原因和测试报告。 - [x] [CONCEPT: C6] 发布修订版本时归档旧版本,并记录修改人、修改原因和测试报告。证据:新增 `AgentAssetRiskRulePublishMixin`,发布修订时将旧 `rule_document` 写入 `revision_history.previous_rule_document`,切换新 JSON 文件并写入 `last_operation=publish_revision`;容器内 `test_publish_regenerated_revision_replaces_online_document` 通过。
- [ ] [CONCEPT: C6] 普通用户误判/漏判反馈进入规则反馈记录,不直接修改规则。 - [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. 常见费控规则模板库 ## 7. 常见费控规则模板库
- [ ] [CONCEPT: C1] 增加“从常见规则模板创建”入口。 - [x] [CONCEPT: C1] 增加“从常见规则模板创建”入口。证据:`AuditRuleDialogs.vue` 新建风险规则弹窗新增常见规则模板选择,选择后预填标题、附件要求、业务环节、费用领域和自然语言。
- [ ] [CONCEPT: C1] 模板按预算、票据、差旅、招待、采购/AP、企业卡、通用分组。 - [x] [CONCEPT: C1] 模板按预算、票据、差旅、招待、采购/AP、企业卡、通用分组。证据:新增 `risk_rule_template_catalog.py``GET /agent-assets/risk-rules/templates` 返回 7 个分组;容器内 `test_risk_rule_template_catalog.py` 通过。
- [ ] [CONCEPT: C3] 每个模板提供默认自然语言、字段清单、附件要求和 DSL 样例。 - [x] [CONCEPT: C3] 每个模板提供默认自然语言、字段清单、附件要求和 DSL 样例。证据:模板接口返回 `natural_language``fields``requires_attachment``dsl_example`;容器内测试逐个调用 DSL validator 验证通过。
- [ ] [CONCEPT: 非目标] 模板不得绕过通用生成链路,不写定制校准器。 - [x] [CONCEPT: 非目标] 模板不得绕过通用生成链路,不写定制校准器。证据:前端模板只预填 `riskRuleCreateForm`,提交仍走 `generateRiskRuleAsset`;无新增定制校准器,容器内 `npm run build` 通过。
## 8. 前端详情与交互 ## 8. 前端详情与交互
- [ ] [CONCEPT: 前端设计] 详情页 topbar 展示规则标题、状态、风险分数、风险等级、上线/启用状态。 - [x] [CONCEPT: 前端设计] 详情页 topbar 展示规则标题、状态、风险分数、风险等级、上线/启用状态。证据:`auditViewDetailTopBar.js` 为风险规则详情输出风险分、风险等级、规则状态、上线状态、启用状态 KPI容器内 `npm run build` 通过。
- [ ] [CONCEPT: C4] 判断流程区域改成左侧文字流程解释、右侧流程图。 - [x] [CONCEPT: C4] 判断流程区域改成左侧文字流程解释、右侧流程图。证据:`RiskRuleFlowDiagram.vue` 使用左侧 `risk-rule-flow-explainer` 和右侧 `risk-rule-flow-visual` 的两栏布局;容器内 `npm run build` 通过。
- [ ] [CONCEPT: C4] 流程图标题固定为“流程图”,高度与“流程解释”标题对齐。 - [x] [CONCEPT: C4] 流程图标题固定为“流程图”,高度与“流程解释”标题对齐。证据:`RiskRuleFlowDiagram.vue` 使用统一 `risk-rule-section-title`,右侧标题固定为“流程图”;容器内 `npm run build` 通过。
- [ ] [CONCEPT: C5] 测试弹窗展示字段识别结果、规范化字段、判断路径和测试报告。 - [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` 通过。 - [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. 后端接口与权限 ## 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: 接口设计] 实现或调整 `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` 与已上线阻断用例通过。 - [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`,仿真测试覆盖返回值。 - [x] [CONCEPT: 接口设计] 仿真测试接口返回 `recognized_fields``normalized_fields``execution_result``trace`。证据:`AgentAssetRiskRuleSimulationRead` 新增 `normalized_fields``trace`,仿真测试覆盖返回值。
- [ ] [CONCEPT: 用户与场景] 普通财务人员只能编辑未上线/修订草稿admin 才能删除和测试,管理员按现有权限上线/下线。 - [x] [CONCEPT: 用户与场景] 普通财务人员只能编辑未上线/修订草稿admin 才能删除和测试,管理员按现有权限上线/下线。证据:路由依赖使用 `RuleEditorUser``RuleReviewerUser``PlatformAdminUser` 分层,`test_risk_rule_revision_endpoints.py` 覆盖 finance 新建/测试阻断、manager 删除阻断和 manager 启停入口。
- [ ] [CONCEPT: 数据设计] 所有操作写入 `last_operation`,用于详情页“最后操作”展示。 - [x] [CONCEPT: 数据设计] 所有操作写入 `last_operation`,用于详情页“最后操作”展示。证据:生成、后台生成、草稿编辑、创建修订、重新生成、发布/下线、测试确认等风险规则服务均写入 `config_json.last_operation`,前端 `AuditJsonRiskRuleDetail.vue` 展示 `lastOperationLabel`
## 10. 测试与验证 ## 10. 测试与验证
- [x] [CONCEPT: 测试方案] 后端补充语义计划、DSL validator、执行器 trace、流程图转换单元测试。证据`test_risk_rule_explainability.py` 覆盖语义计划、flow_model、trace`test_risk_rule_dsl_validator.py` 覆盖 DSL validator 与 `numeric_compare` 执行;容器内相关测试通过。 - [x] [CONCEPT: 测试方案] 后端补充语义计划、DSL validator、执行器 trace、流程图转换单元测试。证据`test_risk_rule_explainability.py` 覆盖语义计划、flow_model、trace`test_risk_rule_dsl_validator.py` 覆盖 DSL validator 与 `numeric_compare` 执行;容器内相关测试通过。
- [ ] [CONCEPT: 测试方案] 后端补充修订版本接口和发布替换接口测试。进度:已补草稿编辑创建修订版本服务/接口测试,发布替换接口测试仍待补齐 - [x] [CONCEPT: 测试方案] 后端补充修订版本接口和发布替换接口测试。证据:`test_risk_rule_revision_service.py` 覆盖草稿编辑创建修订、修订重生成和发布替换;`test_risk_rule_revision_endpoints.py` 覆盖草稿编辑、创建修订和重生成接口;容器内相关测试通过
- [ ] [CONCEPT: 测试方案] 前端补充详情页流程展示、测试弹窗字段展示、修订版本按钮状态测试。 - [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: 容器验证] 在容器执行后端定向测试,单个命令设置 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: 容器验证] 在容器执行 `cd /app/web && npm run build`。证据:容器 `/app/web` 构建通过。
- [x] [CONCEPT: 指标与验收] 用“武汉到上海票据 + 北京出差 3 天”样例验证城市不一致规则必须命中或给出明确不命中原因。证据:`test_simulation_returns_execution_trace_for_ticket_city_mismatch` 验证命中并返回 trace。 - [x] [CONCEPT: 指标与验收] 用“武汉到上海票据 + 北京出差 3 天”样例验证城市不一致规则必须命中或给出明确不命中原因。证据:`test_simulation_returns_execution_trace_for_ticket_city_mismatch` 验证命中并返回 trace。
@@ -91,6 +91,6 @@
## 11. 文档收尾 ## 11. 文档收尾
- [ ] [CONCEPT: 指标与验收] 开发完成后补充实际接口、文件和测试命令结果。 - [x] [CONCEPT: 指标与验收] 开发完成后补充实际接口、文件和测试命令结果。证据:`CONCEPT.md` 新增“本轮落地结果”,列出接口、关键文件和容器验证命令。
- [ ] [CONCEPT: 风险与开放问题] 记录暂未解决的字段本体缺口和复杂规则降级策略。 - [x] [CONCEPT: 风险与开放问题] 记录暂未解决的字段本体缺口和复杂规则降级策略。证据:`CONCEPT.md` 风险与开放问题补充企业卡、采购/AP、预算字段本体缺口和 DSL validator/dry-run/仿真兜底策略。
- [ ] [CONCEPT: 功能一句话] 确认最终实现没有偏离“解释图和执行逻辑一致”的核心目标。 - [x] [CONCEPT: 功能一句话] 确认最终实现没有偏离“解释图和执行逻辑一致”的核心目标。证据:`CONCEPT.md` 新增“实现确认”明确自然语言、字段本体、JSON DSL、流程图、测试 trace 和上线版本围绕同一 DSL。

View 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 或不出现。
- 如果后续新增数字员工技能,需要同步更新任务类型映射,避免看板归类为“其他”。

View 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: 指标与验收] 证据:本文档已更新。

View 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`
- 定向测试通过。
- 风险看板不再展示候选规则指标,改为待复核线索和反馈样本。
- 不引入数据库迁移和破坏性变更。
## 风险与开放问题
- 新增技能中部分为“定义先行”,立即运行时需要后续逐步接入真实执行器。
- 如果用户希望每个技能都能立即产出真实结果,需要继续拆分执行服务和工作记录产物。
- 已接入风险线索归集真实执行器,后续应继续把多凭证、时空、预算、供应商异常从风险图谱主引擎中拆成独立算法模块。
- 若技能命名或说明再次出现“数字员工承担规则主流程、规则发现、规则优化、自动总结风险”等表述,应优先改为读取规则命中结果、事实、线索、复核材料等受控表述。

View 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 个通过。

View 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 模块,不改变申请预览和提交的数据契约。

View 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 用例通过;整包定向存在无关查询动作测试失败。

View 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` 标记。
- 预算资金动作仍由原有提交、退回、财务终审链路处理。
## 风险与开放问题
- 历史风险的口径会影响预算管理者工作量,当前一期采用“存在实质风险即复核”的严格口径。
- 缺失预算池时是否全部进入预算复核,当前按预算风险处理。
- 后续如要支持可配置路由阈值,应新增配置表或策略服务,而不是继续改审批流分支。

View 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。

View 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 数据表达流程。
- 后续需要明确“审核通过”是自动发布,还是高级财务人员审核后再点击发布。
- 归档预算的触发条件需要后续和预算发布版本模型一起设计。

View 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: 风险与开放问题]`

View File

@@ -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"
}

View File

@@ -3,7 +3,7 @@
"rule_code": "risk.application.marketing_without_campaign", "rule_code": "risk.application.marketing_without_campaign",
"name": "市场推广费无活动申请", "name": "市场推广费无活动申请",
"description": "市场活动、投放、展会等推广费用,缺少已审批的活动申请或投放方案。", "description": "市场活动、投放、展会等推广费用,缺少已审批的活动申请或投放方案。",
"enabled": true, "enabled": false,
"requires_attachment": false, "requires_attachment": false,
"risk_dimension": "expense_control_demo", "risk_dimension": "expense_control_demo",
"risk_category": "申请前置", "risk_category": "申请前置",

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -18,17 +18,7 @@
"budget_execution" "budget_execution"
], ],
"expense_types": [ "expense_types": [
"travel", "all"
"hotel",
"transport",
"meal",
"meeting",
"marketing",
"office",
"training",
"software",
"communication",
"welfare"
], ],
"budget_required": true, "budget_required": true,
"applies_to": { "applies_to": {
@@ -36,17 +26,7 @@
"expense" "expense"
], ],
"expense_types": [ "expense_types": [
"travel", "all"
"hotel",
"transport",
"meal",
"meeting",
"marketing",
"office",
"training",
"software",
"communication",
"welfare"
], ],
"business_stages": [ "business_stages": [
"expense_application", "expense_application",
@@ -68,17 +48,65 @@
"type": "enum", "type": "enum",
"source": "claim" "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", "key": "budget.available_amount",
"label": "预算可用金额", "label": "预算可用金额",
"type": "number", "type": "number",
"source": "budget" "source": "budget"
}, },
{
"key": "budget.used_rate",
"label": "预算使用率",
"type": "number",
"source": "budget"
},
{ {
"key": "budget.status", "key": "budget.status",
"label": "预算状态", "label": "预算状态",
"type": "enum", "type": "enum",
"source": "budget" "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" "budget_execution"
], ],
"expense_types": [ "expense_types": [
"travel", "all"
"hotel",
"transport",
"meal",
"meeting",
"marketing",
"office",
"training",
"software",
"communication",
"welfare"
], ],
"budget_required": true "budget_required": true
}, },
@@ -146,7 +164,7 @@
"owner": "风控与审计部", "owner": "风控与审计部",
"stability": "platform", "stability": "platform",
"source_ref": "费用管控 Demo 风险规则库", "source_ref": "费用管控 Demo 风险规则库",
"created_at": "2026-05-30T00:00:00Z", "created_at": "2026-05-31T00:10:41.751292+00:00",
"created_by": "system", "created_by": "system",
"risk_score": 88, "risk_score": 88,
"risk_level": "high", "risk_level": "high",
@@ -159,17 +177,7 @@
"budget_execution" "budget_execution"
], ],
"expense_types": [ "expense_types": [
"travel", "all"
"hotel",
"transport",
"meal",
"meeting",
"marketing",
"office",
"training",
"software",
"communication",
"welfare"
], ],
"budget_required": true "budget_required": true
}, },

View 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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View 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"
}

View 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"
}

View 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"
}

View File

@@ -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"
}

View 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"
}

View 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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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
}
}
}

View File

@@ -12,19 +12,16 @@ SERVER_DIR = Path(__file__).resolve().parents[1]
RISK_RULE_DIR = SERVER_DIR / "rules" / "risk-rules" RISK_RULE_DIR = SERVER_DIR / "rules" / "risk-rules"
BUDGET_EXPENSE_TYPES = ( BUDGET_EXPENSE_TYPES = ("all",)
SUPPORTED_DEMO_EXPENSE_TYPES = {
"all",
"travel", "travel",
"hotel", "hotel",
"transport", "transport",
"meal", "meal",
"meeting",
"marketing",
"office", "office",
"training",
"software",
"communication", "communication",
"welfare", }
)
FIELD_LABELS = { FIELD_LABELS = {
@@ -456,7 +453,7 @@ RULES: tuple[DemoRiskRule, ...] = (
"rule.expense.company_travel_expense_reimbursement", "rule.expense.company_travel_expense_reimbursement",
"差旅住宿费标准", "差旅住宿费标准",
("reimbursement",), ("reimbursement",),
("travel", "hotel", "transport"), ("travel",),
"差旅金额达到大额阈值且缺少有效出差申请时触发。", "差旅金额达到大额阈值且缺少有效出差申请时触发。",
("差旅申请", "大额差旅", "未申请"), ("差旅申请", "大额差旅", "未申请"),
"high", "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: def main() -> None:
RISK_RULE_DIR.mkdir(parents=True, exist_ok=True) RISK_RULE_DIR.mkdir(parents=True, exist_ok=True)
for rule in RULES: for rule in RULES:

View 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()

View File

@@ -24,6 +24,10 @@ class CurrentUserContext:
is_admin: bool is_admin: bool
department_name: str = "" department_name: str = ""
cost_center: str = "" cost_center: str = ""
position: str = ""
grade: str = ""
employee_no: str = ""
manager_name: str = ""
def get_current_user( def get_current_user(
@@ -51,6 +55,22 @@ def get_current_user(
str | None, str | None,
Header(description="当前登录人的成本中心。"), Header(description="当前登录人的成本中心。"),
] = None, ] = 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: ) -> CurrentUserContext:
role_codes = [ role_codes = [
_normalize_role_code(item) _normalize_role_code(item)
@@ -79,6 +99,10 @@ def get_current_user(
is_admin=is_admin, is_admin=is_admin,
department_name=(x_auth_department or "").strip(), department_name=(x_auth_department or "").strip(),
cost_center=(x_auth_cost_center 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(),
) )

View File

@@ -2,21 +2,29 @@ from __future__ import annotations
from typing import Annotated, NoReturn 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 sqlalchemy.orm import Session
from app.api.deps import ( from app.api.deps import (
CurrentUserContext, CurrentUserContext,
get_current_user,
get_db, get_db,
require_rule_editor_user, require_rule_editor_user,
require_rule_reviewer_user,
) )
from app.schemas.agent_asset import ( from app.schemas.agent_asset import (
AgentAssetRead, AgentAssetRead,
AgentAssetRiskRuleDraftUpdate, AgentAssetRiskRuleDraftUpdate,
AgentAssetRiskRuleFeedbackCreate,
AgentAssetRiskRuleFeedbackRead,
AgentAssetRiskRuleRegenerateRequest,
AgentAssetRiskRuleRevisionCreate, 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_asset_risk_rule_revision import AgentAssetRiskRuleRevisionService
from app.services.agent_assets import AgentAssetService 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") router = APIRouter(prefix="/agent-assets")
DbSession = Annotated[Session, Depends(get_db)] DbSession = Annotated[Session, Depends(get_db)]
@@ -29,6 +37,8 @@ RequestIdHeader = Annotated[
Header(description="外部请求 ID用于串联审计日志和上游调用链。"), Header(description="外部请求 ID用于串联审计日志和上游调用链。"),
] ]
RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)] 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: def _handle_asset_error(exc: Exception) -> NoReturn:
@@ -50,6 +60,16 @@ def _read_asset(db: Session, asset_id: str) -> AgentAssetRead:
return asset 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( @router.patch(
"/{asset_id}/risk-rules/draft", "/{asset_id}/risk-rules/draft",
response_model=AgentAssetRead, response_model=AgentAssetRead,
@@ -101,3 +121,80 @@ def create_risk_rule_revision(
return _read_asset(db, asset_id) return _read_asset(db, asset_id)
except Exception as exc: except Exception as exc:
_handle_asset_error(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)

View 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

View File

@@ -7,8 +7,10 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db 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.finance_dashboard import FinanceDashboardRead
from app.schemas.system_dashboard import SystemDashboardRead 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.finance_dashboard import FinanceDashboardService
from app.services.system_dashboard import SystemDashboardService from app.services.system_dashboard import SystemDashboardService
@@ -32,6 +34,26 @@ def get_system_dashboard(
return SystemDashboardService(db).build_dashboard(days=days) 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( @router.get(
"/finance-dashboard", "/finance-dashboard",
response_model=FinanceDashboardRead, response_model=FinanceDashboardRead,

View File

@@ -5,8 +5,9 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session 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 ( from app.schemas.auth import (
AuthUserRead,
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
SessionFinishRequest, 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 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( @router.post(
"/sessions/{session_id}/finish", "/sessions/{session_id}/finish",
response_model=SessionFinishResponse, response_model=SessionFinishResponse,

View File

@@ -545,6 +545,34 @@ def delete_expense_claim_item_attachment(
return ExpenseClaimAttachmentActionResponse(**payload) 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( @router.post(
"/claims/{claim_id}/submit", "/claims/{claim_id}/submit",
response_model=ExpenseClaimRead, response_model=ExpenseClaimRead,

View File

@@ -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_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_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_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.analytics import router as analytics_router
from app.api.v1.endpoints.audit_logs import router as audit_logs_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 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_asset_risk_rules_router, tags=["agent-assets"])
router.include_router(agent_feedback_router, tags=["agent-feedback"]) router.include_router(agent_feedback_router, tags=["agent-feedback"])
router.include_router(agent_runs_router, tags=["agent-runs"]) 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(analytics_router, tags=["analytics"])
router.include_router(audit_logs_router, tags=["audit-logs"]) router.include_router(audit_logs_router, tags=["audit-logs"])
router.include_router(knowledge_router, tags=["knowledge"]) router.include_router(knowledge_router, tags=["knowledge"])

View File

@@ -1,8 +1,14 @@
from app.db.base_class import Base from app.db.base_class import Base
from app.models.agent_conversation import AgentConversation, AgentConversationMessage 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_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.approval import ApprovalRecord
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
@@ -34,11 +40,13 @@ __all__ = [
"AgentConversationMessage", "AgentConversationMessage",
"AgentAsset", "AgentAsset",
"AgentAssetReview", "AgentAssetReview",
"AgentAssetRuleFeedback",
"AgentAssetTestRun", "AgentAssetTestRun",
"AgentAssetVersion", "AgentAssetVersion",
"AgentOperationFeedback", "AgentOperationFeedback",
"AgentRun", "AgentRun",
"AgentToolCall", "AgentToolCall",
"AgentTraceEvent",
"ApprovalRecord", "ApprovalRecord",
"AuditLog", "AuditLog",
"BudgetAllocation", "BudgetAllocation",

View File

@@ -1,7 +1,7 @@
from app.models.agent_conversation import AgentConversation, AgentConversationMessage 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_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.approval import ApprovalRecord
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
@@ -32,10 +32,12 @@ __all__ = [
"AgentConversationMessage", "AgentConversationMessage",
"AgentAsset", "AgentAsset",
"AgentAssetReview", "AgentAssetReview",
"AgentAssetRuleFeedback",
"AgentAssetVersion", "AgentAssetVersion",
"AgentOperationFeedback", "AgentOperationFeedback",
"AgentRun", "AgentRun",
"AgentToolCall", "AgentToolCall",
"AgentTraceEvent",
"ApprovalRecord", "ApprovalRecord",
"AuditLog", "AuditLog",
"BudgetAllocation", "BudgetAllocation",

View File

@@ -4,7 +4,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Any 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.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON from sqlalchemy.types import JSON
@@ -52,6 +52,12 @@ class AgentAsset(Base):
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="desc(AgentAssetTestRun.created_at)", 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): class AgentAssetVersion(Base):
@@ -103,3 +109,34 @@ class AgentAssetTestRun(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
asset = relationship("AgentAsset", back_populates="test_runs") 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")

View File

@@ -4,7 +4,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Any 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.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON 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()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
run = relationship("AgentRun", back_populates="semantic_parse_logs") 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())

View File

@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from app.models.agent_asset import ( from app.models.agent_asset import (
AgentAsset, AgentAsset,
AgentAssetReview, AgentAssetReview,
AgentAssetRuleFeedback,
AgentAssetTestRun, AgentAssetTestRun,
AgentAssetVersion, AgentAssetVersion,
) )
@@ -218,6 +219,36 @@ class AgentAssetRepository:
self.db.refresh(test_run) self.db.refresh(test_run)
return 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: def delete_asset(self, asset: AgentAsset) -> None:
self.db.delete(asset) self.db.delete(asset)
self.db.commit() self.db.commit()

View File

@@ -146,6 +146,38 @@ class AgentAssetRiskRuleRegenerateRequest(BaseModel):
requires_attachment: bool | None = None 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): class AgentAssetRiskRuleSampleCase(BaseModel):
case_id: str | None = Field(default=None, max_length=60) case_id: str | None = Field(default=None, max_length=60)
name: str = Field(default="测试样例", min_length=1, max_length=80) 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) trace: dict[str, Any] = Field(default_factory=dict)
attachments: list[dict[str, Any]] = Field(default_factory=list) attachments: list[dict[str, Any]] = Field(default_factory=list)
recognized_fields: 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) missing_fields: list[dict[str, Any]] = Field(default_factory=list)
recognition_summary: list[dict[str, Any]] = Field(default_factory=list) recognition_summary: list[dict[str, Any]] = Field(default_factory=list)
execution_mode: str = "risk_rule_simulation" execution_mode: str = "risk_rule_simulation"
@@ -229,6 +264,38 @@ class AgentAssetRiskRuleLevelUpdate(BaseModel):
risk_level: str = Field(pattern="^(low|medium|high|critical)$") 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): class AgentAssetRiskRuleTestRunRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View 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)

View 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)

View File

@@ -130,6 +130,9 @@ class ExpenseClaimRead(BaseModel):
employee_position: str | None = None employee_position: str | None = None
employee_grade: str | None = None employee_grade: str | None = None
manager_name: 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) role_labels: list[str] = Field(default_factory=list)
project_code: str | None project_code: str | None
expense_type: str expense_type: str
@@ -202,6 +205,7 @@ class ExpenseClaimAttachmentActionResponse(BaseModel):
item_location: str | None = None item_location: str | None = None
item_amount: Decimal | None = None item_amount: Decimal | None = None
claim_amount: Decimal | None = None claim_amount: Decimal | None = None
claim_risk_flags: list[Any] = Field(default_factory=list)
attachment: ExpenseClaimAttachmentRead | None = None attachment: ExpenseClaimAttachmentRead | None = None

View File

@@ -117,9 +117,11 @@ class RiskObservationDashboardRead(BaseModel):
window_days: int window_days: int
total_observations: int total_observations: int
pending_count: int pending_count: int
risk_clue_count: int = 0
high_or_above_count: int high_or_above_count: int
confirmed_count: int confirmed_count: int
false_positive_count: int false_positive_count: int
feedback_sample_count: int = 0
total_amount: float = 0.0 total_amount: float = 0.0
average_score: float average_score: float
level_distribution: dict[str, int] = Field(default_factory=dict) level_distribution: dict[str, int] = Field(default_factory=dict)

View File

@@ -17,7 +17,7 @@ class AgentAssetJsonRuleMixin:
if rule_library not in RULE_LIBRARY_NAMES: if rule_library not in RULE_LIBRARY_NAMES:
raise ValueError("规则库目录不合法。") 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): if not isinstance(rule_document, dict):
raise ValueError("规则资产缺少 rule_document 配置。") raise ValueError("规则资产缺少 rule_document 配置。")
@@ -26,6 +26,27 @@ class AgentAssetJsonRuleMixin:
raise ValueError("规则资产缺少 JSON 文件名。") raise ValueError("规则资产缺少 JSON 文件名。")
return rule_library, file_name 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: def read_rule_json(self, asset_id: str) -> AgentAssetRuleJsonRead:
asset = self.repository.get(asset_id) asset = self.repository.get(asset_id)
if asset is None: if asset is None:

View 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 {}

View 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

View 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 {},
}

View File

@@ -40,7 +40,20 @@ class AgentAssetRiskRuleSimulationMixin:
attachments=attachments, attachments=attachments,
) )
recognition_summary = self._build_recognition_summary(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) 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( missing_fields = self._build_missing_fields(
manifest, manifest,
field_values=field_values, field_values=field_values,
@@ -67,6 +80,9 @@ class AgentAssetRiskRuleSimulationMixin:
normalized_fields=field_values, normalized_fields=field_values,
attachments=attachments, attachments=attachments,
recognized_fields=recognized_fields, 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, missing_fields=missing_fields,
recognition_summary=recognition_summary, recognition_summary=recognition_summary,
created_at=datetime.now(UTC), created_at=datetime.now(UTC),
@@ -108,6 +124,9 @@ class AgentAssetRiskRuleSimulationMixin:
trace=execution["trace"] if isinstance(execution.get("trace"), dict) else {}, trace=execution["trace"] if isinstance(execution.get("trace"), dict) else {},
attachments=attachments, attachments=attachments,
recognized_fields=recognized_fields, 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, recognition_summary=recognition_summary,
created_at=datetime.now(UTC), created_at=datetime.now(UTC),
@@ -565,6 +584,108 @@ class AgentAssetRiskRuleSimulationMixin:
if source_map.get(key) 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 @staticmethod
def _build_recognition_summary(attachments: list[dict[str, Any]]) -> list[dict[str, Any]]: def _build_recognition_summary(attachments: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [ return [

View File

@@ -28,7 +28,9 @@ from app.schemas.agent_asset import (
) )
from app.services.agent_asset_json_rules import AgentAssetJsonRuleMixin from app.services.agent_asset_json_rules import AgentAssetJsonRuleMixin
from app.services.agent_asset_onlyoffice import AgentAssetOnlyOfficeMixin 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_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_simulation import AgentAssetRiskRuleSimulationMixin
from app.services.agent_asset_risk_rule_testing import AgentAssetRiskRuleTestingMixin from app.services.agent_asset_risk_rule_testing import AgentAssetRiskRuleTestingMixin
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
@@ -47,6 +49,8 @@ class AgentAssetService(
AgentAssetOnlyOfficeMixin, AgentAssetOnlyOfficeMixin,
AgentAssetSpreadsheetHelperMixin, AgentAssetSpreadsheetHelperMixin,
AgentAssetRiskRuleLevelMixin, AgentAssetRiskRuleLevelMixin,
AgentAssetRiskRulePublishMixin,
AgentAssetRiskRuleFeedbackMixin,
AgentAssetRiskRuleTestingMixin, AgentAssetRiskRuleTestingMixin,
AgentAssetRiskRuleSimulationMixin, AgentAssetRiskRuleSimulationMixin,
AgentAssetTimelineMixin, AgentAssetTimelineMixin,

View File

@@ -126,14 +126,31 @@ class AgentFoundationAssetSeedMixin:
[ [
"---", "---",
"name: risk-rule-discovery", "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": "财务制度", "folder": "财务制度",
"changed_only": True, "changed_only": True,
"output_format": "knowledge_organizing_report", "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() self.db.flush()
company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed( company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed(
@@ -615,22 +645,6 @@ class AgentFoundationAssetSeedMixin:
change_note="初始化整理公司财务知识制度能力。", change_note="初始化整理公司财务知识制度能力。",
created_by="系统初始化", 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="系统初始化",
),
] ]
) )

View File

@@ -614,6 +614,15 @@ class AgentFoundationAssetTopUpMixin:
"folder": "财务制度", "folder": "财务制度",
"changed_only": True, "changed_only": True,
"output_format": "knowledge_organizing_report", "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: if DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE not in existing_codes:
@@ -669,6 +678,15 @@ class AgentFoundationAssetTopUpMixin:
"folder": existing_config.get("folder") or "财务制度", "folder": existing_config.get("folder") or "财务制度",
"changed_only": existing_config.get("changed_only", True), "changed_only": existing_config.get("changed_only", True),
"output_format": "knowledge_organizing_report", "output_format": "knowledge_organizing_report",
"allowed_outputs": [
"facts",
"policy_refs",
"evidence_refs",
"knowledge_items",
"human_review_required",
],
"role_boundary": "规则由人定义,风险由人确认,数字员工只整理人提供的制度和报销事实。",
"writes_rules": False,
**schedule_config, **schedule_config,
} }
self.db.add(asset) self.db.add(asset)

View File

@@ -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_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 = ( DIGITAL_EMPLOYEE_LEGACY_TASK_CODES = (
"task.hermes.daily_risk_scan", "task.hermes.daily_risk_scan",
"task.hermes.weekly_ar_summary", "task.hermes.weekly_ar_summary",
@@ -107,8 +133,21 @@ DIGITAL_EMPLOYEE_LEGACY_TASK_CODES = (
DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP = { DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP = {
DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE: "整理", DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE: "整理",
DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_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_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 = { ATTACHMENT_RULE_RUNTIME_CONFIG = {

View File

@@ -11,16 +11,126 @@ from app.core.agent_enums import (
) )
from app.models.agent_asset import AgentAsset from app.models.agent_asset import AgentAsset
from app.services.agent_foundation_constants import ( 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_PROFILE_SCAN_TASK_CODE,
DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE, DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE,
DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE, DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE,
DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE,
DIGITAL_EMPLOYEE_SKILL_CATEGORIES, 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: class AgentFoundationDigitalEmployeeTaskMixin:
def _runtime_digital_employee_task_specs(self) -> tuple[dict[str, object], ...]: def _runtime_digital_employee_task_specs(self) -> tuple[dict[str, object], ...]:
return ( 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, "code": DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE,
"name": "财务风险图谱巡检", "name": "财务风险图谱巡检",
@@ -43,6 +153,15 @@ class AgentFoundationDigitalEmployeeTaskMixin:
], ],
"output_format": "risk_observation_report", "output_format": "risk_observation_report",
"writes_risk_observations": True, "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": "风控与审计部", "owner": "风控与审计部",
"reviewer": "顾承宇", "reviewer": "顾承宇",
"cron": "30 8 * * 1", "cron": "30 8 * * 1",
"skill_category": "评估", "skill_category": "积累",
"markdown": self._employee_behavior_profile_scan_skill_markdown, "markdown": self._employee_behavior_profile_scan_skill_markdown,
"change_note": "初始化员工行为画像巡检能力。", "change_note": "初始化员工行为画像巡检能力。",
"config": { "config": {
@@ -66,30 +185,199 @@ class AgentFoundationDigitalEmployeeTaskMixin:
], ],
"output_format": "employee_behavior_profile_snapshot", "output_format": "employee_behavior_profile_snapshot",
"writes_profile_snapshots": True, "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, "code": DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE,
"name": "风险规则候选发现", "name": "风险线索归集",
"description": "按计划复盘风险观察和人工反馈,生成带证据、来源和置信度的候选规则,不直接上线", "description": "按计划复盘申请、报销、规则命中和人工反馈,归集带事实依据的潜在线索,提交人工复核,不生成规则",
"scenario_json": ["schedule", "risk_observation", "feedback", "rule_candidate"], "scenario_json": ["schedule", "application", "reimbursement", "risk_clue"],
"owner": "风控与审计部", "owner": "风控与审计部",
"reviewer": "顾承宇", "reviewer": "顾承宇",
"cron": "0 10 * * 1", "cron": "0 10 * * 1",
"skill_category": "升级", "skill_category": "升级",
"markdown": self._risk_rule_discovery_skill_markdown, "markdown": self._risk_clue_collector_skill_markdown,
"change_note": "初始化风险规则候选发现能力。", "change_note": "初始化风险线索归集能力。",
"config": { "config": {
"skill_name": "risk-rule-discovery", "task_type": "risk_clue_collect",
"skill_name": "risk-clue-collector",
"input_sources": [ "input_sources": [
"risk_observations", "expense_applications",
"expense_claims",
"rule_hits",
"risk_observation_feedback", "risk_observation_feedback",
"algorithm_replay_sets",
], ],
"output_format": "candidate_risk_rules", "output_format": "risk_clue_review_packet",
"auto_publish": False, "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: def _upsert_runtime_digital_employee_tasks(self, existing_codes: set[str]) -> None:
@@ -146,6 +434,7 @@ class AgentFoundationDigitalEmployeeTaskMixin:
cron = str(spec["cron"]) cron = str(spec["cron"])
base = { base = {
**self._digital_employee_task_config(code, cron), **self._digital_employee_task_config(code, cron),
"skill_category": str(spec["skill_category"]),
"schedule": cron, "schedule": cron,
"cron_expression": cron, "cron_expression": cron,
**dict(spec["config"]), **dict(spec["config"]),

View File

@@ -24,6 +24,7 @@ from app.services.agent_foundation_constants import (
logger = get_logger("app.services.agent_foundation") logger = get_logger("app.services.agent_foundation")
EXPENSE_TYPE_SCENARIO_LABELS = { EXPENSE_TYPE_SCENARIO_LABELS = {
"all": "全部",
"travel": "差旅费", "travel": "差旅费",
"hotel": "住宿费", "hotel": "住宿费",
"transport": "交通费", "transport": "交通费",
@@ -158,6 +159,10 @@ class AgentFoundationRiskRuleMixin:
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {} metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
candidates.extend(_collect(metadata.get("expense_types"))) 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] = [] normalized: list[str] = []
seen: set[str] = set() seen: set[str] = set()
for item in candidates: for item in candidates:
@@ -170,6 +175,9 @@ class AgentFoundationRiskRuleMixin:
@staticmethod @staticmethod
def _expense_type_scenario_labels(expense_types: list[str]) -> list[str]: 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] = [] labels: list[str] = []
seen: set[str] = set() seen: set[str] = set()
for expense_type in expense_types: for expense_type in expense_types:

View 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)

View 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]

View File

@@ -81,6 +81,20 @@ class AuthService:
session = UserSessionMetricService(self.db).start_session(user) session = UserSessionMetricService(self.db).start_session(user)
return LoginResponse(user=self._serialize_user(user), sessionId=session.session_id) 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: def _authenticate_admin(self, identifier: str, password: str) -> AuthenticatedUser | None:
record = SettingsService(self.db).verify_admin_login(identifier, password) record = SettingsService(self.db).verify_admin_login(identifier, password)
if record is None: if record is None:
@@ -114,17 +128,7 @@ class AuthService:
return None return None
EmployeeService(self.db).ensure_directory_ready() EmployeeService(self.db).ensure_directory_ready()
employee = self._find_employee_by_email(identifier)
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()
if employee is None or not employee.password_hash: if employee is None or not employee.password_hash:
return None return None
@@ -136,6 +140,21 @@ class AuthService:
if not verify_password(password, employee.password_hash): if not verify_password(password, employee.password_hash):
return None 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( sorted_roles = sorted(
list(employee.roles), list(employee.roles),
key=lambda item: (ROLE_DISPLAY_ORDER.get(item.role_code, 999), item.name), key=lambda item: (ROLE_DISPLAY_ORDER.get(item.role_code, 999), item.name),

View File

@@ -24,6 +24,7 @@ from app.services.budget_types import (
SUPPORTED_BUDGET_SUBJECT_CODES, SUPPORTED_BUDGET_SUBJECT_CODES,
) )
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS 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 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(), "created_at": datetime.now(UTC).isoformat(),
} }
payload.update(extra or {}) 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( def _build_operation_flag(
self, self,

View 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 "未标记")

View File

@@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
from sqlalchemy import or_, select from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
from app.algorithem.employee_behavior_profile import ( from app.algorithem.employee_behavior_profile import (
@@ -102,7 +102,8 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
commit: bool = True, commit: bool = True,
) -> list[EmployeeBehaviorProfileSnapshot]: ) -> list[EmployeeBehaviorProfileSnapshot]:
self.ensure_storage_ready() 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: if employee is None:
return [] return []
@@ -161,10 +162,11 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
expense_type_scope: str = "overall", expense_type_scope: str = "overall",
) -> EmployeeProfileLatestRead: ) -> EmployeeProfileLatestRead:
self.ensure_storage_ready() 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: if employee is None:
return EmployeeProfileLatestRead( return EmployeeProfileLatestRead(
employee_id=employee_id, employee_id=requested_employee_id,
scene=scene, scene=scene,
window_days=window_days, window_days=window_days,
expense_type_scope=expense_type_scope, 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_scope = self._resolve_scope_from_claim(claim_id, expense_type_scope)
resolved_employee_id = employee.id
rows = self._load_latest_snapshots( rows = self._load_latest_snapshots(
employee_id=employee_id, employee_id=resolved_employee_id,
window_days=window_days, window_days=window_days,
expense_type_scope=resolved_scope, expense_type_scope=resolved_scope,
scene=scene, scene=scene,
) )
if not rows and claim_id: if not rows and claim_id:
self.refresh_employee_profiles( self.refresh_employee_profiles(
employee_id=employee_id, employee_id=resolved_employee_id,
window_days=(window_days,), window_days=(window_days,),
expense_type_scope=resolved_scope, expense_type_scope=resolved_scope,
source_task_type="api_on_demand", source_task_type="api_on_demand",
claim_id=claim_id, claim_id=claim_id,
) )
rows = self._load_latest_snapshots( rows = self._load_latest_snapshots(
employee_id=employee_id, employee_id=resolved_employee_id,
window_days=window_days, window_days=window_days,
expense_type_scope=resolved_scope, expense_type_scope=resolved_scope,
scene=scene, scene=scene,
@@ -201,6 +204,31 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
expense_type_scope=resolved_scope, 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( def _build_window_context(
self, self,
*, *,

View File

@@ -185,10 +185,7 @@ class ExpenseClaimAccessPolicy:
return False return False
if current_user.is_admin: if current_user.is_admin:
return True return True
role_codes = self.normalize_role_codes(current_user) return self.is_department_budget_approver(current_user, claim)
if "executive" in role_codes:
return True
return self.is_department_p8_budget_monitor(current_user, claim)
def is_budget_manager_user(self, current_user: CurrentUserContext) -> bool: def is_budget_manager_user(self, current_user: CurrentUserContext) -> bool:
if current_user.is_admin: if current_user.is_admin:
@@ -197,13 +194,16 @@ class ExpenseClaimAccessPolicy:
return bool(role_codes & BUDGET_APPROVAL_ROLE_CODES) return bool(role_codes & BUDGET_APPROVAL_ROLE_CODES)
def is_department_p8_budget_monitor(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: def is_department_p8_budget_monitor(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
role_codes = self.normalize_role_codes(current_user) return self.is_department_budget_approver(current_user, claim)
if BUDGET_MONITOR_ROLE_CODE not in role_codes:
return False
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) current_employee = self.resolve_current_employee(current_user)
if current_employee is None: if current_employee is None:
return False 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): if not self._employee_has_budget_approval_grade(current_employee):
return False return False
@@ -224,7 +224,7 @@ class ExpenseClaimAccessPolicy:
.options(selectinload(Employee.organization_unit), selectinload(Employee.roles)) .options(selectinload(Employee.organization_unit), selectinload(Employee.roles))
.where( .where(
func.upper(func.coalesce(Employee.grade, "")) == BUDGET_MONITOR_APPROVAL_GRADE, 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), or_(*department_conditions),
) )
.order_by(Employee.name.asc(), Employee.employee_no.asc()) .order_by(Employee.name.asc(), Employee.employee_no.asc())
@@ -235,6 +235,37 @@ class ExpenseClaimAccessPolicy:
stmt = stmt.where(Employee.id != claim_employee_id) stmt = stmt.where(Employee.id != claim_employee_id)
return self.db.scalar(stmt) 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 @staticmethod
def normalize_role_codes(current_user: CurrentUserContext) -> set[str]: def normalize_role_codes(current_user: CurrentUserContext) -> set[str]:
return { return {
@@ -243,6 +274,16 @@ class ExpenseClaimAccessPolicy:
if str(item).strip() 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 @staticmethod
def _employee_has_budget_approval_grade(employee: Employee) -> bool: def _employee_has_budget_approval_grade(employee: Employee) -> bool:
return str(employee.grade or "").strip().upper() == BUDGET_MONITOR_APPROVAL_GRADE 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.username or "").strip(),
str(current_user.name 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" 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: 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) current_employee = self.resolve_current_employee(current_user)
if current_employee is not None: 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 return True
identity_values = { identity_values = {
str(current_employee.name or "").strip(), str(current_employee.name or "").strip(),
@@ -325,9 +368,12 @@ class ExpenseClaimAccessPolicy:
{ {
str(current_user.username or "").strip(), str(current_user.username or "").strip(),
str(current_user.name or "").strip(), str(current_user.name or "").strip(),
str(current_user.employee_no or "").strip(),
} }
) )
identity_values.discard("") 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 return str(claim.employee_name or "").strip() in identity_values
@staticmethod @staticmethod
@@ -490,8 +536,10 @@ class ExpenseClaimAccessPolicy:
add_condition("employee_name", employee.name) add_condition("employee_name", employee.name)
else: else:
add_condition("employee_id", username) 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", username)
add_condition("employee_name", str(current_user.name or "").strip()) add_condition("employee_name", str(current_user.name or "").strip())
add_condition("employee_name", str(current_user.employee_no or "").strip())
return conditions return conditions
@@ -531,10 +579,10 @@ class ExpenseClaimAccessPolicy:
return conditions return conditions
def build_budget_approval_claim_conditions(self, current_user: CurrentUserContext) -> list[Any]: 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) 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): if employee is None or not self._employee_has_budget_approval_grade(employee):
return [] return []
@@ -568,7 +616,7 @@ class ExpenseClaimAccessPolicy:
def apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any: def apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
role_codes = self.normalize_role_codes(current_user) 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") return stmt.where(ExpenseClaim.status == "submitted")
conditions = [] conditions = []
if "finance" in role_codes: if "finance" in role_codes:

View File

@@ -15,6 +15,10 @@ from app.services.expense_claim_workflow_constants import (
PAYMENT_PENDING_STAGE, PAYMENT_PENDING_STAGE,
PAYMENT_PENDING_STATUS, PAYMENT_PENDING_STATUS,
) )
from app.services.expense_claim_risk_stage import (
risk_business_stage_for_claim,
with_risk_business_stage,
)
class ExpenseClaimApprovalFlowMixin: class ExpenseClaimApprovalFlowMixin:
@@ -35,41 +39,72 @@ class ExpenseClaimApprovalFlowMixin:
previous_stage = str(claim.approval_stage or "").strip() previous_stage = str(claim.approval_stage or "").strip()
is_application_claim = self._is_expense_application_claim(claim) 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 next_budget_manager = None
merged_budget_approval = False merged_budget_approval = False
route_decision_flag: dict[str, Any] | None = None
if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE: if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE:
if not self._access_policy.can_approve_claim(current_user, claim): if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有当前直属领导审批人可以审批通过该单据。") raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
approval_source = "manual_approval" approval_source = "manual_approval"
event_type = "expense_application_approval" if is_application_claim else "expense_claim_approval" event_type = "expense_application_approval" if is_application_claim else "expense_claim_approval"
label = "领导审批通过" 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: 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: if merged_budget_approval:
label = "领导及预算审核通过" label = "领导及预算审核通过"
next_status = "approved" next_status = "approved"
next_stage = APPROVAL_DONE_STAGE next_stage = APPROVAL_DONE_STAGE
default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。" default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。"
else: elif requires_budget_review:
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim) next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
if next_budget_manager is None:
raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。")
next_status = "submitted" next_status = "submitted"
next_stage = BUDGET_MANAGER_APPROVAL_STAGE 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: else:
next_status = "submitted" next_status = "submitted"
next_stage = FINANCE_APPROVAL_STAGE next_stage = FINANCE_APPROVAL_STAGE
default_message = "{operator} 已审批通过,流转至{next_stage}" default_message = "{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}"
elif previous_stage == BUDGET_MANAGER_APPROVAL_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): if not self._access_policy.can_approve_claim(current_user, claim):
raise ValueError("只有当前预算管理者可以审批通过该费用申请") raise ValueError("只有当前预算管理者可以审批通过该单据")
approval_source = "budget_approval" approval_source = "budget_approval"
event_type = "expense_application_budget_approval" event_type = (
"expense_application_budget_approval"
if is_application_claim
else "expense_claim_budget_approval"
)
label = "预算管理者审核通过" label = "预算管理者审核通过"
if is_application_claim:
next_status = "approved" next_status = "approved"
next_stage = APPROVAL_DONE_STAGE next_stage = APPROVAL_DONE_STAGE
default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。" default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。"
else:
next_status = "submitted"
next_stage = FINANCE_APPROVAL_STAGE
default_message = "{operator} 已完成预算审核,流转至{next_stage}"
elif previous_stage == FINANCE_APPROVAL_STAGE: elif previous_stage == FINANCE_APPROVAL_STAGE:
if is_application_claim: if is_application_claim:
raise ValueError("费用申请需先完成预算管理者审批。") raise ValueError("费用申请需先完成预算管理者审批。")
@@ -95,7 +130,8 @@ class ExpenseClaimApprovalFlowMixin:
consumed_budget_flag = self._consume_budget_for_finance_approval(claim, current_user) consumed_budget_flag = self._consume_budget_for_finance_approval(claim, current_user)
if consumed_budget_flag is not None: if consumed_budget_flag is not None:
budget_flags.append(consumed_budget_flag) budget_flags.append(consumed_budget_flag)
approval_flag = { approval_flag = with_risk_business_stage(
{
"source": approval_source, "source": approval_source,
"event_type": event_type, "event_type": event_type,
"approval_event_id": str(uuid.uuid4()), "approval_event_id": str(uuid.uuid4()),
@@ -115,12 +151,14 @@ class ExpenseClaimApprovalFlowMixin:
"next_status": next_status, "next_status": next_status,
"next_approval_stage": next_stage, "next_approval_stage": next_stage,
"created_at": datetime.now(UTC).isoformat(), "created_at": datetime.now(UTC).isoformat(),
} },
business_stage,
)
if merged_budget_approval: if merged_budget_approval:
approval_flag.update( approval_flag.update(
{ {
"budget_approval_merged": True, "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: 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_name": str(next_budget_manager.name or "").strip(),
"next_approver_employee_id": next_budget_manager.id, "next_approver_employee_id": next_budget_manager.id,
"next_approver_grade": str(next_budget_manager.grade or "").strip(), "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.status = next_status
claim.approval_stage = next_stage claim.approval_stage = next_stage
@@ -147,6 +196,13 @@ class ExpenseClaimApprovalFlowMixin:
elif merged_budget_approval: elif merged_budget_approval:
approval_flag["leader_opinion"] = approval_opinion approval_flag["leader_opinion"] = approval_opinion
approval_flag["budget_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( generated_draft = self._create_reimbursement_draft_from_application(
application_claim=claim, application_claim=claim,
approval_flag=approval_flag, approval_flag=approval_flag,
@@ -162,14 +218,21 @@ class ExpenseClaimApprovalFlowMixin:
generated_draft.risk_flags_json = self._append_budget_flags( generated_draft.risk_flags_json = self._append_budget_flags(
generated_draft.risk_flags_json, generated_draft.risk_flags_json,
transferred_budget_flag, 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( claim.risk_flags_json = self._append_budget_flags(
[*list(claim.risk_flags_json or []), approval_flag], approval_flags,
budget_flags, budget_flags,
business_stage=business_stage,
) )
self.db.commit() self.db.commit()
self.db.refresh(claim) self.db.refresh(claim)
self._access_policy.attach_budget_approval_snapshot(claim)
self.audit_service.log_action( self.audit_service.log_action(
actor=operator, actor=operator,
@@ -202,7 +265,8 @@ class ExpenseClaimApprovalFlowMixin:
before_json = self._serialize_claim(claim) before_json = self._serialize_claim(claim)
operator = self._access_policy.resolve_current_user_display_name(current_user) operator = self._access_policy.resolve_current_user_display_name(current_user)
previous_stage = str(claim.approval_stage or "").strip() previous_stage = str(claim.approval_stage or "").strip()
payment_flag = { payment_flag = with_risk_business_stage(
{
"source": "payment", "source": "payment",
"event_type": "expense_claim_payment_completed", "event_type": "expense_claim_payment_completed",
"payment_event_id": str(uuid.uuid4()), "payment_event_id": str(uuid.uuid4()),
@@ -221,7 +285,9 @@ class ExpenseClaimApprovalFlowMixin:
"next_status": PAYMENT_PAID_STATUS, "next_status": PAYMENT_PAID_STATUS,
"next_approval_stage": PAYMENT_PAID_STAGE, "next_approval_stage": PAYMENT_PAID_STAGE,
"created_at": datetime.now(UTC).isoformat(), "created_at": datetime.now(UTC).isoformat(),
} },
"reimbursement",
)
claim.status = PAYMENT_PAID_STATUS claim.status = PAYMENT_PAID_STATUS
claim.approval_stage = PAYMENT_PAID_STAGE claim.approval_stage = PAYMENT_PAID_STAGE

View 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")

View File

@@ -277,6 +277,7 @@ class ExpenseClaimAttachmentOperationsMixin:
"item_location": item.item_location, "item_location": item.item_location,
"item_amount": item.item_amount, "item_amount": item.item_amount,
"claim_amount": claim.amount, "claim_amount": claim.amount,
"claim_risk_flags": list(claim.risk_flags_json or []),
"attachment": self._build_attachment_payload(item), "attachment": self._build_attachment_payload(item),
} }
@@ -371,6 +372,7 @@ class ExpenseClaimAttachmentOperationsMixin:
"claim_id": claim.id, "claim_id": claim.id,
"item_id": item.id, "item_id": item.id,
"invoice_id": item.invoice_id, "invoice_id": item.invoice_id,
"claim_risk_flags": list(claim.risk_flags_json or []),
"attachment": None, "attachment": None,
} }

View File

@@ -5,6 +5,7 @@ from typing import Any
from app.api.deps import CurrentUserContext from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.services.budget import BudgetService from app.services.budget import BudgetService
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
class ExpenseClaimBudgetFlowMixin: class ExpenseClaimBudgetFlowMixin:
@@ -80,6 +81,8 @@ class ExpenseClaimBudgetFlowMixin:
def _append_budget_flags( def _append_budget_flags(
risk_flags: list[Any] | None, risk_flags: list[Any] | None,
budget_flags: list[dict[str, Any]] | dict[str, Any] | None, budget_flags: list[dict[str, Any]] | dict[str, Any] | None,
*,
business_stage: str | None = None,
) -> list[Any]: ) -> list[Any]:
if budget_flags is None: if budget_flags is None:
return list(risk_flags or []) return list(risk_flags or [])
@@ -89,7 +92,19 @@ class ExpenseClaimBudgetFlowMixin:
next_flags = list(budget_flags or []) next_flags = list(budget_flags or [])
if not next_flags: if not next_flags:
return list(risk_flags or []) 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 @staticmethod
def _resolve_budget_operator(current_user: CurrentUserContext) -> str: def _resolve_budget_operator(current_user: CurrentUserContext) -> str:

View File

@@ -224,6 +224,10 @@ class ExpenseClaimDraftFlowMixin:
existing_flags=list(claim.risk_flags_json or []) if claim is not None else [], existing_flags=list(claim.risk_flags_json or []) if claim is not None else [],
next_flags=list(ontology.risk_flags), 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: if context_documents or attachment_names:
document_specs = self._build_context_item_specs( document_specs = self._build_context_item_specs(
context_documents=context_documents, context_documents=context_documents,
@@ -347,6 +351,7 @@ class ExpenseClaimDraftFlowMixin:
context_json=retry_context, context_json=retry_context,
) )
raise raise
except Exception: except Exception:
self.db.rollback() self.db.rollback()
raise raise
@@ -374,6 +379,86 @@ class ExpenseClaimDraftFlowMixin:
"invoice_count": int(claim.invoice_count or 0), "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( def _find_target_claim(
self, self,
*, *,

View File

@@ -27,6 +27,7 @@ from app.services.expense_claim_constants import (
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES, TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN, TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
) )
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_rule_runtime import ( from app.services.expense_rule_runtime import (
ExpenseRuleRuntimeService, ExpenseRuleRuntimeService,
RuntimeTravelPolicy, RuntimeTravelPolicy,
@@ -215,6 +216,7 @@ class ExpenseClaimItemSyncMixin:
claim.amount = Decimal("0.00") claim.amount = Decimal("0.00")
claim.invoice_count = 0 claim.invoice_count = 0
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, []) claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(claim, [])
return return
ordered_items = sorted( ordered_items = sorted(
@@ -253,6 +255,7 @@ class ExpenseClaimItemSyncMixin:
claim, claim,
self._build_claim_attachment_risk_flags(ordered_items), self._build_claim_attachment_risk_flags(ordered_items),
) )
self._refresh_claim_platform_risk_preview_flags(claim)
if str(claim.status or "").strip().lower() == "draft": if str(claim.status or "").strip().lower() == "draft":
claim.approval_stage = "待提交" claim.approval_stage = "待提交"
@@ -359,6 +362,7 @@ class ExpenseClaimItemSyncMixin:
analysis.get("label") or ("高风险" if severity == "high" else "中风险") analysis.get("label") or ("高风险" if severity == "high" else "中风险")
).strip() ).strip()
derived_flags.append( derived_flags.append(
with_risk_business_stage(
{ {
"source": "attachment_analysis", "source": "attachment_analysis",
"item_id": item.id, "item_id": item.id,
@@ -367,7 +371,9 @@ class ExpenseClaimItemSyncMixin:
"message": f"费用明细第 {index} 条:{message_detail}", "message": f"费用明细第 {index} 条:{message_detail}",
"summary": summary, "summary": summary,
"points": points, "points": points,
} },
"reimbursement",
)
) )
return derived_flags return derived_flags
@@ -412,6 +418,38 @@ class ExpenseClaimItemSyncMixin:
] ]
return preserved_flags + attachment_risk_flags 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 @staticmethod
def _format_submission_blocked_message(issues: list[str]) -> str: def _format_submission_blocked_message(issues: list[str]) -> str:
normalized_issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()] normalized_issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()]

View File

@@ -29,7 +29,9 @@ class ExpenseClaimPaginationMixin:
ExpenseClaim.occurred_at.desc(), ExpenseClaim.occurred_at.desc(),
) )
stmt = self._access_policy.apply_claim_scope(stmt, current_user) 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( def list_approval_claims_page(
self, self,
@@ -43,7 +45,9 @@ class ExpenseClaimPaginationMixin:
ExpenseClaim.created_at.desc(), ExpenseClaim.created_at.desc(),
) )
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user) 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( def list_archived_claims_page(
self, self,

View File

@@ -15,18 +15,27 @@ from app.services.expense_rule_runtime import (
RuntimeTravelPolicy, RuntimeTravelPolicy,
) )
from app.services.expense_type_keywords import resolve_expense_type_code_from_text 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_manifest_normalizer import normalize_risk_rule_manifest
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
class ExpenseClaimPlatformRiskMixin: class ExpenseClaimPlatformRiskMixin:
_DEFAULT_RISK_BUSINESS_STAGE = "reimbursement"
_SUPPORTED_RISK_BUSINESS_STAGES = {"expense_application", "reimbursement"}
def evaluate_platform_risk_rules( def evaluate_platform_risk_rules(
self, self,
claim: ExpenseClaim, claim: ExpenseClaim,
*, *,
rule_codes: list[str] | None = None, rule_codes: list[str] | None = None,
business_stage: str | None = None,
) -> dict[str, list[Any]]: ) -> 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: if not manifests:
return {"flags": [], "blocking_reasons": []} return {"flags": [], "blocking_reasons": []}
@@ -69,6 +78,7 @@ class ExpenseClaimPlatformRiskMixin:
self, self,
*, *,
rule_codes: list[str] | None, rule_codes: list[str] | None,
business_stage: str | None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
code_filter = { code_filter = {
str(code or "").strip() for code in list(rule_codes or []) if str(code or "").strip() 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() 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): if not manifest_code or (code_filter and manifest_code not in code_filter):
continue 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 continue
payload = dict(payload) payload = dict(payload)
@@ -149,7 +162,10 @@ class ExpenseClaimPlatformRiskMixin:
continue continue
if code_filter and rule_code not in missing_codes: if code_filter and rule_code not in missing_codes:
continue 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 continue
payload = dict(payload) payload = dict(payload)
payload["_rule_version"] = "v1.0.0" payload["_rule_version"] = "v1.0.0"
@@ -157,6 +173,34 @@ class ExpenseClaimPlatformRiskMixin:
return list(manifests_by_code.values()) 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( def _risk_manifest_applies_to_claim(
self, self,
manifest: dict[str, Any], manifest: dict[str, Any],
@@ -187,9 +231,19 @@ class ExpenseClaimPlatformRiskMixin:
configured_expense_types = self._normalize_expense_type_values( configured_expense_types = self._normalize_expense_type_values(
*[str(value or "") for value in list(applies_to.get("expense_types") or [])] *[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): if configured_expense_types and not (expense_types & configured_expense_types):
return False return False
if configured_expense_categories and not (expense_types & configured_expense_categories):
return False
if domains and not self._risk_domains_match_claim( if domains and not self._risk_domains_match_claim(
domains, domains,
expense_types=expense_types, expense_types=expense_types,
@@ -207,11 +261,19 @@ class ExpenseClaimPlatformRiskMixin:
if not raw: if not raw:
continue continue
normalized.add(raw.lower()) 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) resolved = resolve_expense_type_code_from_text(raw)
if resolved: if resolved:
normalized.add(resolved) normalized.add(resolved)
return normalized return normalized
@staticmethod
def _is_all_expense_scope(values: set[str]) -> bool:
return bool(values & {"all", "*", "overall", "general", "全部", "通用"})
def _risk_domains_match_claim( def _risk_domains_match_claim(
self, self,
domains: set[str], domains: set[str],
@@ -634,25 +696,12 @@ class ExpenseClaimPlatformRiskMixin:
message: str, message: str,
evidence: dict[str, Any], evidence: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {} return build_platform_risk_flag(
fail_outcome = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {} manifest,
severity = str(fail_outcome.get("severity") or "medium").strip().lower() or "medium" message=message,
default_action = "block" if severity in {"high", "critical"} else "manual_review" evidence=evidence,
action = str(fail_outcome.get("action") or default_action).strip() default_business_stage=self._DEFAULT_RISK_BUSINESS_STAGE,
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,
}
@staticmethod @staticmethod
def _count_values(values: list[str]) -> dict[str, int]: def _count_values(values: list[str]) -> dict[str, int]:

View 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,
)

View File

@@ -27,6 +27,7 @@ from app.services.expense_claim_constants import (
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES, TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN, TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
) )
from app.services.expense_claim_risk_stage import with_risk_business_stage
from app.services.expense_rule_runtime import ( from app.services.expense_rule_runtime import (
ExpenseRuleRuntimeService, ExpenseRuleRuntimeService,
RuntimeTravelPolicy, RuntimeTravelPolicy,
@@ -135,7 +136,7 @@ class ExpenseClaimPolicyReviewMixin:
) )
return { 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)), "blocking_reasons": list(dict.fromkeys(reason for reason in blocking_reasons if reason)),
} }
@@ -393,7 +394,7 @@ class ExpenseClaimPolicyReviewMixin:
blocking_reasons.append("交通舱位或席别超出当前职级差标,且未补充例外说明。") blocking_reasons.append("交通舱位或席别超出当前职级差标,且未补充例外说明。")
return { 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)), "blocking_reasons": list(dict.fromkeys(reason for reason in blocking_reasons if reason)),
} }

View 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]

View File

@@ -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_item_sync import ExpenseClaimItemSyncMixin
from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin
from app.services.expense_claim_policy_review import ExpenseClaimPolicyReviewMixin 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 from app.services.risk_observations import RiskObservationService
logger = get_logger("app.services.expense_claim_risk_review") 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 { return {
"status": "submitted", "status": "submitted",
"approval_stage": "直属领导审批", "approval_stage": "直属领导审批",

View 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)

View File

@@ -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.document_numbering import is_application_claim_no
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin 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_presentation import ExpenseClaimAttachmentPresentation
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin 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_draft_persistence import ExpenseClaimDraftPersistenceMixin
from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError
from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin 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_ontology_resolvers import ExpenseClaimOntologyResolverMixin
from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin 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_review_preview import ExpenseClaimReviewPreviewMixin
from app.services.expense_claim_constants import ( from app.services.expense_claim_constants import (
EXPENSE_TYPE_LABELS, EXPENSE_TYPE_LABELS,
@@ -131,7 +134,9 @@ from app.services.ocr import OcrService
class ExpenseClaimService( class ExpenseClaimService(
ExpenseClaimPaginationMixin, ExpenseClaimPaginationMixin,
ExpenseClaimApprovalFlowMixin, ExpenseClaimApprovalFlowMixin,
ExpenseClaimApprovalRoutingMixin,
ExpenseClaimApplicationHandoffMixin, ExpenseClaimApplicationHandoffMixin,
ExpenseClaimPreReviewMixin,
ExpenseClaimBudgetFlowMixin, ExpenseClaimBudgetFlowMixin,
ExpenseClaimAttachmentOperationsMixin, ExpenseClaimAttachmentOperationsMixin,
ExpenseClaimReviewPreviewMixin, ExpenseClaimReviewPreviewMixin,
@@ -197,7 +202,7 @@ class ExpenseClaimService(
.order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc()) .order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc())
) )
stmt = self._access_policy.apply_claim_scope(stmt, current_user) 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]: def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
stmt = ( stmt = (
@@ -210,7 +215,7 @@ class ExpenseClaimService(
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc()) .order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc())
) )
stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user) 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]: def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]:
stmt = ( stmt = (
@@ -236,7 +241,7 @@ class ExpenseClaimService(
.where(ExpenseClaim.id == claim_id) .where(ExpenseClaim.id == claim_id)
) )
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True) 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: def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
if claim is None: if claim is None:
@@ -468,10 +473,17 @@ class ExpenseClaimService(
for flag in list(claim.risk_flags_json or []) for flag in list(claim.risk_flags_json or [])
if not ( if not (
isinstance(flag, dict) 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 = { 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", "source": "application_submission",
"event_type": "expense_application_submission", "event_type": "expense_application_submission",
"severity": "info", "severity": "info",
@@ -482,13 +494,23 @@ class ExpenseClaimService(
"next_status": "submitted", "next_status": "submitted",
"next_approval_stage": "直属领导审批", "next_approval_stage": "直属领导审批",
"created_at": submitted_at.isoformat(), "created_at": submitted_at.isoformat(),
} },
"expense_application",
)
claim.status = "submitted" claim.status = "submitted"
claim.approval_stage = "直属领导审批" 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 claim.submitted_at = submitted_at
else: 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) review_result = self._run_ai_submission_review(claim)
claim.status = str(review_result.get("status") or "supplement") claim.status = str(review_result.get("status") or "supplement")
@@ -681,6 +703,7 @@ class ExpenseClaimService(
claim.risk_flags_json = self._append_budget_flags( claim.risk_flags_json = self._append_budget_flags(
[*list(claim.risk_flags_json or []), return_flag], [*list(claim.risk_flags_json or []), return_flag],
budget_flags, budget_flags,
business_stage="expense_application" if is_application_claim else "reimbursement",
) )
self.db.commit() self.db.commit()
@@ -717,10 +740,6 @@ class ExpenseClaimService(

View 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()

View File

@@ -13,6 +13,7 @@ from app.algorithem.risk_graph import (
from app.core.logging import get_logger from app.core.logging import get_logger
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.models.hermes_report import HermesRiskReport 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 from app.services.risk_observations import RiskObservationService
logger = get_logger("app.services.hermes_risk_scanner") logger = get_logger("app.services.hermes_risk_scanner")
@@ -110,7 +111,8 @@ class HermesRiskScannerService:
@staticmethod @staticmethod
def _append_algorithm_flag(claim: ExpenseClaim, observation: dict) -> list: def _append_algorithm_flag(claim: ExpenseClaim, observation: dict) -> list:
existing = list(claim.risk_flags_json or []) existing = list(claim.risk_flags_json or [])
flag = { flag = with_risk_business_stage(
{
"source": "financial_risk_graph", "source": "financial_risk_graph",
"risk_signal": observation.get("risk_signal"), "risk_signal": observation.get("risk_signal"),
"severity": observation.get("risk_level"), "severity": observation.get("risk_level"),
@@ -118,7 +120,9 @@ class HermesRiskScannerService:
"confidence_score": observation.get("confidence_score"), "confidence_score": observation.get("confidence_score"),
"algorithm_version": observation.get("algorithm_version"), "algorithm_version": observation.get("algorithm_version"),
"observation_key": observation.get("observation_key"), "observation_key": observation.get("observation_key"),
} },
"reimbursement",
)
if any( if any(
isinstance(item, dict) isinstance(item, dict)
and item.get("observation_key") == flag["observation_key"] and item.get("observation_key") == flag["observation_key"]

View File

@@ -10,6 +10,7 @@ from app.db.session import get_session_factory
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService
from app.services.hermes_expense_report import HermesExpenseReportService 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 from app.services.hermes_risk_scanner import HermesRiskScannerService
logger = get_logger("app.services.hermes_scheduler") logger = get_logger("app.services.hermes_scheduler")
@@ -168,6 +169,14 @@ class HermesScheduler:
f"生成 {summary.get('snapshot_count', 0)} 条快照," f"生成 {summary.get('snapshot_count', 0)} 条快照,"
f"重点关注 {summary.get('high_attention_employee_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.status = "success"
log_record.completed_at = datetime.now(UTC) log_record.completed_at = datetime.now(UTC)

View File

@@ -25,9 +25,11 @@ from app.schemas.orchestrator import (
from app.schemas.user_agent import UserAgentRequest from app.schemas.user_agent import UserAgentRequest
from app.services.agent_assets import AgentAssetService from app.services.agent_assets import AgentAssetService
from app.services.agent_conversations import AgentConversationService from app.services.agent_conversations import AgentConversationService
from app.services.auth import AuthService
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.agent_foundation import AgentFoundationService from app.services.agent_foundation import AgentFoundationService
from app.services.agent_runs import AgentRunService from app.services.agent_runs import AgentRunService
from app.services.agent_traces import AgentTraceService
from app.services.knowledge import KnowledgeService from app.services.knowledge import KnowledgeService
from app.services.ontology import SemanticOntologyService from app.services.ontology import SemanticOntologyService
from app.services.orchestrator_execution import ExecutionOutcome, OrchestratorExecutionEngine from app.services.orchestrator_execution import ExecutionOutcome, OrchestratorExecutionEngine
@@ -57,6 +59,7 @@ class OrchestratorService:
self.expense_claim_service = ExpenseClaimService(db) self.expense_claim_service = ExpenseClaimService(db)
self.knowledge_service = KnowledgeService(db=db) self.knowledge_service = KnowledgeService(db=db)
self.run_service = AgentRunService(db) self.run_service = AgentRunService(db)
self.trace_service = AgentTraceService(db)
self.ontology_service = SemanticOntologyService(db) self.ontology_service = SemanticOntologyService(db)
self.user_agent_service = UserAgentService(db) self.user_agent_service = UserAgentService(db)
self.database_query_builder = OrchestratorDatabaseQueryBuilder(db) self.database_query_builder = OrchestratorDatabaseQueryBuilder(db)
@@ -67,11 +70,15 @@ class OrchestratorService:
knowledge_service=self.knowledge_service, knowledge_service=self.knowledge_service,
user_agent_service=self.user_agent_service, user_agent_service=self.user_agent_service,
database_query_builder=self.database_query_builder, database_query_builder=self.database_query_builder,
trace_service=self.trace_service,
) )
def run(self, payload: OrchestratorRequest) -> OrchestratorResponse: def run(self, payload: OrchestratorRequest) -> OrchestratorResponse:
AgentFoundationService(self.db).ensure_foundation_ready() 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_id = str(payload.conversation_id or "").strip() or None
conversation = None conversation = None
if payload.source == AgentRunSource.USER_MESSAGE.value: if payload.source == AgentRunSource.USER_MESSAGE.value:
@@ -87,6 +94,9 @@ class OrchestratorService:
context_json=context_json, context_json=context_json,
message=payload.message, message=payload.message,
) )
context_json["conversation_id"] = conversation_id
elif conversation_id:
context_json["conversation_id"] = conversation_id
route_json: dict[str, Any] = { route_json: dict[str, Any] = {
"orchestrated_by": AgentName.ORCHESTRATOR.value, "orchestrated_by": AgentName.ORCHESTRATOR.value,
@@ -105,6 +115,25 @@ class OrchestratorService:
status=AgentRunStatus.RUNNING.value, status=AgentRunStatus.RUNNING.value,
result_summary="Orchestrator 已接收请求。", 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: try:
message, task_asset = self._resolve_message(payload) message, task_asset = self._resolve_message(payload)
@@ -120,6 +149,19 @@ class OrchestratorService:
"ocr_summary": context_json.get("ocr_summary", ""), "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( ontology = self.ontology_service.parse_for_run(
OntologyParseRequest( OntologyParseRequest(
query=message, query=message,
@@ -128,6 +170,16 @@ class OrchestratorService:
), ),
run_id=run.run_id, 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"): if context_json.get("simulate_orchestrator_exception"):
raise RuntimeError("simulated orchestrator exception") raise RuntimeError("simulated orchestrator exception")
selected_agent, route_reason = self._select_agent(payload, ontology) 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_review_action
and not is_expense_application_context 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 = { route_json = {
"orchestrated_by": AgentName.ORCHESTRATOR.value, "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( return OrchestratorResponse(
run_id=run.run_id, run_id=run.run_id,
conversation_id=conversation_id, conversation_id=conversation_id,
@@ -350,6 +447,17 @@ class OrchestratorService:
) )
except Exception as exc: except Exception as exc:
logger.exception("Orchestrator run failed run_id=%s", run.run_id) 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( self.run_service.update_run(
run.run_id, run.run_id,
agent=AgentName.ORCHESTRATOR.value, 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( def _resolve_message(
self, self,
payload: OrchestratorRequest, payload: OrchestratorRequest,

View File

@@ -13,6 +13,7 @@ from app.schemas.ontology import OntologyParseResult
from app.schemas.orchestrator import OrchestratorRequest from app.schemas.orchestrator import OrchestratorRequest
from app.schemas.user_agent import UserAgentRequest, UserAgentResponse from app.schemas.user_agent import UserAgentRequest, UserAgentResponse
from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService 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.hermes_risk_scanner import HermesRiskScannerService
from app.services.knowledge_sync import KnowledgeSyncDispatchService from app.services.knowledge_sync import KnowledgeSyncDispatchService
@@ -36,6 +37,7 @@ class OrchestratorExecutionEngine:
knowledge_service, knowledge_service,
user_agent_service, user_agent_service,
database_query_builder, database_query_builder,
trace_service=None,
) -> None: ) -> None:
self.db = db self.db = db
self.run_service = run_service self.run_service = run_service
@@ -43,6 +45,7 @@ class OrchestratorExecutionEngine:
self.knowledge_service = knowledge_service self.knowledge_service = knowledge_service
self.user_agent_service = user_agent_service self.user_agent_service = user_agent_service
self.database_query_builder = database_query_builder self.database_query_builder = database_query_builder
self.trace_service = trace_service
def _execute_user_agent( def _execute_user_agent(
self, self,
@@ -383,6 +386,8 @@ class OrchestratorExecutionEngine:
task_asset=task_asset, task_asset=task_asset,
context_json=context_json, 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 return None
def _execute_risk_graph_scan(self, *, run_id: str, context_json: dict[str, Any]) -> ExecutionOutcome: 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, 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 @staticmethod
def _resolve_task_type(task_asset: AgentAssetRead | None) -> str: def _resolve_task_type(task_asset: AgentAssetRead | None) -> str:
if task_asset is None: if task_asset is None:
@@ -613,6 +653,11 @@ class OrchestratorExecutionEngine:
status="succeeded", status="succeeded",
duration_ms=duration_ms, 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 return response, False
except Exception as exc: except Exception as exc:
duration_ms = int((perf_counter() - started) * 1000) duration_ms = int((perf_counter() - started) * 1000)
@@ -627,6 +672,11 @@ class OrchestratorExecutionEngine:
duration_ms=duration_ms, duration_ms=duration_ms,
error_message=str(exc), 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 return response, True
@staticmethod @staticmethod

Some files were not shown because too many files have changed in this diff Show More