Compare commits
6 Commits
68f663f2f4
...
8814fe7dfa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8814fe7dfa | ||
|
|
9b97f456cf | ||
|
|
9d90bf5299 | ||
|
|
35a3783481 | ||
|
|
4414ffb34c | ||
|
|
55e0591a5e |
453
document/development/rules/rule-version-center-ui-plan.md
Normal file
453
document/development/rules/rule-version-center-ui-plan.md
Normal file
@@ -0,0 +1,453 @@
|
||||
# 规则版本中心 UI 方案
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前“任务规则中心 > 财务规则 > 公司差旅费报销规则”已经具备:
|
||||
|
||||
- 在线 Excel 编辑
|
||||
- 工作版本 / 线上版本分离
|
||||
- 最近 5 个版本展示
|
||||
- 审核、上线、恢复能力
|
||||
|
||||
但页面仍然存在一个明显问题:
|
||||
**版本治理能力已经有了,用户却很难第一眼看见。**
|
||||
|
||||
当前版本列表更像“历史记录”,不是一个明确的“版本操作中心”。
|
||||
用户无法快速判断:
|
||||
|
||||
1. 当前真正生效的是哪个版本
|
||||
2. 当前正在编辑的是哪个版本
|
||||
3. 从哪里进入版本切换
|
||||
4. 从哪里发起版本对比
|
||||
5. 某个版本经历了哪些流转动作
|
||||
|
||||
因此,需要把现有“版本列表”升级为一个真正可用的 **版本中心**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计目标
|
||||
|
||||
### 2.1 用户一眼能看懂
|
||||
|
||||
进入规则详情页后,用户无需点击就能立即识别:
|
||||
|
||||
- 当前线上版本
|
||||
- 当前工作版本
|
||||
- 是否存在未上线工作稿
|
||||
- 最近版本是否处于待审 / 已通过 / 已驳回状态
|
||||
|
||||
### 2.2 关键操作显性化
|
||||
|
||||
以下操作不能再隐藏在不明显的位置:
|
||||
|
||||
- 切换查看版本
|
||||
- 与线上版本对比
|
||||
- 查看完整流转
|
||||
- 从历史版本恢复
|
||||
|
||||
### 2.3 保持 OnlyOffice 是主角
|
||||
|
||||
该页面的核心仍然是 Excel 规则表。
|
||||
版本中心必须增强治理能力,但不能把主表格压缩成附属内容。
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐方案
|
||||
|
||||
采用:
|
||||
|
||||
> **左侧 OnlyOffice 主工作区 + 右侧版本中心 + 顶部显性入口 + 抽屉式详情**
|
||||
|
||||
这是比“单独开二级页签”更适合当前页面的方案,因为用户经常需要:
|
||||
|
||||
- 一边看表
|
||||
- 一边知道自己看的是什么版本
|
||||
- 一边进入版本对比或恢复
|
||||
|
||||
---
|
||||
|
||||
## 4. 页面整体布局
|
||||
|
||||
```text
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ 标题区:公司差旅费报销规则 │
|
||||
│ 线上版本 v1.0.5 已上线 工作版本 v1.0.6 待审核 │
|
||||
│ [下载 Excel] [上传表格] [版本对比] [查看流转] │
|
||||
├───────────────────────────────────────────────┬────────────────────┤
|
||||
│ │ 版本中心 │
|
||||
│ │ │
|
||||
│ │ ┌──────────────┐ │
|
||||
│ │ │ 线上版本 │ │
|
||||
│ │ │ v1.0.5 │ │
|
||||
│ │ └──────────────┘ │
|
||||
│ OnlyOffice │ ┌──────────────┐ │
|
||||
│ 规则表主工作区 │ │ 工作版本 │ │
|
||||
│ │ │ v1.0.6 │ │
|
||||
│ │ └──────────────┘ │
|
||||
│ │ │
|
||||
│ │ 最近版本 │
|
||||
│ │ v1.0.6 待审核 │
|
||||
│ │ v1.0.5 已上线 │
|
||||
│ │ v1.0.4 历史版本 │
|
||||
│ │ │
|
||||
│ │ 最近流转 │
|
||||
│ │ [查看完整流转] │
|
||||
└───────────────────────────────────────────────┴────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 顶部操作区设计
|
||||
|
||||
顶部必须保留并强化四个动作:
|
||||
|
||||
| 按钮 | 用途 |
|
||||
| --- | --- |
|
||||
| 下载 Excel | 下载当前预览版本 |
|
||||
| 上传表格 | 导入内容生成新工作稿 |
|
||||
| 版本对比 | 打开对比抽屉 |
|
||||
| 查看流转 | 打开流转抽屉 |
|
||||
|
||||
### 5.1 版本对比按钮
|
||||
|
||||
这是一级入口,不能只藏在版本列表里。
|
||||
默认行为:
|
||||
|
||||
- 基准版本:当前线上版本
|
||||
- 对比版本:当前工作版本
|
||||
|
||||
如果两者相同,则按钮仍可用,但进入后提示:
|
||||
|
||||
> 当前工作版本与线上版本一致,可选择其他历史版本进行比较。
|
||||
|
||||
### 5.2 查看流转按钮
|
||||
|
||||
用于进入当前规则的完整生命周期视图。
|
||||
不应只展示审计日志,而要展示“版本业务履历”。
|
||||
|
||||
---
|
||||
|
||||
## 6. 右侧版本中心设计
|
||||
|
||||
### 6.1 顶部双版本卡片
|
||||
|
||||
```text
|
||||
线上版本
|
||||
v1.0.5
|
||||
已上线
|
||||
|
||||
工作版本
|
||||
v1.0.6
|
||||
待审核
|
||||
```
|
||||
|
||||
#### 设计目的
|
||||
|
||||
用户进入页面后,最先要知道的是:
|
||||
|
||||
- **谁在线上**
|
||||
- **谁正在被编辑**
|
||||
|
||||
而不是先看一个无上下文的历史列表。
|
||||
|
||||
### 6.2 最近版本列表
|
||||
|
||||
每个版本项包含:
|
||||
|
||||
- 版本号
|
||||
- 生命周期状态
|
||||
- 创建时间
|
||||
- 变更说明
|
||||
- 操作入口
|
||||
|
||||
建议样式:
|
||||
|
||||
```text
|
||||
v1.0.6 待审核
|
||||
2026-05-18 09:12
|
||||
补充出差补助标准
|
||||
[查看] [与线上比]
|
||||
|
||||
v1.0.5 已上线
|
||||
2026-05-18 08:40
|
||||
新增补助页签
|
||||
[查看]
|
||||
|
||||
v1.0.4 历史版本
|
||||
2026-05-17 17:20
|
||||
修正住宿标准
|
||||
[查看] [恢复]
|
||||
```
|
||||
|
||||
#### 规则
|
||||
|
||||
- `查看`:切换当前预览版本
|
||||
- `与线上比`:直接以线上版本为基准进入对比
|
||||
- `恢复`:仅高级管理人员可见
|
||||
- 当前 `working_version` 不显示“恢复”
|
||||
|
||||
### 6.3 最近流转摘要
|
||||
|
||||
右侧版本中心底部展示最近 3 条流转:
|
||||
|
||||
```text
|
||||
最近流转
|
||||
09:12 曹笑竹 保存工作稿
|
||||
09:25 曹笑竹 提交审核
|
||||
10:08 顾承宇 审核通过
|
||||
[查看完整流转]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 版本流转时间线设计
|
||||
|
||||
## 7.1 入口
|
||||
|
||||
两个入口:
|
||||
|
||||
1. 顶部 `查看流转`
|
||||
2. 右侧版本中心底部 `查看完整流转`
|
||||
|
||||
## 7.2 容器
|
||||
|
||||
使用右侧宽抽屉,不使用小弹窗。
|
||||
原因:
|
||||
|
||||
- 时间线内容会逐步增长
|
||||
- 审核意见需要足够宽度展示
|
||||
- 后续可能接入版本说明、操作人、来源版本
|
||||
|
||||
## 7.3 时间线内容
|
||||
|
||||
时间线按时间倒序或正序展示,推荐默认正序:
|
||||
|
||||
```text
|
||||
● 2026-05-18 09:12
|
||||
v1.0.6 工作稿创建
|
||||
曹笑竹 保存工作稿
|
||||
变更说明:补充出差补助标准
|
||||
|
||||
● 2026-05-18 09:25
|
||||
提交审核
|
||||
曹笑竹 提交当前工作版本
|
||||
|
||||
● 2026-05-18 10:08
|
||||
审核通过
|
||||
顾承宇:口径已核对,可上线
|
||||
|
||||
○ 待正式上线
|
||||
```
|
||||
|
||||
如果版本来自恢复:
|
||||
|
||||
```text
|
||||
● 基于 v1.0.3 恢复生成 v1.0.7
|
||||
```
|
||||
|
||||
## 7.4 时间线事件类型
|
||||
|
||||
| 事件类型 | 说明 |
|
||||
| --- | --- |
|
||||
| `created` | 创建版本 |
|
||||
| `submitted` | 提交审核 |
|
||||
| `approved` | 审核通过 |
|
||||
| `rejected` | 驳回 |
|
||||
| `published` | 正式上线 |
|
||||
| `restored` | 基于历史版本恢复 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 版本差异对比设计
|
||||
|
||||
## 8.1 入口
|
||||
|
||||
版本对比必须有两个入口:
|
||||
|
||||
1. 顶部一级按钮:`版本对比`
|
||||
2. 每个历史版本行内操作:`与线上比`
|
||||
|
||||
这样既满足“主动进入”,也满足“看到某个版本就顺手比较”。
|
||||
|
||||
## 8.2 容器
|
||||
|
||||
使用宽抽屉,推荐宽度:
|
||||
|
||||
- 桌面:页面宽度的 70% ~ 80%
|
||||
- 小屏:全屏
|
||||
|
||||
不建议用普通弹窗,因为:
|
||||
|
||||
- Excel 差异需要足够展示宽度
|
||||
- 版本选择器、摘要、表格都要共存
|
||||
|
||||
## 8.3 顶部区域
|
||||
|
||||
```text
|
||||
版本对比
|
||||
|
||||
基准版本 [v1.0.5 已上线 ▼]
|
||||
对比版本 [v1.0.6 待审核 ▼]
|
||||
```
|
||||
|
||||
默认值:
|
||||
|
||||
- `baseVersion = published_version`
|
||||
- `targetVersion = working_version`
|
||||
|
||||
## 8.4 差异摘要
|
||||
|
||||
优先先给决策信息,再给底层明细。
|
||||
|
||||
```text
|
||||
差异摘要
|
||||
- 修改 2 个工作表
|
||||
- 新增 1 个工作表
|
||||
- 修改 12 个单元格
|
||||
- 删除 2 行
|
||||
```
|
||||
|
||||
如果无差异:
|
||||
|
||||
```text
|
||||
两个版本内容一致,没有发现表格差异。
|
||||
```
|
||||
|
||||
## 8.5 差异详情
|
||||
|
||||
第一阶段优先支持 Excel 规则表:
|
||||
|
||||
| 工作表 | 位置 | 旧值 | 新值 | 类型 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 出差补助标准 | B4 | 75 | 90 | 修改 |
|
||||
| 差旅住宿费标准 | A106 | - | 新增城市 | 新增 |
|
||||
|
||||
后续可扩展:
|
||||
|
||||
- 仅看新增
|
||||
- 仅看删除
|
||||
- 仅看数值变化
|
||||
- 按工作表筛选
|
||||
|
||||
## 8.6 对比结果的业务语气
|
||||
|
||||
不要把页面做成“程序员 diff 工具”。
|
||||
它应该像制度审核页面:
|
||||
|
||||
- 先讲影响
|
||||
- 再讲位置
|
||||
- 最后给证据
|
||||
|
||||
---
|
||||
|
||||
## 9. 数据接口设计
|
||||
|
||||
## 9.1 时间线接口
|
||||
|
||||
建议新增:
|
||||
|
||||
```http
|
||||
GET /agent-assets/{asset_id}/version-timeline
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
- 版本号
|
||||
- 事件类型
|
||||
- 操作人
|
||||
- 操作时间
|
||||
- 审核意见
|
||||
- 来源版本(如有)
|
||||
|
||||
## 9.2 对比接口
|
||||
|
||||
建议新增:
|
||||
|
||||
```http
|
||||
GET /agent-assets/{asset_id}/versions/compare?base_version=v1.0.5&target_version=v1.0.6
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
- 基准版本
|
||||
- 对比版本
|
||||
- 工作表差异摘要
|
||||
- 单元格级差异明细
|
||||
|
||||
---
|
||||
|
||||
## 10. 视觉规范
|
||||
|
||||
### 10.1 颜色
|
||||
|
||||
沿用当前系统已有色彩,不引入新风格:
|
||||
|
||||
| 状态 | 建议色 |
|
||||
| --- | --- |
|
||||
| 已上线 | 绿色 |
|
||||
| 工作稿 | 蓝色 |
|
||||
| 待审核 | 橙色 |
|
||||
| 已驳回 | 红色 |
|
||||
| 历史版本 | 灰色 |
|
||||
|
||||
### 10.2 密度
|
||||
|
||||
- 右侧版本中心应为紧凑型信息面板
|
||||
- 不要使用过大的卡片间距
|
||||
- 不能明显压缩 OnlyOffice 主区域
|
||||
|
||||
### 10.3 交互反馈
|
||||
|
||||
- 可点击元素必须有 hover
|
||||
- 当前预览版本必须有 active 高亮
|
||||
- 抽屉打开后保留明确关闭按钮
|
||||
- 恢复操作必须二次确认
|
||||
|
||||
---
|
||||
|
||||
## 11. 推荐实施顺序
|
||||
|
||||
### 第一阶段
|
||||
|
||||
1. 顶部新增 `版本对比`、`查看流转`
|
||||
2. Excel 详情页改成:
|
||||
- 左侧 OnlyOffice
|
||||
- 右侧版本中心
|
||||
3. 右侧展示:
|
||||
- 线上版本
|
||||
- 工作版本
|
||||
- 最近 5 个版本
|
||||
- 最近 3 条流转
|
||||
|
||||
### 第二阶段
|
||||
|
||||
1. 实现版本流转抽屉
|
||||
2. 实现版本对比抽屉
|
||||
3. 补齐真实后端接口
|
||||
|
||||
### 第三阶段
|
||||
|
||||
1. 增加更细的工作表筛选
|
||||
2. 增加更多 diff 维度
|
||||
3. 增加版本差异导出能力
|
||||
|
||||
---
|
||||
|
||||
## 12. 本次开发目标
|
||||
|
||||
本次开发直接完成以下内容:
|
||||
|
||||
1. 规则详情页出现明确的版本中心
|
||||
2. 页面上出现明确的:
|
||||
- `版本对比`
|
||||
- `查看流转`
|
||||
3. 最近版本列表增加:
|
||||
- `查看`
|
||||
- `与线上比`
|
||||
- `恢复为工作稿`
|
||||
4. 版本流转抽屉可用
|
||||
5. 版本对比抽屉可用
|
||||
6. 对比结果至少支持 Excel 表格的:
|
||||
- 工作表新增 / 删除
|
||||
- 单元格新增 / 删除 / 修改
|
||||
|
||||
237
document/development/rules/rule-version-governance-plan.md
Normal file
237
document/development/rules/rule-version-governance-plan.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# 规则版本治理方案
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前“任务规则中心”的规则资产只有一个 `current_version` 指针。
|
||||
它同时承担了三种含义:
|
||||
|
||||
1. 财务人员正在编辑的版本
|
||||
2. 审核中的候选版本
|
||||
3. 系统运行时真正生效的线上版本
|
||||
|
||||
这会直接带来三个问题:
|
||||
|
||||
- 财务人员一旦修改 Excel,最新内容就会立刻成为 `current_version`,容易被误解为已经正式生效
|
||||
- 审核、上线、回滚都围绕同一个指针转,权限边界不清晰
|
||||
- 如果误上线,虽然能切换历史版本,但缺少“线上版本”和“工作版本”分离后的安全缓冲
|
||||
|
||||
## 2. 设计目标
|
||||
|
||||
这次改造要解决的不是“多存几个历史版本”,而是建立一套可长期使用的规则治理机制:
|
||||
|
||||
1. 财务人员可以编辑规则,但编辑结果默认只是草稿
|
||||
2. 只有高级管理人员审核通过后,规则才能成为正式线上版本
|
||||
3. 系统运行时只能读取正式线上版本,不能读取草稿
|
||||
4. 前台要能清楚区分:
|
||||
- 当前线上版本
|
||||
- 当前工作版本
|
||||
- 最近 5 个历史版本
|
||||
5. 如果误操作上线,可以快速恢复,但恢复动作仍然要留下完整审计链
|
||||
|
||||
## 3. 核心模型
|
||||
|
||||
### 3.1 双指针版本模型
|
||||
|
||||
在规则资产上新增两个版本指针:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `published_version` | 当前正式在线上生效的版本 |
|
||||
| `working_version` | 当前最新的工作稿 / 待审稿 |
|
||||
|
||||
兼容策略:
|
||||
|
||||
- 现有 `current_version` 暂时保留,用于兼容历史代码
|
||||
- 对规则资产来说,后续它只承担“当前工作版本”的兼容语义
|
||||
- 运行时逻辑不再读取 `current_version`,而是优先读取 `published_version`
|
||||
|
||||
### 3.2 版本状态
|
||||
|
||||
不额外在版本表中硬存一套容易失真的状态,而是根据“版本指针 + 最新审核记录”动态推导:
|
||||
|
||||
| 条件 | 版本状态 |
|
||||
| --- | --- |
|
||||
| `version == published_version` | 已上线 |
|
||||
| `version == working_version` 且无审核记录 | 草稿 |
|
||||
| `version == working_version` 且最新审核为 `pending` | 待审核 |
|
||||
| `version == working_version` 且最新审核为 `approved` | 已通过待上线 |
|
||||
| `version == working_version` 且最新审核为 `rejected` | 已驳回 |
|
||||
| 其他历史版本 | 历史版本 |
|
||||
|
||||
这样可以避免“版本状态”和“审核记录”两套数据互相打架。
|
||||
|
||||
## 4. 权限边界
|
||||
|
||||
| 角色 | 能力 |
|
||||
| --- | --- |
|
||||
| 财务人员 `finance` | 编辑工作稿、上传/导入 Excel、提交审核 |
|
||||
| 高级管理人员 `manager` / `admin` | 审核通过、驳回、正式发布、恢复历史版本 |
|
||||
| 其他普通员工 | 只读 |
|
||||
|
||||
### 4.1 财务人员
|
||||
|
||||
- 可以直接编辑当前 `working_version`
|
||||
- 每次保存自动生成新版本,并把它设为新的 `working_version`
|
||||
- 不能把草稿直接变成 `published_version`
|
||||
|
||||
### 4.2 高级管理人员
|
||||
|
||||
- 可以对 `working_version` 发起:
|
||||
- 审核通过
|
||||
- 驳回
|
||||
- 正式发布
|
||||
- 只有 `approved` 的工作版本才能发布
|
||||
|
||||
## 5. 发布与回滚流程
|
||||
|
||||
### 5.1 正常发布
|
||||
|
||||
1. 财务人员编辑并保存
|
||||
2. 系统生成新版本,例如 `v1.0.6`
|
||||
3. `working_version = v1.0.6`
|
||||
4. 财务人员提交审核
|
||||
5. 高级管理人员审核通过
|
||||
6. 高级管理人员点击“正式上线”
|
||||
7. `published_version = v1.0.6`
|
||||
8. 系统运行时切换到新版本
|
||||
|
||||
### 5.2 驳回
|
||||
|
||||
1. 财务人员提交审核
|
||||
2. 高级管理人员驳回
|
||||
3. 当前工作版本保留,但状态显示为“已驳回”
|
||||
4. 财务人员继续编辑,形成新的工作版本
|
||||
|
||||
### 5.3 恢复历史版本
|
||||
|
||||
不直接把 `published_version` 指回旧版本,而是采用“复制恢复”的方式:
|
||||
|
||||
1. 管理员在最近 5 个版本中选择一个历史版本
|
||||
2. 系统基于该历史版本内容生成一个新的恢复版本,例如 `v1.0.7`
|
||||
3. 新版本写入 `working_version`
|
||||
4. 审核通过后再正式发布
|
||||
|
||||
这么做的好处:
|
||||
|
||||
- 不会破坏历史链路
|
||||
- 每一次恢复都有明确的责任人与时间
|
||||
- 既能快速回滚,又保留审计闭环
|
||||
|
||||
## 6. 版本保留策略
|
||||
|
||||
### 6.1 前台展示
|
||||
|
||||
- 详情页固定展示最近 5 个版本
|
||||
- 每个版本显示:
|
||||
- 版本号
|
||||
- 状态
|
||||
- 创建人
|
||||
- 创建时间
|
||||
- 变更说明
|
||||
|
||||
### 6.2 后台保存
|
||||
|
||||
后台不能机械地“只保留 5 个版本”,否则可能把关键线上版本挤掉。
|
||||
建议策略:
|
||||
|
||||
1. 始终保留当前 `published_version`
|
||||
2. 始终保留当前 `working_version`
|
||||
3. 额外保留最近 5 个历史版本
|
||||
|
||||
这样既满足前台简洁,也能避免误删关键版本。
|
||||
|
||||
## 7. 前端交互
|
||||
|
||||
### 7.1 规则详情页顶部
|
||||
|
||||
展示两个醒目的版本标签:
|
||||
|
||||
- 当前线上版本
|
||||
- 当前工作版本
|
||||
|
||||
如果两者不同,需要明确提示:
|
||||
|
||||
> 当前存在尚未上线的工作稿,系统运行仍以线上版本为准。
|
||||
|
||||
### 7.2 编辑区
|
||||
|
||||
- 财务人员看到“可编辑工作稿”
|
||||
- 普通用户只读
|
||||
- 管理员可编辑,但主要职责仍是审核与发布
|
||||
|
||||
### 7.3 版本区
|
||||
|
||||
最近 5 个版本中每条都显示状态:
|
||||
|
||||
- 已上线
|
||||
- 草稿
|
||||
- 待审核
|
||||
- 已通过待上线
|
||||
- 已驳回
|
||||
- 历史版本
|
||||
|
||||
可执行操作:
|
||||
|
||||
- 查看
|
||||
- 基于该版本恢复
|
||||
- 对当前工作版本提交审核 / 审核 / 发布
|
||||
|
||||
## 8. 后端改造清单
|
||||
|
||||
1. `agent_assets`
|
||||
- 新增 `published_version`
|
||||
- 新增 `working_version`
|
||||
2. 兼容旧数据
|
||||
- 历史规则资产初始化时:
|
||||
- `published_version = current_version`
|
||||
- `working_version = current_version`
|
||||
3. 版本保存
|
||||
- 保存新版本后:
|
||||
- 只更新 `working_version`
|
||||
- `current_version` 同步为 `working_version` 以兼容旧逻辑
|
||||
4. 审核
|
||||
- 审核只针对 `working_version`
|
||||
5. 发布
|
||||
- 只允许把已审核通过的 `working_version` 推到 `published_version`
|
||||
6. 运行时
|
||||
- 只读取 `published_version`
|
||||
7. 回滚
|
||||
- 新增“基于历史版本恢复为新工作稿”的接口
|
||||
|
||||
## 9. 前端改造清单
|
||||
|
||||
1. 资产详情模型增加:
|
||||
- `publishedVersion`
|
||||
- `workingVersion`
|
||||
- 每个历史版本的派生状态
|
||||
2. 规则详情页展示:
|
||||
- 当前线上版本
|
||||
- 当前工作版本
|
||||
- 最近 5 个版本
|
||||
3. 操作权限拆分:
|
||||
- finance:编辑、上传、提交审核
|
||||
- manager/admin:审核、上线、恢复
|
||||
4. OnlyOffice 编辑逻辑:
|
||||
- 默认编辑工作版本
|
||||
- 历史版本只读
|
||||
5. 正式上线按钮:
|
||||
- 只有工作版本已审核通过时可用
|
||||
|
||||
## 10. 本次实现边界
|
||||
|
||||
本轮优先完成以下能力:
|
||||
|
||||
1. 规则版本双指针
|
||||
2. 财务角色可编辑工作稿
|
||||
3. 正式上线只切换 `published_version`
|
||||
4. 运行时只读取 `published_version`
|
||||
5. 最近 5 个版本展示
|
||||
6. 基于历史版本快速恢复为新工作稿
|
||||
|
||||
后续如需要,再继续补:
|
||||
|
||||
- 版本差异对比
|
||||
- 审核意见流转面板
|
||||
- 发布说明 / 审批单号
|
||||
- 定时生效
|
||||
|
||||
@@ -18,8 +18,9 @@ dependencies = [
|
||||
"pydantic-settings>=2.6.0,<3.0.0",
|
||||
"python-dotenv>=1.0.1,<2.0.0",
|
||||
"email-validator>=2.2.0,<3.0.0",
|
||||
"python-multipart>=0.0.20,<1.0.0",
|
||||
"lightrag-hku>=1.4.16,<1.5.0",
|
||||
"python-multipart>=0.0.20,<1.0.0",
|
||||
"openpyxl>=3.1.5,<4.0.0",
|
||||
"lightrag-hku>=1.4.16,<1.5.0",
|
||||
"qdrant-client>=1.18.0,<2.0.0",
|
||||
]
|
||||
|
||||
|
||||
BIN
server/rules/finance-rules/公司差旅费报销规则.xlsx
Normal file
BIN
server/rules/finance-rules/公司差旅费报销规则.xlsx
Normal file
Binary file not shown.
BIN
server/rules/finance-rules/远光软件2026费用报销说明手册.pdf
Normal file
BIN
server/rules/finance-rules/远光软件2026费用报销说明手册.pdf
Normal file
Binary file not shown.
259
server/scripts/build_company_travel_default_workbook.py
Normal file
259
server/scripts/build_company_travel_default_workbook.py
Normal file
@@ -0,0 +1,259 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
OUTPUT_PATH = Path(__file__).resolve().parents[1] / "rules" / "finance-rules" / "公司差旅费报销规则.xlsx"
|
||||
|
||||
ROWS = [
|
||||
(1, "北京", "北京", "", "", 500, 450, 450, 500, ""),
|
||||
(2, "天津", "6 个中心城区、滨海新区、东丽区、西青区、津南区、北辰区、武清区、宝坻区、静海区、蓟县", "", "", 380, 360, 350, 380, ""),
|
||||
(2, "天津", "宁河区", "", "", 320, 300, 280, 320, ""),
|
||||
(3, "河北", "石家庄", "", "", 350, 330, 300, 350, ""),
|
||||
(3, "河北", "张家口、秦皇岛、廊坊、承德、保定", "张家口市;秦皇岛市;承德市", "张家口市:7-9 月、11-3 月;秦皇岛市:7-8 月;承德市:7-9 月", 350, 300, 250, 350, 420),
|
||||
(3, "河北", "雄安新区(不含雄县、安新县、容城县)", "", "", 450, 400, 350, 450, ""),
|
||||
(3, "河北", "其他地区", "", "", 310, 290, 250, 310, ""),
|
||||
(4, "山西", "太原", "", "", 350, 330, 300, 350, ""),
|
||||
(4, "山西", "大同、晋城", "", "", 350, 300, 250, 350, ""),
|
||||
(4, "山西", "临汾", "", "", 330, 300, 250, 330, ""),
|
||||
(4, "山西", "阳泉、长治、晋中", "", "", 310, 290, 250, 310, ""),
|
||||
(4, "山西", "其他地区", "", "", 240, 220, 200, 240, ""),
|
||||
(5, "内蒙古", "呼和浩特", "", "", 350, 330, 300, 350, ""),
|
||||
(5, "内蒙古", "海拉尔市、满洲里市、阿尔山市", "海拉尔市、满洲里市、阿尔山市", "7-9 月", 320, 300, 250, 320, 380),
|
||||
(5, "内蒙古", "二连浩特市", "二连浩特市", "7-9 月", 320, 300, 250, 320, 380),
|
||||
(5, "内蒙古", "额济纳市", "额济纳市", "9-10 月", 320, 300, 250, 320, 380),
|
||||
(5, "内蒙古", "其他地区", "", "", 320, 300, 250, 320, ""),
|
||||
(6, "辽宁", "沈阳", "", "", 350, 330, 300, 350, ""),
|
||||
(6, "辽宁", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||
(7, "大连", "大连", "大连", "7-9 月", 350, 300, 300, 350, 420),
|
||||
(8, "吉林", "长春", "长春", "7-9 月", 350, 330, 300, 350, 420),
|
||||
(8, "吉林", "吉林、延边州、长白山管理区", "吉林、延边州、长白山管理区", "7-9 月", 350, 300, 250, 350, 420),
|
||||
(8, "吉林", "其他地区", "", "", 300, 280, 250, 300, ""),
|
||||
(9, "黑龙江", "哈尔滨", "哈尔滨", "7-9 月", 350, 330, 300, 350, 420),
|
||||
(9, "黑龙江", "牡丹江、伊春、大兴安岭地区、黑河、佳木斯", "牡丹江、伊春、大兴安岭地区、黑河、佳木斯", "6-8 月", 300, 280, 250, 300, 360),
|
||||
(9, "黑龙江", "其他地区", "", "", 300, 280, 250, 300, ""),
|
||||
(10, "上海", "上海", "", "", 500, 450, 450, 500, ""),
|
||||
(11, "江苏", "南京", "", "", 380, 350, 350, 380, ""),
|
||||
(11, "江苏", "苏州、无锡、常州、镇江", "", "", 350, 300, 250, 380, ""),
|
||||
(11, "江苏", "其他地区", "", "", 350, 300, 250, 360, ""),
|
||||
(12, "浙江", "杭州", "", "", 400, 350, 350, 400, ""),
|
||||
(12, "浙江", "其他地区", "", "", 340, 300, 250, 340, ""),
|
||||
(13, "宁波", "宁波", "", "", 350, 300, 250, 350, ""),
|
||||
(14, "安徽", "合肥", "", "", 350, 330, 300, 350, ""),
|
||||
(14, "安徽", "其他地区", "", "", 350, 300, 250, 350, ""),
|
||||
(15, "福建", "福州", "", "", 380, 350, 300, 380, ""),
|
||||
(15, "福建", "泉州、平潭综合实验区", "", "", 350, 300, 250, 380, ""),
|
||||
(15, "福建", "其他地区", "", "", 350, 300, 250, 350, ""),
|
||||
(16, "厦门", "厦门", "", "", 400, 380, 350, 400, ""),
|
||||
(17, "江西", "南昌", "", "", 350, 330, 300, 350, ""),
|
||||
(17, "江西", "其他地区", "", "", 350, 300, 250, 350, ""),
|
||||
(18, "山东", "济南", "", "", 380, 350, 300, 380, ""),
|
||||
(18, "山东", "烟台、威海、日照", "烟台、威海、日照", "7-9 月", 350, 300, 250, 380, 450),
|
||||
(18, "山东", "淄博、枣庄、东营、潍坊、济宁、泰安", "", "", 350, 300, 250, 380, ""),
|
||||
(18, "山东", "其他地区", "", "", 350, 300, 250, 360, ""),
|
||||
(19, "青岛", "青岛", "青岛", "7-9 月", 350, 300, 250, 380, 450),
|
||||
(20, "河南", "郑州", "", "", 380, 350, 300, 380, ""),
|
||||
(20, "河南", "洛阳", "洛阳", "4-5 月上旬", 330, 300, 250, 330, 390),
|
||||
(20, "河南", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||
(21, "湖北", "武汉", "", "", 350, 330, 300, 350, ""),
|
||||
(21, "湖北", "其他地区", "", "", 320, 300, 250, 320, ""),
|
||||
(22, "湖南", "长沙", "", "", 350, 330, 300, 350, ""),
|
||||
(22, "湖南", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||
(23, "广东", "广州", "", "", 450, 400, 400, 450, ""),
|
||||
(23, "广东", "珠海", "", "", 450, 400, 350, 450, ""),
|
||||
(23, "广东", "佛山、东莞、中山、江门", "", "", 350, 300, 250, 450, ""),
|
||||
(23, "广东", "其他地区", "", "", 350, 300, 250, 420, ""),
|
||||
(24, "深圳", "深圳", "", "", 450, 400, 400, 450, ""),
|
||||
(25, "广西", "南宁", "", "", 350, 330, 300, 350, ""),
|
||||
(25, "广西", "桂林、北海", "桂林、北海", "1-2 月、7-9 月", 330, 300, 250, 330, 390),
|
||||
(25, "广西", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||
(26, "海南", "海口、文昌、澄迈县", "海口、文昌、澄迈县", "11-2 月", 350, 330, 310, 350, 420),
|
||||
(26, "海南", "琼海、万宁、陵水县、保亭县", "琼海、万宁、陵水县、保亭县", "11-3 月", 350, 330, 310, 350, 420),
|
||||
(26, "海南", "三沙、儋州、五指山、东方、安定县、屯昌县、临高县、白沙县、昌江县、乐东县、琼中县、洋浦开发区", "", "", 350, 330, 310, 350, ""),
|
||||
(26, "海南", "三亚", "三亚", "10-4 月", 400, 380, 350, 400, 480),
|
||||
(27, "重庆", "9 个中心城区、北部新区", "", "", 370, 350, 330, 370, ""),
|
||||
(27, "重庆", "其他地区", "", "", 300, 280, 260, 300, ""),
|
||||
(28, "四川", "成都", "", "", 370, 350, 330, 370, ""),
|
||||
(28, "四川", "阿坝州、甘孜州", "", "", 330, 300, 250, 330, ""),
|
||||
(28, "四川", "绵阳、乐山、雅安", "", "", 320, 300, 250, 320, ""),
|
||||
(28, "四川", "宜宾", "", "", 300, 280, 250, 300, ""),
|
||||
(28, "四川", "凉山州", "", "", 330, 300, 250, 330, ""),
|
||||
(28, "四川", "德阳、遂宁、巴中", "", "", 310, 290, 250, 310, ""),
|
||||
(28, "四川", "其他地区", "", "", 300, 280, 250, 300, ""),
|
||||
(29, "贵州", "贵阳", "", "", 370, 350, 300, 370, ""),
|
||||
(29, "贵州", "其他地区", "", "", 300, 280, 250, 300, ""),
|
||||
(30, "云南", "昆明", "", "", 380, 350, 300, 380, ""),
|
||||
(30, "云南", "大理州、丽江市、迪庆州、西双版纳州", "", "", 350, 300, 250, 380, ""),
|
||||
(30, "云南", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||
(31, "西藏", "拉萨", "拉萨", "5-10 月", 350, 330, 300, 350, 420),
|
||||
(31, "西藏", "其他地区", "其他地区", "5-10 月", 300, 280, 250, 300, 360),
|
||||
(32, "陕西", "西安", "", "", 350, 330, 300, 350, ""),
|
||||
(32, "陕西", "榆林、延安", "", "", 300, 280, 250, 300, ""),
|
||||
(32, "陕西", "杨凌区", "", "", 260, 240, 220, 260, ""),
|
||||
(32, "陕西", "咸阳、宝鸡", "", "", 260, 240, 220, 260, ""),
|
||||
(32, "陕西", "渭南、韩城", "", "", 260, 240, 220, 260, ""),
|
||||
(32, "陕西", "其他地区", "", "", 230, 210, 200, 230, ""),
|
||||
(33, "甘肃", "兰州", "", "", 350, 330, 300, 350, ""),
|
||||
(33, "甘肃", "其他地区", "", "", 310, 290, 250, 310, ""),
|
||||
(34, "青海", "西宁", "西宁", "6-9 月", 350, 330, 300, 350, 420),
|
||||
(34, "青海", "玉树州", "玉树州", "5-9 月", 300, 280, 250, 300, 360),
|
||||
(34, "青海", "果洛州", "", "", 300, 280, 250, 300, ""),
|
||||
(34, "青海", "海北州、黄南州", "海北州、黄南州", "5-9 月", 250, 230, 210, 250, 300),
|
||||
(34, "青海", "海东、海南州", "海东、海南州", "5-9 月", 250, 230, 210, 250, 300),
|
||||
(34, "青海", "海西州", "海西州", "5-9 月", 200, 200, 200, 200, 240),
|
||||
(35, "宁夏", "银川", "", "", 350, 330, 300, 350, ""),
|
||||
(35, "宁夏", "其他地区", "", "", 330, 300, 250, 330, ""),
|
||||
(36, "新疆", "乌鲁木齐", "", "", 350, 330, 300, 350, ""),
|
||||
(36, "新疆", "石河子、克拉玛依、昌吉州、伊犁州、阿勒泰地区、博州、吐鲁番、哈密地区、巴州、和田地区", "", "", 340, 300, 250, 340, ""),
|
||||
(36, "新疆", "克州", "", "", 320, 300, 250, 320, ""),
|
||||
(36, "新疆", "喀什地区", "", "", 300, 280, 250, 300, ""),
|
||||
(36, "新疆", "阿克苏地区", "", "", 300, 280, 250, 300, ""),
|
||||
(36, "新疆", "塔城地区", "", "", 300, 280, 250, 300, ""),
|
||||
(37, "港澳台", "香港、澳门、台湾", "", "", 450, 400, 350, 500, ""),
|
||||
(38, "国外", "国外", "", "", 700, 600, 500, 700, ""),
|
||||
]
|
||||
|
||||
|
||||
def build_workbook() -> Workbook:
|
||||
workbook = Workbook()
|
||||
worksheet = workbook.active
|
||||
worksheet.title = "差旅住宿费标准"
|
||||
headers = [
|
||||
"序号",
|
||||
"地区",
|
||||
"地区(城市)",
|
||||
"旺季地区",
|
||||
"旺季期间",
|
||||
"公司级管理人员、高层经理(P7及以上)",
|
||||
"中层经理、基层经理(P4-P6、外聘专家)",
|
||||
"其他员工",
|
||||
"超标限额",
|
||||
"旺季超标限额",
|
||||
]
|
||||
|
||||
worksheet.append(["差旅住宿费标准"])
|
||||
worksheet.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(headers))
|
||||
worksheet["A1"].font = Font(bold=True, size=16, color="FFFFFF")
|
||||
worksheet["A1"].fill = PatternFill("solid", fgColor="1F4E78")
|
||||
worksheet["A1"].alignment = Alignment(horizontal="center")
|
||||
worksheet.append(headers)
|
||||
for row in ROWS:
|
||||
worksheet.append(row)
|
||||
|
||||
header_fill = PatternFill("solid", fgColor="D9EAF7")
|
||||
thin = Side(style="thin", color="B7C9D6")
|
||||
for cell in worksheet[2]:
|
||||
cell.font = Font(bold=True)
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
for row in worksheet.iter_rows(min_row=3, max_row=worksheet.max_row):
|
||||
for cell in row:
|
||||
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
||||
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
for cell in row[5:]:
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
worksheet.freeze_panes = "A3"
|
||||
worksheet.auto_filter.ref = f"A2:J{worksheet.max_row}"
|
||||
widths = [8, 12, 42, 28, 28, 22, 26, 12, 12, 14]
|
||||
for index, width in enumerate(widths, start=1):
|
||||
worksheet.column_dimensions[get_column_letter(index)].width = width
|
||||
worksheet.row_dimensions[1].height = 26
|
||||
worksheet.row_dimensions[2].height = 42
|
||||
for index in range(3, worksheet.max_row + 1):
|
||||
worksheet.row_dimensions[index].height = 36
|
||||
|
||||
subsidy_sheet = workbook.create_sheet("出差补助标准")
|
||||
subsidy_headers = [
|
||||
"补助类型",
|
||||
"项目",
|
||||
"港澳台",
|
||||
"直辖市/特区",
|
||||
"西藏",
|
||||
"新疆-乌鲁木齐",
|
||||
"新疆-其他",
|
||||
"其他地区",
|
||||
"国外",
|
||||
]
|
||||
subsidy_rows = [
|
||||
("伙食补助", "自行解决餐食", 75, 65, 65, 55, 55, 55, 140),
|
||||
("基本补助", "基本出差补贴", 35, 35, 105, 75, 135, 35, 35),
|
||||
("合计", "", 110, 100, 170, 130, 190, 90, 175),
|
||||
]
|
||||
subsidy_sheet.append(["出差补助标准"])
|
||||
subsidy_sheet.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(subsidy_headers))
|
||||
subsidy_sheet["A1"].font = Font(bold=True, size=16, color="FFFFFF")
|
||||
subsidy_sheet["A1"].fill = PatternFill("solid", fgColor="1F4E78")
|
||||
subsidy_sheet["A1"].alignment = Alignment(horizontal="center")
|
||||
subsidy_sheet.append(subsidy_headers)
|
||||
for row in subsidy_rows:
|
||||
subsidy_sheet.append(row)
|
||||
subsidy_sheet.append(["备注", "注 1:新疆分公司同事出差至乌鲁木齐外的其他新疆地区,基本补助标准为 95 元。"])
|
||||
subsidy_sheet.append(["备注", "注 2:西藏分公司同事出差至拉萨市外的其他西藏地区,基本补助标准为 35 元。"])
|
||||
subsidy_sheet.merge_cells(start_row=6, start_column=2, end_row=6, end_column=len(subsidy_headers))
|
||||
subsidy_sheet.merge_cells(start_row=7, start_column=2, end_row=7, end_column=len(subsidy_headers))
|
||||
for cell in subsidy_sheet[2]:
|
||||
cell.font = Font(bold=True)
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
for row in subsidy_sheet.iter_rows(min_row=3, max_row=7):
|
||||
for cell in row:
|
||||
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
||||
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
for cell in subsidy_sheet[5]:
|
||||
cell.font = Font(bold=True)
|
||||
cell.fill = PatternFill("solid", fgColor="E2F0D9")
|
||||
subsidy_sheet.freeze_panes = "A3"
|
||||
subsidy_sheet.auto_filter.ref = "A2:I5"
|
||||
subsidy_widths = [14, 18, 12, 16, 12, 18, 16, 14, 12]
|
||||
for index, width in enumerate(subsidy_widths, start=1):
|
||||
subsidy_sheet.column_dimensions[get_column_letter(index)].width = width
|
||||
subsidy_sheet.row_dimensions[1].height = 26
|
||||
subsidy_sheet.row_dimensions[2].height = 36
|
||||
for index in range(3, 8):
|
||||
subsidy_sheet.row_dimensions[index].height = 28
|
||||
|
||||
source_sheet = workbook.create_sheet("来源说明")
|
||||
source_sheet.append(["来源文件", "页码", "说明"])
|
||||
source_sheet.append(
|
||||
[
|
||||
"远光软件2026费用报销说明手册.pdf",
|
||||
"第 13-19 页",
|
||||
"依据 PDF 附件 3《差旅住宿费标准》整理为默认支撑表。",
|
||||
]
|
||||
)
|
||||
source_sheet.append(
|
||||
[
|
||||
"远光软件2026费用报销说明手册.pdf",
|
||||
"第 20 页",
|
||||
"依据 PDF 附件 4《出差补助标准》整理为默认支撑表。",
|
||||
]
|
||||
)
|
||||
for row in source_sheet.iter_rows():
|
||||
for cell in row:
|
||||
cell.alignment = Alignment(wrap_text=True, vertical="center")
|
||||
cell.border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
for cell in source_sheet[1]:
|
||||
cell.font = Font(bold=True)
|
||||
cell.fill = header_fill
|
||||
source_sheet.column_dimensions["A"].width = 34
|
||||
source_sheet.column_dimensions["B"].width = 14
|
||||
source_sheet.column_dimensions["C"].width = 56
|
||||
return workbook
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
workbook = build_workbook()
|
||||
workbook.save(OUTPUT_PATH)
|
||||
print(OUTPUT_PATH)
|
||||
print(f"rows={len(ROWS)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -240,7 +240,7 @@ run_bootstrap_python() {
|
||||
}
|
||||
|
||||
dependencies_ready() {
|
||||
"$PYTHON_BIN" -c "import alembic, dotenv, email_validator, fastapi, jwt, lightrag, multipart, psycopg, pydantic_settings, qdrant_client, sqlalchemy, uvicorn" >/dev/null 2>&1
|
||||
"$PYTHON_BIN" -c "import alembic, dotenv, email_validator, fastapi, jwt, lightrag, multipart, openpyxl, psycopg, pydantic_settings, qdrant_client, sqlalchemy, uvicorn" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
pip_ready() {
|
||||
|
||||
@@ -62,13 +62,39 @@ def get_current_user(
|
||||
)
|
||||
|
||||
|
||||
def require_admin_user(
|
||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
) -> CurrentUserContext:
|
||||
def require_admin_user(
|
||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
) -> CurrentUserContext:
|
||||
if current_user.is_admin or "manager" in current_user.role_codes:
|
||||
return current_user
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有管理员可以上传、删除或修改知识库文件。",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有管理员可以上传、删除或修改知识库文件。",
|
||||
)
|
||||
|
||||
|
||||
def require_rule_editor_user(
|
||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
) -> CurrentUserContext:
|
||||
role_codes = {item.strip() for item in current_user.role_codes}
|
||||
if current_user.is_admin or "manager" in role_codes or "finance" in role_codes:
|
||||
return current_user
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有财务人员或高级管理人员可以编辑规则草稿。",
|
||||
)
|
||||
|
||||
|
||||
def require_rule_reviewer_user(
|
||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||
) -> CurrentUserContext:
|
||||
role_codes = {item.strip() for item in current_user.role_codes}
|
||||
if current_user.is_admin or "manager" in role_codes:
|
||||
return current_user
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有高级管理人员可以审核、发布或恢复正式规则。",
|
||||
)
|
||||
|
||||
@@ -2,19 +2,32 @@ from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.api.deps import (
|
||||
CurrentUserContext,
|
||||
get_current_user,
|
||||
get_db,
|
||||
require_admin_user,
|
||||
require_rule_editor_user,
|
||||
require_rule_reviewer_user,
|
||||
)
|
||||
from app.schemas.agent_asset import (
|
||||
AgentAssetCreate,
|
||||
AgentAssetListItem,
|
||||
AgentAssetOnlyOfficeCallbackRead,
|
||||
AgentAssetOnlyOfficeCallbackWrite,
|
||||
AgentAssetOnlyOfficeConfigRead,
|
||||
AgentAssetRead,
|
||||
AgentAssetReviewCreate,
|
||||
AgentAssetReviewRead,
|
||||
AgentAssetVersionCompareRead,
|
||||
AgentAssetUpdate,
|
||||
AgentAssetVersionCreate,
|
||||
AgentAssetVersionRead,
|
||||
AgentAssetVersionTimelineItemRead,
|
||||
)
|
||||
from app.schemas.common import ErrorResponse
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
@@ -29,6 +42,10 @@ RequestIdHeader = Annotated[
|
||||
str | None,
|
||||
Header(description="外部请求 ID,用于串联审计日志和上游调用链。"),
|
||||
]
|
||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||
AdminUser = Annotated[CurrentUserContext, Depends(require_admin_user)]
|
||||
RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)]
|
||||
RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)]
|
||||
|
||||
|
||||
def _handle_asset_error(exc: Exception) -> None:
|
||||
@@ -93,6 +110,185 @@ def get_agent_asset(asset_id: str, db: DbSession) -> AgentAssetRead:
|
||||
return asset
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{asset_id}/spreadsheet/onlyoffice-config",
|
||||
response_model=AgentAssetOnlyOfficeConfigRead,
|
||||
summary="读取规则 Excel 的 ONLYOFFICE 配置",
|
||||
description="为规则详情页中的 Excel 规则表生成 ONLYOFFICE 配置。",
|
||||
)
|
||||
def get_agent_asset_spreadsheet_onlyoffice_config(
|
||||
asset_id: str,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
version: Annotated[
|
||||
str | None,
|
||||
Query(description="可选的规则版本号;不传时默认当前版本。"),
|
||||
] = None,
|
||||
) -> AgentAssetOnlyOfficeConfigRead:
|
||||
try:
|
||||
return AgentAssetService(db).build_rule_spreadsheet_onlyoffice_config(
|
||||
asset_id,
|
||||
current_user,
|
||||
version=version,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{asset_id}/spreadsheet/content",
|
||||
response_class=FileResponse,
|
||||
summary="下载或预览规则 Excel 文件",
|
||||
description="按版本返回规则的 Excel 快照,用于浏览器预览或下载。",
|
||||
)
|
||||
def get_agent_asset_spreadsheet_content(
|
||||
asset_id: str,
|
||||
_: CurrentUser,
|
||||
db: DbSession,
|
||||
version: Annotated[
|
||||
str | None,
|
||||
Query(description="可选的规则版本号;不传时默认当前版本。"),
|
||||
] = None,
|
||||
) -> FileResponse:
|
||||
try:
|
||||
file_path, media_type, filename = AgentAssetService(db).get_rule_spreadsheet_content(
|
||||
asset_id,
|
||||
version=version,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
return FileResponse(file_path, media_type=media_type, filename=filename)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{asset_id}/spreadsheet/onlyoffice/content",
|
||||
response_class=FileResponse,
|
||||
summary="供 ONLYOFFICE 读取规则 Excel 源文件",
|
||||
description="使用短时令牌供 ONLYOFFICE 拉取规则表源文件。",
|
||||
)
|
||||
def get_agent_asset_spreadsheet_onlyoffice_content(
|
||||
asset_id: str,
|
||||
db: DbSession,
|
||||
version: Annotated[
|
||||
str,
|
||||
Query(min_length=1, description="规则版本号。"),
|
||||
],
|
||||
access_token: Annotated[
|
||||
str,
|
||||
Query(min_length=1, description="ONLYOFFICE 临时访问令牌。"),
|
||||
],
|
||||
) -> FileResponse:
|
||||
try:
|
||||
service = AgentAssetService(db)
|
||||
service.validate_rule_spreadsheet_access_token(asset_id, version, access_token)
|
||||
file_path, media_type, filename = service.get_rule_spreadsheet_content(
|
||||
asset_id,
|
||||
version=version,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
return FileResponse(file_path, media_type=media_type, filename=filename)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{asset_id}/spreadsheet/upload",
|
||||
response_model=AgentAssetRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="上传规则 Excel 文件",
|
||||
description="为指定规则上传新的 Excel 快照,并自动生成新规则版本。",
|
||||
)
|
||||
def upload_agent_asset_spreadsheet(
|
||||
asset_id: str,
|
||||
content: Annotated[
|
||||
bytes,
|
||||
Body(
|
||||
media_type="application/octet-stream",
|
||||
description="待上传的 Excel 文件二进制内容。",
|
||||
),
|
||||
],
|
||||
filename: Annotated[str, Query(min_length=1, description="原始文件名。")],
|
||||
current_user: RuleEditorUser,
|
||||
db: DbSession,
|
||||
x_request_id: RequestIdHeader = None,
|
||||
) -> AgentAssetRead:
|
||||
try:
|
||||
return AgentAssetService(db).upload_rule_spreadsheet(
|
||||
asset_id,
|
||||
filename=filename,
|
||||
content=content,
|
||||
actor=current_user.name,
|
||||
request_id=x_request_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{asset_id}/spreadsheet/import-content",
|
||||
response_model=AgentAssetRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="导入规则 Excel 表格内容",
|
||||
description="读取上传 Excel 中的工作表内容,写回当前规则表;保留当前规则文件名与规则身份。",
|
||||
)
|
||||
def import_agent_asset_spreadsheet_content(
|
||||
asset_id: str,
|
||||
content: Annotated[
|
||||
bytes,
|
||||
Body(
|
||||
media_type="application/octet-stream",
|
||||
description="待导入的 Excel 文件二进制内容。",
|
||||
),
|
||||
],
|
||||
filename: Annotated[str, Query(min_length=1, description="上传文件原始文件名。")],
|
||||
current_user: RuleEditorUser,
|
||||
db: DbSession,
|
||||
x_request_id: RequestIdHeader = None,
|
||||
) -> AgentAssetRead:
|
||||
try:
|
||||
return AgentAssetService(db).import_rule_spreadsheet_content(
|
||||
asset_id,
|
||||
filename=filename,
|
||||
content=content,
|
||||
actor=current_user.name,
|
||||
request_id=x_request_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{asset_id}/spreadsheet/onlyoffice/callback",
|
||||
response_model=AgentAssetOnlyOfficeCallbackRead,
|
||||
summary="接收规则 Excel 的 ONLYOFFICE 回调",
|
||||
description="接收 ONLYOFFICE 回写内容,并自动生成新的规则版本。",
|
||||
)
|
||||
def handle_agent_asset_spreadsheet_onlyoffice_callback(
|
||||
asset_id: str,
|
||||
payload: AgentAssetOnlyOfficeCallbackWrite,
|
||||
db: DbSession,
|
||||
version: Annotated[
|
||||
str,
|
||||
Query(min_length=1, description="打开编辑器时对应的规则版本号。"),
|
||||
],
|
||||
) -> AgentAssetOnlyOfficeCallbackRead:
|
||||
try:
|
||||
AgentAssetService(db).handle_rule_spreadsheet_onlyoffice_callback(
|
||||
asset_id,
|
||||
version=version,
|
||||
payload=payload.model_dump(),
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
return AgentAssetOnlyOfficeCallbackRead()
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=AgentAssetRead,
|
||||
@@ -237,11 +433,22 @@ def create_agent_asset_version(
|
||||
def create_agent_asset_review(
|
||||
asset_id: str,
|
||||
payload: AgentAssetReviewCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbSession,
|
||||
x_actor: ActorHeader = None,
|
||||
x_request_id: RequestIdHeader = None,
|
||||
) -> AgentAssetReviewRead:
|
||||
try:
|
||||
role_codes = {item.strip() for item in current_user.role_codes}
|
||||
if payload.review_status.value == "pending":
|
||||
if not (
|
||||
current_user.is_admin
|
||||
or "manager" in role_codes
|
||||
or "finance" in role_codes
|
||||
):
|
||||
raise PermissionError("只有财务人员或高级管理人员可以提交审核。")
|
||||
elif not (current_user.is_admin or "manager" in role_codes):
|
||||
raise PermissionError("只有高级管理人员可以审核规则。")
|
||||
return AgentAssetService(db).create_review(
|
||||
asset_id,
|
||||
payload,
|
||||
@@ -270,6 +477,7 @@ def create_agent_asset_review(
|
||||
)
|
||||
def activate_agent_asset(
|
||||
asset_id: str,
|
||||
_: RuleReviewerUser,
|
||||
db: DbSession,
|
||||
x_actor: ActorHeader = None,
|
||||
x_request_id: RequestIdHeader = None,
|
||||
@@ -282,3 +490,68 @@ def activate_agent_asset(
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{asset_id}/versions/{version}/restore",
|
||||
response_model=AgentAssetRead,
|
||||
summary="基于历史版本恢复工作稿",
|
||||
description="复制指定历史版本内容生成新的工作版本,用于误上线后的快速恢复与重新审核。",
|
||||
)
|
||||
def restore_agent_asset_version(
|
||||
asset_id: str,
|
||||
version: str,
|
||||
current_user: RuleReviewerUser,
|
||||
db: DbSession,
|
||||
x_actor: ActorHeader = None,
|
||||
x_request_id: RequestIdHeader = None,
|
||||
) -> AgentAssetRead:
|
||||
try:
|
||||
return AgentAssetService(db).restore_version_as_working_copy(
|
||||
asset_id,
|
||||
version,
|
||||
actor=(x_actor or current_user.name or "system").strip() or "system",
|
||||
request_id=x_request_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{asset_id}/version-timeline",
|
||||
response_model=list[AgentAssetVersionTimelineItemRead],
|
||||
summary="读取规则版本流转时间线",
|
||||
description="返回规则版本创建、提交审核、审核结果和正式上线等流转事件。",
|
||||
)
|
||||
def get_agent_asset_version_timeline(
|
||||
asset_id: str,
|
||||
_: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> list[AgentAssetVersionTimelineItemRead]:
|
||||
try:
|
||||
return AgentAssetService(db).list_version_timeline(asset_id)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{asset_id}/versions/compare",
|
||||
response_model=AgentAssetVersionCompareRead,
|
||||
summary="比较两个规则表版本",
|
||||
description="对比两个 Excel 规则表版本的工作表变化与单元格级差异。",
|
||||
)
|
||||
def compare_agent_asset_spreadsheet_versions(
|
||||
asset_id: str,
|
||||
_: CurrentUser,
|
||||
db: DbSession,
|
||||
base_version: Annotated[str, Query(min_length=1, description="基准版本号")],
|
||||
target_version: Annotated[str, Query(min_length=1, description="对比版本号")],
|
||||
) -> AgentAssetVersionCompareRead:
|
||||
try:
|
||||
return AgentAssetService(db).compare_spreadsheet_versions(
|
||||
asset_id,
|
||||
base_version=base_version,
|
||||
target_version=target_version,
|
||||
)
|
||||
except Exception as exc:
|
||||
_handle_asset_error(exc)
|
||||
|
||||
@@ -25,6 +25,8 @@ class AgentAsset(Base):
|
||||
reviewer: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), index=True, default="draft")
|
||||
current_version: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
published_version: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
working_version: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
config_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -50,6 +50,16 @@ class AgentRunRepository:
|
||||
self.db.refresh(tool_call)
|
||||
return tool_call
|
||||
|
||||
def get_tool_call(self, tool_call_id: str) -> AgentToolCall | None:
|
||||
stmt = select(AgentToolCall).where(AgentToolCall.id == tool_call_id)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def save_tool_call(self, tool_call: AgentToolCall) -> AgentToolCall:
|
||||
self.db.add(tool_call)
|
||||
self.db.commit()
|
||||
self.db.refresh(tool_call)
|
||||
return tool_call
|
||||
|
||||
def create_semantic_parse(self, semantic_parse: SemanticParseLog) -> SemanticParseLog:
|
||||
self.db.add(semantic_parse)
|
||||
self.db.commit()
|
||||
|
||||
@@ -36,6 +36,8 @@ class AgentAssetUpdate(BaseModel):
|
||||
reviewer: str | None = Field(default=None, max_length=100)
|
||||
status: AgentAssetStatus | None = None
|
||||
current_version: str | None = Field(default=None, max_length=30)
|
||||
published_version: str | None = Field(default=None, max_length=30)
|
||||
working_version: str | None = Field(default=None, max_length=30)
|
||||
config_json: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@@ -74,6 +76,58 @@ class AgentAssetReviewRead(BaseModel):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AgentAssetOnlyOfficeConfigRead(BaseModel):
|
||||
documentServerUrl: str
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentAssetOnlyOfficeCallbackRead(BaseModel):
|
||||
error: int = 0
|
||||
|
||||
|
||||
class AgentAssetOnlyOfficeCallbackWrite(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
status: int = Field(description="ONLYOFFICE 回调状态码。")
|
||||
url: str | None = Field(default=None, description="文档下载地址,状态为 2 或 6 时使用。")
|
||||
users: list[str] = Field(default_factory=list, description="当前编辑用户列表。")
|
||||
|
||||
|
||||
class AgentAssetVersionTimelineItemRead(BaseModel):
|
||||
event_type: str
|
||||
version: str
|
||||
actor: str
|
||||
event_time: datetime
|
||||
title: str
|
||||
description: str = ""
|
||||
note: str | None = None
|
||||
source_version: str | None = None
|
||||
|
||||
|
||||
class AgentAssetSpreadsheetDiffCellRead(BaseModel):
|
||||
sheet_name: str
|
||||
cell: str
|
||||
change_type: str
|
||||
before_value: Any | None = None
|
||||
after_value: Any | None = None
|
||||
|
||||
|
||||
class AgentAssetSpreadsheetDiffSheetRead(BaseModel):
|
||||
sheet_name: str
|
||||
change_type: str
|
||||
|
||||
|
||||
class AgentAssetVersionCompareRead(BaseModel):
|
||||
base_version: str
|
||||
target_version: str
|
||||
added_sheet_count: int = 0
|
||||
removed_sheet_count: int = 0
|
||||
changed_sheet_count: int = 0
|
||||
changed_cell_count: int = 0
|
||||
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = Field(default_factory=list)
|
||||
cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentAssetVersionRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -86,6 +140,9 @@ class AgentAssetVersionRead(BaseModel):
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
is_current: bool = False
|
||||
is_published: bool = False
|
||||
is_working: bool = False
|
||||
lifecycle_state: str = "history"
|
||||
|
||||
|
||||
class AgentAssetListItem(BaseModel):
|
||||
@@ -102,6 +159,8 @@ class AgentAssetListItem(BaseModel):
|
||||
reviewer: str | None
|
||||
status: str
|
||||
current_version: str | None
|
||||
published_version: str | None
|
||||
working_version: str | None
|
||||
config_json: dict[str, Any]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
478
server/src/app/services/agent_asset_spreadsheet.py
Normal file
478
server/src/app/services/agent_asset_spreadsheet.py
Normal file
@@ -0,0 +1,478 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from xml.sax.saxutils import escape
|
||||
from zipfile import ZIP_DEFLATED, ZipFile
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from app.core.config import SERVER_DIR, get_settings
|
||||
|
||||
RULE_SPREADSHEET_BLOCK_PATTERN = re.compile(
|
||||
r"```rule-spreadsheet\s*(\{.*?\})\s*```",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE = "rule.expense.company_travel_expense_reimbursement"
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME = "公司差旅费报销规则.xlsx"
|
||||
FINANCE_RULES_LIBRARY = "finance-rules"
|
||||
RISK_RULES_LIBRARY = "risk-rules"
|
||||
RULE_LIBRARY_NAMES = {FINANCE_RULES_LIBRARY, RISK_RULES_LIBRARY}
|
||||
SPREADSHEET_MIME_TYPE = (
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RuleSpreadsheetMeta:
|
||||
file_name: str
|
||||
storage_key: str
|
||||
mime_type: str
|
||||
size_bytes: int
|
||||
checksum: str
|
||||
updated_at: str
|
||||
updated_by: str
|
||||
source: str = "upload"
|
||||
|
||||
|
||||
class AgentAssetSpreadsheetManager:
|
||||
def __init__(
|
||||
self,
|
||||
storage_root: Path | None = None,
|
||||
rule_root: Path | None = None,
|
||||
) -> None:
|
||||
settings = get_settings()
|
||||
self.storage_root = Path(storage_root or settings.resolved_storage_root_dir).resolve()
|
||||
self.asset_root = (self.storage_root / "agent_assets").resolve()
|
||||
self.rule_root = Path(rule_root or (SERVER_DIR / "rules")).resolve()
|
||||
|
||||
def ensure_rule_library_dirs(self) -> None:
|
||||
for library in sorted(RULE_LIBRARY_NAMES):
|
||||
(self.rule_root / library).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def store_spreadsheet(
|
||||
self,
|
||||
*,
|
||||
asset_id: str,
|
||||
version: str,
|
||||
file_name: str,
|
||||
content: bytes,
|
||||
actor_name: str,
|
||||
source: str = "upload",
|
||||
) -> RuleSpreadsheetMeta:
|
||||
normalized_name = Path(str(file_name or "").strip()).name.strip()
|
||||
if not normalized_name:
|
||||
raise ValueError("规则表文件名不能为空。")
|
||||
if not content:
|
||||
raise ValueError("规则表文件内容不能为空。")
|
||||
|
||||
relative_path = Path("agent_assets") / asset_id / "rule_spreadsheets" / version / normalized_name
|
||||
target_path = (self.storage_root / relative_path).resolve()
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_bytes(content)
|
||||
|
||||
mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE
|
||||
return RuleSpreadsheetMeta(
|
||||
file_name=normalized_name,
|
||||
storage_key=relative_path.as_posix(),
|
||||
mime_type=mime_type,
|
||||
size_bytes=len(content),
|
||||
checksum=hashlib.sha256(content).hexdigest(),
|
||||
updated_at=datetime.now(UTC).isoformat(),
|
||||
updated_by=str(actor_name or "system").strip() or "system",
|
||||
source=source,
|
||||
)
|
||||
|
||||
def store_rule_library_spreadsheet(
|
||||
self,
|
||||
*,
|
||||
library: str,
|
||||
file_name: str,
|
||||
content: bytes,
|
||||
actor_name: str,
|
||||
source: str = "rule-library",
|
||||
) -> RuleSpreadsheetMeta:
|
||||
normalized_library = str(library or "").strip()
|
||||
if normalized_library not in RULE_LIBRARY_NAMES:
|
||||
raise ValueError("规则库目录不合法。")
|
||||
|
||||
normalized_name = Path(str(file_name or "").strip()).name.strip()
|
||||
if not normalized_name:
|
||||
raise ValueError("规则表文件名不能为空。")
|
||||
if not content:
|
||||
raise ValueError("规则表文件内容不能为空。")
|
||||
|
||||
self.ensure_rule_library_dirs()
|
||||
relative_path = Path("rules") / normalized_library / normalized_name
|
||||
target_path = (SERVER_DIR / relative_path).resolve()
|
||||
try:
|
||||
target_path.relative_to(self.rule_root)
|
||||
except ValueError:
|
||||
raise ValueError("规则库文件路径不合法。")
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_bytes(content)
|
||||
|
||||
mime_type = mimetypes.guess_type(normalized_name)[0] or SPREADSHEET_MIME_TYPE
|
||||
return RuleSpreadsheetMeta(
|
||||
file_name=normalized_name,
|
||||
storage_key=relative_path.as_posix(),
|
||||
mime_type=mime_type,
|
||||
size_bytes=len(content),
|
||||
checksum=hashlib.sha256(content).hexdigest(),
|
||||
updated_at=datetime.now(UTC).isoformat(),
|
||||
updated_by=str(actor_name or "system").strip() or "system",
|
||||
source=source,
|
||||
)
|
||||
|
||||
def resolve_storage_path(self, storage_key: str) -> Path:
|
||||
normalized = Path(str(storage_key or "").strip())
|
||||
if not normalized.parts:
|
||||
raise FileNotFoundError("规则表文件不存在。")
|
||||
|
||||
if normalized.parts[0] == "rules":
|
||||
resolved = (SERVER_DIR / normalized).resolve()
|
||||
allowed_root = self.rule_root
|
||||
else:
|
||||
resolved = (self.storage_root / normalized).resolve()
|
||||
allowed_root = self.storage_root
|
||||
|
||||
try:
|
||||
resolved.relative_to(allowed_root)
|
||||
except ValueError:
|
||||
raise FileNotFoundError("规则表文件不存在。")
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
def parse_version_markdown(markdown: str) -> RuleSpreadsheetMeta | None:
|
||||
match = RULE_SPREADSHEET_BLOCK_PATTERN.search(str(markdown or ""))
|
||||
if match is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = json.loads(match.group(1))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
return RuleSpreadsheetMeta(
|
||||
file_name=str(payload.get("file_name") or "").strip(),
|
||||
storage_key=str(payload.get("storage_key") or "").strip(),
|
||||
mime_type=str(payload.get("mime_type") or SPREADSHEET_MIME_TYPE).strip()
|
||||
or SPREADSHEET_MIME_TYPE,
|
||||
size_bytes=int(payload.get("size_bytes") or 0),
|
||||
checksum=str(payload.get("checksum") or "").strip(),
|
||||
updated_at=str(payload.get("updated_at") or "").strip(),
|
||||
updated_by=str(payload.get("updated_by") or "system").strip() or "system",
|
||||
source=str(payload.get("source") or "upload").strip() or "upload",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_version_markdown(
|
||||
*,
|
||||
rule_name: str,
|
||||
version: str,
|
||||
metadata: RuleSpreadsheetMeta,
|
||||
) -> str:
|
||||
sections = [
|
||||
f"# {rule_name}",
|
||||
"",
|
||||
"## 规则载体",
|
||||
"",
|
||||
"- 详情类型:Excel 表格",
|
||||
f"- 当前规则版本:`{version}`",
|
||||
f"- 表格文件:`{metadata.file_name}`",
|
||||
f"- 最近更新人:{metadata.updated_by}",
|
||||
f"- 最近更新时间:{metadata.updated_at}",
|
||||
"",
|
||||
"## 使用说明",
|
||||
"",
|
||||
"- 管理员可直接在规则中心内联编辑 Excel 表格,并通过 ONLYOFFICE 回写新版本。",
|
||||
"- 上传新的 Excel 文件后,会自动生成新的规则版本快照。",
|
||||
"- 切换到历史版本时仅提供预览,不允许直接覆盖历史快照。",
|
||||
"",
|
||||
"```rule-spreadsheet",
|
||||
json.dumps(asdict(metadata), ensure_ascii=False, indent=2),
|
||||
"```",
|
||||
]
|
||||
return "\n".join(sections)
|
||||
|
||||
@staticmethod
|
||||
def build_rule_document_config(
|
||||
metadata: RuleSpreadsheetMeta,
|
||||
*,
|
||||
asset_version: str,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"kind": "spreadsheet",
|
||||
"file_name": metadata.file_name,
|
||||
"mime_type": metadata.mime_type,
|
||||
"size_bytes": metadata.size_bytes,
|
||||
"checksum": metadata.checksum,
|
||||
"updated_at": metadata.updated_at,
|
||||
"updated_by": metadata.updated_by,
|
||||
"source": metadata.source,
|
||||
"asset_version": asset_version,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def build_company_travel_rule_template() -> bytes:
|
||||
standard_rows = [
|
||||
["费用分类", "适用场景", "票据要求", "报销标准", "审批要求", "备注"],
|
||||
["长途交通", "飞机、高铁、火车等跨城出行", "行程单、车票、发票", "据实报销", "超预算需直属领导审批", "优先选择公共交通"],
|
||||
["住宿费", "出差住宿", "酒店发票、入住清单", "一线城市 650/晚;二线城市 500/晚;其他城市 380/晚", "超标需总监审批", "协议酒店优先"],
|
||||
["市内交通", "出租车、网约车、地铁、公交", "发票或电子行程单", "150/天", "超限需补充说明", "夜间或无公共交通场景可豁免"],
|
||||
["餐补", "出差期间日常补助", "无需票据", "120/天", "系统自动核定", "当天往返默认不享受"],
|
||||
["招待餐费", "客户接待或项目宴请", "餐饮发票、参与人清单", "300/人", "需业务负责人审批", "需关联客户或项目"],
|
||||
]
|
||||
instruction_rows = [
|
||||
["字段", "填写说明"],
|
||||
["费用分类", "建议保持固定选项,避免审批口径漂移。"],
|
||||
["适用场景", "写清楚业务场景,例如客户拜访、项目驻场、参会等。"],
|
||||
["票据要求", "必须明确哪些单据为必传,哪些场景允许补充说明替代。"],
|
||||
["报销标准", "建议拆成统一金额、按城市等级、按职级分档三类口径。"],
|
||||
["审批要求", "超标、例外、补录等情形应写清升级审批链。"],
|
||||
["备注", "记录豁免条件、灰度口径或制度来源。"],
|
||||
["版本建议", "每次修改表格后在规则中心同步生成一个新的规则版本。"],
|
||||
]
|
||||
return _build_xlsx_bytes(
|
||||
[
|
||||
("差旅报销标准", standard_rows),
|
||||
("填表说明", instruction_rows),
|
||||
]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_blank_rule_workbook(sheet_name: str = "规则配置") -> bytes:
|
||||
return _build_xlsx_bytes([(sheet_name, [[""]])])
|
||||
|
||||
@staticmethod
|
||||
def rebuild_from_uploaded_content(content: bytes) -> bytes:
|
||||
if not content:
|
||||
raise ValueError("待导入的表格内容不能为空。")
|
||||
|
||||
try:
|
||||
workbook = load_workbook(
|
||||
filename=BytesIO(content),
|
||||
read_only=True,
|
||||
data_only=False,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ValueError("无法解析上传的 Excel 表格。") from exc
|
||||
|
||||
sheets: list[tuple[str, list[list[object]]]] = []
|
||||
for worksheet in workbook.worksheets:
|
||||
rows = [
|
||||
list(row)
|
||||
for row in worksheet.iter_rows(values_only=True)
|
||||
]
|
||||
sheets.append((worksheet.title, _trim_empty_table(rows)))
|
||||
|
||||
if not sheets:
|
||||
raise ValueError("上传的 Excel 表格中没有可导入的工作表。")
|
||||
return _build_xlsx_bytes(sheets)
|
||||
|
||||
|
||||
def _build_xlsx_bytes(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
|
||||
created_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
workbook_buffer = BytesIO()
|
||||
|
||||
with ZipFile(workbook_buffer, "w", ZIP_DEFLATED) as archive:
|
||||
archive.writestr("[Content_Types].xml", _build_content_types_xml(sheets))
|
||||
archive.writestr("_rels/.rels", _build_root_rels_xml())
|
||||
archive.writestr("docProps/app.xml", _build_app_xml(sheets))
|
||||
archive.writestr("docProps/core.xml", _build_core_xml(created_at))
|
||||
archive.writestr("xl/workbook.xml", _build_workbook_xml(sheets))
|
||||
archive.writestr("xl/_rels/workbook.xml.rels", _build_workbook_rels_xml(sheets))
|
||||
archive.writestr("xl/styles.xml", _build_styles_xml())
|
||||
|
||||
for index, (_, rows) in enumerate(sheets, start=1):
|
||||
archive.writestr(
|
||||
f"xl/worksheets/sheet{index}.xml",
|
||||
_build_sheet_xml(rows),
|
||||
)
|
||||
|
||||
return workbook_buffer.getvalue()
|
||||
|
||||
|
||||
def _build_content_types_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
||||
overrides = [
|
||||
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>',
|
||||
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>',
|
||||
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>',
|
||||
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>',
|
||||
]
|
||||
overrides.extend(
|
||||
[
|
||||
f'<Override PartName="/xl/worksheets/sheet{index}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||||
for index, _ in enumerate(sheets, start=1)
|
||||
]
|
||||
)
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
'<Default Extension="xml" ContentType="application/xml"/>'
|
||||
f'{"".join(overrides)}'
|
||||
"</Types>"
|
||||
)
|
||||
|
||||
|
||||
def _build_root_rels_xml() -> str:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
||||
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
|
||||
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>'
|
||||
"</Relationships>"
|
||||
)
|
||||
|
||||
|
||||
def _build_app_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
||||
titles = "".join(
|
||||
[f'<vt:lpstr>{escape(name)}</vt:lpstr>' for name, _ in sheets]
|
||||
)
|
||||
sheet_count = len(sheets)
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" '
|
||||
'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">'
|
||||
'<Application>Microsoft Excel</Application>'
|
||||
f"<HeadingPairs><vt:vector size=\"2\" baseType=\"variant\"><vt:variant><vt:lpstr>Worksheets</vt:lpstr></vt:variant><vt:variant><vt:i4>{sheet_count}</vt:i4></vt:variant></vt:vector></HeadingPairs>"
|
||||
f"<TitlesOfParts><vt:vector size=\"{sheet_count}\" baseType=\"lpstr\">{titles}</vt:vector></TitlesOfParts>"
|
||||
"</Properties>"
|
||||
)
|
||||
|
||||
|
||||
def _build_core_xml(created_at: str) -> str:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
|
||||
'xmlns:dc="http://purl.org/dc/elements/1.1/" '
|
||||
'xmlns:dcterms="http://purl.org/dc/terms/" '
|
||||
'xmlns:dcmitype="http://purl.org/dc/dcmitype/" '
|
||||
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
|
||||
"<dc:creator>X-Financial</dc:creator>"
|
||||
"<cp:lastModifiedBy>X-Financial</cp:lastModifiedBy>"
|
||||
f'<dcterms:created xsi:type="dcterms:W3CDTF">{created_at}</dcterms:created>'
|
||||
f'<dcterms:modified xsi:type="dcterms:W3CDTF">{created_at}</dcterms:modified>'
|
||||
"</cp:coreProperties>"
|
||||
)
|
||||
|
||||
|
||||
def _build_workbook_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
||||
sheet_items = "".join(
|
||||
[
|
||||
f'<sheet name="{escape(name)}" sheetId="{index}" r:id="rId{index}"/>'
|
||||
for index, (name, _) in enumerate(sheets, start=1)
|
||||
]
|
||||
)
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||
"<bookViews><workbookView/></bookViews>"
|
||||
f"<sheets>{sheet_items}</sheets>"
|
||||
"</workbook>"
|
||||
)
|
||||
|
||||
|
||||
def _build_workbook_rels_xml(sheets: list[tuple[str, list[list[object]]]]) -> str:
|
||||
relationships = "".join(
|
||||
[
|
||||
f'<Relationship Id="rId{index}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{index}.xml"/>'
|
||||
for index, _ in enumerate(sheets, start=1)
|
||||
]
|
||||
)
|
||||
relationships += (
|
||||
f'<Relationship Id="rId{len(sheets) + 1}" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" '
|
||||
'Target="styles.xml"/>'
|
||||
)
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
f"{relationships}"
|
||||
"</Relationships>"
|
||||
)
|
||||
|
||||
|
||||
def _build_styles_xml() -> str:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
'<fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>'
|
||||
'<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>'
|
||||
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
|
||||
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
|
||||
'<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>'
|
||||
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
|
||||
'</styleSheet>'
|
||||
)
|
||||
|
||||
|
||||
def _build_sheet_xml(rows: list[list[object]]) -> str:
|
||||
normalized_rows = rows or [[""]]
|
||||
max_column_count = max((len(row) for row in normalized_rows), default=1)
|
||||
worksheet_rows: list[str] = []
|
||||
|
||||
for row_index, row in enumerate(normalized_rows, start=1):
|
||||
cells: list[str] = []
|
||||
for column_index, cell in enumerate(row, start=1):
|
||||
ref = f"{_column_letter(column_index)}{row_index}"
|
||||
text = "" if cell is None else str(cell)
|
||||
preserve = ' xml:space="preserve"' if text.strip() != text or "\n" in text else ""
|
||||
cells.append(
|
||||
f'<c r="{ref}" t="inlineStr"><is><t{preserve}>{escape(text)}</t></is></c>'
|
||||
)
|
||||
worksheet_rows.append(f'<row r="{row_index}">{"".join(cells)}</row>')
|
||||
|
||||
dimension = f"A1:{_column_letter(max_column_count)}{len(normalized_rows)}"
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||
f'<dimension ref="{dimension}"/>'
|
||||
"<sheetViews><sheetView workbookViewId=\"0\"/></sheetViews>"
|
||||
"<sheetFormatPr defaultRowHeight=\"18\"/>"
|
||||
f"<sheetData>{''.join(worksheet_rows)}</sheetData>"
|
||||
"</worksheet>"
|
||||
)
|
||||
|
||||
|
||||
def _column_letter(index: int) -> str:
|
||||
value = max(1, int(index))
|
||||
result = ""
|
||||
while value > 0:
|
||||
value, remainder = divmod(value - 1, 26)
|
||||
result = f"{chr(65 + remainder)}{result}"
|
||||
return result
|
||||
|
||||
|
||||
def _trim_empty_table(rows: list[list[object]]) -> list[list[object]]:
|
||||
normalized_rows = [list(row) for row in rows]
|
||||
while normalized_rows and all(cell in (None, "") for cell in normalized_rows[-1]):
|
||||
normalized_rows.pop()
|
||||
|
||||
if not normalized_rows:
|
||||
return [[""]]
|
||||
|
||||
max_column = 0
|
||||
for row in normalized_rows:
|
||||
for index, cell in enumerate(row, start=1):
|
||||
if cell not in (None, ""):
|
||||
max_column = max(max_column, index)
|
||||
|
||||
if max_column <= 0:
|
||||
return [[""]]
|
||||
|
||||
return [row[:max_column] for row in normalized_rows]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import json
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import inspect, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.agent_enums import (
|
||||
@@ -26,16 +28,23 @@ from app.db.session import get_session_factory
|
||||
from app.models.agent_asset import AgentAsset, AgentAssetReview, AgentAssetVersion
|
||||
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.financial_record import (
|
||||
AccountsPayableRecord,
|
||||
AccountsReceivableRecord,
|
||||
ExpenseClaim,
|
||||
ExpenseClaimItem,
|
||||
)
|
||||
from app.services.expense_rule_runtime import (
|
||||
build_scene_submission_standard_markdown,
|
||||
build_travel_risk_control_standard_markdown,
|
||||
)
|
||||
from app.models.financial_record import (
|
||||
AccountsPayableRecord,
|
||||
AccountsReceivableRecord,
|
||||
ExpenseClaim,
|
||||
ExpenseClaimItem,
|
||||
)
|
||||
from app.services.agent_asset_spreadsheet import (
|
||||
AgentAssetSpreadsheetManager,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
FINANCE_RULES_LIBRARY,
|
||||
RuleSpreadsheetMeta,
|
||||
)
|
||||
from app.services.expense_rule_runtime import (
|
||||
build_scene_submission_standard_markdown,
|
||||
build_travel_risk_control_standard_markdown,
|
||||
)
|
||||
|
||||
logger = get_logger("app.services.agent_foundation")
|
||||
|
||||
@@ -77,7 +86,8 @@ LEGACY_RULE_CODES = (
|
||||
"rule.ap.payment_dual_review",
|
||||
)
|
||||
|
||||
ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements"
|
||||
ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements"
|
||||
COMPANY_TRAVEL_RULE_VERSION = "v1.0.0"
|
||||
|
||||
ATTACHMENT_RULE_RUNTIME_CONFIG = {
|
||||
"kind": "policy_rule_draft",
|
||||
@@ -156,10 +166,11 @@ class AgentFoundationService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def ensure_foundation_ready(self) -> None:
|
||||
try:
|
||||
Base.metadata.create_all(bind=self.db.get_bind())
|
||||
self._seed_agent_assets()
|
||||
def ensure_foundation_ready(self) -> None:
|
||||
try:
|
||||
Base.metadata.create_all(bind=self.db.get_bind())
|
||||
self._ensure_agent_asset_schema()
|
||||
self._seed_agent_assets()
|
||||
self._sync_demo_financial_records()
|
||||
self._seed_runs_and_logs()
|
||||
self.db.commit()
|
||||
@@ -174,7 +185,7 @@ class AgentFoundationService:
|
||||
return
|
||||
self._purge_demo_financial_records()
|
||||
|
||||
def _seed_agent_assets(self) -> None:
|
||||
def _seed_agent_assets(self) -> None:
|
||||
existing_codes = set(self.db.scalars(select(AgentAsset.code)).all())
|
||||
if existing_codes:
|
||||
self._top_up_agent_assets(existing_codes)
|
||||
@@ -189,8 +200,10 @@ class AgentFoundationService:
|
||||
scenario_json=["expense", "risk_check", "attachment_policy", "invoice_anomaly"],
|
||||
owner="财务制度管理组",
|
||||
reviewer="高嘉禾",
|
||||
status=AgentAssetStatus.REVIEW.value,
|
||||
current_version="v1.0.0",
|
||||
status=AgentAssetStatus.REVIEW.value,
|
||||
current_version="v1.0.0",
|
||||
published_version=None,
|
||||
working_version="v1.0.0",
|
||||
config_json={
|
||||
"severity": "high",
|
||||
"enabled": False,
|
||||
@@ -209,8 +222,10 @@ class AgentFoundationService:
|
||||
scenario_json=["expense", "risk_check", "scene_policy", "attachment_policy"],
|
||||
owner="费用运营组",
|
||||
reviewer="顾承宇",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
published_version="v1.0.0",
|
||||
working_version="v1.0.0",
|
||||
config_json={
|
||||
"severity": "high",
|
||||
"enabled": True,
|
||||
@@ -218,17 +233,19 @@ class AgentFoundationService:
|
||||
"rule_template_label": "系统内置场景矩阵规则",
|
||||
},
|
||||
)
|
||||
travel_policy_rule = AgentAsset(
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code="rule.expense.travel_risk_control_standard",
|
||||
name="差旅报销风险管控制度",
|
||||
travel_policy_rule = AgentAsset(
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code="rule.expense.travel_risk_control_standard",
|
||||
name="差旅报销风险管控制度",
|
||||
description="统一定义差旅报销的行程闭环、酒店地点一致性、职级差标和风险处置口径。",
|
||||
domain=AgentAssetDomain.EXPENSE.value,
|
||||
scenario_json=["expense", "risk_check", "travel_policy", "travel_standard"],
|
||||
owner="风控与审计部",
|
||||
reviewer="顾承宇",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.1.0",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.1.0",
|
||||
published_version="v1.1.0",
|
||||
working_version="v1.1.0",
|
||||
config_json={
|
||||
"severity": "high",
|
||||
"enabled": True,
|
||||
@@ -236,21 +253,45 @@ class AgentFoundationService:
|
||||
"warning_on_medium_risk": True,
|
||||
"source_doc": "document/development/risks/travel-risk-control-standard.md",
|
||||
"runtime_kind": "travel_policy",
|
||||
"rule_template_key": "travel_standard_v1",
|
||||
"rule_template_label": "差旅标准模板",
|
||||
},
|
||||
)
|
||||
skill_expense_asset = AgentAsset(
|
||||
asset_type=AgentAssetType.SKILL.value,
|
||||
code="skill.expense.summary_lookup",
|
||||
"rule_template_key": "travel_standard_v1",
|
||||
"rule_template_label": "差旅标准模板",
|
||||
},
|
||||
)
|
||||
company_travel_rule = AgentAsset(
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
name="公司差旅费报销规则",
|
||||
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
|
||||
domain=AgentAssetDomain.EXPENSE.value,
|
||||
scenario_json=["expense", "travel_policy", "travel_standard"],
|
||||
owner="财务制度管理组",
|
||||
reviewer="顾承宇",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
published_version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
working_version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
config_json={
|
||||
"severity": "medium",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"rule_template_label": "差旅报销 Excel 模板",
|
||||
},
|
||||
)
|
||||
skill_expense_asset = AgentAsset(
|
||||
asset_type=AgentAssetType.SKILL.value,
|
||||
code="skill.expense.summary_lookup",
|
||||
name="报销汇总查询技能",
|
||||
description="根据时间、员工和部门汇总报销金额与单据数量。",
|
||||
domain=AgentAssetDomain.EXPENSE.value,
|
||||
scenario_json=["expense", "query", "summary"],
|
||||
owner="平台研发组",
|
||||
reviewer="陈硕",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
published_version="v1.0.0",
|
||||
working_version="v1.0.0",
|
||||
config_json={"input_schema": ["time_range", "employee", "department"]},
|
||||
)
|
||||
skill_ar_asset = AgentAsset(
|
||||
@@ -262,8 +303,10 @@ class AgentFoundationService:
|
||||
scenario_json=["accounts_receivable", "query", "aging_summary"],
|
||||
owner="平台研发组",
|
||||
reviewer="陈硕",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
published_version="v1.0.0",
|
||||
working_version="v1.0.0",
|
||||
config_json={"input_schema": ["customer", "aging_bucket", "status"]},
|
||||
)
|
||||
invoice_mcp_asset = AgentAsset(
|
||||
@@ -275,8 +318,10 @@ class AgentFoundationService:
|
||||
scenario_json=["expense", "invoice_validation"],
|
||||
owner="平台研发组",
|
||||
reviewer="周悦宁",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
published_version="v1.0.0",
|
||||
working_version="v1.0.0",
|
||||
config_json={"endpoint": "mock://invoice/verify", "timeout_ms": 1200},
|
||||
)
|
||||
ledger_mcp_asset = AgentAsset(
|
||||
@@ -288,8 +333,10 @@ class AgentFoundationService:
|
||||
scenario_json=["expense", "accounts_receivable", "accounts_payable"],
|
||||
owner="平台研发组",
|
||||
reviewer="周悦宁",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
published_version="v1.0.0",
|
||||
working_version="v1.0.0",
|
||||
config_json={"endpoint": "mock://ledger/snapshot", "timeout_ms": 1500},
|
||||
)
|
||||
task_asset = AgentAsset(
|
||||
@@ -301,8 +348,10 @@ class AgentFoundationService:
|
||||
scenario_json=["schedule", "risk_check"],
|
||||
owner="风控与审计部",
|
||||
reviewer="顾承宇",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
published_version="v1.0.0",
|
||||
working_version="v1.0.0",
|
||||
config_json={"cron": "0 9 * * *", "agent": AgentName.HERMES.value},
|
||||
)
|
||||
ar_summary_task = AgentAsset(
|
||||
@@ -314,8 +363,10 @@ class AgentFoundationService:
|
||||
scenario_json=["schedule", "accounts_receivable", "summary"],
|
||||
owner="风控与审计部",
|
||||
reviewer="顾承宇",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
published_version="v1.0.0",
|
||||
working_version="v1.0.0",
|
||||
config_json={"cron": "0 10 * * 1", "agent": AgentName.HERMES.value},
|
||||
)
|
||||
rule_digest_task = AgentAsset(
|
||||
@@ -327,8 +378,10 @@ class AgentFoundationService:
|
||||
scenario_json=["schedule", "rule_center", "review_digest"],
|
||||
owner="风控与审计部",
|
||||
reviewer="顾承宇",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
published_version="v1.0.0",
|
||||
working_version="v1.0.0",
|
||||
config_json={"cron": "0 18 * * *", "agent": AgentName.HERMES.value},
|
||||
)
|
||||
knowledge_index_task = AgentAsset(
|
||||
@@ -340,30 +393,39 @@ class AgentFoundationService:
|
||||
scenario_json=["schedule", "knowledge", "rule_center"],
|
||||
owner="财务制度管理组",
|
||||
reviewer="顾承宇",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version="v1.0.0",
|
||||
published_version="v1.0.0",
|
||||
working_version="v1.0.0",
|
||||
config_json={"cron": "0 0 * * *", "agent": AgentName.HERMES.value},
|
||||
)
|
||||
|
||||
self.db.add_all(
|
||||
[
|
||||
attachment_rule,
|
||||
scene_submission_rule,
|
||||
travel_policy_rule,
|
||||
skill_expense_asset,
|
||||
skill_ar_asset,
|
||||
invoice_mcp_asset,
|
||||
attachment_rule,
|
||||
scene_submission_rule,
|
||||
travel_policy_rule,
|
||||
company_travel_rule,
|
||||
skill_expense_asset,
|
||||
skill_ar_asset,
|
||||
invoice_mcp_asset,
|
||||
ledger_mcp_asset,
|
||||
task_asset,
|
||||
ar_summary_task,
|
||||
rule_digest_task,
|
||||
knowledge_index_task,
|
||||
]
|
||||
)
|
||||
self.db.flush()
|
||||
|
||||
self.db.add_all(
|
||||
[
|
||||
)
|
||||
self.db.flush()
|
||||
|
||||
company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed(
|
||||
company_travel_rule,
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
actor_name="系统初始化",
|
||||
)
|
||||
|
||||
self.db.add_all(
|
||||
[
|
||||
AgentAssetVersion(
|
||||
asset=attachment_rule,
|
||||
version="v0.9.0",
|
||||
@@ -402,17 +464,29 @@ class AgentFoundationService:
|
||||
change_note="首版差旅制度执行规则,覆盖行程闭环与基础差标校验。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=travel_policy_rule,
|
||||
version="v1.1.0",
|
||||
content=self._travel_risk_control_standard_markdown(version="v1.1.0"),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note="补充可执行规则块,供审核引擎直接消费差旅制度标准。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=skill_expense_asset,
|
||||
version="v1.0.0",
|
||||
AgentAssetVersion(
|
||||
asset=travel_policy_rule,
|
||||
version="v1.1.0",
|
||||
content=self._travel_risk_control_standard_markdown(version="v1.1.0"),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note="补充可执行规则块,供审核引擎直接消费差旅制度标准。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=company_travel_rule,
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
content=AgentAssetSpreadsheetManager.build_version_markdown(
|
||||
rule_name=company_travel_rule.name,
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
metadata=company_travel_rule_meta,
|
||||
),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note="初始化差旅费报销 Excel 规则表。",
|
||||
created_by="系统初始化",
|
||||
),
|
||||
AgentAssetVersion(
|
||||
asset=skill_expense_asset,
|
||||
version="v1.0.0",
|
||||
content=self._json_content(
|
||||
{
|
||||
"inputs": ["time_range", "employee", "department"],
|
||||
@@ -545,16 +619,24 @@ class AgentFoundationService:
|
||||
review_note="可作为报销场景统一审核标准正式执行。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
),
|
||||
AgentAssetReview(
|
||||
asset=travel_policy_rule,
|
||||
version="v1.1.0",
|
||||
reviewer="顾承宇",
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
),
|
||||
]
|
||||
)
|
||||
AgentAssetReview(
|
||||
asset=travel_policy_rule,
|
||||
version="v1.1.0",
|
||||
reviewer="顾承宇",
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
),
|
||||
AgentAssetReview(
|
||||
asset=company_travel_rule,
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def _seed_financial_records(self) -> None:
|
||||
if self.db.scalar(select(ExpenseClaim.id).limit(1)) is not None:
|
||||
@@ -913,9 +995,12 @@ class AgentFoundationService:
|
||||
scene_submission_rule = self.db.scalar(
|
||||
select(AgentAsset).where(AgentAsset.code == "rule.expense.scene_submission_standard")
|
||||
)
|
||||
travel_policy_rule = self.db.scalar(
|
||||
select(AgentAsset).where(AgentAsset.code == "rule.expense.travel_risk_control_standard")
|
||||
)
|
||||
travel_policy_rule = self.db.scalar(
|
||||
select(AgentAsset).where(AgentAsset.code == "rule.expense.travel_risk_control_standard")
|
||||
)
|
||||
company_travel_rule = self.db.scalar(
|
||||
select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE)
|
||||
)
|
||||
|
||||
if ATTACHMENT_RULE_ASSET_CODE not in existing_codes:
|
||||
attachment_rule = self._create_seed_asset(
|
||||
@@ -939,9 +1024,12 @@ class AgentFoundationService:
|
||||
},
|
||||
)
|
||||
|
||||
if attachment_rule is not None:
|
||||
attachment_rule.current_version = "v1.0.0"
|
||||
attachment_rule.status = AgentAssetStatus.REVIEW.value
|
||||
if attachment_rule is not None:
|
||||
if not str(attachment_rule.current_version or "").strip():
|
||||
attachment_rule.current_version = "v1.0.0"
|
||||
if not str(attachment_rule.working_version or "").strip():
|
||||
attachment_rule.working_version = attachment_rule.current_version
|
||||
attachment_rule.status = attachment_rule.status or AgentAssetStatus.REVIEW.value
|
||||
attachment_rule.description = "统一定义报销提交时的附件数量、票据类型和补件处理口径,作为上线前待审核规则。"
|
||||
attachment_rule.config_json = {
|
||||
"severity": "high",
|
||||
@@ -1002,9 +1090,14 @@ class AgentFoundationService:
|
||||
},
|
||||
)
|
||||
|
||||
if scene_submission_rule is not None:
|
||||
scene_submission_rule.current_version = "v1.0.0"
|
||||
scene_submission_rule.status = AgentAssetStatus.ACTIVE.value
|
||||
if scene_submission_rule is not None:
|
||||
if not str(scene_submission_rule.current_version or "").strip():
|
||||
scene_submission_rule.current_version = "v1.0.0"
|
||||
if not str(scene_submission_rule.working_version or "").strip():
|
||||
scene_submission_rule.working_version = scene_submission_rule.current_version
|
||||
if not str(scene_submission_rule.published_version or "").strip():
|
||||
scene_submission_rule.published_version = scene_submission_rule.current_version
|
||||
scene_submission_rule.status = scene_submission_rule.status or AgentAssetStatus.ACTIVE.value
|
||||
scene_submission_rule.description = "统一定义各报销场景的必填字段、附件类型要求和金额阈值。"
|
||||
scene_submission_rule.config_json = {
|
||||
"severity": "high",
|
||||
@@ -1053,9 +1146,14 @@ class AgentFoundationService:
|
||||
},
|
||||
)
|
||||
|
||||
if travel_policy_rule is not None:
|
||||
travel_policy_rule.current_version = "v1.1.0"
|
||||
travel_policy_rule.status = AgentAssetStatus.ACTIVE.value
|
||||
if travel_policy_rule is not None:
|
||||
if not str(travel_policy_rule.current_version or "").strip():
|
||||
travel_policy_rule.current_version = "v1.1.0"
|
||||
if not str(travel_policy_rule.working_version or "").strip():
|
||||
travel_policy_rule.working_version = travel_policy_rule.current_version
|
||||
if not str(travel_policy_rule.published_version or "").strip():
|
||||
travel_policy_rule.published_version = travel_policy_rule.current_version
|
||||
travel_policy_rule.status = travel_policy_rule.status or AgentAssetStatus.ACTIVE.value
|
||||
travel_policy_rule.config_json = {
|
||||
"severity": "high",
|
||||
"enabled": True,
|
||||
@@ -1087,12 +1185,79 @@ class AgentFoundationService:
|
||||
version="v1.1.0",
|
||||
reviewer="顾承宇",
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
if "skill.ar.aging_summary" not in existing_codes:
|
||||
asset = self._create_seed_asset(
|
||||
review_note="制度口径已确认,并已补充可执行配置供审核引擎读取。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
if COMPANY_TRAVEL_EXPENSE_RULE_CODE not in existing_codes:
|
||||
company_travel_rule = self._create_seed_asset(
|
||||
asset_type=AgentAssetType.RULE.value,
|
||||
code=COMPANY_TRAVEL_EXPENSE_RULE_CODE,
|
||||
name="公司差旅费报销规则",
|
||||
description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。",
|
||||
domain=AgentAssetDomain.EXPENSE.value,
|
||||
scenario_json=["expense", "travel_policy", "travel_standard"],
|
||||
owner="财务制度管理组",
|
||||
reviewer="顾承宇",
|
||||
status=AgentAssetStatus.ACTIVE.value,
|
||||
current_version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
config_json={
|
||||
"severity": "medium",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_template_label": "差旅报销 Excel 模板",
|
||||
},
|
||||
)
|
||||
|
||||
if company_travel_rule is not None:
|
||||
if not str(company_travel_rule.current_version or "").strip():
|
||||
company_travel_rule.current_version = COMPANY_TRAVEL_RULE_VERSION
|
||||
if not str(company_travel_rule.working_version or "").strip():
|
||||
company_travel_rule.working_version = company_travel_rule.current_version
|
||||
if not str(company_travel_rule.published_version or "").strip():
|
||||
company_travel_rule.published_version = company_travel_rule.current_version
|
||||
if not str(company_travel_rule.status or "").strip():
|
||||
company_travel_rule.status = AgentAssetStatus.ACTIVE.value
|
||||
company_travel_rule.description = "通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。"
|
||||
company_travel_rule.config_json = {
|
||||
**(company_travel_rule.config_json or {}),
|
||||
"severity": "medium",
|
||||
"enabled": True,
|
||||
"tag": "财务规则",
|
||||
"detail_mode": "spreadsheet",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"rule_template_label": "差旅报销 Excel 模板",
|
||||
}
|
||||
company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed(
|
||||
company_travel_rule,
|
||||
version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION),
|
||||
actor_name="系统初始化",
|
||||
)
|
||||
self._ensure_asset_version(
|
||||
company_travel_rule,
|
||||
version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION),
|
||||
content=AgentAssetSpreadsheetManager.build_version_markdown(
|
||||
rule_name=company_travel_rule.name,
|
||||
version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION),
|
||||
metadata=company_travel_rule_meta,
|
||||
),
|
||||
content_type=AgentAssetContentType.MARKDOWN.value,
|
||||
change_note="初始化差旅费报销 Excel 规则表。",
|
||||
created_by="系统初始化",
|
||||
)
|
||||
if str(company_travel_rule.current_version or "").strip() == COMPANY_TRAVEL_RULE_VERSION:
|
||||
self._ensure_asset_review(
|
||||
company_travel_rule,
|
||||
version=COMPANY_TRAVEL_RULE_VERSION,
|
||||
reviewer="顾承宇",
|
||||
review_status=AgentReviewStatus.APPROVED.value,
|
||||
review_note="首版 Excel 规则表已确认,可作为财务规则使用。",
|
||||
reviewed_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
if "skill.ar.aging_summary" not in existing_codes:
|
||||
asset = self._create_seed_asset(
|
||||
asset_type=AgentAssetType.SKILL.value,
|
||||
code="skill.ar.aging_summary",
|
||||
name="应收账龄汇总技能",
|
||||
@@ -1207,10 +1372,10 @@ class AgentFoundationService:
|
||||
created_by="系统初始化",
|
||||
)
|
||||
|
||||
if "task.hermes.knowledge_index_sync" not in existing_codes:
|
||||
asset = self._create_seed_asset(
|
||||
asset_type=AgentAssetType.TASK.value,
|
||||
code="task.hermes.knowledge_index_sync",
|
||||
if "task.hermes.knowledge_index_sync" not in existing_codes:
|
||||
asset = self._create_seed_asset(
|
||||
asset_type=AgentAssetType.TASK.value,
|
||||
code="task.hermes.knowledge_index_sync",
|
||||
name="Hermes ??????",
|
||||
description="?????????? LightRAG ???????",
|
||||
domain=AgentAssetDomain.SYSTEM.value,
|
||||
@@ -1234,14 +1399,112 @@ class AgentFoundationService:
|
||||
}
|
||||
),
|
||||
content_type=AgentAssetContentType.JSON.value,
|
||||
change_note="初始化制度知识与规则草稿形成任务。",
|
||||
created_by="系统初始化",
|
||||
)
|
||||
|
||||
def _create_seed_asset(
|
||||
self,
|
||||
*,
|
||||
asset_type: str,
|
||||
change_note="初始化制度知识与规则草稿形成任务。",
|
||||
created_by="系统初始化",
|
||||
)
|
||||
|
||||
def _ensure_company_travel_rule_spreadsheet_seed(
|
||||
self,
|
||||
asset: AgentAsset,
|
||||
*,
|
||||
version: str,
|
||||
actor_name: str,
|
||||
):
|
||||
manager = AgentAssetSpreadsheetManager()
|
||||
manager.ensure_rule_library_dirs()
|
||||
live_document = manager.store_rule_library_spreadsheet(
|
||||
library=FINANCE_RULES_LIBRARY,
|
||||
file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
content=self._read_or_build_company_travel_rule_file(manager),
|
||||
actor_name=actor_name,
|
||||
source="rule-library",
|
||||
)
|
||||
existing_document = (
|
||||
asset.config_json.get("rule_document")
|
||||
if isinstance(asset.config_json, dict)
|
||||
else None
|
||||
)
|
||||
storage_key = (
|
||||
str(existing_document.get("storage_key") or "").strip()
|
||||
if isinstance(existing_document, dict)
|
||||
else ""
|
||||
)
|
||||
if storage_key:
|
||||
try:
|
||||
existing_path = manager.resolve_storage_path(storage_key)
|
||||
except FileNotFoundError:
|
||||
existing_path = None
|
||||
if existing_path is not None and existing_path.exists():
|
||||
metadata = RuleSpreadsheetMeta(
|
||||
file_name=str(existing_document.get("file_name") or COMPANY_TRAVEL_EXPENSE_RULE_FILENAME),
|
||||
storage_key=storage_key,
|
||||
mime_type=str(existing_document.get("mime_type") or "").strip()
|
||||
or "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
size_bytes=int(existing_document.get("size_bytes") or existing_path.stat().st_size),
|
||||
checksum=hashlib.sha256(existing_path.read_bytes()).hexdigest(),
|
||||
updated_at=str(existing_document.get("updated_at") or "").strip()
|
||||
or datetime.now(UTC).isoformat(),
|
||||
updated_by=str(existing_document.get("updated_by") or actor_name).strip()
|
||||
or actor_name,
|
||||
source=str(existing_document.get("source") or "seed").strip() or "seed",
|
||||
)
|
||||
asset.config_json = {
|
||||
**(asset.config_json or {}),
|
||||
"detail_mode": "spreadsheet",
|
||||
"tag": "财务规则",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"rule_document": {
|
||||
**AgentAssetSpreadsheetManager.build_rule_document_config(
|
||||
live_document,
|
||||
asset_version=version,
|
||||
),
|
||||
"storage_key": live_document.storage_key,
|
||||
},
|
||||
}
|
||||
return metadata
|
||||
|
||||
live_content = manager.resolve_storage_path(live_document.storage_key).read_bytes()
|
||||
metadata = manager.store_spreadsheet(
|
||||
asset_id=asset.id,
|
||||
version=version,
|
||||
file_name=COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
|
||||
content=live_content,
|
||||
actor_name=actor_name,
|
||||
source="seed",
|
||||
)
|
||||
asset.config_json = {
|
||||
**(asset.config_json or {}),
|
||||
"detail_mode": "spreadsheet",
|
||||
"tag": "财务规则",
|
||||
"rule_library": FINANCE_RULES_LIBRARY,
|
||||
"rule_document": {
|
||||
**AgentAssetSpreadsheetManager.build_rule_document_config(
|
||||
live_document,
|
||||
asset_version=version,
|
||||
),
|
||||
"storage_key": live_document.storage_key,
|
||||
},
|
||||
}
|
||||
return metadata
|
||||
|
||||
@staticmethod
|
||||
def _read_or_build_company_travel_rule_file(
|
||||
manager: AgentAssetSpreadsheetManager,
|
||||
) -> bytes:
|
||||
live_key = (
|
||||
Path("rules")
|
||||
/ FINANCE_RULES_LIBRARY
|
||||
/ COMPANY_TRAVEL_EXPENSE_RULE_FILENAME
|
||||
).as_posix()
|
||||
live_path = manager.resolve_storage_path(live_key)
|
||||
if live_path.exists():
|
||||
return live_path.read_bytes()
|
||||
return AgentAssetSpreadsheetManager.build_blank_rule_workbook("差旅费报销规则")
|
||||
|
||||
def _create_seed_asset(
|
||||
self,
|
||||
*,
|
||||
asset_type: str,
|
||||
code: str,
|
||||
name: str,
|
||||
description: str,
|
||||
@@ -1262,10 +1525,12 @@ class AgentFoundationService:
|
||||
scenario_json=scenario_json,
|
||||
owner=owner,
|
||||
reviewer=reviewer,
|
||||
status=status,
|
||||
current_version=current_version,
|
||||
config_json=config_json,
|
||||
)
|
||||
status=status,
|
||||
current_version=current_version,
|
||||
published_version=current_version if status == AgentAssetStatus.ACTIVE.value else None,
|
||||
working_version=current_version,
|
||||
config_json=config_json,
|
||||
)
|
||||
self.db.add(asset)
|
||||
self.db.flush()
|
||||
return asset
|
||||
@@ -1331,7 +1596,7 @@ class AgentFoundationService:
|
||||
)
|
||||
)
|
||||
|
||||
def _remove_legacy_rule_assets(self) -> None:
|
||||
def _remove_legacy_rule_assets(self) -> None:
|
||||
assets = list(
|
||||
self.db.scalars(
|
||||
select(AgentAsset).where(AgentAsset.code.in_(LEGACY_RULE_CODES))
|
||||
@@ -1345,8 +1610,38 @@ class AgentFoundationService:
|
||||
select(AuditLog).where(AuditLog.resource_id.in_(LEGACY_RULE_CODES))
|
||||
).all()
|
||||
)
|
||||
for log in obsolete_logs:
|
||||
self.db.delete(log)
|
||||
for log in obsolete_logs:
|
||||
self.db.delete(log)
|
||||
|
||||
def _ensure_agent_asset_schema(self) -> None:
|
||||
bind = self.db.get_bind()
|
||||
inspector = inspect(bind)
|
||||
if "agent_assets" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
column_names = {column["name"] for column in inspector.get_columns("agent_assets")}
|
||||
migration_statements: list[str] = []
|
||||
if "published_version" not in column_names:
|
||||
migration_statements.append("ALTER TABLE agent_assets ADD COLUMN published_version VARCHAR(30)")
|
||||
if "working_version" not in column_names:
|
||||
migration_statements.append("ALTER TABLE agent_assets ADD COLUMN working_version VARCHAR(30)")
|
||||
|
||||
for statement in migration_statements:
|
||||
self.db.execute(text(statement))
|
||||
|
||||
self.db.execute(
|
||||
text(
|
||||
"UPDATE agent_assets "
|
||||
"SET working_version = COALESCE(working_version, current_version), "
|
||||
"published_version = CASE "
|
||||
"WHEN published_version IS NOT NULL THEN published_version "
|
||||
"WHEN status = 'active' THEN current_version "
|
||||
"ELSE published_version END"
|
||||
)
|
||||
)
|
||||
|
||||
if migration_statements:
|
||||
self.db.commit()
|
||||
|
||||
def _attachment_submission_requirement_markdown(
|
||||
self,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.agent_enums import AgentPermissionLevel, AgentRunStatus
|
||||
from app.core.agent_enums import AgentName, AgentPermissionLevel, AgentRunStatus
|
||||
from app.core.logging import get_logger
|
||||
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
|
||||
from app.repositories.agent_run import AgentRunRepository
|
||||
@@ -15,6 +15,8 @@ from app.services.agent_foundation import AgentFoundationService
|
||||
|
||||
logger = get_logger("app.services.agent_runs")
|
||||
|
||||
KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30)
|
||||
|
||||
|
||||
class AgentRunService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
@@ -30,11 +32,13 @@ class AgentRunService:
|
||||
limit: int = 20,
|
||||
) -> list[AgentRunRead]:
|
||||
self._ensure_ready()
|
||||
self._reconcile_stale_knowledge_index_runs()
|
||||
runs = self.repository.list(agent=agent, status=status, source=source, limit=limit)
|
||||
return [self._serialize_run(item) for item in runs]
|
||||
|
||||
def get_run(self, run_id: str) -> AgentRunRead | None:
|
||||
self._ensure_ready()
|
||||
self._reconcile_stale_knowledge_index_runs(target_run_id=run_id)
|
||||
run = self.repository.get_by_run_id(run_id)
|
||||
if run is None:
|
||||
return None
|
||||
@@ -174,6 +178,35 @@ class AgentRunService:
|
||||
logger.info("Recorded tool call run_id=%s tool=%s", run_id, tool_name)
|
||||
return AgentToolCallRead.model_validate(created)
|
||||
|
||||
def update_tool_call(
|
||||
self,
|
||||
tool_call_id: str,
|
||||
*,
|
||||
request_json: dict[str, Any] | None = None,
|
||||
response_json: dict[str, Any] | None = None,
|
||||
status: str | None = None,
|
||||
duration_ms: int | None = None,
|
||||
error_message: str | None = None,
|
||||
) -> AgentToolCallRead:
|
||||
self._ensure_ready()
|
||||
tool_call = self.repository.get_tool_call(tool_call_id)
|
||||
if tool_call is None:
|
||||
raise LookupError("Tool call not found")
|
||||
|
||||
if request_json is not None:
|
||||
tool_call.request_json = request_json
|
||||
if response_json is not None:
|
||||
tool_call.response_json = response_json
|
||||
if status is not None:
|
||||
tool_call.status = status
|
||||
if duration_ms is not None:
|
||||
tool_call.duration_ms = duration_ms
|
||||
tool_call.error_message = error_message
|
||||
|
||||
updated = self.repository.save_tool_call(tool_call)
|
||||
logger.info("Updated tool call id=%s status=%s", updated.id, updated.status)
|
||||
return AgentToolCallRead.model_validate(updated)
|
||||
|
||||
def record_semantic_parse(
|
||||
self,
|
||||
*,
|
||||
@@ -214,6 +247,73 @@ class AgentRunService:
|
||||
def _ensure_ready(self) -> None:
|
||||
AgentFoundationService(self.db).ensure_foundation_ready()
|
||||
|
||||
def _reconcile_stale_knowledge_index_runs(self, *, target_run_id: str | None = None) -> None:
|
||||
runs = self.repository.list(
|
||||
agent=AgentName.HERMES.value,
|
||||
status=AgentRunStatus.RUNNING.value,
|
||||
limit=200,
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
for run in runs:
|
||||
if target_run_id is not None and run.run_id != target_run_id:
|
||||
continue
|
||||
|
||||
route_json = dict(run.route_json or {})
|
||||
if str(route_json.get("job_type") or "").strip() != "knowledge_index_sync":
|
||||
continue
|
||||
|
||||
heartbeat_at = self._parse_heartbeat_time(
|
||||
str(route_json.get("heartbeat_at") or "").strip()
|
||||
)
|
||||
last_seen_at = heartbeat_at or run.started_at
|
||||
if last_seen_at.tzinfo is None:
|
||||
last_seen_at = last_seen_at.replace(tzinfo=UTC)
|
||||
|
||||
if now - last_seen_at <= KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT:
|
||||
continue
|
||||
|
||||
stale_document_ids = [
|
||||
str(document_id).strip()
|
||||
for document_id in list(route_json.get("requested_document_ids") or [])
|
||||
if str(document_id).strip()
|
||||
]
|
||||
if stale_document_ids:
|
||||
from app.services.knowledge import (
|
||||
KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||
KnowledgeService,
|
||||
)
|
||||
|
||||
KnowledgeService(db=self.db).set_document_ingest_statuses(
|
||||
stale_document_ids,
|
||||
KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||
agent_run_id=run.run_id,
|
||||
)
|
||||
|
||||
route_json.update(
|
||||
{
|
||||
"phase": "stale_failed",
|
||||
"heartbeat_at": now.isoformat(),
|
||||
}
|
||||
)
|
||||
run.route_json = route_json
|
||||
run.status = AgentRunStatus.FAILED.value
|
||||
run.result_summary = "知识归纳任务长时间无心跳,系统已自动标记失败。"
|
||||
run.error_message = "Knowledge index heartbeat timed out."
|
||||
run.finished_at = now
|
||||
self.repository.save_run(run)
|
||||
logger.warning("Marked stale knowledge index run as failed run_id=%s", run.run_id)
|
||||
|
||||
@staticmethod
|
||||
def _parse_heartbeat_time(raw_value: str) -> datetime | None:
|
||||
normalized = str(raw_value or "").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(normalized)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _serialize_run(run: AgentRun) -> AgentRunRead:
|
||||
semantic_parse = run.semantic_parse_logs[0] if run.semantic_parse_logs else None
|
||||
|
||||
@@ -598,13 +598,13 @@ class ExpenseRuleRuntimeService:
|
||||
return catalog
|
||||
|
||||
def _get_current_version(self, asset: AgentAsset) -> AgentAssetVersion | None:
|
||||
current_version = str(asset.current_version or "").strip()
|
||||
if not current_version:
|
||||
published_version = str(asset.published_version or asset.current_version or "").strip()
|
||||
if not published_version:
|
||||
return None
|
||||
return self.db.scalar(
|
||||
select(AgentAssetVersion).where(
|
||||
AgentAssetVersion.asset_id == asset.id,
|
||||
AgentAssetVersion.version == current_version,
|
||||
AgentAssetVersion.version == published_version,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -856,7 +856,13 @@ class KnowledgeService:
|
||||
|
||||
status_payload = status_map.get(document_id) or {}
|
||||
rag_status = str(status_payload.get("status") or "").strip().lower()
|
||||
if bool(status_payload.get("query_ready")):
|
||||
linked_run_status = self._resolve_linked_ingest_run_status(entry)
|
||||
if (
|
||||
linked_run_status == AgentRunStatus.FAILED.value
|
||||
and rag_status in {"pending", "processing", "preprocessed"}
|
||||
):
|
||||
desired_status = KNOWLEDGE_INGEST_STATUS_FAILED
|
||||
elif bool(status_payload.get("query_ready")):
|
||||
desired_status = KNOWLEDGE_INGEST_STATUS_INGESTED
|
||||
elif rag_status in {"pending", "processing", "preprocessed"}:
|
||||
desired_status = KNOWLEDGE_INGEST_STATUS_SYNCING
|
||||
@@ -1007,12 +1013,22 @@ class KnowledgeService:
|
||||
probe_entry = {"ingest_status_updated_at": heartbeat_at}
|
||||
return not self._is_syncing_status_stale(probe_entry)
|
||||
|
||||
return not self._is_syncing_status_stale(entry)
|
||||
|
||||
def _require_entry(self, index: dict[str, Any], document_id: str) -> dict[str, Any]:
|
||||
for entry in index["documents"]:
|
||||
if entry["id"] == document_id:
|
||||
return entry
|
||||
return not self._is_syncing_status_stale(entry)
|
||||
|
||||
def _resolve_linked_ingest_run_status(self, entry: dict[str, Any]) -> str:
|
||||
agent_run_id = str(entry.get("ingest_agent_run_id") or "").strip()
|
||||
if not agent_run_id or self.db is None:
|
||||
return ""
|
||||
|
||||
run = self.db.scalar(select(AgentRun).where(AgentRun.run_id == agent_run_id))
|
||||
if run is None:
|
||||
return ""
|
||||
return str(run.status or "").strip()
|
||||
|
||||
def _require_entry(self, index: dict[str, Any], document_id: str) -> dict[str, Any]:
|
||||
for entry in index["documents"]:
|
||||
if entry["id"] == document_id:
|
||||
return entry
|
||||
raise FileNotFoundError(document_id)
|
||||
|
||||
def _resolve_document_path(self, entry: dict[str, Any]) -> Path:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from datetime import UTC, datetime
|
||||
from time import perf_counter
|
||||
@@ -18,6 +19,7 @@ from app.services.knowledge import (
|
||||
from app.services.knowledge_rag import KnowledgeRagService
|
||||
|
||||
logger = get_logger("app.services.knowledge_index_tasks")
|
||||
HEARTBEAT_INTERVAL_SECONDS = 10
|
||||
|
||||
|
||||
class KnowledgeIndexTaskManager:
|
||||
@@ -58,6 +60,15 @@ class KnowledgeIndexTaskManager:
|
||||
session_factory = get_session_factory()
|
||||
db = session_factory()
|
||||
started = perf_counter()
|
||||
heartbeat_stop = threading.Event()
|
||||
heartbeat_thread: threading.Thread | None = None
|
||||
tool_call_id = ""
|
||||
tool_request_json = {
|
||||
"agent": AgentName.HERMES.value,
|
||||
"folder": folder,
|
||||
"document_ids": document_ids,
|
||||
"force": force,
|
||||
}
|
||||
|
||||
try:
|
||||
run_service = AgentRunService(db)
|
||||
@@ -84,6 +95,44 @@ class KnowledgeIndexTaskManager:
|
||||
},
|
||||
},
|
||||
)
|
||||
tool_call = run_service.record_tool_call(
|
||||
run_id=agent_run_id,
|
||||
tool_type=AgentToolType.LLM.value,
|
||||
tool_name="lightrag.index_documents",
|
||||
request_json=tool_request_json,
|
||||
response_json={"phase": "indexing"},
|
||||
status="running",
|
||||
duration_ms=0,
|
||||
error_message=None,
|
||||
)
|
||||
tool_call_id = tool_call.id
|
||||
|
||||
def heartbeat_worker() -> None:
|
||||
while not heartbeat_stop.wait(HEARTBEAT_INTERVAL_SECONDS):
|
||||
heartbeat_db = session_factory()
|
||||
try:
|
||||
AgentRunService(heartbeat_db).merge_route_json(
|
||||
agent_run_id,
|
||||
{
|
||||
"job_type": "knowledge_index_sync",
|
||||
"phase": "indexing",
|
||||
"heartbeat_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Knowledge index heartbeat update failed run_id=%s",
|
||||
agent_run_id,
|
||||
)
|
||||
finally:
|
||||
heartbeat_db.close()
|
||||
|
||||
heartbeat_thread = threading.Thread(
|
||||
target=heartbeat_worker,
|
||||
name=f"knowledge-index-heartbeat-{agent_run_id}",
|
||||
daemon=True,
|
||||
)
|
||||
heartbeat_thread.start()
|
||||
|
||||
response = rag_service.index_documents(document_ids=document_ids, force=force)
|
||||
succeeded_document_ids = [
|
||||
@@ -117,16 +166,11 @@ class KnowledgeIndexTaskManager:
|
||||
|
||||
duration_ms = int((perf_counter() - started) * 1000)
|
||||
tool_status = "succeeded" if not failed_document_ids else "failed"
|
||||
run_service.record_tool_call(
|
||||
run_id=agent_run_id,
|
||||
tool_type=AgentToolType.LLM.value,
|
||||
tool_name="lightrag.index_documents",
|
||||
request_json={
|
||||
"agent": AgentName.HERMES.value,
|
||||
"folder": folder,
|
||||
"document_ids": document_ids,
|
||||
"force": force,
|
||||
},
|
||||
heartbeat_stop.set()
|
||||
if heartbeat_thread is not None:
|
||||
heartbeat_thread.join(timeout=1)
|
||||
run_service.update_tool_call(
|
||||
tool_call_id,
|
||||
response_json=response,
|
||||
status=tool_status,
|
||||
duration_ms=duration_ms,
|
||||
@@ -166,22 +210,29 @@ class KnowledgeIndexTaskManager:
|
||||
finished_at=datetime.now(UTC),
|
||||
)
|
||||
except Exception as exc:
|
||||
heartbeat_stop.set()
|
||||
if heartbeat_thread is not None:
|
||||
heartbeat_thread.join(timeout=1)
|
||||
try:
|
||||
AgentRunService(db).record_tool_call(
|
||||
run_id=agent_run_id,
|
||||
tool_type=AgentToolType.LLM.value,
|
||||
tool_name="lightrag.index_documents",
|
||||
request_json={
|
||||
"agent": AgentName.HERMES.value,
|
||||
"folder": folder,
|
||||
"document_ids": document_ids,
|
||||
"force": force,
|
||||
},
|
||||
response_json={"error": str(exc)},
|
||||
status="failed",
|
||||
duration_ms=int((perf_counter() - started) * 1000),
|
||||
error_message=str(exc),
|
||||
)
|
||||
if tool_call_id:
|
||||
AgentRunService(db).update_tool_call(
|
||||
tool_call_id,
|
||||
response_json={"error": str(exc)},
|
||||
status="failed",
|
||||
duration_ms=int((perf_counter() - started) * 1000),
|
||||
error_message=str(exc),
|
||||
)
|
||||
else:
|
||||
AgentRunService(db).record_tool_call(
|
||||
run_id=agent_run_id,
|
||||
tool_type=AgentToolType.LLM.value,
|
||||
tool_name="lightrag.index_documents",
|
||||
request_json=tool_request_json,
|
||||
response_json={"error": str(exc)},
|
||||
status="failed",
|
||||
duration_ms=int((perf_counter() - started) * 1000),
|
||||
error_message=str(exc),
|
||||
)
|
||||
KnowledgeService(db=db).set_document_ingest_statuses(
|
||||
document_ids,
|
||||
KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||
@@ -210,6 +261,9 @@ class KnowledgeIndexTaskManager:
|
||||
logger.exception("Knowledge index task finalization failed run_id=%s", agent_run_id)
|
||||
logger.exception("Knowledge index task failed run_id=%s", agent_run_id)
|
||||
finally:
|
||||
heartbeat_stop.set()
|
||||
if heartbeat_thread is not None and heartbeat_thread.is_alive():
|
||||
heartbeat_thread.join(timeout=1)
|
||||
db.close()
|
||||
|
||||
|
||||
|
||||
@@ -83,24 +83,23 @@ class KnowledgeNormalizationService:
|
||||
if rendered:
|
||||
normalized_tables.append(f"## {candidate.title}\n\n{rendered}")
|
||||
|
||||
parts: list[str] = []
|
||||
appendix_parts: list[str] = []
|
||||
if section_appendix:
|
||||
parts.append(section_appendix)
|
||||
appendix_parts.append(section_appendix)
|
||||
if answer_clue_appendix:
|
||||
parts.append(answer_clue_appendix)
|
||||
appendix_parts.append(answer_clue_appendix)
|
||||
if normalized_tables:
|
||||
appendix = "\n\n".join(normalized_tables)
|
||||
parts.append(
|
||||
appendix_parts.append(
|
||||
"# 结构化表格补充\n\n"
|
||||
"以下表格由知识归纳阶段依据原文重新整理,供问答检索时优先理解行列关系。\n\n"
|
||||
f"{appendix}"
|
||||
)
|
||||
|
||||
if not parts:
|
||||
if not appendix_parts:
|
||||
return normalized_text
|
||||
|
||||
parts.append(f"# 原文\n\n{normalized_text}")
|
||||
return "\n\n".join(parts)
|
||||
return "\n\n".join([normalized_text, *appendix_parts])
|
||||
|
||||
@staticmethod
|
||||
def _extract_table_candidates(text: str) -> list[TableCandidate]:
|
||||
|
||||
@@ -33,6 +33,7 @@ DEFAULT_LIGHTRAG_QUERY_MODE = "naive"
|
||||
DEFAULT_LLM_TIMEOUT_SECONDS = 180
|
||||
DEFAULT_EMBEDDING_TIMEOUT_SECONDS = 120
|
||||
MAX_KNOWLEDGE_HIT_CONTENT_LENGTH = 2200
|
||||
MAX_KNOWLEDGE_HIT_EXCERPT_LENGTH = 220
|
||||
MAX_QUERY_TERMS = 12
|
||||
QUERY_TERM_STOPWORDS = {
|
||||
"什么",
|
||||
@@ -62,6 +63,13 @@ TABLE_OR_STANDARD_QUERY_HINTS = (
|
||||
"档位",
|
||||
"额度",
|
||||
)
|
||||
STRUCTURED_APPENDIX_LEADING_MARKERS = (
|
||||
"# 章节导航",
|
||||
"# 重点章节摘录",
|
||||
"# 问答线索补充",
|
||||
"# 结构化表格补充",
|
||||
)
|
||||
STRUCTURED_APPENDIX_LEADING_WINDOW = 220
|
||||
|
||||
_runtime_lock = threading.RLock()
|
||||
_runtime_instance: _LightRagRuntime | None = None
|
||||
@@ -830,7 +838,11 @@ class KnowledgeRagService:
|
||||
document_id, document_name = _parse_document_identity(file_path)
|
||||
normalized_chunk_id = chunk_id or f"path-{rank}"
|
||||
normalized_content = _truncate_text(content, max_length=MAX_KNOWLEDGE_HIT_CONTENT_LENGTH)
|
||||
excerpt = _build_excerpt(normalized_content, max_length=220)
|
||||
excerpt = _build_query_focused_excerpt(
|
||||
normalized_content,
|
||||
query_terms=query_terms,
|
||||
max_length=MAX_KNOWLEDGE_HIT_EXCERPT_LENGTH,
|
||||
)
|
||||
candidates.append(
|
||||
{
|
||||
"code": f"knowledge.{document_id or 'unknown'}.{normalized_chunk_id}",
|
||||
@@ -907,8 +919,12 @@ class KnowledgeRagService:
|
||||
@staticmethod
|
||||
def is_query_ready_status(status_obj: Any) -> bool:
|
||||
status_text = KnowledgeRagService._status_value(status_obj)
|
||||
if status_text in {"failed", "error", "aborted"}:
|
||||
return False
|
||||
if status_text == "processed":
|
||||
return True
|
||||
if status_text in {"pending", "processing", "preprocessed"}:
|
||||
return False
|
||||
|
||||
chunks_count = getattr(status_obj, "chunks_count", None)
|
||||
if chunks_count is None and isinstance(status_obj, dict):
|
||||
@@ -1168,6 +1184,35 @@ def _build_excerpt(text: str, *, max_length: int = 180) -> str:
|
||||
return f"{normalized[: max_length - 3].rstrip()}..."
|
||||
|
||||
|
||||
def _build_query_focused_excerpt(
|
||||
text: str,
|
||||
*,
|
||||
query_terms: list[str],
|
||||
max_length: int = 180,
|
||||
) -> str:
|
||||
normalized = " ".join(str(text or "").split()).strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
lowered = normalized.lower()
|
||||
match_positions = [
|
||||
lowered.find(term)
|
||||
for term in query_terms
|
||||
if term and lowered.find(term) >= 0
|
||||
]
|
||||
if not match_positions:
|
||||
return _build_excerpt(normalized, max_length=max_length)
|
||||
|
||||
start = max(0, min(match_positions) - max_length // 3)
|
||||
end = min(len(normalized), start + max_length)
|
||||
snippet = normalized[start:end].strip()
|
||||
if start > 0:
|
||||
snippet = f"...{snippet.lstrip()}"
|
||||
if end < len(normalized):
|
||||
snippet = f"{snippet.rstrip()}..."
|
||||
return snippet
|
||||
|
||||
|
||||
def _truncate_text(text: str, *, max_length: int) -> str:
|
||||
normalized = str(text or "").strip()
|
||||
if len(normalized) <= max_length:
|
||||
@@ -1243,19 +1288,43 @@ def _score_knowledge_hit(
|
||||
score += len(matched_terms) * 8
|
||||
score += sum(1 for term in matched_terms if term in title) * 6
|
||||
|
||||
if "结构化表格补充" in content:
|
||||
score += 18
|
||||
if "问答线索补充" in content:
|
||||
score += 16 if not prefers_tabular_evidence else 8
|
||||
if "重点章节摘录" in content:
|
||||
leading_appendix_marker = _leading_structured_appendix_marker(content)
|
||||
if leading_appendix_marker == "# 章节导航":
|
||||
score -= 24
|
||||
elif leading_appendix_marker == "# 重点章节摘录":
|
||||
score += 4 if matched_terms else -12
|
||||
elif leading_appendix_marker == "# 问答线索补充":
|
||||
score += 8 if matched_terms and not prefers_tabular_evidence else 2 if matched_terms else -20
|
||||
elif leading_appendix_marker == "# 结构化表格补充":
|
||||
if prefers_tabular_evidence and matched_terms:
|
||||
score += 16
|
||||
elif matched_terms:
|
||||
score += 6
|
||||
else:
|
||||
score -= 18
|
||||
|
||||
if prefers_tabular_evidence and matched_terms and ("|" in content or "表" in content):
|
||||
score += 10
|
||||
if "章节导航" in content:
|
||||
if matched_terms and any(marker in content for marker in (":", ":")):
|
||||
score += 10
|
||||
if matched_terms and "\n" in content:
|
||||
score += 4
|
||||
if prefers_tabular_evidence and ("|" in content or "表" in content or "结构化表格补充" in content):
|
||||
score += 12
|
||||
if not prefers_tabular_evidence and any(marker in content for marker in ("第", "条", ":", "-", "•")):
|
||||
if matched_terms and any(marker in content for marker in ("附表", "第", "条")):
|
||||
score += 4
|
||||
if not prefers_tabular_evidence and matched_terms and any(marker in content for marker in ("第", "条", ":", "-", "•")):
|
||||
score += 4
|
||||
if title and any(term in title for term in query_terms):
|
||||
score += 6
|
||||
if re.search(r"没有.{0,8}(信息|规定|说明|依据)", content):
|
||||
score -= 12
|
||||
|
||||
return score
|
||||
|
||||
|
||||
def _leading_structured_appendix_marker(content: str) -> str:
|
||||
normalized = str(content or "").lstrip()
|
||||
for marker in STRUCTURED_APPENDIX_LEADING_MARKERS:
|
||||
index = normalized.find(marker)
|
||||
if 0 <= index <= STRUCTURED_APPENDIX_LEADING_WINDOW:
|
||||
return marker
|
||||
return ""
|
||||
|
||||
@@ -135,11 +135,14 @@ KNOWLEDGE_QUERY_STOPWORDS = {
|
||||
}
|
||||
MAX_KNOWLEDGE_QUERY_TERMS = 12
|
||||
MAX_KNOWLEDGE_DIRECT_EVIDENCE = 4
|
||||
MAX_KNOWLEDGE_MODEL_HITS = 5
|
||||
KNOWLEDGE_SECTION_HEADING_PATTERN = re.compile(
|
||||
r"^(#\s*.+|##\s*.+|###\s*.+|第[一二三四五六七八九十百零0-9]+[章节条]\s*.*|[一二三四五六七八九十]+、.*|([一二三四五六七八九十]+).*|\([一二三四五六七八九十]+\).*)$"
|
||||
)
|
||||
KNOWLEDGE_LIST_ITEM_PATTERN = re.compile(r"^[-*•]\s+.+$")
|
||||
KNOWLEDGE_NUMBERED_ITEM_PATTERN = re.compile(r"^(?:\d+[.)、]|[①②③④⑤⑥⑦⑧⑨⑩])\s*.+$")
|
||||
KNOWLEDGE_NUMBERED_ITEM_PATTERN = re.compile(
|
||||
r"^(?:(?:\d+[.)、])|(?:[((][一二三四五六七八九十百零0-9]+[))])|[①②③④⑤⑥⑦⑧⑨⑩])\s*.+$"
|
||||
)
|
||||
KNOWLEDGE_ARTICLE_PATTERN = re.compile(r"^(第[一二三四五六七八九十百零0-9]+条)\s*.*$")
|
||||
|
||||
EXPENSE_STATUS_LABELS = {
|
||||
@@ -496,10 +499,15 @@ class UserAgentService:
|
||||
draft_payload: UserAgentDraftPayload | None,
|
||||
fallback_answer: str,
|
||||
) -> list[dict[str, str]]:
|
||||
facts = {
|
||||
"run_id": payload.run_id,
|
||||
"user_message": payload.message,
|
||||
"ontology": payload.ontology.model_dump(mode="json"),
|
||||
knowledge_question = (
|
||||
self._resolve_knowledge_question(payload)
|
||||
if payload.ontology.scenario == "knowledge"
|
||||
else ""
|
||||
)
|
||||
facts = {
|
||||
"run_id": payload.run_id,
|
||||
"user_message": payload.message,
|
||||
"ontology": payload.ontology.model_dump(mode="json"),
|
||||
"context": {
|
||||
"entry_source": payload.context_json.get("entry_source"),
|
||||
"user_name": payload.context_json.get("name"),
|
||||
@@ -519,7 +527,10 @@ class UserAgentService:
|
||||
"draft_claim_id": payload.context_json.get("draft_claim_id"),
|
||||
"conversation_history": self._resolve_conversation_history(payload),
|
||||
},
|
||||
"tool_payload": self._build_model_tool_payload(payload.tool_payload),
|
||||
"tool_payload": self._build_model_tool_payload(
|
||||
payload.tool_payload,
|
||||
question=knowledge_question,
|
||||
),
|
||||
"citations": [item.model_dump(mode="json") for item in citations],
|
||||
"suggested_actions": [item.model_dump(mode="json") for item in suggested_actions],
|
||||
"risk_flags": risk_flags,
|
||||
@@ -530,7 +541,8 @@ class UserAgentService:
|
||||
}
|
||||
if payload.ontology.scenario == "knowledge":
|
||||
facts["knowledge_evidence_blocks"] = self._build_knowledge_evidence_blocks(
|
||||
payload.tool_payload
|
||||
payload.tool_payload,
|
||||
question=knowledge_question,
|
||||
)
|
||||
facts["knowledge_answer_evidence"] = [
|
||||
{
|
||||
@@ -598,13 +610,20 @@ class UserAgentService:
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _build_model_tool_payload(tool_payload: dict[str, Any]) -> dict[str, Any]:
|
||||
def _build_model_tool_payload(
|
||||
tool_payload: dict[str, Any],
|
||||
*,
|
||||
question: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized = dict(tool_payload or {})
|
||||
hits = []
|
||||
for item in UserAgentService._select_knowledge_model_hits(tool_payload):
|
||||
for item in UserAgentService._select_knowledge_model_hits(
|
||||
tool_payload,
|
||||
question=question,
|
||||
):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
hits.append(
|
||||
hits.append(
|
||||
{
|
||||
"title": str(item.get("title") or "").strip(),
|
||||
"document_name": str(item.get("document_name") or "").strip(),
|
||||
@@ -619,10 +638,17 @@ class UserAgentService:
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _build_knowledge_evidence_blocks(tool_payload: dict[str, Any]) -> str:
|
||||
def _build_knowledge_evidence_blocks(
|
||||
tool_payload: dict[str, Any],
|
||||
*,
|
||||
question: str | None = None,
|
||||
) -> str:
|
||||
blocks: list[str] = []
|
||||
for index, item in enumerate(
|
||||
UserAgentService._select_knowledge_model_hits(tool_payload)[:3],
|
||||
UserAgentService._select_knowledge_model_hits(
|
||||
tool_payload,
|
||||
question=question,
|
||||
)[:3],
|
||||
start=1,
|
||||
):
|
||||
if not isinstance(item, dict):
|
||||
@@ -645,24 +671,86 @@ class UserAgentService:
|
||||
return "\n\n".join(blocks)
|
||||
|
||||
@staticmethod
|
||||
def _select_knowledge_model_hits(tool_payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
raw_hits = [item for item in list(tool_payload.get("hits") or []) if isinstance(item, dict)]
|
||||
structured_hits = [
|
||||
def _select_knowledge_model_hits(
|
||||
tool_payload: dict[str, Any],
|
||||
*,
|
||||
question: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
raw_hits = [
|
||||
item
|
||||
for item in raw_hits
|
||||
if any(
|
||||
marker in str(item.get("content") or "")
|
||||
for marker in ("问答线索补充", "结构化表格补充", "重点章节摘录")
|
||||
)
|
||||
]
|
||||
selected = structured_hits[:2]
|
||||
for item in raw_hits:
|
||||
if item in selected:
|
||||
continue
|
||||
selected.append(item)
|
||||
if len(selected) >= 3:
|
||||
break
|
||||
return selected[:3]
|
||||
for item in list(tool_payload.get("hits") or [])
|
||||
if isinstance(item, dict)
|
||||
][: max(MAX_KNOWLEDGE_MODEL_HITS + 1, 6)]
|
||||
if not raw_hits:
|
||||
return []
|
||||
|
||||
query_terms = UserAgentService._extract_knowledge_query_terms(question or "")
|
||||
if not query_terms:
|
||||
return raw_hits[:MAX_KNOWLEDGE_MODEL_HITS]
|
||||
|
||||
ranked_hits = sorted(
|
||||
enumerate(raw_hits),
|
||||
key=lambda value: (
|
||||
UserAgentService._score_knowledge_model_hit(
|
||||
value[1],
|
||||
query_terms=query_terms,
|
||||
rank_index=value[0],
|
||||
),
|
||||
-value[0],
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return [item for _, item in ranked_hits[:MAX_KNOWLEDGE_MODEL_HITS]]
|
||||
|
||||
@staticmethod
|
||||
def _score_knowledge_model_hit(
|
||||
item: dict[str, Any],
|
||||
*,
|
||||
query_terms: list[str],
|
||||
rank_index: int,
|
||||
) -> int:
|
||||
title = str(item.get("title") or item.get("document_name") or "").lower()
|
||||
excerpt = str(item.get("excerpt") or "").lower()
|
||||
content = str(item.get("content") or "").lower()
|
||||
haystack = "\n".join([title, excerpt, content[:1400]])
|
||||
|
||||
matched_terms = [term for term in query_terms if term in haystack]
|
||||
score = max(1, 48 - rank_index * 4)
|
||||
score += len(matched_terms) * 10
|
||||
score += sum(1 for term in matched_terms if term in title) * 8
|
||||
|
||||
leading_marker = UserAgentService._leading_knowledge_appendix_marker(content)
|
||||
if leading_marker == "# 章节导航":
|
||||
score -= 22
|
||||
elif leading_marker == "# 问答线索补充":
|
||||
score += 6 if matched_terms else -8
|
||||
elif leading_marker == "# 重点章节摘录":
|
||||
score += 4 if matched_terms else -4
|
||||
elif leading_marker == "# 结构化表格补充":
|
||||
score += 8 if matched_terms else -3
|
||||
|
||||
if matched_terms and "|" in content:
|
||||
score += 8
|
||||
if matched_terms and any(marker in content for marker in (":", ":")):
|
||||
score += 10
|
||||
if matched_terms and "\n" in content:
|
||||
score += 4
|
||||
if matched_terms and any(marker in content for marker in ("附表", "第", "条")):
|
||||
score += 4
|
||||
if matched_terms and any(marker in content for marker in ("第", "条", ":", "-", "•")):
|
||||
score += 4
|
||||
if re.search(r"没有.{0,8}(信息|规定|说明|依据)", content):
|
||||
score -= 12
|
||||
return score
|
||||
|
||||
@staticmethod
|
||||
def _leading_knowledge_appendix_marker(content: str) -> str:
|
||||
normalized = str(content or "").lstrip()
|
||||
for marker in ("# 章节导航", "# 重点章节摘录", "# 问答线索补充", "# 结构化表格补充"):
|
||||
index = normalized.find(marker)
|
||||
if 0 <= index <= 220:
|
||||
return marker
|
||||
return ""
|
||||
|
||||
def _build_query_answer(self, payload: UserAgentRequest) -> str:
|
||||
scenario = payload.ontology.scenario
|
||||
@@ -860,25 +948,33 @@ class UserAgentService:
|
||||
question = self._resolve_knowledge_question(payload)
|
||||
query_terms = self._extract_knowledge_query_terms(question)
|
||||
ordered_evidence_items = self._prioritize_knowledge_evidence_items(question, evidence_items)
|
||||
lead = self._summarize_knowledge_evidence_content(ordered_evidence_items[0], query_terms)
|
||||
primary_item = ordered_evidence_items[0]
|
||||
primary_heading = self._format_knowledge_heading_label(
|
||||
str(primary_item.get("heading") or "").strip()
|
||||
)
|
||||
primary_lines = self._collect_direct_knowledge_answer_lines(ordered_evidence_items)
|
||||
|
||||
lines: list[str] = []
|
||||
if user_name:
|
||||
lines.append(f"{user_name},您好。")
|
||||
lines.append(f"根据《{title}》,当前能直接确认的是:{lead}")
|
||||
lines.append("")
|
||||
lines.append("## 依据")
|
||||
source_prefix = f"根据《{title}》"
|
||||
if primary_heading:
|
||||
source_prefix = f"{source_prefix}({primary_heading})"
|
||||
|
||||
for item in ordered_evidence_items:
|
||||
heading = str(item.get("heading") or "").strip()
|
||||
heading_text = f" > {heading}" if heading else ""
|
||||
content = str(item.get("content") or "").strip()
|
||||
if str(item.get("kind") or "") == "table":
|
||||
lines.append(f"《{item.get('title') or title}》{heading_text}")
|
||||
lines.append(self._extract_relevant_table_preview(content, query_terms))
|
||||
lines.append("")
|
||||
continue
|
||||
lines.append(f"- 《{item.get('title') or title}》{heading_text}:{self._clean_knowledge_segment_text(content)}")
|
||||
if str(primary_item.get("kind") or "") == "table":
|
||||
lines.append(f"{source_prefix},当前能直接确认的是:")
|
||||
lines.append(self._extract_relevant_table_preview(str(primary_item.get("content") or ""), query_terms))
|
||||
else:
|
||||
if not primary_lines:
|
||||
lines.append(
|
||||
f"{source_prefix},当前能直接确认的是:"
|
||||
f"{self._summarize_knowledge_evidence_content(primary_item, query_terms)}"
|
||||
)
|
||||
elif len(primary_lines) == 1:
|
||||
lines.append(f"{source_prefix},当前能直接确认的是:{primary_lines[0].strip()}")
|
||||
else:
|
||||
lines.append(f"{source_prefix},当前能直接确认的是:")
|
||||
lines.extend(primary_lines)
|
||||
|
||||
notes: list[str] = []
|
||||
location_note = self._build_missing_location_grounding_note(question, evidence_items)
|
||||
@@ -889,7 +985,7 @@ class UserAgentService:
|
||||
|
||||
if notes:
|
||||
lines.append("")
|
||||
lines.append("## 说明")
|
||||
lines.append("说明:")
|
||||
lines.extend(f"- {note}" for note in notes)
|
||||
|
||||
return "\n".join(line for line in lines if line is not None).strip()
|
||||
@@ -944,7 +1040,10 @@ class UserAgentService:
|
||||
query_terms = self._extract_knowledge_query_terms(question)
|
||||
candidates: list[dict[str, Any]] = []
|
||||
|
||||
for hit in self._select_knowledge_model_hits(payload.tool_payload):
|
||||
for hit in self._select_knowledge_model_hits(
|
||||
payload.tool_payload,
|
||||
question=question,
|
||||
):
|
||||
if not isinstance(hit, dict):
|
||||
continue
|
||||
candidates.extend(self._extract_knowledge_evidence_candidates(hit, query_terms))
|
||||
@@ -988,7 +1087,9 @@ class UserAgentService:
|
||||
if not content:
|
||||
return []
|
||||
|
||||
raw_candidates = self._split_knowledge_hit_into_segments(content)
|
||||
raw_candidates = self._merge_knowledge_lead_in_segments(
|
||||
self._split_knowledge_hit_into_segments(content)
|
||||
)
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for item in raw_candidates:
|
||||
score = self._score_knowledge_evidence_candidate(item, query_terms)
|
||||
@@ -1015,6 +1116,95 @@ class UserAgentService:
|
||||
}
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _is_knowledge_lead_in_segment(item: dict[str, str]) -> bool:
|
||||
kind = str(item.get("kind") or "").strip()
|
||||
content = str(item.get("content") or "").strip()
|
||||
return kind in {"kv", "list", "clause"} and content.endswith((":", ":"))
|
||||
|
||||
@staticmethod
|
||||
def _extract_knowledge_marker_family(content: str) -> str:
|
||||
normalized = str(content or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
if KNOWLEDGE_ARTICLE_PATTERN.match(normalized):
|
||||
return "article"
|
||||
if re.match(r"^\d+[.)、]\s*", normalized):
|
||||
return "arabic"
|
||||
if re.match(r"^[((][一二三四五六七八九十百零0-9]+[))]\s*", normalized):
|
||||
return "paren"
|
||||
if re.match(r"^[①②③④⑤⑥⑦⑧⑨⑩]\s*", normalized):
|
||||
return "circled"
|
||||
if KNOWLEDGE_LIST_ITEM_PATTERN.match(normalized):
|
||||
return "bullet"
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _format_knowledge_heading_label(heading: str) -> str:
|
||||
parts = [item.strip() for item in str(heading or "").split(">") if item.strip()]
|
||||
return " / ".join(parts)
|
||||
|
||||
def _merge_knowledge_lead_in_segments(
|
||||
self,
|
||||
segments: list[dict[str, str]],
|
||||
) -> list[dict[str, str]]:
|
||||
if not segments:
|
||||
return []
|
||||
|
||||
merged: list[dict[str, str]] = []
|
||||
index = 0
|
||||
while index < len(segments):
|
||||
current = dict(segments[index])
|
||||
if not self._is_knowledge_lead_in_segment(current):
|
||||
merged.append(current)
|
||||
index += 1
|
||||
continue
|
||||
|
||||
base_heading = str(current.get("heading") or "").strip()
|
||||
current_marker = self._extract_knowledge_marker_family(str(current.get("content") or ""))
|
||||
follow_segments: list[dict[str, str]] = []
|
||||
next_index = index + 1
|
||||
|
||||
while next_index < len(segments):
|
||||
candidate = segments[next_index]
|
||||
if str(candidate.get("heading") or "").strip() != base_heading:
|
||||
break
|
||||
|
||||
candidate_kind = str(candidate.get("kind") or "").strip()
|
||||
candidate_content = str(candidate.get("content") or "").strip()
|
||||
candidate_marker = self._extract_knowledge_marker_family(candidate_content)
|
||||
if not candidate_content or candidate_kind == "table":
|
||||
break
|
||||
if current_marker and candidate_marker == current_marker:
|
||||
break
|
||||
if self._is_knowledge_lead_in_segment(candidate) and follow_segments:
|
||||
break
|
||||
if candidate_kind not in {"list", "paragraph", "kv", "clause"}:
|
||||
break
|
||||
|
||||
follow_segments.append(candidate)
|
||||
next_index += 1
|
||||
if len(follow_segments) >= 4:
|
||||
break
|
||||
if candidate_kind == "paragraph" and len(candidate_content) >= 200:
|
||||
break
|
||||
|
||||
if follow_segments:
|
||||
current["content"] = "\n".join(
|
||||
[str(current.get("content") or "").strip()]
|
||||
+ [str(item.get("content") or "").strip() for item in follow_segments]
|
||||
)
|
||||
if any(str(item.get("kind") or "").strip() == "list" for item in follow_segments):
|
||||
current["kind"] = "list"
|
||||
merged.append(current)
|
||||
index = next_index
|
||||
continue
|
||||
|
||||
merged.append(current)
|
||||
index += 1
|
||||
|
||||
return merged
|
||||
|
||||
def _split_knowledge_hit_into_segments(self, content: str) -> list[dict[str, str]]:
|
||||
segments: list[dict[str, str]] = []
|
||||
markdown_headings: list[str] = []
|
||||
@@ -1218,11 +1408,85 @@ class UserAgentService:
|
||||
normalized = str(content or "").strip()
|
||||
normalized = re.sub(r"^[-*•]\s*", "", normalized)
|
||||
normalized = re.sub(r"^(?:\d+[.)、]|[①②③④⑤⑥⑦⑧⑨⑩])\s*", "", normalized)
|
||||
normalized = re.sub(r"^[((][一二三四五六七八九十百零0-9]+[))]\s*", "", normalized)
|
||||
normalized = re.sub(r"\s+", " ", normalized)
|
||||
if len(normalized) <= 180:
|
||||
return normalized
|
||||
return f"{normalized[:177].rstrip()}..."
|
||||
|
||||
@staticmethod
|
||||
def _normalize_knowledge_line(content: str, *, preserve_marker: bool) -> str:
|
||||
normalized = str(content or "").strip()
|
||||
normalized = re.sub(r"^[-*•]\s*", "", normalized)
|
||||
if not preserve_marker:
|
||||
normalized = re.sub(r"^(?:\d+[.)、]|[①②③④⑤⑥⑦⑧⑨⑩])\s*", "", normalized)
|
||||
normalized = re.sub(r"^[((][一二三四五六七八九十百零0-9]+[))]\s*", "", normalized)
|
||||
normalized = re.sub(r"\s+", " ", normalized)
|
||||
return normalized
|
||||
|
||||
def _split_clean_knowledge_lines(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
preserve_marker: bool,
|
||||
) -> list[str]:
|
||||
return [
|
||||
line
|
||||
for line in (
|
||||
self._normalize_knowledge_line(item, preserve_marker=preserve_marker)
|
||||
for item in str(content or "").splitlines()
|
||||
)
|
||||
if line
|
||||
]
|
||||
|
||||
def _render_knowledge_evidence_text(self, item: dict[str, Any]) -> str:
|
||||
lines = self._split_clean_knowledge_lines(
|
||||
str(item.get("content") or ""),
|
||||
preserve_marker=True,
|
||||
)
|
||||
if not lines:
|
||||
return ""
|
||||
if len(lines) == 1:
|
||||
return self._clean_knowledge_segment_text(lines[0])
|
||||
return "\n".join(f" {line}" for line in lines)
|
||||
|
||||
def _collect_direct_knowledge_answer_lines(
|
||||
self,
|
||||
ordered_evidence_items: list[dict[str, Any]],
|
||||
) -> list[str]:
|
||||
if not ordered_evidence_items:
|
||||
return []
|
||||
|
||||
primary_item = ordered_evidence_items[0]
|
||||
primary_title = str(primary_item.get("title") or "").strip()
|
||||
primary_heading = str(primary_item.get("heading") or "").strip()
|
||||
primary_kind = str(primary_item.get("kind") or "").strip()
|
||||
|
||||
related_items = [primary_item]
|
||||
if primary_kind != "table":
|
||||
for item in ordered_evidence_items[1:]:
|
||||
if len(related_items) >= 3:
|
||||
break
|
||||
if str(item.get("kind") or "").strip() != primary_kind:
|
||||
continue
|
||||
if str(item.get("title") or "").strip() != primary_title:
|
||||
continue
|
||||
if str(item.get("heading") or "").strip() != primary_heading:
|
||||
continue
|
||||
related_items.append(item)
|
||||
|
||||
lines: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in related_items:
|
||||
rendered = self._render_knowledge_evidence_text(item)
|
||||
for line in rendered.splitlines():
|
||||
normalized = str(line or "").strip()
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
def _summarize_knowledge_evidence_content(
|
||||
self,
|
||||
item: dict[str, Any],
|
||||
@@ -1236,6 +1500,9 @@ class UserAgentService:
|
||||
if len(preview_rows) >= 3:
|
||||
return "当前命中的直接依据是一张与问题强相关的标准表,已摘出最相关的表头和行。"
|
||||
return "当前命中的直接依据是一张与问题强相关的标准表。"
|
||||
lines = self._split_clean_knowledge_lines(content, preserve_marker=True)
|
||||
if len(lines) >= 2:
|
||||
return self._clean_knowledge_segment_text(f"{lines[0]} {' '.join(lines[1:4])}")
|
||||
return self._clean_knowledge_segment_text(content)
|
||||
|
||||
@staticmethod
|
||||
@@ -1354,9 +1621,12 @@ class UserAgentService:
|
||||
)
|
||||
evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:\n{preview}")
|
||||
continue
|
||||
content = self._clean_knowledge_segment_text(str(item.get("content") or ""))
|
||||
if content:
|
||||
evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:{content}")
|
||||
rendered = self._render_knowledge_evidence_text(item)
|
||||
if rendered:
|
||||
if "\n" in rendered:
|
||||
evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:\n{rendered}")
|
||||
else:
|
||||
evidence_lines.append(f"- 《{item.get('title') or title}》{heading_text}:{rendered}")
|
||||
|
||||
if not evidence_lines:
|
||||
for item in hits[:2]:
|
||||
|
||||
@@ -14,6 +14,7 @@ Requires-Dist: pydantic-settings<3.0.0,>=2.6.0
|
||||
Requires-Dist: python-dotenv<2.0.0,>=1.0.1
|
||||
Requires-Dist: email-validator<3.0.0,>=2.2.0
|
||||
Requires-Dist: python-multipart<1.0.0,>=0.0.20
|
||||
Requires-Dist: openpyxl<4.0.0,>=3.1.5
|
||||
Requires-Dist: lightrag-hku<1.5.0,>=1.4.16
|
||||
Requires-Dist: qdrant-client<2.0.0,>=1.18.0
|
||||
Provides-Extra: dev
|
||||
|
||||
@@ -76,6 +76,7 @@ src/app/schemas/settings.py
|
||||
src/app/schemas/system_log.py
|
||||
src/app/schemas/user_agent.py
|
||||
src/app/services/__init__.py
|
||||
src/app/services/agent_asset_spreadsheet.py
|
||||
src/app/services/agent_assets.py
|
||||
src/app/services/agent_conversations.py
|
||||
src/app/services/agent_foundation.py
|
||||
@@ -90,7 +91,10 @@ src/app/services/expense_rule_runtime.py
|
||||
src/app/services/hermes_sync.py
|
||||
src/app/services/knowledge.py
|
||||
src/app/services/knowledge_index_tasks.py
|
||||
src/app/services/knowledge_normalizer.py
|
||||
src/app/services/knowledge_rag.py
|
||||
src/app/services/knowledge_scheduler.py
|
||||
src/app/services/knowledge_sync.py
|
||||
src/app/services/model_connectivity.py
|
||||
src/app/services/ocr.py
|
||||
src/app/services/ontology.py
|
||||
@@ -106,8 +110,11 @@ src/x_financial_server.egg-info/SOURCES.txt
|
||||
src/x_financial_server.egg-info/dependency_links.txt
|
||||
src/x_financial_server.egg-info/requires.txt
|
||||
src/x_financial_server.egg-info/top_level.txt
|
||||
tests/test_agent_asset_onlyoffice_key.py
|
||||
tests/test_agent_asset_service.py
|
||||
tests/test_agent_asset_spreadsheet_import.py
|
||||
tests/test_agent_foundation_endpoints.py
|
||||
tests/test_agent_runs_service.py
|
||||
tests/test_auth_service.py
|
||||
tests/test_config_settings_reload.py
|
||||
tests/test_document_intelligence.py
|
||||
@@ -115,12 +122,16 @@ tests/test_employee_service.py
|
||||
tests/test_env_file_precedence.py
|
||||
tests/test_expense_claim_service.py
|
||||
tests/test_imports.py
|
||||
tests/test_knowledge_normalizer.py
|
||||
tests/test_knowledge_onlyoffice_config.py
|
||||
tests/test_knowledge_rag_service.py
|
||||
tests/test_knowledge_service.py
|
||||
tests/test_ocr_endpoints.py
|
||||
tests/test_ocr_service.py
|
||||
tests/test_ontology_service.py
|
||||
tests/test_openapi_schema.py
|
||||
tests/test_reimbursement_endpoints.py
|
||||
tests/test_runtime_chat_service.py
|
||||
tests/test_server_start_dependencies.py
|
||||
tests/test_settings_persistence.py
|
||||
tests/test_settings_service.py
|
||||
|
||||
@@ -8,6 +8,7 @@ pydantic-settings<3.0.0,>=2.6.0
|
||||
python-dotenv<2.0.0,>=1.0.1
|
||||
email-validator<3.0.0,>=2.2.0
|
||||
python-multipart<1.0.0,>=0.0.20
|
||||
openpyxl<4.0.0,>=3.1.5
|
||||
lightrag-hku<1.5.0,>=1.4.16
|
||||
qdrant-client<2.0.0,>=1.18.0
|
||||
|
||||
|
||||
@@ -2,25 +2,46 @@
|
||||
"version": 1,
|
||||
"documents": [
|
||||
{
|
||||
"id": "bf761bd8eccf402bb676423d64401a56",
|
||||
"id": "2c1cb358f08d44ceb0e4d287133206ec",
|
||||
"folder": "报销制度",
|
||||
"original_name": "远光《公司支出管理办法(2024)》.pdf",
|
||||
"stored_name": "bf761bd8eccf402bb676423d64401a56__远光《公司支出管理办法(2024)》.pdf",
|
||||
"stored_name": "2c1cb358f08d44ceb0e4d287133206ec__远光《公司支出管理办法(2024)》.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"extension": "pdf",
|
||||
"size_bytes": 621401,
|
||||
"sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece",
|
||||
"created_at": "2026-05-09T08:39:53.788042+00:00",
|
||||
"updated_at": "2026-05-09T08:39:53.788042+00:00",
|
||||
"created_at": "2026-05-17T09:28:28.999515+00:00",
|
||||
"updated_at": "2026-05-17T09:28:28.999515+00:00",
|
||||
"uploaded_by": "admin",
|
||||
"version_number": 1,
|
||||
"ingest_status": 3,
|
||||
"ingest_status_updated_at": "2026-05-16T15:37:12.723203+00:00",
|
||||
"ingest_agent_run_id": "run_94562b13f7a54341",
|
||||
"ingest_completed_at": "2026-05-16T15:37:12.723203+00:00",
|
||||
"ingest_status_updated_at": "2026-05-17T10:01:33.272539+00:00",
|
||||
"ingest_completed_at": "2026-05-17T10:01:33.272539+00:00",
|
||||
"ingest_document_name": "远光《公司支出管理办法(2024)》.pdf",
|
||||
"ingest_document_updated_at": "2026-05-09T08:39:53.788042+00:00",
|
||||
"ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece"
|
||||
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
|
||||
"ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece",
|
||||
"ingest_agent_run_id": "run_8b0ead1e3c734a53"
|
||||
},
|
||||
{
|
||||
"id": "a8f8465df08e455ebe133351721d49f8",
|
||||
"folder": "报销制度",
|
||||
"original_name": "无单需求文档0506.docx",
|
||||
"stored_name": "a8f8465df08e455ebe133351721d49f8__无单需求文档0506.docx",
|
||||
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"extension": "docx",
|
||||
"size_bytes": 454307,
|
||||
"sha256": "00985ec85a8163be9c9ffc5eb522df18ed52d4b131ceed12102c2d75e4df85a9",
|
||||
"created_at": "2026-05-17T13:00:09.485818+00:00",
|
||||
"updated_at": "2026-05-17T13:00:09.485818+00:00",
|
||||
"uploaded_by": "admin",
|
||||
"version_number": 1,
|
||||
"ingest_status": 1,
|
||||
"ingest_status_updated_at": "2026-05-17T13:00:09.485818+00:00",
|
||||
"ingest_completed_at": "",
|
||||
"ingest_document_name": "",
|
||||
"ingest_document_updated_at": "",
|
||||
"ingest_document_sha256": "",
|
||||
"ingest_agent_run_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,28 @@
|
||||
{
|
||||
"bf761bd8eccf402bb676423d64401a56": {
|
||||
"2c1cb358f08d44ceb0e4d287133206ec": {
|
||||
"status": "processed",
|
||||
"chunks_count": 11,
|
||||
"chunks_count": 10,
|
||||
"chunks_list": [
|
||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339",
|
||||
"chunk-0e8b903e5d2a7deeadd9ec0ca70d964c",
|
||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26",
|
||||
"chunk-60066b4c758ad553106e2343a99c890e",
|
||||
"chunk-30373ec763ee53fb2c91741699128f30",
|
||||
"chunk-2d84cd4e27b2bcd246988dabe93d2062",
|
||||
"chunk-090b225cc6d57e9bf0cf7e0f34b4760c",
|
||||
"chunk-8881e68061e1b668defe35b1cd9d8a83",
|
||||
"chunk-cca4d7b1d51b1e831b80471cd168fef0",
|
||||
"chunk-78998358de8a8cc3c018264c9a553b4d",
|
||||
"chunk-37889c882c89c19f96b9b2ca93685014"
|
||||
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263",
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee",
|
||||
"chunk-061324cc36078214691a6fc1cd0aaeea",
|
||||
"chunk-613d6dfd4c5e9c807229a3147f96b584",
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362",
|
||||
"chunk-e9438f69c9e221d9f0f00a05ad84eac6",
|
||||
"chunk-9841d66d8fb8548aab40220663a51693",
|
||||
"chunk-afc57a0e9548d1f484da6df6c182676b",
|
||||
"chunk-18d968b78afe916b419c1b5973421ebe",
|
||||
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||
],
|
||||
"content_summary": "# 章节导航\n\n以下内容由入库阶段从制度原文中提取,供检索时优先理解制度层级、条目和标准所在章节。\n\n- 第一章 总则.............................................................. 4\n- 第二章 职责分工 .......................................................... 4\n- 第三章 支出报销申请与审批 ................................",
|
||||
"content_length": 25627,
|
||||
"created_at": "2026-05-16T15:30:53.520431+00:00",
|
||||
"updated_at": "2026-05-16T15:37:12.723203+00:00",
|
||||
"file_path": "/app/server/storage/knowledge/报销制度/bf761bd8eccf402bb676423d64401a56__远光《公司支出管理办法(2024)》.pdf",
|
||||
"track_id": "insert_20260516_153053_5bdb18b7",
|
||||
"content_summary": "商密【中】\n\n 远光软件股份有限公司文件\n\n 远光制度〔2024〕14 号\n\n关于颁布《公司支出管理办法(2024)》的\n 通知\n\n公司各部门、分支机构、子公司:\n 为适应公司业务发展需要,优化、完善支出和报销标准,规\n范支出业务审批和报销过程,防范经营风险,依据国家有关法\n律法规,参照国家电网公司和国网数科公司有关管理规定,结\n合市场经营环境和公司实际情况,在广泛征求意见的基础上,\n公司对《公司支出管理办法》进行了修订,现予颁布。本办法自\n颁布之日起...",
|
||||
"content_length": 25301,
|
||||
"created_at": "2026-05-17T09:57:22.410485+00:00",
|
||||
"updated_at": "2026-05-17T10:01:33.272539+00:00",
|
||||
"file_path": "/app/server/storage/knowledge/报销制度/2c1cb358f08d44ceb0e4d287133206ec__远光《公司支出管理办法(2024)》.pdf",
|
||||
"track_id": "insert_20260517_095722_e223c7de",
|
||||
"metadata": {
|
||||
"processing_start_time": 1778945453,
|
||||
"processing_end_time": 1778945832
|
||||
"processing_start_time": 1779011842,
|
||||
"processing_end_time": 1779012093
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,225 +1,268 @@
|
||||
{
|
||||
"bf761bd8eccf402bb676423d64401a56": {
|
||||
"2c1cb358f08d44ceb0e4d287133206ec": {
|
||||
"entity_names": [
|
||||
"公对公结算方式",
|
||||
"业务招待",
|
||||
"营销中心",
|
||||
"预算外支出",
|
||||
"第十四条业务招待费",
|
||||
"材料采购",
|
||||
"对外捐赠支出",
|
||||
"第五章附则",
|
||||
"事业部总经理",
|
||||
"第四章重点支出管理规定",
|
||||
"Home Visit Travel Expenses Management Policy",
|
||||
"组织人事部",
|
||||
"三流一致原则",
|
||||
"预算先行原则",
|
||||
"第五条计划财务部主要职责",
|
||||
"业务招待费",
|
||||
"Long-Term Business Accommodation",
|
||||
"无形资产",
|
||||
"各级管理人员",
|
||||
"第二十三条",
|
||||
"第四条归口管理部门主要职责",
|
||||
"附表3",
|
||||
"各委员会主任",
|
||||
"信息管理部",
|
||||
"经办人",
|
||||
"品牌及市场运营中心",
|
||||
"计划财务部",
|
||||
"Department Head",
|
||||
"业务原始凭据",
|
||||
"经办部门",
|
||||
"系统单据",
|
||||
"保证金",
|
||||
"邮递费",
|
||||
"第十一条",
|
||||
"第二十一条",
|
||||
"分级授权原则",
|
||||
"后勤服务部",
|
||||
"Company Business Travel System",
|
||||
"国家电网公司",
|
||||
"Meeting Expenses",
|
||||
"工会委员会",
|
||||
"特殊事项",
|
||||
"前款不清、后款不借",
|
||||
"Advertising and Promotion Expenses",
|
||||
"广告宣传费",
|
||||
"异地挂职锻炼",
|
||||
"Grassroots Manager P4",
|
||||
"Other Employees",
|
||||
"税控系统明细清单",
|
||||
"商旅订票",
|
||||
"分支机构",
|
||||
"低值易耗品",
|
||||
"产品规划设计部",
|
||||
"培训费",
|
||||
"第一条规定义",
|
||||
"经济舱6折及以下",
|
||||
"三个月",
|
||||
"托运费",
|
||||
"Commercial Insurance",
|
||||
"工会经费管理办法",
|
||||
"增值税专用发票",
|
||||
"第七条各级管理人员主要职责",
|
||||
"Compensation and Benefits Expenses",
|
||||
"外聘专家",
|
||||
"第十二条市内交通费",
|
||||
"分类控制原则",
|
||||
"Value Reimbursement System",
|
||||
"第八条支出报销申请",
|
||||
"会议费",
|
||||
"轮船三等舱",
|
||||
"第四条归口管理",
|
||||
"终审岗",
|
||||
"总经理",
|
||||
"中国外汇交易中心",
|
||||
"Middle And Grassroots Manager P4-P6",
|
||||
"办公用品",
|
||||
"办公室(党委办公室)",
|
||||
"控股子公司",
|
||||
"支出审批权限表",
|
||||
"公司领导",
|
||||
"证券与法律事务部",
|
||||
"支出审批流转程序",
|
||||
"第二条适用范围",
|
||||
"出差补贴标准",
|
||||
"附表2",
|
||||
"高铁/动车二等座",
|
||||
"Business Original Documents",
|
||||
"First Approver",
|
||||
"P8",
|
||||
"报销资料规范",
|
||||
"Staff P1-P3",
|
||||
"2024年4月17日",
|
||||
"董事长",
|
||||
"通信费",
|
||||
"财务审核时限",
|
||||
"一万元",
|
||||
"工会支出",
|
||||
"交通工具等级标准",
|
||||
"第二十条",
|
||||
"影像扫描",
|
||||
"异地调动邮寄费",
|
||||
"第二十二条",
|
||||
"基层经理",
|
||||
"第二十四条附件",
|
||||
"公对私结算方式",
|
||||
"第二十三条本办法的归口与实施",
|
||||
"异地挂职锻炼补贴标准",
|
||||
"公司酒店住宿限额标准",
|
||||
"经办部门(个人)",
|
||||
"一级部门总经理",
|
||||
"组织人事部",
|
||||
"业务原始凭据",
|
||||
"营销中心",
|
||||
"保证金",
|
||||
"投标保证金",
|
||||
"远光制度〔2024〕14号",
|
||||
"Business Travel",
|
||||
"Communication Expenses",
|
||||
"交通费",
|
||||
"远光软件股份有限公司",
|
||||
"第三条管理原则",
|
||||
"全资子公司",
|
||||
"第十三条差旅费",
|
||||
"薪酬福利支出",
|
||||
"Relocation Expenses",
|
||||
"住宿费",
|
||||
"公司支出管理办法(2024)",
|
||||
"中层经理",
|
||||
"第六条经办部门主要职责",
|
||||
"Business Trip",
|
||||
"批办分离原则",
|
||||
"备用金借款",
|
||||
"岗位支出业务",
|
||||
"公司支出管理办法",
|
||||
"第二十四条",
|
||||
"报销业务",
|
||||
"第七条管理人员",
|
||||
"外包分包业务",
|
||||
"归口管理部门",
|
||||
"第十条",
|
||||
"财务审核",
|
||||
"备用金",
|
||||
"预付款项",
|
||||
"支出报销申请",
|
||||
"公司团建管理办法",
|
||||
"总工程师",
|
||||
"商旅系统",
|
||||
"Training Expenses",
|
||||
"固定资产",
|
||||
"DAP研发中心",
|
||||
"第五条计划财务部",
|
||||
"第一条目的",
|
||||
"全列软席列车二等座",
|
||||
"涉外业务汇率标准",
|
||||
"客服及商务",
|
||||
"其他支出",
|
||||
"快递费",
|
||||
"需求计划",
|
||||
"党委办公室",
|
||||
"财务信息化系统",
|
||||
"P5及以上",
|
||||
"因公借款",
|
||||
"效益优先原则",
|
||||
"市内交通",
|
||||
"Business Entertainment Expenses",
|
||||
"第二章职责分工",
|
||||
"出差补贴",
|
||||
"人力资源服务部",
|
||||
"P4及以下",
|
||||
"因公用车补贴",
|
||||
"Company Leader P8 And Above",
|
||||
"国网数科公司",
|
||||
"Mailing and Courier Expenses",
|
||||
"季度清理",
|
||||
"附表1",
|
||||
"正式员工",
|
||||
"审批流转程序",
|
||||
"后续审批人",
|
||||
"商密【中】",
|
||||
"High-Level Manager P7",
|
||||
"Home Visit Travel Expenses",
|
||||
"支出成本中心归属",
|
||||
"供应商",
|
||||
"High-Speed Rail And Bullet Train",
|
||||
"资产采购",
|
||||
"附表1:员工支出报销审批权限表",
|
||||
"第九条支出报销审批",
|
||||
"审批权限",
|
||||
"经济舱5折及以下",
|
||||
"产业投资部",
|
||||
"第六条经办部门",
|
||||
"第十条支出成本中心归属",
|
||||
"第九条",
|
||||
"邮件费",
|
||||
"第四章",
|
||||
"第十三条",
|
||||
"Travel Allowance",
|
||||
"第十一条备用金借款",
|
||||
"财务部门",
|
||||
"公司",
|
||||
"中国银行",
|
||||
"Business Travel Ticket Booking",
|
||||
"市内交通费",
|
||||
"发票",
|
||||
"第十二条",
|
||||
"支出报销审批",
|
||||
"餐补",
|
||||
"第十四条业务招待费",
|
||||
"Chief Engineer",
|
||||
"业务招待",
|
||||
"Employee Welfare",
|
||||
"经济舱",
|
||||
"第一审批人",
|
||||
"品牌",
|
||||
"火车硬席",
|
||||
"审批时限",
|
||||
"2024年4月17日",
|
||||
"三等舱",
|
||||
"财务信息化系统",
|
||||
"分管领导",
|
||||
"重点支出管理规定",
|
||||
"备用金借款",
|
||||
"Financial Review",
|
||||
"第五章附则",
|
||||
"Company Leadership",
|
||||
"第十九条",
|
||||
"经办人",
|
||||
"预算内支出",
|
||||
"President",
|
||||
"Current Account Payment",
|
||||
"Business Entertainment",
|
||||
"Tax Control System Details",
|
||||
"第二十一条",
|
||||
"成本中心归属",
|
||||
"岗位支出报销审批权限表",
|
||||
"工会经费管理办法",
|
||||
"商旅系统",
|
||||
"Special Subsidy",
|
||||
"中国银行外汇折算价",
|
||||
"因公借款",
|
||||
"资产采购",
|
||||
"广告费",
|
||||
"First-Level Department General Manager",
|
||||
"正式员工",
|
||||
"一万元",
|
||||
"公司员工教育培训管理办法",
|
||||
"责任原则",
|
||||
"第二章职责分工",
|
||||
"预算先行",
|
||||
"Planning and Finance Department",
|
||||
"Accommodation Cost Reimbursement",
|
||||
"Official Vehicle Subsidy",
|
||||
"第四条归口管理部门主要职责",
|
||||
"Personal Service Compensation",
|
||||
"邮递费",
|
||||
"附表3:支出归口管理部门与归口业务范围",
|
||||
"员工",
|
||||
"第二条目的",
|
||||
"Director",
|
||||
"支出归口管理部门与归口业务范围",
|
||||
"其他支出(员工)",
|
||||
"报销标准",
|
||||
"5000000 Yuan Approval Limit",
|
||||
"第十一条备用金借款",
|
||||
"会议费",
|
||||
"第十七条",
|
||||
"第七条各级管理人员主要职责",
|
||||
"50000 Yuan Approval Limit",
|
||||
"全资子公司",
|
||||
"涉外业务汇率标准",
|
||||
"总监",
|
||||
"第十三条差旅费",
|
||||
"审批权限表",
|
||||
"商旅订票规范",
|
||||
"Final Approval Position",
|
||||
"报销资格",
|
||||
"新增报销规定",
|
||||
"公司支出管理办法",
|
||||
"Institution General Manager",
|
||||
"房屋租金",
|
||||
"Staff Activities",
|
||||
"分包外包(内部单位)",
|
||||
"报销申请时限",
|
||||
"Financial Information System",
|
||||
"Expenditure Authorization Approval Scope",
|
||||
"直辖市",
|
||||
"培训费",
|
||||
"第十二条市内交通费",
|
||||
"第十五条",
|
||||
"终审岗",
|
||||
"Remote Work Housing",
|
||||
"Centralized Management department",
|
||||
"第二十条",
|
||||
"办公室(党委办公室)",
|
||||
"Three Flows Consistency Principle",
|
||||
"审批权限",
|
||||
"VAT Special Invoice",
|
||||
"后勤服务部",
|
||||
"员工支出报销审批权限表",
|
||||
"公司总裁",
|
||||
"出差补贴",
|
||||
"Basic Level Managers",
|
||||
"预付款项",
|
||||
"附表1:员工支出报销审批权限表",
|
||||
"经办部门",
|
||||
"信息管理部",
|
||||
"通信费",
|
||||
"第十六条",
|
||||
"增值税发票",
|
||||
"财务入账条件",
|
||||
"Hotel Accommodation Standards",
|
||||
"审批流转程序",
|
||||
"Self-Driving Travel Provisions",
|
||||
"交通费",
|
||||
"第九条支出报销审批",
|
||||
"薪酬福利支出分配计划",
|
||||
"产品规划设计部",
|
||||
"因公用车补贴",
|
||||
"Committee Chairpersons",
|
||||
"Business Division General Manager",
|
||||
"组织安排",
|
||||
"1 Yuan Per Person Per Kilometer Reimbursement",
|
||||
"Separation of Approval and Processing Principle",
|
||||
"第五条计划财务部主要职责",
|
||||
"200000 Yuan Approval Limit",
|
||||
"公司各部门",
|
||||
"第十四条",
|
||||
"Other Areas",
|
||||
"分支机构",
|
||||
"Departments And Units",
|
||||
"计划财务部",
|
||||
"Other Employees",
|
||||
"第二十三条",
|
||||
"公司团建管理办法",
|
||||
"火车硬席",
|
||||
"税控系统明细清单",
|
||||
"Trade Union Fund",
|
||||
"报销标准变化情况",
|
||||
"薪酬福利支出",
|
||||
"Hong Kong, Macau, And Taiwan Region",
|
||||
"对外捐赠支出",
|
||||
"Multi-Level Approval Rule",
|
||||
"Three Working Days Deadline",
|
||||
"Employee Remuneration",
|
||||
"销售退款",
|
||||
"股权投资、兼并收购",
|
||||
"控股子公司",
|
||||
"取消报销规定",
|
||||
"Procurement Management Regulations",
|
||||
"Middle Managers",
|
||||
"差旅费",
|
||||
"高层经理",
|
||||
"厉行节约原则",
|
||||
"第三章支出报销申请与审批",
|
||||
"批办分离",
|
||||
"住宿费",
|
||||
"Travel Allowance Standards",
|
||||
"第二十三条本办法的归口与实施",
|
||||
"Senior Vice President",
|
||||
"供应商",
|
||||
"人事归口管理部门",
|
||||
"Management Personnel At All Levels",
|
||||
"效益优先",
|
||||
"Operating Department Individual",
|
||||
"Remote Work Housing Rental Expenses",
|
||||
"取消报销规定内容",
|
||||
"Company",
|
||||
"修订说明",
|
||||
"国网数科公司",
|
||||
"Vice President",
|
||||
"分级授权",
|
||||
"Expenditure Reimbursement Application",
|
||||
"第二十四条附件",
|
||||
"第二十二条",
|
||||
"出租车",
|
||||
"Night High-Speed Rail Provision",
|
||||
"各级管理人员",
|
||||
"受益原则",
|
||||
"公司员工因公通讯费用实施细则",
|
||||
"公司支出管理办法(2024)",
|
||||
"出差补贴标准",
|
||||
"Bid Security Deposit Approval Limits Table",
|
||||
"第二条范围",
|
||||
"基建工程",
|
||||
"Company Property Rental Management",
|
||||
"调动工作",
|
||||
"远光软件股份有限公司",
|
||||
"市内交通费",
|
||||
"交通工具等级标准",
|
||||
"Operator",
|
||||
"第八条支出报销申请",
|
||||
"Directly-Controlled Municipalities And Special Administrative Regions",
|
||||
"出差规定",
|
||||
"业务招待费",
|
||||
"Senior Managers",
|
||||
"逐级审批规则",
|
||||
"招标采购规定",
|
||||
"第一章总则"
|
||||
"Company Business Travel System",
|
||||
"广告宣传费",
|
||||
"Transportation Cost Reimbursement",
|
||||
"财务",
|
||||
"第一章总则",
|
||||
"材料采购",
|
||||
"人力资源服务部",
|
||||
"证券与法律事务部",
|
||||
"Transportation Level Standards",
|
||||
"归口管理部门",
|
||||
"商旅客服",
|
||||
"第四章重点支出管理规定",
|
||||
"出差审批程序",
|
||||
"Business Trip Approval",
|
||||
"西藏",
|
||||
"附表2:岗位支出报销审批权限表",
|
||||
"第十八条",
|
||||
"第二十四条",
|
||||
"Company Hotel Accommodation Limit Standards",
|
||||
"办法",
|
||||
"DAP研发中心",
|
||||
"新增规定内容",
|
||||
"基本补助",
|
||||
"Travel Allowance",
|
||||
"异地挂职锻炼补贴标准",
|
||||
"部门负责人",
|
||||
"Provincial Capitals",
|
||||
"特区",
|
||||
"Transportation Tickets",
|
||||
"第三章支出报销申请与审批",
|
||||
"品牌及市场运营中心",
|
||||
"分包外包(外部单位)",
|
||||
"探亲路费",
|
||||
"President",
|
||||
"凭据报销",
|
||||
"基本出差补贴",
|
||||
"Taxi Usage Regulations",
|
||||
"Government Fees",
|
||||
"Commercial Travel System",
|
||||
"远光制度〔2024〕14号",
|
||||
"审批权限变化情况",
|
||||
"基建工程",
|
||||
"支出报销申请与审批",
|
||||
"中国外汇交易中心参考汇率",
|
||||
"Department Manager",
|
||||
"支出报销审批",
|
||||
"预算调整决策程序",
|
||||
"公司1号文",
|
||||
"External Conference Accommodation",
|
||||
"厉行节约",
|
||||
"Commercial Insurance",
|
||||
"公司",
|
||||
"第三条管理原则",
|
||||
"捐赠申请",
|
||||
"分类控制",
|
||||
"业务宣传费",
|
||||
"产业投资部",
|
||||
"公司员工探亲管理办法",
|
||||
"Subsequent Approver",
|
||||
"100000 Yuan Approval Limit",
|
||||
"Tax Authority Recognized Invoice",
|
||||
"国家电网公司",
|
||||
"业务佐证材料",
|
||||
"第六条经办部门(个人)主要职责",
|
||||
"结算起点",
|
||||
"第十条支出成本中心归属",
|
||||
"母公司"
|
||||
],
|
||||
"count": 215,
|
||||
"create_time": 1778945832,
|
||||
"update_time": 1778945832,
|
||||
"_id": "bf761bd8eccf402bb676423d64401a56"
|
||||
"count": 258,
|
||||
"create_time": 1779012093,
|
||||
"update_time": 1779012093,
|
||||
"_id": "2c1cb358f08d44ceb0e4d287133206ec"
|
||||
}
|
||||
}
|
||||
@@ -1,182 +1,166 @@
|
||||
{
|
||||
"bf761bd8eccf402bb676423d64401a56": {
|
||||
"2c1cb358f08d44ceb0e4d287133206ec": {
|
||||
"relation_pairs": [
|
||||
[
|
||||
"公司支出管理办法",
|
||||
"审批流转程序"
|
||||
"Departments And Units",
|
||||
"Taxi Usage Regulations"
|
||||
],
|
||||
[
|
||||
"供应商",
|
||||
"公司"
|
||||
"取消报销规定内容",
|
||||
"报销标准变化情况"
|
||||
],
|
||||
[
|
||||
"全资子公司",
|
||||
"远光软件股份有限公司"
|
||||
"业务招待费",
|
||||
"第十四条"
|
||||
],
|
||||
[
|
||||
"发票",
|
||||
"报销业务"
|
||||
],
|
||||
[
|
||||
"出差补贴",
|
||||
"组织人事部"
|
||||
],
|
||||
[
|
||||
"第十三条差旅费",
|
||||
"第四章重点支出管理规定"
|
||||
],
|
||||
[
|
||||
"公司支出管理办法",
|
||||
"差旅费"
|
||||
],
|
||||
[
|
||||
"第一章总则",
|
||||
"远光软件股份有限公司"
|
||||
],
|
||||
[
|
||||
"计划财务部",
|
||||
"远光软件股份有限公司"
|
||||
],
|
||||
[
|
||||
"分支机构",
|
||||
"远光软件股份有限公司"
|
||||
],
|
||||
[
|
||||
"经办部门",
|
||||
"需求计划"
|
||||
],
|
||||
[
|
||||
"增值税专用发票",
|
||||
"税控系统明细清单"
|
||||
],
|
||||
[
|
||||
"第三章支出报销申请与审批",
|
||||
"财务信息化系统"
|
||||
],
|
||||
[
|
||||
"业务原始凭据",
|
||||
"经办人"
|
||||
],
|
||||
[
|
||||
"第十四条业务招待费",
|
||||
"第四章重点支出管理规定"
|
||||
],
|
||||
[
|
||||
"系统单据",
|
||||
"经办人"
|
||||
],
|
||||
[
|
||||
"第二十三条本办法的归口与实施",
|
||||
"第五章附则"
|
||||
],
|
||||
[
|
||||
"第二十四条",
|
||||
"附表2"
|
||||
],
|
||||
[
|
||||
"第二十三条",
|
||||
"控股子公司",
|
||||
"计划财务部"
|
||||
],
|
||||
[
|
||||
"归口管理部门",
|
||||
"报销业务"
|
||||
],
|
||||
[
|
||||
"公司支出管理办法",
|
||||
"投标保证金"
|
||||
"工会委员会"
|
||||
],
|
||||
[
|
||||
"对外捐赠支出",
|
||||
"第二十一条"
|
||||
"第一章总则",
|
||||
"第三条管理原则"
|
||||
],
|
||||
[
|
||||
"第二十条",
|
||||
"薪酬福利支出"
|
||||
"广告宣传费",
|
||||
"第十六条"
|
||||
],
|
||||
[
|
||||
"国网数科公司",
|
||||
"Tax Control System Details",
|
||||
"VAT Special Invoice"
|
||||
],
|
||||
[
|
||||
"Expenditure Reimbursement Application",
|
||||
"Tax Authority Recognized Invoice"
|
||||
],
|
||||
[
|
||||
"远光制度〔2024〕14号",
|
||||
"远光软件股份有限公司"
|
||||
],
|
||||
[
|
||||
"事业部总经理",
|
||||
"逐级审批规则"
|
||||
"Financial Review",
|
||||
"Operator"
|
||||
],
|
||||
[
|
||||
"特殊事项",
|
||||
"终审岗"
|
||||
"Operating Department Individual",
|
||||
"Procurement Management Regulations"
|
||||
],
|
||||
[
|
||||
"发票",
|
||||
"经办人"
|
||||
"会议费",
|
||||
"第十五条"
|
||||
],
|
||||
[
|
||||
"Company",
|
||||
"Management Personnel At All Levels"
|
||||
],
|
||||
[
|
||||
"公司",
|
||||
"第十七条"
|
||||
],
|
||||
[
|
||||
"公司",
|
||||
"第十八条"
|
||||
],
|
||||
[
|
||||
"Operator",
|
||||
"Three Working Days Deadline"
|
||||
],
|
||||
[
|
||||
"第十一条备用金借款",
|
||||
"第四章重点支出管理规定"
|
||||
],
|
||||
[
|
||||
"归口管理部门",
|
||||
"Expenditure Reimbursement Application",
|
||||
"Operator"
|
||||
],
|
||||
[
|
||||
"业务招待费",
|
||||
"差旅费"
|
||||
],
|
||||
[
|
||||
"公司",
|
||||
"第二十一条"
|
||||
],
|
||||
[
|
||||
"公司支出管理办法(2024)",
|
||||
"远光软件股份有限公司"
|
||||
],
|
||||
[
|
||||
"第四条归口管理部门主要职责",
|
||||
"计划财务部"
|
||||
],
|
||||
[
|
||||
"工会委员会",
|
||||
"工会支出"
|
||||
"会议费",
|
||||
"差旅费"
|
||||
],
|
||||
[
|
||||
"第二十四条附件",
|
||||
"第五章附则"
|
||||
"Company",
|
||||
"Operating Department Individual"
|
||||
],
|
||||
[
|
||||
"涉外业务汇率标准",
|
||||
"第二十二条"
|
||||
"商旅系统",
|
||||
"差旅费"
|
||||
],
|
||||
[
|
||||
"各级管理人员",
|
||||
"支出报销审批"
|
||||
"会议费",
|
||||
"公司总裁"
|
||||
],
|
||||
[
|
||||
"第十二条市内交通费",
|
||||
"第四章重点支出管理规定"
|
||||
],
|
||||
[
|
||||
"支出审批流转程序",
|
||||
"逐级审批规则"
|
||||
],
|
||||
[
|
||||
"支出审批流转程序",
|
||||
"终审岗"
|
||||
],
|
||||
[
|
||||
"各级管理人员",
|
||||
"报销业务"
|
||||
],
|
||||
[
|
||||
"归口管理部门",
|
||||
"计划财务部",
|
||||
"远光软件股份有限公司"
|
||||
],
|
||||
[
|
||||
"控股子公司",
|
||||
"公司",
|
||||
"第十九条"
|
||||
],
|
||||
[
|
||||
"公司",
|
||||
"第二十条"
|
||||
],
|
||||
[
|
||||
"Company",
|
||||
"Planning and Finance Department"
|
||||
],
|
||||
[
|
||||
"公司支出管理办法",
|
||||
"营销中心"
|
||||
],
|
||||
[
|
||||
"Business Original Documents",
|
||||
"Operator"
|
||||
],
|
||||
[
|
||||
"公司支出管理办法",
|
||||
"办公室(党委办公室)"
|
||||
],
|
||||
[
|
||||
"Departments And Units",
|
||||
"Night High-Speed Rail Provision"
|
||||
],
|
||||
[
|
||||
"Centralized Management department",
|
||||
"Company"
|
||||
],
|
||||
[
|
||||
"组织人事部",
|
||||
"调动工作"
|
||||
],
|
||||
[
|
||||
"报销标准变化情况",
|
||||
"远光软件股份有限公司"
|
||||
],
|
||||
[
|
||||
"报销业务",
|
||||
"财务部门"
|
||||
],
|
||||
[
|
||||
"报销业务",
|
||||
"经办部门"
|
||||
],
|
||||
[
|
||||
"国家电网公司",
|
||||
"第一章总则",
|
||||
"远光软件股份有限公司"
|
||||
],
|
||||
[
|
||||
"第二十四条",
|
||||
"附表1"
|
||||
]
|
||||
],
|
||||
"count": 43,
|
||||
"create_time": 1778945832,
|
||||
"update_time": 1778945832,
|
||||
"_id": "bf761bd8eccf402bb676423d64401a56"
|
||||
"count": 39,
|
||||
"create_time": 1779012093,
|
||||
"update_time": 1779012093,
|
||||
"_id": "2c1cb358f08d44ceb0e4d287133206ec"
|
||||
}
|
||||
}
|
||||
@@ -1,389 +1,353 @@
|
||||
{
|
||||
"第一章总则<SEP>远光软件股份有限公司": {
|
||||
"chunk_ids": [
|
||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339"
|
||||
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945805,
|
||||
"update_time": 1778945805,
|
||||
"create_time": 1779012088,
|
||||
"update_time": 1779012088,
|
||||
"_id": "第一章总则<SEP>远光软件股份有限公司"
|
||||
},
|
||||
"第三章支出报销申请与审批<SEP>财务信息化系统": {
|
||||
"chunk_ids": [
|
||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945805,
|
||||
"update_time": 1778945805,
|
||||
"_id": "第三章支出报销申请与审批<SEP>财务信息化系统"
|
||||
},
|
||||
"第十一条备用金借款<SEP>第四章重点支出管理规定": {
|
||||
"chunk_ids": [
|
||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339"
|
||||
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945805,
|
||||
"update_time": 1778945805,
|
||||
"create_time": 1779012088,
|
||||
"update_time": 1779012088,
|
||||
"_id": "第十一条备用金借款<SEP>第四章重点支出管理规定"
|
||||
},
|
||||
"第二十三条本办法的归口与实施<SEP>第五章附则": {
|
||||
"公司支出管理办法<SEP>办公室(党委办公室)": {
|
||||
"chunk_ids": [
|
||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339"
|
||||
"chunk-afc57a0e9548d1f484da6df6c182676b"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945805,
|
||||
"update_time": 1778945805,
|
||||
"_id": "第二十三条本办法的归口与实施<SEP>第五章附则"
|
||||
},
|
||||
"第十二条市内交通费<SEP>第四章重点支出管理规定": {
|
||||
"chunk_ids": [
|
||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945808,
|
||||
"update_time": 1778945808,
|
||||
"_id": "第十二条市内交通费<SEP>第四章重点支出管理规定"
|
||||
},
|
||||
"归口管理部门<SEP>报销业务": {
|
||||
"chunk_ids": [
|
||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945808,
|
||||
"update_time": 1778945808,
|
||||
"_id": "归口管理部门<SEP>报销业务"
|
||||
},
|
||||
"第十三条差旅费<SEP>第四章重点支出管理规定": {
|
||||
"chunk_ids": [
|
||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945808,
|
||||
"update_time": 1778945808,
|
||||
"_id": "第十三条差旅费<SEP>第四章重点支出管理规定"
|
||||
},
|
||||
"第二十四条附件<SEP>第五章附则": {
|
||||
"chunk_ids": [
|
||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945809,
|
||||
"update_time": 1778945809,
|
||||
"_id": "第二十四条附件<SEP>第五章附则"
|
||||
},
|
||||
"业务原始凭据<SEP>经办人": {
|
||||
"chunk_ids": [
|
||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945809,
|
||||
"update_time": 1778945809,
|
||||
"_id": "业务原始凭据<SEP>经办人"
|
||||
},
|
||||
"报销业务<SEP>财务部门": {
|
||||
"chunk_ids": [
|
||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945812,
|
||||
"update_time": 1778945812,
|
||||
"_id": "报销业务<SEP>财务部门"
|
||||
},
|
||||
"增值税专用发票<SEP>税控系统明细清单": {
|
||||
"chunk_ids": [
|
||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945812,
|
||||
"update_time": 1778945812,
|
||||
"_id": "增值税专用发票<SEP>税控系统明细清单"
|
||||
},
|
||||
"第十四条业务招待费<SEP>第四章重点支出管理规定": {
|
||||
"chunk_ids": [
|
||||
"chunk-a28dc5c0a449bfa3ec07f3ea70720339"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945812,
|
||||
"update_time": 1778945812,
|
||||
"_id": "第十四条业务招待费<SEP>第四章重点支出管理规定"
|
||||
},
|
||||
"经办部门<SEP>需求计划": {
|
||||
"chunk_ids": [
|
||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945812,
|
||||
"update_time": 1778945812,
|
||||
"_id": "经办部门<SEP>需求计划"
|
||||
},
|
||||
"系统单据<SEP>经办人": {
|
||||
"chunk_ids": [
|
||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945812,
|
||||
"update_time": 1778945812,
|
||||
"_id": "系统单据<SEP>经办人"
|
||||
},
|
||||
"报销业务<SEP>经办部门": {
|
||||
"chunk_ids": [
|
||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945812,
|
||||
"update_time": 1778945812,
|
||||
"_id": "报销业务<SEP>经办部门"
|
||||
},
|
||||
"供应商<SEP>公司": {
|
||||
"chunk_ids": [
|
||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945812,
|
||||
"update_time": 1778945812,
|
||||
"_id": "供应商<SEP>公司"
|
||||
},
|
||||
"工会委员会<SEP>工会支出": {
|
||||
"chunk_ids": [
|
||||
"chunk-78998358de8a8cc3c018264c9a553b4d"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945812,
|
||||
"update_time": 1778945812,
|
||||
"_id": "工会委员会<SEP>工会支出"
|
||||
},
|
||||
"出差补贴<SEP>组织人事部": {
|
||||
"chunk_ids": [
|
||||
"chunk-78998358de8a8cc3c018264c9a553b4d"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945812,
|
||||
"update_time": 1778945812,
|
||||
"_id": "出差补贴<SEP>组织人事部"
|
||||
},
|
||||
"公司支出管理办法<SEP>差旅费": {
|
||||
"chunk_ids": [
|
||||
"chunk-78998358de8a8cc3c018264c9a553b4d"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945812,
|
||||
"update_time": 1778945812,
|
||||
"_id": "公司支出管理办法<SEP>差旅费"
|
||||
},
|
||||
"各级管理人员<SEP>报销业务": {
|
||||
"chunk_ids": [
|
||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945813,
|
||||
"update_time": 1778945813,
|
||||
"_id": "各级管理人员<SEP>报销业务"
|
||||
},
|
||||
"公司支出管理办法<SEP>投标保证金": {
|
||||
"chunk_ids": [
|
||||
"chunk-78998358de8a8cc3c018264c9a553b4d"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945815,
|
||||
"update_time": 1778945815,
|
||||
"_id": "公司支出管理办法<SEP>投标保证金"
|
||||
},
|
||||
"第二十条<SEP>薪酬福利支出": {
|
||||
"chunk_ids": [
|
||||
"chunk-8881e68061e1b668defe35b1cd9d8a83"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945815,
|
||||
"update_time": 1778945815,
|
||||
"_id": "第二十条<SEP>薪酬福利支出"
|
||||
},
|
||||
"对外捐赠支出<SEP>第二十一条": {
|
||||
"chunk_ids": [
|
||||
"chunk-8881e68061e1b668defe35b1cd9d8a83"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945815,
|
||||
"update_time": 1778945815,
|
||||
"_id": "对外捐赠支出<SEP>第二十一条"
|
||||
},
|
||||
"涉外业务汇率标准<SEP>第二十二条": {
|
||||
"chunk_ids": [
|
||||
"chunk-8881e68061e1b668defe35b1cd9d8a83"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945827,
|
||||
"update_time": 1778945827,
|
||||
"_id": "涉外业务汇率标准<SEP>第二十二条"
|
||||
},
|
||||
"第二十三条<SEP>计划财务部": {
|
||||
"chunk_ids": [
|
||||
"chunk-8881e68061e1b668defe35b1cd9d8a83"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945815,
|
||||
"update_time": 1778945815,
|
||||
"_id": "第二十三条<SEP>计划财务部"
|
||||
},
|
||||
"第二十四条<SEP>附表1": {
|
||||
"chunk_ids": [
|
||||
"chunk-8881e68061e1b668defe35b1cd9d8a83"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945816,
|
||||
"update_time": 1778945816,
|
||||
"_id": "第二十四条<SEP>附表1"
|
||||
},
|
||||
"第二十四条<SEP>附表2": {
|
||||
"chunk_ids": [
|
||||
"chunk-8881e68061e1b668defe35b1cd9d8a83"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945816,
|
||||
"update_time": 1778945816,
|
||||
"_id": "第二十四条<SEP>附表2"
|
||||
},
|
||||
"发票<SEP>报销业务": {
|
||||
"chunk_ids": [
|
||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945816,
|
||||
"update_time": 1778945816,
|
||||
"_id": "发票<SEP>报销业务"
|
||||
},
|
||||
"支出审批流转程序<SEP>逐级审批规则": {
|
||||
"chunk_ids": [
|
||||
"chunk-37889c882c89c19f96b9b2ca93685014"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945816,
|
||||
"update_time": 1778945816,
|
||||
"_id": "支出审批流转程序<SEP>逐级审批规则"
|
||||
},
|
||||
"公司支出管理办法<SEP>审批流转程序": {
|
||||
"chunk_ids": [
|
||||
"chunk-78998358de8a8cc3c018264c9a553b4d"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945820,
|
||||
"update_time": 1778945820,
|
||||
"_id": "公司支出管理办法<SEP>审批流转程序"
|
||||
},
|
||||
"特殊事项<SEP>终审岗": {
|
||||
"chunk_ids": [
|
||||
"chunk-37889c882c89c19f96b9b2ca93685014"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945820,
|
||||
"update_time": 1778945820,
|
||||
"_id": "特殊事项<SEP>终审岗"
|
||||
},
|
||||
"事业部总经理<SEP>逐级审批规则": {
|
||||
"chunk_ids": [
|
||||
"chunk-37889c882c89c19f96b9b2ca93685014"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945821,
|
||||
"update_time": 1778945821,
|
||||
"_id": "事业部总经理<SEP>逐级审批规则"
|
||||
"create_time": 1779012088,
|
||||
"update_time": 1779012088,
|
||||
"_id": "公司支出管理办法<SEP>办公室(党委办公室)"
|
||||
},
|
||||
"计划财务部<SEP>远光软件股份有限公司": {
|
||||
"chunk_ids": [
|
||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
||||
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945821,
|
||||
"update_time": 1778945821,
|
||||
"create_time": 1779012076,
|
||||
"update_time": 1779012076,
|
||||
"_id": "计划财务部<SEP>远光软件股份有限公司"
|
||||
},
|
||||
"发票<SEP>经办人": {
|
||||
"第一章总则<SEP>第三条管理原则": {
|
||||
"chunk_ids": [
|
||||
"chunk-60066b4c758ad553106e2343a99c890e"
|
||||
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945822,
|
||||
"update_time": 1778945822,
|
||||
"_id": "发票<SEP>经办人"
|
||||
"create_time": 1779012076,
|
||||
"update_time": 1779012076,
|
||||
"_id": "第一章总则<SEP>第三条管理原则"
|
||||
},
|
||||
"支出审批流转程序<SEP>终审岗": {
|
||||
"Company<SEP>Management Personnel At All Levels": {
|
||||
"chunk_ids": [
|
||||
"chunk-37889c882c89c19f96b9b2ca93685014"
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945823,
|
||||
"update_time": 1778945823,
|
||||
"_id": "支出审批流转程序<SEP>终审岗"
|
||||
"create_time": 1779012076,
|
||||
"update_time": 1779012076,
|
||||
"_id": "Company<SEP>Management Personnel At All Levels"
|
||||
},
|
||||
"归口管理部门<SEP>远光软件股份有限公司": {
|
||||
"Centralized Management department<SEP>Company": {
|
||||
"chunk_ids": [
|
||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945824,
|
||||
"update_time": 1778945824,
|
||||
"_id": "归口管理部门<SEP>远光软件股份有限公司"
|
||||
"create_time": 1779012077,
|
||||
"update_time": 1779012077,
|
||||
"_id": "Centralized Management department<SEP>Company"
|
||||
},
|
||||
"国家电网公司<SEP>远光软件股份有限公司": {
|
||||
"Company<SEP>Planning and Finance Department": {
|
||||
"chunk_ids": [
|
||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945824,
|
||||
"update_time": 1778945824,
|
||||
"_id": "国家电网公司<SEP>远光软件股份有限公司"
|
||||
"create_time": 1779012077,
|
||||
"update_time": 1779012077,
|
||||
"_id": "Company<SEP>Planning and Finance Department"
|
||||
},
|
||||
"归口管理部门<SEP>计划财务部": {
|
||||
"Company<SEP>Operating Department Individual": {
|
||||
"chunk_ids": [
|
||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945824,
|
||||
"update_time": 1778945824,
|
||||
"_id": "归口管理部门<SEP>计划财务部"
|
||||
"create_time": 1779012078,
|
||||
"update_time": 1779012078,
|
||||
"_id": "Company<SEP>Operating Department Individual"
|
||||
},
|
||||
"各级管理人员<SEP>支出报销审批": {
|
||||
"公司支出管理办法<SEP>工会委员会": {
|
||||
"chunk_ids": [
|
||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
||||
"chunk-afc57a0e9548d1f484da6df6c182676b"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945824,
|
||||
"update_time": 1778945824,
|
||||
"_id": "各级管理人员<SEP>支出报销审批"
|
||||
"create_time": 1779012079,
|
||||
"update_time": 1779012079,
|
||||
"_id": "公司支出管理办法<SEP>工会委员会"
|
||||
},
|
||||
"国网数科公司<SEP>远光软件股份有限公司": {
|
||||
"Expenditure Reimbursement Application<SEP>Operator": {
|
||||
"chunk_ids": [
|
||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945828,
|
||||
"update_time": 1778945828,
|
||||
"_id": "国网数科公司<SEP>远光软件股份有限公司"
|
||||
"create_time": 1779012079,
|
||||
"update_time": 1779012079,
|
||||
"_id": "Expenditure Reimbursement Application<SEP>Operator"
|
||||
},
|
||||
"分支机构<SEP>远光软件股份有限公司": {
|
||||
"公司支出管理办法<SEP>营销中心": {
|
||||
"chunk_ids": [
|
||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
||||
"chunk-afc57a0e9548d1f484da6df6c182676b"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945831,
|
||||
"update_time": 1778945831,
|
||||
"_id": "分支机构<SEP>远光软件股份有限公司"
|
||||
"create_time": 1779012079,
|
||||
"update_time": 1779012079,
|
||||
"_id": "公司支出管理办法<SEP>营销中心"
|
||||
},
|
||||
"全资子公司<SEP>远光软件股份有限公司": {
|
||||
"第四条归口管理部门主要职责<SEP>计划财务部": {
|
||||
"chunk_ids": [
|
||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
||||
"chunk-aa5435156b829944c173fa1d2d7a93d4"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945832,
|
||||
"update_time": 1778945832,
|
||||
"_id": "全资子公司<SEP>远光软件股份有限公司"
|
||||
"create_time": 1779012079,
|
||||
"update_time": 1779012079,
|
||||
"_id": "第四条归口管理部门主要职责<SEP>计划财务部"
|
||||
},
|
||||
"控股子公司<SEP>远光软件股份有限公司": {
|
||||
"Tax Control System Details<SEP>VAT Special Invoice": {
|
||||
"chunk_ids": [
|
||||
"chunk-16edf05e3f89da28ca60c9b8e3101d26"
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1778945832,
|
||||
"update_time": 1778945832,
|
||||
"_id": "控股子公司<SEP>远光软件股份有限公司"
|
||||
"create_time": 1779012079,
|
||||
"update_time": 1779012079,
|
||||
"_id": "Tax Control System Details<SEP>VAT Special Invoice"
|
||||
},
|
||||
"Operating Department Individual<SEP>Procurement Management Regulations": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012081,
|
||||
"update_time": 1779012081,
|
||||
"_id": "Operating Department Individual<SEP>Procurement Management Regulations"
|
||||
},
|
||||
"Business Original Documents<SEP>Operator": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012094,
|
||||
"update_time": 1779012094,
|
||||
"_id": "Business Original Documents<SEP>Operator"
|
||||
},
|
||||
"Expenditure Reimbursement Application<SEP>Tax Authority Recognized Invoice": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012094,
|
||||
"update_time": 1779012094,
|
||||
"_id": "Expenditure Reimbursement Application<SEP>Tax Authority Recognized Invoice"
|
||||
},
|
||||
"公司<SEP>第十七条": {
|
||||
"chunk_ids": [
|
||||
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012094,
|
||||
"update_time": 1779012094,
|
||||
"_id": "公司<SEP>第十七条"
|
||||
},
|
||||
"Operator<SEP>Three Working Days Deadline": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012083,
|
||||
"update_time": 1779012083,
|
||||
"_id": "Operator<SEP>Three Working Days Deadline"
|
||||
},
|
||||
"Departments And Units<SEP>Night High-Speed Rail Provision": {
|
||||
"chunk_ids": [
|
||||
"chunk-613d6dfd4c5e9c807229a3147f96b584"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012084,
|
||||
"update_time": 1779012084,
|
||||
"_id": "Departments And Units<SEP>Night High-Speed Rail Provision"
|
||||
},
|
||||
"公司<SEP>第十八条": {
|
||||
"chunk_ids": [
|
||||
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012084,
|
||||
"update_time": 1779012084,
|
||||
"_id": "公司<SEP>第十八条"
|
||||
},
|
||||
"公司<SEP>第十九条": {
|
||||
"chunk_ids": [
|
||||
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012084,
|
||||
"update_time": 1779012084,
|
||||
"_id": "公司<SEP>第十九条"
|
||||
},
|
||||
"报销标准变化情况<SEP>远光软件股份有限公司": {
|
||||
"chunk_ids": [
|
||||
"chunk-18d968b78afe916b419c1b5973421ebe"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012084,
|
||||
"update_time": 1779012084,
|
||||
"_id": "报销标准变化情况<SEP>远光软件股份有限公司"
|
||||
},
|
||||
"取消报销规定内容<SEP>报销标准变化情况": {
|
||||
"chunk_ids": [
|
||||
"chunk-18d968b78afe916b419c1b5973421ebe"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012085,
|
||||
"update_time": 1779012085,
|
||||
"_id": "取消报销规定内容<SEP>报销标准变化情况"
|
||||
},
|
||||
"Financial Review<SEP>Operator": {
|
||||
"chunk_ids": [
|
||||
"chunk-74c01decac4a10cd40a491786743b0ee"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012085,
|
||||
"update_time": 1779012085,
|
||||
"_id": "Financial Review<SEP>Operator"
|
||||
},
|
||||
"公司支出管理办法(2024)<SEP>远光软件股份有限公司": {
|
||||
"chunk_ids": [
|
||||
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012085,
|
||||
"update_time": 1779012085,
|
||||
"_id": "公司支出管理办法(2024)<SEP>远光软件股份有限公司"
|
||||
},
|
||||
"远光制度〔2024〕14号<SEP>远光软件股份有限公司": {
|
||||
"chunk_ids": [
|
||||
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012086,
|
||||
"update_time": 1779012086,
|
||||
"_id": "远光制度〔2024〕14号<SEP>远光软件股份有限公司"
|
||||
},
|
||||
"Departments And Units<SEP>Taxi Usage Regulations": {
|
||||
"chunk_ids": [
|
||||
"chunk-613d6dfd4c5e9c807229a3147f96b584"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012099,
|
||||
"update_time": 1779012099,
|
||||
"_id": "Departments And Units<SEP>Taxi Usage Regulations"
|
||||
},
|
||||
"控股子公司<SEP>计划财务部": {
|
||||
"chunk_ids": [
|
||||
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012099,
|
||||
"update_time": 1779012099,
|
||||
"_id": "控股子公司<SEP>计划财务部"
|
||||
},
|
||||
"公司<SEP>第二十条": {
|
||||
"chunk_ids": [
|
||||
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012086,
|
||||
"update_time": 1779012086,
|
||||
"_id": "公司<SEP>第二十条"
|
||||
},
|
||||
"商旅系统<SEP>差旅费": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012086,
|
||||
"update_time": 1779012086,
|
||||
"_id": "商旅系统<SEP>差旅费"
|
||||
},
|
||||
"业务招待费<SEP>差旅费": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012089,
|
||||
"update_time": 1779012089,
|
||||
"_id": "业务招待费<SEP>差旅费"
|
||||
},
|
||||
"公司<SEP>第二十一条": {
|
||||
"chunk_ids": [
|
||||
"chunk-e9438f69c9e221d9f0f00a05ad84eac6"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012089,
|
||||
"update_time": 1779012089,
|
||||
"_id": "公司<SEP>第二十一条"
|
||||
},
|
||||
"广告宣传费<SEP>第十六条": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012089,
|
||||
"update_time": 1779012089,
|
||||
"_id": "广告宣传费<SEP>第十六条"
|
||||
},
|
||||
"组织人事部<SEP>调动工作": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012090,
|
||||
"update_time": 1779012090,
|
||||
"_id": "组织人事部<SEP>调动工作"
|
||||
},
|
||||
"会议费<SEP>差旅费": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012092,
|
||||
"update_time": 1779012092,
|
||||
"_id": "会议费<SEP>差旅费"
|
||||
},
|
||||
"业务招待费<SEP>第十四条": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012092,
|
||||
"update_time": 1779012092,
|
||||
"_id": "业务招待费<SEP>第十四条"
|
||||
},
|
||||
"会议费<SEP>第十五条": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012092,
|
||||
"update_time": 1779012092,
|
||||
"_id": "会议费<SEP>第十五条"
|
||||
},
|
||||
"会议费<SEP>公司总裁": {
|
||||
"chunk_ids": [
|
||||
"chunk-d26b288ed4001dc5c504dce0eb841362"
|
||||
],
|
||||
"count": 1,
|
||||
"create_time": 1779012093,
|
||||
"update_time": 1779012093,
|
||||
"_id": "会议费<SEP>公司总裁"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
23
server/tests/test_agent_asset_onlyoffice_key.py
Normal file
23
server/tests/test_agent_asset_onlyoffice_key.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from app.services.agent_asset_spreadsheet import RuleSpreadsheetMeta
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
|
||||
|
||||
def test_rule_spreadsheet_onlyoffice_key_uses_safe_characters() -> None:
|
||||
metadata = RuleSpreadsheetMeta(
|
||||
file_name="公司差旅费报销规则.xlsx",
|
||||
storage_key="rules/finance-rules/公司差旅费报销规则.xlsx",
|
||||
mime_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
size_bytes=1,
|
||||
checksum="abc123",
|
||||
updated_at="2026-05-17T14:32:00+00:00",
|
||||
updated_by="system",
|
||||
)
|
||||
|
||||
key = AgentAssetService._build_onlyoffice_document_key(
|
||||
"asset:id",
|
||||
"v1.0.0",
|
||||
metadata,
|
||||
)
|
||||
|
||||
assert key == "asset_id-v1.0.0-abc123"
|
||||
assert ":" not in key
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
@@ -26,6 +28,7 @@ from app.schemas.agent_asset import (
|
||||
from app.services.agent_assets import AgentAssetService
|
||||
from app.services.agent_runs import AgentRunService
|
||||
from app.services.audit import AuditLogService
|
||||
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
@@ -39,6 +42,17 @@ def build_session() -> Session:
|
||||
return session_factory()
|
||||
|
||||
|
||||
def build_workbook_bytes(rows: list[list[object]], *, sheet_name: str = "规则表") -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = sheet_name
|
||||
for row in rows:
|
||||
sheet.append(row)
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def test_agent_asset_service_seeds_assets_and_enforces_review_before_activation() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentAssetService(db)
|
||||
@@ -120,10 +134,218 @@ def test_agent_asset_service_can_activate_rule_after_review() -> None:
|
||||
|
||||
assert activated.status == AgentAssetStatus.ACTIVE.value
|
||||
assert activated.current_version == "v1.0.0"
|
||||
assert activated.working_version == "v1.0.0"
|
||||
assert activated.published_version == "v1.0.0"
|
||||
assert activated.latest_review is not None
|
||||
assert activated.latest_review.review_status == AgentReviewStatus.APPROVED.value
|
||||
|
||||
|
||||
def test_rule_working_version_does_not_replace_published_version_until_activation() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentAssetService(db)
|
||||
rule = next(
|
||||
item
|
||||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
if item.code == "rule.expense.travel_risk_control_standard"
|
||||
)
|
||||
|
||||
created = service.create_version(
|
||||
rule.id,
|
||||
AgentAssetVersionCreate(
|
||||
version="v1.1.1",
|
||||
content="# 差旅报销风险管控制度\n\n- 工作稿",
|
||||
content_type=AgentAssetContentType.MARKDOWN,
|
||||
change_note="新增工作稿",
|
||||
created_by="finance_user",
|
||||
),
|
||||
actor="finance_user",
|
||||
)
|
||||
detail = service.get_asset(rule.id)
|
||||
|
||||
assert created.is_working is True
|
||||
assert created.is_published is False
|
||||
assert created.lifecycle_state == "draft"
|
||||
assert detail is not None
|
||||
assert detail.status == AgentAssetStatus.ACTIVE.value
|
||||
assert detail.current_version == "v1.1.1"
|
||||
assert detail.working_version == "v1.1.1"
|
||||
assert detail.published_version == "v1.1.0"
|
||||
assert detail.latest_review is None
|
||||
|
||||
|
||||
def test_expense_rule_runtime_uses_published_version_instead_of_working_version() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentAssetService(db)
|
||||
rule = next(
|
||||
item
|
||||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
if item.code == "rule.expense.travel_risk_control_standard"
|
||||
)
|
||||
|
||||
service.create_version(
|
||||
rule.id,
|
||||
AgentAssetVersionCreate(
|
||||
version="v1.1.1",
|
||||
content="# 工作稿\n\n```expense-rule\n{\"kind\":\"travel_policy\",\"version\":1}\n```",
|
||||
content_type=AgentAssetContentType.MARKDOWN,
|
||||
change_note="未上线草稿",
|
||||
created_by="finance_user",
|
||||
),
|
||||
actor="finance_user",
|
||||
)
|
||||
|
||||
catalog = ExpenseRuleRuntimeService(db).load_catalog()
|
||||
|
||||
assert catalog.travel_policy is not None
|
||||
assert catalog.travel_policy.rule_version == "v1.1.0"
|
||||
|
||||
|
||||
def test_restore_version_creates_new_working_copy_without_rewriting_published_version() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentAssetService(db)
|
||||
rule = next(
|
||||
item
|
||||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
if item.code == "rule.expense.travel_risk_control_standard"
|
||||
)
|
||||
|
||||
restored = service.restore_version_as_working_copy(
|
||||
rule.id,
|
||||
"v1.0.0",
|
||||
actor="manager_user",
|
||||
)
|
||||
|
||||
assert restored.working_version == "v1.1.1"
|
||||
assert restored.current_version == "v1.1.1"
|
||||
assert restored.published_version == "v1.1.0"
|
||||
assert restored.current_version_change_note == "基于历史版本 v1.0.0 恢复生成工作稿"
|
||||
|
||||
|
||||
def test_spreadsheet_version_compare_returns_sheet_and_cell_changes() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentAssetService(db)
|
||||
rule = next(
|
||||
item
|
||||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
if item.code == "rule.expense.company_travel_expense_reimbursement"
|
||||
)
|
||||
|
||||
service.upload_rule_spreadsheet(
|
||||
rule.id,
|
||||
filename="公司差旅费报销规则.xlsx",
|
||||
content=build_workbook_bytes([["城市", "住宿"], ["北京", 500]]),
|
||||
actor="finance_user",
|
||||
)
|
||||
base_version = service.get_asset(rule.id).working_version # type: ignore[union-attr]
|
||||
service.upload_rule_spreadsheet(
|
||||
rule.id,
|
||||
filename="公司差旅费报销规则.xlsx",
|
||||
content=build_workbook_bytes([["城市", "住宿"], ["北京", 550], ["武汉", 450]]),
|
||||
actor="finance_user",
|
||||
)
|
||||
target_version = service.get_asset(rule.id).working_version # type: ignore[union-attr]
|
||||
|
||||
diff = service.compare_spreadsheet_versions(
|
||||
rule.id,
|
||||
base_version=base_version or "",
|
||||
target_version=target_version or "",
|
||||
)
|
||||
|
||||
assert diff.changed_sheet_count == 1
|
||||
assert diff.changed_cell_count == 3
|
||||
assert any(item.cell == "B2" and item.change_type == "modified" for item in diff.cell_changes)
|
||||
assert any(item.cell == "A3" and item.change_type == "added" for item in diff.cell_changes)
|
||||
|
||||
|
||||
def test_working_spreadsheet_version_reads_immutable_snapshot_instead_of_live_copy() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentAssetService(db)
|
||||
rule = next(
|
||||
item
|
||||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
if item.code == "rule.expense.company_travel_expense_reimbursement"
|
||||
)
|
||||
|
||||
service.upload_rule_spreadsheet(
|
||||
rule.id,
|
||||
filename="公司差旅费报销规则.xlsx",
|
||||
content=build_workbook_bytes([["城市", "住宿"], ["北京", 500]]),
|
||||
actor="finance_user",
|
||||
)
|
||||
detail = service.get_asset(rule.id)
|
||||
assert detail is not None
|
||||
working_version = detail.working_version or ""
|
||||
|
||||
current_asset = service.repository.get(rule.id)
|
||||
assert current_asset is not None
|
||||
live_storage_key = str((current_asset.config_json or {})["rule_document"]["storage_key"])
|
||||
live_path = service.spreadsheet_manager.resolve_storage_path(live_storage_key)
|
||||
original_live_bytes = live_path.read_bytes()
|
||||
try:
|
||||
live_path.write_bytes(build_workbook_bytes([["城市", "住宿"], ["北京", 999]]))
|
||||
|
||||
snapshot_path, _, _ = service.get_rule_spreadsheet_content(
|
||||
rule.id,
|
||||
version=working_version,
|
||||
)
|
||||
|
||||
assert snapshot_path != live_path
|
||||
workbook = load_workbook(snapshot_path, data_only=False)
|
||||
assert workbook.active["B2"].value == 500
|
||||
finally:
|
||||
live_path.write_bytes(original_live_bytes)
|
||||
|
||||
|
||||
def test_version_timeline_contains_created_review_and_publish_events() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentAssetService(db)
|
||||
rule = next(
|
||||
item
|
||||
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
|
||||
if item.code == "rule.expense.travel_risk_control_standard"
|
||||
)
|
||||
service.create_version(
|
||||
rule.id,
|
||||
AgentAssetVersionCreate(
|
||||
version="v1.1.1",
|
||||
content="# 工作稿",
|
||||
content_type=AgentAssetContentType.MARKDOWN,
|
||||
change_note="补充口径",
|
||||
created_by="finance_user",
|
||||
),
|
||||
actor="finance_user",
|
||||
)
|
||||
service.create_review(
|
||||
rule.id,
|
||||
AgentAssetReviewCreate(
|
||||
version="v1.1.1",
|
||||
reviewer="finance_user",
|
||||
review_status=AgentReviewStatus.PENDING,
|
||||
review_note="请审核",
|
||||
),
|
||||
actor="finance_user",
|
||||
)
|
||||
service.create_review(
|
||||
rule.id,
|
||||
AgentAssetReviewCreate(
|
||||
version="v1.1.1",
|
||||
reviewer="manager_user",
|
||||
review_status=AgentReviewStatus.APPROVED,
|
||||
review_note="可以上线",
|
||||
),
|
||||
actor="manager_user",
|
||||
)
|
||||
service.activate_asset(rule.id, actor="manager_user")
|
||||
|
||||
timeline = service.list_version_timeline(rule.id)
|
||||
event_types = [item.event_type for item in timeline if item.version == "v1.1.1"]
|
||||
|
||||
assert "created" in event_types
|
||||
assert "submitted" in event_types
|
||||
assert "approved" in event_types
|
||||
assert "published" in event_types
|
||||
|
||||
|
||||
def test_agent_asset_service_returns_recent_versions_for_rule_detail() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentAssetService(db)
|
||||
|
||||
41
server/tests/test_agent_asset_spreadsheet_import.py
Normal file
41
server/tests/test_agent_asset_spreadsheet_import.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
|
||||
from app.services.agent_asset_spreadsheet import AgentAssetSpreadsheetManager
|
||||
|
||||
|
||||
def test_rebuild_from_uploaded_content_preserves_sheet_values() -> None:
|
||||
source = Workbook()
|
||||
first = source.active
|
||||
first.title = "差旅标准"
|
||||
first.append(["城市", "住宿费"])
|
||||
first.append(["北京", 500])
|
||||
second = source.create_sheet("补贴标准")
|
||||
second.append(["区域", "餐补"])
|
||||
second.append(["直辖市", 75])
|
||||
|
||||
source_buffer = BytesIO()
|
||||
source.save(source_buffer)
|
||||
|
||||
rebuilt = AgentAssetSpreadsheetManager.rebuild_from_uploaded_content(
|
||||
source_buffer.getvalue()
|
||||
)
|
||||
workbook = load_workbook(BytesIO(rebuilt), data_only=False)
|
||||
|
||||
assert workbook.sheetnames == ["差旅标准", "补贴标准"]
|
||||
assert workbook["差旅标准"]["A2"].value == "北京"
|
||||
assert workbook["差旅标准"]["B2"].value == "500"
|
||||
assert workbook["补贴标准"]["A2"].value == "直辖市"
|
||||
assert workbook["补贴标准"]["B2"].value == "75"
|
||||
|
||||
|
||||
def build_workbook_bytes(rows: list[list[object]], *, sheet_name: str = "规则表") -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = sheet_name
|
||||
for row in rows:
|
||||
sheet.append(row)
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
85
server/tests/test_agent_runs_service.py
Normal file
85
server/tests/test_agent_runs_service.py
Normal file
@@ -0,0 +1,85 @@
|
||||
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.core.agent_enums import AgentName, AgentRunSource, AgentRunStatus, AgentToolType
|
||||
from app.db.base import Base
|
||||
from app.services.agent_runs import AgentRunService
|
||||
|
||||
|
||||
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_agent_run_service_marks_stale_knowledge_sync_run_failed_on_read() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentRunService(db)
|
||||
created = service.create_run(
|
||||
agent=AgentName.HERMES.value,
|
||||
source=AgentRunSource.USER_MESSAGE.value,
|
||||
status=AgentRunStatus.RUNNING.value,
|
||||
route_json={
|
||||
"job_type": "knowledge_index_sync",
|
||||
"heartbeat_at": (datetime.now(UTC) - timedelta(minutes=31)).isoformat(),
|
||||
"requested_document_ids": [],
|
||||
},
|
||||
)
|
||||
|
||||
fetched = service.get_run(created.run_id)
|
||||
running_runs = service.list_runs(
|
||||
agent=AgentName.HERMES.value,
|
||||
status=AgentRunStatus.RUNNING.value,
|
||||
limit=100,
|
||||
)
|
||||
|
||||
assert fetched is not None
|
||||
assert fetched.status == AgentRunStatus.FAILED.value
|
||||
assert fetched.error_message == "Knowledge index heartbeat timed out."
|
||||
assert all(item.run_id != created.run_id for item in running_runs)
|
||||
|
||||
|
||||
def test_agent_run_service_updates_existing_tool_call() -> None:
|
||||
with build_session() as db:
|
||||
service = AgentRunService(db)
|
||||
run = service.create_run(
|
||||
agent=AgentName.HERMES.value,
|
||||
source=AgentRunSource.USER_MESSAGE.value,
|
||||
status=AgentRunStatus.RUNNING.value,
|
||||
route_json={"job_type": "knowledge_index_sync"},
|
||||
)
|
||||
tool_call = 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={"phase": "indexing"},
|
||||
status="running",
|
||||
duration_ms=0,
|
||||
)
|
||||
|
||||
updated = service.update_tool_call(
|
||||
tool_call.id,
|
||||
response_json={"track_id": "insert_123"},
|
||||
status="succeeded",
|
||||
duration_ms=1250,
|
||||
error_message=None,
|
||||
)
|
||||
fetched = service.get_run(run.run_id)
|
||||
|
||||
assert updated.status == "succeeded"
|
||||
assert updated.duration_ms == 1250
|
||||
assert fetched is not None
|
||||
assert len(fetched.tool_calls) == 1
|
||||
assert fetched.tool_calls[0].status == "succeeded"
|
||||
assert fetched.tool_calls[0].response_json == {"track_id": "insert_123"}
|
||||
@@ -44,9 +44,9 @@ def test_knowledge_normalizer_appends_structured_table(monkeypatch) -> None:
|
||||
|
||||
enriched = service.build_enriched_text(raw_text)
|
||||
|
||||
assert enriched.startswith("# 结构化表格补充")
|
||||
assert enriched.startswith(raw_text.strip())
|
||||
assert "| 餐补 | 75 | 65 | 55 | 140 |" in enriched
|
||||
assert enriched.endswith(raw_text.strip())
|
||||
assert enriched.endswith("| 合计 | 110 | 100 | 90 | 175 |")
|
||||
|
||||
|
||||
def test_knowledge_normalizer_keeps_only_markdown_table_body() -> None:
|
||||
@@ -79,12 +79,12 @@ def test_knowledge_normalizer_builds_section_navigation_without_table() -> None:
|
||||
service = KnowledgeNormalizationService(db)
|
||||
enriched = service.build_enriched_text(raw_text)
|
||||
|
||||
assert enriched.startswith("# 章节导航")
|
||||
assert enriched.startswith(raw_text.strip())
|
||||
assert "- 第一章 总则" in enriched
|
||||
assert "## 第二章 住宿费标准" in enriched
|
||||
assert "# 问答线索补充" in enriched
|
||||
assert "- 第二章 住宿费标准:住宿费按照出差城市档位和职级标准执行" in enriched
|
||||
assert enriched.endswith(raw_text.strip())
|
||||
assert "# 章节导航" in enriched
|
||||
|
||||
|
||||
def test_knowledge_normalizer_builds_answer_clues_from_lists_and_kv_lines() -> None:
|
||||
|
||||
@@ -74,6 +74,28 @@ def test_build_hits_prioritizes_answer_clue_appendix_for_rule_queries() -> None:
|
||||
assert [item["candidate_id"] for item in hits] == ["clue-1", "plain-1"]
|
||||
|
||||
|
||||
def test_build_hits_demotes_chapter_navigation_for_specific_rule_queries() -> None:
|
||||
hits = KnowledgeRagService._build_hits_from_query_data(
|
||||
query="探亲差旅归哪个部门管理?",
|
||||
chunks=[
|
||||
{
|
||||
"chunk_id": "toc-1",
|
||||
"file_path": "/tmp/doc-1__费用制度.md",
|
||||
"content": "# 章节导航\n\n- 第一章 总则\n- 第二章 职责分工\n- 第三章 支出归口",
|
||||
},
|
||||
{
|
||||
"chunk_id": "body-1",
|
||||
"file_path": "/tmp/doc-1__费用制度.md",
|
||||
"content": "附表3:支出归口管理部门与归口业务范围\n组织人事部:探亲差旅、条件艰苦及安全风险较高区域补助等支出。",
|
||||
},
|
||||
],
|
||||
entities=[],
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert [item["candidate_id"] for item in hits] == ["body-1", "toc-1"]
|
||||
|
||||
|
||||
def test_resolve_default_qdrant_url_prefers_container_host(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
knowledge_rag_module.socket,
|
||||
@@ -93,3 +115,29 @@ def test_resolve_default_qdrant_url_falls_back_to_loopback(monkeypatch) -> None:
|
||||
monkeypatch.setattr(knowledge_rag_module.socket, "getaddrinfo", raise_lookup_error)
|
||||
|
||||
assert knowledge_rag_module._resolve_default_qdrant_url() == "http://127.0.0.1:6333"
|
||||
|
||||
|
||||
def test_is_query_ready_status_rejects_failed_status_even_with_chunks() -> None:
|
||||
assert (
|
||||
KnowledgeRagService.is_query_ready_status(
|
||||
{
|
||||
"status": "failed",
|
||||
"chunks_count": 11,
|
||||
"chunks_list": ["chunk-1"],
|
||||
}
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_is_query_ready_status_rejects_processing_status_even_with_chunks() -> None:
|
||||
assert (
|
||||
KnowledgeRagService.is_query_ready_status(
|
||||
{
|
||||
"status": "processing",
|
||||
"chunks_count": 11,
|
||||
"chunks_list": ["chunk-1"],
|
||||
}
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
81
server/tests/test_knowledge_service.py
Normal file
81
server/tests/test_knowledge_service.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
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 AgentName, AgentRunSource, AgentRunStatus
|
||||
from app.db.base import Base
|
||||
from app.services.agent_runs import AgentRunService
|
||||
from app.services.knowledge import (
|
||||
KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||
KNOWLEDGE_INGEST_STATUS_SYNCING,
|
||||
KnowledgeService,
|
||||
)
|
||||
|
||||
|
||||
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_reconcile_document_ingest_status_keeps_failed_when_linked_run_failed(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
with build_session() as db:
|
||||
service = KnowledgeService(storage_root=tmp_path, db=db)
|
||||
uploaded = service.upload_document(
|
||||
"报销制度",
|
||||
"demo.txt",
|
||||
b"hello",
|
||||
CurrentUserContext(
|
||||
username="admin",
|
||||
name="管理员",
|
||||
role_codes=["manager"],
|
||||
is_admin=True,
|
||||
),
|
||||
)
|
||||
|
||||
run = AgentRunService(db).create_run(
|
||||
agent=AgentName.HERMES.value,
|
||||
source=AgentRunSource.USER_MESSAGE.value,
|
||||
status=AgentRunStatus.FAILED.value,
|
||||
route_json={"job_type": "knowledge_index_sync"},
|
||||
)
|
||||
service.set_document_ingest_statuses(
|
||||
[uploaded.id],
|
||||
KNOWLEDGE_INGEST_STATUS_SYNCING,
|
||||
agent_run_id=run.run_id,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.knowledge_rag.KnowledgeRagService.get_document_status_map",
|
||||
lambda self, _document_ids: {
|
||||
uploaded.id: {
|
||||
"status": "processing",
|
||||
"query_ready": False,
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
index = service._load_index()
|
||||
changed = service._reconcile_document_ingest_statuses(
|
||||
index,
|
||||
document_ids=[uploaded.id],
|
||||
preserve_syncing=False,
|
||||
)
|
||||
|
||||
entry = next(item for item in index["documents"] if item["id"] == uploaded.id)
|
||||
assert changed is True
|
||||
assert entry["ingest_status"] == KNOWLEDGE_INGEST_STATUS_FAILED
|
||||
@@ -202,21 +202,37 @@ def test_user_agent_knowledge_answer_generation_uses_fast_timeouts(monkeypatch)
|
||||
assert captured["max_attempts"] == 1
|
||||
|
||||
|
||||
def test_user_agent_prefers_structured_knowledge_hit_for_answer_generation() -> None:
|
||||
def test_user_agent_prefers_structured_table_hit_for_standard_query() -> None:
|
||||
selected = UserAgentService._select_knowledge_model_hits(
|
||||
{
|
||||
"hits": [
|
||||
{"content": "raw hit 1"},
|
||||
{"content": "raw hit 2"},
|
||||
{"content": "# 问答线索补充\n\n- 第二章 报销时限:费用发生后 30 日内提交申请。"},
|
||||
{"content": "# 结构化表格补充\n\n| 项目 | 金额 |"},
|
||||
{"content": "# 结构化表格补充\n\n| 项目 | 餐补 |\n| 其他地区 | 55 |"},
|
||||
]
|
||||
}
|
||||
},
|
||||
question="餐补标准是多少?",
|
||||
)
|
||||
|
||||
assert selected[0]["content"].startswith("# 问答线索补充")
|
||||
assert selected[1]["content"].startswith("# 结构化表格补充")
|
||||
assert selected[2]["content"] == "raw hit 1"
|
||||
assert selected[0]["content"].startswith("# 结构化表格补充")
|
||||
assert any(item["content"].startswith("# 结构化表格补充") for item in selected[:2])
|
||||
|
||||
|
||||
def test_user_agent_prefers_relevant_raw_hit_over_generic_appendix() -> None:
|
||||
selected = UserAgentService._select_knowledge_model_hits(
|
||||
{
|
||||
"hits": [
|
||||
{"content": "# 章节导航\n\n- 第一章 总则\n- 第二章 职责分工"},
|
||||
{"content": "# 问答线索补充\n\n- 第二章 职责分工:计划财务部负责财务审核。"},
|
||||
{"content": "一般性说明文字,没有探亲差旅归口信息。"},
|
||||
{"content": "附表3:支出归口管理部门与归口业务范围\n组织人事部:探亲差旅、条件艰苦及安全风险较高区域补助等支出。"},
|
||||
]
|
||||
},
|
||||
question="探亲差旅归哪个部门管理?",
|
||||
)
|
||||
|
||||
assert "组织人事部" in selected[0]["content"]
|
||||
|
||||
|
||||
def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> None:
|
||||
@@ -266,6 +282,7 @@ def test_user_agent_uses_fast_knowledge_answer_without_model(monkeypatch) -> Non
|
||||
assert response.answer.startswith("张三,您好。")
|
||||
assert "当前能直接确认的是" in response.answer
|
||||
assert "30 日内提交报销申请" in response.answer
|
||||
assert "## 依据" not in response.answer
|
||||
assert "答案整理阶段本轮没有及时返回" not in response.answer
|
||||
|
||||
|
||||
@@ -314,6 +331,7 @@ def test_user_agent_fast_knowledge_answer_renders_relevant_table_preview() -> No
|
||||
assert answer is not None
|
||||
assert "| 项目 | 港澳台 | 其他地区 | 国外 |" in answer
|
||||
assert "| 餐补 | 75 | 55 | 140 |" in answer
|
||||
assert "## 依据" not in answer
|
||||
|
||||
|
||||
def test_user_agent_fast_knowledge_answer_notes_missing_location_grounding() -> None:
|
||||
@@ -360,6 +378,57 @@ def test_user_agent_fast_knowledge_answer_notes_missing_location_grounding() ->
|
||||
|
||||
assert answer is not None
|
||||
assert "没有直接写出“北京”对应的地区档位或映射关系" in answer
|
||||
assert "## 依据" not in answer
|
||||
|
||||
|
||||
def test_user_agent_fast_knowledge_answer_expands_lead_in_list_items() -> None:
|
||||
session_factory = build_session_factory()
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query="出差记录链条中断时,要提供哪些业务佐证材料?",
|
||||
user_id="pytest",
|
||||
context_json={"session_type": "knowledge"},
|
||||
)
|
||||
)
|
||||
service = UserAgentService(db)
|
||||
|
||||
answer = service._build_fast_knowledge_answer(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest",
|
||||
message="出差记录链条中断时,要提供哪些业务佐证材料?",
|
||||
ontology=ontology,
|
||||
context_json={
|
||||
"session_type": "knowledge",
|
||||
"user_input_text": "出差记录链条中断时,要提供哪些业务佐证材料?",
|
||||
},
|
||||
tool_payload={
|
||||
"result_type": "knowledge_search",
|
||||
"hits": [
|
||||
{
|
||||
"title": "费用报销制度",
|
||||
"content": (
|
||||
"第十三条 差旅费\n\n"
|
||||
"(2)出差记录链条中断时,应提供业务佐证材料:\n"
|
||||
"① 登机牌、高速道路通行记录、其他道路通行记录、租车记录等。\n"
|
||||
"② 支付记录。\n"
|
||||
"③ 出差审批邮件、短信、微信等。"
|
||||
),
|
||||
}
|
||||
],
|
||||
},
|
||||
),
|
||||
citations=[],
|
||||
)
|
||||
|
||||
assert answer is not None
|
||||
assert "当前能直接确认的是" in answer
|
||||
assert "登机牌、高速道路通行记录" in answer
|
||||
assert "支付记录" in answer
|
||||
assert "出差审批邮件、短信、微信等" in answer
|
||||
assert "(3)" not in answer
|
||||
assert "## 依据" not in answer
|
||||
|
||||
|
||||
def test_user_agent_model_prompt_supports_contextual_personalization() -> None:
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.skill-detail.spreadsheet-skill-detail {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.skill-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -474,6 +478,7 @@ tbody tr.spotlight {
|
||||
}
|
||||
|
||||
.skill-avatar.emerald { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
.skill-avatar.rose { background: linear-gradient(135deg, #f43f5e, #e11d48); }
|
||||
.skill-avatar.violet { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
||||
.skill-avatar.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
||||
.skill-avatar.amber { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
@@ -557,6 +562,13 @@ tbody tr.spotlight {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.spreadsheet-skill-detail .detail-scroll {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
align-content: stretch;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.detail-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
@@ -581,6 +593,7 @@ tbody tr.spotlight {
|
||||
}
|
||||
|
||||
.skill-badge.emerald { background: linear-gradient(135deg, #10b981, #059669); }
|
||||
.skill-badge.rose { background: linear-gradient(135deg, #f43f5e, #e11d48); }
|
||||
.skill-badge.violet { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
|
||||
.skill-badge.blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
||||
.skill-badge.amber { background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
@@ -813,6 +826,832 @@ tbody tr.spotlight {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.spreadsheet-rule-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.spreadsheet-editor-shell {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.spreadsheet-editor-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.spreadsheet-editor-title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.spreadsheet-editor-title h2 {
|
||||
color: #0f172a;
|
||||
font-size: 18px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.spreadsheet-editor-title p {
|
||||
margin-top: 2px;
|
||||
max-width: 760px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.spreadsheet-editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.spreadsheet-editor-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.spreadsheet-editor-meta span {
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.spreadsheet-editor-meta strong {
|
||||
color: #0f172a;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.spreadsheet-editor-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 286px;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.spreadsheet-main-stage {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.spreadsheet-workbench {
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
.spreadsheet-workbench .rule-spreadsheet-host {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.spreadsheet-editor-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.spreadsheet-version-center {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.version-center-head h3,
|
||||
.version-center-head p,
|
||||
.version-center-section header,
|
||||
.version-center-section p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.version-center-head h3 {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.version-center-head p {
|
||||
margin-top: 3px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.version-pair-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-pair-card {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-height: 84px;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.version-pair-card span {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.version-pair-card strong {
|
||||
color: #0f172a;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.version-pair-card b {
|
||||
width: fit-content;
|
||||
min-height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.version-pair-card.published {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.version-pair-card.published b {
|
||||
background: #dcfce7;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.version-pair-card.working {
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
.version-pair-card.working b {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.version-center-section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-history-section {
|
||||
min-height: 0;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.version-center-section > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-center-section > header strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.version-center-section > header small,
|
||||
.version-center-section > header button {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.version-center-section > header button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.version-center-list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.version-center-item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.version-center-item.active {
|
||||
border-color: rgba(16, 185, 129, 0.35);
|
||||
background: rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
|
||||
.version-center-item > button {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.version-center-item > button div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-center-item > button strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.version-center-item > button span,
|
||||
.version-center-item > button p {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.version-center-item > button p {
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.version-center-item footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.version-center-item footer button {
|
||||
min-height: 24px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.version-center-item footer button:nth-child(2) {
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.version-center-item footer button:nth-child(3) {
|
||||
background: #fff7ed;
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.version-flow-preview {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-flow-preview article {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.version-flow-preview i {
|
||||
color: #2563eb;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.version-flow-preview div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.version-flow-preview strong {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.version-flow-preview span,
|
||||
.version-flow-preview small,
|
||||
.version-flow-empty {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.version-flow-preview small {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.rule-drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background: rgba(15, 23, 42, 0.34);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.rule-drawer {
|
||||
width: min(860px, 78vw);
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
padding: 22px;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
box-shadow: -18px 0 42px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.timeline-drawer {
|
||||
width: min(640px, 62vw);
|
||||
}
|
||||
|
||||
.rule-drawer-head {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.rule-drawer-head span {
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.rule-drawer-head h3 {
|
||||
margin: 4px 0 0;
|
||||
color: #0f172a;
|
||||
font-size: 20px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.rule-drawer-head button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rule-drawer-state {
|
||||
min-height: 160px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 10px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.rule-drawer-state.error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.rule-timeline-list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.rule-timeline-item {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.rule-timeline-item:not(:last-child)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 28px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.rule-timeline-item > i {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.rule-timeline-item > i.success {
|
||||
background: #dcfce7;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.rule-timeline-item > i.warning {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.rule-timeline-item > i.danger {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.rule-timeline-item > i.info {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.rule-timeline-item header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rule-timeline-item header strong {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rule-timeline-item header b {
|
||||
min-height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rule-timeline-item header span,
|
||||
.rule-timeline-item p,
|
||||
.rule-timeline-item small {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.rule-timeline-item p {
|
||||
margin: 8px 0 4px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.compare-toolbar {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.compare-toolbar label {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.compare-toolbar span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.compare-toolbar select {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.compare-toolbar i {
|
||||
margin-bottom: 10px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.compare-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.compare-summary-grid article {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-height: 76px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.compare-summary-grid span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.compare-summary-grid strong {
|
||||
color: #0f172a;
|
||||
font-size: 24px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.compare-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.compare-panel header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.compare-panel header strong {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.compare-panel p,
|
||||
.compare-panel small {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.compare-sheet-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.compare-sheet-list article {
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.compare-sheet-list strong {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.compare-sheet-list b {
|
||||
min-height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.compare-sheet-list b.success {
|
||||
background: #dcfce7;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.compare-sheet-list b.danger {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.compare-table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.compare-table-wrap table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.compare-table-wrap th,
|
||||
.compare-table-wrap td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.compare-table-wrap th {
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.compare-table-wrap td {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.compare-table-wrap td b {
|
||||
min-height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.compare-table-wrap td b.success {
|
||||
background: #dcfce7;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.compare-table-wrap td b.warning {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.compare-table-wrap td b.danger {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.spreadsheet-editor-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.rule-drawer,
|
||||
.timeline-drawer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.compare-summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.rule-spreadsheet-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.spreadsheet-mode-pill {
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.spreadsheet-upload-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spreadsheet-meta-strip {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.spreadsheet-meta-strip span {
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.spreadsheet-meta-strip strong {
|
||||
color: #0f172a;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.rule-spreadsheet-stage {
|
||||
position: relative;
|
||||
min-height: 720px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
}
|
||||
|
||||
.rule-spreadsheet-host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 720px;
|
||||
}
|
||||
|
||||
.rule-spreadsheet-host.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.rule-spreadsheet-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
background: rgba(248, 250, 252, 0.94);
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rule-spreadsheet-state i {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.rule-spreadsheet-state.error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.preview-mode-note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(14, 165, 233, 0.22);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, rgba(240, 249, 255, 0.96), rgba(224, 242, 254, 0.9));
|
||||
color: #075985;
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.preview-mode-note i {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.markdown-card .field {
|
||||
min-height: 0;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
@@ -958,7 +1797,6 @@ tbody tr.spotlight {
|
||||
border-left: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 180ms ease;
|
||||
}
|
||||
|
||||
@@ -976,6 +1814,17 @@ tbody tr.spotlight {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
}
|
||||
|
||||
.version-main {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.version-row-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(52px, 1fr) 46px 82px;
|
||||
@@ -1017,12 +1866,76 @@ tbody tr.spotlight {
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.version-current-slot .current-version.working {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.version-row p {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.version-row-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-state {
|
||||
min-height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.version-state.success {
|
||||
background: #dcfce7;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.version-state.warning {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.version-state.danger {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.version-state.draft {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.version-state.disabled {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.version-restore-btn {
|
||||
min-height: 24px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.version-restore-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.empty-side-note {
|
||||
min-height: 120px;
|
||||
display: grid;
|
||||
@@ -1392,6 +2305,11 @@ tbody tr.spotlight {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.rule-spreadsheet-stage,
|
||||
.rule-spreadsheet-host {
|
||||
min-height: 620px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -282,12 +282,34 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.summary-meta {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.trace-cell {
|
||||
max-width: 180px;
|
||||
color: #2563eb;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.status-stack {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.status-note {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.system-table .summary-cell {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ function buildQuery(params = {}) {
|
||||
search.set('limit', String(params.limit))
|
||||
}
|
||||
|
||||
if (params.version) {
|
||||
search.set('version', String(params.version))
|
||||
}
|
||||
|
||||
if (params.agent) {
|
||||
search.set('agent', params.agent)
|
||||
}
|
||||
@@ -80,10 +84,66 @@ export function fetchAgentAssetDetail(assetId) {
|
||||
return apiRequest(`/agent-assets/${assetId}`)
|
||||
}
|
||||
|
||||
export function fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId, version = '') {
|
||||
const query = buildQuery({ version })
|
||||
return apiRequest(`/agent-assets/${assetId}/spreadsheet/onlyoffice-config${query}`)
|
||||
}
|
||||
|
||||
export function fetchAgentAssetSpreadsheetBlob(assetId, version = '', disposition = 'inline') {
|
||||
const search = new URLSearchParams()
|
||||
if (version) {
|
||||
search.set('version', String(version).trim())
|
||||
}
|
||||
if (disposition) {
|
||||
search.set('disposition', String(disposition).trim())
|
||||
}
|
||||
const query = search.toString()
|
||||
return apiRequest(`/agent-assets/${assetId}/spreadsheet/content${query ? `?${query}` : ''}`, {
|
||||
responseType: 'blob',
|
||||
contentType: null
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadAgentAssetSpreadsheet(assetId, file, options = {}) {
|
||||
return apiRequest(
|
||||
`/agent-assets/${assetId}/spreadsheet/upload?filename=${encodeURIComponent(file.name)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: file,
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
headers: buildWriteHeaders(options)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function importAgentAssetSpreadsheetContent(assetId, file, options = {}) {
|
||||
return apiRequest(
|
||||
`/agent-assets/${assetId}/spreadsheet/import-content?filename=${encodeURIComponent(file.name)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: file,
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
headers: buildWriteHeaders(options)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAgentAssetVersions(assetId, limit = 5) {
|
||||
return apiRequest(`/agent-assets/${assetId}/versions${buildQuery({ limit })}`)
|
||||
}
|
||||
|
||||
export function fetchAgentAssetVersionTimeline(assetId) {
|
||||
return apiRequest(`/agent-assets/${assetId}/version-timeline`)
|
||||
}
|
||||
|
||||
export function compareAgentAssetSpreadsheetVersions(assetId, baseVersion, targetVersion) {
|
||||
const query = new URLSearchParams({
|
||||
base_version: String(baseVersion || '').trim(),
|
||||
target_version: String(targetVersion || '').trim()
|
||||
})
|
||||
return apiRequest(`/agent-assets/${assetId}/versions/compare?${query.toString()}`)
|
||||
}
|
||||
|
||||
export function updateAgentAsset(assetId, payload, options = {}) {
|
||||
return apiRequest(`/agent-assets/${assetId}`, {
|
||||
method: 'PATCH',
|
||||
@@ -115,6 +175,13 @@ export function activateAgentAsset(assetId, options = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
export function restoreAgentAssetVersion(assetId, version, options = {}) {
|
||||
return apiRequest(`/agent-assets/${assetId}/versions/${encodeURIComponent(version)}/restore`, {
|
||||
method: 'POST',
|
||||
headers: buildWriteHeaders(options)
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchAgentRuns(params = {}) {
|
||||
return apiRequest(`/agent-runs${buildQuery(params)}`)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'policies'])
|
||||
const VIEW_ROLE_RULES = {
|
||||
overview: ['finance', 'executive'],
|
||||
approval: ['approver'],
|
||||
audit: ['auditor'],
|
||||
audit: ['auditor', 'finance'],
|
||||
logs: ['manager'],
|
||||
employees: ['manager'],
|
||||
settings: ['manager']
|
||||
@@ -28,9 +28,13 @@ function normalizedRoleCodes(user) {
|
||||
return Array.isArray(user.roleCodes) ? user.roleCodes.filter(Boolean) : []
|
||||
}
|
||||
|
||||
export function isManagerUser(user) {
|
||||
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
|
||||
}
|
||||
export function isManagerUser(user) {
|
||||
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
|
||||
}
|
||||
|
||||
export function isFinanceUser(user) {
|
||||
return normalizedRoleCodes(user).includes('finance')
|
||||
}
|
||||
|
||||
export function canAccessAppView(user, viewId) {
|
||||
if (!viewId || !user) {
|
||||
|
||||
271
web/src/utils/agentRunMonitor.js
Normal file
271
web/src/utils/agentRunMonitor.js
Normal file
@@ -0,0 +1,271 @@
|
||||
const KNOWLEDGE_JOB_TYPES = new Set(['knowledge_index_sync', 'llm_wiki_sync'])
|
||||
|
||||
const STATUS_LABELS = {
|
||||
running: '运行中',
|
||||
succeeded: '已完成',
|
||||
failed: '失败',
|
||||
blocked: '待确认'
|
||||
}
|
||||
|
||||
const STATUS_TONES = {
|
||||
running: 'warning',
|
||||
succeeded: 'success',
|
||||
failed: 'danger',
|
||||
blocked: 'muted'
|
||||
}
|
||||
|
||||
const PHASE_LABELS = {
|
||||
queued: '排队中',
|
||||
indexing: '归纳中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
stale_failed: '超时失败'
|
||||
}
|
||||
|
||||
export const AGENT_RUN_POLL_INTERVAL_MS = 5000
|
||||
export const AGENT_RUN_HEARTBEAT_DELAY_MS = 60 * 1000
|
||||
export const AGENT_RUN_HEARTBEAT_STUCK_MS = 5 * 60 * 1000
|
||||
|
||||
function toDate(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function resolveNowMs(now = Date.now()) {
|
||||
if (now instanceof Date) {
|
||||
return now.getTime()
|
||||
}
|
||||
|
||||
const numeric = Number(now)
|
||||
return Number.isFinite(numeric) ? numeric : Date.now()
|
||||
}
|
||||
|
||||
export function isKnowledgeIndexRun(run) {
|
||||
const jobType = String(run?.route_json?.job_type || '').trim()
|
||||
return KNOWLEDGE_JOB_TYPES.has(jobType)
|
||||
}
|
||||
|
||||
export function getAgentRunPhase(run) {
|
||||
return String(run?.route_json?.phase || '').trim()
|
||||
}
|
||||
|
||||
export function formatDurationShort(valueMs) {
|
||||
const numeric = Number(valueMs)
|
||||
if (!Number.isFinite(numeric) || numeric < 0) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
const totalSeconds = Math.max(0, Math.round(numeric / 1000))
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}小时${minutes}分`
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return seconds > 0 ? `${minutes}分${seconds}秒` : `${minutes}分`
|
||||
}
|
||||
return `${seconds}秒`
|
||||
}
|
||||
|
||||
export function formatAgentRunElapsed(run, now = Date.now()) {
|
||||
const startedAt = toDate(run?.started_at)
|
||||
if (startedAt === null) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
const finishedAt = toDate(run?.finished_at)
|
||||
const endMs = finishedAt ? finishedAt.getTime() : resolveNowMs(now)
|
||||
return formatDurationShort(endMs - startedAt.getTime())
|
||||
}
|
||||
|
||||
export function resolveAgentRunPhaseLabel(run) {
|
||||
const phase = getAgentRunPhase(run)
|
||||
return PHASE_LABELS[phase] || STATUS_LABELS[String(run?.status || '').trim()] || '未知'
|
||||
}
|
||||
|
||||
export function resolveAgentRunHeartbeat(run, now = Date.now()) {
|
||||
const heartbeatAt = toDate(run?.route_json?.heartbeat_at)
|
||||
const startedAt = toDate(run?.started_at)
|
||||
const nowMs = resolveNowMs(now)
|
||||
const phase = getAgentRunPhase(run)
|
||||
const isRunning = String(run?.status || '').trim() === 'running'
|
||||
const heartbeatAgeMs = heartbeatAt ? Math.max(0, nowMs - heartbeatAt.getTime()) : null
|
||||
const startedAgeMs = startedAt ? Math.max(0, nowMs - startedAt.getTime()) : null
|
||||
|
||||
if (heartbeatAt) {
|
||||
if (heartbeatAgeMs >= AGENT_RUN_HEARTBEAT_STUCK_MS) {
|
||||
return {
|
||||
at: heartbeatAt,
|
||||
ageMs: heartbeatAgeMs,
|
||||
text: `${formatDurationShort(heartbeatAgeMs)}前`,
|
||||
label: '疑似中断',
|
||||
tone: 'danger'
|
||||
}
|
||||
}
|
||||
if (heartbeatAgeMs >= AGENT_RUN_HEARTBEAT_DELAY_MS) {
|
||||
return {
|
||||
at: heartbeatAt,
|
||||
ageMs: heartbeatAgeMs,
|
||||
text: `${formatDurationShort(heartbeatAgeMs)}前`,
|
||||
label: '心跳延迟',
|
||||
tone: 'warning'
|
||||
}
|
||||
}
|
||||
return {
|
||||
at: heartbeatAt,
|
||||
ageMs: heartbeatAgeMs,
|
||||
text: `${formatDurationShort(heartbeatAgeMs)}前`,
|
||||
label: '心跳正常',
|
||||
tone: 'success'
|
||||
}
|
||||
}
|
||||
|
||||
if (!isKnowledgeIndexRun(run) || !isRunning) {
|
||||
return {
|
||||
at: null,
|
||||
ageMs: null,
|
||||
text: '—',
|
||||
label: '无心跳',
|
||||
tone: 'muted'
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === 'queued') {
|
||||
return {
|
||||
at: null,
|
||||
ageMs: startedAgeMs,
|
||||
text: '尚未开始',
|
||||
label: '等待执行',
|
||||
tone: 'muted'
|
||||
}
|
||||
}
|
||||
|
||||
if ((startedAgeMs || 0) >= AGENT_RUN_HEARTBEAT_DELAY_MS) {
|
||||
return {
|
||||
at: null,
|
||||
ageMs: startedAgeMs,
|
||||
text: '尚未收到',
|
||||
label: '无心跳',
|
||||
tone: 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
at: null,
|
||||
ageMs: startedAgeMs,
|
||||
text: '等待首个心跳',
|
||||
label: '等待心跳',
|
||||
tone: 'muted'
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAgentRunStatus(run, now = Date.now()) {
|
||||
const status = String(run?.status || '').trim()
|
||||
const phase = getAgentRunPhase(run)
|
||||
const heartbeat = resolveAgentRunHeartbeat(run, now)
|
||||
let label = STATUS_LABELS[status] || status || '未知'
|
||||
let tone = STATUS_TONES[status] || 'muted'
|
||||
let note = ''
|
||||
let isSuspicious = false
|
||||
|
||||
if (status === 'failed' && phase === 'stale_failed') {
|
||||
return {
|
||||
label: '已超时',
|
||||
tone: 'danger',
|
||||
note: '系统已按长时间无心跳自动判定失败',
|
||||
phase,
|
||||
phaseLabel: resolveAgentRunPhaseLabel(run),
|
||||
heartbeat,
|
||||
isSuspicious: true
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'running' && isKnowledgeIndexRun(run)) {
|
||||
if (phase === 'queued') {
|
||||
label = '排队中'
|
||||
tone = 'muted'
|
||||
note = '等待后台线程接管'
|
||||
} else if (phase === 'indexing') {
|
||||
if (heartbeat.at === null && heartbeat.label === '无心跳') {
|
||||
label = '无心跳'
|
||||
tone = 'warning'
|
||||
note = '已进入归纳流程,但还没有收到心跳'
|
||||
isSuspicious = true
|
||||
} else if (heartbeat.tone === 'danger') {
|
||||
label = '疑似卡住'
|
||||
tone = 'danger'
|
||||
note = `最后心跳在 ${heartbeat.text}`
|
||||
isSuspicious = true
|
||||
} else if (heartbeat.tone === 'warning') {
|
||||
label = '心跳延迟'
|
||||
tone = 'warning'
|
||||
note = `最后心跳在 ${heartbeat.text}`
|
||||
isSuspicious = true
|
||||
} else if (heartbeat.at === null) {
|
||||
label = '归纳启动中'
|
||||
tone = 'warning'
|
||||
note = '任务已启动,等待首个心跳'
|
||||
} else {
|
||||
label = '归纳中'
|
||||
tone = 'warning'
|
||||
note = `最后心跳在 ${heartbeat.text}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!note && status === 'failed' && run?.error_message) {
|
||||
note = String(run.error_message).trim()
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
tone,
|
||||
note,
|
||||
phase,
|
||||
phaseLabel: resolveAgentRunPhaseLabel(run),
|
||||
heartbeat,
|
||||
isSuspicious
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAgentRunProgress(run) {
|
||||
const progress = run?.route_json?.progress || {}
|
||||
const percent = Number(progress.percent || 0)
|
||||
const completed = Number(progress.completed_documents || 0)
|
||||
const total = Number(progress.total_documents || 0)
|
||||
const failed = Number(progress.failed_documents || 0)
|
||||
const stage = String(progress.current_stage || '').trim()
|
||||
const stageLabelMap = {
|
||||
document_started: '文档启动',
|
||||
text_extracted: '文本已提取',
|
||||
candidate_chunks_selected: '已筛正文',
|
||||
extracting_candidates: '候选提炼中',
|
||||
candidate_extraction_completed: '候选提炼完成',
|
||||
document_completed: '文档完成',
|
||||
skipped: '跳过'
|
||||
}
|
||||
const stageLabel = stageLabelMap[stage] || stage
|
||||
|
||||
if (total > 0) {
|
||||
const parts = [`${percent}%`, `${completed}/${total} 文档`]
|
||||
if (failed > 0) {
|
||||
parts.push(`失败 ${failed}`)
|
||||
}
|
||||
if (stageLabel) {
|
||||
parts.push(stageLabel)
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
if (stageLabel) {
|
||||
return `${percent}% · ${stageLabel}`
|
||||
}
|
||||
|
||||
return `${percent}%`
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,43 +15,56 @@
|
||||
</article>
|
||||
|
||||
<template v-else-if="isHermes && hermesRun">
|
||||
<article class="detail-hero panel">
|
||||
<div class="hero-copy">
|
||||
<div class="hero-tags">
|
||||
<span class="level-pill" :class="resolveLevelTone(resolveRunLevel(hermesRun))">
|
||||
{{ resolveRunLevel(hermesRun) }}
|
||||
</span>
|
||||
<span class="status-pill" :class="resolveStatusTone(hermesRun.status)">
|
||||
{{ resolveStatusLabel(hermesRun.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<h2>{{ resolveRunTitle(hermesRun) }}</h2>
|
||||
<p>{{ hermesRun.result_summary || '暂无运行摘要。' }}</p>
|
||||
</div>
|
||||
<button class="refresh-btn" type="button" :disabled="loading" @click="loadDetail">
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
<span>刷新详情</span>
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<div class="detail-grid">
|
||||
<article class="panel detail-card wide">
|
||||
<div class="card-head">
|
||||
<h3>基本信息</h3>
|
||||
<article class="detail-hero panel">
|
||||
<div class="hero-copy">
|
||||
<div class="hero-tags">
|
||||
<span class="level-pill" :class="resolveLevelTone(resolveRunLevel(hermesRun))">
|
||||
{{ resolveRunLevel(hermesRun) }}
|
||||
</span>
|
||||
<span class="status-pill" :class="resolveStatusTone(hermesRun)">
|
||||
{{ resolveStatusLabel(hermesRun) }}
|
||||
</span>
|
||||
</div>
|
||||
<h2>{{ resolveRunTitle(hermesRun) }}</h2>
|
||||
<p>{{ hermesRun.result_summary || '暂无运行摘要。' }}</p>
|
||||
<p v-if="hermesRun.status === 'running'" class="hero-hint">运行中每 5 秒自动刷新一次详情。</p>
|
||||
</div>
|
||||
<button class="refresh-btn" type="button" :disabled="loading" @click="loadDetail">
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
<span>刷新详情</span>
|
||||
</button>
|
||||
</article>
|
||||
|
||||
<article
|
||||
v-if="hermesRunAlert"
|
||||
class="panel detail-alert"
|
||||
:class="hermesRunAlert.tone"
|
||||
>
|
||||
{{ hermesRunAlert.message }}
|
||||
</article>
|
||||
|
||||
<div class="detail-grid">
|
||||
<article class="panel detail-card wide">
|
||||
<div class="card-head">
|
||||
<h3>基本信息</h3>
|
||||
<p>围绕当前 Hermes 任务查看关键字段。</p>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div><span>Trace ID</span><strong>{{ hermesRun.run_id }}</strong></div>
|
||||
<div><span>开始时间</span><strong>{{ formatDateTime(hermesRun.started_at) }}</strong></div>
|
||||
<div><span>结束时间</span><strong>{{ formatDateTime(hermesRun.finished_at) }}</strong></div>
|
||||
<div><span>来源</span><strong>{{ resolveRunSourceLabel(hermesRun.source) }}</strong></div>
|
||||
<div><span>模块</span><strong>{{ resolveRunModuleLabel(hermesRun) }}</strong></div>
|
||||
<div><span>当前进度</span><strong>{{ resolveRunProgress(hermesRun) }}</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel detail-card">
|
||||
<div class="card-head">
|
||||
<div><span>Trace ID</span><strong>{{ hermesRun.run_id }}</strong></div>
|
||||
<div><span>开始时间</span><strong>{{ formatDateTime(hermesRun.started_at) }}</strong></div>
|
||||
<div><span>结束时间</span><strong>{{ formatDateTime(hermesRun.finished_at) }}</strong></div>
|
||||
<div><span>来源</span><strong>{{ resolveRunSourceLabel(hermesRun.source) }}</strong></div>
|
||||
<div><span>模块</span><strong>{{ resolveRunModuleLabel(hermesRun) }}</strong></div>
|
||||
<div><span>当前阶段</span><strong>{{ hermesRunStatus.phaseLabel }}</strong></div>
|
||||
<div><span>当前进度</span><strong>{{ resolveRunProgress(hermesRun) }}</strong></div>
|
||||
<div><span>执行耗时</span><strong>{{ resolveRunElapsedLabel(hermesRun) }}</strong></div>
|
||||
<div><span>最后心跳</span><strong>{{ resolveHeartbeatAtText(hermesRunHeartbeat) }}</strong></div>
|
||||
<div><span>心跳状态</span><strong>{{ hermesRunHeartbeat.label }}</strong></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel detail-card">
|
||||
<div class="card-head">
|
||||
<h3>处理链路</h3>
|
||||
<p>按工具调用顺序查看执行链。</p>
|
||||
</div>
|
||||
@@ -62,20 +75,22 @@
|
||||
type="button"
|
||||
class="trace-step"
|
||||
:class="{ active: selectedToolCall?.id === toolCall.id }"
|
||||
@click="selectedToolCallId = toolCall.id"
|
||||
>
|
||||
<span class="step-index">{{ index + 1 }}</span>
|
||||
<div class="step-copy">
|
||||
<strong>{{ toolCall.tool_name }}</strong>
|
||||
<span>{{ toolCall.tool_type }} · {{ toolCall.duration_ms }}ms</span>
|
||||
</div>
|
||||
<span class="status-pill" :class="resolveToolStatusTone(toolCall.status)">
|
||||
{{ toolCall.status }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="inline-empty">当前运行暂无 ToolCall 明细。</div>
|
||||
</article>
|
||||
@click="selectedToolCallId = toolCall.id"
|
||||
>
|
||||
<span class="step-index">{{ index + 1 }}</span>
|
||||
<div class="step-copy">
|
||||
<strong>{{ toolCall.tool_name }}</strong>
|
||||
<span>{{ resolveToolCallMeta(toolCall) }}</span>
|
||||
</div>
|
||||
<span class="status-pill" :class="resolveToolStatusTone(toolCall.status)">
|
||||
{{ toolCall.status }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="inline-empty">
|
||||
当前暂无 ToolCall 明细。若长时间停在运行中且没有心跳,通常表示任务尚未真正进入 LightRAG 索引调用,或执行它的是旧版后端进程。
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article v-if="selectedToolCall" class="panel detail-card">
|
||||
<div class="card-head">
|
||||
@@ -175,12 +190,20 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { fetchAgentRunDetail } from '../services/agentAssets.js'
|
||||
import { fetchSystemLogEntry } from '../services/systemLogs.js'
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { fetchAgentRunDetail } from '../services/agentAssets.js'
|
||||
import { fetchSystemLogEntry } from '../services/systemLogs.js'
|
||||
import {
|
||||
AGENT_RUN_POLL_INTERVAL_MS,
|
||||
formatAgentRunElapsed,
|
||||
formatAgentRunProgress,
|
||||
formatDurationShort,
|
||||
resolveAgentRunHeartbeat,
|
||||
resolveAgentRunStatus
|
||||
} from '../utils/agentRunMonitor.js'
|
||||
|
||||
const SOURCE_LABELS = {
|
||||
schedule: '定时任务',
|
||||
@@ -191,16 +214,38 @@ const SOURCE_LABELS = {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const hermesRun = ref(null)
|
||||
const systemEntry = ref(null)
|
||||
const selectedToolCallId = ref('')
|
||||
|
||||
const isHermes = computed(() => route.params.logKind === 'hermes')
|
||||
const isSystem = computed(() => route.params.logKind === 'system')
|
||||
const selectedToolCall = computed(() =>
|
||||
(hermesRun.value?.tool_calls || []).find((item) => item.id === selectedToolCallId.value) || null
|
||||
)
|
||||
const error = ref('')
|
||||
const hermesRun = ref(null)
|
||||
const systemEntry = ref(null)
|
||||
const selectedToolCallId = ref('')
|
||||
const nowTick = ref(Date.now())
|
||||
let pollTimer = 0
|
||||
|
||||
const isHermes = computed(() => route.params.logKind === 'hermes')
|
||||
const isSystem = computed(() => route.params.logKind === 'system')
|
||||
const selectedToolCall = computed(() =>
|
||||
(hermesRun.value?.tool_calls || []).find((item) => item.id === selectedToolCallId.value) || null
|
||||
)
|
||||
const hermesRunStatus = computed(() => resolveAgentRunStatus(hermesRun.value, nowTick.value))
|
||||
const hermesRunHeartbeat = computed(() => resolveAgentRunHeartbeat(hermesRun.value, nowTick.value))
|
||||
const hermesRunAlert = computed(() => {
|
||||
if (!hermesRun.value) {
|
||||
return null
|
||||
}
|
||||
if (hermesRun.value.error_message) {
|
||||
return {
|
||||
tone: 'danger',
|
||||
message: hermesRun.value.error_message
|
||||
}
|
||||
}
|
||||
if (hermesRunStatus.value.isSuspicious) {
|
||||
return {
|
||||
tone: hermesRunStatus.value.tone === 'danger' ? 'danger' : 'warning',
|
||||
message: hermesRunStatus.value.note || '当前任务长时间没有有效进展,建议检查后台执行器。'
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '未结束'
|
||||
@@ -216,20 +261,13 @@ function formatJson(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStatusLabel(status) {
|
||||
if (status === 'running') return '运行中'
|
||||
if (status === 'succeeded') return '已完成'
|
||||
if (status === 'failed') return '失败'
|
||||
if (status === 'blocked') return '待确认'
|
||||
return status || '未知'
|
||||
}
|
||||
|
||||
function resolveStatusTone(status) {
|
||||
if (status === 'running') return 'warning'
|
||||
if (status === 'succeeded') return 'success'
|
||||
if (status === 'failed') return 'danger'
|
||||
return 'muted'
|
||||
}
|
||||
function resolveStatusLabel(run) {
|
||||
return resolveAgentRunStatus(run, nowTick.value).label
|
||||
}
|
||||
|
||||
function resolveStatusTone(run) {
|
||||
return resolveAgentRunStatus(run, nowTick.value).tone
|
||||
}
|
||||
|
||||
function resolveToolStatusTone(status) {
|
||||
return status === 'succeeded' ? 'success' : status === 'failed' ? 'danger' : 'warning'
|
||||
@@ -255,12 +293,14 @@ function resolveRunTitle(run) {
|
||||
return `Hermes 调用 · ${resolveRunModuleLabel(run)}`
|
||||
}
|
||||
|
||||
function resolveRunLevel(run) {
|
||||
const progress = run?.route_json?.progress || {}
|
||||
if (run?.status === 'failed' || run?.error_message) return 'ERROR'
|
||||
if (run?.status === 'blocked' || Number(progress.failed_documents || 0) > 0) return 'WARN'
|
||||
return 'INFO'
|
||||
}
|
||||
function resolveRunLevel(run) {
|
||||
const progress = run?.route_json?.progress || {}
|
||||
const statusInfo = resolveAgentRunStatus(run, nowTick.value)
|
||||
if (run?.status === 'failed' || run?.error_message) return 'ERROR'
|
||||
if (statusInfo.isSuspicious) return 'WARN'
|
||||
if (run?.status === 'blocked' || Number(progress.failed_documents || 0) > 0) return 'WARN'
|
||||
return 'INFO'
|
||||
}
|
||||
|
||||
function resolveLevelTone(level) {
|
||||
if (level === 'ERROR') return 'danger'
|
||||
@@ -269,27 +309,9 @@ function resolveLevelTone(level) {
|
||||
return 'muted'
|
||||
}
|
||||
|
||||
function resolveRunProgress(run) {
|
||||
const progress = run?.route_json?.progress || {}
|
||||
const percent = Number(progress.percent || 0)
|
||||
const completed = Number(progress.completed_documents || 0)
|
||||
const total = Number(progress.total_documents || 0)
|
||||
const failed = Number(progress.failed_documents || 0)
|
||||
const stage = String(progress.current_stage || '').trim()
|
||||
const stageLabelMap = {
|
||||
document_started: '文档启动',
|
||||
text_extracted: '文本已提取',
|
||||
candidate_chunks_selected: '已筛正文',
|
||||
extracting_candidates: '候选提炼中',
|
||||
candidate_extraction_completed: '候选提炼完成',
|
||||
document_completed: '文档完成',
|
||||
skipped: '跳过'
|
||||
}
|
||||
const stageLabel = stageLabelMap[stage] || (stage || '等待中')
|
||||
return total > 0
|
||||
? `${percent}% · ${completed}/${total} 文档 · ${stageLabel}${failed > 0 ? ` · 失败 ${failed}` : ''}`
|
||||
: `${percent}% · ${stageLabel}`
|
||||
}
|
||||
function resolveRunProgress(run) {
|
||||
return formatAgentRunProgress(run)
|
||||
}
|
||||
|
||||
function resolveSystemLevelTone(level) {
|
||||
if (level === 'ERROR' || level === 'CRITICAL') return 'danger'
|
||||
@@ -309,15 +331,47 @@ function resolveSystemParseLabel(status) {
|
||||
return status === 'parsed' ? '已结构化' : '待人工核查'
|
||||
}
|
||||
|
||||
function resolveSystemRecommendation(entry) {
|
||||
function resolveSystemRecommendation(entry) {
|
||||
if (!entry) return '暂无'
|
||||
if (entry.outcome === '失败') return '优先排查并关联上下游日志'
|
||||
if (entry.outcome === '异常' || entry.outcome === '告警') return '建议继续关注同模块后续记录'
|
||||
if (entry.parse_status !== 'parsed') return '建议人工核对原始日志'
|
||||
return '无需额外处理'
|
||||
}
|
||||
|
||||
function syncSelectedToolCall() {
|
||||
return '无需额外处理'
|
||||
}
|
||||
|
||||
function resolveRunElapsedLabel(run) {
|
||||
const elapsed = formatAgentRunElapsed(run, nowTick.value)
|
||||
if (elapsed === '—') {
|
||||
return elapsed
|
||||
}
|
||||
return run?.status === 'running' ? `已运行 ${elapsed}` : elapsed
|
||||
}
|
||||
|
||||
function resolveHeartbeatAtText(heartbeat) {
|
||||
if (heartbeat?.at) {
|
||||
return `${formatDateTime(heartbeat.at)} · ${heartbeat.text}`
|
||||
}
|
||||
return heartbeat?.text || '—'
|
||||
}
|
||||
|
||||
function resolveToolCallMeta(toolCall) {
|
||||
const toolType = String(toolCall?.tool_type || 'tool').trim()
|
||||
if (String(toolCall?.status || '').trim() === 'running') {
|
||||
const createdAt = new Date(toolCall?.created_at)
|
||||
if (!Number.isNaN(createdAt.getTime())) {
|
||||
return `${toolType} · 已运行 ${formatDurationShort(nowTick.value - createdAt.getTime())}`
|
||||
}
|
||||
return `${toolType} · 执行中`
|
||||
}
|
||||
|
||||
const durationMs = Number(toolCall?.duration_ms || 0)
|
||||
if (durationMs > 0) {
|
||||
return `${toolType} · ${durationMs}ms`
|
||||
}
|
||||
return `${toolType} · 已结束`
|
||||
}
|
||||
|
||||
function syncSelectedToolCall() {
|
||||
const calls = hermesRun.value?.tool_calls || []
|
||||
if (!calls.length) {
|
||||
selectedToolCallId.value = ''
|
||||
@@ -328,14 +382,39 @@ function syncSelectedToolCall() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const id = String(route.params.logId || '')
|
||||
if (isHermes.value) {
|
||||
hermesRun.value = await fetchAgentRunDetail(id)
|
||||
systemEntry.value = null
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
window.clearInterval(pollTimer)
|
||||
pollTimer = 0
|
||||
}
|
||||
}
|
||||
|
||||
function syncPolling() {
|
||||
stopPolling()
|
||||
|
||||
if (!isHermes.value || hermesRun.value?.status !== 'running') {
|
||||
return
|
||||
}
|
||||
|
||||
pollTimer = window.setInterval(() => {
|
||||
nowTick.value = Date.now()
|
||||
if (!loading.value) {
|
||||
void loadDetail({ silent: true })
|
||||
}
|
||||
}, AGENT_RUN_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
async function loadDetail(options = {}) {
|
||||
const silent = options.silent === true
|
||||
if (!silent) {
|
||||
loading.value = true
|
||||
}
|
||||
error.value = ''
|
||||
try {
|
||||
const id = String(route.params.logId || '')
|
||||
if (isHermes.value) {
|
||||
hermesRun.value = await fetchAgentRunDetail(id)
|
||||
systemEntry.value = null
|
||||
syncSelectedToolCall()
|
||||
return
|
||||
}
|
||||
@@ -345,19 +424,35 @@ async function loadDetail() {
|
||||
return
|
||||
}
|
||||
throw new Error('不支持的日志类型。')
|
||||
} catch (nextError) {
|
||||
error.value = nextError?.message || '日志详情加载失败。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
} catch (nextError) {
|
||||
error.value = nextError?.message || '日志详情加载失败。'
|
||||
} finally {
|
||||
nowTick.value = Date.now()
|
||||
syncPolling()
|
||||
if (!silent) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function backToLogs() {
|
||||
router.push({ name: 'app-logs' })
|
||||
}
|
||||
|
||||
watch(() => [route.params.logKind, route.params.logId], loadDetail)
|
||||
onMounted(loadDetail)
|
||||
</script>
|
||||
watch(
|
||||
() => [route.params.logKind, route.params.logId],
|
||||
() => {
|
||||
void loadDetail()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void loadDetail()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/views/log-detail-view.css"></style>
|
||||
|
||||
@@ -113,12 +113,16 @@
|
||||
<td class="summary-cell">
|
||||
<strong>{{ resolveRunTitle(run) }}</strong>
|
||||
<span>{{ formatSummary(run.result_summary) }}</span>
|
||||
<em class="summary-meta">{{ resolveRunSummaryMeta(run) }}</em>
|
||||
</td>
|
||||
<td class="trace-cell">{{ run.run_id }}</td>
|
||||
<td>
|
||||
<span class="status-pill" :class="resolveStatusTone(run.status)">
|
||||
{{ resolveStatusLabel(run.status) }}
|
||||
</span>
|
||||
<div class="status-stack">
|
||||
<span class="status-pill" :class="resolveStatusTone(run)">
|
||||
{{ resolveStatusLabel(run) }}
|
||||
</span>
|
||||
<span class="status-note">{{ resolveRunStatusNote(run) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -225,7 +229,7 @@
|
||||
<div class="analytics-head">
|
||||
<div>
|
||||
<h3>日志趋势</h3>
|
||||
<p>近 8 个小时的 Hermes 运行量与失败量</p>
|
||||
<p>近 8 个小时的 Hermes 启动量与失败量,不代表实时心跳</p>
|
||||
</div>
|
||||
</div>
|
||||
<LogTrendChart
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,20 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import LogTrendChart from '../../components/charts/LogTrendChart.vue'
|
||||
import DonutChart from '../../components/charts/DonutChart.vue'
|
||||
import { fetchAgentRuns } from '../../services/agentAssets.js'
|
||||
import { fetchSystemLogEntries } from '../../services/systemLogs.js'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import { isManagerUser } from '../../utils/accessControl.js'
|
||||
|
||||
const POLL_INTERVAL_MS = 5000
|
||||
import LogTrendChart from '../../components/charts/LogTrendChart.vue'
|
||||
import DonutChart from '../../components/charts/DonutChart.vue'
|
||||
import { fetchAgentRuns } from '../../services/agentAssets.js'
|
||||
import { fetchSystemLogEntries } from '../../services/systemLogs.js'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import {
|
||||
AGENT_RUN_POLL_INTERVAL_MS,
|
||||
formatAgentRunElapsed,
|
||||
formatAgentRunProgress,
|
||||
resolveAgentRunHeartbeat,
|
||||
resolveAgentRunStatus
|
||||
} from '../../utils/agentRunMonitor.js'
|
||||
import { isManagerUser } from '../../utils/accessControl.js'
|
||||
|
||||
const SOURCE_LABELS = {
|
||||
schedule: '定时任务',
|
||||
@@ -30,37 +35,13 @@ function formatDateTime(value) {
|
||||
return date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function resolveStatusLabel(status) {
|
||||
if (status === 'running') {
|
||||
return '运行中'
|
||||
}
|
||||
if (status === 'succeeded') {
|
||||
return '已完成'
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return '失败'
|
||||
}
|
||||
if (status === 'blocked') {
|
||||
return '待确认'
|
||||
}
|
||||
return status || '未知'
|
||||
}
|
||||
|
||||
function resolveStatusTone(status) {
|
||||
if (status === 'running') {
|
||||
return 'warning'
|
||||
}
|
||||
if (status === 'succeeded') {
|
||||
return 'success'
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return 'danger'
|
||||
}
|
||||
if (status === 'blocked') {
|
||||
return 'muted'
|
||||
}
|
||||
return 'muted'
|
||||
}
|
||||
function resolveStatusLabel(run) {
|
||||
return resolveAgentRunStatus(run).label
|
||||
}
|
||||
|
||||
function resolveStatusTone(run) {
|
||||
return resolveAgentRunStatus(run).tone
|
||||
}
|
||||
|
||||
function resolveRunSourceLabel(source) {
|
||||
return SOURCE_LABELS[source] || source || '未标记'
|
||||
@@ -88,16 +69,20 @@ function resolveRunTitle(run) {
|
||||
return `Hermes 调用 · ${resolveRunModuleLabel(run)}`
|
||||
}
|
||||
|
||||
function resolveRunLevel(run) {
|
||||
const progress = run?.route_json?.progress || {}
|
||||
if (run?.status === 'failed' || run?.error_message) {
|
||||
return 'ERROR'
|
||||
}
|
||||
if (run?.status === 'blocked' || Number(progress.failed_documents || 0) > 0) {
|
||||
return 'WARN'
|
||||
}
|
||||
if (run?.status === 'running') {
|
||||
return 'INFO'
|
||||
function resolveRunLevel(run) {
|
||||
const progress = run?.route_json?.progress || {}
|
||||
const statusInfo = resolveAgentRunStatus(run)
|
||||
if (run?.status === 'failed' || run?.error_message) {
|
||||
return 'ERROR'
|
||||
}
|
||||
if (statusInfo.isSuspicious) {
|
||||
return 'WARN'
|
||||
}
|
||||
if (run?.status === 'blocked' || Number(progress.failed_documents || 0) > 0) {
|
||||
return 'WARN'
|
||||
}
|
||||
if (run?.status === 'running') {
|
||||
return 'INFO'
|
||||
}
|
||||
return 'INFO'
|
||||
}
|
||||
@@ -115,7 +100,7 @@ function resolveLevelTone(level) {
|
||||
return 'muted'
|
||||
}
|
||||
|
||||
function formatSummary(summary) {
|
||||
function formatSummary(summary) {
|
||||
const text = String(summary || '').trim()
|
||||
if (!text) {
|
||||
return '暂无摘要。'
|
||||
@@ -123,8 +108,39 @@ function formatSummary(summary) {
|
||||
if (text.length <= 64) {
|
||||
return text
|
||||
}
|
||||
return `${text.slice(0, 64)}...`
|
||||
}
|
||||
return `${text.slice(0, 64)}...`
|
||||
}
|
||||
|
||||
function resolveRunSummaryMeta(run) {
|
||||
const statusInfo = resolveAgentRunStatus(run)
|
||||
const progressText = formatAgentRunProgress(run)
|
||||
const elapsedLabel = run?.status === 'running' ? '已运行' : '耗时'
|
||||
const elapsedText = formatAgentRunElapsed(run)
|
||||
const parts = [`阶段 ${statusInfo.phaseLabel}`]
|
||||
|
||||
if (progressText) {
|
||||
parts.push(progressText)
|
||||
}
|
||||
if (elapsedText !== '—') {
|
||||
parts.push(`${elapsedLabel} ${elapsedText}`)
|
||||
}
|
||||
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function resolveRunStatusNote(run) {
|
||||
const statusInfo = resolveAgentRunStatus(run)
|
||||
if (statusInfo.note) {
|
||||
return statusInfo.note
|
||||
}
|
||||
|
||||
const heartbeat = resolveAgentRunHeartbeat(run)
|
||||
if (heartbeat.at !== null) {
|
||||
return `最后心跳 ${formatDateTime(heartbeat.at)}`
|
||||
}
|
||||
|
||||
return '暂无额外状态'
|
||||
}
|
||||
|
||||
function resolveSystemLevelTone(level) {
|
||||
if (level === 'ERROR' || level === 'CRITICAL') {
|
||||
@@ -368,9 +384,9 @@ export default {
|
||||
stopPolling()
|
||||
pollTimer = window.setInterval(() => {
|
||||
loadHermesRuns()
|
||||
loadSystemLogs()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
loadSystemLogs()
|
||||
}, AGENT_RUN_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
@@ -442,11 +458,13 @@ export default {
|
||||
pageSizes,
|
||||
resolveLevelTone,
|
||||
resolveRunLevel,
|
||||
resolveRunModuleLabel,
|
||||
resolveRunSourceLabel,
|
||||
resolveRunTitle,
|
||||
resolveStatusLabel,
|
||||
resolveStatusTone,
|
||||
resolveRunModuleLabel,
|
||||
resolveRunSourceLabel,
|
||||
resolveRunStatusNote,
|
||||
resolveRunSummaryMeta,
|
||||
resolveRunTitle,
|
||||
resolveStatusLabel,
|
||||
resolveStatusTone,
|
||||
resolveSystemLevelTone,
|
||||
resolveSystemOutcomeTone,
|
||||
runningRunCount,
|
||||
|
||||
@@ -28,3 +28,28 @@ export function buildOnlyOfficePreviewConfig(config, options = {}) {
|
||||
height: `${clampHeight(viewportHeight)}px`
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOnlyOfficeEditorConfig(config, options = {}) {
|
||||
const viewportHeight = options.viewportHeight
|
||||
const editable = Boolean(options.editable)
|
||||
const fillContainer = Boolean(options.fillContainer)
|
||||
|
||||
return {
|
||||
...config,
|
||||
type: editable ? 'desktop' : 'embedded',
|
||||
editorConfig: {
|
||||
...(config.editorConfig || {}),
|
||||
embedded: editable
|
||||
? undefined
|
||||
: {
|
||||
embedUrl: '',
|
||||
fullscreenUrl: '',
|
||||
saveUrl: '',
|
||||
shareUrl: '',
|
||||
toolbarDocked: 'top'
|
||||
}
|
||||
},
|
||||
width: '100%',
|
||||
height: fillContainer ? '100%' : `${clampHeight(viewportHeight)}px`
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user