chore: 更新配置和构建脚本

This commit is contained in:
caoxiaozhu
2026-05-18 02:53:06 +00:00
parent 9b97f456cf
commit 8814fe7dfa
15 changed files with 1592 additions and 15 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", "pydantic-settings>=2.6.0,<3.0.0",
"python-dotenv>=1.0.1,<2.0.0", "python-dotenv>=1.0.1,<2.0.0",
"email-validator>=2.2.0,<3.0.0", "email-validator>=2.2.0,<3.0.0",
"python-multipart>=0.0.20,<1.0.0", "python-multipart>=0.0.20,<1.0.0",
"lightrag-hku>=1.4.16,<1.5.0", "openpyxl>=3.1.5,<4.0.0",
"lightrag-hku>=1.4.16,<1.5.0",
"qdrant-client>=1.18.0,<2.0.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() { 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() { pip_ready() {

View File

@@ -62,13 +62,39 @@ def get_current_user(
) )
def require_admin_user( def require_admin_user(
current_user: Annotated[CurrentUserContext, Depends(get_current_user)], current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
) -> CurrentUserContext: ) -> CurrentUserContext:
if current_user.is_admin or "manager" in current_user.role_codes: if current_user.is_admin or "manager" in current_user.role_codes:
return current_user return current_user
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="只有管理员可以上传、删除或修改知识库文件。", 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

@@ -50,6 +50,16 @@ class AgentRunRepository:
self.db.refresh(tool_call) self.db.refresh(tool_call)
return 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: def create_semantic_parse(self, semantic_parse: SemanticParseLog) -> SemanticParseLog:
self.db.add(semantic_parse) self.db.add(semantic_parse)
self.db.commit() self.db.commit()

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]

View File

