6 Commits

Author SHA1 Message Date
caoxiaozhu
8814fe7dfa chore: 更新配置和构建脚本 2026-05-18 02:53:06 +00:00
caoxiaozhu
9b97f456cf test: 添加资产和运行服务测试用例 2026-05-18 02:52:13 +00:00
caoxiaozhu
9d90bf5299 feat: 更新前端UI,增强审计和日志视图功能 2026-05-18 02:51:25 +00:00
caoxiaozhu
35a3783481 feat: 更新 user_agent 服务,增强用户代理功能 2026-05-18 02:50:32 +00:00
caoxiaozhu
4414ffb34c feat: 增强知识库功能,优化索引和RAG检索 2026-05-18 02:49:39 +00:00
caoxiaozhu
55e0591a5e feat: 增强 agent_assets 功能,支持更多资产操作 2026-05-18 02:48:51 +00:00
55 changed files with 13211 additions and 5831 deletions

View 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 表格的:
- 工作表新增 / 删除
- 单元格新增 / 删除 / 修改

View 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. 基于历史版本快速恢复为新工作稿
后续如需要,再继续补:
- 版本差异对比
- 审核意见流转面板
- 发布说明 / 审批单号
- 定时生效

View File

@@ -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",
]

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

View File

@@ -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() {

View File

@@ -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="只有高级管理人员可以审核、发布或恢复正式规则。",
)

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]:

View File

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

View File

@@ -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]:

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 远光制度202414 号\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 one or more lines are too long

View File

@@ -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日",
"董事长",
"通信费",
"财务审核时限",
"一万元",
"工会支出",
"交通工具等级标准",
"第二十条",
"影像扫描",
"异地调动邮寄费",
"第二十二条",
"基层经理",
"第二十四条附件",
"公对私结算方式",
"第二十三条本办法的归口与实施",
"异地挂职锻炼补贴标准",
"公司酒店住宿限额标准",
"经办部门(个人)",
"一级部门总经理",
"组织人事部",
"业务原始凭据",
"营销中心",
"保证金",
"投标保证金",
"远光制度202414号",
"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",
"远光制度202414号",
"审批权限变化情况",
"基建工程",
"支出报销申请与审批",
"中国外汇交易中心参考汇率",
"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"
}
}

View File

@@ -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"
],
[
"远光制度202414号",
"远光软件股份有限公司"
],
[
"事业部总经理",
"逐级审批规则"
"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"
}
}

View File

@@ -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>远光软件股份有限公司"
},
"远光制度202414号<SEP>远光软件股份有限公司": {
"chunk_ids": [
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
],
"count": 1,
"create_time": 1779012086,
"update_time": 1779012086,
"_id": "远光制度202414号<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

View 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

View File

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

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

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View 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

View File

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

View File

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

View File

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

View File

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