diff --git a/.env b/.env index d7a68bc..385d376 100644 --- a/.env +++ b/.env @@ -27,7 +27,7 @@ SERVER_BLOCKING_STARTUP_TIMEOUT=12 VITE_API_BASE_URL=/api/v1 VITE_AUTH_IDLE_TIMEOUT_MINUTES=30 ONLYOFFICE_ENABLED=true -ONLYOFFICE_PUBLIC_URL=http://10.10.10.122:8082 +ONLYOFFICE_PUBLIC_URL=http://www.caoxiaozhu.com:8082 ONLYOFFICE_BACKEND_URL=http://main:8000 ONLYOFFICE_JWT_SECRET=change-me-onlyoffice HERMES_AGENT_SHARED_TOKEN=change-me-hermes diff --git a/AGENTS.md b/AGENTS.md index 86a316b..f7f34d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,8 +32,25 @@ - 前端大型 Vue 页面:优先拆分 composable、view model、样式分片、业务工具函数和子组件。 - 算法/规则模块:优先拆分输入解析、规则匹配、评分策略、结果解释和异常处理。 -## 验证规范 +## 容器与运行环境(必读) -- 后端改动优先在 Docker 容器 `x-financial-main` 中运行验证。 -- 单元测试设置合理超时,避免长时间卡死。 -- 每次重构后至少运行对应服务的定向测试;涉及公共协议时补充端到端或接口测试。 +本项目代码是 Docker 容器 `x-financial-main`(镜像 `x-financial-dev:latest`)的源码映射。 + +- **容器映射**:宿主机 `D:\Code\Project\X-Financial` ↔ 容器内 `/app`(`docker-compose.yml` 中 `volumes: - .:/app`,`working_dir: /app`)。 +- **后端 venv**:容器内位于 `/tmp/x-financial-server-venv`(环境变量 `SERVER_VENV_DIR`),不要假设宿主机上有相同的 venv。 +- **外部依赖**:Qdrant(`x-financial-qdrant`)、OnlyOffice(`x-financial-onlyoffice`)也在同一 compose 网络里。 + +## 验证规范(硬性约束) + +> 本项目代码与运行环境以容器为唯一事实来源。所有后端测试、集成测试、依赖了 Qdrant / OnlyOffice / venv 的验证,都必须在 `x-financial-main` 容器内执行,**不要在宿主机上直接跑 pytest / pip / python**。 + +- **进入容器跑命令**(最常用): + ```bash + docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main + ``` + - 跑后端测试:`docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-main /tmp/x-financial-server-venv/bin/pytest -q ` + - 交互式排查:`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 路径相关代码**时,自己也要回容器里跑一次确认改动生效,不要只改文件就声称完成。 diff --git a/document/development/Agent链路追踪中心/CONCEPT.md b/document/development/Agent链路追踪中心/CONCEPT.md new file mode 100644 index 0000000..b482240 --- /dev/null +++ b/document/development/Agent链路追踪中心/CONCEPT.md @@ -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/总账体系和前端统一状态管理。 diff --git a/document/development/Agent链路追踪中心/TODO.md b/document/development/Agent链路追踪中心/TODO.md new file mode 100644 index 0000000..df0b24b --- /dev/null +++ b/document/development/Agent链路追踪中心/TODO.md @@ -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/前端状态管理两项待定问题。 diff --git a/document/development/hermes-risk-graph-algorithm/CONCEPT.md b/document/development/hermes-risk-graph-algorithm/CONCEPT.md index 0f6e238..ce2d90e 100644 --- a/document/development/hermes-risk-graph-algorithm/CONCEPT.md +++ b/document/development/hermes-risk-graph-algorithm/CONCEPT.md @@ -4,7 +4,9 @@ ## 1. 功能一句话 -以数字员工为后台执行入口,持续把财务业务数据转成行为画像、制度语义和风险观察,再通过图谱证据链、单据详情和风险看板提供可解释的风险判断。 +以数字员工为后台分析入口,持续把财务业务数据转成行为画像、制度语义和风险观察,再通过图谱证据链、单据详情和风险看板提供可解释的风险判断。 + +规则中心、审批和报销主流程仍由外层智能体流程负责调度;数字员工只消费事实、规则命中和反馈结果,生成后台分析、报告、知识库材料和待复核线索。 ## 2. 背景与问题 @@ -16,7 +18,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识 - 分析看板如果只从散表拼统计图,会成为展示型页面,不能反映算法效果。 - 数字员工如果只做定时任务,会变成调度工具,不能形成核心壁垒。 -本方案把核心能力定义为“财务行为图谱风险引擎”。它不是单一模型,而是一套闭环:事件沉淀、实体建图、画像基线、风险推理、人工反馈、规则发现。 +本方案把核心能力定义为“财务行为图谱风险引擎”。它不是单一模型,而是一套闭环:事件沉淀、实体建图、画像基线、风险推理、人工反馈、待复核线索归集。 ## 3. 核心判断 @@ -78,7 +80,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识 算法版本 ``` -这会形成自有训练集、评测集和规则优化样本。竞品能复制算法框架,但复制不了这些被真实审批和财务人员校准过的样本。 +这会形成自有训练集、评测集和规则执行校准样本。竞品能复制算法框架,但复制不了这些被真实审批和财务人员校准过的样本。 第四层壁垒:人机共审行为数据。 @@ -92,7 +94,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识 补件 升级审批 标记误报 -生成候选规则 +归集待复核线索 ``` 这些反馈会反向影响规则质量、抽审比例、自动化门控和数字员工能力考核。越使用越贴近企业自己的财务控制风格。 @@ -139,10 +141,10 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识 ### 4.2 非目标 - 第一版不做独立“大图谱中心”,避免做成展示型页面。 -- 不让大模型直接决定风险等级,大模型只参与语义抽取、解释生成和候选规则发现。 +- 不让大模型直接决定风险等级,大模型只参与语义抽取、解释生成和事实线索整理;不参与规则生成、改写或发布。 - 不用画像自动惩罚员工,不给员工永久贴标签。 - 不在员工技能详情中展示知识归集图谱;图谱结果只进入工作记录详情、单据风险详情或画像详情。 -- 不让数字员工自动上线规则;规则候选必须经过管理员审核。 +- 不让数字员工生成、改写或自动上线规则;规则变更只能由管理员在规则中心维护。 ## 5. 用户与场景 @@ -178,7 +180,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识 - 数字员工运行是否成功。 - 本次分析处理了多少数据、产出多少风险观察。 -- 候选规则是否有足够证据。 +- 待复核风险线索是否有足够事实和证据。 - 是否需要调整技能、规则、制度知识或调度配置。 ### 5.4 管理层 @@ -201,7 +203,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识 - 制度整理员工:把公司财务制度整理成条款、适用范围、费用类型、触发条件和引用关系。 - 风险扫描员工:定期扫描新增单据、票据、供应商、审批链和画像偏离,生成风险观察。 - 画像更新员工:定期更新员工、部门、供应商、费用类型画像和同类基线。 -- 规则发现员工:从历史退回、误报、漏报、制度变化和高频异常中生成候选规则。 +- 风险线索归集员工:从申请、报销、规则命中和人工反馈中归集待复核线索,不生成规则。 ### 6.2 图谱体现方式 @@ -259,7 +261,7 @@ X-Financial 已经具备费用申请、报销、审批、规则中心、知识 - 风险分布:按部门、费用类型、风险类型、供应商、员工职级分布。 - 风险趋势:7 天 / 30 天风险走势、高风险占比、重复触发趋势、处理完成率。 - 异常排行:风险最多部门、偏离基线最大员工、高频异常供应商、高频触发规则。 -- 算法效果:规则命中数、图谱异常命中数、人工确认率、误报率、候选规则数。 +- 算法效果:规则命中数、图谱异常命中数、人工确认率、误报率、待复核线索数。 风险看板必须从 `RiskObservation` 聚合,不直接从散表临时拼图表。 @@ -437,7 +439,7 @@ confirmed_by_feedback 由人工反馈确认 图谱引擎:canonical node + whitelisted edge 构造证据路径 风险观察:ontology fields + evidence path 输出可解释结论 风险看板:按 ontology scenario / expense_type / risk_signal 聚合 -数字员工:只能产出本体可识别的候选风险信号和候选规则 +数字员工:只能产出本体可识别的事实、规则命中、待复核风险线索和证据引用 ``` 当本体置信度不足时,风险图谱必须降级: @@ -602,7 +604,7 @@ $$ ### 8.4 人工反馈校准 -人工反馈不直接覆盖算法,但会影响后续权重和候选规则优先级: +人工反馈不直接覆盖算法,但会影响后续权重和待复核线索优先级: $$ confirmed\_rate = \frac{confirmed}{confirmed + false\_positive + ignored} diff --git a/document/development/hermes-risk-graph-algorithm/TODO.md b/document/development/hermes-risk-graph-algorithm/TODO.md index d9e8308..4ca6197 100644 --- a/document/development/hermes-risk-graph-algorithm/TODO.md +++ b/document/development/hermes-risk-graph-algorithm/TODO.md @@ -43,7 +43,7 @@ - [x] 为风险观察补充 `ontology_parse_id`、`ontology_version`、`domain`、`scenario`、`intent`、`ontology_entities_json`、`risk_signals_json` 和 `canonical_subject_key`。[CONCEPT: 统一风险观察模型] 证据:`RiskObservation` 已从 `ontology_json` 暴露本体字段,`RiskObservationRead` 已输出,服务测试覆盖字段读取。 - [x] 为风险观察增加必要索引:主体、单据、风险类型、等级、状态、来源、创建时间。[CONCEPT: 技术验收] 证据:`RiskObservation.__table_args__` 与字段索引覆盖主体、单据、等级、状态、信号、来源和时间。 - [x] 设计对象中心财务事件日志模型,把申请、预算占用、票据上传、审批、退回、付款、归档、复盘统一为可回放事件。[CONCEPT: 不可复制壁垒设计] 证据:`process_mining.py` 已定义 `ObjectCentricEvent`,统一保存事件类型、发生时间、对象引用、来源、参与人和元数据,测试覆盖从报销单生成可回放事件。 -- [x] 设计风险观察反馈池字段,记录人工采纳、驳回、改写、退回、补件、升级审批、误报和候选规则来源。[CONCEPT: 不可复制壁垒设计] 证据:`RiskObservationFeedback` 已通过兼容属性暴露 `decision/candidate_rule_source/confidence_score/escalation_target/supplement_required`,测试覆盖候选规则反馈元数据。 +- [x] 设计风险观察反馈池字段,记录人工采纳、驳回、改写、退回、补件、升级审批、误报和线索来源。[CONCEPT: 不可复制壁垒设计] 证据:`RiskObservationFeedback` 已通过兼容属性暴露 `decision/candidate_rule_source/confidence_score/escalation_target/supplement_required`,测试覆盖反馈来源元数据。 - [x] 设计算法回放集模型,绑定历史单据、本体版本、规则版本、算法版本和反馈标签。[CONCEPT: 不可复制壁垒设计] 证据:`replay.py` 已定义 `AlgorithmReplayCase/AlgorithmReplaySet/AlgorithmReplaySetBuilder`,测试覆盖从风险观察构建回放集。 ## 3. 后端服务 @@ -98,7 +98,7 @@ - [x] 将风险扫描和员工画像巡检注册到数字员工的员工技能列表。[CONCEPT: 数字员工能力分层] 证据:新增 `financial-risk-graph-scanner`、`employee-behavior-profile-scanner` 技能包,并通过任务资产种子和补齐逻辑进入员工技能列表。 - [x] 员工技能详情的立即运行按技能类型调用真实后端任务。[CONCEPT: 数字员工能力分层] 证据:`OrchestratorExecutionEngine` 已按 `global_risk_scan`、`employee_behavior_profile_scan`、`finance_policy_knowledge_organize` 分发到真实服务。 - [x] 新增或扩展画像更新员工,定期更新员工、部门、供应商、费用类型基线。[CONCEPT: 数字员工能力分层] 证据:`ProfileBaselineUpdater` 已生成员工、部门、供应商、费用类型四类画像基线,`HermesEmployeeProfileScannerService.scan_employee_profiles()` 已返回 `baseline_summary`;`pytest --ignore=.venv-ocr312 tests/test_risk_graph_profile_baselines.py tests/test_hermes_employee_profile_baselines.py -q` 通过。 -- [x] 新增规则发现员工候选输出,候选规则必须带证据、来源和置信度。[CONCEPT: 数字员工能力分层] 证据:`rule_discovery.py` 已定义 `CandidateRiskRuleDiscovery` 和 `CandidateRiskRule`,输出包含证据、来源、置信度和候选状态;数字员工任务与技能已注册为“风险规则候选发现”。 +- [x] 新增风险线索归集员工输出,线索必须带事实、规则命中、证据来源和人工复核标记。[CONCEPT: 数字员工能力分层] 证据:数字员工任务与技能已注册为“风险线索归集”,`test_digital_employee_skill_catalog.py` 已锁定不输出候选规则或自动发布语义。 - [x] 数字员工运行完成后写入处理范围、处理数量、风险观察数量和失败原因。[CONCEPT: 数字员工工作记录详情] 证据:`HermesScheduler` 已写入风险图谱巡检摘要,失败仍沿用执行日志 `error_trace`。 - [x] 确认 UI 上继续使用“数字员工 / 员工技能 / 工作记录”等业务命名,不在普通用户界面暴露内部实现名。[CONCEPT: 用户与场景] 证据:`DigitalEmployeesView.vue` 页签文案为“员工技能 / 工作记录”,普通界面未展示内部 Hermes 名称。 @@ -125,16 +125,16 @@ - [x] 增加风险分布图:部门、费用类型、风险类型、供应商、员工职级。[CONCEPT: 分析看板风险看板] 证据:`RiskObservationDashboard.vue` 新增业务维度分布区,统一读取 `department/expense_type/risk_type/supplier/employee_grade` 分布字段。 - [x] 增加风险趋势图:7 天 / 30 天走势、高风险占比、处理完成率。[CONCEPT: 分析看板风险看板] 证据:`RiskDailyTrendChart.vue` 已展示风险观察与高风险趋势;风险看板时间窗口支持 7/30/90 天切换,处理完成率由闭环效果区承接。 - [x] 增加异常排行:部门、员工、供应商、规则、费用类型。[CONCEPT: 分析看板风险看板] 证据:风险观察聚合接口输出 `top_departments/top_employees/top_suppliers/top_rules/top_expense_types`,前端异常排行区已展示。 -- [x] 增加算法效果:规则命中数、图谱异常命中数、人工确认率、误报率、候选规则数。[CONCEPT: 分析看板风险看板] 证据:风险看板已展示平均风险分、人工确认数、误报样本和候选规则数,规则/图谱来源通过来源分布体现。 +- [x] 增加算法效果:规则命中数、图谱异常命中数、人工确认率、误报率、待复核线索数。[CONCEPT: 分析看板风险看板] 证据:风险看板已展示平均风险分、人工确认数、误报样本和待复核线索口径,规则/图谱来源通过来源分布体现。 - [x] 风险看板所有数据通过风险观察聚合接口读取,不直接拼接业务散表。[CONCEPT: 技术验收] 证据:后端已提供 `/api/v1/risk-observations/dashboard` 作为统一聚合源。 ## 9. 规则与反馈闭环 - [x] 规则中心执行结果写入风险观察池或与风险观察建立关联。[CONCEPT: 统一风险观察模型] 证据:`RiskObservationService.upsert_platform_risk_flags()` 已接收规则中心风险命中,报销提交预审会同步写入风险观察池。 - [x] 风险观察支持人工确认、误报、忽略、已处理等反馈。[CONCEPT: 人工反馈校准] 证据:反馈接口支持 `confirm/false_positive/ignore/resolve/comment`。 -- [x] 规则发现员工根据反馈生成候选规则,不直接上线。[CONCEPT: 非目标] 证据:`CandidateRiskRuleDiscovery.discover_from_feedback()` 只输出 `status == "candidate_review"` 的候选规则,技能文件要求 `auto_publish=false`,测试覆盖不直接上线。 +- [x] 风险线索归集员工根据反馈整理待复核线索,不生成、不改写、不上线规则。[CONCEPT: 非目标] 证据:技能配置统一写入 `writes_rules=false`、`role_boundary` 和 `allowed_outputs`,目录测试覆盖不再注册候选规则技能名或规则优化输出格式。 - [x] 风险观察详情展示反馈历史和当前处理状态。[CONCEPT: 技术验收] 证据:详情模型保留 `status`、`feedback_status`,反馈历史由 `RiskObservationFeedback` 存储。 -- [x] 风险看板展示人工确认率、误报率和候选规则数量。[CONCEPT: 分析看板风险看板] 证据:聚合接口已输出 `confirmation_rate` 和 `false_positive_rate`;候选规则数待规则发现员工接入后补充。 +- [x] 风险看板展示人工确认率、误报率和待复核线索数量。[CONCEPT: 分析看板风险看板] 证据:聚合接口已输出 `confirmation_rate` 和 `false_positive_rate`;待复核线索口径由风险观察与人工复核状态聚合。 ## 10. 测试与验证 diff --git a/document/development/risk-rule-explainable-flow/CONCEPT.md b/document/development/risk-rule-explainable-flow/CONCEPT.md index aedade8..1b7f28f 100644 --- a/document/development/risk-rule-explainable-flow/CONCEPT.md +++ b/document/development/risk-rule-explainable-flow/CONCEPT.md @@ -526,6 +526,32 @@ docker exec x-financial-main sh -lc "cd /app/server && pytest --timeout docker exec x-financial-main sh -lc "cd /app/web && npm run build" ``` +### 本轮落地结果 + +已落地接口: + +- `GET /agent-assets/risk-rules/templates`:返回预算、票据、差旅、招待、采购/AP、企业卡、通用模板分组,包含默认自然语言、字段清单、附件要求和 DSL 样例。 +- `PATCH /agent-assets/{asset_id}/risk-rules/draft`:编辑未上线风险规则草稿。 +- `POST /agent-assets/{asset_id}/risk-rules/revisions`:为已上线规则创建修订草稿。 +- `POST /agent-assets/{asset_id}/risk-rules/regenerate`:重新生成 DSL、流程图、风险评分和业务说明。 +- `POST /agent-assets/{asset_id}/risk-rules/feedback`、`GET /agent-assets/{asset_id}/risk-rules/feedback`:记录和查看误判/漏判反馈。 + +关键文件: + +- 后端模板库与契约:`risk_rule_template_catalog.py`、`agent_asset.py`、`agent_asset_risk_rules.py`。 +- 后端生成、修订、发布、反馈、仿真:`risk_rule_generation*`、`agent_asset_risk_rule_revision.py`、`agent_asset_risk_rule_regeneration.py`、`agent_asset_risk_rule_publish.py`、`agent_asset_risk_rule_feedback.py`、`agent_asset_risk_rule_simulation.py`。 +- 前端新建、详情、测试:`AuditRuleDialogs.vue`、`AuditJsonRiskRuleDetail.vue`、`RiskRuleFlowDiagram.vue`、`RiskRuleTestDialog.vue`、`auditViewDetailTopBar.js`、`useAuditRiskRuleActions.js`、`useAuditAssetData.js`。 +- 测试:`test_risk_rule_template_catalog.py`、`test_risk_rule_feedback.py`、`test_risk_rule_revision_endpoints.py`、`test_risk_rule_explainability.py`、`risk-rule-detail-experience.test.mjs`。 + +已执行验证命令: + +```bash +docker exec x-financial-main bash -lc "cd /app/server && timeout 60 /tmp/x-financial-server-venv/bin/python -m pytest tests/test_risk_rule_template_catalog.py tests/test_openapi_schema.py -q" +docker exec x-financial-main bash -lc "cd /app/server && timeout 60 /tmp/x-financial-server-venv/bin/python -m pytest tests/test_risk_rule_feedback.py tests/test_risk_rule_revision_endpoints.py tests/test_openapi_schema.py -q" +docker exec x-financial-main bash -lc "cd /app/web && timeout 60 node --test tests/risk-rule-detail-experience.test.mjs" +docker exec x-financial-main bash -lc "cd /app/web && timeout 60 npm run build" +``` + ## 指标与验收 - [A1] 新建复杂差旅规则后,详情页流程解释不能出现“检查是否包含风险关键词”这类错误表达。 @@ -544,3 +570,12 @@ docker exec x-financial-main sh -lc "cd /app/web && npm run build" - 老规则没有 `semantic_plan` 或 `flow_model`,需要兼容展示并允许重新生成。 - 常见规则模板要避免写成定制逻辑。模板只能提供默认文本、字段和 DSL 样例,最终仍走通用生成链路。 +当前仍需持续演进的点: + +- 企业卡、采购/AP、预算场景的字段本体还偏少,后续应补充企业卡交易流水、供应商、采购订单、合同、预算期间等字段。 +- 复杂规则的准确性仍依赖 Hermes 语义计划质量,执行前必须继续保留 DSL validator、执行器 dry-run 和仿真测试。 +- 模板库只作为规则编写入口的业务参考,不作为规则执行捷径;新增模板时必须同时提供 DSL 样例和 validator 测试。 + +## 实现确认 + +当前实现仍围绕“解释图和执行逻辑一致”推进:自然语言先经字段本体和语义计划形成受控 JSON DSL,详情页流程图、文字流程解释、测试 trace、上线版本均围绕同一份 DSL 展示和执行,没有新增流程图编辑器或绕过规则执行器的判断链路。 diff --git a/document/development/risk-rule-explainable-flow/TODO.md b/document/development/risk-rule-explainable-flow/TODO.md index e399b3d..a1cbe43 100644 --- a/document/development/risk-rule-explainable-flow/TODO.md +++ b/document/development/risk-rule-explainable-flow/TODO.md @@ -9,10 +9,10 @@ ## 1. 调研与边界 -- [ ] [CONCEPT: 背景与问题] 梳理当前风险规则生成链路,记录 `risk_rule_generation.py` 到 `risk_rule_template_executor.py` 的真实调用关系。 -- [ ] [CONCEPT: 前端设计] 梳理详情页、新建弹窗、测试弹窗当前字段来源,记录 `AuditRuleDialogs.vue`、`AuditJsonRiskRuleDetail.vue`、`RiskRuleTestDialog.vue` 的改造点。 -- [ ] [CONCEPT: 数据设计] 确认 `AgentAssetRead`、版本内容、`config_json` 中已有字段,确定 `semantic_plan`、`flow_model`、`flow_diagram_svg` 的落点。 -- [ ] [CONCEPT: 非目标] 明确本期不做流程图编辑器,不增加拖拽、缩放、节点编辑能力。 +- [x] [CONCEPT: 背景与问题] 梳理当前风险规则生成链路,记录 `risk_rule_generation.py` 到 `risk_rule_template_executor.py` 的真实调用关系。证据:`CONCEPT.md` 后端设计与本轮落地结果记录生成、DSL validator、执行器、流程图、仿真测试链路。 +- [x] [CONCEPT: 前端设计] 梳理详情页、新建弹窗、测试弹窗当前字段来源,记录 `AuditRuleDialogs.vue`、`AuditJsonRiskRuleDetail.vue`、`RiskRuleTestDialog.vue` 的改造点。证据:`CONCEPT.md` 本轮落地结果列出三个组件及对应职责,`risk-rule-detail-experience.test.mjs` 覆盖关键接线。 +- [x] [CONCEPT: 数据设计] 确认 `AgentAssetRead`、版本内容、`config_json` 中已有字段,确定 `semantic_plan`、`flow_model`、`flow_diagram_svg` 的落点。证据:`AgentAssetRead` 返回 `latest_test_summary`,版本 JSON metadata 保存 `semantic_plan`/`flow_model`/`flow_diagram_svg`,生成测试覆盖。 +- [x] [CONCEPT: 非目标] 明确本期不做流程图编辑器,不增加拖拽、缩放、节点编辑能力。证据:`RiskRuleFlowDiagram.vue` 只渲染静态 SVG/文字说明,无编辑、拖拽、缩放入口;前端回归测试断言不存在 zoom 按钮。 ## 2. 语义计划与 DSL 契约 @@ -26,7 +26,7 @@ - [x] [CONCEPT: 总体链路] 调整 `risk_rule_generation_prompt.py`,要求 Hermes 先输出 `semantic_plan`,再输出 DSL。证据:提示词 `required_json_shape` 改为 `{ semantic_plan, dsl }`,`test_prompt_requires_semantic_plan_then_dsl` 验证。 - [x] [CONCEPT: C2] 在提示词中明确:城市、日期、金额、票据关系必须用结构化比较,不允许用关键词替代。证据:`risk_rule_generation_prompt.py` 补充 `numeric_compare` 和预算金额不得关键词匹配的 guardrail。 -- [ ] [CONCEPT: 后端设计] 在 `risk_rule_generation_semantics.py` 或解释层补齐语义计划解析与错误返回。进度:已新增 `risk_rule_generation_semantic_plan.py` 解析 `{ semantic_plan, dsl }` 包装,并在生成 payload 的 `metadata.model_semantic_plan` 中保留模型语义计划;错误详情返回仍待补齐。 +- [x] [CONCEPT: 后端设计] 在 `risk_rule_generation_semantics.py` 或解释层补齐语义计划解析与错误返回。证据:`risk_rule_generation_semantic_plan.py` 解析 `{ semantic_plan, dsl }` 包装并保留 `metadata.model_semantic_plan`;后台生成失败写入 `generation_error` 和 `last_operation=generation_failed`,容器内 `test_risk_rule_generation_failure.py` 与语义计划测试通过。 - [x] [CONCEPT: 后端设计] 在 `risk_rule_generation_interpreter.py` 中从 `semantic_plan` 生成标准 DSL。证据:新增 `build_dsl_from_semantic_plan`,当 Hermes 仅返回 `semantic_plan` 时生成 `composite_rule_v1` 草稿,再由 DSL validator 基于字段本体规范成受控条件;`test_semantic_plan_only_response_can_generate_standard_dsl` 通过。 - [x] [CONCEPT: 指标与验收] 增加复杂差旅规则生成测试,确认判断依据不是关键词匹配。证据:`test_generate_complex_travel_route_rule_uses_formula_not_keyword_match` 验证复杂差旅规则生成后为结构化城市一致性规则,且 `condition_summary` 不含“风险关键词”;容器内 `test_risk_rule_generation.py` 通过。 @@ -41,8 +41,8 @@ ## 5. 执行器 trace 与仿真测试 - [x] [CONCEPT: C5] 修改 `RiskRuleTemplateExecutor`,输出每个判断节点的 trace。证据:新增 `evaluate_with_trace`,仿真测试返回 `trace.steps` 和 `path_node_ids`。 -- [ ] [CONCEPT: C5] 仿真测试统一在“用户点击运行”后处理附件和文本,不允许上传后立即判断。 -- [ ] [CONCEPT: C5] 测试结果中展示 OCR 原始字段、Hermes 规范化字段、执行器实际输入字段。 +- [x] [CONCEPT: C5] 仿真测试统一在“用户点击运行”后处理附件和文本,不允许上传后立即判断。证据:`RiskRuleTestDialog.vue` 的 `handleFileChange` 只把附件加入待发送列表,`sendMessage` 才调用 `recognizeTemporaryFiles` 与 `simulateRiskRuleTest`;容器内 `npm run build` 通过。 +- [x] [CONCEPT: C5] 测试结果中展示 OCR 原始字段、Hermes 规范化字段、执行器实际输入字段。证据:`AgentAssetRiskRuleSimulationRead` 新增 `ocr_raw_fields`、`hermes_normalized_fields`、`executor_input_fields`,`RiskRuleTestDialog.vue` 展示字段流水线;容器内 `test_risk_rule_explainability.py` 与 `test_risk_rule_generation.py` 通过。 - [x] [CONCEPT: C5] 测试弹窗展示命中路径、未命中原因和最终风险动作。证据:`RiskRuleTestDialog.vue` 展示“执行路径”,`riskRuleTestDialogDisplay.js` 格式化 trace。 - [x] [CONCEPT: C5] trace 中的 `node_id` 必须能映射到流程图节点。证据:`flow_model` 使用条件 id 作为节点 id,`risk_rule_execution_trace.py` 输出同名 `node_id`。 @@ -50,40 +50,40 @@ - [x] [CONCEPT: C6] 未上线规则支持编辑标题、费用领域、附件要求和自然语言描述。证据:新增 `AgentAssetRiskRuleRevisionService.update_unpublished_draft` 与 `PATCH /agent-assets/{asset_id}/risk-rules/draft`,容器内 `test_risk_rule_revision_endpoints.py` 覆盖返回字段。 - [x] [CONCEPT: C6] 已上线规则新增“创建修订版本”,不直接覆盖 active 版本。证据:新增 `AgentAssetRiskRuleRevisionService.create_revision_draft` 与 `POST /agent-assets/{asset_id}/risk-rules/revisions`,测试验证 `published_version` 保持不变且 `working_version` 进入修订版本。 -- [ ] [CONCEPT: C6] 修订版本保存后重新生成 DSL、流程图、风险评分和业务说明。 -- [ ] [CONCEPT: C6] 发布修订版本时归档旧版本,并记录修改人、修改原因和测试报告。 -- [ ] [CONCEPT: C6] 普通用户误判/漏判反馈进入规则反馈记录,不直接修改规则。 +- [x] [CONCEPT: C6] 修订版本保存后重新生成 DSL、流程图、风险评分和业务说明。证据:新增 `AgentAssetRiskRuleRegenerationService` 与 `POST /agent-assets/{asset_id}/risk-rules/regenerate`,草稿/修订草稿都会重新生成 JSON DSL、`flow_diagram_svg`、风险评分和版本 Markdown;容器内 `test_regenerate_revision_draft_keeps_active_document_unchanged` 通过。 +- [x] [CONCEPT: C6] 发布修订版本时归档旧版本,并记录修改人、修改原因和测试报告。证据:新增 `AgentAssetRiskRulePublishMixin`,发布修订时将旧 `rule_document` 写入 `revision_history.previous_rule_document`,切换新 JSON 文件并写入 `last_operation=publish_revision`;容器内 `test_publish_regenerated_revision_replaces_online_document` 通过。 +- [x] [CONCEPT: C6] 普通用户误判/漏判反馈进入规则反馈记录,不直接修改规则。证据:新增 `agent_asset_rule_feedback` 模型、`POST/GET /agent-assets/{asset_id}/risk-rules/feedback`、前端服务方法;容器内 `test_risk_rule_feedback.py`、规则回归和 `npm run build` 通过。 ## 7. 常见费控规则模板库 -- [ ] [CONCEPT: C1] 增加“从常见规则模板创建”入口。 -- [ ] [CONCEPT: C1] 模板按预算、票据、差旅、招待、采购/AP、企业卡、通用分组。 -- [ ] [CONCEPT: C3] 每个模板提供默认自然语言、字段清单、附件要求和 DSL 样例。 -- [ ] [CONCEPT: 非目标] 模板不得绕过通用生成链路,不写定制校准器。 +- [x] [CONCEPT: C1] 增加“从常见规则模板创建”入口。证据:`AuditRuleDialogs.vue` 新建风险规则弹窗新增常见规则模板选择,选择后预填标题、附件要求、业务环节、费用领域和自然语言。 +- [x] [CONCEPT: C1] 模板按预算、票据、差旅、招待、采购/AP、企业卡、通用分组。证据:新增 `risk_rule_template_catalog.py`,`GET /agent-assets/risk-rules/templates` 返回 7 个分组;容器内 `test_risk_rule_template_catalog.py` 通过。 +- [x] [CONCEPT: C3] 每个模板提供默认自然语言、字段清单、附件要求和 DSL 样例。证据:模板接口返回 `natural_language`、`fields`、`requires_attachment`、`dsl_example`;容器内测试逐个调用 DSL validator 验证通过。 +- [x] [CONCEPT: 非目标] 模板不得绕过通用生成链路,不写定制校准器。证据:前端模板只预填 `riskRuleCreateForm`,提交仍走 `generateRiskRuleAsset`;无新增定制校准器,容器内 `npm run build` 通过。 ## 8. 前端详情与交互 -- [ ] [CONCEPT: 前端设计] 详情页 topbar 展示规则标题、状态、风险分数、风险等级、上线/启用状态。 -- [ ] [CONCEPT: C4] 判断流程区域改成左侧文字流程解释、右侧流程图。 -- [ ] [CONCEPT: C4] 流程图标题固定为“流程图”,高度与“流程解释”标题对齐。 -- [ ] [CONCEPT: C5] 测试弹窗展示字段识别结果、规范化字段、判断路径和测试报告。 +- [x] [CONCEPT: 前端设计] 详情页 topbar 展示规则标题、状态、风险分数、风险等级、上线/启用状态。证据:`auditViewDetailTopBar.js` 为风险规则详情输出风险分、风险等级、规则状态、上线状态、启用状态 KPI;容器内 `npm run build` 通过。 +- [x] [CONCEPT: C4] 判断流程区域改成左侧文字流程解释、右侧流程图。证据:`RiskRuleFlowDiagram.vue` 使用左侧 `risk-rule-flow-explainer` 和右侧 `risk-rule-flow-visual` 的两栏布局;容器内 `npm run build` 通过。 +- [x] [CONCEPT: C4] 流程图标题固定为“流程图”,高度与“流程解释”标题对齐。证据:`RiskRuleFlowDiagram.vue` 使用统一 `risk-rule-section-title`,右侧标题固定为“流程图”;容器内 `npm run build` 通过。 +- [x] [CONCEPT: C5] 测试弹窗展示字段识别结果、规范化字段、判断路径和测试报告。证据:`RiskRuleTestDialog.vue` 展示字段流水线、执行路径和右侧测试报告摘要;容器内 `cd /app/web && npm run build` 通过。 - [x] [CONCEPT: C6] 已上线规则详情展示“创建修订版本”,草稿规则展示“编辑规则”。证据:`AuditView.vue` 底部动作区按规则状态展示按钮,`AuditRuleDialogs.vue` 提供编辑/修订弹窗,`useAuditRiskRuleActions.js` 调用草稿编辑与修订接口;容器内 `cd /app/web && npm run build` 通过。 -- [ ] [CONCEPT: 指标与验收] 列表和详情状态刷新不能造成页面闪烁。 +- [x] [CONCEPT: 指标与验收] 列表和详情状态刷新不能造成页面闪烁。证据:`useAuditAssetData.loadSelectedAssetDetail` 增加 `{ silent: true }` 静默刷新,规则保存、送审、审核、上线、回退和版本操作均改为静默刷新;容器内 `npm run build` 通过。 ## 9. 后端接口与权限 - [x] [CONCEPT: 接口设计] 实现或调整 `POST /agent-assets/{asset_id}/risk-rules/revisions`。证据:新增独立路由 `agent_asset_risk_rules.py`,容器内 `test_create_risk_rule_revision_endpoint_keeps_active_version` 通过。 - [x] [CONCEPT: 接口设计] 实现或调整 `PATCH /agent-assets/{asset_id}/risk-rules/draft`。证据:新增独立路由 `agent_asset_risk_rules.py`,容器内 `test_update_risk_rule_draft_endpoint_updates_unpublished_rule` 与已上线阻断用例通过。 -- [ ] [CONCEPT: 接口设计] `POST /agent-assets/{asset_id}/risk-rules/regenerate` 返回生成状态和错误详情。 +- [x] [CONCEPT: 接口设计] `POST /agent-assets/{asset_id}/risk-rules/regenerate` 返回生成状态和错误详情。证据:独立路由 `agent_asset_risk_rules.py` 已接入重生成接口,成功返回 `AgentAssetRead.config_json.generation_status`/`revision_draft.generation_status`,接口用例 `test_regenerate_risk_rule_endpoint_returns_updated_detail` 通过。 - [x] [CONCEPT: 接口设计] 仿真测试接口返回 `recognized_fields`、`normalized_fields`、`execution_result`、`trace`。证据:`AgentAssetRiskRuleSimulationRead` 新增 `normalized_fields` 和 `trace`,仿真测试覆盖返回值。 -- [ ] [CONCEPT: 用户与场景] 普通财务人员只能编辑未上线/修订草稿,admin 才能删除和测试,管理员按现有权限上线/下线。 -- [ ] [CONCEPT: 数据设计] 所有操作写入 `last_operation`,用于详情页“最后操作”展示。 +- [x] [CONCEPT: 用户与场景] 普通财务人员只能编辑未上线/修订草稿,admin 才能删除和测试,管理员按现有权限上线/下线。证据:路由依赖使用 `RuleEditorUser`、`RuleReviewerUser`、`PlatformAdminUser` 分层,`test_risk_rule_revision_endpoints.py` 覆盖 finance 新建/测试阻断、manager 删除阻断和 manager 启停入口。 +- [x] [CONCEPT: 数据设计] 所有操作写入 `last_operation`,用于详情页“最后操作”展示。证据:生成、后台生成、草稿编辑、创建修订、重新生成、发布/下线、测试确认等风险规则服务均写入 `config_json.last_operation`,前端 `AuditJsonRiskRuleDetail.vue` 展示 `lastOperationLabel`。 ## 10. 测试与验证 - [x] [CONCEPT: 测试方案] 后端补充语义计划、DSL validator、执行器 trace、流程图转换单元测试。证据:`test_risk_rule_explainability.py` 覆盖语义计划、flow_model、trace;`test_risk_rule_dsl_validator.py` 覆盖 DSL validator 与 `numeric_compare` 执行;容器内相关测试通过。 -- [ ] [CONCEPT: 测试方案] 后端补充修订版本接口和发布替换接口测试。进度:已补草稿编辑与创建修订版本服务/接口测试,发布替换接口测试仍待补齐。 -- [ ] [CONCEPT: 测试方案] 前端补充详情页流程展示、测试弹窗字段展示、修订版本按钮状态测试。 +- [x] [CONCEPT: 测试方案] 后端补充修订版本接口和发布替换接口测试。证据:`test_risk_rule_revision_service.py` 覆盖草稿编辑、创建修订、修订重生成和发布替换;`test_risk_rule_revision_endpoints.py` 覆盖草稿编辑、创建修订和重生成接口;容器内相关测试通过。 +- [x] [CONCEPT: 测试方案] 前端补充详情页流程展示、测试弹窗字段展示、修订版本按钮状态测试。证据:新增 `risk-rule-detail-experience.test.mjs` 覆盖 topbar KPI、左文右图流程、字段流水线和修订按钮;容器内 `node --test tests/risk-rule-detail-experience.test.mjs` 通过。 - [x] [CONCEPT: 容器验证] 在容器执行后端定向测试,单个命令设置 60s 超时。证据:`/tmp/x-financial-server-venv/bin/python -m pytest tests/test_risk_rule_explainability.py -q`、`test_risk_rule_composite_generation.py -q`、`test_risk_rule_generation.py -q` 均通过。 - [x] [CONCEPT: 容器验证] 在容器执行 `cd /app/web && npm run build`。证据:容器 `/app/web` 构建通过。 - [x] [CONCEPT: 指标与验收] 用“武汉到上海票据 + 北京出差 3 天”样例验证城市不一致规则必须命中或给出明确不命中原因。证据:`test_simulation_returns_execution_trace_for_ticket_city_mismatch` 验证命中并返回 trace。 @@ -91,6 +91,6 @@ ## 11. 文档收尾 -- [ ] [CONCEPT: 指标与验收] 开发完成后补充实际接口、文件和测试命令结果。 -- [ ] [CONCEPT: 风险与开放问题] 记录暂未解决的字段本体缺口和复杂规则降级策略。 -- [ ] [CONCEPT: 功能一句话] 确认最终实现没有偏离“解释图和执行逻辑一致”的核心目标。 +- [x] [CONCEPT: 指标与验收] 开发完成后补充实际接口、文件和测试命令结果。证据:`CONCEPT.md` 新增“本轮落地结果”,列出接口、关键文件和容器验证命令。 +- [x] [CONCEPT: 风险与开放问题] 记录暂未解决的字段本体缺口和复杂规则降级策略。证据:`CONCEPT.md` 风险与开放问题补充企业卡、采购/AP、预算字段本体缺口和 DSL validator/dry-run/仿真兜底策略。 +- [x] [CONCEPT: 功能一句话] 确认最终实现没有偏离“解释图和执行逻辑一致”的核心目标。证据:`CONCEPT.md` 新增“实现确认”,明确自然语言、字段本体、JSON DSL、流程图、测试 trace 和上线版本围绕同一 DSL。 diff --git a/document/development/数字员工工作看板/CONCEPT.md b/document/development/数字员工工作看板/CONCEPT.md new file mode 100644 index 0000000..88acf86 --- /dev/null +++ b/document/development/数字员工工作看板/CONCEPT.md @@ -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 或不出现。 +- 如果后续新增数字员工技能,需要同步更新任务类型映射,避免看板归类为“其他”。 diff --git a/document/development/数字员工工作看板/TODO.md b/document/development/数字员工工作看板/TODO.md new file mode 100644 index 0000000..11c2396 --- /dev/null +++ b/document/development/数字员工工作看板/TODO.md @@ -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: 指标与验收] 证据:本文档已更新。 diff --git a/document/development/数字员工能力库扩展/CONCEPT.md b/document/development/数字员工能力库扩展/CONCEPT.md new file mode 100644 index 0000000..8635940 --- /dev/null +++ b/document/development/数字员工能力库扩展/CONCEPT.md @@ -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`。 +- 定向测试通过。 +- 风险看板不再展示候选规则指标,改为待复核线索和反馈样本。 +- 不引入数据库迁移和破坏性变更。 + +## 风险与开放问题 + +- 新增技能中部分为“定义先行”,立即运行时需要后续逐步接入真实执行器。 +- 如果用户希望每个技能都能立即产出真实结果,需要继续拆分执行服务和工作记录产物。 +- 已接入风险线索归集真实执行器,后续应继续把多凭证、时空、预算、供应商异常从风险图谱主引擎中拆成独立算法模块。 +- 若技能命名或说明再次出现“数字员工承担规则主流程、规则发现、规则优化、自动总结风险”等表述,应优先改为读取规则命中结果、事实、线索、复核材料等受控表述。 diff --git a/document/development/数字员工能力库扩展/TODO.md b/document/development/数字员工能力库扩展/TODO.md new file mode 100644 index 0000000..007b335 --- /dev/null +++ b/document/development/数字员工能力库扩展/TODO.md @@ -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 个通过。 diff --git a/document/development/申请交通费用自动预估/CONCEPT.md b/document/development/申请交通费用自动预估/CONCEPT.md new file mode 100644 index 0000000..ab1508a --- /dev/null +++ b/document/development/申请交通费用自动预估/CONCEPT.md @@ -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 模块,不改变申请预览和提交的数据契约。 diff --git a/document/development/申请交通费用自动预估/TODO.md b/document/development/申请交通费用自动预估/TODO.md new file mode 100644 index 0000000..1181702 --- /dev/null +++ b/document/development/申请交通费用自动预估/TODO.md @@ -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 用例通过;整包定向存在无关查询动作测试失败。 diff --git a/document/development/费用审批动态路由/CONCEPT.md b/document/development/费用审批动态路由/CONCEPT.md new file mode 100644 index 0000000..7482e7d --- /dev/null +++ b/document/development/费用审批动态路由/CONCEPT.md @@ -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` 标记。 +- 预算资金动作仍由原有提交、退回、财务终审链路处理。 + +## 风险与开放问题 + +- 历史风险的口径会影响预算管理者工作量,当前一期采用“存在实质风险即复核”的严格口径。 +- 缺失预算池时是否全部进入预算复核,当前按预算风险处理。 +- 后续如要支持可配置路由阈值,应新增配置表或策略服务,而不是继续改审批流分支。 diff --git a/document/development/费用审批动态路由/TODO.md b/document/development/费用审批动态路由/TODO.md new file mode 100644 index 0000000..ef3a12d --- /dev/null +++ b/document/development/费用审批动态路由/TODO.md @@ -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。 diff --git a/document/development/预算中心列表化改造/CONCEPT.md b/document/development/预算中心列表化改造/CONCEPT.md new file mode 100644 index 0000000..041c631 --- /dev/null +++ b/document/development/预算中心列表化改造/CONCEPT.md @@ -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 数据表达流程。 +- 后续需要明确“审核通过”是自动发布,还是高级财务人员审核后再点击发布。 +- 归档预算的触发条件需要后续和预算发布版本模型一起设计。 diff --git a/document/development/预算中心列表化改造/TODO.md b/document/development/预算中心列表化改造/TODO.md new file mode 100644 index 0000000..16b7de5 --- /dev/null +++ b/document/development/预算中心列表化改造/TODO.md @@ -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: 风险与开放问题]` diff --git a/server/rules/risk-rules/risk.application.large_expense_without_preapproval.json b/server/rules/risk-rules/risk.application.large_expense_without_preapproval.json new file mode 100644 index 0000000..441fb6f --- /dev/null +++ b/server/rules/risk-rules/risk.application.large_expense_without_preapproval.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.application.marketing_without_campaign.json b/server/rules/risk-rules/risk.application.marketing_without_campaign.json index 09be94d..4f5fccb 100644 --- a/server/rules/risk-rules/risk.application.marketing_without_campaign.json +++ b/server/rules/risk-rules/risk.application.marketing_without_campaign.json @@ -3,7 +3,7 @@ "rule_code": "risk.application.marketing_without_campaign", "name": "市场推广费无活动申请", "description": "市场活动、投放、展会等推广费用,缺少已审批的活动申请或投放方案。", - "enabled": true, + "enabled": false, "requires_attachment": false, "risk_dimension": "expense_control_demo", "risk_category": "申请前置", diff --git a/server/rules/risk-rules/risk.application.meal_high_value_without_preapproval.json b/server/rules/risk-rules/risk.application.meal_high_value_without_preapproval.json new file mode 100644 index 0000000..a722db1 --- /dev/null +++ b/server/rules/risk-rules/risk.application.meal_high_value_without_preapproval.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.application.office_bulk_without_purchase.json b/server/rules/risk-rules/risk.application.office_bulk_without_purchase.json new file mode 100644 index 0000000..a1ffad5 --- /dev/null +++ b/server/rules/risk-rules/risk.application.office_bulk_without_purchase.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.application.travel_large_without_preapproval.json b/server/rules/risk-rules/risk.application.travel_large_without_preapproval.json new file mode 100644 index 0000000..719f7e3 --- /dev/null +++ b/server/rules/risk-rules/risk.application.travel_large_without_preapproval.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.budget.available_balance_insufficient.json b/server/rules/risk-rules/risk.budget.available_balance_insufficient.json index 0e4f2e4..6eb23f8 100644 --- a/server/rules/risk-rules/risk.budget.available_balance_insufficient.json +++ b/server/rules/risk-rules/risk.budget.available_balance_insufficient.json @@ -18,17 +18,7 @@ "budget_execution" ], "expense_types": [ - "travel", - "hotel", - "transport", - "meal", - "meeting", - "marketing", - "office", - "training", - "software", - "communication", - "welfare" + "all" ], "budget_required": true, "applies_to": { @@ -36,17 +26,7 @@ "expense" ], "expense_types": [ - "travel", - "hotel", - "transport", - "meal", - "meeting", - "marketing", - "office", - "training", - "software", - "communication", - "welfare" + "all" ], "business_stages": [ "expense_application", @@ -68,17 +48,65 @@ "type": "enum", "source": "claim" }, + { + "key": "claim.department_name", + "label": "部门", + "type": "text", + "source": "claim" + }, + { + "key": "claim.reason", + "label": "事由", + "type": "text", + "source": "claim" + }, + { + "key": "item.item_reason", + "label": "明细说明", + "type": "text", + "source": "item" + }, + { + "key": "budget.line_id", + "label": "预算行", + "type": "text", + "source": "budget" + }, { "key": "budget.available_amount", "label": "预算可用金额", "type": "number", "source": "budget" }, + { + "key": "budget.used_rate", + "label": "预算使用率", + "type": "number", + "source": "budget" + }, { "key": "budget.status", "label": "预算状态", "type": "enum", "source": "budget" + }, + { + "key": "budget.department_name", + "label": "预算部门", + "type": "text", + "source": "budget" + }, + { + "key": "budget.quarter", + "label": "预算季度", + "type": "text", + "source": "budget" + }, + { + "key": "budget.project_code", + "label": "预算项目", + "type": "text", + "source": "budget" } ] }, @@ -117,17 +145,7 @@ "budget_execution" ], "expense_types": [ - "travel", - "hotel", - "transport", - "meal", - "meeting", - "marketing", - "office", - "training", - "software", - "communication", - "welfare" + "all" ], "budget_required": true }, @@ -146,7 +164,7 @@ "owner": "风控与审计部", "stability": "platform", "source_ref": "费用管控 Demo 风险规则库", - "created_at": "2026-05-30T00:00:00Z", + "created_at": "2026-05-31T00:10:41.751292+00:00", "created_by": "system", "risk_score": 88, "risk_level": "high", @@ -159,17 +177,7 @@ "budget_execution" ], "expense_types": [ - "travel", - "hotel", - "transport", - "meal", - "meeting", - "marketing", - "office", - "training", - "software", - "communication", - "welfare" + "all" ], "budget_required": true }, diff --git a/server/rules/risk-rules/risk.budget.consume_without_release.json b/server/rules/risk-rules/risk.budget.consume_without_release.json new file mode 100644 index 0000000..dba650c --- /dev/null +++ b/server/rules/risk-rules/risk.budget.consume_without_release.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.budget.cross_department_without_authorization.json b/server/rules/risk-rules/risk.budget.cross_department_without_authorization.json new file mode 100644 index 0000000..37f4db6 --- /dev/null +++ b/server/rules/risk-rules/risk.budget.cross_department_without_authorization.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.budget.cross_quarter_without_explanation.json b/server/rules/risk-rules/risk.budget.cross_quarter_without_explanation.json new file mode 100644 index 0000000..c6337d0 --- /dev/null +++ b/server/rules/risk-rules/risk.budget.cross_quarter_without_explanation.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.budget.duplicate_reserve.json b/server/rules/risk-rules/risk.budget.duplicate_reserve.json new file mode 100644 index 0000000..34b2a5a --- /dev/null +++ b/server/rules/risk-rules/risk.budget.duplicate_reserve.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.budget.frozen_or_closed_used.json b/server/rules/risk-rules/risk.budget.frozen_or_closed_used.json new file mode 100644 index 0000000..34a5940 --- /dev/null +++ b/server/rules/risk-rules/risk.budget.frozen_or_closed_used.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.budget.missing_budget_line.json b/server/rules/risk-rules/risk.budget.missing_budget_line.json new file mode 100644 index 0000000..15ae814 --- /dev/null +++ b/server/rules/risk-rules/risk.budget.missing_budget_line.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.budget.project_department_mismatch.json b/server/rules/risk-rules/risk.budget.project_department_mismatch.json new file mode 100644 index 0000000..c360979 --- /dev/null +++ b/server/rules/risk-rules/risk.budget.project_department_mismatch.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.budget.usage_over_100.json b/server/rules/risk-rules/risk.budget.usage_over_100.json new file mode 100644 index 0000000..c4cc36a --- /dev/null +++ b/server/rules/risk-rules/risk.budget.usage_over_100.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.budget.usage_warning_80.json b/server/rules/risk-rules/risk.budget.usage_warning_80.json new file mode 100644 index 0000000..d3df492 --- /dev/null +++ b/server/rules/risk-rules/risk.budget.usage_warning_80.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.reimbursement.amount_over_application.json b/server/rules/risk-rules/risk.reimbursement.amount_over_application.json new file mode 100644 index 0000000..a2ae428 --- /dev/null +++ b/server/rules/risk-rules/risk.reimbursement.amount_over_application.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.reimbursement.amount_over_application_10pct.json b/server/rules/risk-rules/risk.reimbursement.amount_over_application_10pct.json new file mode 100644 index 0000000..57ee8b6 --- /dev/null +++ b/server/rules/risk-rules/risk.reimbursement.amount_over_application_10pct.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.reimbursement.department_mismatch_application.json b/server/rules/risk-rules/risk.reimbursement.department_mismatch_application.json new file mode 100644 index 0000000..572e895 --- /dev/null +++ b/server/rules/risk-rules/risk.reimbursement.department_mismatch_application.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.reimbursement.duplicate_against_application.json b/server/rules/risk-rules/risk.reimbursement.duplicate_against_application.json new file mode 100644 index 0000000..dcb4b1a --- /dev/null +++ b/server/rules/risk-rules/risk.reimbursement.duplicate_against_application.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.reimbursement.expense_type_mismatch_application.json b/server/rules/risk-rules/risk.reimbursement.expense_type_mismatch_application.json new file mode 100644 index 0000000..969473d --- /dev/null +++ b/server/rules/risk-rules/risk.reimbursement.expense_type_mismatch_application.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.reimbursement.period_outside_application.json b/server/rules/risk-rules/risk.reimbursement.period_outside_application.json new file mode 100644 index 0000000..5c2d558 --- /dev/null +++ b/server/rules/risk-rules/risk.reimbursement.period_outside_application.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.reimbursement.rejected_application_claimed.json b/server/rules/risk-rules/risk.reimbursement.rejected_application_claimed.json new file mode 100644 index 0000000..6f4a6fa --- /dev/null +++ b/server/rules/risk-rules/risk.reimbursement.rejected_application_claimed.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.standard.communication_account_mismatch.json b/server/rules/risk-rules/risk.standard.communication_account_mismatch.json new file mode 100644 index 0000000..90dd708 --- /dev/null +++ b/server/rules/risk-rules/risk.standard.communication_account_mismatch.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.standard.communication_amount_over_policy.json b/server/rules/risk-rules/risk.standard.communication_amount_over_policy.json new file mode 100644 index 0000000..98eafcd --- /dev/null +++ b/server/rules/risk-rules/risk.standard.communication_amount_over_policy.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.standard.meal_participants_missing.json b/server/rules/risk-rules/risk.standard.meal_participants_missing.json new file mode 100644 index 0000000..d5bba72 --- /dev/null +++ b/server/rules/risk-rules/risk.standard.meal_participants_missing.json @@ -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" +} diff --git a/server/rules/risk-rules/risk.standard.office_fixed_asset_as_office.json b/server/rules/risk-rules/risk.standard.office_fixed_asset_as_office.json new file mode 100644 index 0000000..b6081af --- /dev/null +++ b/server/rules/risk-rules/risk.standard.office_fixed_asset_as_office.json @@ -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 + } + } +} diff --git a/server/scripts/build_expense_control_demo_risk_rules.py b/server/scripts/build_expense_control_demo_risk_rules.py index 20aabe1..1c11b02 100644 --- a/server/scripts/build_expense_control_demo_risk_rules.py +++ b/server/scripts/build_expense_control_demo_risk_rules.py @@ -12,19 +12,16 @@ SERVER_DIR = Path(__file__).resolve().parents[1] RISK_RULE_DIR = SERVER_DIR / "rules" / "risk-rules" -BUDGET_EXPENSE_TYPES = ( +BUDGET_EXPENSE_TYPES = ("all",) +SUPPORTED_DEMO_EXPENSE_TYPES = { + "all", "travel", "hotel", "transport", "meal", - "meeting", - "marketing", "office", - "training", - "software", "communication", - "welfare", -) +} FIELD_LABELS = { @@ -456,7 +453,7 @@ RULES: tuple[DemoRiskRule, ...] = ( "rule.expense.company_travel_expense_reimbursement", "差旅住宿费标准", ("reimbursement",), - ("travel", "hotel", "transport"), + ("travel",), "差旅金额达到大额阈值且缺少有效出差申请时触发。", ("差旅申请", "大额差旅", "未申请"), "high", @@ -688,6 +685,54 @@ RULES: tuple[DemoRiskRule, ...] = ( ) +COMMUNICATION_RULES: tuple[DemoRiskRule, ...] = ( + DemoRiskRule( + "risk.standard.communication_amount_over_policy", + "通信费金额超过月度标准", + "通信费、话费、流量费或宽带费超过公司月度标准,且缺少岗位必要性或专项审批说明。", + "费用标准", + "expense_standard_over_limit", + "expense.communication.policy", + "通信费报销规则", + ("expense_application", "reimbursement"), + ("communication",), + "通信费金额超过公司标准且没有岗位、项目或专项审批说明时触发。", + ("通信费", "话费", "流量费", "宽带费", "超标准"), + "medium", + "manual_review", + 68, + "medium", + field_keys=BASE_FIELDS + ("material.invoice_uploaded",), + ), + DemoRiskRule( + "risk.standard.communication_account_mismatch", + "通信账户归属与报销人不一致", + "通信票据、运营商账单或号码归属信息与报销人不一致,且缺少代垫或统一缴费说明。", + "费用归属", + "expense_owner_mismatch", + "expense.communication.policy", + "通信费报销规则", + ("reimbursement",), + ("communication",), + "通信账户归属与报销人不一致且没有代垫、统一缴费或部门公共号码说明时触发。", + ("号码归属", "账户不一致", "代垫", "统一缴费", "公共号码"), + "high", + "manual_review", + 82, + "high", + requires_attachment=True, + field_keys=MATERIAL_FIELDS, + ), +) + + +def _is_supported_demo_rule(rule: DemoRiskRule) -> bool: + return all(expense_type in SUPPORTED_DEMO_EXPENSE_TYPES for expense_type in rule.expense_types) + + +RULES = tuple(rule for rule in RULES if _is_supported_demo_rule(rule)) + COMMUNICATION_RULES + + def main() -> None: RISK_RULE_DIR.mkdir(parents=True, exist_ok=True) for rule in RULES: diff --git a/server/scripts/repair_stuck_application_budget_approval.py b/server/scripts/repair_stuck_application_budget_approval.py new file mode 100644 index 0000000..7dbd423 --- /dev/null +++ b/server/scripts/repair_stuck_application_budget_approval.py @@ -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() diff --git a/server/src/app/api/deps.py b/server/src/app/api/deps.py index 2831e30..40976b3 100644 --- a/server/src/app/api/deps.py +++ b/server/src/app/api/deps.py @@ -24,6 +24,10 @@ class CurrentUserContext: is_admin: bool department_name: str = "" cost_center: str = "" + position: str = "" + grade: str = "" + employee_no: str = "" + manager_name: str = "" def get_current_user( @@ -51,6 +55,22 @@ def get_current_user( str | None, Header(description="当前登录人的成本中心。"), ] = None, + x_auth_position: Annotated[ + str | None, + Header(description="当前登录人的岗位。"), + ] = None, + x_auth_grade: Annotated[ + str | None, + Header(description="当前登录人的职级。"), + ] = None, + x_auth_employee_no: Annotated[ + str | None, + Header(description="当前登录人的员工编号。"), + ] = None, + x_auth_manager_name: Annotated[ + str | None, + Header(description="当前登录人的直属领导。"), + ] = None, ) -> CurrentUserContext: role_codes = [ _normalize_role_code(item) @@ -79,6 +99,10 @@ def get_current_user( is_admin=is_admin, department_name=(x_auth_department or "").strip(), cost_center=(x_auth_cost_center or "").strip(), + position=(x_auth_position or "").strip(), + grade=(x_auth_grade or "").strip(), + employee_no=(x_auth_employee_no or "").strip(), + manager_name=(x_auth_manager_name or "").strip(), ) diff --git a/server/src/app/api/v1/endpoints/agent_asset_risk_rules.py b/server/src/app/api/v1/endpoints/agent_asset_risk_rules.py index 86f9312..73a6e08 100644 --- a/server/src/app/api/v1/endpoints/agent_asset_risk_rules.py +++ b/server/src/app/api/v1/endpoints/agent_asset_risk_rules.py @@ -2,21 +2,29 @@ from __future__ import annotations from typing import Annotated, NoReturn -from fastapi import APIRouter, Depends, Header, HTTPException, status +from fastapi import APIRouter, Depends, Header, HTTPException, Query, status from sqlalchemy.orm import Session from app.api.deps import ( CurrentUserContext, + get_current_user, get_db, require_rule_editor_user, + require_rule_reviewer_user, ) from app.schemas.agent_asset import ( AgentAssetRead, AgentAssetRiskRuleDraftUpdate, + AgentAssetRiskRuleFeedbackCreate, + AgentAssetRiskRuleFeedbackRead, + AgentAssetRiskRuleRegenerateRequest, AgentAssetRiskRuleRevisionCreate, + AgentAssetRiskRuleTemplateGroupRead, ) +from app.services.agent_asset_risk_rule_regeneration import AgentAssetRiskRuleRegenerationService from app.services.agent_asset_risk_rule_revision import AgentAssetRiskRuleRevisionService from app.services.agent_assets import AgentAssetService +from app.services.risk_rule_template_catalog import list_risk_rule_template_groups router = APIRouter(prefix="/agent-assets") DbSession = Annotated[Session, Depends(get_db)] @@ -29,6 +37,8 @@ RequestIdHeader = Annotated[ Header(description="外部请求 ID,用于串联审计日志和上游调用链。"), ] RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)] +RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)] +CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)] def _handle_asset_error(exc: Exception) -> NoReturn: @@ -50,6 +60,16 @@ def _read_asset(db: Session, asset_id: str) -> AgentAssetRead: return asset +@router.get( + "/risk-rules/templates", + response_model=list[AgentAssetRiskRuleTemplateGroupRead], + summary="查询常见费控风险规则模板", + description="返回模板分组、默认自然语言、字段清单和 DSL 样例;模板只用于预填,不绕过通用生成链路。", +) +def list_risk_rule_templates(_: CurrentUser) -> list[AgentAssetRiskRuleTemplateGroupRead]: + return list_risk_rule_template_groups() + + @router.patch( "/{asset_id}/risk-rules/draft", response_model=AgentAssetRead, @@ -101,3 +121,80 @@ def create_risk_rule_revision( return _read_asset(db, asset_id) except Exception as exc: _handle_asset_error(exc) + + +@router.post( + "/{asset_id}/risk-rules/regenerate", + response_model=AgentAssetRead, + summary="重新生成风险规则执行模板", + description="把未上线草稿或已上线规则的修订草稿重新解释为 DSL、流程图、风险评分和业务说明。", +) +def regenerate_risk_rule( + asset_id: str, + payload: AgentAssetRiskRuleRegenerateRequest, + current_user: RuleEditorUser, + db: DbSession, + x_actor: ActorHeader = None, + x_request_id: RequestIdHeader = None, +) -> AgentAssetRead: + try: + AgentAssetRiskRuleRegenerationService(db).regenerate( + asset_id, + payload, + actor=_actor_name(current_user, x_actor), + request_id=x_request_id, + ) + return _read_asset(db, asset_id) + except Exception as exc: + _handle_asset_error(exc) + + +@router.post( + "/{asset_id}/risk-rules/feedback", + response_model=AgentAssetRiskRuleFeedbackRead, + status_code=status.HTTP_201_CREATED, + summary="提交风险规则误判或漏判反馈", + description="普通用户可提交规则误判、漏判或改进反馈;该接口只记录反馈,不直接修改规则。", +) +def create_risk_rule_feedback( + asset_id: str, + payload: AgentAssetRiskRuleFeedbackCreate, + current_user: CurrentUser, + db: DbSession, + x_actor: ActorHeader = None, + x_request_id: RequestIdHeader = None, +) -> AgentAssetRiskRuleFeedbackRead: + try: + return AgentAssetService(db).create_risk_rule_feedback( + asset_id, + payload, + actor=_actor_name(current_user, x_actor), + request_id=x_request_id, + ) + except Exception as exc: + _handle_asset_error(exc) + + +@router.get( + "/{asset_id}/risk-rules/feedback", + response_model=list[AgentAssetRiskRuleFeedbackRead], + summary="查询风险规则反馈记录", + description="高级财务人员或 admin 管理员查看指定风险规则的误判、漏判和改进反馈记录。", +) +def list_risk_rule_feedback( + asset_id: str, + _: RuleReviewerUser, + db: DbSession, + version: Annotated[str | None, Query(max_length=30)] = None, + status_value: Annotated[str | None, Query(alias="status", max_length=30)] = None, + limit: Annotated[int, Query(ge=1, le=200)] = 50, +) -> list[AgentAssetRiskRuleFeedbackRead]: + try: + return AgentAssetService(db).list_risk_rule_feedback( + asset_id, + version=version, + status=status_value, + limit=limit, + ) + except Exception as exc: + _handle_asset_error(exc) diff --git a/server/src/app/api/v1/endpoints/agent_traces.py b/server/src/app/api/v1/endpoints/agent_traces.py new file mode 100644 index 0000000..6812626 --- /dev/null +++ b/server/src/app/api/v1/endpoints/agent_traces.py @@ -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 diff --git a/server/src/app/api/v1/endpoints/analytics.py b/server/src/app/api/v1/endpoints/analytics.py index 293b9cf..39db137 100644 --- a/server/src/app/api/v1/endpoints/analytics.py +++ b/server/src/app/api/v1/endpoints/analytics.py @@ -7,8 +7,10 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from app.api.deps import get_db +from app.schemas.digital_employee_dashboard import DigitalEmployeeDashboardRead from app.schemas.finance_dashboard import FinanceDashboardRead from app.schemas.system_dashboard import SystemDashboardRead +from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService from app.services.finance_dashboard import FinanceDashboardService from app.services.system_dashboard import SystemDashboardService @@ -32,6 +34,26 @@ def get_system_dashboard( return SystemDashboardService(db).build_dashboard(days=days) +@router.get( + "/digital-employee-dashboard", + response_model=DigitalEmployeeDashboardRead, + summary="查询数字员工工作看板", + description="基于数字员工运行记录和工具调用结果聚合每日工作、技能类型、业务产出和近期执行明细。", +) +def get_digital_employee_dashboard( + db: DbSession, + days: Annotated[ + int, + Query(ge=1, le=30, description="统计窗口天数。"), + ] = 7, + limit: Annotated[ + int, + Query(ge=1, le=1000, description="窗口内最多读取的运行记录数。"), + ] = 300, +) -> DigitalEmployeeDashboardRead: + return DigitalEmployeeDashboardService(db).build_dashboard(days=days, limit=limit) + + @router.get( "/finance-dashboard", response_model=FinanceDashboardRead, diff --git a/server/src/app/api/v1/endpoints/auth.py b/server/src/app/api/v1/endpoints/auth.py index 24279df..ee16a6b 100644 --- a/server/src/app/api/v1/endpoints/auth.py +++ b/server/src/app/api/v1/endpoints/auth.py @@ -5,8 +5,9 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from app.api.deps import get_db +from app.api.deps import CurrentUserContext, get_current_user, get_db from app.schemas.auth import ( + AuthUserRead, LoginRequest, LoginResponse, SessionFinishRequest, @@ -39,6 +40,42 @@ def login(payload: LoginRequest, db: DbSession) -> LoginResponse: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc +@router.get( + "/me", + response_model=AuthUserRead, + summary="读取当前登录用户", + description="根据当前会话请求头刷新前端登录态中的员工姓名、部门、岗位和职级。", +) +def get_current_auth_user( + current_user: Annotated[CurrentUserContext, Depends(get_current_user)], + db: DbSession, +) -> AuthUserRead: + user = AuthService(db).get_user_snapshot(current_user.username) + if user is not None: + return user + + if current_user.is_admin: + name = current_user.name or current_user.username or "系统管理员" + return AuthUserRead( + username=current_user.username or name, + name=name, + role="管理员", + department=current_user.department_name, + departmentName=current_user.department_name, + position=current_user.position or "系统管理员", + grade=current_user.grade, + employeeNo=current_user.employee_no, + managerName=current_user.manager_name, + costCenter=current_user.cost_center, + roleCodes=current_user.role_codes or ["manager"], + email=current_user.username if "@" in current_user.username else f"{current_user.username}@local", + avatar=name[:1].upper(), + isAdmin=True, + ) + + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="当前登录用户不存在或已停用") + + @router.post( "/sessions/{session_id}/finish", response_model=SessionFinishResponse, diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index e440807..a243f49 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -545,6 +545,34 @@ def delete_expense_claim_item_attachment( return ExpenseClaimAttachmentActionResponse(**payload) +@router.post( + "/claims/{claim_id}/pre-review", + response_model=ExpenseClaimRead, + summary="执行报销单 AI 预审", + description="只执行 AI 预审并回写风险结果,不提交到审批流程。", + responses={ + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "报销单不存在。", + }, + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "草稿信息不完整或状态不允许预审。", + }, + }, +) +def pre_review_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -> ExpenseClaimRead: + service = ExpenseClaimService(db) + try: + claim = service.pre_review_claim(claim_id, current_user) + except ValueError as error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + + if claim is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") + return claim + + @router.post( "/claims/{claim_id}/submit", response_model=ExpenseClaimRead, diff --git a/server/src/app/api/v1/router.py b/server/src/app/api/v1/router.py index 3fff4c9..df33fb9 100644 --- a/server/src/app/api/v1/router.py +++ b/server/src/app/api/v1/router.py @@ -4,6 +4,7 @@ from app.api.v1.endpoints.agent_asset_risk_rules import router as agent_asset_ri from app.api.v1.endpoints.agent_assets import router as agent_assets_router from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router from app.api.v1.endpoints.agent_runs import router as agent_runs_router +from app.api.v1.endpoints.agent_traces import router as agent_traces_router from app.api.v1.endpoints.analytics import router as analytics_router from app.api.v1.endpoints.audit_logs import router as audit_logs_router from app.api.v1.endpoints.auth import router as auth_router @@ -31,6 +32,7 @@ router.include_router(agent_assets_router, tags=["agent-assets"]) router.include_router(agent_asset_risk_rules_router, tags=["agent-assets"]) router.include_router(agent_feedback_router, tags=["agent-feedback"]) router.include_router(agent_runs_router, tags=["agent-runs"]) +router.include_router(agent_traces_router, tags=["agent-traces"]) router.include_router(analytics_router, tags=["analytics"]) router.include_router(audit_logs_router, tags=["audit-logs"]) router.include_router(knowledge_router, tags=["knowledge"]) diff --git a/server/src/app/db/base.py b/server/src/app/db/base.py index d210d68..b81d3ce 100644 --- a/server/src/app/db/base.py +++ b/server/src/app/db/base.py @@ -1,8 +1,14 @@ from app.db.base_class import Base from app.models.agent_conversation import AgentConversation, AgentConversationMessage -from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetTestRun, AgentAssetVersion +from app.models.agent_asset import ( + AgentAsset, + AgentAssetReview, + AgentAssetRuleFeedback, + AgentAssetTestRun, + AgentAssetVersion, +) from app.models.agent_feedback import AgentOperationFeedback -from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog +from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog from app.models.approval import ApprovalRecord from app.models.audit_log import AuditLog from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction @@ -34,11 +40,13 @@ __all__ = [ "AgentConversationMessage", "AgentAsset", "AgentAssetReview", + "AgentAssetRuleFeedback", "AgentAssetTestRun", "AgentAssetVersion", "AgentOperationFeedback", "AgentRun", "AgentToolCall", + "AgentTraceEvent", "ApprovalRecord", "AuditLog", "BudgetAllocation", diff --git a/server/src/app/models/__init__.py b/server/src/app/models/__init__.py index f6e8eda..ba3153f 100644 --- a/server/src/app/models/__init__.py +++ b/server/src/app/models/__init__.py @@ -1,7 +1,7 @@ from app.models.agent_conversation import AgentConversation, AgentConversationMessage -from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion +from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetRuleFeedback, AgentAssetVersion from app.models.agent_feedback import AgentOperationFeedback -from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog +from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog from app.models.approval import ApprovalRecord from app.models.audit_log import AuditLog from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction @@ -32,10 +32,12 @@ __all__ = [ "AgentConversationMessage", "AgentAsset", "AgentAssetReview", + "AgentAssetRuleFeedback", "AgentAssetVersion", "AgentOperationFeedback", "AgentRun", "AgentToolCall", + "AgentTraceEvent", "ApprovalRecord", "AuditLog", "BudgetAllocation", diff --git a/server/src/app/models/agent_asset.py b/server/src/app/models/agent_asset.py index a6e79a0..e8f9482 100644 --- a/server/src/app/models/agent_asset.py +++ b/server/src/app/models/agent_asset.py @@ -4,7 +4,7 @@ import uuid from datetime import datetime from typing import Any -from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, func +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.types import JSON @@ -52,6 +52,12 @@ class AgentAsset(Base): cascade="all, delete-orphan", order_by="desc(AgentAssetTestRun.created_at)", ) + rule_feedback_items = relationship( + "AgentAssetRuleFeedback", + back_populates="asset", + cascade="all, delete-orphan", + order_by="desc(AgentAssetRuleFeedback.created_at)", + ) class AgentAssetVersion(Base): @@ -103,3 +109,34 @@ class AgentAssetTestRun(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) asset = relationship("AgentAsset", back_populates="test_runs") + + +class AgentAssetRuleFeedback(Base): + __tablename__ = "agent_asset_rule_feedback" + __table_args__ = ( + Index("ix_agent_asset_rule_feedback_asset_version", "asset_id", "version"), + Index("ix_agent_asset_rule_feedback_type_status", "feedback_type", "status"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + feedback_id: Mapped[str] = mapped_column( + String(50), + unique=True, + index=True, + default=lambda: f"arf_{uuid.uuid4().hex[:16]}", + ) + asset_id: Mapped[str] = mapped_column(ForeignKey("agent_assets.id"), index=True) + version: Mapped[str] = mapped_column(String(30), index=True) + feedback_type: Mapped[str] = mapped_column(String(30), index=True) + status: Mapped[str] = mapped_column(String(30), default="open", index=True) + subject_type: Mapped[str] = mapped_column(String(50), default="", index=True) + subject_key: Mapped[str] = mapped_column(String(160), default="", index=True) + subject_label: Mapped[str] = mapped_column(String(200), default="") + actual_result_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + expected_result_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + comment: Mapped[str | None] = mapped_column(Text(), nullable=True) + payload_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + created_by: Mapped[str] = mapped_column(String(100), default="", index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) + + asset = relationship("AgentAsset", back_populates="rule_feedback_items") diff --git a/server/src/app/models/agent_run.py b/server/src/app/models/agent_run.py index 714c5bb..05366b9 100644 --- a/server/src/app/models/agent_run.py +++ b/server/src/app/models/agent_run.py @@ -4,7 +4,7 @@ import uuid from datetime import datetime from typing import Any -from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, func +from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, func from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.types import JSON @@ -84,3 +84,28 @@ class SemanticParseLog(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) run = relationship("AgentRun", back_populates="semantic_parse_logs") + + +class AgentTraceEvent(Base): + __tablename__ = "agent_trace_events" + __table_args__ = ( + Index("ix_agent_trace_events_run_sequence", "run_id", "sequence"), + Index("ix_agent_trace_events_conversation_sequence", "conversation_id", "sequence"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + run_id: Mapped[str] = mapped_column(ForeignKey("agent_runs.run_id"), index=True) + conversation_id: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) + sequence: Mapped[int] = mapped_column(Integer, default=0, index=True) + stage: Mapped[str] = mapped_column(String(50), index=True) + event_name: Mapped[str] = mapped_column(String(100), index=True) + title: Mapped[str] = mapped_column(String(160)) + summary: Mapped[str | None] = mapped_column(Text(), nullable=True) + status: Mapped[str] = mapped_column(String(20), index=True) + input_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + output_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + error_message: Mapped[str | None] = mapped_column(Text(), nullable=True) + started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + duration_ms: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/server/src/app/repositories/agent_asset.py b/server/src/app/repositories/agent_asset.py index 75b4921..71a7a9c 100644 --- a/server/src/app/repositories/agent_asset.py +++ b/server/src/app/repositories/agent_asset.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from app.models.agent_asset import ( AgentAsset, AgentAssetReview, + AgentAssetRuleFeedback, AgentAssetTestRun, AgentAssetVersion, ) @@ -218,6 +219,36 @@ class AgentAssetRepository: self.db.refresh(test_run) return test_run + def list_rule_feedback( + self, + asset_id: str, + *, + version: str | None = None, + status: str | None = None, + limit: int | None = None, + ) -> list[AgentAssetRuleFeedback]: + stmt = ( + select(AgentAssetRuleFeedback) + .where(AgentAssetRuleFeedback.asset_id == asset_id) + .order_by(AgentAssetRuleFeedback.created_at.desc()) + ) + if version: + stmt = stmt.where(AgentAssetRuleFeedback.version == version) + if status: + stmt = stmt.where(AgentAssetRuleFeedback.status == status) + if limit is not None: + stmt = stmt.limit(limit) + return list(self.db.scalars(stmt).all()) + + def create_rule_feedback( + self, + feedback: AgentAssetRuleFeedback, + ) -> AgentAssetRuleFeedback: + self.db.add(feedback) + self.db.commit() + self.db.refresh(feedback) + return feedback + def delete_asset(self, asset: AgentAsset) -> None: self.db.delete(asset) self.db.commit() diff --git a/server/src/app/schemas/agent_asset.py b/server/src/app/schemas/agent_asset.py index dcf2255..be6b335 100644 --- a/server/src/app/schemas/agent_asset.py +++ b/server/src/app/schemas/agent_asset.py @@ -146,6 +146,38 @@ class AgentAssetRiskRuleRegenerateRequest(BaseModel): requires_attachment: bool | None = None +class AgentAssetRiskRuleTemplateFieldRead(BaseModel): + key: str + label: str + display: str + source: str + type: str + + +class AgentAssetRiskRuleTemplateRead(BaseModel): + template_id: str + group: str + group_label: str + title: str + description: str = "" + business_domain: str = "expense" + business_stage: str = "reimbursement" + business_stage_label: str = "费用报销" + expense_category: str | None = None + expense_category_label: str = "" + requires_attachment: bool = False + natural_language: str + fields: list[AgentAssetRiskRuleTemplateFieldRead] = Field(default_factory=list) + dsl_example: dict[str, Any] = Field(default_factory=dict) + + +class AgentAssetRiskRuleTemplateGroupRead(BaseModel): + group: str + group_label: str + order: int + templates: list[AgentAssetRiskRuleTemplateRead] = Field(default_factory=list) + + class AgentAssetRiskRuleSampleCase(BaseModel): case_id: str | None = Field(default=None, max_length=60) name: str = Field(default="测试样例", min_length=1, max_length=80) @@ -211,6 +243,9 @@ class AgentAssetRiskRuleSimulationRead(BaseModel): trace: dict[str, Any] = Field(default_factory=dict) attachments: list[dict[str, Any]] = Field(default_factory=list) recognized_fields: list[dict[str, Any]] = Field(default_factory=list) + ocr_raw_fields: list[dict[str, Any]] = Field(default_factory=list) + hermes_normalized_fields: list[dict[str, Any]] = Field(default_factory=list) + executor_input_fields: list[dict[str, Any]] = Field(default_factory=list) missing_fields: list[dict[str, Any]] = Field(default_factory=list) recognition_summary: list[dict[str, Any]] = Field(default_factory=list) execution_mode: str = "risk_rule_simulation" @@ -229,6 +264,38 @@ class AgentAssetRiskRuleLevelUpdate(BaseModel): risk_level: str = Field(pattern="^(low|medium|high|critical)$") +class AgentAssetRiskRuleFeedbackCreate(BaseModel): + version: str | None = Field(default=None, max_length=30) + feedback_type: str = Field(pattern="^(false_positive|false_negative|unclear|improvement)$") + subject_type: str | None = Field(default=None, max_length=50) + subject_key: str | None = Field(default=None, max_length=160) + subject_label: str | None = Field(default=None, max_length=200) + actual_result: dict[str, Any] = Field(default_factory=dict) + expected_result: dict[str, Any] = Field(default_factory=dict) + comment: str = Field(min_length=1, max_length=1000) + payload: dict[str, Any] = Field(default_factory=dict) + + +class AgentAssetRiskRuleFeedbackRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + feedback_id: str + asset_id: str + version: str + feedback_type: str + status: str + subject_type: str = "" + subject_key: str = "" + subject_label: str = "" + actual_result_json: dict[str, Any] = Field(default_factory=dict) + expected_result_json: dict[str, Any] = Field(default_factory=dict) + comment: str | None = None + payload_json: dict[str, Any] = Field(default_factory=dict) + created_by: str + created_at: datetime + + class AgentAssetRiskRuleTestRunRead(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/server/src/app/schemas/agent_trace.py b/server/src/app/schemas/agent_trace.py new file mode 100644 index 0000000..2650467 --- /dev/null +++ b/server/src/app/schemas/agent_trace.py @@ -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) diff --git a/server/src/app/schemas/digital_employee_dashboard.py b/server/src/app/schemas/digital_employee_dashboard.py new file mode 100644 index 0000000..214ca8d --- /dev/null +++ b/server/src/app/schemas/digital_employee_dashboard.py @@ -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) diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py index afcb395..9967568 100644 --- a/server/src/app/schemas/reimbursement.py +++ b/server/src/app/schemas/reimbursement.py @@ -130,6 +130,9 @@ class ExpenseClaimRead(BaseModel): employee_position: str | None = None employee_grade: str | None = None manager_name: str | None = None + budget_approver_name: str | None = None + budget_approver_grade: str | None = None + budget_approver_role_code: str | None = None role_labels: list[str] = Field(default_factory=list) project_code: str | None expense_type: str @@ -202,6 +205,7 @@ class ExpenseClaimAttachmentActionResponse(BaseModel): item_location: str | None = None item_amount: Decimal | None = None claim_amount: Decimal | None = None + claim_risk_flags: list[Any] = Field(default_factory=list) attachment: ExpenseClaimAttachmentRead | None = None diff --git a/server/src/app/schemas/risk_observation.py b/server/src/app/schemas/risk_observation.py index 7238636..6ecb170 100644 --- a/server/src/app/schemas/risk_observation.py +++ b/server/src/app/schemas/risk_observation.py @@ -117,9 +117,11 @@ class RiskObservationDashboardRead(BaseModel): window_days: int total_observations: int pending_count: int + risk_clue_count: int = 0 high_or_above_count: int confirmed_count: int false_positive_count: int + feedback_sample_count: int = 0 total_amount: float = 0.0 average_score: float level_distribution: dict[str, int] = Field(default_factory=dict) diff --git a/server/src/app/services/agent_asset_json_rules.py b/server/src/app/services/agent_asset_json_rules.py index e64bc19..d2a4811 100644 --- a/server/src/app/services/agent_asset_json_rules.py +++ b/server/src/app/services/agent_asset_json_rules.py @@ -17,7 +17,7 @@ class AgentAssetJsonRuleMixin: if rule_library not in RULE_LIBRARY_NAMES: raise ValueError("规则库目录不合法。") - rule_document = config_json.get("rule_document") + rule_document = self._resolve_working_json_risk_rule_document(asset, config_json) if not isinstance(rule_document, dict): raise ValueError("规则资产缺少 rule_document 配置。") @@ -26,6 +26,27 @@ class AgentAssetJsonRuleMixin: raise ValueError("规则资产缺少 JSON 文件名。") return rule_library, file_name + @staticmethod + def _resolve_working_json_risk_rule_document( + asset: AgentAsset, + config_json: dict, + ) -> dict | None: + revision = config_json.get("revision_draft") + if isinstance(revision, dict): + revision_version = str(revision.get("version") or "").strip() + working_version = str(asset.working_version or "").strip() + published_version = str(asset.published_version or "").strip() + revision_document = revision.get("rule_document") + if ( + revision_version + and revision_version == working_version + and revision_version != published_version + and isinstance(revision_document, dict) + and str(revision_document.get("file_name") or "").strip() + ): + return revision_document + return config_json.get("rule_document") + def read_rule_json(self, asset_id: str) -> AgentAssetRuleJsonRead: asset = self.repository.get(asset_id) if asset is None: diff --git a/server/src/app/services/agent_asset_risk_rule_feedback.py b/server/src/app/services/agent_asset_risk_rule_feedback.py new file mode 100644 index 0000000..dba6a64 --- /dev/null +++ b/server/src/app/services/agent_asset_risk_rule_feedback.py @@ -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 {} diff --git a/server/src/app/services/agent_asset_risk_rule_publish.py b/server/src/app/services/agent_asset_risk_rule_publish.py new file mode 100644 index 0000000..0e874b1 --- /dev/null +++ b/server/src/app/services/agent_asset_risk_rule_publish.py @@ -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 diff --git a/server/src/app/services/agent_asset_risk_rule_regeneration.py b/server/src/app/services/agent_asset_risk_rule_regeneration.py new file mode 100644 index 0000000..6a82820 --- /dev/null +++ b/server/src/app/services/agent_asset_risk_rule_regeneration.py @@ -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 {}, + } diff --git a/server/src/app/services/agent_asset_risk_rule_simulation.py b/server/src/app/services/agent_asset_risk_rule_simulation.py index d12c0b6..bbe2799 100644 --- a/server/src/app/services/agent_asset_risk_rule_simulation.py +++ b/server/src/app/services/agent_asset_risk_rule_simulation.py @@ -40,7 +40,20 @@ class AgentAssetRiskRuleSimulationMixin: attachments=attachments, ) recognition_summary = self._build_recognition_summary(attachments) + fields = self._extract_manifest_fields(manifest) + ocr_raw_fields = self._build_ocr_raw_fields(attachments) + hermes_normalized_fields = self._build_hermes_normalized_fields( + fields, + field_values, + source_map, + ) required_keys = self._extract_execution_field_keys(manifest) + executor_input_fields = self._build_executor_input_fields( + fields, + field_values, + source_map, + required_keys, + ) missing_fields = self._build_missing_fields( manifest, field_values=field_values, @@ -67,6 +80,9 @@ class AgentAssetRiskRuleSimulationMixin: normalized_fields=field_values, attachments=attachments, recognized_fields=recognized_fields, + ocr_raw_fields=ocr_raw_fields, + hermes_normalized_fields=hermes_normalized_fields, + executor_input_fields=executor_input_fields, missing_fields=missing_fields, recognition_summary=recognition_summary, created_at=datetime.now(UTC), @@ -108,6 +124,9 @@ class AgentAssetRiskRuleSimulationMixin: trace=execution["trace"] if isinstance(execution.get("trace"), dict) else {}, attachments=attachments, recognized_fields=recognized_fields, + ocr_raw_fields=ocr_raw_fields, + hermes_normalized_fields=hermes_normalized_fields, + executor_input_fields=executor_input_fields, missing_fields=[], recognition_summary=recognition_summary, created_at=datetime.now(UTC), @@ -565,6 +584,108 @@ class AgentAssetRiskRuleSimulationMixin: if source_map.get(key) ] + @staticmethod + def _build_ocr_raw_fields(attachments: list[dict[str, Any]]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for attachment_index, attachment in enumerate(attachments): + attachment_name = str(attachment.get("name") or f"attachment-{attachment_index + 1}") + for field_index, field in enumerate(list(attachment.get("document_fields") or [])): + if not isinstance(field, dict): + continue + value = field.get("value") + if not AgentAssetRiskRuleSimulationMixin._has_meaningful_value(value): + continue + key = str(field.get("key") or f"field_{field_index + 1}").strip() + label = str(field.get("label") or key).strip() + rows.append( + { + "key": key, + "label": label, + "value": value, + "source": "ocr", + "source_label": "OCR结构字段", + "attachment_name": attachment_name, + } + ) + for key, label in (("summary", "单据摘要"), ("ocr_text", "OCR原文")): + value = attachment.get(key) + if AgentAssetRiskRuleSimulationMixin._has_meaningful_value(value): + rows.append( + { + "key": key, + "label": label, + "value": value, + "source": "ocr", + "source_label": "OCR文本", + "attachment_name": attachment_name, + } + ) + return rows[:80] + + @staticmethod + def _build_hermes_normalized_fields( + fields: list[dict[str, str]], + values: dict[str, Any], + source_map: dict[str, str], + ) -> list[dict[str, Any]]: + labels = {field["key"]: field["label"] for field in fields} + rows: list[dict[str, Any]] = [] + for key, value in values.items(): + if not AgentAssetRiskRuleSimulationMixin._has_meaningful_value(value): + continue + source = source_map.get(key, "") + rows.append( + { + "key": key, + "label": labels.get(key, key), + "value": value, + "source": source, + "source_label": AgentAssetRiskRuleSimulationMixin._field_source_label(source), + } + ) + return rows + + @staticmethod + def _build_executor_input_fields( + fields: list[dict[str, str]], + values: dict[str, Any], + source_map: dict[str, str], + required_keys: list[str], + ) -> list[dict[str, Any]]: + labels = {field["key"]: field["label"] for field in fields} + required_set = set(required_keys or []) + ordered_keys = [*required_keys] + for field in fields: + key = field["key"] + if key not in ordered_keys and key in values: + ordered_keys.append(key) + rows: list[dict[str, Any]] = [] + for key in ordered_keys: + value = values.get(key) + if not AgentAssetRiskRuleSimulationMixin._has_meaningful_value(value): + continue + source = source_map.get(key, "") + rows.append( + { + "key": key, + "label": labels.get(key, key), + "value": value, + "source": source, + "source_label": AgentAssetRiskRuleSimulationMixin._field_source_label(source), + "required": key in required_set, + } + ) + return rows + + @staticmethod + def _field_source_label(source: str) -> str: + return { + "manual": "用户输入", + "ocr": "OCR结构字段", + "inferred": "文本推断", + "model_refined": "Hermes规范化", + }.get(str(source or "").strip(), "未标注来源") + @staticmethod def _build_recognition_summary(attachments: list[dict[str, Any]]) -> list[dict[str, Any]]: return [ diff --git a/server/src/app/services/agent_assets.py b/server/src/app/services/agent_assets.py index a1c93c0..4f8e4e7 100644 --- a/server/src/app/services/agent_assets.py +++ b/server/src/app/services/agent_assets.py @@ -28,7 +28,9 @@ from app.schemas.agent_asset import ( ) from app.services.agent_asset_json_rules import AgentAssetJsonRuleMixin from app.services.agent_asset_onlyoffice import AgentAssetOnlyOfficeMixin +from app.services.agent_asset_risk_rule_feedback import AgentAssetRiskRuleFeedbackMixin from app.services.agent_asset_risk_rule_level import AgentAssetRiskRuleLevelMixin +from app.services.agent_asset_risk_rule_publish import AgentAssetRiskRulePublishMixin from app.services.agent_asset_risk_rule_simulation import AgentAssetRiskRuleSimulationMixin from app.services.agent_asset_risk_rule_testing import AgentAssetRiskRuleTestingMixin from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager @@ -47,6 +49,8 @@ class AgentAssetService( AgentAssetOnlyOfficeMixin, AgentAssetSpreadsheetHelperMixin, AgentAssetRiskRuleLevelMixin, + AgentAssetRiskRulePublishMixin, + AgentAssetRiskRuleFeedbackMixin, AgentAssetRiskRuleTestingMixin, AgentAssetRiskRuleSimulationMixin, AgentAssetTimelineMixin, diff --git a/server/src/app/services/agent_foundation_asset_seed.py b/server/src/app/services/agent_foundation_asset_seed.py index b82e912..c67fcc7 100644 --- a/server/src/app/services/agent_foundation_asset_seed.py +++ b/server/src/app/services/agent_foundation_asset_seed.py @@ -126,14 +126,31 @@ class AgentFoundationAssetSeedMixin: [ "---", "name: risk-rule-discovery", - "description: 用于根据风险观察反馈生成候选规则,不直接上线。", + "description: 兼容别名。用于归集申请和报销事实中的潜在线索,不生成规则。", "---", "", - "# 风险规则候选发现", + "# 风险线索归集", "", "## 功能说明", "", - "从风险观察、人工反馈和误报复盘中生成带证据、来源和置信度的候选规则。", + "从申请、报销、规则命中和人工反馈中整理事实、证据和待复核线索。", + ], + ) + + def _risk_clue_collector_skill_markdown(self) -> str: + return self._read_domain_skill_markdown( + "risk-clue-collector", + [ + "---", + "name: risk-clue-collector", + "description: 用于归集申请和报销事实中的潜在线索,不生成规则、不发布规则、不替代人工确认。", + "---", + "", + "# 风险线索归集", + "", + "## 功能说明", + "", + "从申请、报销、规则命中和人工反馈中整理事实、证据和待复核线索。", ], ) @@ -370,6 +387,15 @@ class AgentFoundationAssetSeedMixin: "folder": "财务制度", "changed_only": True, "output_format": "knowledge_organizing_report", + "allowed_outputs": [ + "facts", + "policy_refs", + "evidence_refs", + "knowledge_items", + "human_review_required", + ], + "role_boundary": "规则由人定义,风险由人确认,数字员工只整理人提供的制度和报销事实。", + "writes_rules": False, }, ) @@ -452,6 +478,10 @@ class AgentFoundationAssetSeedMixin: ] ) + self.db.flush() + self._upsert_runtime_digital_employee_tasks( + set(self.db.scalars(select(AgentAsset.code)).all()) + ) self.db.flush() company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed( @@ -615,22 +645,6 @@ class AgentFoundationAssetSeedMixin: change_note="初始化整理公司财务知识制度能力。", created_by="系统初始化", ), - AgentAssetVersion( - asset=risk_graph_scan_task, - version="v1.0.0", - content=self._financial_risk_graph_scan_skill_markdown(), - content_type=AgentAssetContentType.MARKDOWN.value, - change_note="初始化财务风险图谱巡检能力。", - created_by="系统初始化", - ), - AgentAssetVersion( - asset=employee_profile_scan_task, - version="v1.0.0", - content=self._employee_behavior_profile_scan_skill_markdown(), - content_type=AgentAssetContentType.MARKDOWN.value, - change_note="初始化员工行为画像巡检能力。", - created_by="系统初始化", - ), ] ) diff --git a/server/src/app/services/agent_foundation_asset_topup.py b/server/src/app/services/agent_foundation_asset_topup.py index 1752bf5..7f03816 100644 --- a/server/src/app/services/agent_foundation_asset_topup.py +++ b/server/src/app/services/agent_foundation_asset_topup.py @@ -614,6 +614,15 @@ class AgentFoundationAssetTopUpMixin: "folder": "财务制度", "changed_only": True, "output_format": "knowledge_organizing_report", + "allowed_outputs": [ + "facts", + "policy_refs", + "evidence_refs", + "knowledge_items", + "human_review_required", + ], + "role_boundary": "规则由人定义,风险由人确认,数字员工只整理人提供的制度和报销事实。", + "writes_rules": False, } if DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE not in existing_codes: @@ -669,6 +678,15 @@ class AgentFoundationAssetTopUpMixin: "folder": existing_config.get("folder") or "财务制度", "changed_only": existing_config.get("changed_only", True), "output_format": "knowledge_organizing_report", + "allowed_outputs": [ + "facts", + "policy_refs", + "evidence_refs", + "knowledge_items", + "human_review_required", + ], + "role_boundary": "规则由人定义,风险由人确认,数字员工只整理人提供的制度和报销事实。", + "writes_rules": False, **schedule_config, } self.db.add(asset) diff --git a/server/src/app/services/agent_foundation_constants.py b/server/src/app/services/agent_foundation_constants.py index 8e4b770..aa96247 100644 --- a/server/src/app/services/agent_foundation_constants.py +++ b/server/src/app/services/agent_foundation_constants.py @@ -96,6 +96,32 @@ DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE = "task.hermes.employee_behavior_profile DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE = "task.hermes.risk_rule_discovery" +DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE = "task.hermes.finance_policy_clause_extract" + +DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE = "task.hermes.expense_policy_alignment" + +DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE = "task.hermes.risk_rule_template_organize" + +DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE = "task.hermes.department_expense_baseline_accumulate" + +DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE = "task.hermes.supplier_risk_profile_accumulate" + +DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE = "task.hermes.false_positive_sample_accumulate" + +DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE = "task.hermes.risk_feedback_sample_accumulate" + +DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE = "task.hermes.multi_evidence_consistency_evaluate" + +DIGITAL_EMPLOYEE_SPATIOTEMPORAL_TASK_CODE = "task.hermes.travel_spatiotemporal_consistency_evaluate" + +DIGITAL_EMPLOYEE_BUDGET_PRECONTROL_TASK_CODE = "task.hermes.budget_overrun_precontrol_evaluate" + +DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE = "task.hermes.supplier_abnormal_relation_evaluate" + +DIGITAL_EMPLOYEE_ALGORITHM_REPLAY_TASK_CODE = "task.hermes.risk_algorithm_replay_evaluate" + +DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE = "task.hermes.policy_gap_rule_optimization" + DIGITAL_EMPLOYEE_LEGACY_TASK_CODES = ( "task.hermes.daily_risk_scan", "task.hermes.weekly_ar_summary", @@ -107,8 +133,21 @@ DIGITAL_EMPLOYEE_LEGACY_TASK_CODES = ( DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP = { DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE: "整理", DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE: "评估", - DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE: "评估", + DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE: "积累", DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE: "升级", + DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE: "整理", + DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE: "整理", + DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE: "整理", + DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE: "积累", + DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE: "积累", + DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE: "积累", + DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE: "积累", + DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE: "评估", + DIGITAL_EMPLOYEE_SPATIOTEMPORAL_TASK_CODE: "评估", + DIGITAL_EMPLOYEE_BUDGET_PRECONTROL_TASK_CODE: "评估", + DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE: "评估", + DIGITAL_EMPLOYEE_ALGORITHM_REPLAY_TASK_CODE: "升级", + DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE: "升级", } ATTACHMENT_RULE_RUNTIME_CONFIG = { diff --git a/server/src/app/services/agent_foundation_digital_employee_tasks.py b/server/src/app/services/agent_foundation_digital_employee_tasks.py index 229c9c7..8f5c260 100644 --- a/server/src/app/services/agent_foundation_digital_employee_tasks.py +++ b/server/src/app/services/agent_foundation_digital_employee_tasks.py @@ -11,16 +11,126 @@ from app.core.agent_enums import ( ) from app.models.agent_asset import AgentAsset from app.services.agent_foundation_constants import ( + DIGITAL_EMPLOYEE_ALGORITHM_REPLAY_TASK_CODE, + DIGITAL_EMPLOYEE_BUDGET_PRECONTROL_TASK_CODE, + DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE, + DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE, + DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE, + DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE, + DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE, + DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE, + DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE, DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE, DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE, DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE, + DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE, DIGITAL_EMPLOYEE_SKILL_CATEGORIES, + DIGITAL_EMPLOYEE_SPATIOTEMPORAL_TASK_CODE, + DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE, + DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE, +) + + +DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY = ( + "规则由人定义,风险由人确认,主流程由外层智能体执行," + "数字员工只读取事实、规则命中和反馈结果,生成后台分析、报告和待复核材料。" ) class AgentFoundationDigitalEmployeeTaskMixin: def _runtime_digital_employee_task_specs(self) -> tuple[dict[str, object], ...]: return ( + self._digital_employee_task_spec( + code=DIGITAL_EMPLOYEE_POLICY_CLAUSE_EXTRACT_TASK_CODE, + name="制度条款结构化抽取", + description="按计划从财务制度和报销政策中抽取适用范围、限制条件、金额标准、审批要求和证据字段。", + scenario_json=["schedule", "knowledge", "policy_clause", "ontology"], + owner="财务制度管理组", + cron="15 3 * * *", + skill_category="整理", + skill_name="finance-policy-clause-extractor", + output_format="policy_clause_structuring_report", + input_sources=["finance_policies", "knowledge_documents", "ontology_parse_logs"], + execution_strategy="definition_ready", + ), + self._digital_employee_task_spec( + code=DIGITAL_EMPLOYEE_POLICY_ALIGNMENT_TASK_CODE, + name="报销政策口径对齐", + description="对齐不同制度、规则中心和知识库中的报销口径,发现同义、冲突、缺失和过期条款。", + scenario_json=["schedule", "knowledge", "expense_policy", "rule_center"], + owner="财务制度管理组", + cron="30 3 * * *", + skill_category="整理", + skill_name="expense-policy-alignment", + output_format="policy_alignment_report", + input_sources=["finance_policies", "risk_rules", "knowledge_items"], + execution_strategy="definition_ready", + ), + self._digital_employee_task_spec( + code=DIGITAL_EMPLOYEE_RULE_TEMPLATE_ORGANIZE_TASK_CODE, + name="规则命中样本整理", + description="把外层智能体流程已经产生的规则命中、制度引用和历史样本整理为字段映射与复核材料,不新增、不改写、不发布规则。", + scenario_json=["schedule", "rule_hit", "risk_rule", "policy_ref"], + owner="风控与审计部", + cron="45 3 * * 1", + skill_category="整理", + skill_name="rule-execution-case-organizer", + output_format="rule_hit_sample_pack", + input_sources=["approved_risk_rules", "policy_refs", "rule_hits"], + execution_strategy="definition_ready", + ), + self._digital_employee_task_spec( + code=DIGITAL_EMPLOYEE_DEPARTMENT_BASELINE_TASK_CODE, + name="部门费用基线沉淀", + description="按部门、费用类型和时间窗口沉淀费用基线,为预算柔性控制和同类对比提供长期参照。", + scenario_json=["schedule", "department", "baseline", "expense"], + owner="风控与审计部", + cron="45 8 * * 1", + skill_category="积累", + skill_name="department-expense-baseline-accumulator", + output_format="department_expense_baseline_snapshot", + input_sources=["expense_claims", "expense_items", "profile_baselines"], + execution_strategy="reuse_employee_profile_baseline", + ), + self._digital_employee_task_spec( + code=DIGITAL_EMPLOYEE_SUPPLIER_PROFILE_TASK_CODE, + name="供应商风险画像沉淀", + description="沉淀供应商、商户、酒店和收款方的费用频次、金额分布、异常关系和历史风险反馈。", + scenario_json=["schedule", "supplier", "baseline", "risk_graph"], + owner="风控与审计部", + cron="0 8 * * 2", + skill_category="积累", + skill_name="supplier-risk-profile-accumulator", + output_format="supplier_risk_profile_snapshot", + input_sources=["expense_claims", "invoice_entities", "risk_observations"], + execution_strategy="reuse_profile_baseline", + ), + self._digital_employee_task_spec( + code=DIGITAL_EMPLOYEE_FALSE_POSITIVE_SAMPLE_TASK_CODE, + name="历史误报样本沉淀", + description="归集被人工标记为误报、忽略或撤销的风险观察,形成算法回放和人工复核校准样本。", + scenario_json=["schedule", "false_positive", "feedback", "replay"], + owner="风控与审计部", + cron="20 10 * * 1", + skill_category="积累", + skill_name="false-positive-sample-accumulator", + output_format="false_positive_sample_pool", + input_sources=["risk_observations", "risk_observation_feedback"], + execution_strategy="definition_ready", + ), + self._digital_employee_task_spec( + code=DIGITAL_EMPLOYEE_FEEDBACK_SAMPLE_TASK_CODE, + name="风险观察反馈样本沉淀", + description="归集确认、补件、升级、改写和人工复核反馈,形成风险观察反馈样本池。", + scenario_json=["schedule", "feedback", "risk_observation", "sample_pool"], + owner="风控与审计部", + cron="40 10 * * 1", + skill_category="积累", + skill_name="risk-feedback-sample-accumulator", + output_format="risk_feedback_sample_pool", + input_sources=["risk_observations", "risk_observation_feedback", "agent_runs"], + execution_strategy="definition_ready", + ), { "code": DIGITAL_EMPLOYEE_RISK_GRAPH_SCAN_TASK_CODE, "name": "财务风险图谱巡检", @@ -43,6 +153,15 @@ class AgentFoundationDigitalEmployeeTaskMixin: ], "output_format": "risk_observation_report", "writes_risk_observations": True, + "allowed_outputs": [ + "facts", + "rule_hits", + "risk_clues", + "evidence_refs", + "human_review_required", + ], + "role_boundary": DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY, + "writes_rules": False, }, }, { @@ -53,7 +172,7 @@ class AgentFoundationDigitalEmployeeTaskMixin: "owner": "风控与审计部", "reviewer": "顾承宇", "cron": "30 8 * * 1", - "skill_category": "评估", + "skill_category": "积累", "markdown": self._employee_behavior_profile_scan_skill_markdown, "change_note": "初始化员工行为画像巡检能力。", "config": { @@ -66,30 +185,199 @@ class AgentFoundationDigitalEmployeeTaskMixin: ], "output_format": "employee_behavior_profile_snapshot", "writes_profile_snapshots": True, + "allowed_outputs": [ + "facts", + "profile_snapshots", + "baseline_metrics", + "evidence_refs", + "human_review_required", + ], + "role_boundary": DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY, + "writes_rules": False, }, }, + self._digital_employee_task_spec( + code=DIGITAL_EMPLOYEE_MULTI_EVIDENCE_TASK_CODE, + name="单据多凭证一致性评估", + description="比对报销单、费用明细、发票、流水、合同和事前申请之间的金额、数量、主体和时间字段。", + scenario_json=["schedule", "expense", "multi_evidence", "risk_observation"], + owner="风控与审计部", + cron="15 9 * * *", + skill_category="评估", + skill_name="multi-evidence-consistency-evaluator", + output_format="multi_evidence_consistency_report", + input_sources=["expense_claims", "expense_items", "invoices", "attachments"], + execution_strategy="reuse_financial_risk_graph_scan", + ), + self._digital_employee_task_spec( + code=DIGITAL_EMPLOYEE_SPATIOTEMPORAL_TASK_CODE, + name="差旅时空一致性评估", + description="评估差旅发生时间、提交时间、票据地点、消费地点、行程轨迹和开票地点是否一致。", + scenario_json=["schedule", "travel", "spatiotemporal", "risk_observation"], + owner="风控与审计部", + cron="30 9 * * *", + skill_category="评估", + skill_name="travel-spatiotemporal-consistency-evaluator", + output_format="spatiotemporal_consistency_report", + input_sources=["expense_claims", "expense_items", "invoice_locations", "travel_routes"], + execution_strategy="reuse_financial_risk_graph_scan", + ), + self._digital_employee_task_spec( + code=DIGITAL_EMPLOYEE_BUDGET_PRECONTROL_TASK_CODE, + name="预算占用与超标预警", + description="评估预算占用、费用标准、历史基线和柔性控制边界,输出提交前或审批前预警建议。", + scenario_json=["schedule", "budget", "expense", "precontrol"], + owner="预算管理组", + cron="45 9 * * *", + skill_category="评估", + skill_name="budget-overrun-precontrol-evaluator", + output_format="budget_precontrol_warning_report", + input_sources=["expense_claims", "budget_snapshots", "policy_refs", "profile_baselines"], + execution_strategy="definition_ready", + ), + self._digital_employee_task_spec( + code=DIGITAL_EMPLOYEE_SUPPLIER_RELATION_TASK_CODE, + name="供应商异常关系评估", + description="识别员工、部门、供应商、票据和报销单之间的异常聚集、重复关系和跨部门集中风险。", + scenario_json=["schedule", "supplier", "risk_graph", "relationship"], + owner="风控与审计部", + cron="0 9 * * 2", + skill_category="评估", + skill_name="supplier-abnormal-relation-evaluator", + output_format="supplier_abnormal_relation_report", + input_sources=["risk_graph", "expense_claims", "invoice_entities", "entity_registry"], + execution_strategy="reuse_financial_risk_graph_scan", + ), { "code": DIGITAL_EMPLOYEE_RULE_DISCOVERY_TASK_CODE, - "name": "风险规则候选发现", - "description": "按计划复盘风险观察和人工反馈,生成带证据、来源和置信度的候选规则,不直接上线。", - "scenario_json": ["schedule", "risk_observation", "feedback", "rule_candidate"], + "name": "风险线索归集", + "description": "按计划复盘申请、报销、规则命中和人工反馈,归集带事实依据的潜在线索,提交人工复核,不生成规则。", + "scenario_json": ["schedule", "application", "reimbursement", "risk_clue"], "owner": "风控与审计部", "reviewer": "顾承宇", "cron": "0 10 * * 1", "skill_category": "升级", - "markdown": self._risk_rule_discovery_skill_markdown, - "change_note": "初始化风险规则候选发现能力。", + "markdown": self._risk_clue_collector_skill_markdown, + "change_note": "初始化风险线索归集能力。", "config": { - "skill_name": "risk-rule-discovery", + "task_type": "risk_clue_collect", + "skill_name": "risk-clue-collector", "input_sources": [ - "risk_observations", + "expense_applications", + "expense_claims", + "rule_hits", "risk_observation_feedback", - "algorithm_replay_sets", ], - "output_format": "candidate_risk_rules", - "auto_publish": False, + "output_format": "risk_clue_review_packet", + "allowed_outputs": [ + "facts", + "rule_hits", + "risk_clues", + "evidence_refs", + "human_review_required", + ], + "role_boundary": DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY, + "writes_rules": False, + "human_review_required": True, }, }, + self._digital_employee_task_spec( + code=DIGITAL_EMPLOYEE_ALGORITHM_REPLAY_TASK_CODE, + name="风险算法回放评测", + description="复跑历史风险观察、反馈标签、本体版本和规则版本,评估算法升级前后的误报率和确认率。", + scenario_json=["schedule", "algorithm_replay", "evaluation", "feedback"], + owner="风控与审计部", + cron="30 10 * * 1", + skill_category="升级", + skill_name="risk-algorithm-replay-evaluator", + output_format="algorithm_replay_evaluation_report", + input_sources=["algorithm_replay_sets", "risk_observations", "risk_observation_feedback"], + execution_strategy="definition_ready", + ), + self._digital_employee_task_spec( + code=DIGITAL_EMPLOYEE_POLICY_GAP_TASK_CODE, + name="制度引用缺口提示", + description="整理申请、报销、规则命中和人工反馈中缺少制度引用的事实位置,提示人工补齐制度依据,不输出规则变更建议。", + scenario_json=["schedule", "policy_reference", "evidence_gap", "human_review"], + owner="财务制度管理组", + cron="0 11 * * 1", + skill_category="升级", + skill_name="policy-reference-gap-hinter", + output_format="policy_reference_gap_hint_report", + input_sources=["policy_refs", "rule_hits", "expense_claims", "risk_feedback_samples"], + execution_strategy="definition_ready", + ), + ) + + def _digital_employee_task_spec( + self, + *, + code: str, + name: str, + description: str, + scenario_json: list[str], + owner: str, + cron: str, + skill_category: str, + skill_name: str, + output_format: str, + input_sources: list[str], + execution_strategy: str, + ) -> dict[str, object]: + return { + "code": code, + "name": name, + "description": description, + "scenario_json": scenario_json, + "owner": owner, + "reviewer": "顾承宇", + "cron": cron, + "skill_category": skill_category, + "markdown": lambda: self._generic_digital_employee_skill_markdown( + skill_name=skill_name, + title=name, + description=description, + ), + "change_note": f"初始化{name}能力。", + "config": { + "skill_name": skill_name, + "input_sources": input_sources, + "output_format": output_format, + "writes_work_record": True, + "execution_strategy": execution_strategy, + "allowed_outputs": [ + "facts", + "rule_hits", + "risk_clues", + "evidence_refs", + "human_review_required", + ], + "role_boundary": DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY, + "writes_rules": False, + }, + } + + def _generic_digital_employee_skill_markdown( + self, + *, + skill_name: str, + title: str, + description: str, + ) -> str: + return self._read_domain_skill_markdown( + skill_name, + [ + "---", + f"name: {skill_name}", + f"description: {description}", + "---", + "", + f"# {title}", + "", + "## 功能说明", + "", + description, + ], ) def _upsert_runtime_digital_employee_tasks(self, existing_codes: set[str]) -> None: @@ -146,6 +434,7 @@ class AgentFoundationDigitalEmployeeTaskMixin: cron = str(spec["cron"]) base = { **self._digital_employee_task_config(code, cron), + "skill_category": str(spec["skill_category"]), "schedule": cron, "cron_expression": cron, **dict(spec["config"]), diff --git a/server/src/app/services/agent_foundation_risk_rules.py b/server/src/app/services/agent_foundation_risk_rules.py index fa9dd7d..2faf348 100644 --- a/server/src/app/services/agent_foundation_risk_rules.py +++ b/server/src/app/services/agent_foundation_risk_rules.py @@ -24,6 +24,7 @@ from app.services.agent_foundation_constants import ( logger = get_logger("app.services.agent_foundation") EXPENSE_TYPE_SCENARIO_LABELS = { + "all": "全部", "travel": "差旅费", "hotel": "住宿费", "transport": "交通费", @@ -158,6 +159,10 @@ class AgentFoundationRiskRuleMixin: metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {} candidates.extend(_collect(metadata.get("expense_types"))) + all_scope_values = {"all", "*", "overall", "general", "全部", "通用"} + if any(str(item or "").strip().lower() in all_scope_values for item in candidates): + return ["all"] + normalized: list[str] = [] seen: set[str] = set() for item in candidates: @@ -170,6 +175,9 @@ class AgentFoundationRiskRuleMixin: @staticmethod def _expense_type_scenario_labels(expense_types: list[str]) -> list[str]: + if any(str(item or "").strip().lower() in {"all", "*", "overall", "general"} for item in expense_types): + return ["全部"] + labels: list[str] = [] seen: set[str] = set() for expense_type in expense_types: diff --git a/server/src/app/services/agent_traces.py b/server/src/app/services/agent_traces.py new file mode 100644 index 0000000..8848ae7 --- /dev/null +++ b/server/src/app/services/agent_traces.py @@ -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) diff --git a/server/src/app/services/application_system_estimate.py b/server/src/app/services/application_system_estimate.py new file mode 100644 index 0000000..9a5b88f --- /dev/null +++ b/server/src/app/services/application_system_estimate.py @@ -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] diff --git a/server/src/app/services/auth.py b/server/src/app/services/auth.py index c8f6be1..c796940 100644 --- a/server/src/app/services/auth.py +++ b/server/src/app/services/auth.py @@ -81,6 +81,20 @@ class AuthService: session = UserSessionMetricService(self.db).start_session(user) return LoginResponse(user=self._serialize_user(user), sessionId=session.session_id) + def get_user_snapshot(self, identifier: str) -> AuthUserRead | None: + normalized = identifier.strip() + if not normalized or not self.settings.setup_completed: + return None + + employee = self._find_employee_by_email(normalized) + if employee is None: + EmployeeService(self.db).ensure_directory_ready() + employee = self._find_employee_by_email(normalized) + if employee is None or employee.employment_status == "停用": + return None + + return self._serialize_user(self._build_employee_user(employee)) + def _authenticate_admin(self, identifier: str, password: str) -> AuthenticatedUser | None: record = SettingsService(self.db).verify_admin_login(identifier, password) if record is None: @@ -114,17 +128,7 @@ class AuthService: return None EmployeeService(self.db).ensure_directory_ready() - - stmt = ( - select(Employee) - .options( - selectinload(Employee.organization_unit), - selectinload(Employee.manager), - selectinload(Employee.roles), - ) - .where(func.lower(Employee.email) == identifier.lower()) - ) - employee = self.db.execute(stmt).scalars().first() + employee = self._find_employee_by_email(identifier) if employee is None or not employee.password_hash: return None @@ -136,6 +140,21 @@ class AuthService: if not verify_password(password, employee.password_hash): return None + return self._build_employee_user(employee) + + def _find_employee_by_email(self, identifier: str) -> Employee | None: + stmt = ( + select(Employee) + .options( + selectinload(Employee.organization_unit), + selectinload(Employee.manager), + selectinload(Employee.roles), + ) + .where(func.lower(Employee.email) == identifier.lower()) + ) + return self.db.execute(stmt).scalars().first() + + def _build_employee_user(self, employee: Employee) -> AuthenticatedUser: sorted_roles = sorted( list(employee.roles), key=lambda item: (ROLE_DISPLAY_ORDER.get(item.role_code, 999), item.name), diff --git a/server/src/app/services/budget_support.py b/server/src/app/services/budget_support.py index 4d01e1e..2b56447 100644 --- a/server/src/app/services/budget_support.py +++ b/server/src/app/services/budget_support.py @@ -24,6 +24,7 @@ from app.services.budget_types import ( SUPPORTED_BUDGET_SUBJECT_CODES, ) from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS +from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics from app.services.expense_type_keywords import resolve_expense_type_code_from_text @@ -604,7 +605,12 @@ class BudgetSupportMixin: "created_at": datetime.now(UTC).isoformat(), } payload.update(extra or {}) - return payload + return enrich_risk_flag_semantics( + payload, + risk_domain="budget", + visibility_scope="budget_manager", + actionability="budget_governance", + ) def _build_operation_flag( self, diff --git a/server/src/app/services/digital_employee_dashboard.py b/server/src/app/services/digital_employee_dashboard.py new file mode 100644 index 0000000..fa8fbad --- /dev/null +++ b/server/src/app/services/digital_employee_dashboard.py @@ -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 "未标记") diff --git a/server/src/app/services/employee_behavior_profile_service.py b/server/src/app/services/employee_behavior_profile_service.py index 4b24ac7..f603ddd 100644 --- a/server/src/app/services/employee_behavior_profile_service.py +++ b/server/src/app/services/employee_behavior_profile_service.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta from decimal import Decimal from typing import Any -from sqlalchemy import or_, select +from sqlalchemy import func, or_, select from sqlalchemy.orm import Session, selectinload from app.algorithem.employee_behavior_profile import ( @@ -102,7 +102,8 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers): commit: bool = True, ) -> list[EmployeeBehaviorProfileSnapshot]: self.ensure_storage_ready() - employee = self.db.get(Employee, employee_id) + requested_employee_id = str(employee_id or "").strip() + employee = self._resolve_employee_by_identifier(requested_employee_id) if employee is None: return [] @@ -161,10 +162,11 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers): expense_type_scope: str = "overall", ) -> EmployeeProfileLatestRead: self.ensure_storage_ready() - employee = self.db.get(Employee, employee_id) + requested_employee_id = str(employee_id or "").strip() + employee = self._resolve_employee_by_identifier(requested_employee_id) if employee is None: return EmployeeProfileLatestRead( - employee_id=employee_id, + employee_id=requested_employee_id, scene=scene, window_days=window_days, expense_type_scope=expense_type_scope, @@ -172,22 +174,23 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers): ) resolved_scope = self._resolve_scope_from_claim(claim_id, expense_type_scope) + resolved_employee_id = employee.id rows = self._load_latest_snapshots( - employee_id=employee_id, + employee_id=resolved_employee_id, window_days=window_days, expense_type_scope=resolved_scope, scene=scene, ) if not rows and claim_id: self.refresh_employee_profiles( - employee_id=employee_id, + employee_id=resolved_employee_id, window_days=(window_days,), expense_type_scope=resolved_scope, source_task_type="api_on_demand", claim_id=claim_id, ) rows = self._load_latest_snapshots( - employee_id=employee_id, + employee_id=resolved_employee_id, window_days=window_days, expense_type_scope=resolved_scope, scene=scene, @@ -201,6 +204,31 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers): expense_type_scope=resolved_scope, ) + def _resolve_employee_by_identifier(self, identifier: str) -> Employee | None: + normalized = str(identifier or "").strip() + if not normalized: + return None + + employee = self.db.get(Employee, normalized) + if employee is not None: + return employee + + normalized_email = normalized.lower() + conditions = [ + Employee.name == normalized, + Employee.employee_no == normalized, + ] + if "@" in normalized_email: + conditions.append(func.lower(Employee.email) == normalized_email) + + stmt = ( + select(Employee) + .where(or_(*conditions)) + .order_by(Employee.created_at.asc()) + .limit(1) + ) + return self.db.scalars(stmt).first() + def _build_window_context( self, *, diff --git a/server/src/app/services/expense_claim_access_policy.py b/server/src/app/services/expense_claim_access_policy.py index 374283a..9e881ca 100644 --- a/server/src/app/services/expense_claim_access_policy.py +++ b/server/src/app/services/expense_claim_access_policy.py @@ -185,10 +185,7 @@ class ExpenseClaimAccessPolicy: return False if current_user.is_admin: return True - role_codes = self.normalize_role_codes(current_user) - if "executive" in role_codes: - return True - return self.is_department_p8_budget_monitor(current_user, claim) + return self.is_department_budget_approver(current_user, claim) def is_budget_manager_user(self, current_user: CurrentUserContext) -> bool: if current_user.is_admin: @@ -197,13 +194,16 @@ class ExpenseClaimAccessPolicy: return bool(role_codes & BUDGET_APPROVAL_ROLE_CODES) def is_department_p8_budget_monitor(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: - role_codes = self.normalize_role_codes(current_user) - if BUDGET_MONITOR_ROLE_CODE not in role_codes: - return False + return self.is_department_budget_approver(current_user, claim) + def is_department_budget_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: + role_codes = self.normalize_role_codes(current_user) current_employee = self.resolve_current_employee(current_user) if current_employee is None: return False + role_codes |= self._collect_employee_role_codes(current_employee) + if not role_codes & BUDGET_APPROVAL_ROLE_CODES: + return False if not self._employee_has_budget_approval_grade(current_employee): return False @@ -224,7 +224,7 @@ class ExpenseClaimAccessPolicy: .options(selectinload(Employee.organization_unit), selectinload(Employee.roles)) .where( func.upper(func.coalesce(Employee.grade, "")) == BUDGET_MONITOR_APPROVAL_GRADE, - Employee.roles.any(Role.role_code == BUDGET_MONITOR_ROLE_CODE), + Employee.roles.any(Role.role_code.in_(BUDGET_APPROVAL_ROLE_CODES)), or_(*department_conditions), ) .order_by(Employee.name.asc(), Employee.employee_no.asc()) @@ -235,6 +235,37 @@ class ExpenseClaimAccessPolicy: stmt = stmt.where(Employee.id != claim_employee_id) return self.db.scalar(stmt) + def resolve_budget_approval_role_code(self, employee: Employee | None) -> str: + role_codes = self._collect_employee_role_codes(employee) + for role_code in ("budget_monitor", "executive"): + if role_code in role_codes: + return role_code + return BUDGET_MONITOR_ROLE_CODE + + def attach_budget_approval_snapshot(self, claim: ExpenseClaim | None) -> ExpenseClaim | None: + if claim is None: + return None + if str(claim.approval_stage or "").strip() != BUDGET_MANAGER_APPROVAL_STAGE: + return claim + + budget_manager = self.resolve_department_budget_manager(claim) + if budget_manager is None: + return claim + + setattr(claim, "budget_approver_name", str(budget_manager.name or "").strip()) + setattr(claim, "budget_approver_grade", str(budget_manager.grade or "").strip()) + setattr( + claim, + "budget_approver_role_code", + self.resolve_budget_approval_role_code(budget_manager), + ) + return claim + + def attach_budget_approval_snapshots(self, claims: list[ExpenseClaim]) -> list[ExpenseClaim]: + for claim in claims: + self.attach_budget_approval_snapshot(claim) + return claims + @staticmethod def normalize_role_codes(current_user: CurrentUserContext) -> set[str]: return { @@ -243,6 +274,16 @@ class ExpenseClaimAccessPolicy: if str(item).strip() } + @staticmethod + def _collect_employee_role_codes(employee: Employee | None) -> set[str]: + if employee is None: + return set() + return { + str(role.role_code or "").strip().lower() + for role in list(employee.roles or []) + if str(role.role_code or "").strip() + } + @staticmethod def _employee_has_budget_approval_grade(employee: Employee) -> bool: return str(employee.grade or "").strip().upper() == BUDGET_MONITOR_APPROVAL_GRADE @@ -293,6 +334,7 @@ class ExpenseClaimAccessPolicy: [ str(current_user.username or "").strip(), str(current_user.name or "").strip(), + str(current_user.employee_no or "").strip(), ] ) @@ -309,9 +351,10 @@ class ExpenseClaimAccessPolicy: return str(current_user.username or current_user.name or "anonymous").strip() or "anonymous" def is_claim_owned_by_current_user(self, claim: ExpenseClaim, current_user: CurrentUserContext) -> bool: + claim_employee_id = str(claim.employee_id or "").strip() current_employee = self.resolve_current_employee(current_user) if current_employee is not None: - if str(claim.employee_id or "").strip() == current_employee.id: + if claim_employee_id == current_employee.id: return True identity_values = { str(current_employee.name or "").strip(), @@ -325,9 +368,12 @@ class ExpenseClaimAccessPolicy: { str(current_user.username or "").strip(), str(current_user.name or "").strip(), + str(current_user.employee_no or "").strip(), } ) identity_values.discard("") + if claim_employee_id and claim_employee_id in identity_values: + return True return str(claim.employee_name or "").strip() in identity_values @staticmethod @@ -490,8 +536,10 @@ class ExpenseClaimAccessPolicy: add_condition("employee_name", employee.name) else: add_condition("employee_id", username) + add_condition("employee_id", str(current_user.employee_no or "").strip()) add_condition("employee_name", username) add_condition("employee_name", str(current_user.name or "").strip()) + add_condition("employee_name", str(current_user.employee_no or "").strip()) return conditions @@ -531,10 +579,10 @@ class ExpenseClaimAccessPolicy: return conditions def build_budget_approval_claim_conditions(self, current_user: CurrentUserContext) -> list[Any]: - role_codes = self.normalize_role_codes(current_user) - if BUDGET_MONITOR_ROLE_CODE not in role_codes: - return [] employee = self.resolve_current_employee(current_user) + role_codes = self.normalize_role_codes(current_user) | self._collect_employee_role_codes(employee) + if not role_codes & BUDGET_APPROVAL_ROLE_CODES: + return [] if employee is None or not self._employee_has_budget_approval_grade(employee): return [] @@ -568,7 +616,7 @@ class ExpenseClaimAccessPolicy: def apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any: role_codes = self.normalize_role_codes(current_user) - if current_user.is_admin or "executive" in role_codes: + if current_user.is_admin: return stmt.where(ExpenseClaim.status == "submitted") conditions = [] if "finance" in role_codes: diff --git a/server/src/app/services/expense_claim_approval_flow.py b/server/src/app/services/expense_claim_approval_flow.py index 6276a01..3359d14 100644 --- a/server/src/app/services/expense_claim_approval_flow.py +++ b/server/src/app/services/expense_claim_approval_flow.py @@ -15,6 +15,10 @@ from app.services.expense_claim_workflow_constants import ( PAYMENT_PENDING_STAGE, PAYMENT_PENDING_STATUS, ) +from app.services.expense_claim_risk_stage import ( + risk_business_stage_for_claim, + with_risk_business_stage, +) class ExpenseClaimApprovalFlowMixin: @@ -35,41 +39,72 @@ class ExpenseClaimApprovalFlowMixin: previous_stage = str(claim.approval_stage or "").strip() is_application_claim = self._is_expense_application_claim(claim) + business_stage = risk_business_stage_for_claim(is_application_claim=is_application_claim) next_budget_manager = None merged_budget_approval = False + route_decision_flag: dict[str, Any] | None = None if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE: if not self._access_policy.can_approve_claim(current_user, claim): raise ValueError("只有当前直属领导审批人可以审批通过该单据。") approval_source = "manual_approval" event_type = "expense_application_approval" if is_application_claim else "expense_claim_approval" label = "领导审批通过" + route_decision_flag = self._build_approval_route_decision( + claim, + is_application_claim=is_application_claim, + ) + requires_budget_review = bool(route_decision_flag.get("requires_budget_review")) if is_application_claim: - merged_budget_approval = self._access_policy.is_department_p8_budget_monitor(current_user, claim) + merged_budget_approval = ( + requires_budget_review + and self._access_policy.is_department_p8_budget_monitor(current_user, claim) + ) if merged_budget_approval: label = "领导及预算审核通过" next_status = "approved" next_stage = APPROVAL_DONE_STAGE default_message = "{operator} 已完成直属领导和预算管理者审核,申请流程完成并生成报销草稿。" - else: + elif requires_budget_review: next_budget_manager = self._access_policy.resolve_department_budget_manager(claim) + if next_budget_manager is None: + raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。") next_status = "submitted" next_stage = BUDGET_MANAGER_APPROVAL_STAGE - default_message = "{operator} 已确认直属领导审核,流转至预算管理者审批。" + default_message = "{operator} 已确认直属领导审核,因预算或风险关注项流转至预算管理者审批。" + else: + next_status = "approved" + next_stage = APPROVAL_DONE_STAGE + default_message = "{operator} 已确认直属领导审核,系统判断预算充足且无风险,申请流程完成并生成报销草稿。" + else: + if requires_budget_review: + next_budget_manager = self._access_policy.resolve_department_budget_manager(claim) + if next_budget_manager is None: + raise ValueError("未找到同部门 P8 预算审批人,无法流转预算审批。请先配置预算审批人。") + next_status = "submitted" + next_stage = BUDGET_MANAGER_APPROVAL_STAGE + default_message = "{operator} 已审批通过,因预算或风险关注项流转至预算管理者审批。" + else: + next_status = "submitted" + next_stage = FINANCE_APPROVAL_STAGE + default_message = "{operator} 已审批通过,系统判断预算充足且无风险,流转至{next_stage}。" + elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE: + if not self._access_policy.can_approve_claim(current_user, claim): + raise ValueError("只有当前预算管理者可以审批通过该单据。") + approval_source = "budget_approval" + event_type = ( + "expense_application_budget_approval" + if is_application_claim + else "expense_claim_budget_approval" + ) + label = "预算管理者审核通过" + if is_application_claim: + next_status = "approved" + next_stage = APPROVAL_DONE_STAGE + default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。" else: next_status = "submitted" next_stage = FINANCE_APPROVAL_STAGE - default_message = "{operator} 已审批通过,流转至{next_stage}。" - elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE: - if not is_application_claim: - raise ValueError("只有费用申请需要预算管理者审批。") - if not self._access_policy.can_approve_claim(current_user, claim): - raise ValueError("只有当前预算管理者可以审批通过该费用申请。") - approval_source = "budget_approval" - event_type = "expense_application_budget_approval" - label = "预算管理者审核通过" - next_status = "approved" - next_stage = APPROVAL_DONE_STAGE - default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。" + default_message = "{operator} 已完成预算审核,流转至{next_stage}。" elif previous_stage == FINANCE_APPROVAL_STAGE: if is_application_claim: raise ValueError("费用申请需先完成预算管理者审批。") @@ -95,32 +130,35 @@ class ExpenseClaimApprovalFlowMixin: consumed_budget_flag = self._consume_budget_for_finance_approval(claim, current_user) if consumed_budget_flag is not None: budget_flags.append(consumed_budget_flag) - approval_flag = { - "source": approval_source, - "event_type": event_type, - "approval_event_id": str(uuid.uuid4()), - "severity": "info", - "label": label, - "message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage), - "opinion": approval_opinion, - "operator": operator, - "operator_username": current_user.username, - "operator_role_codes": [ - str(item).strip().lower() - for item in current_user.role_codes - if str(item).strip() - ], - "previous_status": str(claim.status or "").strip(), - "previous_approval_stage": previous_stage, - "next_status": next_status, - "next_approval_stage": next_stage, - "created_at": datetime.now(UTC).isoformat(), - } + approval_flag = with_risk_business_stage( + { + "source": approval_source, + "event_type": event_type, + "approval_event_id": str(uuid.uuid4()), + "severity": "info", + "label": label, + "message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage), + "opinion": approval_opinion, + "operator": operator, + "operator_username": current_user.username, + "operator_role_codes": [ + str(item).strip().lower() + for item in current_user.role_codes + if str(item).strip() + ], + "previous_status": str(claim.status or "").strip(), + "previous_approval_stage": previous_stage, + "next_status": next_status, + "next_approval_stage": next_stage, + "created_at": datetime.now(UTC).isoformat(), + }, + business_stage, + ) if merged_budget_approval: approval_flag.update( { "budget_approval_merged": True, - "budget_approval_merged_reason": "direct_manager_is_department_budget_monitor", + "budget_approval_merged_reason": "direct_manager_is_department_budget_approver", } ) if next_budget_manager is not None: @@ -129,9 +167,20 @@ class ExpenseClaimApprovalFlowMixin: "next_approver_name": str(next_budget_manager.name or "").strip(), "next_approver_employee_id": next_budget_manager.id, "next_approver_grade": str(next_budget_manager.grade or "").strip(), - "next_approver_role_code": "budget_monitor", + "next_approver_role_code": self._access_policy.resolve_budget_approval_role_code( + next_budget_manager, + ), } ) + if route_decision_flag is not None: + approval_flag["route_decision"] = { + "requires_budget_review": route_decision_flag.get("requires_budget_review"), + "route": route_decision_flag.get("route"), + "reasons": route_decision_flag.get("reasons", []), + "budget_result": route_decision_flag.get("budget_result", {}), + "current_risk_count": route_decision_flag.get("current_risk_count", 0), + "historical_risk_count": route_decision_flag.get("historical_risk_count", 0), + } claim.status = next_status claim.approval_stage = next_stage @@ -147,6 +196,13 @@ class ExpenseClaimApprovalFlowMixin: elif merged_budget_approval: approval_flag["leader_opinion"] = approval_opinion approval_flag["budget_opinion"] = approval_opinion + elif ( + previous_stage == DIRECT_MANAGER_APPROVAL_STAGE + and route_decision_flag is not None + and not route_decision_flag.get("requires_budget_review") + ): + approval_flag["leader_opinion"] = approval_opinion + approval_flag["budget_opinion"] = "系统动态路由跳过预算复核" generated_draft = self._create_reimbursement_draft_from_application( application_claim=claim, approval_flag=approval_flag, @@ -162,14 +218,21 @@ class ExpenseClaimApprovalFlowMixin: generated_draft.risk_flags_json = self._append_budget_flags( generated_draft.risk_flags_json, transferred_budget_flag, + business_stage="reimbursement", ) + approval_flags: list[Any] = list(claim.risk_flags_json or []) + if route_decision_flag is not None: + approval_flags.append(route_decision_flag) + approval_flags.append(approval_flag) claim.risk_flags_json = self._append_budget_flags( - [*list(claim.risk_flags_json or []), approval_flag], + approval_flags, budget_flags, + business_stage=business_stage, ) self.db.commit() self.db.refresh(claim) + self._access_policy.attach_budget_approval_snapshot(claim) self.audit_service.log_action( actor=operator, @@ -202,26 +265,29 @@ class ExpenseClaimApprovalFlowMixin: before_json = self._serialize_claim(claim) operator = self._access_policy.resolve_current_user_display_name(current_user) previous_stage = str(claim.approval_stage or "").strip() - payment_flag = { - "source": "payment", - "event_type": "expense_claim_payment_completed", - "payment_event_id": str(uuid.uuid4()), - "severity": "info", - "label": "付款完成", - "message": f"{operator} 已确认付款,报销单进入已付款。", - "operator": operator, - "operator_username": current_user.username, - "operator_role_codes": [ - str(item).strip().lower() - for item in current_user.role_codes - if str(item).strip() - ], - "previous_status": str(claim.status or "").strip(), - "previous_approval_stage": previous_stage, - "next_status": PAYMENT_PAID_STATUS, - "next_approval_stage": PAYMENT_PAID_STAGE, - "created_at": datetime.now(UTC).isoformat(), - } + payment_flag = with_risk_business_stage( + { + "source": "payment", + "event_type": "expense_claim_payment_completed", + "payment_event_id": str(uuid.uuid4()), + "severity": "info", + "label": "付款完成", + "message": f"{operator} 已确认付款,报销单进入已付款。", + "operator": operator, + "operator_username": current_user.username, + "operator_role_codes": [ + str(item).strip().lower() + for item in current_user.role_codes + if str(item).strip() + ], + "previous_status": str(claim.status or "").strip(), + "previous_approval_stage": previous_stage, + "next_status": PAYMENT_PAID_STATUS, + "next_approval_stage": PAYMENT_PAID_STAGE, + "created_at": datetime.now(UTC).isoformat(), + }, + "reimbursement", + ) claim.status = PAYMENT_PAID_STATUS claim.approval_stage = PAYMENT_PAID_STAGE diff --git a/server/src/app/services/expense_claim_approval_routing.py b/server/src/app/services/expense_claim_approval_routing.py new file mode 100644 index 0000000..9f0f3c0 --- /dev/null +++ b/server/src/app/services/expense_claim_approval_routing.py @@ -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") diff --git a/server/src/app/services/expense_claim_attachment_operations.py b/server/src/app/services/expense_claim_attachment_operations.py index 89b9cd8..d51f41b 100644 --- a/server/src/app/services/expense_claim_attachment_operations.py +++ b/server/src/app/services/expense_claim_attachment_operations.py @@ -277,6 +277,7 @@ class ExpenseClaimAttachmentOperationsMixin: "item_location": item.item_location, "item_amount": item.item_amount, "claim_amount": claim.amount, + "claim_risk_flags": list(claim.risk_flags_json or []), "attachment": self._build_attachment_payload(item), } @@ -371,6 +372,7 @@ class ExpenseClaimAttachmentOperationsMixin: "claim_id": claim.id, "item_id": item.id, "invoice_id": item.invoice_id, + "claim_risk_flags": list(claim.risk_flags_json or []), "attachment": None, } diff --git a/server/src/app/services/expense_claim_budget_flow.py b/server/src/app/services/expense_claim_budget_flow.py index b098e2a..e84eb1d 100644 --- a/server/src/app/services/expense_claim_budget_flow.py +++ b/server/src/app/services/expense_claim_budget_flow.py @@ -5,6 +5,7 @@ from typing import Any from app.api.deps import CurrentUserContext from app.models.financial_record import ExpenseClaim from app.services.budget import BudgetService +from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics class ExpenseClaimBudgetFlowMixin: @@ -80,6 +81,8 @@ class ExpenseClaimBudgetFlowMixin: def _append_budget_flags( risk_flags: list[Any] | None, budget_flags: list[dict[str, Any]] | dict[str, Any] | None, + *, + business_stage: str | None = None, ) -> list[Any]: if budget_flags is None: return list(risk_flags or []) @@ -89,7 +92,19 @@ class ExpenseClaimBudgetFlowMixin: next_flags = list(budget_flags or []) if not next_flags: return list(risk_flags or []) - return [*list(risk_flags or []), *next_flags] + enriched_flags = [ + enrich_risk_flag_semantics( + flag, + business_stage=business_stage, + risk_domain="budget", + visibility_scope="budget_manager", + actionability="budget_governance", + ) + if isinstance(flag, dict) + else flag + for flag in next_flags + ] + return [*list(risk_flags or []), *enriched_flags] @staticmethod def _resolve_budget_operator(current_user: CurrentUserContext) -> str: diff --git a/server/src/app/services/expense_claim_draft_flow.py b/server/src/app/services/expense_claim_draft_flow.py index 85e0c65..b52a7e9 100644 --- a/server/src/app/services/expense_claim_draft_flow.py +++ b/server/src/app/services/expense_claim_draft_flow.py @@ -224,6 +224,10 @@ class ExpenseClaimDraftFlowMixin: existing_flags=list(claim.risk_flags_json or []) if claim is not None else [], next_flags=list(ontology.risk_flags), ) + final_risk_flags = self._merge_application_link_flag( + final_risk_flags, + context_json=context_json, + ) if context_documents or attachment_names: document_specs = self._build_context_item_specs( context_documents=context_documents, @@ -347,6 +351,7 @@ class ExpenseClaimDraftFlowMixin: context_json=retry_context, ) raise + except Exception: self.db.rollback() raise @@ -374,6 +379,86 @@ class ExpenseClaimDraftFlowMixin: "invoice_count": int(claim.invoice_count or 0), } + @staticmethod + def _merge_application_link_flag( + risk_flags: list[Any], + *, + context_json: dict[str, Any], + ) -> list[Any]: + link_flag = ExpenseClaimDraftFlowMixin._build_application_link_flag(context_json) + if link_flag is None: + return list(risk_flags or []) + + application_claim_no = str(link_flag.get("application_claim_no") or "").strip() + for flag in list(risk_flags or []): + if not isinstance(flag, dict): + continue + existing_no = str( + flag.get("application_claim_no") + or flag.get("applicationClaimNo") + or "" + ).strip() + if existing_no and existing_no == application_claim_no: + return list(risk_flags or []) + return [*list(risk_flags or []), link_flag] + + @staticmethod + def _build_application_link_flag(context_json: dict[str, Any]) -> dict[str, Any] | None: + review_values = ExpenseClaimDraftFlowMixin._normalize_context_object( + context_json.get("review_form_values") + ) + scene_selection = ExpenseClaimDraftFlowMixin._normalize_context_object( + context_json.get("expense_scene_selection") + ) + + def pick(*keys: str) -> str: + for source in (review_values, scene_selection, context_json): + for key in keys: + value = str(source.get(key) or "").strip() + if value: + return value + return "" + + application_claim_no = pick("application_claim_no", "applicationClaimNo") + if not application_claim_no: + return None + + application_claim_id = pick("application_claim_id", "applicationClaimId") + application_amount = pick("application_amount", "applicationAmount") + application_amount_label = pick("application_amount_label", "applicationAmountLabel") + application_reason = pick("application_reason", "applicationReason", "reason") + application_location = pick("application_location", "applicationLocation", "location") + application_date = pick("application_date", "applicationDate", "business_time", "time_range") + application_status = pick("application_status", "applicationStatus") + application_status_label = pick("application_status_label", "applicationStatusLabel") + + return { + "source": "application_link", + "event_type": "expense_reimbursement_application_linked", + "severity": "info", + "label": "关联申请单", + "message": f"报销草稿已关联申请单 {application_claim_no}。", + "application_claim_id": application_claim_id, + "application_claim_no": application_claim_no, + "application_amount_label": application_amount_label, + "application_status": application_status, + "application_status_label": application_status_label, + "application_detail": { + "application_reason": application_reason, + "application_location": application_location, + "application_amount": application_amount, + "application_amount_label": application_amount_label, + "application_time": application_date, + }, + "review_form_values": review_values, + "expense_scene_selection": scene_selection, + "created_at": datetime.now(UTC).isoformat(), + } + + @staticmethod + def _normalize_context_object(value: Any) -> dict[str, Any]: + return dict(value) if isinstance(value, dict) else {} + def _find_target_claim( self, *, diff --git a/server/src/app/services/expense_claim_item_sync.py b/server/src/app/services/expense_claim_item_sync.py index c3650fd..162cf4f 100644 --- a/server/src/app/services/expense_claim_item_sync.py +++ b/server/src/app/services/expense_claim_item_sync.py @@ -27,6 +27,7 @@ from app.services.expense_claim_constants import ( TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES, TRAVEL_POLICY_HOTEL_NIGHT_PATTERN, ) +from app.services.expense_claim_risk_stage import with_risk_business_stage from app.services.expense_rule_runtime import ( ExpenseRuleRuntimeService, RuntimeTravelPolicy, @@ -215,6 +216,7 @@ class ExpenseClaimItemSyncMixin: claim.amount = Decimal("0.00") claim.invoice_count = 0 claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, []) + claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(claim, []) return ordered_items = sorted( @@ -253,6 +255,7 @@ class ExpenseClaimItemSyncMixin: claim, self._build_claim_attachment_risk_flags(ordered_items), ) + self._refresh_claim_platform_risk_preview_flags(claim) if str(claim.status or "").strip().lower() == "draft": claim.approval_stage = "待提交" @@ -359,15 +362,18 @@ class ExpenseClaimItemSyncMixin: analysis.get("label") or ("高风险" if severity == "high" else "中风险") ).strip() derived_flags.append( - { - "source": "attachment_analysis", - "item_id": item.id, - "severity": severity, - "label": label, - "message": f"费用明细第 {index} 条:{message_detail}", - "summary": summary, - "points": points, - } + with_risk_business_stage( + { + "source": "attachment_analysis", + "item_id": item.id, + "severity": severity, + "label": label, + "message": f"费用明细第 {index} 条:{message_detail}", + "summary": summary, + "points": points, + }, + "reimbursement", + ) ) return derived_flags @@ -412,6 +418,38 @@ class ExpenseClaimItemSyncMixin: ] return preserved_flags + attachment_risk_flags + def _refresh_claim_platform_risk_preview_flags(self, claim: ExpenseClaim) -> None: + if str(claim.expense_type or "").strip().lower().endswith("_application"): + return + evaluator = getattr(self, "evaluate_platform_risk_rules", None) + if not callable(evaluator): + return + try: + review = evaluator(claim, business_stage="reimbursement") + except Exception: + return + platform_flags = list(review.get("flags") or []) if isinstance(review, dict) else [] + claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags( + claim, + platform_flags, + ) + + @staticmethod + def _merge_claim_platform_risk_preview_flags( + claim: ExpenseClaim, + platform_flags: list[dict[str, Any]], + ) -> list[Any]: + preserved_flags = [ + flag + for flag in list(claim.risk_flags_json or []) + if not ( + isinstance(flag, dict) + and str(flag.get("source") or "").strip() == "submission_review" + and str(flag.get("hit_source") or "").strip() == "rule_center" + ) + ] + return preserved_flags + platform_flags + @staticmethod def _format_submission_blocked_message(issues: list[str]) -> str: normalized_issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()] diff --git a/server/src/app/services/expense_claim_pagination.py b/server/src/app/services/expense_claim_pagination.py index 2d75cf3..01b3792 100644 --- a/server/src/app/services/expense_claim_pagination.py +++ b/server/src/app/services/expense_claim_pagination.py @@ -29,7 +29,9 @@ class ExpenseClaimPaginationMixin: ExpenseClaim.occurred_at.desc(), ) stmt = self._access_policy.apply_claim_scope(stmt, current_user) - return paginate_select(self.db, stmt, page=page, page_size=page_size) + result = paginate_select(self.db, stmt, page=page, page_size=page_size) + self._access_policy.attach_budget_approval_snapshots(result.items) + return result def list_approval_claims_page( self, @@ -43,7 +45,9 @@ class ExpenseClaimPaginationMixin: ExpenseClaim.created_at.desc(), ) stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user) - return paginate_select(self.db, stmt, page=page, page_size=page_size) + result = paginate_select(self.db, stmt, page=page, page_size=page_size) + self._access_policy.attach_budget_approval_snapshots(result.items) + return result def list_archived_claims_page( self, diff --git a/server/src/app/services/expense_claim_platform_risk.py b/server/src/app/services/expense_claim_platform_risk.py index 72f1331..c526370 100644 --- a/server/src/app/services/expense_claim_platform_risk.py +++ b/server/src/app/services/expense_claim_platform_risk.py @@ -15,18 +15,27 @@ from app.services.expense_rule_runtime import ( RuntimeTravelPolicy, ) from app.services.expense_type_keywords import resolve_expense_type_code_from_text +from app.services.expense_claim_platform_risk_flag import build_platform_risk_flag from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor class ExpenseClaimPlatformRiskMixin: + _DEFAULT_RISK_BUSINESS_STAGE = "reimbursement" + _SUPPORTED_RISK_BUSINESS_STAGES = {"expense_application", "reimbursement"} + def evaluate_platform_risk_rules( self, claim: ExpenseClaim, *, rule_codes: list[str] | None = None, + business_stage: str | None = None, ) -> dict[str, list[Any]]: - manifests = self._load_platform_risk_rule_manifests(rule_codes=rule_codes) + normalized_stage = self._normalize_platform_risk_business_stage(business_stage) + manifests = self._load_platform_risk_rule_manifests( + rule_codes=rule_codes, + business_stage=normalized_stage, + ) if not manifests: return {"flags": [], "blocking_reasons": []} @@ -69,6 +78,7 @@ class ExpenseClaimPlatformRiskMixin: self, *, rule_codes: list[str] | None, + business_stage: str | None, ) -> list[dict[str, Any]]: code_filter = { str(code or "").strip() for code in list(rule_codes or []) if str(code or "").strip() @@ -117,7 +127,10 @@ class ExpenseClaimPlatformRiskMixin: manifest_code = str(payload.get("rule_code") or rule_code).strip() if not manifest_code or (code_filter and manifest_code not in code_filter): continue - if payload.get("enabled") is False: + if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage( + payload, + business_stage=business_stage, + ): continue payload = dict(payload) @@ -149,7 +162,10 @@ class ExpenseClaimPlatformRiskMixin: continue if code_filter and rule_code not in missing_codes: continue - if payload.get("enabled") is False: + if payload.get("enabled") is False or not self._risk_manifest_matches_business_stage( + payload, + business_stage=business_stage, + ): continue payload = dict(payload) payload["_rule_version"] = "v1.0.0" @@ -157,6 +173,34 @@ class ExpenseClaimPlatformRiskMixin: return list(manifests_by_code.values()) + @classmethod + def _normalize_platform_risk_business_stage(cls, value: str | None) -> str: + normalized = str(value or cls._DEFAULT_RISK_BUSINESS_STAGE).strip().lower() + if not normalized or normalized not in cls._SUPPORTED_RISK_BUSINESS_STAGES: + return cls._DEFAULT_RISK_BUSINESS_STAGE + return normalized + + @classmethod + def _risk_manifest_matches_business_stage( + cls, + manifest: dict[str, Any], + *, + business_stage: str | None, + ) -> bool: + if not business_stage: + return True + applies_to = manifest.get("applies_to") if isinstance(manifest.get("applies_to"), dict) else {} + raw_stages = applies_to.get("business_stages") + if not isinstance(raw_stages, list): + metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {} + raw_stages = [manifest.get("business_stage") or metadata.get("business_stage") or cls._DEFAULT_RISK_BUSINESS_STAGE] + stages = { + cls._normalize_platform_risk_business_stage(str(item)) + for item in raw_stages + if str(item or "").strip() + } + return business_stage in (stages or {cls._DEFAULT_RISK_BUSINESS_STAGE}) + def _risk_manifest_applies_to_claim( self, manifest: dict[str, Any], @@ -187,9 +231,19 @@ class ExpenseClaimPlatformRiskMixin: configured_expense_types = self._normalize_expense_type_values( *[str(value or "") for value in list(applies_to.get("expense_types") or [])] ) + configured_expense_categories = self._normalize_expense_type_values( + *[str(value or "") for value in list(applies_to.get("expense_categories") or [])] + ) + + if self._is_all_expense_scope(configured_expense_types): + configured_expense_types = set() + if self._is_all_expense_scope(configured_expense_categories): + configured_expense_categories = set() if configured_expense_types and not (expense_types & configured_expense_types): return False + if configured_expense_categories and not (expense_types & configured_expense_categories): + return False if domains and not self._risk_domains_match_claim( domains, expense_types=expense_types, @@ -207,11 +261,19 @@ class ExpenseClaimPlatformRiskMixin: if not raw: continue normalized.add(raw.lower()) + if raw in {"全部", "通用"}: + normalized.add("all") + if raw.lower().endswith("_application"): + normalized.add(raw.lower().removesuffix("_application")) resolved = resolve_expense_type_code_from_text(raw) if resolved: normalized.add(resolved) return normalized + @staticmethod + def _is_all_expense_scope(values: set[str]) -> bool: + return bool(values & {"all", "*", "overall", "general", "全部", "通用"}) + def _risk_domains_match_claim( self, domains: set[str], @@ -634,25 +696,12 @@ class ExpenseClaimPlatformRiskMixin: message: str, evidence: dict[str, Any], ) -> dict[str, Any]: - outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {} - fail_outcome = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {} - severity = str(fail_outcome.get("severity") or "medium").strip().lower() or "medium" - default_action = "block" if severity in {"high", "critical"} else "manual_review" - action = str(fail_outcome.get("action") or default_action).strip() - label = str(manifest.get("name") or manifest.get("rule_code") or "风险规则命中").strip() - - return { - "source": "submission_review", - "hit_source": "rule_center", - "rule_type": "risk", - "rule_code": str(manifest.get("rule_code") or "").strip(), - "rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(), - "severity": severity, - "action": action, - "label": label, - "message": message, - "evidence": evidence, - } + return build_platform_risk_flag( + manifest, + message=message, + evidence=evidence, + default_business_stage=self._DEFAULT_RISK_BUSINESS_STAGE, + ) @staticmethod def _count_values(values: list[str]) -> dict[str, int]: diff --git a/server/src/app/services/expense_claim_platform_risk_flag.py b/server/src/app/services/expense_claim_platform_risk_flag.py new file mode 100644 index 0000000..767c6db --- /dev/null +++ b/server/src/app/services/expense_claim_platform_risk_flag.py @@ -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, + ) diff --git a/server/src/app/services/expense_claim_policy_review.py b/server/src/app/services/expense_claim_policy_review.py index c00c532..b06c0d6 100644 --- a/server/src/app/services/expense_claim_policy_review.py +++ b/server/src/app/services/expense_claim_policy_review.py @@ -27,6 +27,7 @@ from app.services.expense_claim_constants import ( TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES, TRAVEL_POLICY_HOTEL_NIGHT_PATTERN, ) +from app.services.expense_claim_risk_stage import with_risk_business_stage from app.services.expense_rule_runtime import ( ExpenseRuleRuntimeService, RuntimeTravelPolicy, @@ -135,7 +136,7 @@ class ExpenseClaimPolicyReviewMixin: ) return { - "flags": flags, + "flags": [with_risk_business_stage(flag, "reimbursement") for flag in flags], "blocking_reasons": list(dict.fromkeys(reason for reason in blocking_reasons if reason)), } @@ -393,7 +394,7 @@ class ExpenseClaimPolicyReviewMixin: blocking_reasons.append("交通舱位或席别超出当前职级差标,且未补充例外说明。") return { - "flags": flags, + "flags": [with_risk_business_stage(flag, "reimbursement") for flag in flags], "blocking_reasons": list(dict.fromkeys(reason for reason in blocking_reasons if reason)), } diff --git a/server/src/app/services/expense_claim_pre_review.py b/server/src/app/services/expense_claim_pre_review.py new file mode 100644 index 0000000..49868d6 --- /dev/null +++ b/server/src/app/services/expense_claim_pre_review.py @@ -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] diff --git a/server/src/app/services/expense_claim_risk_review.py b/server/src/app/services/expense_claim_risk_review.py index 9359130..47360c6 100644 --- a/server/src/app/services/expense_claim_risk_review.py +++ b/server/src/app/services/expense_claim_risk_review.py @@ -15,6 +15,7 @@ from app.services.expense_claim_constants import ( from app.services.expense_claim_item_sync import ExpenseClaimItemSyncMixin from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin from app.services.expense_claim_policy_review import ExpenseClaimPolicyReviewMixin +from app.services.expense_claim_risk_stage import with_risk_business_stage from app.services.risk_observations import RiskObservationService logger = get_logger("app.services.expense_claim_risk_review") @@ -159,6 +160,8 @@ class ExpenseClaimRiskReviewMixin( }, ) + review_flags = [with_risk_business_stage(flag, "reimbursement") for flag in review_flags] + return { "status": "submitted", "approval_stage": "直属领导审批", diff --git a/server/src/app/services/expense_claim_risk_stage.py b/server/src/app/services/expense_claim_risk_stage.py new file mode 100644 index 0000000..51e0f80 --- /dev/null +++ b/server/src/app/services/expense_claim_risk_stage.py @@ -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) diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 8b70ca9..762ee16 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -36,6 +36,7 @@ from app.services.document_intelligence import build_document_insight from app.services.document_numbering import is_application_claim_no from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin +from app.services.expense_claim_approval_routing import ExpenseClaimApprovalRoutingMixin from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin @@ -49,8 +50,10 @@ from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin from app.services.expense_claim_draft_persistence import ExpenseClaimDraftPersistenceMixin from app.services.expense_claim_errors import ExpenseClaimSubmissionBlockedError from app.services.expense_claim_pagination import ExpenseClaimPaginationMixin +from app.services.expense_claim_pre_review import ExpenseClaimPreReviewMixin from app.services.expense_claim_ontology_resolvers import ExpenseClaimOntologyResolverMixin from app.services.expense_claim_read_model import ExpenseClaimReadModelMixin +from app.services.expense_claim_risk_stage import with_risk_business_stage from app.services.expense_claim_review_preview import ExpenseClaimReviewPreviewMixin from app.services.expense_claim_constants import ( EXPENSE_TYPE_LABELS, @@ -131,7 +134,9 @@ from app.services.ocr import OcrService class ExpenseClaimService( ExpenseClaimPaginationMixin, ExpenseClaimApprovalFlowMixin, + ExpenseClaimApprovalRoutingMixin, ExpenseClaimApplicationHandoffMixin, + ExpenseClaimPreReviewMixin, ExpenseClaimBudgetFlowMixin, ExpenseClaimAttachmentOperationsMixin, ExpenseClaimReviewPreviewMixin, @@ -197,7 +202,7 @@ class ExpenseClaimService( .order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc()) ) stmt = self._access_policy.apply_claim_scope(stmt, current_user) - return list(self.db.scalars(stmt).all()) + return self._access_policy.attach_budget_approval_snapshots(list(self.db.scalars(stmt).all())) def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]: stmt = ( @@ -210,7 +215,7 @@ class ExpenseClaimService( .order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc()) ) stmt = self._access_policy.apply_approval_claim_scope(stmt, current_user) - return list(self.db.scalars(stmt).all()) + return self._access_policy.attach_budget_approval_snapshots(list(self.db.scalars(stmt).all())) def list_archived_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]: stmt = ( @@ -236,7 +241,7 @@ class ExpenseClaimService( .where(ExpenseClaim.id == claim_id) ) stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True) - return self.db.scalar(stmt) + return self._access_policy.attach_budget_approval_snapshot(self.db.scalar(stmt)) def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool: if claim is None: @@ -468,27 +473,44 @@ class ExpenseClaimService( for flag in list(claim.risk_flags_json or []) if not ( isinstance(flag, dict) - and str(flag.get("source") or "").strip() in {"submission_review", "attachment_analysis"} + and str(flag.get("source") or "").strip() + in {"submission_review", "attachment_analysis"} ) ] - submit_flag = { - "source": "application_submission", - "event_type": "expense_application_submission", - "severity": "info", - "label": "申请提交", - "message": "费用申请已提交至直属领导审批,请等待审核结果。", - "previous_status": str(claim.status or "").strip(), - "previous_approval_stage": str(claim.approval_stage or "").strip(), - "next_status": "submitted", - "next_approval_stage": "直属领导审批", - "created_at": submitted_at.isoformat(), - } + platform_review = self.evaluate_platform_risk_rules( + claim, + business_stage="expense_application", + ) + platform_flags = list(platform_review.get("flags") or []) + submit_flag = with_risk_business_stage( + { + "source": "application_submission", + "event_type": "expense_application_submission", + "severity": "info", + "label": "申请提交", + "message": "费用申请已提交至直属领导审批,请等待审核结果。", + "previous_status": str(claim.status or "").strip(), + "previous_approval_stage": str(claim.approval_stage or "").strip(), + "next_status": "submitted", + "next_approval_stage": "直属领导审批", + "created_at": submitted_at.isoformat(), + }, + "expense_application", + ) claim.status = "submitted" claim.approval_stage = "直属领导审批" - claim.risk_flags_json = self._append_budget_flags([*preserved_flags, submit_flag], budget_flags) + claim.risk_flags_json = self._append_budget_flags( + [*preserved_flags, submit_flag, *platform_flags], + budget_flags, + business_stage="expense_application", + ) claim.submitted_at = submitted_at else: - claim.risk_flags_json = self._append_budget_flags(claim.risk_flags_json, budget_flags) + claim.risk_flags_json = self._append_budget_flags( + claim.risk_flags_json, + budget_flags, + business_stage="reimbursement", + ) review_result = self._run_ai_submission_review(claim) claim.status = str(review_result.get("status") or "supplement") @@ -681,6 +703,7 @@ class ExpenseClaimService( claim.risk_flags_json = self._append_budget_flags( [*list(claim.risk_flags_json or []), return_flag], budget_flags, + business_stage="expense_application" if is_application_claim else "reimbursement", ) self.db.commit() @@ -717,10 +740,6 @@ class ExpenseClaimService( - - - - diff --git a/server/src/app/services/hermes_risk_clue_collector.py b/server/src/app/services/hermes_risk_clue_collector.py new file mode 100644 index 0000000..cfed88a --- /dev/null +++ b/server/src/app/services/hermes_risk_clue_collector.py @@ -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() diff --git a/server/src/app/services/hermes_risk_scanner.py b/server/src/app/services/hermes_risk_scanner.py index 43dce92..4b3546a 100644 --- a/server/src/app/services/hermes_risk_scanner.py +++ b/server/src/app/services/hermes_risk_scanner.py @@ -13,6 +13,7 @@ from app.algorithem.risk_graph import ( from app.core.logging import get_logger from app.models.financial_record import ExpenseClaim from app.models.hermes_report import HermesRiskReport +from app.services.expense_claim_risk_stage import with_risk_business_stage from app.services.risk_observations import RiskObservationService logger = get_logger("app.services.hermes_risk_scanner") @@ -110,15 +111,18 @@ class HermesRiskScannerService: @staticmethod def _append_algorithm_flag(claim: ExpenseClaim, observation: dict) -> list: existing = list(claim.risk_flags_json or []) - flag = { - "source": "financial_risk_graph", - "risk_signal": observation.get("risk_signal"), - "severity": observation.get("risk_level"), - "risk_score": observation.get("risk_score"), - "confidence_score": observation.get("confidence_score"), - "algorithm_version": observation.get("algorithm_version"), - "observation_key": observation.get("observation_key"), - } + flag = with_risk_business_stage( + { + "source": "financial_risk_graph", + "risk_signal": observation.get("risk_signal"), + "severity": observation.get("risk_level"), + "risk_score": observation.get("risk_score"), + "confidence_score": observation.get("confidence_score"), + "algorithm_version": observation.get("algorithm_version"), + "observation_key": observation.get("observation_key"), + }, + "reimbursement", + ) if any( isinstance(item, dict) and item.get("observation_key") == flag["observation_key"] diff --git a/server/src/app/services/hermes_scheduler.py b/server/src/app/services/hermes_scheduler.py index f6f5e22..b36e627 100644 --- a/server/src/app/services/hermes_scheduler.py +++ b/server/src/app/services/hermes_scheduler.py @@ -10,6 +10,7 @@ from app.db.session import get_session_factory from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService from app.services.hermes_expense_report import HermesExpenseReportService +from app.services.hermes_risk_clue_collector import HermesRiskClueCollectorService from app.services.hermes_risk_scanner import HermesRiskScannerService logger = get_logger("app.services.hermes_scheduler") @@ -168,6 +169,14 @@ class HermesScheduler: f"生成 {summary.get('snapshot_count', 0)} 条快照," f"重点关注 {summary.get('high_attention_employee_count', 0)} 人。" ) + elif config.task_type == "risk_clue_collect": + collector = HermesRiskClueCollectorService(db) + summary = collector.collect_risk_clues(run_id=log_record.id) + log_record.result_summary = ( + f"风险线索归集完成:读取 {summary.get('fact_count', 0)} 条事实," + f"整理 {summary.get('rule_hit_count', 0)} 条规则命中," + f"输出 {summary.get('risk_clue_count', 0)} 条待复核线索。" + ) log_record.status = "success" log_record.completed_at = datetime.now(UTC) diff --git a/server/src/app/services/orchestrator.py b/server/src/app/services/orchestrator.py index 47b53e1..2d2d7b2 100644 --- a/server/src/app/services/orchestrator.py +++ b/server/src/app/services/orchestrator.py @@ -25,9 +25,11 @@ from app.schemas.orchestrator import ( from app.schemas.user_agent import UserAgentRequest from app.services.agent_assets import AgentAssetService from app.services.agent_conversations import AgentConversationService +from app.services.auth import AuthService from app.services.expense_claims import ExpenseClaimService from app.services.agent_foundation import AgentFoundationService from app.services.agent_runs import AgentRunService +from app.services.agent_traces import AgentTraceService from app.services.knowledge import KnowledgeService from app.services.ontology import SemanticOntologyService from app.services.orchestrator_execution import ExecutionOutcome, OrchestratorExecutionEngine @@ -57,6 +59,7 @@ class OrchestratorService: self.expense_claim_service = ExpenseClaimService(db) self.knowledge_service = KnowledgeService(db=db) self.run_service = AgentRunService(db) + self.trace_service = AgentTraceService(db) self.ontology_service = SemanticOntologyService(db) self.user_agent_service = UserAgentService(db) self.database_query_builder = OrchestratorDatabaseQueryBuilder(db) @@ -67,11 +70,15 @@ class OrchestratorService: knowledge_service=self.knowledge_service, user_agent_service=self.user_agent_service, database_query_builder=self.database_query_builder, + trace_service=self.trace_service, ) def run(self, payload: OrchestratorRequest) -> OrchestratorResponse: AgentFoundationService(self.db).ensure_foundation_ready() - context_json = dict(payload.context_json or {}) + context_json = self._hydrate_user_context( + user_id=payload.user_id, + context_json=dict(payload.context_json or {}), + ) conversation_id = str(payload.conversation_id or "").strip() or None conversation = None if payload.source == AgentRunSource.USER_MESSAGE.value: @@ -87,6 +94,9 @@ class OrchestratorService: context_json=context_json, message=payload.message, ) + context_json["conversation_id"] = conversation_id + elif conversation_id: + context_json["conversation_id"] = conversation_id route_json: dict[str, Any] = { "orchestrated_by": AgentName.ORCHESTRATOR.value, @@ -105,6 +115,25 @@ class OrchestratorService: status=AgentRunStatus.RUNNING.value, result_summary="Orchestrator 已接收请求。", ) + self._record_trace_event( + run_id=run.run_id, + conversation_id=conversation_id, + stage="orchestrator", + event_name="request_received", + title="接收用户请求", + summary=str(payload.message or payload.task_id or payload.source or "").strip(), + input_json={ + "source": payload.source, + "user_id": payload.user_id, + "conversation_id": conversation_id, + "task_id": payload.task_id, + "message": payload.message, + }, + output_json={ + "run_id": run.run_id, + "context_keys": sorted(context_json.keys()), + }, + ) try: message, task_asset = self._resolve_message(payload) @@ -120,6 +149,19 @@ class OrchestratorService: "ocr_summary": context_json.get("ocr_summary", ""), }, ) + self._record_trace_event( + run_id=run.run_id, + conversation_id=conversation.conversation_id, + stage="conversation", + event_name="conversation_hydrated", + title="会话上下文补全", + summary=f"会话 {conversation.conversation_id} 已写入用户消息。", + input_json={"message": message}, + output_json={ + "conversation_id": conversation.conversation_id, + "context_keys": sorted(context_json.keys()), + }, + ) ontology = self.ontology_service.parse_for_run( OntologyParseRequest( query=message, @@ -128,6 +170,16 @@ class OrchestratorService: ), run_id=run.run_id, ) + self._record_trace_event( + run_id=run.run_id, + conversation_id=conversation_id, + stage="semantic", + event_name="semantic_parsed", + title="语义识别完成", + summary=f"{ontology.scenario} / {ontology.intent}", + input_json={"query": message, "context_keys": sorted(context_json.keys())}, + output_json=self.execution_engine._build_ontology_json(ontology), + ) if context_json.get("simulate_orchestrator_exception"): raise RuntimeError("simulated orchestrator exception") selected_agent, route_reason = self._select_agent(payload, ontology) @@ -144,6 +196,25 @@ class OrchestratorService: and not is_expense_review_action and not is_expense_application_context ) + self._record_trace_event( + run_id=run.run_id, + conversation_id=conversation_id, + stage="route", + event_name="route_resolved", + title="路由与能力选择", + summary=route_reason, + input_json={ + "scenario": ontology.scenario, + "intent": ontology.intent, + "permission_level": ontology.permission.level, + }, + output_json={ + "selected_agent": selected_agent, + "route_reason": route_reason, + "selected_capability_codes": selected_capability_codes, + "requires_confirmation": requires_confirmation, + }, + ) route_json = { "orchestrated_by": AgentName.ORCHESTRATOR.value, @@ -337,6 +408,32 @@ class OrchestratorService: }, }, ) + self._record_trace_event( + run_id=run.run_id, + conversation_id=conversation_id, + stage="conversation", + event_name="conversation_updated", + title="会话状态写回", + summary="助手回复与会话状态已写回。", + input_json={"draft_payload_present": isinstance(draft_payload, dict)}, + output_json={"status": final_status, "message": result_message}, + ) + self._record_trace_event( + run_id=run.run_id, + conversation_id=conversation_id, + stage="response", + event_name="response_built", + title="生成最终回复", + status=final_status, + summary=result_message, + input_json={"outcome_status": outcome.status}, + output_json={ + "status": response_status, + "requires_confirmation": response_requires_confirmation, + "trace_summary": trace_summary.model_dump(), + "result": outcome.result, + }, + ) return OrchestratorResponse( run_id=run.run_id, conversation_id=conversation_id, @@ -350,6 +447,17 @@ class OrchestratorService: ) except Exception as exc: logger.exception("Orchestrator run failed run_id=%s", run.run_id) + self._record_trace_event( + run_id=run.run_id, + conversation_id=conversation_id, + stage="orchestrator", + event_name="failed", + title="Orchestrator 执行失败", + status="failed", + summary=str(exc), + output_json={"route_json": route_json}, + error_message=str(exc), + ) self.run_service.update_run( run.run_id, agent=AgentName.ORCHESTRATOR.value, @@ -394,6 +502,40 @@ class OrchestratorService: ), ) + def _record_trace_event(self, **kwargs: Any) -> None: + self.trace_service.record_event_safe(**kwargs) + + def _hydrate_user_context(self, user_id: str | None, context_json: dict[str, Any]) -> dict[str, Any]: + identifier = str(user_id or context_json.get("username") or context_json.get("email") or "").strip() + if not identifier: + return context_json + + snapshot = AuthService(self.db).get_user_snapshot(identifier) + if snapshot is None: + return context_json + + values = { + "name": snapshot.name, + "department": snapshot.department, + "department_name": snapshot.departmentName or snapshot.department, + "position": snapshot.position, + "grade": snapshot.grade, + "employee_no": snapshot.employeeNo, + "manager_name": snapshot.managerName, + "employee_location": snapshot.location, + "cost_center": snapshot.costCenter, + "finance_owner_name": snapshot.financeOwnerName, + "employee_risk_profile": snapshot.riskProfile, + "role_codes": snapshot.roleCodes, + "is_admin": snapshot.isAdmin, + } + + for key, value in values.items(): + if context_json.get(key) in (None, "", [], {}): + context_json[key] = value + + return context_json + def _resolve_message( self, payload: OrchestratorRequest, diff --git a/server/src/app/services/orchestrator_execution.py b/server/src/app/services/orchestrator_execution.py index b625e69..7a84c99 100644 --- a/server/src/app/services/orchestrator_execution.py +++ b/server/src/app/services/orchestrator_execution.py @@ -13,6 +13,7 @@ from app.schemas.ontology import OntologyParseResult from app.schemas.orchestrator import OrchestratorRequest from app.schemas.user_agent import UserAgentRequest, UserAgentResponse from app.services.hermes_employee_profile_scanner import HermesEmployeeProfileScannerService +from app.services.hermes_risk_clue_collector import HermesRiskClueCollectorService from app.services.hermes_risk_scanner import HermesRiskScannerService from app.services.knowledge_sync import KnowledgeSyncDispatchService @@ -36,6 +37,7 @@ class OrchestratorExecutionEngine: knowledge_service, user_agent_service, database_query_builder, + trace_service=None, ) -> None: self.db = db self.run_service = run_service @@ -43,6 +45,7 @@ class OrchestratorExecutionEngine: self.knowledge_service = knowledge_service self.user_agent_service = user_agent_service self.database_query_builder = database_query_builder + self.trace_service = trace_service def _execute_user_agent( self, @@ -383,6 +386,8 @@ class OrchestratorExecutionEngine: task_asset=task_asset, context_json=context_json, ) + if task_type == "risk_clue_collect": + return self._execute_risk_clue_collect(run_id=run_id, context_json=context_json) return None def _execute_risk_graph_scan(self, *, run_id: str, context_json: dict[str, Any]) -> ExecutionOutcome: @@ -502,6 +507,41 @@ class OrchestratorExecutionEngine: failed_tool_count=1 if degraded else 0, ) + def _execute_risk_clue_collect( + self, + *, + run_id: str, + context_json: dict[str, Any], + ) -> ExecutionOutcome: + summary, degraded = self._invoke_tool( + run_id=run_id, + tool_type=AgentToolType.DATABASE.value, + tool_name="digital_employee.risk_clue.collect", + request_json={"task_type": "risk_clue_collect"}, + context_json=context_json, + executor=lambda: HermesRiskClueCollectorService(self.db).collect_risk_clues( + run_id=run_id + ), + fallback_factory=lambda exc: { + "message": f"风险线索归集失败,已保留失败记录:{exc}", + "degraded": True, + }, + ) + message = ( + str(summary.get("message") or "").strip() + or "风险线索归集完成:" + f"读取 {summary.get('fact_count', 0)} 条事实," + f"整理 {summary.get('rule_hit_count', 0)} 条规则命中," + f"输出 {summary.get('risk_clue_count', 0)} 条待复核线索。" + ) + return ExecutionOutcome( + status=AgentRunStatus.SUCCEEDED.value, + result={"message": message, "report_type": "risk_clue_collect", "summary": summary, "degraded": degraded}, + degraded=degraded, + tool_count=1, + failed_tool_count=1 if degraded else 0, + ) + @staticmethod def _resolve_task_type(task_asset: AgentAssetRead | None) -> str: if task_asset is None: @@ -613,6 +653,11 @@ class OrchestratorExecutionEngine: status="succeeded", duration_ms=duration_ms, ) + if self.trace_service: + self.trace_service.record_tool_event_safe( + run_id, tool_type, tool_name, request_json, response, + "succeeded", duration_ms, context_json, + ) return response, False except Exception as exc: duration_ms = int((perf_counter() - started) * 1000) @@ -627,6 +672,11 @@ class OrchestratorExecutionEngine: duration_ms=duration_ms, error_message=str(exc), ) + if self.trace_service: + self.trace_service.record_tool_event_safe( + run_id, tool_type, tool_name, request_json, response, + "failed", duration_ms, context_json, str(exc), + ) return response, True @staticmethod diff --git a/server/src/app/services/risk_observations.py b/server/src/app/services/risk_observations.py index 392c287..3cb2635 100644 --- a/server/src/app/services/risk_observations.py +++ b/server/src/app/services/risk_observations.py @@ -15,6 +15,7 @@ from app.schemas.risk_observation import ( RiskObservationDashboardRead, RiskObservationFeedbackCreate, ) +from app.services.expense_claim_risk_stage import normalize_risk_business_stage HIGH_LEVELS = {"high", "critical"} SEVERITY_SCORE = { @@ -122,6 +123,7 @@ class RiskObservationService: severity = _normalize_level(flag.get("severity")) score = SEVERITY_SCORE.get(severity, SEVERITY_SCORE["medium"]) rule_code = _text(flag.get("rule_code")) + business_stage = normalize_risk_business_stage(flag.get("business_stage")) observation_key = ( f"risk:{claim.id}:platform:{rule_code or signal}" ) @@ -141,7 +143,7 @@ class RiskObservationService: "risk_score": score, "risk_level": severity, "confidence_score": "0.78", - "control_stage": "reimbursement", + "control_stage": business_stage, "control_mode": "risk_observation", "automation_mode": ( "semi_auto_review" @@ -333,6 +335,14 @@ class RiskObservationService: confirmed = sum(1 for item in observations if item.feedback_status == "confirmed") false_positive = sum(1 for item in observations if item.feedback_status == "false_positive") pending = sum(1 for item in observations if item.status == "pending_review") + feedback_samples = int( + self.db.scalar( + select(func.count()) + .select_from(RiskObservationFeedback) + .where(RiskObservationFeedback.created_at >= since) + ) + or 0 + ) high_or_above = sum(1 for item in observations if item.risk_level in HIGH_LEVELS) score_sum = sum(int(item.risk_score or 0) for item in observations) reviewed = confirmed + false_positive @@ -343,9 +353,11 @@ class RiskObservationService: window_days=window_days, total_observations=total, pending_count=pending, + risk_clue_count=pending, high_or_above_count=high_or_above, confirmed_count=confirmed, false_positive_count=false_positive, + feedback_sample_count=feedback_samples, total_amount=float(total_amount), average_score=round(score_sum / total, 2) if total else 0.0, level_distribution=_count_by(observations, "risk_level"), diff --git a/server/src/app/services/risk_rule_generation.py b/server/src/app/services/risk_rule_generation.py index 3471406..cd7fed3 100644 --- a/server/src/app/services/risk_rule_generation.py +++ b/server/src/app/services/risk_rule_generation.py @@ -13,6 +13,7 @@ from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest 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.expense_claim_risk_stage import infer_risk_domain from app.services.risk_rule_explainability import build_risk_rule_explainability_artifacts from app.services.risk_rule_generation_ontology import ( BUSINESS_DOMAIN_LABELS, @@ -423,6 +424,28 @@ class RiskRuleGenerationService: risk_level_label = str( risk_score_payload.get("level_label") or RISK_LEVEL_LABELS.get(risk_level, "风险") ) + semantic_risk_domain = infer_risk_domain( + { + "rule_code": rule_code, + "risk_category": risk_category, + "name": rule_title or draft.get("name"), + "description": self._clean_text(draft.get("description")) or natural_language, + } + ) + semantic_visibility_scope = ( + "budget_manager" + if semantic_risk_domain == "budget" + else "leader" + if business_stage == "expense_application" + else "submitter" + ) + semantic_actionability = ( + "budget_governance" + if semantic_risk_domain == "budget" + else "review_decision" + if business_stage == "expense_application" + else "fixable_by_submitter" + ) keywords = list(draft.get("keywords") or []) field_by_key = {item.key: item for item in fields} params: dict[str, Any] = { @@ -432,6 +455,9 @@ class RiskRuleGenerationService: "natural_language": natural_language, "business_stage": business_stage, "business_stage_label": business_stage_label, + "risk_domain": semantic_risk_domain, + "visibility_scope": semantic_visibility_scope, + "actionability": semantic_actionability, } semantic_type = str(draft.get("semantic_type") or "").strip() if semantic_type: @@ -508,6 +534,9 @@ class RiskRuleGenerationService: "risk_score": risk_score_value, "risk_level": risk_level, "risk_level_label": risk_level_label, + "risk_domain": semantic_risk_domain, + "visibility_scope": semantic_visibility_scope, + "actionability": semantic_actionability, "risk_score_model": risk_score_payload.get("model"), "risk_score_detail": risk_score_payload, "rule_title": rule_title, @@ -519,7 +548,11 @@ class RiskRuleGenerationService: "business_explanation": self._clean_text(draft.get("description")), "condition_summary": condition_summary, "rule_ir": draft.get("rule_ir") if isinstance(draft.get("rule_ir"), dict) else {}, - "model_semantic_plan": draft.get("model_semantic_plan") if isinstance(draft.get("model_semantic_plan"), dict) else {}, + "model_semantic_plan": ( + draft.get("model_semantic_plan") + if isinstance(draft.get("model_semantic_plan"), dict) + else {} + ), "flow": draft.get("flow") if isinstance(draft.get("flow"), dict) else {}, }, } diff --git a/server/src/app/services/risk_rule_generation_jobs.py b/server/src/app/services/risk_rule_generation_jobs.py index 8d7119a..a930b61 100644 --- a/server/src/app/services/risk_rule_generation_jobs.py +++ b/server/src/app/services/risk_rule_generation_jobs.py @@ -164,6 +164,11 @@ class RiskRuleGenerationJobService: "generation_status": AgentAssetStatus.FAILED.value, "generation_error": error_message[:1000], "generation_failed_at": datetime.now(UTC).isoformat(), + "last_operation": { + "action": "generation_failed", + "actor": actor, + "at": datetime.now(UTC).isoformat(), + }, } ) asset.status = AgentAssetStatus.FAILED.value diff --git a/server/src/app/services/risk_rule_generation_ontology.py b/server/src/app/services/risk_rule_generation_ontology.py index 71a5c88..d535a18 100644 --- a/server/src/app/services/risk_rule_generation_ontology.py +++ b/server/src/app/services/risk_rule_generation_ontology.py @@ -29,6 +29,7 @@ RISK_LEVEL_LABELS: dict[str, str] = { } EXPENSE_RISK_CATEGORY_CODES: tuple[str, ...] = ( + "all", "travel", "hotel", "transport", @@ -40,10 +41,22 @@ EXPENSE_RISK_CATEGORY_CODES: tuple[str, ...] = ( "welfare", ) EXPENSE_RISK_CATEGORY_LABELS: dict[str, str] = { - code: EXPENSE_TYPE_LABEL_BY_CODE[code] for code in EXPENSE_RISK_CATEGORY_CODES + "all": "全部", + **{ + code: EXPENSE_TYPE_LABEL_BY_CODE[code] + for code in EXPENSE_RISK_CATEGORY_CODES + if code != "all" + }, } EXPENSE_RISK_CATEGORY_ALIASES = { + "*": "all", + "overall": "all", + "general": "all", + "全部": "all", + "通用": "all", "entertainment": "meal", + "business_meal": "meal", + "purchase": "office", } EXPENSE_BUSINESS_STAGE_LABELS: dict[str, str] = { diff --git a/server/src/app/services/risk_rule_template_catalog.py b/server/src/app/services/risk_rule_template_catalog.py new file mode 100644 index 0000000..58b3150 --- /dev/null +++ b/server/src/app/services/risk_rule_template_catalog.py @@ -0,0 +1,495 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from app.core.agent_enums import AgentAssetDomain +from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY +from app.services.risk_rule_generation_ontology import ( + EXPENSE_BUSINESS_STAGE_LABELS, + EXPENSE_RISK_CATEGORY_LABELS, + FIELD_ONTOLOGY, +) + +TEMPLATE_GROUPS: tuple[dict[str, str | int], ...] = ( + {"group": "budget", "group_label": "预算", "order": 10}, + {"group": "invoice", "group_label": "票据", "order": 20}, + {"group": "travel", "group_label": "差旅", "order": 30}, + {"group": "entertainment", "group_label": "招待", "order": 40}, + {"group": "procurement_ap", "group_label": "采购/AP", "order": 50}, + {"group": "corporate_card", "group_label": "企业卡", "order": 60}, + {"group": "general", "group_label": "通用", "order": 70}, +) + +_GROUP_LABELS = {str(item["group"]): str(item["group_label"]) for item in TEMPLATE_GROUPS} +_FIELD_BY_KEY = {field.key: field for field in FIELD_ONTOLOGY} + + +def list_risk_rule_template_groups() -> list[dict[str, Any]]: + templates = [_build_template(item) for item in _TEMPLATE_DEFINITIONS] + groups: list[dict[str, Any]] = [] + for group in TEMPLATE_GROUPS: + group_code = str(group["group"]) + group_templates = [item for item in templates if item["group"] == group_code] + groups.append( + { + "group": group_code, + "group_label": str(group["group_label"]), + "order": int(group["order"]), + "templates": group_templates, + } + ) + return deepcopy(groups) + + +def list_risk_rule_templates() -> list[dict[str, Any]]: + return [ + template + for group in list_risk_rule_template_groups() + for template in group["templates"] + ] + + +def _build_template(definition: dict[str, Any]) -> dict[str, Any]: + field_keys = list(definition["field_keys"]) + business_stage = str(definition.get("business_stage") or "reimbursement") + expense_category = definition.get("expense_category") + group = str(definition["group"]) + return { + "template_id": definition["template_id"], + "group": group, + "group_label": _GROUP_LABELS[group], + "title": definition["title"], + "description": definition["description"], + "business_domain": str(definition.get("business_domain") or AgentAssetDomain.EXPENSE.value), + "business_stage": business_stage, + "business_stage_label": EXPENSE_BUSINESS_STAGE_LABELS.get(business_stage, "费用报销"), + "expense_category": expense_category, + "expense_category_label": EXPENSE_RISK_CATEGORY_LABELS.get(str(expense_category or ""), ""), + "requires_attachment": bool(definition.get("requires_attachment")), + "natural_language": definition["natural_language"], + "fields": _field_rows(field_keys), + "dsl_example": _manifest( + field_keys=field_keys, + conditions=definition["conditions"], + hit_logic=definition["hit_logic"], + summary=definition["summary"], + message=definition["message"], + semantic_type=definition["semantic_type"], + ), + } + + +def _field_rows(field_keys: list[str]) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for key in field_keys: + field = _FIELD_BY_KEY.get(key) + if field is None: + continue + rows.append( + { + "key": field.key, + "label": field.label, + "display": f"{field.label}[{field.key}]", + "source": field.source, + "type": field.field_type, + } + ) + return rows + + +def _manifest( + *, + field_keys: list[str], + conditions: list[dict[str, Any]], + hit_logic: dict[str, Any], + summary: str, + message: str, + semantic_type: str, +) -> dict[str, Any]: + return { + "template_key": COMPOSITE_RULE_TEMPLATE_KEY, + "params": { + "template_key": COMPOSITE_RULE_TEMPLATE_KEY, + "semantic_type": semantic_type, + "field_keys": field_keys, + "conditions": conditions, + "hit_logic": hit_logic, + "condition_summary": summary, + "message_template": message, + "keywords": [], + }, + } + + +_TEMPLATE_DEFINITIONS: tuple[dict[str, Any], ...] = ( + { + "template_id": "budget_available_balance", + "group": "budget", + "title": "费用申请预算余额校验", + "description": "申请金额超过预算可用余额时提示预算占用风险,适合费用申请阶段前置控制。", + "business_stage": "expense_application", + "expense_category": "all", + "requires_attachment": False, + "natural_language": ( + "费用申请时,先读取申请金额、预算可用余额、部门、费用类型和申请事由。" + "若申请金额超过当前可用预算余额,且申请事由中没有预算追加、专项审批或紧急事项说明," + "则标记为中风险,要求补充预算审批说明后再继续流转。" + ), + "field_keys": [ + "claim.amount", + "budget.remaining_amount", + "claim.department_name", + "item.item_type", + "claim.reason", + ], + "conditions": [ + { + "id": "amount_exceeds_available_budget", + "operator": "numeric_compare", + "left_fields": ["claim.amount"], + "right_fields": ["budget.remaining_amount"], + "compare": "gt", + }, + { + "id": "missing_budget_exception", + "operator": "not_contains_any", + "fields": ["claim.reason"], + "keywords": ["预算追加", "专项审批", "紧急事项", "预算调整"], + }, + ], + "hit_logic": {"all": ["amount_exceeds_available_budget", "missing_budget_exception"]}, + "summary": "申请金额大于预算可用余额,且缺少预算追加或专项审批说明时命中。", + "message": "申请金额超过预算可用余额,需补充预算审批说明。", + "semantic_type": "budget_available_balance_check", + }, + { + "template_id": "duplicate_invoice_number", + "group": "invoice", + "title": "重复发票号码校验", + "description": "识别同一发票号码在本次提交中重复出现的报销风险。", + "business_stage": "reimbursement", + "expense_category": "office", + "requires_attachment": True, + "natural_language": ( + "费用报销时,先确认已上传发票或票据附件,再读取附件识别出的发票号码、明细附件编号和报销事由。" + "若同一发票号码或同一明细附件编号在本次提交中重复出现,且报销事由没有说明拆单、补票或更正提交原因," + "则标记为高风险,要求删除重复票据或补充说明。" + ), + "field_keys": ["attachment.invoice_no", "item.invoice_id", "claim.reason"], + "conditions": [ + { + "id": "invoice_evidence_present", + "operator": "exists_any", + "fields": ["attachment.invoice_no", "item.invoice_id"], + }, + { + "id": "same_invoice_no_repeated", + "operator": "duplicate_value", + "fields": ["attachment.invoice_no", "item.invoice_id"], + }, + { + "id": "missing_duplicate_reason", + "operator": "not_contains_any", + "fields": ["claim.reason"], + "keywords": ["拆单", "补票", "更正", "冲红"], + }, + ], + "hit_logic": { + "all": ["invoice_evidence_present", "same_invoice_no_repeated", "missing_duplicate_reason"] + }, + "summary": "发票号码或明细附件编号重复,且缺少拆单、补票或更正说明时命中。", + "message": "存在重复发票或重复附件编号,需删除重复票据或补充合理说明。", + "semantic_type": "duplicate_invoice_check", + }, + { + "template_id": "travel_city_route_consistency", + "group": "travel", + "title": "差旅票据城市一致性校验", + "description": "比对交通票、住宿票据城市与申报目的地,识别跨城、绕行或目的地不一致风险。", + "business_stage": "reimbursement", + "expense_category": "travel", + "requires_attachment": True, + "natural_language": ( + "差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;" + "再读取员工常驻地、申报目的地、明细发生地点、交通票行程城市、住宿发票城市和报销事由。" + "若交通票或住宿票据中的城市无法与申报目的地、明细地点形成一致关系," + "或票据路线中出现申报目的地与员工常驻地之外的额外中转城市," + "且报销事由中没有说明绕行、跨城办事或临时改签原因," + "则标记为高风险,要求补充行程说明或退回修改。" + ), + "field_keys": [ + "employee.location", + "claim.location", + "item.item_location", + "attachment.route_cities", + "attachment.hotel_city", + "claim.reason", + ], + "conditions": [ + { + "id": "attachment_city_evidence_present", + "operator": "exists_any", + "fields": ["attachment.route_cities", "attachment.hotel_city"], + }, + { + "id": "city_outside_business_scope", + "operator": "not_in_scope", + "left_fields": ["attachment.route_cities", "attachment.hotel_city"], + "right_fields": ["claim.location", "item.item_location", "employee.location"], + }, + { + "id": "missing_route_exception", + "operator": "not_contains_any", + "fields": ["claim.reason"], + "keywords": ["绕行", "跨城办事", "临时改签", "临时任务"], + }, + ], + "hit_logic": { + "all": [ + "attachment_city_evidence_present", + "city_outside_business_scope", + "missing_route_exception", + ] + }, + "summary": "票据城市集合与申报行程集合无交集或出现额外中转城市,且缺少合理例外说明时命中。", + "message": "票据城市与申报行程不一致,需补充绕行、跨城或改签说明。", + "semantic_type": "travel_route_city_consistency", + }, + { + "template_id": "travel_lodging_date_range", + "group": "travel", + "title": "差旅住宿日期范围校验", + "description": "校验住宿票据日期是否落在差旅开始和结束日期范围内。", + "business_stage": "reimbursement", + "expense_category": "travel", + "requires_attachment": True, + "natural_language": ( + "差旅住宿报销时,先确认已上传住宿发票或酒店水单;" + "再读取报销事由、申报目的地、住宿城市、住宿开始日期、住宿结束日期、出差开始日期和出差结束日期。" + "若住宿发生时间早于出差开始或晚于出差结束,且没有延期、改签、临时任务等说明," + "则标记为高风险,要求补充住宿原因、行程证明或重新提交票据。" + ), + "field_keys": [ + "attachment.stay_start_date", + "attachment.stay_end_date", + "claim.trip_start_date", + "claim.trip_end_date", + "attachment.hotel_city", + "claim.location", + "claim.reason", + ], + "conditions": [ + { + "id": "lodging_date_evidence_present", + "operator": "exists_any", + "fields": ["attachment.stay_start_date", "attachment.stay_end_date"], + }, + { + "id": "lodging_date_outside_trip_range", + "operator": "date_outside_range", + "date_fields": ["attachment.stay_start_date", "attachment.stay_end_date"], + "range_start_fields": ["claim.trip_start_date"], + "range_end_fields": ["claim.trip_end_date"], + }, + { + "id": "missing_lodging_exception", + "operator": "not_contains_any", + "fields": ["claim.reason"], + "keywords": ["延期", "改签", "临时任务", "行程变更"], + }, + ], + "hit_logic": { + "all": [ + "lodging_date_evidence_present", + "lodging_date_outside_trip_range", + "missing_lodging_exception", + ] + }, + "summary": "住宿日期不在差旅日期范围内,且缺少延期、改签或临时任务说明时命中。", + "message": "住宿日期超出差旅行程范围,需补充行程证明或重新提交票据。", + "semantic_type": "lodging_date_range_consistency", + }, + { + "template_id": "entertainment_per_capita_limit", + "group": "entertainment", + "title": "招待人均金额超标校验", + "description": "按参与人数计算人均招待金额,超过标准且缺少说明时提示风险。", + "business_stage": "reimbursement", + "expense_category": "meal", + "requires_attachment": True, + "natural_language": ( + "业务招待报销时,读取申报总金额、参与人数、人均金额、报销事由和附件票据。" + "若人均金额超过公司招待标准 500 元,且事由中没有高级审批、重要客户接待或专项审批说明," + "则标记为中风险,要求补充招待对象和审批依据。" + ), + "field_keys": [ + "claim.amount", + "claim.attendee_count", + "claim.per_capita_amount", + "claim.reason", + "attachment.invoice_no", + ], + "conditions": [ + { + "id": "per_capita_amount_exceeds_limit", + "operator": "numeric_compare", + "left_fields": ["claim.per_capita_amount"], + "threshold": 500, + "compare": "gt", + }, + { + "id": "missing_special_approval_reason", + "operator": "not_contains_any", + "fields": ["claim.reason"], + "keywords": ["高级审批", "重要客户", "专项审批", "特殊接待"], + }, + ], + "hit_logic": { + "all": ["per_capita_amount_exceeds_limit", "missing_special_approval_reason"] + }, + "summary": "人均金额大于招待标准阈值,且缺少合理审批说明时命中。", + "message": "业务招待人均金额超过公司标准,需补充审批依据。", + "semantic_type": "entertainment_per_capita_limit_check", + }, + { + "template_id": "procurement_goods_category_mismatch", + "group": "procurement_ap", + "title": "采购票据品名与费用类型一致性校验", + "description": "检查发票商品服务名称是否与费用类型、采购用途一致。", + "business_stage": "reimbursement", + "expense_category": "office", + "requires_attachment": True, + "natural_language": ( + "采购类费用报销时,先确认已上传发票或采购票据;" + "再读取商品服务名称、费用类型、明细事由、申报金额和报销事由。" + "若发票商品服务名称与费用类型或明细事由无法形成一致关系," + "且报销事由没有说明代采、项目采购或费用归集原因," + "则标记为中风险,要求补充采购用途或更换正确费用类型。" + ), + "field_keys": [ + "attachment.goods_name", + "item.item_type", + "item.item_reason", + "claim.amount", + "claim.reason", + ], + "conditions": [ + { + "id": "goods_evidence_present", + "operator": "exists_any", + "fields": ["attachment.goods_name"], + }, + { + "id": "goods_outside_expense_scope", + "operator": "not_in_scope", + "left_fields": ["attachment.goods_name"], + "right_fields": ["item.item_type", "item.item_reason"], + }, + { + "id": "missing_procurement_exception", + "operator": "not_contains_any", + "fields": ["claim.reason"], + "keywords": ["代采", "项目采购", "费用归集", "统一采购"], + }, + ], + "hit_logic": { + "all": [ + "goods_evidence_present", + "goods_outside_expense_scope", + "missing_procurement_exception", + ] + }, + "summary": "发票品名与费用类型或明细事由不一致,且缺少采购归集说明时命中。", + "message": "采购票据品名与费用类型不一致,需补充采购用途说明。", + "semantic_type": "procurement_goods_category_consistency", + }, + { + "template_id": "corporate_card_date_consistency", + "group": "corporate_card", + "title": "企业卡交易日期一致性校验", + "description": "用于企业卡消费日期与费用明细日期不一致的仿真规则。", + "business_stage": "reimbursement", + "expense_category": "travel", + "requires_attachment": True, + "natural_language": ( + "企业卡费用报销时,读取企业卡交易日期、费用明细发生日期、开票日期、申报金额和报销事由。" + "若企业卡交易日期或开票日期明显不在明细发生日期对应的业务期间内," + "且报销事由没有说明补录、跨月结算或集中开票原因," + "则标记为低风险,提示补充交易说明。" + ), + "field_keys": [ + "attachment.issue_date", + "item.item_date", + "claim.amount", + "claim.reason", + ], + "conditions": [ + { + "id": "card_date_evidence_present", + "operator": "exists_any", + "fields": ["attachment.issue_date", "item.item_date"], + }, + { + "id": "issue_date_outside_item_date", + "operator": "date_outside_range", + "date_fields": ["attachment.issue_date"], + "range_start_fields": ["item.item_date"], + "range_end_fields": ["item.item_date"], + "tolerance_days": 7, + }, + { + "id": "missing_card_exception", + "operator": "not_contains_any", + "fields": ["claim.reason"], + "keywords": ["补录", "跨月结算", "集中开票", "统一结算"], + }, + ], + "hit_logic": { + "all": ["card_date_evidence_present", "issue_date_outside_item_date", "missing_card_exception"] + }, + "summary": "开票日期偏离明细发生日期超过容忍范围,且缺少企业卡结算说明时命中。", + "message": "企业卡交易或开票日期与业务发生日期不一致,需补充说明。", + "semantic_type": "corporate_card_date_consistency", + }, + { + "template_id": "general_missing_business_reason", + "group": "general", + "title": "报销事由完整性校验", + "description": "识别事由过于笼统、缺少业务对象或用途说明的低风险提示。", + "business_stage": "reimbursement", + "expense_category": "all", + "requires_attachment": False, + "natural_language": ( + "费用报销时,读取报销事由、费用类型、申报金额、部门和明细事由。" + "若报销事由没有包含项目、客户、会议、用途或审批等业务背景信息," + "且申报金额高于 300 元,则标记为低风险,提示补充业务背景说明。" + ), + "field_keys": [ + "claim.reason", + "item.item_reason", + "item.item_type", + "claim.amount", + "claim.department_name", + ], + "conditions": [ + { + "id": "amount_needs_reason_context", + "operator": "numeric_compare", + "left_fields": ["claim.amount"], + "threshold": 300, + "compare": "gt", + }, + { + "id": "missing_business_context", + "operator": "not_contains_any", + "fields": ["claim.reason", "item.item_reason"], + "keywords": ["项目", "客户", "会议", "用途", "审批"], + }, + ], + "hit_logic": {"all": ["amount_needs_reason_context", "missing_business_context"]}, + "summary": "申报金额超过低额阈值,且事由缺少业务背景关键词时命中。", + "message": "报销事由缺少业务背景,请补充用途或审批说明。", + "semantic_type": "general_business_reason_completeness", + }, +) diff --git a/server/src/app/services/risk_rule_template_executor.py b/server/src/app/services/risk_rule_template_executor.py index bd66b31..c298d02 100644 --- a/server/src/app/services/risk_rule_template_executor.py +++ b/server/src/app/services/risk_rule_template_executor.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from calendar import monthrange from datetime import date, datetime, timedelta from typing import Any @@ -506,8 +507,8 @@ class RiskRuleTemplateExecutor: for key in field_keys: for value in self._resolve_values(key, claim=claim, contexts=contexts): parsed = self._parse_date_value(value) - if parsed and parsed not in values: - values.append(parsed) + if parsed and parsed not in values: + values.append(parsed) return values def _resolve_group_numbers( @@ -695,6 +696,9 @@ class RiskRuleTemplateExecutor: @staticmethod def _claim_trip_date(claim: ExpenseClaim, *, start: bool) -> date | datetime | None: + application_date = RiskRuleTemplateExecutor._claim_application_trip_date(claim, start=start) + if application_date is not None: + return application_date item_dates = [ item.item_date for item in list(claim.items or []) @@ -704,6 +708,166 @@ class RiskRuleTemplateExecutor: return min(item_dates) if start else max(item_dates) return getattr(claim, "occurred_at", None) + @staticmethod + def _claim_application_trip_date(claim: ExpenseClaim, *, start: bool) -> date | None: + windows: list[tuple[date, date]] = [] + reference_year = RiskRuleTemplateExecutor._claim_reference_year(claim) + for raw_value in RiskRuleTemplateExecutor._iter_application_time_values(claim): + windows.extend( + RiskRuleTemplateExecutor._parse_date_windows( + raw_value, + reference_year=reference_year, + ) + ) + if not windows: + return None + values = [window[0] if start else window[1] for window in windows] + return min(values) if start else max(values) + + @staticmethod + def _claim_reference_year(claim: ExpenseClaim) -> int | None: + for value in [getattr(claim, "occurred_at", None)]: + parsed = RiskRuleTemplateExecutor._parse_date_value(value) + if parsed is not None: + return parsed.year + for item in list(claim.items or []): + parsed = RiskRuleTemplateExecutor._parse_date_value(getattr(item, "item_date", None)) + if parsed is not None: + return parsed.year + return None + + @staticmethod + def _iter_application_time_values(claim: ExpenseClaim) -> list[Any]: + values: list[Any] = [] + application_sources = {"application_detail", "application_handoff", "application_link"} + time_keys = ( + "application_time", + "applicationTime", + "application_date", + "applicationDate", + "business_time", + "businessTime", + "time_range", + "timeRange", + "time", + "date", + ) + nested_keys = ( + "application_detail", + "applicationDetail", + "review_form_values", + "reviewFormValues", + "expense_scene_selection", + "expenseSceneSelection", + ) + for flag in list(getattr(claim, "risk_flags_json", None) or []): + if not isinstance(flag, dict): + continue + source = str(flag.get("source") or "").strip() + has_application_anchor = ( + source in application_sources + or any(key in flag for key in ("application_claim_no", "applicationClaimNo")) + or any(isinstance(flag.get(key), dict) for key in ("application_detail", "applicationDetail")) + ) + if not has_application_anchor: + continue + sources: list[dict[str, Any]] = [flag] + for key in nested_keys: + nested = flag.get(key) + if isinstance(nested, dict): + sources.append(nested) + for source_dict in sources: + for key in time_keys: + value = source_dict.get(key) + if value not in (None, ""): + values.append(value) + return values + + @staticmethod + def _parse_date_windows( + value: Any, + *, + reference_year: int | None = None, + ) -> list[tuple[date, date]]: + if isinstance(value, datetime): + item = value.date() + return [(item, item)] + if isinstance(value, date): + return [(value, value)] + + text = str(value or "").strip() + if not text: + return [] + + exact_dates = RiskRuleTemplateExecutor._parse_exact_dates( + text, + reference_year=reference_year, + ) + if exact_dates: + return [(min(exact_dates), max(exact_dates))] + + month_windows = RiskRuleTemplateExecutor._parse_month_windows( + text, + reference_year=reference_year, + ) + if month_windows: + return month_windows + return [] + + @staticmethod + def _parse_exact_dates(text: str, *, reference_year: int | None = None) -> list[date]: + values: list[date] = [] + + def append_date(year: int, month: int, day: int) -> None: + try: + parsed = date(year, month, day) + except ValueError: + return + if parsed not in values: + values.append(parsed) + + for pattern in ( + r"(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})", + r"(\d{4})年(\d{1,2})月(\d{1,2})日?", + ): + for match in re.finditer(pattern, text): + year, month, day = (int(part) for part in match.groups()) + append_date(year, month, day) + + if reference_year is not None: + for match in re.finditer(r"(? list[tuple[date, date]]: + windows: list[tuple[date, date]] = [] + + def append_month(year: int, month: int) -> None: + if month < 1 or month > 12: + return + last_day = monthrange(year, month)[1] + window = (date(year, month, 1), date(year, month, last_day)) + if window not in windows: + windows.append(window) + + for match in re.finditer(r"(\d{4})[-/.](\d{1,2})(?![-/.]\d)", text): + year, month = (int(part) for part in match.groups()) + append_month(year, month) + for match in re.finditer(r"(\d{4})年(\d{1,2})月(?!\d)", text): + year, month = (int(part) for part in match.groups()) + append_month(year, month) + if reference_year is not None: + for match in re.finditer(r"(? bool: if operator == "is_empty": diff --git a/server/src/app/services/user_agent_application.py b/server/src/app/services/user_agent_application.py index 3746ada..0654188 100644 --- a/server/src/app/services/user_agent_application.py +++ b/server/src/app/services/user_agent_application.py @@ -15,12 +15,14 @@ from app.schemas.user_agent import ( UserAgentSuggestedAction, ) from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy +from app.services.expense_claim_risk_stage import with_risk_business_stage from app.services.document_numbering import ( build_document_number, generate_unique_expense_claim_no, ) from app.services.user_agent_application_dates import expand_application_time_with_days from app.services.user_agent_application_locations import normalize_application_location +from app.services.application_system_estimate import apply_application_system_estimate_to_facts APPLICATION_CONTEXT_VALUES = { "application", @@ -152,7 +154,7 @@ class UserAgentApplicationMixin: "我已按「费用申请 / 事前审批」来处理这条内容。", "已识别信息:\n" + recognized_table, f"当前还需要补充:{missing_text}。", - "请一次性补齐上述字段,我会继续生成模拟申请结果并让你确认是否提交。", + "请一次性补齐上述字段,我会继续生成申请核对结果并让你确认是否提交。", ] ) @@ -170,7 +172,7 @@ class UserAgentApplicationMixin: return "\n\n".join( [ - "这是模拟的费用申请结果,请核对:", + "这是费用申请核对结果,请核对:", self._build_application_summary_table(facts), "请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。", ] @@ -185,7 +187,11 @@ class UserAgentApplicationMixin: "transport_mode": "", "amount": "", "application_type": "", + "applicant": "", "grade": "", + "department": "", + "position": "", + "manager_name": "", "lodging_daily_cap": "", "subsidy_daily_cap": "", "transport_policy": "", @@ -193,6 +199,12 @@ class UserAgentApplicationMixin: "matched_city": "", "rule_name": "", "rule_version": "", + "hotel_amount": "", + "allowance_amount": "", + "transport_estimated_amount": "", + "transport_estimate_source": "", + "transport_estimate_confidence": "", + "policy_total_amount": "", } for message, is_current in self._iter_application_user_messages(payload): partial = { @@ -212,6 +224,41 @@ class UserAgentApplicationMixin: if value: facts[key] = value + context_json = payload.context_json or {} + current_user = getattr(payload, "current_user", None) + if not facts["applicant"]: + facts["applicant"] = str( + context_json.get("name") + or context_json.get("user_name") + or context_json.get("applicant") + or getattr(current_user, "name", "") + or "" + ).strip() + if not facts["department"]: + facts["department"] = str( + context_json.get("department") + or context_json.get("department_name") + or context_json.get("departmentName") + or getattr(current_user, "department_name", "") + or "" + ).strip() + if not facts["position"]: + facts["position"] = str( + context_json.get("position") + or context_json.get("employee_position") + or context_json.get("employeePosition") + or "" + ).strip() + if not facts["manager_name"]: + facts["manager_name"] = str( + context_json.get("manager_name") + or context_json.get("managerName") + or context_json.get("direct_manager_name") + or context_json.get("directManagerName") + or getattr(current_user, "manager_name", "") + or "" + ).strip() + if not facts["application_type"]: facts["application_type"] = self._infer_application_type(facts) facts["time"] = self._expand_application_time_with_days( @@ -219,6 +266,7 @@ class UserAgentApplicationMixin: facts.get("days", ""), payload.context_json or {}, ) + apply_application_system_estimate_to_facts(facts) return facts @staticmethod @@ -245,7 +293,11 @@ class UserAgentApplicationMixin: "days": pick("days"), "transport_mode": pick("transportMode", "transport_mode"), "amount": pick("amount"), + "applicant": pick("applicant", "name", "userName", "user_name"), "grade": pick("grade"), + "department": pick("department", "departmentName", "department_name"), + "position": pick("position", "employeePosition", "employee_position"), + "manager_name": pick("managerName", "manager_name", "directManagerName", "direct_manager_name"), "lodging_daily_cap": pick("lodgingDailyCap", "lodging_daily_cap"), "subsidy_daily_cap": pick("subsidyDailyCap", "subsidy_daily_cap"), "transport_policy": pick("transportPolicy", "transport_policy"), @@ -253,6 +305,12 @@ class UserAgentApplicationMixin: "matched_city": pick("matchedCity", "matched_city"), "rule_name": pick("ruleName", "rule_name"), "rule_version": pick("ruleVersion", "rule_version"), + "hotel_amount": pick("hotelAmount", "hotel_amount"), + "allowance_amount": pick("allowanceAmount", "allowance_amount"), + "transport_estimated_amount": pick("transportEstimatedAmount", "transport_estimated_amount"), + "transport_estimate_source": pick("transportEstimateSource", "transport_estimate_source"), + "transport_estimate_confidence": pick("transportEstimateConfidence", "transport_estimate_confidence"), + "policy_total_amount": pick("policyTotalAmount", "policy_total_amount"), } def _resolve_expense_application_step( @@ -294,7 +352,7 @@ class UserAgentApplicationMixin: def _resolve_application_missing_followup_fields(facts: dict[str, str]) -> list[str]: return [ field - for field in ("transport_mode", "amount") + for field in ("transport_mode",) if not str(facts.get(field) or "").strip() ] @@ -558,7 +616,7 @@ class UserAgentApplicationMixin: def _display_application_slot_label(slot: str) -> str: return { "expense_type": "申请类型", - "amount": "用户预估费用", + "amount": "系统预估费用", "time_range": "发生时间", "time": "发生时间", "location": "地点", @@ -603,7 +661,7 @@ class UserAgentApplicationMixin: "reason": ("补充申请事由", "事由:"), "days": ("补充天数", "天数:"), "transport_mode": ("补充出行方式", "出行方式:"), - "amount": ("补充预估费用", "用户预估费用:"), + "amount": ("补充系统预估费用", "系统预估费用:"), } return config.get(field, ("补充申请信息", "")) @@ -646,17 +704,21 @@ class UserAgentApplicationMixin: f"{label}:{value or '待补充'}" for label, value in ( ("申请类型", facts.get("application_type", "")), + ("姓名", facts.get("applicant", "")), + ("部门", facts.get("department", "")), + ("岗位", facts.get("position", "")), + ("职级", facts.get("grade", "")), + ("直属领导", facts.get("manager_name", "")), ("发生时间", facts.get("time", "")), ("地点", facts.get("location", "")), ("事由", facts.get("reason", "")), ("天数", facts.get("days", "")), ("出行方式", facts.get("transport_mode", "")), - ("职级", facts.get("grade", "")), ("住宿上限/天", facts.get("lodging_daily_cap", "")), ("补贴标准/天", facts.get("subsidy_daily_cap", "")), ("交通费用口径", facts.get("transport_policy", "")), ("规则测算参考", facts.get("policy_estimate", "")), - ("用户预估费用", facts.get("amount", "")), + ("系统预估费用", facts.get("amount", "")), ) ) @@ -668,17 +730,21 @@ class UserAgentApplicationMixin: ) -> str: rows = [ ("申请类型", facts.get("application_type", "")), + ("姓名", facts.get("applicant", "")), + ("部门", facts.get("department", "")), + ("岗位", facts.get("position", "")), + ("职级", facts.get("grade", "")), + ("直属领导", facts.get("manager_name", "")), ("发生时间", facts.get("time", "")), ("地点", facts.get("location", "")), ("事由", facts.get("reason", "")), ("天数", facts.get("days", "")), ("出行方式", facts.get("transport_mode", "")), - ("职级", facts.get("grade", "")), ("住宿上限/天", facts.get("lodging_daily_cap", "")), ("补贴标准/天", facts.get("subsidy_daily_cap", "")), ("交通费用口径", facts.get("transport_policy", "")), ("规则测算参考", facts.get("policy_estimate", "")), - ("用户预估费用", facts.get("amount", "")), + ("系统预估费用", facts.get("amount", "")), ] visible_rows = rows if include_empty else [(label, value) for label, value in rows if str(value or "").strip()] if not visible_rows: @@ -736,34 +802,53 @@ class UserAgentApplicationMixin: risk_flags_json=[self._build_application_detail_flag(facts)], ) self.db.add(claim) + self.db.flush() + from app.services.expense_claims import ExpenseClaimService + + platform_review = ExpenseClaimService(self.db).evaluate_platform_risk_rules( + claim, + business_stage="expense_application", + ) + platform_flags = list(platform_review.get("flags") or []) + if platform_flags: + claim.risk_flags_json = [*list(claim.risk_flags_json or []), *platform_flags] self.db.commit() self.db.refresh(claim) return claim @staticmethod def _build_application_detail_flag(facts: dict[str, str]) -> dict[str, object]: - return { - "source": "application_detail", - "severity": "info", - "label": "申请详情", - "application_detail": { - "application_type": str(facts.get("application_type") or "").strip(), - "time": str(facts.get("time") or "").strip(), - "location": str(facts.get("location") or "").strip(), - "reason": str(facts.get("reason") or "").strip(), - "days": str(facts.get("days") or "").strip(), - "transport_mode": str(facts.get("transport_mode") or "").strip(), - "amount": str(facts.get("amount") or "").strip(), - "grade": str(facts.get("grade") or "").strip(), - "lodging_daily_cap": str(facts.get("lodging_daily_cap") or "").strip(), - "subsidy_daily_cap": str(facts.get("subsidy_daily_cap") or "").strip(), - "transport_policy": str(facts.get("transport_policy") or "").strip(), - "policy_estimate": str(facts.get("policy_estimate") or "").strip(), - "matched_city": str(facts.get("matched_city") or "").strip(), - "rule_name": str(facts.get("rule_name") or "").strip(), - "rule_version": str(facts.get("rule_version") or "").strip(), + return with_risk_business_stage( + { + "source": "application_detail", + "severity": "info", + "label": "申请详情", + "application_detail": { + "application_type": str(facts.get("application_type") or "").strip(), + "time": str(facts.get("time") or "").strip(), + "location": str(facts.get("location") or "").strip(), + "reason": str(facts.get("reason") or "").strip(), + "days": str(facts.get("days") or "").strip(), + "transport_mode": str(facts.get("transport_mode") or "").strip(), + "amount": str(facts.get("amount") or "").strip(), + "grade": str(facts.get("grade") or "").strip(), + "lodging_daily_cap": str(facts.get("lodging_daily_cap") or "").strip(), + "subsidy_daily_cap": str(facts.get("subsidy_daily_cap") or "").strip(), + "transport_policy": str(facts.get("transport_policy") or "").strip(), + "policy_estimate": str(facts.get("policy_estimate") or "").strip(), + "matched_city": str(facts.get("matched_city") or "").strip(), + "rule_name": str(facts.get("rule_name") or "").strip(), + "rule_version": str(facts.get("rule_version") or "").strip(), + "hotel_amount": str(facts.get("hotel_amount") or "").strip(), + "allowance_amount": str(facts.get("allowance_amount") or "").strip(), + "transport_estimated_amount": str(facts.get("transport_estimated_amount") or "").strip(), + "transport_estimate_source": str(facts.get("transport_estimate_source") or "").strip(), + "transport_estimate_confidence": str(facts.get("transport_estimate_confidence") or "").strip(), + "policy_total_amount": str(facts.get("policy_total_amount") or "").strip(), + }, }, - } + "expense_application", + ) def _resolve_application_manager_name( self, @@ -810,6 +895,13 @@ class UserAgentApplicationMixin: or context_json.get("departmentName") or "" ).strip(), + manager_name=str( + context_json.get("manager_name") + or context_json.get("managerName") + or context_json.get("direct_manager_name") + or context_json.get("directManagerName") + or "" + ).strip(), ) @staticmethod diff --git a/server/src/app/services/user_agent_response.py b/server/src/app/services/user_agent_response.py index a315f73..db8b204 100644 --- a/server/src/app/services/user_agent_response.py +++ b/server/src/app/services/user_agent_response.py @@ -558,9 +558,16 @@ class UserAgentResponseMixin: payload.ontology, query_text=payload.message, ) - review = ExpenseClaimService(self.db).evaluate_platform_risk_rules( + claim_service = ExpenseClaimService(self.db) + business_stage = ( + "expense_application" + if claim_service._is_expense_application_claim(claim) + else "reimbursement" + ) + review = claim_service.evaluate_platform_risk_rules( claim, rule_codes=rule_codes, + business_stage=business_stage, ) messages: list[str] = [] for flag in review.get("flags") or []: diff --git a/server/src/app/skills/domain/budget-overrun-precontrol-evaluator/SKILL.md b/server/src/app/skills/domain/budget-overrun-precontrol-evaluator/SKILL.md new file mode 100644 index 0000000..3f6d56f --- /dev/null +++ b/server/src/app/skills/domain/budget-overrun-precontrol-evaluator/SKILL.md @@ -0,0 +1,33 @@ +--- +name: budget-overrun-precontrol-evaluator +description: 用于评估预算占用、费用标准和柔性控制边界,输出超标预警。 +--- + +# 预算占用与超标预警 + +## 功能说明 + +结合预算快照、费用标准、部门基线和员工画像,识别预算占用异常、标准超限和柔性控制触发场景。 + +## 适用场景 + +- 报销提交前预警。 +- 审批时解释预算占用风险。 +- 月度预算风险巡检。 + +## 输入 + +- 报销单。 +- 预算快照。 +- 制度条款。 +- 部门和费用类型基线。 + +## 输出 + +- 预算占用预警。 +- 超标原因。 +- 建议补充材料或审批动作。 + +## 边界 + +该技能输出预警建议,不直接冻结预算。 diff --git a/server/src/app/skills/domain/department-expense-baseline-accumulator/SKILL.md b/server/src/app/skills/domain/department-expense-baseline-accumulator/SKILL.md new file mode 100644 index 0000000..38fd493 --- /dev/null +++ b/server/src/app/skills/domain/department-expense-baseline-accumulator/SKILL.md @@ -0,0 +1,33 @@ +--- +name: department-expense-baseline-accumulator +description: 用于沉淀部门、费用类型和时间窗口下的费用基线,为预算柔性控制和同类对比提供参照。 +--- + +# 部门费用基线沉淀 + +## 功能说明 + +按部门、费用类型、时间窗口和员工职级沉淀费用基线,输出可用于风险图谱、预算预警和画像分析的基线快照。 + +## 适用场景 + +- 周期性更新部门费用水平。 +- 审批时对比同部门同类费用。 +- 预算柔性控制需要历史参照。 + +## 输入 + +- 报销单。 +- 费用明细。 +- 员工部门和职级。 +- 历史画像基线。 + +## 输出 + +- 部门费用总额、均值、中位数、P75、P90。 +- 费用类型分布。 +- 样本数量和降级原因。 + +## 边界 + +样本不足时只输出低置信基线,不触发强风险结论。 diff --git a/server/src/app/skills/domain/expense-policy-alignment/SKILL.md b/server/src/app/skills/domain/expense-policy-alignment/SKILL.md new file mode 100644 index 0000000..4a4a6f4 --- /dev/null +++ b/server/src/app/skills/domain/expense-policy-alignment/SKILL.md @@ -0,0 +1,33 @@ +--- +name: expense-policy-alignment +description: 用于对齐报销政策、规则中心和知识库口径,发现同义、冲突、缺失和过期条款。 +--- + +# 报销政策口径对齐 + +## 功能说明 + +对比不同来源的报销政策口径,识别同义条款、冲突条款、缺失字段和过期制度,输出可复核的政策对齐报告。 + +## 适用场景 + +- 制度变更后检查规则中心是否同步。 +- 知识库存在多个版本政策时做口径合并。 +- 审批争议中追溯当前有效口径。 + +## 输入 + +- 财务制度条款。 +- 风险规则模板。 +- 知识库政策片段。 +- 历史风险观察引用。 + +## 输出 + +- 口径一致项。 +- 冲突项和缺失项。 +- 建议更新的规则或知识条目。 + +## 边界 + +该技能输出建议,不直接覆盖制度或规则。 diff --git a/server/src/app/skills/domain/false-positive-sample-accumulator/SKILL.md b/server/src/app/skills/domain/false-positive-sample-accumulator/SKILL.md new file mode 100644 index 0000000..abade75 --- /dev/null +++ b/server/src/app/skills/domain/false-positive-sample-accumulator/SKILL.md @@ -0,0 +1,33 @@ +--- +name: false-positive-sample-accumulator +description: 用于沉淀历史误报、忽略和撤销的风险观察样本,支撑算法回放与规则调优。 +--- + +# 历史误报样本沉淀 + +## 功能说明 + +归集被人工标记为误报、忽略、撤销或无需处理的风险观察,形成可回放的误报样本池。 + +## 适用场景 + +- 算法升级前评估误报率。 +- 规则阈值调优。 +- 识别高误报风险信号。 + +## 输入 + +- 风险观察。 +- 人工反馈。 +- 决策追踪。 +- 本体和规则版本。 + +## 输出 + +- 误报样本清单。 +- 高误报规则和风险信号排行。 +- 建议降低权重或调整阈值的规则。 + +## 边界 + +该技能不自动修改规则权重。 diff --git a/server/src/app/skills/domain/finance-policy-clause-extractor/SKILL.md b/server/src/app/skills/domain/finance-policy-clause-extractor/SKILL.md new file mode 100644 index 0000000..cd31c2d --- /dev/null +++ b/server/src/app/skills/domain/finance-policy-clause-extractor/SKILL.md @@ -0,0 +1,32 @@ +--- +name: finance-policy-clause-extractor +description: 用于从公司财务制度中抽取可结构化引用的制度条款、适用范围、金额标准和审批要求。 +--- + +# 制度条款结构化抽取 + +## 功能说明 + +从财务制度、报销政策和知识库文档中抽取结构化条款,形成可被风险观察、规则模板和单据详情引用的制度证据。 + +## 适用场景 + +- 新制度入库后自动抽取条款。 +- 制度内容更新后刷新条款版本。 +- 风险规则需要引用制度依据时补充 `policy_refs`。 + +## 输入 + +- 财务制度文档。 +- 知识库条目。 +- 本体解析结果。 + +## 输出 + +- 条款编号、标题、适用范围、限制条件、金额标准。 +- 审批要求、必备凭证、例外口径。 +- 可进入风险观察的 `risk_policy_refs`。 + +## 边界 + +该技能只整理条款,不直接上线风险规则。 diff --git a/server/src/app/skills/domain/financial-risk-graph-scanner/SKILL.md b/server/src/app/skills/domain/financial-risk-graph-scanner/SKILL.md index cb1a604..7f4949d 100644 --- a/server/src/app/skills/domain/financial-risk-graph-scanner/SKILL.md +++ b/server/src/app/skills/domain/financial-risk-graph-scanner/SKILL.md @@ -39,7 +39,7 @@ description: 用于财务风险图谱巡检,把单据、票据、审批链、 - `risk_observations`:风险观察列表,包含风险类型、等级、置信度和证据。 - `graph_evidence`:局部图谱节点、边、来源和本体映射。 - `decision_trace`:规则命中、画像偏离、图谱评分和降级路径。 -- `next_actions`:需要人工复核、补充制度或转候选规则的建议。 +- `next_actions`:需要人工复核、补充制度或沉淀复核样本的建议。 ## 执行约束 diff --git a/server/src/app/skills/domain/multi-evidence-consistency-evaluator/SKILL.md b/server/src/app/skills/domain/multi-evidence-consistency-evaluator/SKILL.md new file mode 100644 index 0000000..db8f5fc --- /dev/null +++ b/server/src/app/skills/domain/multi-evidence-consistency-evaluator/SKILL.md @@ -0,0 +1,32 @@ +--- +name: multi-evidence-consistency-evaluator +description: 用于评估报销单、明细、发票、流水、合同和事前申请之间的多凭证一致性。 +--- + +# 单据多凭证一致性评估 + +## 功能说明 + +比对报销单、费用明细、发票、流水、合同、附件和事前申请之间的金额、数量、主体、时间和地点字段。 + +## 适用场景 + +- 报销提交后自动校验。 +- 审批前补充证据链。 +- 风险图谱巡检时生成多凭证风险信号。 + +## 输入 + +- 报销单和费用明细。 +- 发票、附件、流水和合同。 +- 事前申请和审批记录。 + +## 输出 + +- 一致性检查结果。 +- 不一致字段和证据来源。 +- 风险观察候选。 + +## 边界 + +证据不足时只输出不确定性原因,不生成强拦截结论。 diff --git a/server/src/app/skills/domain/policy-gap-rule-optimizer/SKILL.md b/server/src/app/skills/domain/policy-gap-rule-optimizer/SKILL.md new file mode 100644 index 0000000..bceb1e8 --- /dev/null +++ b/server/src/app/skills/domain/policy-gap-rule-optimizer/SKILL.md @@ -0,0 +1,35 @@ +--- +name: policy-gap-rule-optimizer +description: 兼容别名。用于提示申请和报销事实缺少制度引用的位置,不输出规则变更建议。 +--- + +# 制度引用缺口提示 + +## 功能说明 + +整理申请、报销、规则命中和人工反馈中缺少制度引用或证据来源的事实位置,提示管理员补齐制度依据。 + +## 适用场景 + +- 月度风控复盘。 +- 制度修订前准备材料。 +- 规则命中结果缺少制度引用时补充人工复核材料。 + +## 输入 + +- 制度条款引用。 +- 申请和报销事实。 +- 已确认规则命中结果。 +- 风险观察和人工反馈样本。 + +## 输出 + +- `facts`:缺少制度引用的事实位置。 +- `rule_hits`:相关规则命中结果。 +- `evidence_refs`:已有制度、单据、附件和审批来源。 +- `policy_reference_gaps`:待人工确认的制度引用缺口。 +- `human_review_required`:必须为 `true`。 + +## 边界 + +该技能不总结风险规则,不输出规则变更建议,不直接修改制度文本或发布规则。制度修订和规则调整必须由管理员完成。 diff --git a/server/src/app/skills/domain/policy-reference-gap-hinter/SKILL.md b/server/src/app/skills/domain/policy-reference-gap-hinter/SKILL.md new file mode 100644 index 0000000..0c66fec --- /dev/null +++ b/server/src/app/skills/domain/policy-reference-gap-hinter/SKILL.md @@ -0,0 +1,35 @@ +--- +name: policy-reference-gap-hinter +description: 用于提示申请和报销事实缺少制度引用的位置,不输出规则变更建议。 +--- + +# 制度引用缺口提示 + +## 功能说明 + +整理申请、报销、规则命中和人工反馈中缺少制度引用或证据来源的事实位置,提示管理员补齐制度依据。 + +## 适用场景 + +- 月度风控复盘。 +- 制度修订前准备材料。 +- 规则命中结果缺少制度引用时补充人工复核材料。 + +## 输入 + +- 制度条款引用。 +- 申请和报销事实。 +- 已确认规则命中结果。 +- 风险观察和人工反馈样本。 + +## 输出 + +- `facts`:缺少制度引用的事实位置。 +- `rule_hits`:相关规则命中结果。 +- `evidence_refs`:已有制度、单据、附件和审批来源。 +- `policy_reference_gaps`:待人工确认的制度引用缺口。 +- `human_review_required`:必须为 `true`。 + +## 边界 + +该技能不总结风险规则,不输出规则变更建议,不直接修改制度文本或发布规则。制度修订和规则调整必须由管理员完成。 diff --git a/server/src/app/skills/domain/risk-algorithm-replay-evaluator/SKILL.md b/server/src/app/skills/domain/risk-algorithm-replay-evaluator/SKILL.md new file mode 100644 index 0000000..bb9b73f --- /dev/null +++ b/server/src/app/skills/domain/risk-algorithm-replay-evaluator/SKILL.md @@ -0,0 +1,33 @@ +--- +name: risk-algorithm-replay-evaluator +description: 用于复跑历史风险观察和反馈样本,评估算法、规则和本体版本升级效果。 +--- + +# 风险算法回放评测 + +## 功能说明 + +基于历史风险观察、人工反馈、本体版本、规则版本和算法版本构建回放评测,比较升级前后的确认率、误报率和风险覆盖。 + +## 适用场景 + +- 风险算法升级前验收。 +- 规则模板调整后复测。 +- 误报率异常时定位原因。 + +## 输入 + +- 算法回放集。 +- 风险观察。 +- 人工反馈标签。 +- 规则、本体和算法版本。 + +## 输出 + +- 回放评测报告。 +- 确认率、误报率和覆盖变化。 +- 风险信号级别的升级建议。 + +## 边界 + +该技能只做评测,不直接上线新算法。 diff --git a/server/src/app/skills/domain/risk-clue-collector/SKILL.md b/server/src/app/skills/domain/risk-clue-collector/SKILL.md new file mode 100644 index 0000000..0fcb9b7 --- /dev/null +++ b/server/src/app/skills/domain/risk-clue-collector/SKILL.md @@ -0,0 +1,35 @@ +--- +name: risk-clue-collector +description: 用于归集申请和报销事实中的潜在线索,不生成规则、不发布规则、不替代人工确认。 +--- + +# 风险线索归集 + +## 技能类型 + +- 当前类型:升级 +- 类型范围:积累、升级、整理、评估 + +## 工作目标 + +- 读取申请单、报销单、票据、审批记录、规则命中和人工反馈。 +- 抽取金额、时间、地点、人员、供应商、票据号、申请关系等客观事实。 +- 读取外层智能体流程已经产生的规则命中结果,整理命中依据和原始证据。 +- 输出待人工复核的潜在线索,不生成规则,不修改规则中心。 + +## 输出要求 + +- `facts`:从申请和报销材料抽取出的事实。 +- `rule_hits`:外层智能体流程或规则中心执行后产生的命中结果和字段依据。 +- `risk_clues`:待人工复核的潜在线索。 +- `evidence_refs`:关联单据、附件、审批记录和规则命中来源。 +- `human_review_required`:必须为 `true`。 + +## 执行约束 + +- 不生成候选规则。 +- 不总结或改写风险规则。 +- 不发布、删除或覆盖正式规则。 +- 不触发规则主流程,不推动审批或报销流程。 +- 不把线索判定为最终违规结论。 +- 没有事实和证据来源的线索不得输出。 diff --git a/server/src/app/skills/domain/risk-feedback-sample-accumulator/SKILL.md b/server/src/app/skills/domain/risk-feedback-sample-accumulator/SKILL.md new file mode 100644 index 0000000..38a3aae --- /dev/null +++ b/server/src/app/skills/domain/risk-feedback-sample-accumulator/SKILL.md @@ -0,0 +1,32 @@ +--- +name: risk-feedback-sample-accumulator +description: 用于沉淀确认、补件、升级、改写和人工复核来源等风险观察反馈样本。 +--- + +# 风险观察反馈样本沉淀 + +## 功能说明 + +把人工确认、误报、补件、升级审批、复核来源和处理备注沉淀为反馈样本池。 + +## 适用场景 + +- 构建线索复核和算法评测输入。 +- 训练或评估风险排序策略。 +- 分析人工处理闭环质量。 + +## 输入 + +- 风险观察。 +- 风险观察反馈。 +- AgentRun 和工具调用记录。 + +## 输出 + +- 反馈样本池。 +- 标签分布。 +- 高价值人工复核来源。 + +## 边界 + +反馈样本只作为训练和评测资产,不直接改变线上结论。 diff --git a/server/src/app/skills/domain/risk-rule-discovery/SKILL.md b/server/src/app/skills/domain/risk-rule-discovery/SKILL.md index c508b13..165e724 100644 --- a/server/src/app/skills/domain/risk-rule-discovery/SKILL.md +++ b/server/src/app/skills/domain/risk-rule-discovery/SKILL.md @@ -1,9 +1,9 @@ --- name: risk-rule-discovery -description: 用于根据风险观察、人工反馈和回放评测结果生成候选风险规则,不直接上线。 +description: 兼容别名。用于归集申请和报销事实中的潜在线索,不生成规则、不发布规则、不替代人工确认。 --- -# 风险规则候选发现 +# 风险线索归集 ## 技能类型 @@ -12,23 +12,24 @@ description: 用于根据风险观察、人工反馈和回放评测结果生成 ## 工作目标 -- 读取风险观察、人工反馈、误报复盘和算法回放结果。 -- 识别可以沉淀为规则候选的稳定风险模式。 -- 输出候选规则,不直接上线,不修改正式规则中心。 -- 每条候选规则必须包含证据、来源、置信度和待复核状态。 +- 读取申请单、报销单、票据、审批记录、规则命中和人工反馈。 +- 抽取金额、时间、地点、人员、供应商、票据号、申请关系等客观事实。 +- 读取外层智能体流程已经产生的规则命中结果,整理命中依据和原始证据。 +- 输出待人工复核的潜在线索,不生成规则,不修改规则中心。 ## 输出要求 -- `candidate_rules`:候选规则列表。 -- `evidence`:关联风险观察、反馈、单据和制度引用。 -- `source`:候选来源,例如 `risk_observation_feedback`。 -- `confidence_score`:候选置信度。 -- `status`:固定为 `candidate_review` 或同等待复核状态。 -- `auto_publish`:必须为 `false`。 +- `facts`:从申请和报销材料抽取出的事实。 +- `rule_hits`:外层智能体流程或规则中心执行后产生的命中结果和字段依据。 +- `risk_clues`:待人工复核的潜在线索。 +- `evidence_refs`:关联单据、附件、审批记录和规则命中来源。 +- `human_review_required`:必须为 `true`。 ## 执行约束 -- 不直接发布规则。 -- 不删除或覆盖正式规则。 -- 没有证据来源的候选不得输出。 -- 低置信度候选只能进入人工复核队列。 +- 不生成候选规则。 +- 不总结或改写风险规则。 +- 不发布、删除或覆盖正式规则。 +- 不触发规则主流程,不推动审批或报销流程。 +- 不把线索判定为最终违规结论。 +- 没有事实和证据来源的线索不得输出。 diff --git a/server/src/app/skills/domain/risk-rule-template-organizer/SKILL.md b/server/src/app/skills/domain/risk-rule-template-organizer/SKILL.md new file mode 100644 index 0000000..efa6055 --- /dev/null +++ b/server/src/app/skills/domain/risk-rule-template-organizer/SKILL.md @@ -0,0 +1,35 @@ +--- +name: risk-rule-template-organizer +description: 兼容别名。用于把外层流程产生的规则命中和历史样本整理为复核材料,不新增、不改写、不发布规则。 +--- + +# 规则命中样本整理 + +## 功能说明 + +把外层智能体流程已经产生的规则命中、制度引用、历史样本和人工反馈整理为字段映射、证据引用和复核材料,帮助管理员复核命中质量。 + +## 适用场景 + +- 新增费用场景前整理规则命中样本。 +- 制度条款变更后检查命中字段是否仍然匹配。 +- 规则中心人工维护规则后补齐测试样本。 + +## 输入 + +- 人工确认的规则中心规则。 +- 制度引用和字段口径。 +- 外层智能体流程产生的历史规则命中记录。 +- 人工反馈样本。 + +## 输出 + +- `facts`:规则命中涉及的字段事实。 +- `rule_hits`:历史命中样本和字段依据。 +- `evidence_refs`:制度引用、单据和附件来源。 +- `hit_review_cases`:可复核的命中样本材料。 +- `human_review_required`:必须为 `true`。 + +## 边界 + +该技能不触发规则主流程,不新增、不总结、不改写规则,不直接发布规则。规则内容仍由管理员在规则中心维护。 diff --git a/server/src/app/skills/domain/rule-execution-case-organizer/SKILL.md b/server/src/app/skills/domain/rule-execution-case-organizer/SKILL.md new file mode 100644 index 0000000..80fc154 --- /dev/null +++ b/server/src/app/skills/domain/rule-execution-case-organizer/SKILL.md @@ -0,0 +1,35 @@ +--- +name: rule-execution-case-organizer +description: 用于把外层流程产生的规则命中和历史样本整理为复核材料,不新增、不改写、不发布规则。 +--- + +# 规则命中样本整理 + +## 功能说明 + +把外层智能体流程已经产生的规则命中、制度引用、历史样本和人工反馈整理为字段映射、证据引用和复核材料,帮助管理员复核命中质量。 + +## 适用场景 + +- 新增费用场景前整理规则命中样本。 +- 制度条款变更后检查命中字段是否仍然匹配。 +- 规则中心人工维护规则后补齐测试样本。 + +## 输入 + +- 人工确认的规则中心规则。 +- 制度引用和字段口径。 +- 外层智能体流程产生的历史规则命中记录。 +- 人工反馈样本。 + +## 输出 + +- `facts`:规则命中涉及的字段事实。 +- `rule_hits`:历史命中样本和字段依据。 +- `evidence_refs`:制度引用、单据和附件来源。 +- `hit_review_cases`:可复核的命中样本材料。 +- `human_review_required`:必须为 `true`。 + +## 边界 + +该技能不触发规则主流程,不新增、不总结、不改写规则,不直接发布规则。规则内容仍由管理员在规则中心维护。 diff --git a/server/src/app/skills/domain/supplier-abnormal-relation-evaluator/SKILL.md b/server/src/app/skills/domain/supplier-abnormal-relation-evaluator/SKILL.md new file mode 100644 index 0000000..7ea4fd2 --- /dev/null +++ b/server/src/app/skills/domain/supplier-abnormal-relation-evaluator/SKILL.md @@ -0,0 +1,33 @@ +--- +name: supplier-abnormal-relation-evaluator +description: 用于评估员工、部门、供应商、票据和报销单之间的异常关系。 +--- + +# 供应商异常关系评估 + +## 功能说明 + +识别员工、部门、供应商、票据和报销单之间的异常聚集、重复关系、跨部门集中和供应商高风险邻域。 + +## 适用场景 + +- 供应商风险巡检。 +- 重复票据和异常报销关系分析。 +- 风险看板供应商维度下钻。 + +## 输入 + +- 风险图谱节点和边。 +- 报销单和发票。 +- 供应商画像。 +- 实体标准化结果。 + +## 输出 + +- 异常关系报告。 +- 高风险供应商邻域。 +- 关联单据和证据链。 + +## 边界 + +该技能不直接做供应商准入或禁用决策。 diff --git a/server/src/app/skills/domain/supplier-risk-profile-accumulator/SKILL.md b/server/src/app/skills/domain/supplier-risk-profile-accumulator/SKILL.md new file mode 100644 index 0000000..0cb243a --- /dev/null +++ b/server/src/app/skills/domain/supplier-risk-profile-accumulator/SKILL.md @@ -0,0 +1,33 @@ +--- +name: supplier-risk-profile-accumulator +description: 用于沉淀供应商、商户、酒店和收款方的风险画像与费用关系基线。 +--- + +# 供应商风险画像沉淀 + +## 功能说明 + +围绕供应商、商户、酒店和收款方沉淀费用频次、金额分布、关联员工、关联部门、历史风险和人工反馈。 + +## 适用场景 + +- 周期性生成供应商画像。 +- 供应商异常聚集分析。 +- 重复票据和异常关系复盘。 + +## 输入 + +- 报销单和费用明细。 +- 发票和供应商实体。 +- 风险观察和反馈。 +- 实体标准化结果。 + +## 输出 + +- 供应商画像快照。 +- 金额和频次基线。 +- 关联员工、部门和风险信号摘要。 + +## 边界 + +该技能只沉淀画像,不直接判定供应商黑名单。 diff --git a/server/src/app/skills/domain/travel-spatiotemporal-consistency-evaluator/SKILL.md b/server/src/app/skills/domain/travel-spatiotemporal-consistency-evaluator/SKILL.md new file mode 100644 index 0000000..7bb945c --- /dev/null +++ b/server/src/app/skills/domain/travel-spatiotemporal-consistency-evaluator/SKILL.md @@ -0,0 +1,33 @@ +--- +name: travel-spatiotemporal-consistency-evaluator +description: 用于评估差旅时间、地点、行程、消费和开票关系是否一致。 +--- + +# 差旅时空一致性评估 + +## 功能说明 + +围绕差旅发生时间、提交时间、费用明细日期、消费地点、票据地点、开票地点和行程轨迹构造时空一致性风险信号。 + +## 适用场景 + +- 差旅报销自动审核。 +- 跨日期、跨城市和异常地点复核。 +- 风险图谱中的时空异常解释。 + +## 输入 + +- 差旅报销单。 +- 费用明细。 +- 发票地点和时间。 +- 行程或订单信息。 + +## 输出 + +- 时空一致性报告。 +- 异常时间和地点证据。 +- 风险观察候选。 + +## 边界 + +该技能不要求外部地图服务,第一版基于系统内结构化字段判断。 diff --git a/server/storage/expense_claims/6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.pdf.meta.json b/server/storage/expense_claims/6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.pdf.meta.json deleted file mode 100644 index 9c75808..0000000 --- a/server/storage/expense_claims/6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.pdf.meta.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "file_name": "2月20_武汉-上海.pdf", - "storage_key": "6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.pdf", - "media_type": "application/pdf", - "size_bytes": 24995, - "uploaded_at": "2026-05-30T07:00:40.483034+00:00", - "previewable": true, - "preview_kind": "image", - "preview_storage_key": "6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.preview.png", - "preview_media_type": "image/png", - "preview_file_name": "2月20_武汉-上海.preview.png", - "analysis": { - "severity": "pass", - "label": "AI提示符合条件", - "headline": "AI提示:附件符合基础校验条件", - "summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。", - "points": [ - "票据类型:已识别为火车/高铁票。", - "附件类型要求:当前费用项目为火车票,已识别为火车/高铁票。", - "金额字段:已识别到与当前明细接近的金额 354.00 元。" - ], - "rule_basis": [], - "suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。" - }, - "document_info": { - "document_type": "train_ticket", - "document_type_label": "火车/高铁票", - "scene_code": "travel", - "scene_label": "差旅票据", - "fields": [ - { - "key": "amount", - "label": "金额", - "value": "354元" - }, - { - "key": "date", - "label": "列车出发时间", - "value": "2026-02-20 07:55" - }, - { - "key": "merchant_name", - "label": "商户", - "value": "中国铁路" - }, - { - "key": "invoice_number", - "label": "票据号码", - "value": "26429165800002785705" - }, - { - "key": "route", - "label": "行程", - "value": "武汉-上海" - } - ] - }, - "requirement_check": { - "matches": true, - "current_expense_type": "train_ticket", - "current_expense_type_label": "火车票", - "allowed_scene_labels": [], - "allowed_document_type_labels": [], - "recognized_scene_code": "travel", - "recognized_scene_label": "差旅票据", - "recognized_document_type": "train_ticket", - "recognized_document_type_label": "火车/高铁票", - "mismatch_severity": "high", - "rule_code": "rule.expense.scene_submission_standard", - "rule_name": "报销场景提交与附件标准", - "message": "当前费用项目为火车票,已识别为火车/高铁票。" - }, - "ocr_status": "recognized", - "ocr_error": "", - "ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", - "ocr_summary": "电子发票;(铁路电子客票);州", - "ocr_avg_score": 0.9580968717734019, - "ocr_line_count": 24, - "ocr_classification_source": "rule", - "ocr_classification_confidence": 0.88, - "ocr_classification_evidence": [ - "铁路电子客票", - "电子客票", - "铁路", - "二等座" - ], - "ocr_warnings": [] -} \ No newline at end of file diff --git a/server/storage/expense_claims/6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.pdf b/server/storage/receipt_folder/caoxiaozhu_xf.com/057901b1-d38a-4e0c-9d53-44d14244317e/2月20_武汉-上海.pdf similarity index 100% rename from server/storage/expense_claims/6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.pdf rename to server/storage/receipt_folder/caoxiaozhu_xf.com/057901b1-d38a-4e0c-9d53-44d14244317e/2月20_武汉-上海.pdf diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/057901b1-d38a-4e0c-9d53-44d14244317e/meta.json b/server/storage/receipt_folder/caoxiaozhu_xf.com/057901b1-d38a-4e0c-9d53-44d14244317e/meta.json new file mode 100644 index 0000000..fffd433 --- /dev/null +++ b/server/storage/receipt_folder/caoxiaozhu_xf.com/057901b1-d38a-4e0c-9d53-44d14244317e/meta.json @@ -0,0 +1,121 @@ +{ + "id": "057901b1-d38a-4e0c-9d53-44d14244317e", + "owner_key": "caoxiaozhu_xf.com", + "file_name": "2月20_武汉-上海.pdf", + "source_file_name": "2月20_武汉-上海.pdf", + "media_type": "application/pdf", + "size_bytes": 24995, + "uploaded_at": "2026-06-01T06:39:27.813933+00:00", + "status": "unlinked", + "linked_claim_id": "", + "linked_claim_no": "", + "linked_item_id": "", + "linked_at": "", + "engine": "paddleocr_mobile", + "model": "PP-OCRv5_mobile", + "ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", + "summary": "电子发票;(铁路电子客票);州", + "ocr_avg_score": 0.9580968717734019, + "ocr_line_count": 24, + "page_count": 1, + "document_type": "train_ticket", + "document_type_label": "火车/高铁票", + "scene_code": "travel", + "scene_label": "差旅票据", + "ocr_classification_source": "rule", + "ocr_classification_confidence": 0.88, + "ocr_classification_evidence": [ + "铁路电子客票", + "电子客票", + "铁路", + "二等座" + ], + "document_fields": [ + { + "key": "amount", + "label": "金额", + "value": "354元" + }, + { + "key": "date", + "label": "列车出发时间", + "value": "2026-02-20 07:55" + }, + { + "key": "merchant_name", + "label": "商户", + "value": "中国铁路" + }, + { + "key": "invoice_number", + "label": "票据号码", + "value": "26429165800002785705" + }, + { + "key": "route", + "label": "行程", + "value": "武汉-上海" + }, + { + "key": "invoice_date", + "label": "开票日期", + "value": "2026-05-18" + }, + { + "key": "departure_station", + "label": "出发地点", + "value": "武汉" + }, + { + "key": "arrival_station", + "label": "到达地点", + "value": "上海虹桥" + }, + { + "key": "train_no", + "label": "车次", + "value": "G458" + }, + { + "key": "passenger_name", + "label": "乘车人", + "value": "曹笑竹" + }, + { + "key": "id_number", + "label": "身份证号", + "value": "4201061987****1615" + }, + { + "key": "electronic_ticket_no", + "label": "电子客票号", + "value": "6580061086021391007342026" + }, + { + "key": "seat_class", + "label": "席别", + "value": "二等座" + }, + { + "key": "carriage_no", + "label": "车厢", + "value": "06车" + }, + { + "key": "seat_no", + "label": "座位号", + "value": "01B" + }, + { + "key": "fare", + "label": "票价", + "value": "354.00元" + } + ], + "editable_fields": {}, + "ocr_warnings": [], + "previewable": true, + "preview_kind": "image", + "preview_file_name": "preview.png", + "preview_media_type": "image/png" +} \ No newline at end of file diff --git a/server/storage/expense_claims/6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.preview.png b/server/storage/receipt_folder/caoxiaozhu_xf.com/057901b1-d38a-4e0c-9d53-44d14244317e/preview.png similarity index 100% rename from server/storage/expense_claims/6b8e29c9-8bd6-453b-b594-ed0e5b59b91f/329f477a-d926-4101-8ec8-4c8a95150f22/2月20_武汉-上海.preview.png rename to server/storage/receipt_folder/caoxiaozhu_xf.com/057901b1-d38a-4e0c-9d53-44d14244317e/preview.png diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/0abbdfaf-7952-4854-a5b2-bc34298fa1c4/2月20_武汉-上海.pdf b/server/storage/receipt_folder/caoxiaozhu_xf.com/0abbdfaf-7952-4854-a5b2-bc34298fa1c4/2月20_武汉-上海.pdf new file mode 100644 index 0000000..b2207b8 Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/0abbdfaf-7952-4854-a5b2-bc34298fa1c4/2月20_武汉-上海.pdf differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/0abbdfaf-7952-4854-a5b2-bc34298fa1c4/meta.json b/server/storage/receipt_folder/caoxiaozhu_xf.com/0abbdfaf-7952-4854-a5b2-bc34298fa1c4/meta.json new file mode 100644 index 0000000..4f146a8 --- /dev/null +++ b/server/storage/receipt_folder/caoxiaozhu_xf.com/0abbdfaf-7952-4854-a5b2-bc34298fa1c4/meta.json @@ -0,0 +1,121 @@ +{ + "id": "0abbdfaf-7952-4854-a5b2-bc34298fa1c4", + "owner_key": "caoxiaozhu_xf.com", + "file_name": "2月20_武汉-上海.pdf", + "source_file_name": "2月20_武汉-上海.pdf", + "media_type": "application/pdf", + "size_bytes": 24995, + "uploaded_at": "2026-06-01T06:08:52.697458+00:00", + "status": "linked", + "linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8", + "linked_claim_no": "RE-20260601060546-EE2PHJRK", + "linked_item_id": "b1b343b0-3564-4d35-919a-0e4220a9fceb", + "linked_at": "2026-06-01T06:08:52.697458+00:00", + "engine": "paddleocr_mobile", + "model": "PP-OCRv5_mobile", + "ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", + "summary": "电子发票;(铁路电子客票);州", + "ocr_avg_score": 0.9580968717734019, + "ocr_line_count": 24, + "page_count": 1, + "document_type": "train_ticket", + "document_type_label": "火车/高铁票", + "scene_code": "travel", + "scene_label": "差旅票据", + "ocr_classification_source": "rule", + "ocr_classification_confidence": 0.88, + "ocr_classification_evidence": [ + "铁路电子客票", + "电子客票", + "铁路", + "二等座" + ], + "document_fields": [ + { + "key": "amount", + "label": "金额", + "value": "354元" + }, + { + "key": "date", + "label": "列车出发时间", + "value": "2026-02-20 07:55" + }, + { + "key": "merchant_name", + "label": "商户", + "value": "中国铁路" + }, + { + "key": "invoice_number", + "label": "票据号码", + "value": "26429165800002785705" + }, + { + "key": "route", + "label": "行程", + "value": "武汉-上海" + }, + { + "key": "invoice_date", + "label": "开票日期", + "value": "2026-05-18" + }, + { + "key": "departure_station", + "label": "出发地点", + "value": "武汉" + }, + { + "key": "arrival_station", + "label": "到达地点", + "value": "上海虹桥" + }, + { + "key": "train_no", + "label": "车次", + "value": "G458" + }, + { + "key": "passenger_name", + "label": "乘车人", + "value": "曹笑竹" + }, + { + "key": "id_number", + "label": "身份证号", + "value": "4201061987****1615" + }, + { + "key": "electronic_ticket_no", + "label": "电子客票号", + "value": "6580061086021391007342026" + }, + { + "key": "seat_class", + "label": "席别", + "value": "二等座" + }, + { + "key": "carriage_no", + "label": "车厢", + "value": "06车" + }, + { + "key": "seat_no", + "label": "座位号", + "value": "01B" + }, + { + "key": "fare", + "label": "票价", + "value": "354.00元" + } + ], + "editable_fields": {}, + "ocr_warnings": [], + "previewable": true, + "preview_kind": "image", + "preview_file_name": "preview.png", + "preview_media_type": "image/png" +} \ No newline at end of file diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/0abbdfaf-7952-4854-a5b2-bc34298fa1c4/preview.png b/server/storage/receipt_folder/caoxiaozhu_xf.com/0abbdfaf-7952-4854-a5b2-bc34298fa1c4/preview.png new file mode 100644 index 0000000..0bdfb91 Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/0abbdfaf-7952-4854-a5b2-bc34298fa1c4/preview.png differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/64b90764-b957-4a54-b231-0646ee60d1cd/2月20_武汉-上海.pdf b/server/storage/receipt_folder/caoxiaozhu_xf.com/64b90764-b957-4a54-b231-0646ee60d1cd/2月20_武汉-上海.pdf new file mode 100644 index 0000000..b2207b8 Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/64b90764-b957-4a54-b231-0646ee60d1cd/2月20_武汉-上海.pdf differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/64b90764-b957-4a54-b231-0646ee60d1cd/meta.json b/server/storage/receipt_folder/caoxiaozhu_xf.com/64b90764-b957-4a54-b231-0646ee60d1cd/meta.json new file mode 100644 index 0000000..2cee5b3 --- /dev/null +++ b/server/storage/receipt_folder/caoxiaozhu_xf.com/64b90764-b957-4a54-b231-0646ee60d1cd/meta.json @@ -0,0 +1,121 @@ +{ + "id": "64b90764-b957-4a54-b231-0646ee60d1cd", + "owner_key": "caoxiaozhu_xf.com", + "file_name": "2月20_武汉-上海.pdf", + "source_file_name": "2月20_武汉-上海.pdf", + "media_type": "application/pdf", + "size_bytes": 24995, + "uploaded_at": "2026-06-01T06:08:07.688668+00:00", + "status": "unlinked", + "linked_claim_id": "", + "linked_claim_no": "", + "linked_item_id": "", + "linked_at": "", + "engine": "paddleocr_mobile", + "model": "PP-OCRv5_mobile", + "ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", + "summary": "电子发票;(铁路电子客票);州", + "ocr_avg_score": 0.9580968717734019, + "ocr_line_count": 24, + "page_count": 1, + "document_type": "train_ticket", + "document_type_label": "火车/高铁票", + "scene_code": "travel", + "scene_label": "差旅票据", + "ocr_classification_source": "rule", + "ocr_classification_confidence": 0.88, + "ocr_classification_evidence": [ + "铁路电子客票", + "电子客票", + "铁路", + "二等座" + ], + "document_fields": [ + { + "key": "amount", + "label": "金额", + "value": "354元" + }, + { + "key": "date", + "label": "列车出发时间", + "value": "2026-02-20 07:55" + }, + { + "key": "merchant_name", + "label": "商户", + "value": "中国铁路" + }, + { + "key": "invoice_number", + "label": "票据号码", + "value": "26429165800002785705" + }, + { + "key": "route", + "label": "行程", + "value": "武汉-上海" + }, + { + "key": "invoice_date", + "label": "开票日期", + "value": "2026-05-18" + }, + { + "key": "departure_station", + "label": "出发地点", + "value": "武汉" + }, + { + "key": "arrival_station", + "label": "到达地点", + "value": "上海虹桥" + }, + { + "key": "train_no", + "label": "车次", + "value": "G458" + }, + { + "key": "passenger_name", + "label": "乘车人", + "value": "曹笑竹" + }, + { + "key": "id_number", + "label": "身份证号", + "value": "4201061987****1615" + }, + { + "key": "electronic_ticket_no", + "label": "电子客票号", + "value": "6580061086021391007342026" + }, + { + "key": "seat_class", + "label": "席别", + "value": "二等座" + }, + { + "key": "carriage_no", + "label": "车厢", + "value": "06车" + }, + { + "key": "seat_no", + "label": "座位号", + "value": "01B" + }, + { + "key": "fare", + "label": "票价", + "value": "354.00元" + } + ], + "editable_fields": {}, + "ocr_warnings": [], + "previewable": true, + "preview_kind": "image", + "preview_file_name": "preview.png", + "preview_media_type": "image/png" +} \ No newline at end of file diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/64b90764-b957-4a54-b231-0646ee60d1cd/preview.png b/server/storage/receipt_folder/caoxiaozhu_xf.com/64b90764-b957-4a54-b231-0646ee60d1cd/preview.png new file mode 100644 index 0000000..0bdfb91 Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/64b90764-b957-4a54-b231-0646ee60d1cd/preview.png differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/67dc947b-be67-444d-9a91-897190170021/2月20_武汉-上海.pdf b/server/storage/receipt_folder/caoxiaozhu_xf.com/67dc947b-be67-444d-9a91-897190170021/2月20_武汉-上海.pdf new file mode 100644 index 0000000..b2207b8 Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/67dc947b-be67-444d-9a91-897190170021/2月20_武汉-上海.pdf differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/67dc947b-be67-444d-9a91-897190170021/meta.json b/server/storage/receipt_folder/caoxiaozhu_xf.com/67dc947b-be67-444d-9a91-897190170021/meta.json new file mode 100644 index 0000000..44aab58 --- /dev/null +++ b/server/storage/receipt_folder/caoxiaozhu_xf.com/67dc947b-be67-444d-9a91-897190170021/meta.json @@ -0,0 +1,121 @@ +{ + "id": "67dc947b-be67-444d-9a91-897190170021", + "owner_key": "caoxiaozhu_xf.com", + "file_name": "2月20_武汉-上海.pdf", + "source_file_name": "2月20_武汉-上海.pdf", + "media_type": "application/pdf", + "size_bytes": 24995, + "uploaded_at": "2026-06-01T06:40:08.599943+00:00", + "status": "linked", + "linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8", + "linked_claim_no": "RE-20260601060546-EE2PHJRK", + "linked_item_id": "c1693c4a-dcbd-4a8b-941c-bb0abc6ec65a", + "linked_at": "2026-06-01T06:40:08.599943+00:00", + "engine": "paddleocr_mobile", + "model": "PP-OCRv5_mobile", + "ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", + "summary": "电子发票;(铁路电子客票);州", + "ocr_avg_score": 0.9580968717734019, + "ocr_line_count": 24, + "page_count": 1, + "document_type": "train_ticket", + "document_type_label": "火车/高铁票", + "scene_code": "travel", + "scene_label": "差旅票据", + "ocr_classification_source": "rule", + "ocr_classification_confidence": 0.88, + "ocr_classification_evidence": [ + "铁路电子客票", + "电子客票", + "铁路", + "二等座" + ], + "document_fields": [ + { + "key": "amount", + "label": "金额", + "value": "354元" + }, + { + "key": "date", + "label": "列车出发时间", + "value": "2026-02-20 07:55" + }, + { + "key": "merchant_name", + "label": "商户", + "value": "中国铁路" + }, + { + "key": "invoice_number", + "label": "票据号码", + "value": "26429165800002785705" + }, + { + "key": "route", + "label": "行程", + "value": "武汉-上海" + }, + { + "key": "invoice_date", + "label": "开票日期", + "value": "2026-05-18" + }, + { + "key": "departure_station", + "label": "出发地点", + "value": "武汉" + }, + { + "key": "arrival_station", + "label": "到达地点", + "value": "上海虹桥" + }, + { + "key": "train_no", + "label": "车次", + "value": "G458" + }, + { + "key": "passenger_name", + "label": "乘车人", + "value": "曹笑竹" + }, + { + "key": "id_number", + "label": "身份证号", + "value": "4201061987****1615" + }, + { + "key": "electronic_ticket_no", + "label": "电子客票号", + "value": "6580061086021391007342026" + }, + { + "key": "seat_class", + "label": "席别", + "value": "二等座" + }, + { + "key": "carriage_no", + "label": "车厢", + "value": "06车" + }, + { + "key": "seat_no", + "label": "座位号", + "value": "01B" + }, + { + "key": "fare", + "label": "票价", + "value": "354.00元" + } + ], + "editable_fields": {}, + "ocr_warnings": [], + "previewable": true, + "preview_kind": "image", + "preview_file_name": "preview.png", + "preview_media_type": "image/png" +} \ No newline at end of file diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/67dc947b-be67-444d-9a91-897190170021/preview.png b/server/storage/receipt_folder/caoxiaozhu_xf.com/67dc947b-be67-444d-9a91-897190170021/preview.png new file mode 100644 index 0000000..0bdfb91 Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/67dc947b-be67-444d-9a91-897190170021/preview.png differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/6a7335e9-b36a-415c-b2d5-26f421ec72b0/2月23_上海-武汉.pdf b/server/storage/receipt_folder/caoxiaozhu_xf.com/6a7335e9-b36a-415c-b2d5-26f421ec72b0/2月23_上海-武汉.pdf new file mode 100644 index 0000000..d516ecb Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/6a7335e9-b36a-415c-b2d5-26f421ec72b0/2月23_上海-武汉.pdf differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/6a7335e9-b36a-415c-b2d5-26f421ec72b0/meta.json b/server/storage/receipt_folder/caoxiaozhu_xf.com/6a7335e9-b36a-415c-b2d5-26f421ec72b0/meta.json new file mode 100644 index 0000000..d1d9c53 --- /dev/null +++ b/server/storage/receipt_folder/caoxiaozhu_xf.com/6a7335e9-b36a-415c-b2d5-26f421ec72b0/meta.json @@ -0,0 +1,121 @@ +{ + "id": "6a7335e9-b36a-415c-b2d5-26f421ec72b0", + "owner_key": "caoxiaozhu_xf.com", + "file_name": "2月23_上海-武汉.pdf", + "source_file_name": "2月23_上海-武汉.pdf", + "media_type": "application/pdf", + "size_bytes": 24940, + "uploaded_at": "2026-06-01T06:08:07.724830+00:00", + "status": "unlinked", + "linked_claim_id": "", + "linked_claim_no": "", + "linked_item_id": "", + "linked_at": "", + "engine": "paddleocr_mobile", + "model": "PP-OCRv5_mobile", + "ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", + "summary": "电子发票;(铁路电子客票);州", + "ocr_avg_score": 0.9620026834309101, + "ocr_line_count": 24, + "page_count": 1, + "document_type": "train_ticket", + "document_type_label": "火车/高铁票", + "scene_code": "travel", + "scene_label": "差旅票据", + "ocr_classification_source": "rule", + "ocr_classification_confidence": 0.88, + "ocr_classification_evidence": [ + "铁路电子客票", + "电子客票", + "铁路", + "二等座" + ], + "document_fields": [ + { + "key": "amount", + "label": "金额", + "value": "354元" + }, + { + "key": "date", + "label": "列车出发时间", + "value": "2026-02-23 13:54" + }, + { + "key": "merchant_name", + "label": "商户", + "value": "中国铁路" + }, + { + "key": "invoice_number", + "label": "票据号码", + "value": "26319166100006175398" + }, + { + "key": "route", + "label": "行程", + "value": "上海-武汉" + }, + { + "key": "invoice_date", + "label": "开票日期", + "value": "2026-05-18" + }, + { + "key": "departure_station", + "label": "出发地点", + "value": "上海虹桥" + }, + { + "key": "arrival_station", + "label": "到达地点", + "value": "武汉" + }, + { + "key": "train_no", + "label": "车次", + "value": "G456" + }, + { + "key": "passenger_name", + "label": "乘车人", + "value": "曹笑竹" + }, + { + "key": "id_number", + "label": "身份证号", + "value": "4201061987****1615" + }, + { + "key": "electronic_ticket_no", + "label": "电子客票号", + "value": "6610061086021394837402026" + }, + { + "key": "seat_class", + "label": "席别", + "value": "二等座" + }, + { + "key": "carriage_no", + "label": "车厢", + "value": "12车" + }, + { + "key": "seat_no", + "label": "座位号", + "value": "08B" + }, + { + "key": "fare", + "label": "票价", + "value": "354.00元" + } + ], + "editable_fields": {}, + "ocr_warnings": [], + "previewable": true, + "preview_kind": "image", + "preview_file_name": "preview.png", + "preview_media_type": "image/png" +} \ No newline at end of file diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/6a7335e9-b36a-415c-b2d5-26f421ec72b0/preview.png b/server/storage/receipt_folder/caoxiaozhu_xf.com/6a7335e9-b36a-415c-b2d5-26f421ec72b0/preview.png new file mode 100644 index 0000000..099413e Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/6a7335e9-b36a-415c-b2d5-26f421ec72b0/preview.png differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/87c5ddbf-15be-4d21-982d-808267902ab0/2月23_上海-武汉.pdf b/server/storage/receipt_folder/caoxiaozhu_xf.com/87c5ddbf-15be-4d21-982d-808267902ab0/2月23_上海-武汉.pdf new file mode 100644 index 0000000..d516ecb Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/87c5ddbf-15be-4d21-982d-808267902ab0/2月23_上海-武汉.pdf differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/87c5ddbf-15be-4d21-982d-808267902ab0/meta.json b/server/storage/receipt_folder/caoxiaozhu_xf.com/87c5ddbf-15be-4d21-982d-808267902ab0/meta.json new file mode 100644 index 0000000..4df0203 --- /dev/null +++ b/server/storage/receipt_folder/caoxiaozhu_xf.com/87c5ddbf-15be-4d21-982d-808267902ab0/meta.json @@ -0,0 +1,121 @@ +{ + "id": "87c5ddbf-15be-4d21-982d-808267902ab0", + "owner_key": "caoxiaozhu_xf.com", + "file_name": "2月23_上海-武汉.pdf", + "source_file_name": "2月23_上海-武汉.pdf", + "media_type": "application/pdf", + "size_bytes": 24940, + "uploaded_at": "2026-06-01T06:39:27.857168+00:00", + "status": "unlinked", + "linked_claim_id": "", + "linked_claim_no": "", + "linked_item_id": "", + "linked_at": "", + "engine": "paddleocr_mobile", + "model": "PP-OCRv5_mobile", + "ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", + "summary": "电子发票;(铁路电子客票);州", + "ocr_avg_score": 0.9620026834309101, + "ocr_line_count": 24, + "page_count": 1, + "document_type": "train_ticket", + "document_type_label": "火车/高铁票", + "scene_code": "travel", + "scene_label": "差旅票据", + "ocr_classification_source": "rule", + "ocr_classification_confidence": 0.88, + "ocr_classification_evidence": [ + "铁路电子客票", + "电子客票", + "铁路", + "二等座" + ], + "document_fields": [ + { + "key": "amount", + "label": "金额", + "value": "354元" + }, + { + "key": "date", + "label": "列车出发时间", + "value": "2026-02-23 13:54" + }, + { + "key": "merchant_name", + "label": "商户", + "value": "中国铁路" + }, + { + "key": "invoice_number", + "label": "票据号码", + "value": "26319166100006175398" + }, + { + "key": "route", + "label": "行程", + "value": "上海-武汉" + }, + { + "key": "invoice_date", + "label": "开票日期", + "value": "2026-05-18" + }, + { + "key": "departure_station", + "label": "出发地点", + "value": "上海虹桥" + }, + { + "key": "arrival_station", + "label": "到达地点", + "value": "武汉" + }, + { + "key": "train_no", + "label": "车次", + "value": "G456" + }, + { + "key": "passenger_name", + "label": "乘车人", + "value": "曹笑竹" + }, + { + "key": "id_number", + "label": "身份证号", + "value": "4201061987****1615" + }, + { + "key": "electronic_ticket_no", + "label": "电子客票号", + "value": "6610061086021394837402026" + }, + { + "key": "seat_class", + "label": "席别", + "value": "二等座" + }, + { + "key": "carriage_no", + "label": "车厢", + "value": "12车" + }, + { + "key": "seat_no", + "label": "座位号", + "value": "08B" + }, + { + "key": "fare", + "label": "票价", + "value": "354.00元" + } + ], + "editable_fields": {}, + "ocr_warnings": [], + "previewable": true, + "preview_kind": "image", + "preview_file_name": "preview.png", + "preview_media_type": "image/png" +} \ No newline at end of file diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/87c5ddbf-15be-4d21-982d-808267902ab0/preview.png b/server/storage/receipt_folder/caoxiaozhu_xf.com/87c5ddbf-15be-4d21-982d-808267902ab0/preview.png new file mode 100644 index 0000000..099413e Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/87c5ddbf-15be-4d21-982d-808267902ab0/preview.png differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/e8d4f21f-846f-4321-a341-52cd3dfb5acc/2月20_武汉-上海.pdf b/server/storage/receipt_folder/caoxiaozhu_xf.com/e8d4f21f-846f-4321-a341-52cd3dfb5acc/2月20_武汉-上海.pdf new file mode 100644 index 0000000..b2207b8 Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/e8d4f21f-846f-4321-a341-52cd3dfb5acc/2月20_武汉-上海.pdf differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/e8d4f21f-846f-4321-a341-52cd3dfb5acc/meta.json b/server/storage/receipt_folder/caoxiaozhu_xf.com/e8d4f21f-846f-4321-a341-52cd3dfb5acc/meta.json new file mode 100644 index 0000000..b3eb590 --- /dev/null +++ b/server/storage/receipt_folder/caoxiaozhu_xf.com/e8d4f21f-846f-4321-a341-52cd3dfb5acc/meta.json @@ -0,0 +1,121 @@ +{ + "id": "e8d4f21f-846f-4321-a341-52cd3dfb5acc", + "owner_key": "caoxiaozhu_xf.com", + "file_name": "2月20_武汉-上海.pdf", + "source_file_name": "2月20_武汉-上海.pdf", + "media_type": "application/pdf", + "size_bytes": 24995, + "uploaded_at": "2026-06-01T06:57:44.644255+00:00", + "status": "linked", + "linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8", + "linked_claim_no": "RE-20260601060546-EE2PHJRK", + "linked_item_id": "26ccabf8-7e69-4812-acc6-fa18899ec5b2", + "linked_at": "2026-06-01T06:57:44.644255+00:00", + "engine": "paddleocr_mobile", + "model": "PP-OCRv5_mobile", + "ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", + "summary": "电子发票;(铁路电子客票);州", + "ocr_avg_score": 0.9580968717734019, + "ocr_line_count": 24, + "page_count": 1, + "document_type": "train_ticket", + "document_type_label": "火车/高铁票", + "scene_code": "travel", + "scene_label": "差旅票据", + "ocr_classification_source": "rule", + "ocr_classification_confidence": 0.88, + "ocr_classification_evidence": [ + "铁路电子客票", + "电子客票", + "铁路", + "二等座" + ], + "document_fields": [ + { + "key": "amount", + "label": "金额", + "value": "354元" + }, + { + "key": "date", + "label": "列车出发时间", + "value": "2026-02-20 07:55" + }, + { + "key": "merchant_name", + "label": "商户", + "value": "中国铁路" + }, + { + "key": "invoice_number", + "label": "票据号码", + "value": "26429165800002785705" + }, + { + "key": "route", + "label": "行程", + "value": "武汉-上海" + }, + { + "key": "invoice_date", + "label": "开票日期", + "value": "2026-05-18" + }, + { + "key": "departure_station", + "label": "出发地点", + "value": "武汉" + }, + { + "key": "arrival_station", + "label": "到达地点", + "value": "上海虹桥" + }, + { + "key": "train_no", + "label": "车次", + "value": "G458" + }, + { + "key": "passenger_name", + "label": "乘车人", + "value": "曹笑竹" + }, + { + "key": "id_number", + "label": "身份证号", + "value": "4201061987****1615" + }, + { + "key": "electronic_ticket_no", + "label": "电子客票号", + "value": "6580061086021391007342026" + }, + { + "key": "seat_class", + "label": "席别", + "value": "二等座" + }, + { + "key": "carriage_no", + "label": "车厢", + "value": "06车" + }, + { + "key": "seat_no", + "label": "座位号", + "value": "01B" + }, + { + "key": "fare", + "label": "票价", + "value": "354.00元" + } + ], + "editable_fields": {}, + "ocr_warnings": [], + "previewable": true, + "preview_kind": "image", + "preview_file_name": "preview.png", + "preview_media_type": "image/png" +} \ No newline at end of file diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/e8d4f21f-846f-4321-a341-52cd3dfb5acc/preview.png b/server/storage/receipt_folder/caoxiaozhu_xf.com/e8d4f21f-846f-4321-a341-52cd3dfb5acc/preview.png new file mode 100644 index 0000000..0bdfb91 Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/e8d4f21f-846f-4321-a341-52cd3dfb5acc/preview.png differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/fa80f870-10a3-4797-a151-39e702927eb5/2月23_上海-武汉.pdf b/server/storage/receipt_folder/caoxiaozhu_xf.com/fa80f870-10a3-4797-a151-39e702927eb5/2月23_上海-武汉.pdf new file mode 100644 index 0000000..d516ecb Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/fa80f870-10a3-4797-a151-39e702927eb5/2月23_上海-武汉.pdf differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/fa80f870-10a3-4797-a151-39e702927eb5/meta.json b/server/storage/receipt_folder/caoxiaozhu_xf.com/fa80f870-10a3-4797-a151-39e702927eb5/meta.json new file mode 100644 index 0000000..4124496 --- /dev/null +++ b/server/storage/receipt_folder/caoxiaozhu_xf.com/fa80f870-10a3-4797-a151-39e702927eb5/meta.json @@ -0,0 +1,121 @@ +{ + "id": "fa80f870-10a3-4797-a151-39e702927eb5", + "owner_key": "caoxiaozhu_xf.com", + "file_name": "2月23_上海-武汉.pdf", + "source_file_name": "2月23_上海-武汉.pdf", + "media_type": "application/pdf", + "size_bytes": 24940, + "uploaded_at": "2026-06-01T06:40:32.249473+00:00", + "status": "linked", + "linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8", + "linked_claim_no": "RE-20260601060546-EE2PHJRK", + "linked_item_id": "b0b28405-30b5-4c35-9bd5-13abe4d2c4cd", + "linked_at": "2026-06-01T06:40:32.249473+00:00", + "engine": "paddleocr_mobile", + "model": "PP-OCRv5_mobile", + "ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", + "summary": "电子发票;(铁路电子客票);州", + "ocr_avg_score": 0.9620026834309101, + "ocr_line_count": 24, + "page_count": 1, + "document_type": "train_ticket", + "document_type_label": "火车/高铁票", + "scene_code": "travel", + "scene_label": "差旅票据", + "ocr_classification_source": "rule", + "ocr_classification_confidence": 0.88, + "ocr_classification_evidence": [ + "铁路电子客票", + "电子客票", + "铁路", + "二等座" + ], + "document_fields": [ + { + "key": "amount", + "label": "金额", + "value": "354元" + }, + { + "key": "date", + "label": "列车出发时间", + "value": "2026-02-23 13:54" + }, + { + "key": "merchant_name", + "label": "商户", + "value": "中国铁路" + }, + { + "key": "invoice_number", + "label": "票据号码", + "value": "26319166100006175398" + }, + { + "key": "route", + "label": "行程", + "value": "上海-武汉" + }, + { + "key": "invoice_date", + "label": "开票日期", + "value": "2026-05-18" + }, + { + "key": "departure_station", + "label": "出发地点", + "value": "上海虹桥" + }, + { + "key": "arrival_station", + "label": "到达地点", + "value": "武汉" + }, + { + "key": "train_no", + "label": "车次", + "value": "G456" + }, + { + "key": "passenger_name", + "label": "乘车人", + "value": "曹笑竹" + }, + { + "key": "id_number", + "label": "身份证号", + "value": "4201061987****1615" + }, + { + "key": "electronic_ticket_no", + "label": "电子客票号", + "value": "6610061086021394837402026" + }, + { + "key": "seat_class", + "label": "席别", + "value": "二等座" + }, + { + "key": "carriage_no", + "label": "车厢", + "value": "12车" + }, + { + "key": "seat_no", + "label": "座位号", + "value": "08B" + }, + { + "key": "fare", + "label": "票价", + "value": "354.00元" + } + ], + "editable_fields": {}, + "ocr_warnings": [], + "previewable": true, + "preview_kind": "image", + "preview_file_name": "preview.png", + "preview_media_type": "image/png" +} \ No newline at end of file diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/fa80f870-10a3-4797-a151-39e702927eb5/preview.png b/server/storage/receipt_folder/caoxiaozhu_xf.com/fa80f870-10a3-4797-a151-39e702927eb5/preview.png new file mode 100644 index 0000000..099413e Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/fa80f870-10a3-4797-a151-39e702927eb5/preview.png differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/fd4f2229-bf01-48ae-99b9-b98458e2b632/2月20_武汉-上海.pdf b/server/storage/receipt_folder/caoxiaozhu_xf.com/fd4f2229-bf01-48ae-99b9-b98458e2b632/2月20_武汉-上海.pdf new file mode 100644 index 0000000..b2207b8 Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/fd4f2229-bf01-48ae-99b9-b98458e2b632/2月20_武汉-上海.pdf differ diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/fd4f2229-bf01-48ae-99b9-b98458e2b632/meta.json b/server/storage/receipt_folder/caoxiaozhu_xf.com/fd4f2229-bf01-48ae-99b9-b98458e2b632/meta.json new file mode 100644 index 0000000..c70d26c --- /dev/null +++ b/server/storage/receipt_folder/caoxiaozhu_xf.com/fd4f2229-bf01-48ae-99b9-b98458e2b632/meta.json @@ -0,0 +1,121 @@ +{ + "id": "fd4f2229-bf01-48ae-99b9-b98458e2b632", + "owner_key": "caoxiaozhu_xf.com", + "file_name": "2月20_武汉-上海.pdf", + "source_file_name": "2月20_武汉-上海.pdf", + "media_type": "application/pdf", + "size_bytes": 24995, + "uploaded_at": "2026-06-01T06:56:59.257104+00:00", + "status": "unlinked", + "linked_claim_id": "", + "linked_claim_no": "", + "linked_item_id": "", + "linked_at": "", + "engine": "paddleocr_mobile", + "model": "PP-OCRv5_mobile", + "ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", + "summary": "电子发票;(铁路电子客票);州", + "ocr_avg_score": 0.9580968717734019, + "ocr_line_count": 24, + "page_count": 1, + "document_type": "train_ticket", + "document_type_label": "火车/高铁票", + "scene_code": "travel", + "scene_label": "差旅票据", + "ocr_classification_source": "rule", + "ocr_classification_confidence": 0.88, + "ocr_classification_evidence": [ + "铁路电子客票", + "电子客票", + "铁路", + "二等座" + ], + "document_fields": [ + { + "key": "amount", + "label": "金额", + "value": "354元" + }, + { + "key": "date", + "label": "列车出发时间", + "value": "2026-02-20 07:55" + }, + { + "key": "merchant_name", + "label": "商户", + "value": "中国铁路" + }, + { + "key": "invoice_number", + "label": "票据号码", + "value": "26429165800002785705" + }, + { + "key": "route", + "label": "行程", + "value": "武汉-上海" + }, + { + "key": "invoice_date", + "label": "开票日期", + "value": "2026-05-18" + }, + { + "key": "departure_station", + "label": "出发地点", + "value": "武汉" + }, + { + "key": "arrival_station", + "label": "到达地点", + "value": "上海虹桥" + }, + { + "key": "train_no", + "label": "车次", + "value": "G458" + }, + { + "key": "passenger_name", + "label": "乘车人", + "value": "曹笑竹" + }, + { + "key": "id_number", + "label": "身份证号", + "value": "4201061987****1615" + }, + { + "key": "electronic_ticket_no", + "label": "电子客票号", + "value": "6580061086021391007342026" + }, + { + "key": "seat_class", + "label": "席别", + "value": "二等座" + }, + { + "key": "carriage_no", + "label": "车厢", + "value": "06车" + }, + { + "key": "seat_no", + "label": "座位号", + "value": "01B" + }, + { + "key": "fare", + "label": "票价", + "value": "354.00元" + } + ], + "editable_fields": {}, + "ocr_warnings": [], + "previewable": true, + "preview_kind": "image", + "preview_file_name": "preview.png", + "preview_media_type": "image/png" +} \ No newline at end of file diff --git a/server/storage/receipt_folder/caoxiaozhu_xf.com/fd4f2229-bf01-48ae-99b9-b98458e2b632/preview.png b/server/storage/receipt_folder/caoxiaozhu_xf.com/fd4f2229-bf01-48ae-99b9-b98458e2b632/preview.png new file mode 100644 index 0000000..0bdfb91 Binary files /dev/null and b/server/storage/receipt_folder/caoxiaozhu_xf.com/fd4f2229-bf01-48ae-99b9-b98458e2b632/preview.png differ diff --git a/server/tests/test_agent_asset_service.py b/server/tests/test_agent_asset_service.py index 7bc0533..54fb645 100644 --- a/server/tests/test_agent_asset_service.py +++ b/server/tests/test_agent_asset_service.py @@ -212,18 +212,16 @@ def test_demo_budget_risk_rules_sync_with_finance_rule_references() -> None: AgentAsset.code == "risk.budget.available_balance_insufficient" ) ) - marketing_rule = db.scalar( + communication_rule = db.scalar( select(AgentAsset).where( - AgentAsset.code == "risk.application.marketing_without_campaign" + AgentAsset.code == "risk.standard.communication_amount_over_policy" ) ) assert budget_rule is not None - assert "差旅费" in budget_rule.scenario_json - assert "市场推广费" in budget_rule.scenario_json - assert "软件服务费" in budget_rule.scenario_json + assert budget_rule.scenario_json == ["全部"] assert budget_rule.config_json["budget_required"] is True - assert "marketing" in budget_rule.config_json["expense_types"] + assert budget_rule.config_json["expense_types"] == ["all"] assert budget_rule.config_json["business_stage"] == [ "expense_application", "reimbursement", @@ -231,12 +229,12 @@ def test_demo_budget_risk_rules_sync_with_finance_rule_references() -> None: ] assert budget_rule.config_json["finance_rule_code"] == "budget.execution.policy" - assert marketing_rule is not None - assert marketing_rule.scenario_json == ["市场推广费"] - assert marketing_rule.config_json["finance_rule_code"] == "expense.application.policy" - assert marketing_rule.config_json["finance_rule_sheet"] == "费用申请前置规则" - assert marketing_rule.config_json["expense_types"] == ["marketing"] - assert marketing_rule.config_json["budget_required"] is True + assert communication_rule is not None + assert communication_rule.scenario_json == ["通信费"] + assert communication_rule.config_json["finance_rule_code"] == "expense.communication.policy" + assert communication_rule.config_json["finance_rule_sheet"] == "通信费报销规则" + assert communication_rule.config_json["expense_types"] == ["communication"] + assert communication_rule.config_json["budget_required"] is True def test_agent_asset_service_can_activate_rule_after_review() -> None: diff --git a/server/tests/test_agent_trace_service.py b/server/tests/test_agent_trace_service.py new file mode 100644 index 0000000..b223b45 --- /dev/null +++ b/server/tests/test_agent_trace_service.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from collections.abc import Generator +from datetime import UTC, datetime, timedelta + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api.deps import get_db +from app.core.agent_enums import AgentName, AgentRunSource, AgentRunStatus, AgentToolType +from app.db.base import Base +from app.main import create_app +from app.models.agent_conversation import AgentConversation, AgentConversationMessage +from app.services.agent_runs import AgentRunService +from app.services.agent_traces import AgentTraceService + + +def build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + return session_factory() + + +def build_client() -> tuple[TestClient, sessionmaker[Session]]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + app = create_app() + + def override_db() -> Generator[Session, None, None]: + db = session_factory() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_db + return TestClient(app), session_factory + + +def test_agent_trace_service_records_events_and_reads_detail() -> None: + with build_session() as db: + run_service = AgentRunService(db) + trace_service = AgentTraceService(db) + started_at = datetime.now(UTC) - timedelta(seconds=2) + run = run_service.create_run( + agent=AgentName.ORCHESTRATOR.value, + source=AgentRunSource.USER_MESSAGE.value, + status=AgentRunStatus.SUCCEEDED.value, + route_json={"conversation_id": "conv-trace-1"}, + result_summary="expense answer ready", + started_at=started_at, + finished_at=started_at + timedelta(seconds=1), + ) + db.add( + AgentConversation( + conversation_id="conv-trace-1", + user_id="u-1", + source=AgentRunSource.USER_MESSAGE.value, + ) + ) + db.add( + AgentConversationMessage( + conversation_id="conv-trace-1", + run_id=run.run_id, + role="user", + content="帮我看报销风险", + message_json={"source": "test"}, + ) + ) + db.commit() + + first = trace_service.record_event( + run_id=run.run_id, + conversation_id="conv-trace-1", + stage="orchestrator", + event_name="request_received", + title="接收请求", + summary="用户消息进入编排", + input_json={"message": "帮我看报销风险"}, + output_json={"run_id": run.run_id}, + started_at=started_at, + finished_at=started_at + timedelta(milliseconds=20), + ) + second = trace_service.record_event( + run_id=run.run_id, + conversation_id="conv-trace-1", + stage="response", + event_name="response_built", + title="生成回复", + status=AgentRunStatus.SUCCEEDED.value, + output_json={"message": "已完成"}, + started_at=started_at + timedelta(milliseconds=500), + finished_at=started_at + timedelta(milliseconds=650), + ) + + items = trace_service.list_traces(keyword=run.run_id, limit=10) + detail = trace_service.get_trace(run.run_id) + + assert first.sequence == 1 + assert second.sequence == 2 + assert len(items) == 1 + assert items[0].event_count == 2 + assert detail is not None + assert detail.fallback_generated is False + assert [event.event_name for event in detail.events] == [ + "request_received", + "response_built", + ] + assert detail.conversation_id == "conv-trace-1" + assert detail.conversation_messages[0].content == "帮我看报销风险" + + +def test_agent_trace_service_builds_fallback_timeline_for_legacy_runs() -> None: + with build_session() as db: + run_service = AgentRunService(db) + trace_service = AgentTraceService(db) + run = run_service.create_run( + agent=AgentName.HERMES.value, + source=AgentRunSource.SCHEDULE.value, + status=AgentRunStatus.FAILED.value, + route_json={"conversation_id": "conv-trace-legacy", "stage": "tooling"}, + result_summary="sync failed", + error_message="boom", + ) + run_service.record_semantic_parse( + run_id=run.run_id, + user_id="u-1", + raw_query="同步知识库", + scenario="knowledge", + intent="sync", + confidence=0.92, + ) + run_service.record_tool_call( + run_id=run.run_id, + tool_type=AgentToolType.LLM.value, + tool_name="lightrag.index_documents", + request_json={"document_ids": ["doc-1"]}, + response_json={"fallback": True}, + status=AgentRunStatus.FAILED.value, + duration_ms=31, + error_message="boom", + ) + + detail = trace_service.get_trace(run.run_id) + conversation_detail = trace_service.get_conversation_trace("conv-trace-legacy") + + assert detail is not None + assert detail.fallback_generated is True + assert detail.conversation_id == "conv-trace-legacy" + assert "semantic_parsed" in [event.event_name for event in detail.events] + assert "tool_invoked" in [event.event_name for event in detail.events] + assert detail.events[-1].event_name == "failed" + assert detail.tool_calls[0].tool_name == "lightrag.index_documents" + assert [item.run.run_id for item in conversation_detail.runs] == [run.run_id] + + +def test_agent_trace_endpoints_return_admin_trace_detail() -> None: + client, session_factory = build_client() + + with session_factory() as db: + run_service = AgentRunService(db) + trace_service = AgentTraceService(db) + run = run_service.create_run( + agent=AgentName.ORCHESTRATOR.value, + source=AgentRunSource.USER_MESSAGE.value, + status=AgentRunStatus.SUCCEEDED.value, + route_json={"conversation_id": "conv-api-trace"}, + result_summary="api trace ready", + ) + trace_service.record_event( + run_id=run.run_id, + conversation_id="conv-api-trace", + stage="response", + event_name="response_built", + title="生成回复", + status=AgentRunStatus.SUCCEEDED.value, + output_json={"message": "ok"}, + ) + + headers = { + "x-auth-username": "admin", + "x-auth-name": "admin", + "x-auth-is-admin": "true", + } + list_response = client.get("/api/v1/agent-traces", headers=headers) + detail_response = client.get(f"/api/v1/agent-traces/{run.run_id}", headers=headers) + + assert list_response.status_code == 200 + assert any(item["run_id"] == run.run_id for item in list_response.json()) + assert detail_response.status_code == 200 + payload = detail_response.json() + assert payload["run"]["run_id"] == run.run_id + assert payload["events"][0]["event_name"] == "response_built" diff --git a/server/tests/test_auth_service.py b/server/tests/test_auth_service.py index 95a8c6e..22416e0 100644 --- a/server/tests/test_auth_service.py +++ b/server/tests/test_auth_service.py @@ -23,11 +23,11 @@ def build_session() -> Session: return session_factory() -def test_employee_can_login_with_seed_default_password() -> None: - with build_session() as db: - employee = EmployeeService(db).list_employees()[0] - result = AuthService(db).login( - LoginRequest(username=employee.email, password="123456") +def test_employee_can_login_with_seed_default_password() -> None: + with build_session() as db: + employee = EmployeeService(db).list_employees()[0] + result = AuthService(db).login( + LoginRequest(username=employee.email, password="123456") ) assert result.ok is True @@ -36,10 +36,23 @@ def test_employee_can_login_with_seed_default_password() -> None: assert result.user.position == employee.position assert result.user.grade == employee.grade assert result.user.roleCodes - assert result.user.isAdmin is False - - -def test_admin_can_login_with_database_password() -> None: + assert result.user.isAdmin is False + + +def test_current_user_snapshot_refreshes_employee_position() -> None: + with build_session() as db: + employee = EmployeeService(db).list_employees()[0] + result = AuthService(db).get_user_snapshot(employee.email) + + assert result is not None + assert result.username == employee.email + assert result.name == employee.name + assert result.department == employee.department + assert result.position == employee.position + assert result.grade == employee.grade + + +def test_admin_can_login_with_database_password() -> None: with build_session() as db: settings_service = SettingsService(db) payload = settings_service.get_settings_snapshot().model_dump() diff --git a/server/tests/test_digital_employee_dashboard_service.py b/server/tests/test_digital_employee_dashboard_service.py new file mode 100644 index 0000000..bac6b4d --- /dev/null +++ b/server/tests/test_digital_employee_dashboard_service.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.db.base import Base +from app.models.agent_run import AgentRun, AgentToolCall +from app.services.digital_employee_dashboard import DigitalEmployeeDashboardService + + +def build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + return session_factory() + + +def test_digital_employee_dashboard_aggregates_daily_work_from_agent_runs() -> None: + now = datetime.now(UTC) + + with build_session() as db: + db.add_all( + [ + AgentRun( + run_id="run-digital-risk-001", + agent="hermes", + source="schedule", + user_id="system", + status="succeeded", + route_json={"task_code": "task.hermes.global_risk_scan"}, + result_summary="财务风险图谱巡检完成。", + started_at=now - timedelta(hours=4), + finished_at=now - timedelta(hours=4, minutes=-3), + tool_calls=[ + AgentToolCall( + run_id="run-digital-risk-001", + tool_type="rule_engine", + tool_name="digital_employee.financial_risk_graph.scan", + request_json={"task_type": "global_risk_scan"}, + response_json={ + "scanned_claim_count": 18, + "risk_observation_count": 3, + }, + status="succeeded", + duration_ms=1200, + created_at=now - timedelta(hours=4), + ) + ], + ), + AgentRun( + run_id="run-digital-clue-001", + agent="hermes", + source="schedule", + user_id="system", + status="failed", + route_json={"report_type": "risk_clue_collect"}, + result_summary="风险线索归集失败。", + started_at=now - timedelta(hours=3), + finished_at=now - timedelta(hours=3, minutes=-1), + tool_calls=[ + AgentToolCall( + run_id="run-digital-clue-001", + tool_type="database", + tool_name="digital_employee.risk_clue.collect", + request_json={"task_type": "risk_clue_collect"}, + response_json={ + "fact_count": 12, + "rule_hit_count": 5, + "risk_clue_count": 2, + }, + status="failed", + duration_ms=800, + error_message="collector failed", + created_at=now - timedelta(hours=3), + ) + ], + ), + AgentRun( + run_id="run-digital-knowledge-001", + agent="hermes", + source="user_message", + user_id="admin", + status="running", + route_json={ + "job_type": "knowledge_index_sync", + "requested_document_ids": ["doc-1", "doc-2"], + }, + result_summary="知识归纳任务已入队。", + started_at=now - timedelta(hours=1), + ), + AgentRun( + run_id="run-user-001", + agent="user_agent", + source="user_message", + user_id="employee", + status="succeeded", + result_summary="普通报销预审。", + started_at=now - timedelta(hours=2), + finished_at=now - timedelta(hours=2, minutes=-1), + tool_calls=[ + AgentToolCall( + run_id="run-user-001", + tool_type="llm", + tool_name="expense_claim.review", + request_json={}, + response_json={}, + status="succeeded", + duration_ms=500, + created_at=now - timedelta(hours=2), + ) + ], + ), + ] + ) + db.commit() + + dashboard = DigitalEmployeeDashboardService(db).build_dashboard(days=7) + + assert dashboard.has_real_data is True + assert dashboard.totals["totalRuns"] == 3 + assert dashboard.totals["successRuns"] == 1 + assert dashboard.totals["failedRuns"] == 1 + assert dashboard.totals["runningRuns"] == 1 + assert dashboard.totals["toolCalls"] == 2 + assert dashboard.totals["riskObservations"] == 3 + assert dashboard.totals["riskClues"] == 2 + assert dashboard.totals["knowledgeDocuments"] == 2 + assert dashboard.totals["businessOutputs"] == 7 + assert dashboard.totals["successRate"] == 33.3 + + category_counts = {item["name"]: item["count"] for item in dashboard.category_distribution} + assert category_counts["评估"] == 1 + assert category_counts["升级"] == 1 + assert category_counts["整理"] == 1 + assert category_counts["积累"] == 0 + + task_names = {item["name"] for item in dashboard.task_distribution} + assert task_names == {"财务风险图谱巡检", "风险线索归集", "知识制度整理"} + + assert sum(item["total"] for item in dashboard.daily_work) == 3 + assert dashboard.recent_runs[0]["runId"] == "run-digital-knowledge-001" + assert dashboard.recent_runs[0]["statusLabel"] == "运行中" + + +def test_digital_employee_dashboard_keeps_empty_payload_without_fake_data() -> None: + with build_session() as db: + dashboard = DigitalEmployeeDashboardService(db).build_dashboard(days=7) + + assert dashboard.has_real_data is False + assert dashboard.totals["totalRuns"] == 0 + assert dashboard.daily_work + assert dashboard.task_distribution == [] diff --git a/server/tests/test_digital_employee_skill_catalog.py b/server/tests/test_digital_employee_skill_catalog.py new file mode 100644 index 0000000..cb07dc1 --- /dev/null +++ b/server/tests/test_digital_employee_skill_catalog.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from app.core.agent_enums import AgentName +from app.services.agent_foundation_constants import ( + DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE, + DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE, + DIGITAL_EMPLOYEE_SKILL_CATEGORIES, + DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP, +) +from app.services.agent_foundation_digital_employee_tasks import ( + AgentFoundationDigitalEmployeeTaskMixin, + DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY, +) + + +class _CatalogHarness(AgentFoundationDigitalEmployeeTaskMixin): + def _digital_employee_task_config(self, code: str, cron: str) -> dict[str, Any]: + return { + "cron": cron, + "agent": AgentName.HERMES.value, + "task_type": code.replace("task.hermes.", "").replace(".", "_"), + "skill_category": DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP.get(code, "整理"), + "skill_category_options": list(DIGITAL_EMPLOYEE_SKILL_CATEGORIES), + } + + def _read_domain_skill_markdown( + self, + skill_name: str, + fallback_lines: list[str], + ) -> str: + skill_path = _skill_root() / skill_name / "SKILL.md" + if skill_path.exists(): + return skill_path.read_text(encoding="utf-8") + return "\n".join(fallback_lines) + + def _financial_risk_graph_scan_skill_markdown(self) -> str: + return self._read_domain_skill_markdown("financial-risk-graph-scanner", []) + + def _employee_behavior_profile_scan_skill_markdown(self) -> str: + return self._read_domain_skill_markdown("employee-behavior-profile-scanner", []) + + def _risk_rule_discovery_skill_markdown(self) -> str: + return self._read_domain_skill_markdown("risk-rule-discovery", []) + + def _risk_clue_collector_skill_markdown(self) -> str: + return self._read_domain_skill_markdown("risk-clue-collector", []) + + +def test_digital_employee_skill_catalog_has_complete_categories_and_packages() -> None: + harness = _CatalogHarness() + specs = harness._runtime_digital_employee_task_specs() + codes = [str(spec["code"]) for spec in specs] + categories = [str(spec["skill_category"]) for spec in specs] + skill_names = [str(dict(spec["config"])["skill_name"]) for spec in specs] + + assert len(specs) == 16 + assert len(set(codes)) == len(codes) + assert set(categories) == set(DIGITAL_EMPLOYEE_SKILL_CATEGORIES) + assert DIGITAL_EMPLOYEE_TASK_CATEGORY_MAP[DIGITAL_EMPLOYEE_PROFILE_SCAN_TASK_CODE] == "积累" + assert len(set(codes + [DIGITAL_EMPLOYEE_FINANCE_POLICY_TASK_CODE])) == 17 + + for skill_name in ["finance-policy-knowledge-organizer", *skill_names]: + skill_file = _skill_root() / skill_name / "SKILL.md" + assert skill_file.exists(), skill_name + content = skill_file.read_text(encoding="utf-8") + assert f"name: {skill_name}" in content + + +def test_digital_employee_runtime_specs_build_display_ready_config() -> None: + harness = _CatalogHarness() + forbidden_rule_execution = "执行" + "规则" + + for spec in harness._runtime_digital_employee_task_specs(): + config = harness._build_runtime_digital_employee_config(spec) + spec_config = dict(spec["config"]) + markdown = spec["markdown"]() + + assert config["agent"] == AgentName.HERMES.value + assert config["skill_category"] == spec["skill_category"] + assert config["skill_category_options"] == list(DIGITAL_EMPLOYEE_SKILL_CATEGORIES) + assert config["skill_name"] == spec_config["skill_name"] + assert config["output_format"] == spec_config["output_format"] + assert config["schedule"] == spec["cron"] + assert config["cron_expression"] == spec["cron"] + assert config["writes_rules"] is False + assert isinstance(config["role_boundary"], str) + assert config["role_boundary"] == DIGITAL_EMPLOYEE_ANALYSIS_ROLE_BOUNDARY + assert "主流程由外层智能体执行" in config["role_boundary"] + assert forbidden_rule_execution not in config["role_boundary"] + assert "human_review_required" in config["allowed_outputs"] + assert f"name: {config['skill_name']}" in markdown + assert str(spec["name"]) in markdown + + +def test_digital_employee_skills_do_not_cross_rule_governance_boundary() -> None: + harness = _CatalogHarness() + forbidden_rule_execution = "执行" + "规则" + specs = harness._runtime_digital_employee_task_specs() + skill_names = {str(dict(spec["config"])["skill_name"]) for spec in specs} + output_formats = {str(dict(spec["config"])["output_format"]) for spec in specs} + text_contract = "\n".join( + str(value) + for spec in specs + for value in ( + spec["name"], + spec["description"], + dict(spec["config"])["skill_name"], + dict(spec["config"])["output_format"], + dict(spec["config"])["role_boundary"], + ) + ) + + assert "risk-clue-collector" in skill_names + assert "rule-execution-case-organizer" in skill_names + assert "policy-reference-gap-hinter" in skill_names + assert "risk-rule-discovery" not in skill_names + assert "risk-rule-template-organizer" not in skill_names + assert "policy-gap-rule-optimizer" not in skill_names + assert "candidate_risk_rules" not in output_formats + assert "risk_rule_template_library" not in output_formats + assert "policy_gap_rule_optimization_report" not in output_formats + assert "auto_publish" not in text_contract + assert forbidden_rule_execution not in text_contract + + +def _skill_root() -> Path: + return Path(__file__).resolve().parents[1] / "src" / "app" / "skills" / "domain" diff --git a/server/tests/test_employee_behavior_profile_service.py b/server/tests/test_employee_behavior_profile_service.py index 8005173..1a4ebda 100644 --- a/server/tests/test_employee_behavior_profile_service.py +++ b/server/tests/test_employee_behavior_profile_service.py @@ -223,6 +223,26 @@ def test_service_scans_snapshots_and_filters_approval_scene() -> None: assert latest.radar.dimensions +def test_service_resolves_latest_profile_by_employee_name_identifier() -> None: + session_factory = build_session_factory() + with session_factory() as db: + seed_profile_data(db) + employee = db.get(Employee, "emp-main") + assert employee is not None + + latest = EmployeeBehaviorProfileService(db).get_latest_profile( + employee_id=employee.name, + scene="approval", + claim_id="claim-main-1", + window_days=90, + expense_type_scope="travel", + ) + + assert latest.employee_id == "emp-main" + assert latest.empty_reason == "" + assert {item.profile_type for item in latest.profiles} == {"expense", "process_quality"} + + def test_latest_profile_endpoint_returns_approval_payload() -> None: session_factory = build_session_factory() with session_factory() as db: diff --git a/server/tests/test_expense_claim_approval_routing.py b/server/tests/test_expense_claim_approval_routing.py new file mode 100644 index 0000000..88e1a4f --- /dev/null +++ b/server/tests/test_expense_claim_approval_routing.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from decimal import Decimal + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api.deps import CurrentUserContext +from app.db.base import Base +from app.models.budget import BudgetAllocation +from app.models.employee import Employee +from app.models.financial_record import ExpenseClaim +from app.models.organization import OrganizationUnit +from app.models.role import Role +from app.services.expense_claim_workflow_constants import ( + APPROVAL_DONE_STAGE, + BUDGET_MANAGER_APPROVAL_STAGE, + DIRECT_MANAGER_APPROVAL_STAGE, + FINANCE_APPROVAL_STAGE, +) +from app.services.expense_claims import ExpenseClaimService + + +def build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + return session_factory() + + +def _seed_budget_monitor_role(db: Session) -> Role: + role = Role(role_code="budget_monitor", name="预算管理员") + db.add(role) + db.flush() + return role + + +def _seed_budget_allocation( + db: Session, + *, + department_id: str, + department_name: str, + amount: Decimal = Decimal("50000.00"), +) -> None: + db.add( + BudgetAllocation( + budget_no=f"BUD-ROUTE-{uuid.uuid4().hex[:8]}", + fiscal_year=2026, + period_type="quarter", + period_key="2026Q2", + department_id=department_id, + department_name=department_name, + cost_center=None, + project_code=None, + subject_code="travel", + subject_name="差旅", + original_amount=amount, + adjusted_amount=Decimal("0.00"), + status="active", + warning_threshold=Decimal("80.00"), + control_action="block", + ) + ) + db.flush() + + +def _seed_people(db: Session, *, suffix: str) -> tuple[OrganizationUnit, Employee, Employee, Employee]: + budget_role = _seed_budget_monitor_role(db) + department = OrganizationUnit( + unit_code=f"ROUTE-{suffix}", + name=f"动态路由部{suffix}", + unit_type="department", + ) + manager = Employee( + employee_no=f"M-{suffix}", + name=f"直属领导{suffix}", + email=f"manager-{suffix}@example.com", + organization_unit=department, + ) + budget_manager = Employee( + employee_no=f"B-{suffix}", + name=f"预算管理员{suffix}", + email=f"budget-{suffix}@example.com", + grade="P8", + organization_unit=department, + roles=[budget_role], + ) + employee = Employee( + employee_no=f"E-{suffix}", + name=f"申请人{suffix}", + email=f"employee-{suffix}@example.com", + manager=manager, + organization_unit=department, + ) + db.add_all([department, manager, budget_manager, employee]) + db.flush() + return department, manager, budget_manager, employee + + +def test_low_risk_application_skips_budget_manager_and_generates_draft() -> None: + with build_session() as db: + department, manager, _budget_manager, employee = _seed_people(db, suffix="LOW-APP") + _seed_budget_allocation( + db, + department_id=department.id, + department_name=department.name, + ) + claim = ExpenseClaim( + claim_no="APP-20260530-LOW-ROUTE", + employee_id=employee.id, + employee_name=employee.name, + department_id=department.id, + department_name=department.name, + project_code=None, + expense_type="travel_application", + reason="客户现场沟通", + location="上海", + amount=Decimal("500.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage=DIRECT_MANAGER_APPROVAL_STAGE, + risk_flags_json=[], + ) + db.add(claim) + db.commit() + + approved = ExpenseClaimService(db).approve_claim( + claim.id, + CurrentUserContext( + username=manager.email, + name=manager.name, + role_codes=["manager"], + is_admin=False, + ), + opinion="业务必要,同意申请", + ) + + assert approved is not None + assert approved.status == "approved" + assert approved.approval_stage == APPROVAL_DONE_STAGE + assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1 + assert any( + isinstance(flag, dict) + and flag.get("source") == "approval_routing" + and flag.get("requires_budget_review") is False + and flag.get("route") == "approval_done" + and flag.get("business_stage") == "expense_application" + for flag in approved.risk_flags_json + ) + assert any( + isinstance(flag, dict) + and flag.get("source") == "manual_approval" + and flag.get("next_approval_stage") == APPROVAL_DONE_STAGE + and flag.get("route_decision", {}).get("requires_budget_review") is False + for flag in approved.risk_flags_json + ) + + +def test_budget_warning_application_still_skips_budget_manager_when_not_over_budget() -> None: + with build_session() as db: + department, manager, _budget_manager, employee = _seed_people(db, suffix="WARN-APP") + _seed_budget_allocation( + db, + department_id=department.id, + department_name=department.name, + amount=Decimal("10000.00"), + ) + claim = ExpenseClaim( + claim_no="APP-20260530-WARN-ROUTE", + employee_id=employee.id, + employee_name=employee.name, + department_id=department.id, + department_name=department.name, + project_code=None, + expense_type="travel_application", + reason="客户现场支持", + location="上海", + amount=Decimal("8500.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage=DIRECT_MANAGER_APPROVAL_STAGE, + risk_flags_json=[ + { + "source": "budget_control", + "event_type": "budget_warning", + "severity": "medium", + "label": "预算接近预警线", + "message": "预算仍可承接,但审批后使用率将接近预警线。", + } + ], + ) + db.add(claim) + db.commit() + + approved = ExpenseClaimService(db).approve_claim( + claim.id, + CurrentUserContext( + username=manager.email, + name=manager.name, + role_codes=["manager"], + is_admin=False, + ), + opinion="业务必要,同意申请。", + ) + + assert approved is not None + assert approved.status == "approved" + assert approved.approval_stage == APPROVAL_DONE_STAGE + assert any( + isinstance(flag, dict) + and flag.get("source") == "approval_routing" + and flag.get("requires_budget_review") is False + and flag.get("route") == "approval_done" + for flag in approved.risk_flags_json + ) + assert not any( + isinstance(flag, dict) + and flag.get("source") == "manual_approval" + and flag.get("next_approval_stage") == BUDGET_MANAGER_APPROVAL_STAGE + for flag in approved.risk_flags_json + ) + + +def test_application_route_ignores_reimbursement_stage_current_risks() -> None: + with build_session() as db: + department, manager, _budget_manager, employee = _seed_people(db, suffix="MIXED-STAGE") + _seed_budget_allocation( + db, + department_id=department.id, + department_name=department.name, + ) + claim = ExpenseClaim( + claim_no="APP-20260530-MIXED-STAGE", + employee_id=employee.id, + employee_name=employee.name, + department_id=department.id, + department_name=department.name, + project_code=None, + expense_type="travel_application", + reason="客户现场沟通", + location="上海", + amount=Decimal("500.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage=DIRECT_MANAGER_APPROVAL_STAGE, + risk_flags_json=[ + { + "source": "submission_review", + "severity": "high", + "label": "报销票据风险", + "message": "报销票据城市与行程城市不一致。", + "business_stage": "reimbursement", + } + ], + ) + db.add(claim) + db.commit() + + approved = ExpenseClaimService(db).approve_claim( + claim.id, + CurrentUserContext( + username=manager.email, + name=manager.name, + role_codes=["manager"], + is_admin=False, + ), + opinion="业务必要,同意申请。", + ) + + assert approved is not None + assert approved.status == "approved" + assert approved.approval_stage == APPROVAL_DONE_STAGE + route_flag = [ + flag + for flag in approved.risk_flags_json + if isinstance(flag, dict) and flag.get("source") == "approval_routing" + ][0] + assert route_flag["requires_budget_review"] is False + assert route_flag["current_risk_count"] == 0 + assert route_flag["business_stage"] == "expense_application" + + +def test_risky_reimbursement_routes_to_budget_then_finance() -> None: + with build_session() as db: + department, manager, budget_manager, employee = _seed_people(db, suffix="RISK-CLAIM") + _seed_budget_allocation( + db, + department_id=department.id, + department_name=department.name, + ) + claim = ExpenseClaim( + claim_no="RE-20260530-RISK-ROUTE", + employee_id=employee.id, + employee_name=employee.name, + department_id=department.id, + department_name=department.name, + project_code=None, + expense_type="travel", + reason="客户现场沟通", + location="上海", + amount=Decimal("600.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 30, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage=DIRECT_MANAGER_APPROVAL_STAGE, + risk_flags_json=[ + { + "source": "submission_review", + "severity": "high", + "label": "行程城市异常", + "message": "票据城市与申报目的地不一致", + } + ], + ) + db.add(claim) + db.commit() + service = ExpenseClaimService(db) + + routed = service.approve_claim( + claim.id, + CurrentUserContext( + username=manager.email, + name=manager.name, + role_codes=["manager"], + is_admin=False, + ), + opinion="业务属实,同意报账", + ) + + assert routed is not None + assert routed.status == "submitted" + assert routed.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE + assert any( + isinstance(flag, dict) + and flag.get("source") == "approval_routing" + and flag.get("requires_budget_review") is True + and flag.get("route") == "budget_manager" + and any("行程城市异常" in item for item in flag.get("reasons", [])) + for flag in routed.risk_flags_json + ) + + budget_approved = service.approve_claim( + claim.id, + CurrentUserContext( + username=budget_manager.email, + name=budget_manager.name, + role_codes=["budget_monitor"], + is_admin=False, + ), + opinion="预算影响已复核,同意进入财务审批", + ) + + assert budget_approved is not None + assert budget_approved.status == "submitted" + assert budget_approved.approval_stage == FINANCE_APPROVAL_STAGE + assert any( + isinstance(flag, dict) + and flag.get("source") == "budget_approval" + and flag.get("event_type") == "expense_claim_budget_approval" + and flag.get("next_approval_stage") == FINANCE_APPROVAL_STAGE + for flag in budget_approved.risk_flags_json + ) diff --git a/server/tests/test_expense_claim_platform_risk_stage.py b/server/tests/test_expense_claim_platform_risk_stage.py new file mode 100644 index 0000000..502ef6b --- /dev/null +++ b/server/tests/test_expense_claim_platform_risk_stage.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +from datetime import UTC, date, datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api.deps import CurrentUserContext +from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType +from app.db.base import Base +from app.models.agent_asset import AgentAsset +from app.models.financial_record import ExpenseClaim, ExpenseClaimItem +from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager +from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY +from app.services.expense_claims import ExpenseClaimService +from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY + + +def build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + return session_factory() + + +def _build_rule_payload( + *, + rule_code: str, + name: str, + business_stage: str, + message: str, + expense_category: str = "travel", +) -> dict[str, Any]: + return { + "schema_version": "2.0", + "rule_code": rule_code, + "name": name, + "evaluator": "template_rule", + "enabled": True, + "applies_to": { + "domains": [AgentAssetDomain.EXPENSE.value], + "business_stages": [business_stage], + "expense_categories": [expense_category], + }, + "template_key": COMPOSITE_RULE_TEMPLATE_KEY, + "params": { + "template_key": COMPOSITE_RULE_TEMPLATE_KEY, + "field_keys": ["claim.reason"], + "conditions": [ + { + "id": "missing_exception_reason", + "operator": "not_contains_any", + "fields": ["claim.reason"], + "keywords": ["专项审批"], + } + ], + "hit_logic": {"all": ["missing_exception_reason"]}, + "message_template": message, + }, + "outcomes": {"fail": {"severity": "high", "action": "manual_review"}}, + } + + +def _add_active_rule_asset( + db: Session, + manager: AgentAssetRuleLibraryManager, + *, + rule_code: str, + business_stage: str, + message: str, + expense_category: str = "travel", +) -> None: + file_name = f"{rule_code}.json" + payload = _build_rule_payload( + rule_code=rule_code, + name=message, + business_stage=business_stage, + message=message, + expense_category=expense_category, + ) + manager.write_rule_library_json( + library=RISK_RULES_LIBRARY, + file_name=file_name, + payload=payload, + ) + db.add( + AgentAsset( + asset_type=AgentAssetType.RULE.value, + code=rule_code, + name=message, + description="", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=["差旅费"], + owner="pytest", + status=AgentAssetStatus.ACTIVE.value, + current_version="v1.0.0", + published_version="v1.0.0", + config_json={ + "detail_mode": "json_risk", + "rule_library": RISK_RULES_LIBRARY, + "rule_document": {"file_name": file_name}, + }, + ) + ) + + +def _build_claim(*, claim_no: str, expense_type: str, status: str = "draft") -> ExpenseClaim: + return ExpenseClaim( + claim_no=claim_no, + employee_name="张三", + department_name="研发部", + project_code=None, + expense_type=expense_type, + reason="去上海处理客户现场问题", + location="上海", + amount=Decimal("1200.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 30, tzinfo=UTC), + submitted_at=None, + status=status, + approval_stage="待提交", + risk_flags_json=[], + ) + + +def _patch_rule_manager(monkeypatch, manager: AgentAssetRuleLibraryManager) -> None: + from app.services import expense_claim_platform_risk + + monkeypatch.setattr( + expense_claim_platform_risk, + "AgentAssetRuleLibraryManager", + lambda: manager, + ) + + +def test_platform_risk_rules_are_filtered_by_business_stage_and_category( + tmp_path, + monkeypatch, +) -> None: + with build_session() as db: + manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") + _patch_rule_manager(monkeypatch, manager) + _add_active_rule_asset( + db, + manager, + rule_code="application.stage.rule", + business_stage="expense_application", + message="申请环节规则命中", + ) + _add_active_rule_asset( + db, + manager, + rule_code="reimbursement.stage.rule", + business_stage="reimbursement", + message="报账环节规则命中", + ) + _add_active_rule_asset( + db, + manager, + rule_code="office.application.rule", + business_stage="expense_application", + message="办公申请规则不应命中差旅", + expense_category="office", + ) + db.commit() + + service = ExpenseClaimService(db) + application_claim = _build_claim( + claim_no="APP-TEST-001", + expense_type="travel_application", + ) + reimbursement_claim = _build_claim( + claim_no="CLM-TEST-001", + expense_type="travel", + ) + + application_review = service.evaluate_platform_risk_rules( + application_claim, + business_stage="expense_application", + ) + reimbursement_review = service.evaluate_platform_risk_rules( + reimbursement_claim, + business_stage="reimbursement", + ) + + assert [flag["rule_code"] for flag in application_review["flags"]] == [ + "application.stage.rule" + ] + assert application_review["flags"][0]["message"] == "申请环节规则命中" + assert application_review["flags"][0]["business_stage"] == "expense_application" + assert application_review["flags"][0]["visibility_scope"] == "leader" + assert application_review["flags"][0]["actionability"] == "review_decision" + assert [flag["rule_code"] for flag in reimbursement_review["flags"]] == [ + "reimbursement.stage.rule" + ] + assert reimbursement_review["flags"][0]["message"] == "报账环节规则命中" + assert reimbursement_review["flags"][0]["business_stage"] == "reimbursement" + assert reimbursement_review["flags"][0]["visibility_scope"] == "submitter" + assert reimbursement_review["flags"][0]["actionability"] == "fixable_by_submitter" + + +def test_expense_application_pre_review_runs_stage_rules(tmp_path, monkeypatch) -> None: + with build_session() as db: + manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") + _patch_rule_manager(monkeypatch, manager) + _add_active_rule_asset( + db, + manager, + rule_code="application.pre.review.rule", + business_stage="expense_application", + message="申请预审规则命中", + ) + claim = _build_claim(claim_no="APP-TEST-002", expense_type="travel_application") + db.add(claim) + db.commit() + + current_user = CurrentUserContext( + username="张三", + name="张三", + role_codes=[], + is_admin=False, + department_name="研发部", + ) + reviewed = ExpenseClaimService(db).pre_review_claim(claim.id, current_user) + + assert reviewed is not None + rule_flags = [ + flag + for flag in reviewed.risk_flags_json + if isinstance(flag, dict) + and flag.get("rule_code") == "application.pre.review.rule" + ] + assert len(rule_flags) == 1 + assert rule_flags[0]["message"] == "申请预审规则命中" + assert rule_flags[0]["business_stage"] == "expense_application" + assert rule_flags[0]["risk_domain"] == "policy" + assert rule_flags[0]["visibility_scope"] == "leader" + ai_pre_review = [ + flag + for flag in reviewed.risk_flags_json + if isinstance(flag, dict) and flag.get("source") == "ai_pre_review" + ][0] + assert ai_pre_review["passed"] is False + assert ai_pre_review["blocking_risk_count"] == 1 + assert ai_pre_review["business_stage"] == "expense_application" + + +def test_reimbursement_item_sync_persists_rule_center_risk_preview( + tmp_path, + monkeypatch, +) -> None: + with build_session() as db: + manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") + _patch_rule_manager(monkeypatch, manager) + _add_active_rule_asset( + db, + manager, + rule_code="reimbursement.preview.rule", + business_stage="reimbursement", + message="报销风险预判命中", + ) + claim = _build_claim(claim_no="RE-TEST-001", expense_type="travel") + claim.invoice_count = 1 + claim.items = [ + ExpenseClaimItem( + item_date=date(2026, 5, 30), + item_type="travel", + item_reason="客户现场支持", + item_location="上海", + item_amount=Decimal("1200.00"), + invoice_id="ticket.pdf", + ) + ] + db.add(claim) + db.commit() + + service = ExpenseClaimService(db) + service._sync_claim_from_items(claim) + + rule_flags = [ + flag + for flag in claim.risk_flags_json + if isinstance(flag, dict) + and flag.get("rule_code") == "reimbursement.preview.rule" + ] + assert len(rule_flags) == 1 + assert rule_flags[0]["message"] == "报销风险预判命中" + assert rule_flags[0]["severity"] == "high" + assert rule_flags[0]["business_stage"] == "reimbursement" + assert rule_flags[0]["visibility_scope"] == "submitter" + assert rule_flags[0]["actionability"] == "fixable_by_submitter" diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index 692ffe5..2d9f958 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -24,6 +24,11 @@ from app.services.agent_conversations import AgentConversationService from app.services.budget import BudgetService from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claims import ExpenseClaimService +from app.services.expense_claim_workflow_constants import ( + APPROVAL_DONE_STAGE, + BUDGET_MANAGER_APPROVAL_STAGE, + DIRECT_MANAGER_APPROVAL_STAGE, +) from app.services.ontology import SemanticOntologyService from app.services.ocr import OcrService @@ -120,6 +125,16 @@ def _seed_budget_monitor_role(db: Session) -> Role: return role +def _seed_executive_role(db: Session) -> Role: + role = db.query(Role).filter(Role.role_code == "executive").one_or_none() + if role is not None: + return role + role = Role(role_code="executive", name="Senior finance") + db.add(role) + db.flush() + return role + + def test_validate_claim_for_submission_allows_office_claim_without_location() -> None: service = ExpenseClaimService.__new__(ExpenseClaimService) claim = build_claim(expense_type="office", location="待补充") @@ -270,6 +285,63 @@ def test_save_draft_persists_user_changed_expense_category() -> None: assert claim.items[0].item_type == "office" +def test_upsert_draft_from_ontology_persists_linked_application_context() -> None: + user_id = "linked-application-context@example.com" + message = "业务发生时间:2026-05-20,去北京支撑国网部署,火车票354元,申请差旅费报销" + + with build_session() as db: + employee = Employee( + employee_no="E5103", + name="关联员工", + email=user_id, + ) + db.add(employee) + db.commit() + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=message, + user_id=user_id, + ) + ) + + result = ExpenseClaimService(db).upsert_draft_from_ontology( + run_id=ontology.run_id, + user_id=user_id, + message=message, + ontology=ontology, + context_json={ + "name": "关联员工", + "user_input_text": message, + "review_action": "save_draft", + "review_form_values": { + "expense_type": "差旅费", + "amount": "354元", + "application_claim_id": "application-linked-1", + "application_claim_no": "AP-202605-001", + "application_reason": "支撑国网仿生产环境部署", + "application_location": "北京", + "application_amount": "3000", + }, + "expense_scene_selection": { + "expense_type": "travel", + "application_claim_id": "application-linked-1", + "application_claim_no": "AP-202605-001", + }, + }, + ) + + claim = db.get(ExpenseClaim, result["claim_id"]) + assert claim is not None + link_flag = next( + flag + for flag in claim.risk_flags_json + if isinstance(flag, dict) and flag.get("source") == "application_link" + ) + assert link_flag["application_claim_no"] == "AP-202605-001" + assert link_flag["application_claim_id"] == "application-linked-1" + assert link_flag["application_detail"]["application_reason"] == "支撑国网仿生产环境部署" + + def test_unsaved_conversation_expires_after_retention_but_saved_conversation_stays() -> None: with build_session() as db: service = AgentConversationService(db) @@ -1446,6 +1518,98 @@ def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_p assert not any("用途字段" in point for point in uploaded_meta["analysis"]["points"]) +def test_upload_attachment_response_includes_refreshed_rule_center_risk_flags( + monkeypatch, + tmp_path, +) -> None: + current_user = CurrentUserContext( + username="emp-1", + name="张三", + role_codes=[], + is_admin=False, + ) + + def fake_recognize( + self, + files: list[tuple[str, bytes, str | None]], + ) -> OcrRecognizeBatchRead: + return OcrRecognizeBatchRead( + total_file_count=1, + success_count=1, + documents=[ + OcrRecognizeDocumentRead( + filename="train-ticket.png", + media_type="image/png", + text="中国铁路电子客票 武汉-上海 2026-02-20 票价354元", + summary="铁路电子客票,武汉至上海,票价 354 元。", + avg_score=0.98, + line_count=1, + page_count=1, + document_type="train_ticket", + document_type_label="火车/高铁票", + scene_code="travel", + scene_label="差旅费", + document_fields=[ + {"key": "route", "label": "行程", "value": "武汉-上海"}, + {"key": "trip_date", "label": "行程日期", "value": "2026-02-20"}, + {"key": "fare", "label": "票价", "value": "354元"}, + ], + ) + ], + ) + + def fake_evaluate_platform_risk_rules(self, claim, **kwargs): + assert kwargs.get("business_stage") == "reimbursement" + return { + "flags": [ + { + "source": "submission_review", + "hit_source": "rule_center", + "rule_type": "risk", + "rule_code": "risk.test.upload_preview", + "severity": "high", + "message": "测试规则命中", + "business_stage": "reimbursement", + "risk_domain": "invoice", + "visibility_scope": "submitter", + "actionability": "fixable_by_submitter", + } + ], + "blocking_reasons": ["测试规则命中"], + } + + monkeypatch.setattr(OcrService, "recognize_files", fake_recognize) + monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path) + monkeypatch.setattr( + ExpenseClaimService, + "evaluate_platform_risk_rules", + fake_evaluate_platform_risk_rules, + ) + + with build_session() as db: + claim = build_claim(expense_type="travel", location="北京") + claim.items[0].invoice_id = None + db.add(claim) + db.commit() + + payload = ExpenseClaimService(db).upload_claim_item_attachment( + claim_id=claim.id, + item_id=claim.items[0].id, + filename="train-ticket.png", + content=b"fake-image-bytes", + media_type="image/png", + current_user=current_user, + ) + + assert payload is not None + assert any( + flag.get("rule_code") == "risk.test.upload_preview" + for flag in payload["claim_risk_flags"] + ) + db.refresh(claim) + assert payload["claim_risk_flags"] == claim.risk_flags_json + + def test_upload_hotel_attachment_audits_date_like_amount(monkeypatch, tmp_path) -> None: current_user = CurrentUserContext( username="emp-1", @@ -1962,6 +2126,56 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None: assert submitted.submitted_at is not None +def test_pre_review_claim_records_ai_result_without_submitting() -> None: + current_user = CurrentUserContext( + username="emp-pre-review@example.com", + name="张三", + role_codes=[], + is_admin=False, + ) + + with build_session() as db: + manager = Employee( + employee_no="E7050", + name="李经理", + email="manager-pre-review@example.com", + ) + employee = Employee( + employee_no="E7051", + name="张三", + email="emp-pre-review@example.com", + manager=manager, + ) + claim = build_claim(expense_type="transport", location="上海") + claim.employee = employee + claim.employee_id = employee.id + claim.items[0].invoice_id = "taxi-ticket.png" + claim.risk_flags_json = [ + { + "source": "manual_risk", + "severity": "high", + "label": "票据风险", + "message": "票据金额与行程不匹配。", + } + ] + db.add_all([manager, employee, claim]) + db.commit() + + reviewed = ExpenseClaimService(db).pre_review_claim(claim.id, current_user) + + assert reviewed is not None + assert reviewed.status == "draft" + assert reviewed.approval_stage == "AI预审" + assert reviewed.submitted_at is None + pre_review_flag = next( + flag + for flag in reviewed.risk_flags_json + if isinstance(flag, dict) and flag.get("source") == "ai_pre_review" + ) + assert pre_review_flag["status"] == "failed" + assert pre_review_flag["next_action"] == "risk_explanation_required" + + def test_submit_claim_allows_returned_claim_to_be_resubmitted() -> None: current_user = CurrentUserContext( username="emp-submit@example.com", @@ -3163,7 +3377,7 @@ def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch approval_stage="待提交", risk_flags_json=[ { - "source": "submission_review", + "source": "platform_risk", "severity": "medium", "message": "旧 AI 预审提示不应保留到申请单提交结果。", } @@ -3423,6 +3637,12 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg "transport_mode": "高铁", "amount": "12000.00", }, + }, + { + "source": "submission_review", + "severity": "high", + "label": "申请风险复核", + "message": "申请金额和行程安排需要预算管理者二次确认。", } ], ) @@ -3510,6 +3730,273 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg ) +def test_application_routes_to_department_p8_executive_with_approver_name() -> None: + manager_user = CurrentUserContext( + username="manager-executive-route@example.com", + name="Manager", + role_codes=["manager"], + is_admin=False, + ) + budget_user = CurrentUserContext( + username="p8-executive-route@example.com", + name="P8 Executive", + role_codes=["executive"], + is_admin=False, + ) + + with build_session() as db: + executive_role = _seed_executive_role(db) + department = OrganizationUnit( + unit_code="DELIVERY-EXECUTIVE-ROUTE", + name="Engineering", + unit_type="department", + ) + manager = Employee( + employee_no="E-EXEC-ROUTE-MGR", + name="Manager", + email="manager-executive-route@example.com", + organization_unit=department, + ) + budget_manager = Employee( + employee_no="E-EXEC-ROUTE-P8", + name="P8 Executive", + email="p8-executive-route@example.com", + grade="P8", + organization_unit=department, + roles=[executive_role], + ) + employee = Employee( + employee_no="E-EXEC-ROUTE-APP", + name="Applicant", + email="applicant-executive-route@example.com", + manager=manager, + organization_unit=department, + ) + db.add_all([department, manager, budget_manager, employee]) + db.flush() + claim = ExpenseClaim( + claim_no="APP-20260531-EXEC-ROUTE", + employee_id=employee.id, + employee_name=employee.name, + department_id=department.id, + department_name=department.name, + project_code="PRJ-A", + expense_type="travel_application", + reason="Production deployment support", + location="Beijing", + amount=Decimal("12000.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage=DIRECT_MANAGER_APPROVAL_STAGE, + risk_flags_json=[ + { + "source": "submission_review", + "severity": "high", + "label": "Route risk", + "message": "Application requires budget confirmation.", + } + ], + ) + db.add(claim) + db.commit() + claim_id = claim.id + + routed = ExpenseClaimService(db).approve_claim( + claim_id, + manager_user, + opinion="Approved by direct manager.", + ) + + assert routed is not None + assert routed.status == "submitted" + assert routed.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE + assert getattr(routed, "budget_approver_name", "") == "P8 Executive" + assert getattr(routed, "budget_approver_grade", "") == "P8" + assert getattr(routed, "budget_approver_role_code", "") == "executive" + assert any( + isinstance(flag, dict) + and flag.get("source") == "manual_approval" + and flag.get("next_approval_stage") == BUDGET_MANAGER_APPROVAL_STAGE + and flag.get("next_approver_name") == "P8 Executive" + and flag.get("next_approver_grade") == "P8" + and flag.get("next_approver_role_code") == "executive" + for flag in routed.risk_flags_json + ) + + approved = ExpenseClaimService(db).approve_claim( + claim_id, + budget_user, + opinion="Budget confirmed.", + ) + + assert approved is not None + assert approved.status == "approved" + assert approved.approval_stage == APPROVAL_DONE_STAGE + + +def test_direct_manager_cannot_route_application_to_missing_budget_approver() -> None: + manager_user = CurrentUserContext( + username="manager-missing-budget@example.com", + name="Manager", + role_codes=["manager"], + is_admin=False, + ) + + with build_session() as db: + department = OrganizationUnit( + unit_code="DELIVERY-MISSING-BUDGET", + name="Engineering", + unit_type="department", + ) + manager = Employee( + employee_no="E-MISSING-BUDGET-MGR", + name="Manager", + email="manager-missing-budget@example.com", + organization_unit=department, + ) + employee = Employee( + employee_no="E-MISSING-BUDGET-APP", + name="Applicant", + email="applicant-missing-budget@example.com", + manager=manager, + organization_unit=department, + ) + db.add_all([department, manager, employee]) + db.flush() + claim = ExpenseClaim( + claim_no="APP-20260531-MISSING-BUDGET", + employee_id=employee.id, + employee_name=employee.name, + department_id=department.id, + department_name=department.name, + project_code="PRJ-A", + expense_type="travel_application", + reason="Production deployment support", + location="Beijing", + amount=Decimal("12000.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage=DIRECT_MANAGER_APPROVAL_STAGE, + risk_flags_json=[ + { + "source": "submission_review", + "severity": "high", + "label": "Route risk", + "message": "Application requires budget confirmation.", + } + ], + ) + db.add(claim) + db.commit() + claim_id = claim.id + + with pytest.raises(ValueError, match="未找到同部门 P8 预算审批人"): + ExpenseClaimService(db).approve_claim( + claim_id, + manager_user, + opinion="Approved by direct manager.", + ) + + db.refresh(claim) + assert claim.status == "submitted" + assert claim.approval_stage == DIRECT_MANAGER_APPROVAL_STAGE + assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0 + + +def test_direct_manager_p8_executive_completes_application_without_duplicate_budget_approval() -> None: + manager_user = CurrentUserContext( + username="manager-executive-merged@example.com", + name="P8 Manager", + role_codes=["manager"], + is_admin=False, + ) + + with build_session() as db: + executive_role = _seed_executive_role(db) + department = OrganizationUnit( + unit_code="DELIVERY-EXECUTIVE-MERGED", + name="Engineering", + unit_type="department", + ) + manager = Employee( + employee_no="E-EXEC-MERGED-MGR", + name="P8 Manager", + email="manager-executive-merged@example.com", + grade="P8", + organization_unit=department, + roles=[executive_role], + ) + employee = Employee( + employee_no="E-EXEC-MERGED-APP", + name="Applicant", + email="applicant-executive-merged@example.com", + manager=manager, + organization_unit=department, + ) + db.add_all([department, manager, employee]) + db.flush() + claim = ExpenseClaim( + claim_no="APP-20260531-EXEC-MERGED", + employee_id=employee.id, + employee_name=employee.name, + department_id=department.id, + department_name=department.name, + project_code="PRJ-A", + expense_type="travel_application", + reason="Production deployment support", + location="Beijing", + amount=Decimal("12000.00"), + currency="CNY", + invoice_count=0, + occurred_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 31, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage=DIRECT_MANAGER_APPROVAL_STAGE, + risk_flags_json=[ + { + "source": "submission_review", + "severity": "high", + "label": "Route risk", + "message": "Application requires budget confirmation.", + } + ], + ) + db.add(claim) + db.commit() + claim_id = claim.id + + approved = ExpenseClaimService(db).approve_claim( + claim_id, + manager_user, + opinion="Approved by direct manager and budget owner.", + ) + + assert approved is not None + assert approved.status == "approved" + assert approved.approval_stage == APPROVAL_DONE_STAGE + assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1 + assert not any( + isinstance(flag, dict) + and flag.get("next_approval_stage") == BUDGET_MANAGER_APPROVAL_STAGE + for flag in approved.risk_flags_json + ) + assert any( + isinstance(flag, dict) + and flag.get("source") == "manual_approval" + and flag.get("next_status") == "approved" + and flag.get("next_approval_stage") == APPROVAL_DONE_STAGE + and flag.get("budget_approval_merged") is True + and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver" + for flag in approved.risk_flags_json + ) + + def test_direct_manager_budget_monitor_completes_application_claim_without_duplicate_budget_approval() -> None: manager_user = CurrentUserContext( username="manager-budget-monitor-application@example.com", @@ -3559,7 +4046,14 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC), status="submitted", approval_stage="直属领导审批", - risk_flags_json=[], + risk_flags_json=[ + { + "source": "submission_review", + "severity": "high", + "label": "申请风险复核", + "message": "申请金额和行程安排需要预算管理者二次确认。", + } + ], ) db.add(claim) db.commit() @@ -3590,7 +4084,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli and flag.get("next_status") == "approved" and flag.get("next_approval_stage") == "审批完成" and flag.get("budget_approval_merged") is True - and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_monitor" + and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver" for flag in approved.risk_flags_json ) generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one() @@ -3775,7 +4269,14 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf submitted_at=None, status="draft", approval_stage="待提交", - risk_flags_json=[], + risk_flags_json=[ + { + "source": "platform_risk", + "severity": "high", + "label": "申请风险复核", + "message": "申请金额和行程安排需要预算管理者二次确认。", + } + ], ) db.add(claim) db.commit() @@ -3806,6 +4307,23 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf for flag in generated_draft.risk_flags_json ) + deleted = service.delete_claim( + generated_draft.id, + CurrentUserContext( + username="browser-session-user", + name="", + role_codes=["user"], + is_admin=False, + employee_no="E-BUDGET-APP", + ), + ) + db.refresh(reservation) + + assert deleted is not None + assert db.get(ExpenseClaim, generated_draft.id) is None + assert reservation.source_status == "released" + assert reservation.released_amount == Decimal("12000.00") + def test_direct_manager_approval_defaults_blank_opinion_to_agree() -> None: current_user = CurrentUserContext( @@ -4554,7 +5072,7 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica is_admin=False, ) p8_without_budget_role = CurrentUserContext( - username="budget-p8-list@example.com", + username="p8-without-budget-list@example.com", name="budget manager", role_codes=["manager"], is_admin=False, @@ -4580,6 +5098,13 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica organization_unit=delivery_department, roles=[budget_role], ) + p8_without_budget_employee = Employee( + employee_no="E-P8-NO-BUDGET-LIST", + name="P8 No Budget Role", + email="p8-without-budget-list@example.com", + grade="P8", + organization_unit=delivery_department, + ) employee = Employee( employee_no="E-BUDGET-LIST-OWNER", name="张三", @@ -4592,7 +5117,14 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica email="budget-list-market@example.com", organization_unit=market_department, ) - db.add_all([delivery_department, market_department, budget_manager, employee, market_employee]) + db.add_all([ + delivery_department, + market_department, + budget_manager, + p8_without_budget_employee, + employee, + market_employee, + ]) db.flush() db.add_all( [ @@ -4660,5 +5192,8 @@ def test_list_approval_claims_allows_budget_monitor_to_view_budget_stage_applica claims = ExpenseClaimService(db).list_approval_claims(current_user) assert [claim.claim_no for claim in claims] == ["APP-BUDGET-LIST-201"] + assert getattr(claims[0], "budget_approver_name", "") == "赵预算" + assert getattr(claims[0], "budget_approver_grade", "") == "P8" + assert getattr(claims[0], "budget_approver_role_code", "") == "budget_monitor" claims_without_budget_role = ExpenseClaimService(db).list_approval_claims(p8_without_budget_role) assert [claim.claim_no for claim in claims_without_budget_role] == [] diff --git a/server/tests/test_hermes_risk_clue_collector.py b/server/tests/test_hermes_risk_clue_collector.py new file mode 100644 index 0000000..8d58e2f --- /dev/null +++ b/server/tests/test_hermes_risk_clue_collector.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.db.base import Base +from app.models.financial_record import ExpenseClaim +from app.services.hermes_risk_clue_collector import HermesRiskClueCollectorService +from app.services.risk_observations import RiskObservationService + + +def build_session_factory() -> sessionmaker[Session]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + return sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def test_risk_clue_collector_outputs_review_packet_without_rule_writes() -> None: + forbidden_rule_execution = "执行" + "规则" + session_factory = build_session_factory() + with session_factory() as db: + claim = ExpenseClaim( + id="claim-risk-clue-1", + claim_no="RE-20260531090000-ABCDEFGH", + employee_name="张三", + department_name="销售部", + expense_type="travel", + reason="客户现场支持", + location="上海", + amount=Decimal("9800.00"), + currency="CNY", + invoice_count=2, + occurred_at=datetime(2026, 5, 30, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 31, 9, 0, tzinfo=UTC), + status="submitted", + approval_stage="财务审批", + risk_flags_json=[ + { + "source": "rule_center", + "rule_code": "risk.travel.large_without_preapproval", + "label": "大额差旅缺少事前申请", + "message": "报销金额较高,未找到对应事前申请。", + "severity": "high", + } + ], + ) + db.add(claim) + db.flush() + + RiskObservationService(db).upsert_observation( + { + "observation_key": "risk:claim-risk-clue-1:large_without_preapproval", + "subject_type": "expense_claim", + "subject_key": "claim:claim-risk-clue-1", + "subject_label": claim.claim_no, + "claim_id": claim.id, + "claim_no": claim.claim_no, + "risk_type": "preapproval_absent", + "risk_signal": "preapproval_absent", + "title": "大额差旅缺少事前申请", + "description": "报销金额较高,暂未匹配到事前申请,需要人工复核。", + "risk_score": 86, + "risk_level": "high", + "confidence_score": 0.82, + "source": "rule_center", + "contribution_scores": {"S_rule": 86}, + "evidence": [ + { + "source": "rule_center", + "title": "规则命中", + "detail": "金额 9800 元,缺少事前申请。", + } + ], + "policy_refs": ["risk.travel.large_without_preapproval"], + } + ) + db.commit() + + packet = HermesRiskClueCollectorService(db).collect_risk_clues(run_id="run-risk-clue") + + assert packet["task_type"] == "risk_clue_collect" + assert packet["writes_rules"] is False + assert packet["human_review_required"] is True + assert "主流程由外层智能体执行" in packet["role_boundary"] + assert forbidden_rule_execution not in packet["role_boundary"] + assert packet["fact_count"] == 1 + assert packet["rule_hit_count"] >= 1 + assert packet["risk_clue_count"] >= 1 + assert packet["facts"][0]["claim_kind"] == "reimbursement" + assert packet["risk_clues"][0]["status"] == "human_review_required" + assert packet["risk_clues"][0]["observation_key"] + assert packet["risk_clues"][0]["feedback_status"] == "unreviewed" + assert packet["risk_clues"][0]["next_action"] + assert "recent" in packet["feedback_summary"] + assert packet["risk_clues"][0]["not_final_conclusion"] is True + serialized = str(packet) + assert "auto_publish" not in serialized + assert "candidate_risk_rules" not in serialized diff --git a/server/tests/test_orchestrator_review_flow.py b/server/tests/test_orchestrator_review_flow.py index e3b979c..74fc33f 100644 --- a/server/tests/test_orchestrator_review_flow.py +++ b/server/tests/test_orchestrator_review_flow.py @@ -58,6 +58,13 @@ def skip_agent_foundation_bootstrap(monkeypatch: pytest.MonkeyPatch) -> None: }, "生成 9 条快照", ), + ( + "risk_clue_collect", + "task.hermes.risk_rule_discovery", + "app.services.hermes_risk_clue_collector.HermesRiskClueCollectorService.collect_risk_clues", + {"fact_count": 4, "rule_hit_count": 3, "risk_clue_count": 2}, + "输出 2 条待复核线索", + ), ], ) def test_schedule_digital_employee_task_runs_real_service( @@ -708,7 +715,7 @@ def test_orchestrator_application_session_does_not_use_reimbursement_scene_promp assert result.get("review_payload") is None -def test_orchestrator_application_session_guides_transport_amount_and_submit( +def test_orchestrator_application_session_guides_transport_estimate_and_submit( monkeypatch, ) -> None: monkeypatch.setattr( @@ -749,15 +756,6 @@ def test_orchestrator_application_session_guides_transport_amount_and_submit( ) ) third = service.run( - OrchestratorRequest( - source="user_message", - user_id="application-flow@example.com", - conversation_id=first.conversation_id, - message="预计总费用:12000元", - context_json=context_json, - ) - ) - fourth = service.run( OrchestratorRequest( source="user_message", user_id="application-flow@example.com", @@ -768,29 +766,27 @@ def test_orchestrator_application_session_guides_transport_amount_and_submit( ) assert first.status == "blocked" - assert "当前还需要补充:出行方式、用户预估费用" in first.result["answer"] + assert "当前还需要补充:出行方式" in first.result["answer"] assert [item["label"] for item in first.result["suggested_actions"]] == ["一次性补充申请信息"] - assert first.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "出行方式:\n用户预估费用:" + assert first.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "出行方式:" - assert "当前还需要补充:用户预估费用" in second.result["answer"] - assert [item["label"] for item in second.result["suggested_actions"]] == ["一次性补充申请信息"] - assert second.result["suggested_actions"][0]["action_type"] == "prefill_composer" - assert second.result["suggested_actions"][0]["payload"]["prompt_prefill"] == "用户预估费用:" + assert "这是费用申请核对结果" in second.result["answer"] + assert "| 事由 | 支持上海国网服务器部署 |" in second.result["answer"] + assert "| 系统预估费用 |" in second.result["answer"] + assert "按 2026-05-25 参考票价" in second.result["answer"] + assert "2,330元" in second.result["answer"] + assert "请核对上述信息无误" in second.result["answer"] + assert "[确认](#application-submit)" in second.result["answer"] + assert second.status == "blocked" + assert second.result["requires_confirmation"] is True + assert second.result["suggested_actions"] == [] - assert "这是模拟的费用申请结果" in third.result["answer"] - assert "| 事由 | 支持上海国网服务器部署 |" in third.result["answer"] - assert "请核对上述信息无误" in third.result["answer"] - assert "[确认](#application-submit)" in third.result["answer"] - assert third.status == "blocked" - assert third.result["requires_confirmation"] is True + assert third.status == "succeeded" + assert third.result["clarification_required"] is False + assert third.result["missing_slots"] == [] + assert "申请单据已生成,并已进入审批流程" in third.result["answer"] + assert "系统已推送给 陈硕 审核,当前节点:陈硕审核中" in third.result["answer"] assert third.result["suggested_actions"] == [] - - assert fourth.status == "succeeded" - assert fourth.result["clarification_required"] is False - assert fourth.result["missing_slots"] == [] - assert "申请单据已生成,并已进入审批流程" in fourth.result["answer"] - assert "系统已推送给 陈硕 审核,当前节点:陈硕审核中" in fourth.result["answer"] - assert fourth.result["suggested_actions"] == [] application_claims = [ claim for claim in db.query(ExpenseClaim).all() @@ -799,7 +795,7 @@ def test_orchestrator_application_session_guides_transport_amount_and_submit( assert len(application_claims) == 1 assert application_claims[0].status == "submitted" assert application_claims[0].approval_stage == "直属领导审批" - assert fourth.result["draft_payload"]["claim_no"] == application_claims[0].claim_no + assert third.result["draft_payload"]["claim_no"] == application_claims[0].claim_no def test_orchestrator_application_submit_bypasses_generic_operation_block( @@ -833,21 +829,12 @@ def test_orchestrator_application_submit_bypasses_generic_operation_block( context_json=context_json, ) ) - service.run( - OrchestratorRequest( - source="user_message", - user_id="application-approval-required@example.com", - conversation_id=first.conversation_id, - message="飞机", - context_json=context_json, - ) - ) preview = service.run( OrchestratorRequest( source="user_message", user_id="application-approval-required@example.com", conversation_id=first.conversation_id, - message="预计总费用:12000元", + message="飞机", context_json=context_json, ) ) diff --git a/server/tests/test_reimbursement_endpoints.py b/server/tests/test_reimbursement_endpoints.py index 12bab8f..bff5a1c 100644 --- a/server/tests/test_reimbursement_endpoints.py +++ b/server/tests/test_reimbursement_endpoints.py @@ -15,6 +15,7 @@ from app.db.base import Base from app.main import create_app from app.models.employee import Employee from app.models.financial_record import ExpenseClaim, ExpenseClaimItem +from app.models.organization import OrganizationUnit from app.models.role import Role from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage @@ -367,11 +368,32 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review() def test_approve_application_endpoint_routes_direct_manager_review_to_budget_review() -> None: client, session_factory = build_client() with session_factory() as db: + department = OrganizationUnit( + id="dept-1", + unit_code="DELIVERY-API", + name="交付部", + unit_type="department", + ) + budget_role = Role( + id="role-budget-application-approve-1", + role_code="budget_monitor", + name="预算监控员", + ) manager = Employee( id="mgr-application-approve-1", employee_no="E21002", name="李经理", email="manager-application-approve-api@example.com", + organization_unit=department, + ) + budget_manager = Employee( + id="budget-application-approve-1", + employee_no="E31002", + name="赵预算", + email="budget-application-approve-api@example.com", + grade="P8", + organization_unit=department, + roles=[budget_role], ) employee = Employee( id="emp-application-approve-1", @@ -379,6 +401,7 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev name="张三", email="zhangsan-application-approve-api@example.com", manager=manager, + organization_unit=department, ) claim = ExpenseClaim( id="claim-application-approve-1", @@ -398,9 +421,16 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC), status="submitted", approval_stage="直属领导审批", - risk_flags_json=[], + risk_flags_json=[ + { + "source": "submission_review", + "severity": "high", + "label": "申请风险复核", + "message": "申请金额和行程安排需要预算管理者二次确认。", + } + ], ) - db.add_all([manager, employee, claim]) + db.add_all([department, budget_role, manager, budget_manager, employee, claim]) db.commit() response = client.post( @@ -424,6 +454,7 @@ def test_approve_application_endpoint_routes_direct_manager_review_to_budget_rev and item["operator"] == "李经理" and item["next_status"] == "submitted" and item["next_approval_stage"] == "预算管理者审批" + and item["next_approver_name"] == "赵预算" for item in payload["risk_flags_json"] ) @@ -555,3 +586,25 @@ def test_claim_item_delete_removes_item_and_attachment(monkeypatch, tmp_path) -> headers=headers, ) assert deleted_meta_response.status_code == 404 + + +def test_claim_delete_allows_draft_owner_by_employee_id_without_employee_no_header(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path) + + client, session_factory = build_client() + with session_factory() as db: + claim, _ = seed_claim(db) + claim_id = claim.id + + response = client.delete( + f"/api/v1/reimbursements/claims/{claim_id}", + headers={"x-auth-username": "emp-1", "x-auth-name": "Browser Session User"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["claim_id"] == claim_id + assert payload["status"] == "deleted" + + with session_factory() as db: + assert db.get(ExpenseClaim, claim_id) is None diff --git a/server/tests/test_risk_observations_service.py b/server/tests/test_risk_observations_service.py index 0d67915..c874e41 100644 --- a/server/tests/test_risk_observations_service.py +++ b/server/tests/test_risk_observations_service.py @@ -66,7 +66,9 @@ def test_risk_observation_service_upserts_and_summarizes_dashboard() -> None: assert refreshed.canonical_subject_key == "claim:c1" assert dashboard.total_observations == 2 assert dashboard.high_or_above_count == 2 + assert dashboard.risk_clue_count == 1 assert dashboard.confirmed_count == 1 + assert dashboard.feedback_sample_count == 1 assert dashboard.total_amount == 2400.0 assert dashboard.level_distribution["high"] == 2 assert dashboard.signal_distribution["duplicate_invoice"] == 1 @@ -149,6 +151,8 @@ def test_risk_observation_endpoints_return_list_detail_dashboard_and_feedback() assert detail_response.json()["risk_signal"] == "duplicate_invoice" assert dashboard_response.status_code == 200 assert dashboard_response.json()["total_observations"] == 1 + assert dashboard_response.json()["risk_clue_count"] == 1 + assert dashboard_response.json()["feedback_sample_count"] == 0 assert "top_departments" in dashboard_response.json() assert feedback_response.status_code == 200 assert feedback_response.json()["feedback_type"] == "false_positive" diff --git a/server/tests/test_risk_rule_dsl_examples.py b/server/tests/test_risk_rule_dsl_examples.py index 295ebc8..9b1d8b2 100644 --- a/server/tests/test_risk_rule_dsl_examples.py +++ b/server/tests/test_risk_rule_dsl_examples.py @@ -119,6 +119,53 @@ def test_duplicate_invoice_example_reports_duplicate_evidence() -> None: assert condition["duplicates"] == ["inv-dup-001"] +def test_date_rule_uses_application_month_before_ticket_item_date() -> None: + claim = _claim() + claim.trip_start_date = None + claim.trip_end_date = None + claim.occurred_at = datetime(2026, 2, 20, tzinfo=UTC) + claim.items[0].item_date = date(2026, 2, 20) + claim.risk_flags_json = [ + { + "source": "application_handoff", + "application_detail": { + "application_time": "6月", + }, + } + ] + + manifest = { + "template_key": COMPOSITE_RULE_TEMPLATE_KEY, + "params": { + "template_key": COMPOSITE_RULE_TEMPLATE_KEY, + "conditions": [ + { + "id": "ticket_date_outside_trip", + "operator": "date_outside_range", + "date_fields": ["item.item_date", "attachment.issue_date"], + "range_start_fields": ["claim.trip_start_date"], + "range_end_fields": ["claim.trip_end_date"], + "tolerance_days": 0, + } + ], + "hit_logic": "ticket_date_outside_trip", + "condition_summary": "ticket date outside trip window", + }, + } + + result = RiskRuleTemplateExecutor().evaluate( + manifest, + claim=claim, + contexts=[{"document_info": {"issue_date": "2026-02-20"}}], + ) + + assert result is not None + condition = result["evidence"]["conditions"][0] + assert condition["range_start"] == "2026-06-01" + assert condition["range_end"] == "2026-06-30" + assert condition["outside_dates"] == ["2026-02-20"] + + def _claim(*, amount: Decimal = Decimal("1000.00")) -> ExpenseClaim: claim = ExpenseClaim( claim_no="TEST-RISK-RULE-DSL", diff --git a/server/tests/test_risk_rule_explainability.py b/server/tests/test_risk_rule_explainability.py index 1cc7996..7c21857 100644 --- a/server/tests/test_risk_rule_explainability.py +++ b/server/tests/test_risk_rule_explainability.py @@ -114,6 +114,16 @@ def test_simulation_returns_execution_trace_for_ticket_city_mismatch(tmp_path) - assert simulation.ready is True assert simulation.hit is True assert simulation.normalized_fields["claim.location"] == "北京" + assert simulation.ocr_raw_fields[0]["attachment_name"] == "train-ticket.pdf" + assert simulation.ocr_raw_fields[0]["label"] == "行程路线" + assert any( + field["key"] == "attachment.route_cities" + for field in simulation.hermes_normalized_fields + ) + assert any( + field["key"] == "attachment.route_cities" and field["required"] is True + for field in simulation.executor_input_fields + ) assert simulation.trace["matched"] is True assert "hit" in simulation.trace["path_node_ids"] assert simulation.trace["steps"] diff --git a/server/tests/test_risk_rule_feedback.py b/server/tests/test_risk_rule_feedback.py new file mode 100644 index 0000000..0c774e0 --- /dev/null +++ b/server/tests/test_risk_rule_feedback.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +from collections.abc import Generator + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api.deps import get_db +from app.core.agent_enums import AgentAssetDomain +from app.db.base import Base +from app.main import create_app +from app.models.agent_asset import AgentAsset, AgentAssetRuleFeedback +from app.schemas.agent_asset import ( + AgentAssetRiskRuleFeedbackCreate, + AgentAssetRiskRuleGenerateRequest, +) +from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager +from app.services.agent_assets import AgentAssetService +from app.services.risk_rule_generation import RiskRuleGenerationService + + +class NullRuntimeChatService: + def complete(self, *args, **kwargs) -> None: + return None + + +def build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + return sessionmaker(bind=engine, autoflush=False, autocommit=False)() + + +def build_client() -> tuple[TestClient, sessionmaker[Session]]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + app = create_app() + + def override_db() -> Generator[Session, None, None]: + db = session_factory() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_db + return TestClient(app), session_factory + + +def test_risk_rule_feedback_records_misjudgement_without_modifying_rule(tmp_path) -> None: + with build_session() as db: + asset_id = _create_rule(db, tmp_path) + asset = db.get(AgentAsset, asset_id) + assert asset is not None + before_config = dict(asset.config_json or {}) + before_status = asset.status + + feedback = AgentAssetService(db).create_risk_rule_feedback( + asset_id, + AgentAssetRiskRuleFeedbackCreate( + feedback_type="false_positive", + subject_type="expense_claim", + subject_key="CLAIM-001", + subject_label="差旅报销 CLAIM-001", + actual_result={"hit": True, "severity": "high"}, + expected_result={"hit": False}, + comment="票据城市实际与行程一致,当前规则误判。", + payload={"source": "expense_review"}, + ), + actor="employee", + ) + + stored = db.scalar( + select(AgentAssetRuleFeedback).where( + AgentAssetRuleFeedback.feedback_id == feedback.feedback_id + ) + ) + assert stored is not None + assert feedback.feedback_type == "false_positive" + assert feedback.version == asset.working_version + assert stored.actual_result_json["hit"] is True + db.refresh(asset) + assert asset.status == before_status + assert asset.config_json == before_config + + +def test_risk_rule_feedback_endpoint_allows_ordinary_user_and_manager_list(tmp_path) -> None: + client, session_factory = build_client() + with session_factory() as db: + asset_id = _create_rule(db, tmp_path) + + response = client.post( + f"/api/v1/agent-assets/{asset_id}/risk-rules/feedback", + headers=_user_headers(), + json={ + "feedback_type": "false_negative", + "subject_type": "expense_claim", + "subject_key": "CLAIM-002", + "actual_result": {"hit": False}, + "expected_result": {"hit": True, "severity": "medium"}, + "comment": "这张票据应命中风险但没有命中。", + }, + ) + + assert response.status_code == 201 + assert response.json()["created_by"] == "employee" + assert response.json()["status"] == "open" + + list_response = client.get( + f"/api/v1/agent-assets/{asset_id}/risk-rules/feedback", + headers=_manager_headers(), + ) + assert list_response.status_code == 200 + assert list_response.json()[0]["feedback_type"] == "false_negative" + + +def _create_rule(db: Session, tmp_path) -> str: + return RiskRuleGenerationService( + db, + rule_library_manager=AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules"), + runtime_chat_service=NullRuntimeChatService(), + ).generate_rule_asset( + AgentAssetRiskRuleGenerateRequest( + business_domain=AgentAssetDomain.EXPENSE, + expense_category="travel", + rule_title="差旅票据城市规则", + natural_language="差旅票据城市与申报目的地不一致时,提示补充说明。", + ), + actor="pytest", + ) + + +def _user_headers() -> dict[str, str]: + return { + "x-auth-username": "employee", + "x-auth-name": "employee", + "x-auth-role-codes": "user", + } + + +def _manager_headers() -> dict[str, str]: + return { + "x-auth-username": "manager", + "x-auth-name": "manager", + "x-auth-role-codes": "manager", + } diff --git a/server/tests/test_risk_rule_generation.py b/server/tests/test_risk_rule_generation.py index 4187b88..9b45121 100644 --- a/server/tests/test_risk_rule_generation.py +++ b/server/tests/test_risk_rule_generation.py @@ -230,6 +230,41 @@ def test_generate_expense_application_risk_rule_marks_business_stage(tmp_path) - assert payload["params"]["business_stage_label"] == "费用申请" +def test_generate_risk_rule_asset_supports_all_expense_category(tmp_path) -> None: + with build_session() as db: + manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") + service = RiskRuleGenerationService( + db, + rule_library_manager=manager, + runtime_chat_service=NullRuntimeChatService(), + ) + + asset_id = service.generate_rule_asset( + AgentAssetRiskRuleGenerateRequest( + business_domain=AgentAssetDomain.EXPENSE, + business_stage="expense_application", + expense_category="all", + rule_title="预算可用余额不足", + natural_language="费用申请时,如果申请金额超过预算可用余额,则提示预算风险并要求补充说明。", + ), + actor="pytest", + ) + + asset = db.get(AgentAsset, asset_id) + assert asset is not None + assert asset.config_json["expense_category"] == "all" + assert asset.config_json["expense_category_label"] == "全部" + assert asset.scenario_json == ["全部"] + + payload = manager.read_rule_library_json( + library=RISK_RULES_LIBRARY, + file_name=asset.config_json["rule_document"]["file_name"], + ) + assert payload["applies_to"]["expense_categories"] == ["all"] + assert payload["metadata"]["expense_category"] == "all" + assert payload["metadata"]["expense_category_label"] == "全部" + + def test_risk_score_model_keeps_explicit_low_control_rules_low() -> None: field_keys = ["attachment.invoice_no", "attachment.goods_name", "claim.reason"] result = calculate_risk_rule_score( @@ -476,6 +511,44 @@ def test_platform_risk_applies_to_chinese_expense_type_labels() -> None: ) +def test_platform_risk_all_expense_scope_matches_any_budget_category() -> None: + class PlatformRiskProbe(ExpenseClaimPlatformRiskMixin): + pass + + claim = ExpenseClaim( + claim_no="TEST-COMMUNICATION-RISK", + employee_name="测试员工", + department_name="市场部", + expense_type="通信费", + reason="客户支持电话费", + amount=Decimal("300.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime.now(UTC), + status="draft", + ) + manifest = { + "applies_to": { + "domains": ["expense"], + "expense_types": ["all"], + } + } + + assert PlatformRiskProbe()._risk_manifest_applies_to_claim( + manifest, + claim=claim, + contexts=[], + ) + + manifest["applies_to"] = {"domains": ["expense"], "expense_categories": ["全部"]} + + assert PlatformRiskProbe()._risk_manifest_applies_to_claim( + manifest, + claim=claim, + contexts=[], + ) + + def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None: renderer = RiskRuleFlowDiagramRenderer() diff --git a/server/tests/test_risk_rule_generation_failure.py b/server/tests/test_risk_rule_generation_failure.py new file mode 100644 index 0000000..8d0525c --- /dev/null +++ b/server/tests/test_risk_rule_generation_failure.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus +from app.db.base import Base +from app.models.agent_asset import AgentAsset +from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest +from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager +from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService + + +class FailingRuntimeChatService: + def complete(self, *args, **kwargs) -> str: + raise RuntimeError("Hermes semantic plan failed") + + +def build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + return sessionmaker(bind=engine, autoflush=False, autocommit=False)() + + +def test_background_generation_failure_keeps_error_detail_and_last_operation(tmp_path) -> None: + with build_session() as db: + manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") + service = RiskRuleGenerationJobService( + db, + rule_library_manager=manager, + runtime_chat_service=FailingRuntimeChatService(), + ) + body = AgentAssetRiskRuleGenerateRequest( + business_domain=AgentAssetDomain.EXPENSE, + expense_category="travel", + rule_title="差旅异常规则", + natural_language="差旅报销票据城市与申报目的地不一致时提示风险。", + ) + asset_id = service.enqueue_rule_asset_generation(body, actor="pytest") + + service.complete_rule_asset_generation(asset_id, body, actor="pytest") + + asset = db.get(AgentAsset, asset_id) + assert asset is not None + assert asset.status == AgentAssetStatus.FAILED.value + assert asset.config_json["generation_status"] == AgentAssetStatus.FAILED.value + assert asset.config_json["generation_error"] == "Hermes semantic plan failed" + assert asset.config_json["last_operation"]["action"] == "generation_failed" + assert asset.config_json["last_operation"]["actor"] == "pytest" diff --git a/server/tests/test_risk_rule_revision_endpoints.py b/server/tests/test_risk_rule_revision_endpoints.py index 97e47ed..b36d3de 100644 --- a/server/tests/test_risk_rule_revision_endpoints.py +++ b/server/tests/test_risk_rule_revision_endpoints.py @@ -14,6 +14,8 @@ from app.main import create_app from app.models.agent_asset import AgentAsset from app.schemas.agent_asset import AgentAssetRiskRuleGenerateRequest from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager +from app.services.agent_asset_risk_rule_regeneration import AgentAssetRiskRuleRegenerationService +from app.services.agent_assets import AgentAssetService from app.services.risk_rule_generation import RiskRuleGenerationService @@ -111,6 +113,95 @@ def test_create_risk_rule_revision_endpoint_keeps_active_version(tmp_path) -> No assert payload["config_json"]["last_operation"]["action"] == "create_revision" +def test_regenerate_risk_rule_endpoint_returns_updated_detail(tmp_path, monkeypatch) -> None: + client, session_factory = build_client() + asset_id = _create_rule(session_factory, tmp_path) + + def fake_regenerate(self, target_asset_id, body, *, actor, request_id=None): + del body, request_id + asset = self.db.get(AgentAsset, target_asset_id) + assert asset is not None + config = dict(asset.config_json or {}) + config["generation_status"] = "completed" + config["last_operation"] = {"action": "regenerate", "actor": actor, "at": "2026-05-30T00:00:00+00:00"} + asset.config_json = config + self.db.add(asset) + self.db.flush() + return asset + + monkeypatch.setattr(AgentAssetRiskRuleRegenerationService, "regenerate", fake_regenerate) + + response = client.post( + f"/api/v1/agent-assets/{asset_id}/risk-rules/regenerate", + headers=_finance_headers(), + json={"natural_language": "差旅票据城市与申报目的地不一致时要求补充说明。"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["config_json"]["generation_status"] == "completed" + assert payload["config_json"]["last_operation"]["action"] == "regenerate" + + +def test_risk_rule_admin_only_actions_block_non_admin_users(tmp_path) -> None: + client, session_factory = build_client() + asset_id = _create_rule(session_factory, tmp_path) + + generate_response = client.post( + "/api/v1/agent-assets/risk-rules/generate", + headers=_finance_headers(), + json={ + "business_domain": "expense", + "expense_category": "travel", + "rule_title": "普通财务新建规则", + "natural_language": "差旅票据城市与申报目的地不一致时提示风险。", + }, + ) + assert generate_response.status_code == 403 + + simulate_response = client.post( + f"/api/v1/agent-assets/{asset_id}/risk-rule-tests/simulate", + headers=_finance_headers(), + json={"message": "测试一张差旅票据。"}, + ) + assert simulate_response.status_code == 403 + + delete_response = client.delete( + f"/api/v1/agent-assets/{asset_id}", + headers=_manager_headers(), + ) + assert delete_response.status_code == 403 + + +def test_manager_can_toggle_risk_rule_enabled_endpoint(tmp_path, monkeypatch) -> None: + client, session_factory = build_client() + asset_id = _create_rule(session_factory, tmp_path) + + def fake_toggle(self, target_asset_id, *, enabled, actor, request_id=None): + del request_id + asset = self.db.get(AgentAsset, target_asset_id) + assert asset is not None + config = dict(asset.config_json or {}) + config["enabled"] = bool(enabled) + config["last_operation"] = {"action": "offline", "actor": actor} + asset.config_json = config + self.db.add(asset) + self.db.flush() + return asset + + monkeypatch.setattr(AgentAssetService, "set_risk_rule_enabled", fake_toggle) + + response = client.post( + f"/api/v1/agent-assets/{asset_id}/risk-rule-enabled", + headers=_manager_headers(), + json={"enabled": False}, + ) + + assert response.status_code == 200 + assert response.json()["config_json"]["enabled"] is False + assert response.json()["config_json"]["last_operation"]["actor"] == "manager" + + def _create_rule(session_factory: sessionmaker[Session], tmp_path) -> str: with session_factory() as db: return RiskRuleGenerationService( @@ -147,3 +238,12 @@ def _finance_headers() -> dict[str, str]: "x-auth-role-codes": "finance", "x-actor": "finance", } + + +def _manager_headers() -> dict[str, str]: + return { + "x-auth-username": "manager", + "x-auth-name": "manager", + "x-auth-role-codes": "manager", + "x-actor": "manager", + } diff --git a/server/tests/test_risk_rule_revision_service.py b/server/tests/test_risk_rule_revision_service.py index 3779cb7..547223a 100644 --- a/server/tests/test_risk_rule_revision_service.py +++ b/server/tests/test_risk_rule_revision_service.py @@ -7,13 +7,16 @@ from sqlalchemy.pool import StaticPool from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus from app.db.base import Base -from app.models.agent_asset import AgentAsset, AgentAssetVersion +from app.models.agent_asset import AgentAsset, AgentAssetTestRun, AgentAssetVersion from app.schemas.agent_asset import ( AgentAssetRiskRuleDraftUpdate, AgentAssetRiskRuleGenerateRequest, + AgentAssetRiskRuleRegenerateRequest, AgentAssetRiskRuleRevisionCreate, ) from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager +from app.services.agent_asset_risk_rule_regeneration import AgentAssetRiskRuleRegenerationService +from app.services.agent_assets import AgentAssetService from app.services.risk_rule_generation import RiskRuleGenerationService from app.services.agent_asset_risk_rule_revision import AgentAssetRiskRuleRevisionService @@ -107,10 +110,173 @@ def test_create_revision_draft_for_published_rule_does_not_overwrite_active_vers assert db.query(AgentAssetVersion).filter_by(asset_id=asset_id, version="v0.1.1").one() -def _create_rule(db: Session, tmp_path) -> str: +def test_regenerate_unpublished_draft_updates_dsl_and_score(tmp_path) -> None: + with build_session() as db: + manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") + asset_id = _create_rule(db, tmp_path, manager=manager) + updated = AgentAssetRiskRuleRegenerationService( + db, + rule_library_manager=manager, + runtime_chat_service=NullRuntimeChatService(), + ).regenerate( + asset_id, + AgentAssetRiskRuleRegenerateRequest( + rule_title="差旅城市一致性复核", + natural_language="差旅报销票据城市与申报目的地不一致时,要求补充说明。", + requires_attachment=True, + ), + actor="finance", + ) + + assert updated.status == AgentAssetStatus.DRAFT.value + assert updated.config_json["generation_status"] == "completed" + assert updated.config_json["risk_score"] is not None + assert updated.config_json["last_operation"]["action"] == "regenerate" + payload = manager.read_rule_library_json( + library="risk-rules", + file_name=updated.config_json["rule_document"]["file_name"], + ) + assert payload["name"] == "差旅城市一致性复核" + assert payload["flow_diagram_svg"] + assert payload["metadata"]["risk_score"] == updated.config_json["risk_score"] + + +def test_regenerate_revision_draft_keeps_active_document_unchanged(tmp_path) -> None: + with build_session() as db: + manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") + asset_id = _create_rule(db, tmp_path, manager=manager) + asset = db.get(AgentAsset, asset_id) + assert asset is not None + active_document = asset.config_json["rule_document"] + active_payload_before = manager.read_rule_library_json( + library="risk-rules", + file_name=active_document["file_name"], + ) + asset.status = AgentAssetStatus.ACTIVE.value + asset.published_version = "v0.1.0" + asset.current_version = "v0.1.0" + asset.working_version = "v0.1.0" + db.add(asset) + db.flush() + + AgentAssetRiskRuleRevisionService(db).create_revision_draft( + asset_id, + AgentAssetRiskRuleRevisionCreate( + rule_title="票据城市一致性复核", + natural_language="票据城市与申报目的地不一致时,要求补充说明。", + requires_attachment=True, + change_reason="补充城市一致性判断。", + ), + actor="manager", + ) + updated = AgentAssetRiskRuleRegenerationService( + db, + rule_library_manager=manager, + runtime_chat_service=NullRuntimeChatService(), + ).regenerate( + asset_id, + AgentAssetRiskRuleRegenerateRequest(), + actor="manager", + ) + + revision = updated.config_json["revision_draft"] + assert updated.status == AgentAssetStatus.ACTIVE.value + assert updated.published_version == "v0.1.0" + assert updated.config_json["rule_document"] == active_document + assert revision["generation_status"] == "completed" + assert revision["risk_score"] is not None + assert revision["rule_document"]["file_name"] != active_document["file_name"] + active_payload_after = manager.read_rule_library_json( + library="risk-rules", + file_name=active_document["file_name"], + ) + assert active_payload_after == active_payload_before + revision_payload = manager.read_rule_library_json( + library="risk-rules", + file_name=revision["rule_document"]["file_name"], + ) + assert revision_payload["rule_code"] == updated.code + assert revision_payload["enabled"] is False + + detail_service = AgentAssetService(db) + detail_service.rule_library_manager = manager + displayed = detail_service.read_rule_json(asset_id) + assert displayed.file_name == revision["rule_document"]["file_name"] + assert displayed.payload["rule_code"] == updated.code + + +def test_publish_regenerated_revision_replaces_online_document(tmp_path) -> None: + with build_session() as db: + manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules") + asset_id = _create_rule(db, tmp_path, manager=manager) + asset = db.get(AgentAsset, asset_id) + assert asset is not None + old_document = asset.config_json["rule_document"] + asset.status = AgentAssetStatus.ACTIVE.value + asset.published_version = "v0.1.0" + asset.current_version = "v0.1.0" + asset.working_version = "v0.1.0" + db.add(asset) + db.flush() + AgentAssetRiskRuleRevisionService(db).create_revision_draft( + asset_id, + AgentAssetRiskRuleRevisionCreate( + rule_title="差旅票据城市复核", + natural_language="票据城市与申报目的地不一致时,要求补充说明。", + requires_attachment=True, + change_reason="补充城市一致性判断。", + ), + actor="manager", + ) + regenerated = AgentAssetRiskRuleRegenerationService( + db, + rule_library_manager=manager, + runtime_chat_service=NullRuntimeChatService(), + ).regenerate(asset_id, AgentAssetRiskRuleRegenerateRequest(), actor="manager") + revision = regenerated.config_json["revision_draft"] + db.add( + AgentAssetTestRun( + asset_id=asset_id, + version="v0.1.1", + test_type="report", + status="passed", + passed=True, + summary="测试报告已确认。", + input_json={}, + result_json={}, + created_by="manager", + ) + ) + db.flush() + + service = AgentAssetService(db) + service.rule_library_manager = manager + published = service.publish_risk_rule(asset_id, actor="manager") + + assert published.status == AgentAssetStatus.ACTIVE.value + assert published.current_version == "v0.1.1" + assert published.published_version == "v0.1.1" + assert "revision_draft" not in published.config_json + assert published.config_json["rule_document"] == revision["rule_document"] + assert published.config_json["revision_history"][0]["previous_rule_document"] == old_document + assert published.config_json["last_operation"]["action"] == "publish_revision" + manifest = manager.read_rule_library_json( + library="risk-rules", + file_name=published.config_json["rule_document"]["file_name"], + ) + assert manifest["enabled"] is True + assert manifest["rule_code"] == published.code + + +def _create_rule( + db: Session, + tmp_path, + *, + manager: AgentAssetRuleLibraryManager | None = None, +) -> str: return RiskRuleGenerationService( db, - rule_library_manager=AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules"), + rule_library_manager=manager or AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules"), runtime_chat_service=NullRuntimeChatService(), ).generate_rule_asset( AgentAssetRiskRuleGenerateRequest( diff --git a/server/tests/test_risk_rule_template_catalog.py b/server/tests/test_risk_rule_template_catalog.py new file mode 100644 index 0000000..879872f --- /dev/null +++ b/server/tests/test_risk_rule_template_catalog.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.main import create_app +from app.services.risk_rule_dsl_validator import validate_risk_rule_draft +from app.services.risk_rule_generation_interpreter import COMPOSITE_RULE_TEMPLATE_KEY +from app.services.risk_rule_generation_ontology import FIELD_ONTOLOGY +from app.services.risk_rule_template_catalog import ( + list_risk_rule_template_groups, + list_risk_rule_templates, +) + + +def test_risk_rule_template_catalog_groups_and_dsl_examples() -> None: + groups = list_risk_rule_template_groups() + templates = list_risk_rule_templates() + + assert [group["group"] for group in groups] == [ + "budget", + "invoice", + "travel", + "entertainment", + "procurement_ap", + "corporate_card", + "general", + ] + assert len(templates) >= 8 + + for group in groups: + assert group["templates"], group["group"] + + for template in templates: + assert template["title"] + assert template["natural_language"] + assert isinstance(template["requires_attachment"], bool) + assert template["fields"] + assert all("[" in field["display"] and "]" in field["display"] for field in template["fields"]) + assert template["dsl_example"]["template_key"] == COMPOSITE_RULE_TEMPLATE_KEY + + normalized = validate_risk_rule_draft( + template["dsl_example"]["params"], + fields=list(FIELD_ONTOLOGY), + natural_language=template["natural_language"], + ) + assert normalized["template_key"] == COMPOSITE_RULE_TEMPLATE_KEY + assert normalized["dsl_validation"]["status"] == "passed" + assert normalized["conditions"] + + +def test_risk_rule_template_endpoint_requires_login_and_returns_groups() -> None: + client = TestClient(create_app()) + + unauthorized = client.get("/api/v1/agent-assets/risk-rules/templates") + assert unauthorized.status_code == 401 + + response = client.get( + "/api/v1/agent-assets/risk-rules/templates", + headers={"x-auth-username": "finance", "x-auth-role-codes": "finance"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload[0]["group"] == "budget" + assert payload[0]["templates"][0]["dsl_example"]["params"]["conditions"] diff --git a/server/tests/test_user_agent_service.py b/server/tests/test_user_agent_service.py index 16e652b..2046f86 100644 --- a/server/tests/test_user_agent_service.py +++ b/server/tests/test_user_agent_service.py @@ -211,11 +211,11 @@ def test_user_agent_application_context_uses_application_language() -> None: assert "| 字段 | 内容 |" in response.answer assert "| 发生时间 | 2026-05-25 至 2026-05-27 |" in response.answer assert "支持上海国网服务器部署" in response.answer - assert "当前还需要补充:出行方式、用户预估费用" in response.answer + assert "当前还需要补充:出行方式" in response.answer assert "请先在下面选择报销场景" not in response.answer assert response.review_payload is None assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"] - assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n用户预估费用:" + assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:" def test_user_agent_application_infers_natural_reason_and_expands_single_date() -> None: @@ -228,7 +228,7 @@ def test_user_agent_application_infers_natural_reason_and_expands_single_date() assert "| 地点 | 上海市 |" in response.answer assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer assert "当前还需要先补充:申请事由" not in response.answer - assert "当前还需要补充:出行方式、用户预估费用" in response.answer + assert "当前还需要补充:出行方式" in response.answer assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"] @@ -292,13 +292,13 @@ def test_user_agent_application_uses_selected_time_and_natural_language_fields() assert "| 发生时间 | 2026-05-25 |" in response.answer assert "| 地点 | 上海市 |" in response.answer assert "| 事由 | 支撑国网服务器上线部署 |" in response.answer - assert "当前还需要补充:出行方式、用户预估费用" in response.answer + assert "当前还需要补充:出行方式" in response.answer assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"] assert response.suggested_actions[0].action_type == "prefill_composer" - assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n用户预估费用:" + assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:" -def test_user_agent_application_asks_amount_after_transport_choice() -> None: +def test_user_agent_application_builds_system_estimate_after_transport_choice() -> None: session_factory = build_session_factory() initial_message = ( "发生时间:2026-05-25\n" @@ -313,11 +313,16 @@ def test_user_agent_application_asks_amount_after_transport_choice() -> None: history=[{"role": "user", "content": initial_message}], ) + assert "这是费用申请核对结果" in response.answer assert "| 出行方式 | 飞机 |" in response.answer - assert "当前还需要补充:用户预估费用" in response.answer - assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"] - assert response.suggested_actions[0].action_type == "prefill_composer" - assert response.suggested_actions[0].payload["prompt_prefill"] == "用户预估费用:" + assert "| 系统预估费用 |" in response.answer + assert "交通" in response.answer + assert "参考票价" in response.answer + assert "按 2026-05-25 参考票价" in response.answer + assert "2,330元" in response.answer + assert "查询耗时" in response.answer + assert response.requires_confirmation is True + assert response.suggested_actions == [] def test_user_agent_application_missing_base_actions_prefill_composer() -> None: @@ -328,10 +333,10 @@ def test_user_agent_application_missing_base_actions_prefill_composer() -> None: "地点:上海\n事由:支撑国网服务器部署\n天数:3天", ) - assert "当前还需要补充:出行方式、用户预估费用" in response.answer + assert "当前还需要补充:出行方式" in response.answer assert [item.label for item in response.suggested_actions] == ["一次性补充申请信息"] assert response.suggested_actions[0].action_type == "prefill_composer" - assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:\n用户预估费用:" + assert response.suggested_actions[0].payload["prompt_prefill"] == "出行方式:" def test_user_agent_application_precomputes_time_from_today_and_days() -> None: @@ -346,7 +351,7 @@ def test_user_agent_application_precomputes_time_from_today_and_days() -> None: }, ) - assert "这是模拟的费用申请结果" in response.answer + assert "这是费用申请核对结果" in response.answer assert "| 发生时间 | 2026-05-29 至 2026-05-31 |" in response.answer assert response.requires_confirmation is True @@ -363,17 +368,27 @@ def test_user_agent_application_builds_preview_when_amount_is_ready() -> None: response = build_application_user_agent_response( db, "预计总费用:12000元", + context_overrides={ + "name": "张三", + "department_name": "交付部", + "position": "实施经理", + "manager_name": "李文静", + }, history=[ {"role": "user", "content": initial_message}, {"role": "user", "content": "飞机"}, ], ) - assert "这是模拟的费用申请结果" in response.answer + assert "这是费用申请核对结果" in response.answer assert "| 字段 | 内容 |" in response.answer + assert "| 姓名 | 张三 |" in response.answer + assert "| 部门 | 交付部 |" in response.answer + assert "| 岗位 | 实施经理 |" in response.answer + assert "| 直属领导 | 李文静 |" in response.answer assert "| 事由 | 支持上海国网服务器部署 |" in response.answer assert "| 出行方式 | 飞机 |" in response.answer - assert "| 用户预估费用 | 12000元 |" in response.answer + assert "| 系统预估费用 | 12000元 |" in response.answer assert "请核对上述信息无误" in response.answer assert "[确认](#application-submit)" in response.answer assert response.requires_confirmation is True @@ -389,7 +404,7 @@ def test_user_agent_application_submit_enters_leader_review() -> None: "天数:3天" ) preview_answer = ( - "这是模拟的费用申请结果,请核对:\n" + "这是费用申请核对结果,请核对:\n" "| 字段 | 内容 |\n" "| --- | --- |\n" "| 申请类型 | 差旅费用申请 |\n" @@ -398,7 +413,7 @@ def test_user_agent_application_submit_enters_leader_review() -> None: "| 事由 | 支持上海国网服务器部署 |\n" "| 天数 | 3天 |\n" "| 出行方式 | 飞机 |\n" - "| 用户预估费用 | 12000元 |\n\n" + "| 系统预估费用 | 12000元 |\n\n" "请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。" ) with session_factory() as db: diff --git a/web/src/assets/styles/app.css b/web/src/assets/styles/app.css index 4a5de55..1a69401 100644 --- a/web/src/assets/styles/app.css +++ b/web/src/assets/styles/app.css @@ -164,6 +164,7 @@ } .main.documents-main, .main.receipt-folder-main, +.main.budget-main, .main.requests-main, .main.approval-main, .main.archive-main, @@ -183,6 +184,7 @@ .workarea.requests-workarea, .workarea.documents-workarea, .workarea.receipt-folder-workarea, +.workarea.budget-workarea, .workarea.workbench-workarea, .workarea.approval-workarea, .workarea.archive-workarea, diff --git a/web/src/assets/styles/components/digital-employee-run-products.css b/web/src/assets/styles/components/digital-employee-run-products.css index 19a32b2..5ea49a4 100644 --- a/web/src/assets/styles/components/digital-employee-run-products.css +++ b/web/src/assets/styles/components/digital-employee-run-products.css @@ -116,6 +116,11 @@ line-height: 1.65; } +.run-product-muted-copy { + color: #64748b; + font-size: 12px; +} + .run-product-tags { display: flex; flex-wrap: wrap; @@ -234,6 +239,57 @@ background: #f8fafc; } +.run-product-feedback-panel { + margin-top: 10px; + padding: 10px 12px; + border: 1px solid #e5edf6; + border-radius: 4px; + background: #ffffff; +} + +.run-product-section-head.compact { + margin-bottom: 8px; +} + +.run-product-feedback-list { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.run-product-feedback-list li { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 7px 8px; + color: #475569; + font-size: 12px; + border: 1px solid #edf2f7; + border-radius: 4px; + background: #f8fafc; +} + +.run-product-feedback-list strong { + color: #0f172a; + font-size: 12px; +} + +.run-product-feedback-list span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.run-product-feedback-list em { + color: #64748b; + font-style: normal; + white-space: nowrap; +} + .risk-level-pill { display: inline-flex; align-items: center; diff --git a/web/src/assets/styles/components/employee-profile-risk-card.css b/web/src/assets/styles/components/employee-profile-risk-card.css new file mode 100644 index 0000000..d4e36df --- /dev/null +++ b/web/src/assets/styles/components/employee-profile-risk-card.css @@ -0,0 +1,568 @@ +.employee-risk-profile-card { + display: grid; + gap: 14px; +} + +.employee-risk-profile-head { + margin-bottom: 0; +} + +.employee-risk-profile-badges { + display: inline-flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; +} + +.ai-assist-badge, +.profile-level-pill { + min-height: 26px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 10px; + border: 1px solid #dbeafe; + border-radius: 4px; + background: #f8fafc; + color: #2563eb; + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.ai-assist-badge i { + font-size: 14px; +} + +.profile-level-pill.normal { + border-color: #bbf7d0; + background: #f0fdf4; + color: #15803d; +} + +.profile-level-pill.watch { + border-color: #bfdbfe; + background: #eff6ff; + color: #2563eb; +} + +.profile-level-pill.medium { + border-color: #fed7aa; + background: #fff7ed; + color: #c2410c; +} + +.profile-level-pill.high { + border-color: #fecaca; + background: #fef2f2; + color: #b91c1c; +} + +.employee-risk-state { + min-height: 92px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px dashed #cbd5e1; + border-radius: 4px; + background: #f8fafc; + color: #64748b; + font-size: 13px; + font-weight: 750; +} + +.employee-risk-state.error { + border-color: #fecaca; + background: #fef2f2; + color: #b91c1c; +} + +.employee-risk-body { + display: grid; + gap: 12px; +} + +.employee-risk-decision { + display: grid; + grid-template-columns: auto minmax(0, 1fr) minmax(140px, auto); + gap: 14px; + align-items: center; + padding: 14px; + border: 1px solid #dbeafe; + border-radius: 4px; + background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%); +} + +.employee-risk-decision.watch { + border-color: #bfdbfe; +} + +.employee-risk-decision.medium { + border-color: #fed7aa; + background: linear-gradient(180deg, #fffaf5 0%, #ffffff 100%); +} + +.employee-risk-decision.high { + border-color: #fecaca; + background: linear-gradient(180deg, #fff7f7 0%, #ffffff 100%); +} + +.employee-risk-score { + width: 76px; + height: 76px; + display: grid; + place-items: center; + align-content: center; + border: 1px solid #bfdbfe; + border-radius: 50%; + background: #eff6ff; + color: #2563eb; +} + +.employee-risk-decision.normal .employee-risk-score { + border-color: #bbf7d0; + background: #f0fdf4; + color: #15803d; +} + +.employee-risk-decision.medium .employee-risk-score { + border-color: #fed7aa; + background: #fff7ed; + color: #c2410c; +} + +.employee-risk-decision.high .employee-risk-score { + border-color: #fecaca; + background: #fef2f2; + color: #b91c1c; +} + +.employee-risk-score strong { + color: inherit; + font-size: 25px; + font-weight: 900; + line-height: 1; +} + +.employee-risk-score span { + margin-top: 4px; + color: inherit; + font-size: 11px; + font-weight: 800; +} + +.employee-risk-decision-copy, +.employee-risk-action { + min-width: 0; + display: grid; + gap: 5px; +} + +.employee-risk-decision-copy span, +.employee-risk-action span, +.employee-risk-section-head span, +.employee-risk-meta-grid span { + color: #64748b; + font-size: 12px; + font-weight: 800; +} + +.employee-risk-decision-copy strong { + color: #0f172a; + font-size: 16px; + font-weight: 850; +} + +.employee-risk-decision-copy p { + margin: 0; + color: #334155; + font-size: 13px; + line-height: 1.65; +} + +.employee-risk-action { + padding-left: 14px; + border-left: 1px solid #e2e8f0; +} + +.employee-risk-action strong { + color: #0f172a; + font-size: 14px; + font-weight: 850; + line-height: 1.45; +} + +.employee-risk-meta-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.employee-risk-meta-grid article { + min-width: 0; + display: grid; + gap: 5px; + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #f8fafc; +} + +.employee-risk-meta-grid strong { + min-width: 0; + color: #0f172a; + font-size: 13px; + font-weight: 850; + overflow-wrap: anywhere; +} + +.employee-risk-analysis-grid { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(0, .95fr); + gap: 12px; +} + +.employee-risk-section { + min-width: 0; + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #fff; +} + +.employee-risk-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.employee-risk-section-head small { + color: #94a3b8; + font-size: 11px; + font-weight: 700; +} + +.employee-risk-advice-list, +.employee-risk-evidence-list, +.employee-risk-profile-evidence { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.employee-risk-advice-list li { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + gap: 8px; + align-items: flex-start; + padding: 10px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #f8fafc; +} + +.employee-risk-advice-list i { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 4px; + background: #eff6ff; + color: #2563eb; + font-size: 15px; +} + +.employee-risk-advice-list li.normal i { + background: #f0fdf4; + color: #15803d; +} + +.employee-risk-advice-list li.medium i { + background: #fff7ed; + color: #c2410c; +} + +.employee-risk-advice-list li.high i { + background: #fef2f2; + color: #b91c1c; +} + +.employee-risk-advice-list span { + display: block; + color: #0f172a; + font-size: 13px; + font-weight: 760; + line-height: 1.55; +} + +.employee-risk-advice-list strong { + display: inline-block; + margin-top: 4px; + color: #64748b; + font-size: 12px; + font-weight: 800; +} + +.employee-risk-evidence-list li { + display: grid; + grid-template-columns: 82px minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + padding: 9px 0; + border-bottom: 1px solid #edf2f7; +} + +.employee-risk-evidence-list li:last-child { + border-bottom: 0; +} + +.employee-risk-evidence-list span, +.employee-risk-evidence-list em, +.employee-risk-profile-evidence span { + color: #64748b; + font-size: 12px; + font-style: normal; + font-weight: 760; +} + +.employee-risk-evidence-list strong { + min-width: 0; + color: #0f172a; + font-size: 13px; + font-weight: 820; + overflow-wrap: anywhere; +} + +.employee-risk-evidence-list small { + grid-column: 2 / 4; + color: #94a3b8; + font-size: 11px; + line-height: 1.45; +} + +.employee-risk-dimension-list { + display: grid; + gap: 9px; +} + +.employee-risk-dimension { + display: grid; + grid-template-columns: minmax(120px, .65fr) minmax(0, 1fr) 56px; + gap: 10px; + align-items: center; +} + +.employee-risk-dimension > div:first-child { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.employee-risk-dimension span, +.employee-risk-dimension small { + color: #64748b; + font-size: 12px; + font-weight: 760; +} + +.employee-risk-dimension strong { + color: #0f172a; + font-size: 13px; + font-weight: 850; +} + +.employee-risk-dimension-track { + height: 7px; + overflow: hidden; + border-radius: 999px; + background: #e2e8f0; +} + +.employee-risk-dimension-track i { + display: block; + height: 100%; + border-radius: inherit; + background: var(--theme-primary-active); +} + +.employee-risk-profile-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.employee-risk-profile { + min-width: 0; + display: grid; + gap: 10px; + padding: 11px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #f8fafc; +} + +.employee-risk-profile-title { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.employee-risk-profile-title div { + min-width: 0; + display: grid; + gap: 3px; +} + +.employee-risk-profile-title span { + color: #0f172a; + font-size: 13px; + font-weight: 850; +} + +.employee-risk-profile-title small { + color: #64748b; + font-size: 11px; + font-weight: 760; +} + +.employee-risk-profile-title > strong { + min-width: 38px; + min-height: 28px; + display: grid; + place-items: center; + border: 1px solid #dbeafe; + border-radius: 4px; + background: #eff6ff; + color: #2563eb; + font-size: 14px; + font-weight: 900; +} + +.employee-risk-profile-title > strong.normal { + border-color: #bbf7d0; + background: #f0fdf4; + color: #15803d; +} + +.employee-risk-profile-title > strong.medium { + border-color: #fed7aa; + background: #fff7ed; + color: #c2410c; +} + +.employee-risk-profile-title > strong.high { + border-color: #fecaca; + background: #fef2f2; + color: #b91c1c; +} + +.employee-risk-profile-evidence li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-top: 7px; + border-top: 1px solid #e2e8f0; +} + +.employee-risk-profile-evidence strong { + color: #0f172a; + font-size: 12px; + font-weight: 820; +} + +.employee-risk-tags { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.employee-risk-tag { + min-height: 26px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 9px; + border: 1px solid #dbeafe; + border-radius: 4px; + background: #f8fafc; + color: #334155; + font-size: 12px; + font-weight: 800; +} + +.employee-risk-tag strong { + color: inherit; + font-size: 11px; +} + +.employee-risk-tag.risk { + border-color: #fed7aa; + background: #fff7ed; + color: #c2410c; +} + +.employee-risk-tag.behavior { + border-color: #bfdbfe; + background: #eff6ff; + color: #2563eb; +} + +.employee-risk-tag.positive { + border-color: #bbf7d0; + background: #f0fdf4; + color: #15803d; +} + +.employee-risk-muted { + margin: 0; + color: #94a3b8; + font-size: 12px; + line-height: 1.6; +} + +@media (max-width: 1180px) { + .employee-risk-decision, + .employee-risk-analysis-grid, + .employee-risk-profile-list { + grid-template-columns: 1fr; + } + + .employee-risk-action { + padding-left: 0; + padding-top: 12px; + border-left: 0; + border-top: 1px solid #e2e8f0; + } +} + +@media (max-width: 760px) { + .employee-risk-meta-grid, + .employee-risk-dimension { + grid-template-columns: 1fr; + } + + .employee-risk-profile-head, + .employee-risk-section-head { + align-items: flex-start; + flex-direction: column; + } + + .employee-risk-evidence-list li { + grid-template-columns: 1fr; + } + + .employee-risk-evidence-list small { + grid-column: auto; + } +} diff --git a/web/src/assets/styles/components/risk-observation-evidence-card.css b/web/src/assets/styles/components/risk-observation-evidence-card.css index dac4ae9..c3a129a 100644 --- a/web/src/assets/styles/components/risk-observation-evidence-card.css +++ b/web/src/assets/styles/components/risk-observation-evidence-card.css @@ -1,3 +1,42 @@ +.detail-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.detail-card h3 { + margin: 0 0 12px; + color: #0f172a; + font-size: 16px; + font-weight: 850; +} + +.detail-card-head h3 { + margin-bottom: 4px; +} + +.detail-card-title-with-icon { + display: inline-flex; + align-items: center; + gap: 8px; + line-height: 1.5; +} + +.detail-card-title-with-icon i { + margin-top: 1px; + color: #334155; + font-size: 18px; + line-height: 1; +} + +.detail-card-head p { + color: #64748b; + font-size: 12px; + line-height: 1.5; +} + .risk-observation-evidence-card { display: grid; gap: 14px; diff --git a/web/src/assets/styles/components/risk-rule-test-dialog.css b/web/src/assets/styles/components/risk-rule-test-dialog.css index f75e7dd..0d74e30 100644 --- a/web/src/assets/styles/components/risk-rule-test-dialog.css +++ b/web/src/assets/styles/components/risk-rule-test-dialog.css @@ -37,6 +37,7 @@ .risk-sim-result-head span, .risk-sim-evidence span, .risk-sim-recognition-debug > span, +.risk-sim-field-pipeline header span, .risk-sim-recognized-fields > span, .risk-sim-file-strip > span { color: #64748b; @@ -491,6 +492,48 @@ white-space: nowrap; } +.risk-sim-field-pipeline { + display: grid; gap: 8px; + padding: 0 12px 12px; +} +.risk-sim-field-pipeline section { + display: grid; + gap: 6px; + padding: 9px; + border: 1px solid #edf2f7; + border-radius: 10px; + background: #f8fafc; +} +.risk-sim-field-pipeline header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} +.risk-sim-field-pipeline small { + color: #64748b; font-size: 11px; +} +.risk-sim-field-pipeline ul { + display: grid; gap: 5px; + margin: 0; + padding: 0; + list-style: none; +} +.risk-sim-field-pipeline li { + display: grid; + grid-template-columns: minmax(132px, 0.7fr) minmax(92px, auto) minmax(130px, 1fr); + gap: 7px; color: #334155; + font-size: 12px; +} +.risk-sim-field-pipeline strong, +.risk-sim-field-pipeline b, +.risk-sim-field-pipeline em { + min-width: 0; + overflow: hidden; + font-style: normal; + text-overflow: ellipsis; + white-space: nowrap; +} .risk-sim-evidence { display: grid; gap: 6px; diff --git a/web/src/assets/styles/components/top-bar.css b/web/src/assets/styles/components/top-bar.css index bca06cc..2913721 100644 --- a/web/src/assets/styles/components/top-bar.css +++ b/web/src/assets/styles/components/top-bar.css @@ -290,16 +290,33 @@ display: flex; align-items: flex-start; justify-content: flex-end; - gap: 10px; + gap: 8px; flex-wrap: wrap; } .detail-kpi-chips { justify-content: flex-end; + gap: 8px; } .detail-kpi-chip { - min-width: 142px; + min-width: 112px; + padding: 6px 10px; + gap: 1px 8px; +} + +.detail-kpi-chip .chip-value { + font-size: 16px; + font-weight: 820; + letter-spacing: 0; +} + +.detail-kpi-chip .chip-label { + font-size: 11px; +} + +.detail-kpi-chip .chip-delta { + font-size: 10px; } .detail-alert-strip { diff --git a/web/src/assets/styles/components/travel-reimbursement-message-application.css b/web/src/assets/styles/components/travel-reimbursement-message-application.css index 78f3848..b735b5f 100644 --- a/web/src/assets/styles/components/travel-reimbursement-message-application.css +++ b/web/src/assets/styles/components/travel-reimbursement-message-application.css @@ -74,11 +74,11 @@ .application-draft-brief { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0; + gap: 1px; border: 1px solid #d7e4f2; border-radius: 4px; overflow: hidden; - background: #ffffff; + background: #d7e4f2; } .application-draft-brief-item { @@ -88,21 +88,15 @@ align-items: center; min-height: 42px; padding: 8px 12px; - border-top: 1px solid #edf2f7; - border-left: 1px solid #edf2f7; + border: 0; + background: #ffffff; font-size: 12px; } -.application-draft-brief-item:nth-child(even) { - border-left: 0; -} - .application-draft-brief-item.is-primary { grid-column: 1 / -1; grid-template-columns: 42px minmax(0, 1fr); min-height: 48px; - border-top: 0; - border-left: 0; background: #f8fbff; } @@ -180,12 +174,7 @@ } .application-draft-brief-item { - border-left: 0; - border-top: 1px solid #edf2f7; - } - - .application-draft-brief-item.is-primary { - border-top: 0; + border: 0; } } diff --git a/web/src/assets/styles/views/agent-trace-center-view.css b/web/src/assets/styles/views/agent-trace-center-view.css new file mode 100644 index 0000000..b93344b --- /dev/null +++ b/web/src/assets/styles/views/agent-trace-center-view.css @@ -0,0 +1,496 @@ +.agent-trace-center { + height: 100%; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 12px; +} + +.trace-filters, +.trace-list, +.trace-detail { + border: 1px solid rgba(148, 163, 184, 0.18); +} + +.trace-kicker { + color: var(--theme-primary-active); + font-size: 11px; + font-weight: 800; + letter-spacing: .06em; + text-transform: uppercase; +} + +.trace-detail-head h4 { + margin: 0; + color: var(--ink); + font-size: 18px; + line-height: 1.3; +} + +.trace-detail-head p, +.trace-state p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} + +.trace-mini-action { + min-height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: 4px; + background: #fff; + color: var(--text); + font-size: 12px; + font-weight: 800; +} + +.trace-mini-action:hover { + border-color: rgba(var(--theme-primary-rgb), 0.28); + color: var(--theme-primary-active); +} + +.trace-filters { + display: grid; + grid-template-columns: minmax(260px, 1.4fr) repeat(3, minmax(130px, .7fr)); + gap: 10px; + padding: 12px; +} + +.trace-field { + display: grid; + gap: 6px; +} + +.trace-field span { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.trace-field input, +.trace-field select { + width: 100%; + height: 34px; + border: 1px solid var(--line-strong); + border-radius: 4px; + background: #fff; + color: var(--text); + font-size: 13px; +} + +.trace-field input { + padding: 0 10px; +} + +.trace-field select { + padding: 0 8px; +} + +.trace-layout { + min-height: 0; + display: grid; + grid-template-columns: minmax(520px, .95fr) minmax(520px, 1.05fr); + gap: 12px; +} + +.trace-list, +.trace-detail { + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.trace-list-head { + min-height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 14px; + border-bottom: 1px solid var(--line); +} + +.trace-list-head div { + display: flex; + align-items: baseline; + gap: 8px; +} + +.trace-list-head strong { + color: var(--ink); + font-size: 14px; +} + +.trace-list-head span { + color: var(--muted); + font-size: 12px; +} + +.trace-list-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.trace-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.trace-table th { + height: 36px; + padding: 0 12px; + border-bottom: 1px solid var(--line); + background: #f8fafc; + color: #475569; + font-weight: 800; + text-align: left; +} + +.trace-table td { + min-height: 54px; + padding: 10px 12px; + border-bottom: 1px solid #edf2f7; + color: var(--text); + vertical-align: middle; +} + +.trace-table tr { + cursor: pointer; +} + +.trace-table tr:hover td, +.trace-table tr.active td { + background: var(--theme-primary-light-9); +} + +.trace-table td strong, +.trace-table td span { + display: block; +} + +.trace-table td strong { + max-width: 280px; + overflow: hidden; + color: var(--ink); + font-size: 12px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.trace-table td span { + margin-top: 3px; + color: var(--muted); +} + +.trace-run-id { + max-width: 160px; + color: var(--theme-primary-active) !important; + font-family: "SFMono-Regular", Consolas, monospace; +} + +.trace-status { + display: inline-flex; + min-height: 24px; + align-items: center; + justify-content: center; + padding: 0 8px; + border-radius: 4px; + border: 1px solid var(--line); + background: #f8fafc; + color: var(--muted); + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.trace-status.success { + border-color: var(--success-line); + background: var(--success-soft); + color: var(--success); +} + +.trace-status.warning { + border-color: var(--warning-line); + background: var(--warning-soft); + color: var(--warning); +} + +.trace-status.danger { + border-color: var(--danger-line); + background: var(--danger-soft); + color: var(--danger); +} + +.trace-status.info { + border-color: rgba(var(--theme-primary-rgb), 0.18); + background: var(--theme-primary-soft); + color: var(--theme-primary-active); +} + +.trace-status.mini { + min-height: 22px; + font-size: 11px; +} + +.trace-state { + min-height: 220px; + display: grid; + place-items: center; + align-content: center; + gap: 8px; + padding: 24px; + color: var(--muted); + text-align: center; +} + +.trace-state i { + color: var(--theme-primary-active); + font-size: 28px; +} + +.trace-state strong { + color: var(--ink); + font-size: 14px; +} + +.trace-state.error i, +.trace-state.error strong { + color: var(--danger); +} + +.trace-detail-head { + min-height: 92px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 16px; + border-bottom: 1px solid var(--line); +} + +.trace-detail-head > div { + min-width: 0; + display: grid; + gap: 5px; +} + +.trace-inline-alert, +.trace-error-text { + margin: 12px 14px 0; + padding: 10px 12px; + border-radius: 4px; + font-size: 12px; + line-height: 1.55; +} + +.trace-inline-alert { + border: 1px solid var(--warning-line); + background: var(--warning-soft); + color: var(--warning-active); +} + +.trace-error-text { + border: 1px solid var(--danger-line); + background: var(--danger-soft); + color: var(--danger-active); +} + +.trace-metrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + padding: 12px 14px; +} + +.trace-metrics div { + min-width: 0; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 4px; + background: #f8fafc; +} + +.trace-metrics span, +.trace-metrics strong { + display: block; +} + +.trace-metrics span { + color: var(--muted); + font-size: 11px; + font-weight: 700; +} + +.trace-metrics strong { + margin-top: 4px; + overflow: hidden; + color: var(--ink); + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.trace-detail-grid { + min-height: 0; + display: grid; + grid-template-columns: minmax(260px, .8fr) minmax(340px, 1.2fr); + gap: 10px; + padding: 0 14px 14px; + overflow: hidden; +} + +.trace-event-list { + min-height: 0; + overflow: auto; + display: grid; + align-content: start; + gap: 8px; + padding-right: 2px; +} + +.trace-event { + width: 100%; + min-height: 56px; + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 8px; + border: 1px solid var(--line); + border-radius: 4px; + background: #fff; + text-align: left; +} + +.trace-event:hover, +.trace-event.active { + border-color: rgba(var(--theme-primary-rgb), 0.28); + background: var(--theme-primary-light-9); +} + +.event-index { + width: 26px; + height: 26px; + display: grid; + place-items: center; + border-radius: 4px; + background: var(--theme-primary-soft); + color: var(--theme-primary-active); + font-size: 12px; + font-weight: 900; +} + +.event-copy { + min-width: 0; + display: grid; + gap: 3px; +} + +.event-copy strong, +.event-copy small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.event-copy strong { + color: var(--ink); + font-size: 12px; +} + +.event-copy small { + color: var(--muted); + font-size: 11px; +} + +.trace-event-payload { + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + border: 1px solid var(--line); + border-radius: 4px; + background: #fff; +} + +.payload-head { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 12px; + border-bottom: 1px solid var(--line); +} + +.payload-head div { + min-width: 0; + display: grid; + gap: 4px; +} + +.payload-head strong { + color: var(--ink); + font-size: 13px; +} + +.payload-head span { + color: var(--muted); + font-size: 12px; +} + +.payload-columns { + min-height: 0; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + overflow: hidden; +} + +.payload-columns > div { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + border-right: 1px solid var(--line); +} + +.payload-columns > div:last-child { + border-right: 0; +} + +.payload-columns h5 { + margin: 0; + padding: 10px 12px; + border-bottom: 1px solid var(--line); + color: var(--ink); + font-size: 12px; +} + +.payload-columns pre { + min-height: 260px; + max-height: 460px; + margin: 0; + padding: 12px; + overflow: auto; + background: #0f172a; + color: #e2e8f0; + font-size: 12px; + line-height: 1.55; +} + +@media (max-width: 1180px) { + .trace-filters, + .trace-layout, + .trace-detail-grid, + .payload-columns { + grid-template-columns: 1fr; + } +} diff --git a/web/src/assets/styles/views/audit-view-part2.css b/web/src/assets/styles/views/audit-view-part2.css index a626d6e..b512735 100644 --- a/web/src/assets/styles/views/audit-view-part2.css +++ b/web/src/assets/styles/views/audit-view-part2.css @@ -1035,6 +1035,12 @@ color: var(--success-hover); } +.minor-action.primary-action { + border-color: rgba(37, 99, 235, 0.28); + color: #1d4ed8; + background: #f8fbff; +} + .minor-action.enable-action { border-color: rgba(100, 116, 139, 0.26); color: #64748b; diff --git a/web/src/assets/styles/views/budget-center-view.css b/web/src/assets/styles/views/budget-center-view.css index 8b90bd7..747e9ef 100644 --- a/web/src/assets/styles/views/budget-center-view.css +++ b/web/src/assets/styles/views/budget-center-view.css @@ -1,169 +1,47 @@ .budget-center-page { - min-width: 0; + width: 100%; + height: 100%; + min-height: 0; display: grid; - gap: 14px; + grid-template-rows: minmax(0, 1fr); color: #1f2937; -} - - - -.budget-summary-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; -} - -.budget-summary-card { - --accent: var(--theme-primary); - position: relative; - min-height: 112px; - padding: 12px 14px 10px; - display: flex; - flex-direction: column; - border: 1px solid #dbe4ee; - border-left: 3px solid var(--accent); - border-radius: 4px; - background: #fff; - box-shadow: 0 1px 2px rgba(15, 23, 42, .04); - animation: dashboardItemIn 520ms var(--ease) both; - animation-delay: var(--delay, 0ms); - transition: box-shadow 200ms ease, transform 200ms ease; -} - -.budget-summary-card:hover { - box-shadow: 0 4px 12px rgba(15, 23, 42, .055); - transform: translateY(-1px); -} - -.budget-summary-card.primary { - --accent: var(--theme-primary); -} - -.budget-summary-card.info { - --accent: var(--theme-secondary); -} - -.budget-summary-card.warning { - --accent: var(--warning); -} - -.budget-summary-head { - display: flex; - align-items: center; - gap: 6px; - margin-bottom: 8px; - min-width: 0; -} - -.summary-icon { - width: 26px; - height: 26px; - border-radius: 4px; - display: grid; - place-items: center; - background: color-mix(in srgb, var(--accent) 10%, white); - color: var(--accent); - font-size: 14px; - flex: 0 0 auto; - animation: iconPop 560ms var(--ease) both; - animation-delay: calc(var(--delay, 0ms) + 100ms); -} - -.budget-summary-card .summary-label { - display: block; - min-width: 0; - color: #64748b; - font-size: 11px; - font-weight: 500; - line-height: 1.2; - white-space: nowrap; overflow: hidden; - text-overflow: ellipsis; } -.summary-value { - display: block; - min-height: 22px; - margin-bottom: 6px; - color: #0f172a; - font-size: clamp(16px, 1.2vw, 20px); - line-height: 1; - font-weight: 800; - font-variant-numeric: tabular-nums; - white-space: nowrap; - letter-spacing: 0; +.budget-list { + height: 100%; + min-height: 0; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + padding: 16px 18px; + overflow: hidden; } -.summary-comparison-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; - padding-top: 6px; - border-top: 1px solid #f1f5f9; - min-width: 0; - flex-wrap: wrap; -} - -.comparison-pill { +.budget-scope-tabs small { + min-width: 22px; + height: 18px; display: inline-flex; align-items: center; justify-content: center; - gap: 2px; - padding: 1px 6px; + padding: 0 6px; border-radius: 4px; + background: #edf2f7; + color: #64748b; font-size: 11px; - line-height: 1.45; - font-weight: 700; - white-space: nowrap; + font-weight: 850; + line-height: 1; } -.comparison-pill b { - color: inherit; - font-size: 11px; - font-weight: 600; +.budget-scope-tabs button.active small { + background: var(--theme-primary-soft); + color: var(--theme-primary-active); } -.comparison-pill em { - font-style: normal; - font-variant-numeric: tabular-nums; +.budget-toolbar { + align-items: flex-start; } -.comparison-pill i { - font-size: 11px; -} - -.comparison-pill.up { - background: var(--success-soft); - color: var(--success); -} - -.comparison-pill.down { - background: var(--danger-soft); - color: var(--danger-hover); -} - -.budget-filter-bar { - border: 1px solid #e2e8f0; - border-radius: 4px; - background: #fff; - padding: 14px 16px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.budget-filter-set, -.budget-action-set { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; - min-width: 0; -} - -.budget-filter-bar label { +.budget-select-filter { display: inline-flex; align-items: center; gap: 8px; @@ -173,50 +51,40 @@ white-space: nowrap; } -.budget-filter-bar .enterprise-select { - min-width: 128px; +.budget-select-filter .enterprise-select { + min-width: 118px; } -.budget-primary-btn { - min-height: 40px; - border: 0; +.budget-primary-btn, +.budget-ghost-btn { + min-height: 38px; border-radius: 4px; - background: var(--theme-primary-active); - color: #fff; - padding: 0 18px; display: inline-flex; align-items: center; justify-content: center; - gap: 6px; - font-size: 14px; - font-weight: 800; + gap: 7px; + padding: 0 14px; + font-size: 13px; + font-weight: 850; white-space: nowrap; - cursor: pointer; - box-shadow: none; transition: background 160ms ease, border-color 160ms ease, color 160ms ease; } +.budget-primary-btn { + border: 0; + background: var(--theme-primary-active); + color: #fff; + box-shadow: none; +} + .budget-primary-btn:hover { background: var(--theme-primary-hover); - box-shadow: none; } .budget-ghost-btn { - min-height: 38px; border: 1px solid #d7e0ea; - border-radius: 4px; background: #fff; color: #334155; - padding: 0 14px; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 9px; - font-size: 14px; - font-weight: 750; - white-space: nowrap; - cursor: pointer; - transition: border-color 160ms ease, color 160ms ease, background 160ms ease; } .budget-ghost-btn:hover { @@ -225,172 +93,80 @@ color: var(--theme-primary-active); } -.budget-work-grid { - display: grid; - grid-template-columns: 240px minmax(0, 1fr); - gap: 10px; -} - -.budget-work-grid.single-department { - grid-template-columns: minmax(0, 1fr); -} - -.budget-department-panel, -.budget-table-panel, -.budget-chart-panel, -.budget-alert-panel { - border: 1px solid #e5eaf1; - border-radius: 4px; - background: #fff; - overflow: hidden; -} - -.budget-department-panel header, -.budget-table-panel > header, -.budget-card-head { - min-height: 48px; - padding: 13px 18px; - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid #edf1f6; -} - -.budget-department-panel strong, -.budget-table-panel > header strong, -.budget-card-head strong { - color: #111827; - font-size: 16px; - font-weight: 800; -} - -.budget-table-search { - width: min(260px, 42%); - min-width: 190px; -} - -.department-search-input { - width: calc(100% - 28px); - margin: 12px 14px 8px; -} - -.budget-table-search :deep(.el-input__wrapper), -.department-search-input :deep(.el-input__wrapper) { - min-height: 34px; - border-radius: 4px; - background: #fff; -} - -.budget-table-search :deep(.el-input__prefix), -.department-search-input :deep(.el-input__prefix) { - color: #94a3b8; -} - -.department-list { - display: grid; - gap: 4px; - padding: 8px 12px 16px; -} - -.department-switch-btn { - width: 100%; - height: 38px; - border: 0; - border-radius: 4px; - background: transparent; - color: #4b5563; - padding: 0 12px; - display: flex; - align-items: center; - justify-content: flex-start; - gap: 10px; - font-size: 14px; - font-weight: 700; - text-align: left; - margin-left: 0; - transition: - background 160ms ease, - color 160ms ease; -} - -.department-switch-btn.active { - background: var(--theme-primary-soft); - color: var(--theme-primary-active); -} - -.department-switch-btn + .department-switch-btn { - margin-left: 0; -} - .budget-table-wrap { + min-height: 0; + border-radius: 4px; + background: #fff; +} + +.budget-list-table { width: 100%; - overflow-x: auto; - padding-bottom: 8px; + border-collapse: collapse; + table-layout: fixed; } -.budget-data-table { - width: 100%; - min-width: 1540px; +.budget-list-table.all { + min-width: 1210px; } -.budget-table-wrap :deep(.el-table) { - --el-table-border-color: #edf1f6; - --el-table-header-bg-color: #f8fafc; - --el-table-row-hover-bg-color: var(--theme-primary-soft); - --el-table-current-row-bg-color: var(--theme-primary-soft); - color: #273142; - font-size: 14px; +.budget-list-table.review { + min-width: 1240px; } -.budget-table-wrap :deep(.el-table .el-scrollbar__bar.is-horizontal) { - display: none !important; +.budget-list-table.archive { + min-width: 1110px; } -.budget-table-wrap :deep(.el-table__inner-wrapper::before), -.budget-table-wrap :deep(.el-table__border-left-patch) { - display: none; +.budget-list-table tbody tr { + cursor: pointer; } -.budget-table-wrap :deep(.el-table th.el-table__cell) { - background: #f8fafc; - color: #1f2937; - font-weight: 800; +.budget-list-table tbody tr:hover td { + background: var(--theme-primary-soft); } -.budget-table-wrap :deep(.el-table td.el-table__cell) { - color: #273142; +.budget-list-table td:nth-child(4), +.budget-list-table td:nth-child(5), +.budget-list-table td:nth-child(6), +.budget-list-table td:nth-child(7) { + font-variant-numeric: tabular-nums; } -.budget-table-wrap :deep(.el-table .cell) { - padding: 0 12px; - white-space: nowrap; +.budget-list-table th:first-child, +.budget-list-table td:first-child { + text-align: left; } -.budget-table-wrap :deep(.el-table--border .el-table__cell) { - border-right-color: #edf1f6; +.col-budget-no { width: 13%; } +.col-department { width: 9%; } +.col-period { width: 10%; } +.col-money { width: 11%; } +.col-rate { width: 10%; } +.col-status { width: 8%; } +.col-updated { width: 12%; } +.col-person { width: 8%; } +.col-submitted { width: 11%; } +.col-change { width: 8%; } +.col-score { width: 8%; } +.col-version { width: 8%; } + +.budget-no { + color: #0f172a; + font-weight: 850; } .budget-rate { width: 100%; - max-width: 110px; - display: flex; + max-width: 116px; + display: inline-flex; align-items: center; + justify-content: center; gap: 8px; - margin: 0 auto; -} - -.budget-rate span { - flex: 0 0 auto; - color: #273142; - font-size: 13px; - font-variant-numeric: tabular-nums; - text-align: right; - min-width: 32px; } .budget-rate div { flex: 1; - min-width: 0; + min-width: 58px; height: 6px; border-radius: 4px; background: #e9edf3; @@ -407,360 +183,292 @@ background: var(--success); } -.budget-rate em.warn { - background: var(--warning); -} - -.budget-rate em.danger { - background: var(--danger); -} - -.budget-threshold-badge { - min-width: 58px; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 4px 9px; - border-radius: 4px; - font-size: 12px; - font-weight: 800; - line-height: 1.2; -} - -.budget-threshold-badge.reminder { - background: var(--theme-primary-soft); - color: var(--theme-primary-active); -} - -.budget-threshold-badge.alert { - background: rgba(245, 158, 11, .14); - color: #b45309; -} - -.budget-threshold-badge.risk { - background: rgba(127, 29, 29, .1); - color: #7f1d1d; -} - -.budget-table-foot { - min-height: 52px; - padding: 10px 18px; - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px; -} - -.budget-page-summary { - color: #64748b; - font-size: 14px; - font-weight: 650; -} - -.budget-pager { - display: inline-flex; - justify-content: center; - gap: 6px; - padding: 4px; - border: 1px solid #e2e8f0; - border-radius: 4px; - background: #f8fafc; -} - -.budget-pager :deep(.btn-prev), -.budget-pager :deep(.btn-next), -.budget-pager :deep(.el-pager li) { - min-width: 32px; - height: 32px; - border-radius: 4px; - background: transparent; - color: #334155; - font-size: 14px; - font-weight: 800; - transition: background 160ms ease, color 160ms ease; -} - -.budget-pager :deep(.btn-prev:hover:not(:disabled)), -.budget-pager :deep(.btn-next:hover:not(:disabled)), -.budget-pager :deep(.el-pager li:hover:not(.is-active)) { - background: #fff; - color: var(--theme-primary-active); -} - -.budget-pager :deep(.el-pager li.is-active) { - background: var(--theme-primary-active); - color: #fff; -} - -.budget-pager :deep(.btn-prev:disabled), -.budget-pager :deep(.btn-next:disabled) { - color: #94a3b8; - cursor: not-allowed; -} - -.budget-page-size { - min-height: 38px; - min-width: 112px; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 9px; - padding: 0 14px; - border: 1px solid #d7e0ea; - border-radius: 4px; - background: #fff; - color: #334155; - font-size: 14px; - font-weight: 750; - white-space: nowrap; - box-shadow: 0 1px 2px rgba(15, 23, 42, .04); - cursor: pointer; - transition: border-color 160ms ease, color 160ms ease; -} - -.budget-page-size-select { - width: 112px; -} - -.budget-bottom-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(360px, 0.82fr); - gap: 10px; -} - -.budget-link-btn { - height: 30px; - padding: 0 8px; - border-radius: 4px; - color: var(--theme-primary-active); - font-size: 14px; - font-weight: 800; -} - -.budget-link-btn:hover { - background: var(--theme-primary-soft); - color: var(--theme-primary-active); -} - -.budget-chart-legend { - display: flex; - align-items: center; - gap: 22px; - color: #4b5563; - font-size: 13px; -} - -.budget-chart-legend span { - display: inline-flex; - align-items: center; - gap: 7px; -} - -.legend-line { - width: 10px; - height: 10px; - border-radius: 3px; +.budget-rate em.reminder { background: var(--theme-primary); } -.legend-line.occupied { +.budget-rate em.alert { background: var(--warning); } -.legend-line.available { - background: #e5edf3; - border: 1px solid #cbd5e1; -} - -.budget-chart-panel { - padding-bottom: 16px; -} - -.budget-chart-panel .budget-trend-chart { - margin: 12px 18px 0; -} - -.budget-alert-list { - display: grid; - padding: 12px 20px 18px; -} - -.budget-alert-empty { - min-height: 220px; - padding: 28px 24px 30px; - display: grid; - place-items: center; - align-content: center; - gap: 10px; - text-align: center; -} - -.budget-alert-empty-icon { - width: 44px; - height: 44px; - border-radius: 4px; - display: grid; - place-items: center; - background: var(--theme-primary-soft); - color: var(--theme-primary-active); - font-size: 24px; -} - -.budget-alert-empty strong { - color: #111827; - font-size: 16px; - font-weight: 800; -} - -.budget-alert-empty p { - max-width: 260px; - margin: 0; - color: #64748b; - font-size: 13px; - line-height: 1.6; -} - -.budget-alert-row { - min-height: 46px; - display: grid; - grid-template-columns: 12px 120px minmax(0, 1fr) 92px; - gap: 12px; - align-items: center; - border-bottom: 1px solid #edf1f6; -} - -.budget-alert-row:last-child { - border-bottom: 0; -} - -.budget-alert-row i { - width: 8px; - height: 8px; - border-radius: 2px; -} - -.budget-alert-row i.danger { +.budget-rate em.risk { background: var(--danger); } -.budget-alert-row i.warn { - background: var(--warning); -} - -.budget-alert-row i.ok { - background: var(--success); -} - -.budget-alert-row strong { - color: #273142; - font-size: 14px; +.budget-rate span { + min-width: 38px; + color: #334155; + font-size: 12px; font-weight: 800; + text-align: right; + font-variant-numeric: tabular-nums; } -.budget-alert-row span { - min-width: 0; - color: #4b5563; - font-size: 14px; - overflow: hidden; - text-overflow: ellipsis; +.budget-status-tag, +.budget-score, +.budget-change, +.budget-threshold { + min-width: 54px; + min-height: 24px; + padding: 4px 8px; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #334155; + background: #f1f5f9; + font-size: 12px; + line-height: 1.2; + font-weight: 850; white-space: nowrap; } -.budget-alert-row time { - color: #6b7280; +.budget-status-tag.ok, +.budget-status-tag.archived { + color: var(--success); + background: var(--success-soft); +} + +.budget-status-tag.reminder, +.budget-status-tag.pending, +.budget-score, +.budget-threshold.reminder { + color: var(--theme-primary-active); + background: var(--theme-primary-soft); +} + +.budget-status-tag.alert, +.budget-threshold.alert { + color: #b45309; + background: rgba(245, 158, 11, .14); +} + +.budget-status-tag.risk, +.budget-threshold.risk { + color: #7f1d1d; + background: rgba(127, 29, 29, .1); +} + +.budget-change { + color: #b45309; + background: rgba(245, 158, 11, .12); +} + +.budget-detail-page { + min-height: 0; + padding: 16px 18px 0; +} + +.budget-detail-page :deep(.detail-scroll) { + min-height: 0; + display: block; + padding-right: 4px; + overflow: auto; +} + +.budget-detail-page :deep(.detail-scroll) > * + * { + margin-top: 14px; +} + +.budget-detail-page :deep(.detail-grid) { + min-height: 0; + display: grid; + grid-template-columns: minmax(520px, 1fr) minmax(300px, 360px); + gap: 14px; + align-items: start; +} + +.budget-detail-page :deep(.detail-main), +.budget-detail-page :deep(.detail-side) { + min-height: 0; + display: grid; + gap: 14px; +} + +.budget-detail-page :deep(.detail-actions) { + margin-top: 10px; + padding: 10px 0 0; +} + +.budget-period-card span, +.budget-period-card p, +.budget-status-explain-item p { + margin: 0; + color: #64748b; + font-size: 12px; + line-height: 1.5; +} + +.budget-period-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.budget-period-card { + border: 1px solid #dbe4ee; + border-radius: 4px; + background: #fff; + min-height: 94px; + padding: 12px 14px; + display: grid; + gap: 5px; +} + +.budget-period-card strong { + color: #0f172a; + font-size: 19px; + line-height: 1.2; + font-weight: 900; + font-variant-numeric: tabular-nums; +} + +.budget-detail-page :deep(.enterprise-detail-card) { + min-height: 0; + padding: 14px; + border: 1px solid #dbe4ee; + border-radius: 4px; + background: #fff; + overflow: hidden; +} + +.budget-detail-page :deep(.enterprise-detail-card .card-head) { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.budget-detail-page :deep(.enterprise-detail-card .card-head h3) { + margin: 0; + color: #111827; + font-size: 15px; + line-height: 1.35; + font-weight: 850; +} + +.budget-detail-page :deep(.enterprise-detail-card .card-head p) { + margin: 4px 0 0; + color: #64748b; + font-size: 12px; + line-height: 1.5; +} + +.budget-chart-card :deep(.budget-trend-chart) { + min-height: 260px; +} + +.budget-detail-table-wrap { + width: 100%; + overflow-x: auto; +} + +.budget-detail-table { + width: 100%; + min-width: 820px; + border-collapse: collapse; + table-layout: fixed; +} + +.budget-detail-table th, +.budget-detail-table td { + padding: 11px 10px; + border-bottom: 1px solid #edf1f6; + color: #273142; font-size: 13px; - text-align: right; + line-height: 1.35; + text-align: center; + white-space: nowrap; } -@keyframes dashboardItemIn { - from { - opacity: 0; - transform: translateY(12px); - } - to { - opacity: 1; - transform: translateY(0); - } +.budget-detail-table th { + background: #f8fafc; + color: #334155; + font-weight: 850; } -@keyframes iconPop { - 0% { - opacity: 0; - transform: scale(.82); - } - 70% { - opacity: 1; - transform: scale(1.04); - } - 100% { - opacity: 1; - transform: scale(1); - } +.budget-detail-table tbody tr:last-child td { + border-bottom: 0; +} + +.budget-status-explain-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.budget-status-explain-item { + min-height: 72px; + padding: 12px; + border: 1px solid #edf1f6; + border-radius: 4px; + background: #f8fafc; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: flex-start; + gap: 12px; +} + +.budget-status-explain-item strong { + display: block; + margin-bottom: 4px; + color: #0f172a; + font-size: 13px; + line-height: 1.35; + font-weight: 850; +} + +.budget-status-explain-item .budget-status-tag { + margin-top: 1px; } @media (max-width: 1280px) { - .budget-summary-grid, - .budget-bottom-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .budget-work-grid { - grid-template-columns: 1fr; - } - - .budget-department-panel { - min-height: auto; - } - - .department-list { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } -} - -@media (max-width: 760px) { - .budget-summary-grid, - .budget-bottom-grid { - grid-template-columns: 1fr; - } - - .budget-filter-bar { + .budget-toolbar { align-items: stretch; flex-direction: column; } - .budget-filter-bar label, - .budget-filter-bar .enterprise-select, - .budget-filter-set, - .budget-action-set, + .budget-toolbar .document-actions { + justify-content: flex-end; + } + + .budget-detail-page :deep(.detail-grid) { + grid-template-columns: minmax(0, 1fr); + } + + .budget-detail-page :deep(.detail-main), + .budget-detail-page :deep(.detail-side) { + min-height: auto; + } + +} + +@media (max-width: 760px) { + .budget-list { + padding: 16px; + } + + .budget-detail-page { + padding: 16px 16px 0; + } + + .budget-select-filter, + .budget-select-filter .enterprise-select, .budget-primary-btn, .budget-ghost-btn { width: 100%; } - .budget-filter-bar label { + .budget-select-filter { justify-content: space-between; } - .budget-table-foot { - justify-content: flex-start; - flex-wrap: wrap; - } - - .budget-pager, - .budget-page-size { - width: 100%; - } - - .department-list { + .budget-period-grid { grid-template-columns: 1fr; } - .budget-alert-row { - grid-template-columns: 12px minmax(0, 1fr); - } - - .budget-alert-row span, - .budget-alert-row time { - grid-column: 2; + .budget-status-explain-list { + grid-template-columns: minmax(0, 1fr); } } diff --git a/web/src/assets/styles/views/log-detail-view.css b/web/src/assets/styles/views/log-detail-view.css index d8803b9..27f1914 100644 --- a/web/src/assets/styles/views/log-detail-view.css +++ b/web/src/assets/styles/views/log-detail-view.css @@ -119,6 +119,14 @@ line-height: 1.6; } +.hero-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + .refresh-btn { min-height: 38px; display: inline-flex; diff --git a/web/src/assets/styles/views/receipt-folder-view.css b/web/src/assets/styles/views/receipt-folder-view.css index b5a9860..e69caf7 100644 --- a/web/src/assets/styles/views/receipt-folder-view.css +++ b/web/src/assets/styles/views/receipt-folder-view.css @@ -45,7 +45,8 @@ } .receipt-key-grid input, -.receipt-edit-field-row input { +.receipt-edit-field-row input, +.receipt-ocr-field input { width: 100%; border: 1px solid #d7e0ea; border-radius: 4px; @@ -56,13 +57,15 @@ } .receipt-key-grid input, -.receipt-edit-field-row input { +.receipt-edit-field-row input, +.receipt-ocr-field input { height: 36px; padding: 0 10px; } .receipt-key-grid input:focus, -.receipt-edit-field-row input:focus { +.receipt-edit-field-row input:focus, +.receipt-ocr-field input:focus { border-color: var(--theme-primary); box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14); outline: none; @@ -122,6 +125,73 @@ padding-top: 10px; } +.receipt-detail-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border: 1px solid #dbe4ee; + border-radius: 4px; + background: #fff; +} + +.receipt-detail-title { + min-width: 0; + display: grid; + gap: 3px; +} + +.receipt-detail-title strong { + color: #0f172a; + font-size: 18px; + font-weight: 850; +} + +.receipt-detail-title span { + color: #0f172a; + font-size: 13px; + font-weight: 780; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.receipt-detail-title p { + margin: 0; + color: #64748b; + font-size: 12px; +} + +.receipt-toolbar-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.receipt-dashboard { + min-height: 0; + display: grid; + grid-template-columns: minmax(420px, 0.92fr) minmax(520px, 1.08fr); + gap: 14px; + align-items: stretch; +} + +.receipt-dashboard-side { + min-height: 0; + display: grid; + gap: 14px; +} + +.receipt-dashboard-bottom { + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(260px, 0.95fr) minmax(320px, 1.2fr) minmax(240px, 0.85fr); + gap: 14px; +} + .receipt-folder-detail :deep(.detail-grid) { min-height: 0; display: grid; @@ -159,7 +229,11 @@ } .receipt-basic-panel, -.receipt-preview-panel { +.receipt-preview-panel, +.receipt-ocr-panel, +.receipt-status-panel, +.receipt-info-panel, +.receipt-log-panel { min-height: 0; overflow: hidden; border: 1px solid #dbe4ee; @@ -170,7 +244,7 @@ .receipt-basic-panel { display: block; padding: 14px; - overflow-y: auto; + overflow: hidden; } .receipt-field-list-head { @@ -193,22 +267,106 @@ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; - margin-top: 14px; } .receipt-key-field, -.receipt-edit-field-row label { +.receipt-edit-field-row label, +.receipt-ocr-field { display: grid; gap: 6px; } .receipt-key-field span, -.receipt-edit-field-row label span { +.receipt-edit-field-row label span, +.receipt-ocr-field span, +.receipt-static-item span, +.receipt-data-item span, +.receipt-status-item span { color: #64748b; font-size: 12px; font-weight: 750; } +.receipt-card-count { + color: #64748b; + font-size: 12px; + font-weight: 750; +} + +.receipt-static-grid, +.receipt-ocr-grid, +.receipt-status-grid, +.receipt-data-list { + display: grid; + gap: 10px; +} + +.receipt-static-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid #edf2f7; +} + +.receipt-static-item, +.receipt-data-item, +.receipt-status-item { + min-width: 0; + display: grid; + gap: 4px; +} + +.receipt-static-item strong, +.receipt-data-item strong { + min-width: 0; + color: #0f172a; + font-size: 13px; + font-weight: 780; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.receipt-ocr-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-bottom: 12px; +} + +.receipt-status-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.receipt-status-item { + grid-template-columns: minmax(90px, 1fr) auto; + align-items: center; + min-height: 30px; +} + +.receipt-status-item strong { + min-height: 24px; + display: inline-flex; + align-items: center; + justify-self: start; + padding: 0 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 800; +} + +.receipt-status-item .tone-success { + background: var(--success-soft); + color: var(--success-active); +} + +.receipt-status-item .tone-warning { + background: #fff7ed; + color: #ea580c; +} + +.receipt-status-item .tone-info { + background: #eff6ff; + color: #2563eb; +} + .receipt-other-info { margin-top: 18px; } @@ -288,21 +446,32 @@ .receipt-preview-panel { display: grid; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr) auto; + padding: 14px; +} + +.receipt-preview-frame { + min-height: 0; + padding: 10px; + border: 1px solid #e5edf5; + border-radius: 4px; + background: #fff; } .receipt-preview-box { - min-height: 0; + min-height: 340px; display: grid; place-items: center; overflow: auto; - background: #f8fafc; + background: #f7fbff; } .receipt-preview-box img { max-width: 100%; max-height: 100%; object-fit: contain; + transform-origin: center center; + transition: transform 180ms ease; } .receipt-preview-box iframe { @@ -325,6 +494,113 @@ font-size: 34px; } +.receipt-preview-tools { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-top: 12px; +} + +.preview-page, +.preview-tool-group { + min-height: 32px; + display: inline-flex; + align-items: center; +} + +.preview-page { + padding: 0 12px; + border: 1px solid #e1e8f0; + border-radius: 4px; + color: #0f172a; + font-size: 13px; + font-weight: 800; +} + +.preview-tool-group { + gap: 6px; +} + +.preview-tool-group button { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border: 1px solid #e1e8f0; + border-radius: 4px; + background: #fff; + color: #334155; +} + +.preview-tool-group button:disabled { + cursor: not-allowed; + opacity: .45; +} + +.preview-tool-group strong { + min-width: 48px; + text-align: center; + color: #0f172a; + font-size: 12px; + font-weight: 800; +} + +.receipt-log-list { + position: relative; + display: grid; + gap: 10px; + margin: 0; + padding: 0 0 0 16px; + list-style: none; +} + +.receipt-log-list::before { + content: ""; + position: absolute; + left: 4px; + top: 6px; + bottom: 6px; + width: 1px; + background: #dbe4ee; +} + +.receipt-log-list li { + position: relative; + display: grid; + grid-template-columns: 120px 54px minmax(0, 1fr); + gap: 8px; + align-items: start; + color: #334155; + font-size: 12px; +} + +.receipt-log-list li::before { + content: ""; + position: absolute; + left: -15px; + top: 5px; + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--theme-primary); +} + +.receipt-log-list span { + color: #64748b; + font-variant-numeric: tabular-nums; +} + +.receipt-log-list strong { + color: #0f172a; + font-weight: 780; +} + +.receipt-log-list p { + margin: 0; + line-height: 1.45; +} + .associate-step { display: grid; gap: 12px; @@ -387,6 +663,8 @@ } @media (max-width: 1120px) { + .receipt-dashboard, + .receipt-dashboard-bottom, .receipt-folder-detail :deep(.detail-grid) { grid-template-columns: 1fr; overflow-y: auto; @@ -402,8 +680,22 @@ padding: 12px; } + .receipt-detail-toolbar, + .receipt-toolbar-actions, + .receipt-preview-tools { + align-items: stretch; + flex-direction: column; + } + .receipt-key-grid, - .receipt-edit-field-row { + .receipt-edit-field-row, + .receipt-static-grid, + .receipt-ocr-grid, + .receipt-status-grid { + grid-template-columns: 1fr; + } + + .receipt-log-list li { grid-template-columns: 1fr; } } diff --git a/web/src/assets/styles/views/travel-request-detail-view-part2.css b/web/src/assets/styles/views/travel-request-detail-view-part2.css index c323e4e..cd08e58 100644 --- a/web/src/assets/styles/views/travel-request-detail-view-part2.css +++ b/web/src/assets/styles/views/travel-request-detail-view-part2.css @@ -80,15 +80,19 @@ .validation-section--risk .risk-advice-list { display: grid; - gap: 10px; + gap: 8px; margin-top: 0; + max-height: 360px; + padding-right: 4px; + overflow-y: auto; + scrollbar-gutter: stable; } .validation-section--risk .risk-advice-card-head { display: flex; align-items: center; - justify-content: space-between; - gap: 10px; + justify-content: flex-start; + gap: 8px; } .validation-section--risk .risk-advice-card-head span { @@ -96,7 +100,7 @@ display: inline-flex; align-items: center; padding: 0 8px; - border-radius: 4px; + border-radius: 2px; background: #fef2f2; color: #b91c1c; font-size: 10px; @@ -117,9 +121,13 @@ .validation-section--risk .risk-advice-card-head strong { min-width: 0; color: #0f172a; - font-size: 12px; - line-height: 1.4; - text-align: right; + font-size: 13px; + font-weight: 850; + line-height: 1.45; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .validation-section--risk .risk-advice-point { @@ -172,7 +180,7 @@ display: inline-flex; align-items: center; padding: 0 9px; - border-radius: 4px; + border-radius: 2px; background: #fee2e2; color: #b91c1c; font-size: 11px; diff --git a/web/src/assets/styles/views/travel-request-detail-view.css b/web/src/assets/styles/views/travel-request-detail-view.css index 1dd6894..891c34c 100644 --- a/web/src/assets/styles/views/travel-request-detail-view.css +++ b/web/src/assets/styles/views/travel-request-detail-view.css @@ -391,11 +391,12 @@ } .progress-step-status { - max-width: 100%; + box-sizing: border-box; + width: 100%; + max-width: 136px; + min-width: 0; min-height: 22px; - display: inline-flex; - align-items: center; - justify-content: center; + display: block; padding: 0 9px; border: 1px solid #e2e8f0; border-radius: 4px; @@ -403,7 +404,7 @@ color: #64748b; font-size: 11px; font-weight: 850; - line-height: 1; + line-height: 20px; text-align: center; white-space: nowrap; overflow: hidden; @@ -1775,31 +1776,149 @@ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); } -.validation-section--risk .risk-advice-card { +.validation-section--risk .risk-advice-list { display: grid; gap: 8px; - padding: 12px 12px 11px; + max-height: 360px; + padding-right: 4px; + overflow-y: auto; + scrollbar-gutter: stable; +} + +.validation-section--risk .risk-advice-card { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(220px, .9fr); + align-items: start; + gap: 12px; + min-height: 64px; + padding: 10px 12px 10px 14px; border: 1px solid #e5e7eb; - border-radius: 4px; + border-radius: 2px; background: #ffffff; box-shadow: 0 1px 1px rgba(15, 23, 42, 0.03); } +.validation-section--risk .risk-advice-card::before { + content: ""; + position: absolute; + inset: 10px auto 10px 7px; + width: 3px; + border-radius: 1px; + background: #ef4444; +} + .validation-section--risk .risk-advice-card.medium { border-color: #f3e8d9; background: #fffcf7; } +.validation-section--risk .risk-advice-card.medium::before { + background: #f97316; +} + .validation-section--risk .risk-advice-card.low { border-color: #dbeafe; background: #f8fbff; } -.validation-section--risk .risk-advice-meta ul, -.validation-section--risk .risk-advice-meta p { +.validation-section--risk .risk-advice-card.low::before { + background: #2563eb; +} + +.risk-advice-card-main, +.risk-advice-compact-meta { + min-width: 0; +} + +.risk-advice-card-head { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.risk-advice-card-head span { + flex: 0 0 auto; + height: 20px; + display: inline-flex; + align-items: center; + padding: 0 7px; + border: 1px solid #fecaca; + border-radius: 2px; + background: #fef2f2; + color: #b91c1c; + font-size: 10px; + font-weight: 850; + line-height: 1; +} + +.risk-advice-card.medium .risk-advice-card-head span { + border-color: #fed7aa; + background: #fff7ed; + color: #c2410c; +} + +.risk-advice-card.low .risk-advice-card-head span { + border-color: #bfdbfe; + background: #eff6ff; + color: #1d4ed8; +} + +.risk-advice-card-head strong { + min-width: 0; + color: #0f172a; + font-size: 13px; + font-weight: 850; + line-height: 1.45; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.risk-advice-point { + display: -webkit-box; + margin: 5px 0 0; + color: #334155; + font-size: 12px; + line-height: 1.5; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +.risk-advice-compact-meta { + display: grid; + gap: 4px; + padding-left: 12px; + border-left: 1px solid #e2e8f0; +} + +.risk-advice-compact-meta span, +.risk-advice-compact-meta em { margin: 0; color: #334155; font-size: 11px; line-height: 1.5; + font-style: normal; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.risk-advice-compact-meta span { + color: #64748b; +} + +@media (max-width: 1180px) { + .validation-section--risk .risk-advice-card { + grid-template-columns: 1fr; + gap: 8px; + } + + .risk-advice-compact-meta { + padding-left: 0; + border-left: 0; + } } diff --git a/web/src/components/audit/AuditAssetList.vue b/web/src/components/audit/AuditAssetList.vue index c443cd8..6230321 100644 --- a/web/src/components/audit/AuditAssetList.vue +++ b/web/src/components/audit/AuditAssetList.vue @@ -12,7 +12,18 @@ :loading-message="`正在加载${activeTabLabel}资产`" loading-icon="mdi mdi-view-list-outline" :hint="hintText" + :show-pagination="!loading && !errorMessage && visibleSkills.length > 0" + :current-page="currentPage" + :page-size="pageSize" + :page-size-options="pageSizeOptions" + :pages="pageNumbers" + :show-page-size="true" + :summary="paginationSummary" + :total="visibleSkills.length" + :total-pages="totalPages" @update:active-tab="emit('update:activeType', $event)" + @update:current-page="currentPage = $event" + @update:page-size="changePageSize" @empty-action="emit('empty-action')" > - - diff --git a/web/src/components/audit/AuditRuleDialogs.vue b/web/src/components/audit/AuditRuleDialogs.vue index e83f219..d9efb3b 100644 --- a/web/src/components/audit/AuditRuleDialogs.vue +++ b/web/src/components/audit/AuditRuleDialogs.vue @@ -16,6 +16,30 @@ @confirm="emit('submit-risk-rule-create')" >
+ +
+
+ {{ selectedRiskRuleTemplate.title }} + {{ selectedRiskRuleTemplate.group_label }} +
+

{{ selectedRiskRuleTemplate.description }}

+ 字段:{{ formatTemplateFields(selectedRiskRuleTemplate.fields) }} + 模板只预填规则文本,提交后仍走通用自然语言生成链路。 +
+

+ 常见模板暂时加载失败,可以继续手动编写规则。 +