@@ -1,12 +1,12 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from sqlalchemy.orm import Session 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.core.logging import get_logger
from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog from app.models.agent_run import AgentRun, AgentToolCall, SemanticParseLog
from app.repositories.agent_run import AgentRunRepository 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") logger = get_logger("app.services.agent_runs")
KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30)
class AgentRunService: class AgentRunService:
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
@@ -30,11 +32,13 @@ class AgentRunService:
limit: int = 20, limit: int = 20,
) -> list[AgentRunRead]: ) -> list[AgentRunRead]:
self._ensure_ready() self._ensure_ready()
self._reconcile_stale_knowledge_index_runs()
runs = self.repository.list(agent=agent, status=status, source=source, limit=limit) runs = self.repository.list(agent=agent, status=status, source=source, limit=limit)
return [self._serialize_run(item) for item in runs] return [self._serialize_run(item) for item in runs]
def get_run(self, run_id: str) -> AgentRunRead | None: def get_run(self, run_id: str) -> AgentRunRead | None:
self._ensure_ready() self._ensure_ready()
self._reconcile_stale_knowledge_index_runs(target_run_id=run_id)
run = self.repository.get_by_run_id(run_id) run = self.repository.get_by_run_id(run_id)
if run is None: if run is None:
return None return None
@@ -174,6 +178,35 @@ class AgentRunService:
logger.info("Recorded tool call run_id=%s tool=%s", run_id, tool_name) logger.info("Recorded tool call run_id=%s tool=%s", run_id, tool_name)
return AgentToolCallRead.model_validate(created) 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( def record_semantic_parse(
self, self,
*, *,
@@ -214,6 +247,73 @@ class AgentRunService:
def _ensure_ready(self) -> None: def _ensure_ready(self) -> None:
AgentFoundationService(self.db).ensure_foundation_ready() 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 @staticmethod
def _serialize_run(run: AgentRun) -> AgentRunRead: def _serialize_run(run: AgentRun) -> AgentRunRead:
semantic_parse = run.semantic_parse_logs[0] if run.semantic_parse_logs else None semantic_parse = run.semantic_parse_logs[0] if run.semantic_parse_logs else None

View File

@@ -598,13 +598,13 @@ class ExpenseRuleRuntimeService:
return catalog return catalog
def _get_current_version(self, asset: AgentAsset) -> AgentAssetVersion | None: def _get_current_version(self, asset: AgentAsset) -> AgentAssetVersion | None:
current_version = str(asset.current_version or "").strip() published_version = str(asset.published_version or asset.current_version or "").strip()
if not current_version: if not published_version:
return None return None
return self.db.scalar( return self.db.scalar(
select(AgentAssetVersion).where( select(AgentAssetVersion).where(
AgentAssetVersion.asset_id == asset.id, AgentAssetVersion.asset_id == asset.id,
AgentAssetVersion.version == current_version, AgentAssetVersion.version == published_version,
) )
) )

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: python-dotenv<2.0.0,>=1.0.1
Requires-Dist: email-validator<3.0.0,>=2.2.0 Requires-Dist: email-validator<3.0.0,>=2.2.0
Requires-Dist: python-multipart<1.0.0,>=0.0.20 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: lightrag-hku<1.5.0,>=1.4.16
Requires-Dist: qdrant-client<2.0.0,>=1.18.0 Requires-Dist: qdrant-client<2.0.0,>=1.18.0
Provides-Extra: dev Provides-Extra: dev

View File

@@ -76,6 +76,7 @@ src/app/schemas/settings.py
src/app/schemas/system_log.py src/app/schemas/system_log.py
src/app/schemas/user_agent.py src/app/schemas/user_agent.py
src/app/services/__init__.py src/app/services/__init__.py
src/app/services/agent_asset_spreadsheet.py
src/app/services/agent_assets.py src/app/services/agent_assets.py
src/app/services/agent_conversations.py src/app/services/agent_conversations.py
src/app/services/agent_foundation.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/hermes_sync.py
src/app/services/knowledge.py src/app/services/knowledge.py
src/app/services/knowledge_index_tasks.py src/app/services/knowledge_index_tasks.py
src/app/services/knowledge_normalizer.py
src/app/services/knowledge_rag.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/model_connectivity.py
src/app/services/ocr.py src/app/services/ocr.py
src/app/services/ontology.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/dependency_links.txt
src/x_financial_server.egg-info/requires.txt src/x_financial_server.egg-info/requires.txt
src/x_financial_server.egg-info/top_level.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_service.py
tests/test_agent_asset_spreadsheet_import.py
tests/test_agent_foundation_endpoints.py tests/test_agent_foundation_endpoints.py
tests/test_agent_runs_service.py
tests/test_auth_service.py tests/test_auth_service.py
tests/test_config_settings_reload.py tests/test_config_settings_reload.py
tests/test_document_intelligence.py tests/test_document_intelligence.py
@@ -115,12 +122,16 @@ tests/test_employee_service.py
tests/test_env_file_precedence.py tests/test_env_file_precedence.py
tests/test_expense_claim_service.py tests/test_expense_claim_service.py
tests/test_imports.py tests/test_imports.py
tests/test_knowledge_normalizer.py
tests/test_knowledge_onlyoffice_config.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_endpoints.py
tests/test_ocr_service.py tests/test_ocr_service.py
tests/test_ontology_service.py tests/test_ontology_service.py
tests/test_openapi_schema.py tests/test_openapi_schema.py
tests/test_reimbursement_endpoints.py tests/test_reimbursement_endpoints.py
tests/test_runtime_chat_service.py
tests/test_server_start_dependencies.py tests/test_server_start_dependencies.py
tests/test_settings_persistence.py tests/test_settings_persistence.py
tests/test_settings_service.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 python-dotenv<2.0.0,>=1.0.1
email-validator<3.0.0,>=2.2.0 email-validator<3.0.0,>=2.2.0
python-multipart<1.0.0,>=0.0.20 python-multipart<1.0.0,>=0.0.20
openpyxl<4.0.0,>=3.1.5
lightrag-hku<1.5.0,>=1.4.16 lightrag-hku<1.5.0,>=1.4.16
qdrant-client<2.0.0,>=1.18.0 qdrant-client<2.0.0,>=1.18.0