Commit c1ec05a3 authored by David Yang's avatar David Yang

feat(audit): add AI specialist analysis and shared audit skill

- Add AI-driven audit analysis engine with batched semantic selection
- Introduce shared audit skill (public markdown file) with business
  filter rules, skill upload in prompt config dialog, and rule matching
- Add risk point "exclude and remember" action writing back to skill
- Support configurable Word report style (fonts, spacing, margins)
- Include AI analysis summary and skill rules in Markdown/Excel/Word exports
- Persist skill object via DataService (ai_skill subject)
parent 10603edb
...@@ -15,3 +15,5 @@ ...@@ -15,3 +15,5 @@
.idea/* .idea/*
.git/* .git/*
.DS_Store .DS_Store
.pnpm-store/
...@@ -213,3 +213,262 @@ Word 顺序固定为: ...@@ -213,3 +213,262 @@ Word 顺序固定为:
- `_x000D_` 等控制字符不会直接出现在最终 Word。 - `_x000D_` 等控制字符不会直接出现在最终 Word。
- AI 风险事实均能追溯到 Excel 文件和来源行号。 - AI 风险事实均能追溯到 Excel 文件和来源行号。
- 全流程不调用 embedding 接口。 - 全流程不调用 embedding 接口。
## 9. 审计专项分析 Skill 与业务反馈
Skill 是一个完整的规则文件,而不是拆散到数据库中的一组业务字段。业务人员维护的是 Markdown(推荐)或 JSON 文件;`ai_skill` 对象只负责保存文件和少量元数据,不再单独维护 `skills`、`businessRules`、`instruction`、`exclude_terms` 等字段。
### 9.1 `ai_skill` 对象
首版只保留以下字段,不设置业务版本字段:
| 字段 | 类型 | 必填 | 作用 |
| --- | --- | --- | --- |
| `title` | 字符串 | 是 | Skill 名称,例如“审计专项分析 Skill” |
| `skill_file` | 文件 | 是 | Skill 的 Markdown 或 JSON 文件 |
| `enabled` | 布尔 | 是 | 是否允许运行时加载 |
| `description` | 长文本 | 否 | 给业务人员看的用途和维护说明 |
| `audit_domain` | 字符串 | 否 | 适用审计专项,例如“反洗钱”;为空表示全局适用 |
公共 Skill 文件对应一个 `ai_skill` 对象,示例:
```json
{
"subject": "ai_skill",
"title": "审计专项公共 Skill",
"skill_file": {
"name": "audit_public.md",
"url": "/uploads/skills/audit_public.md"
},
"enabled": true,
"description": "统一定义所有审计专项的清单格式、主题扩展、分析规则和业务反馈规则",
"audit_domain": ""
}
```
### 9.2 Skill 文件内容
原来分开的 `audit_checklist_format.md`、`retrieval_rules.md` 和 `keyword_expansion_rules.md` 的规则统一放入一个 Skill 文件。推荐业务人员使用 Markdown,便于阅读、修改和上传;系统负责解析标题、规则段和专项业务规则。
```markdown
# 审计专项分析 Skill
## 审计清单格式
- 输出审计项目、审计对象、审计内容、审计方法、审计人员、审计时间和注意事项。
- 风险点按“业务板块 → 一级项目 → 二级项目 → 风险点”组织。
## 审计主题扩展
- 用户提出“反洗钱”时,扩展客户身份识别、可疑交易、大额交易、交易监控、受益所有人和责任部门等相关业务环节。
- 扩展结果只用于理解审计意图和筛选知识库风险点,不得凭空生成知识库事实。
## 检索与分析规则
- 先理解用户的自然语言请求,再分析相关业务环节、控制目标、可疑点、数据对象和责任部门。
- 优先由大模型进行语义分析,不以关键词命中作为主要业务决策。
- 最终只能引用知识库已有的风险点 ID、风险描述、法规依据、来源和风险等级。
## 业务规则
### 反洗钱
- 关注:客户身份识别、可疑交易、大额交易、交易监控。
- 排除:某类不适用风险点。
- 原因:业务人员确认该风险点不纳入反洗钱专项。
```
### 9.3 生成审计专项流程
```text
用户提出自然语言请求
↓
加载 enabled=true 且适用当前专项的 Skill 文件
↓
解析 Skill,理解审计主题并扩展相关业务环节
↓
对知识库风险点进行语义分析和 Loop / Map-Reduce 分批处理
↓
应用 Skill 文件中的业务规则,过滤已排除风险点
↓
生成风险点清单、分析依据和报告
↓
导出 Word / Excel / Markdown
```
模型分析得到的结果必须包含相关风险点、分析理由、覆盖说明和置信度;报告列出最终纳入的风险点,并显示已应用的业务过滤规则。大数据分批、候选收敛和最终复核规则见第 10 节。
### 9.4 业务人员维护 Skill 文件
```text
业务人员下载或打开 Skill 文件
↓
修改 Markdown / JSON 内容
↓
上传新文件
↓
系统解析并校验文件格式和必需段落
↓
校验通过后保存文件并启用
↓
后续审计专项自动加载该 Skill
```
本方案固定使用一个公共 Skill 文件,所有审计专项共用同一份文件。业务人员不需要修改数据库字段,也不需要理解模型提示词;只需在文件的“业务规则”下维护对应专项章节,例如 `### 反洗钱`、`### 贷款审计`、`### 运营管理审计`。公共规则放在文件的通用章节,专项规则放在对应专项章节,专项规则优先于通用规则。公共 Skill 对象的 `audit_domain` 保持为空,专项名称由文件内部的章节标题标识。
### 9.5 “排除并记住”流程
公共 Skill 文件会自动更新,但必须由业务人员明确触发长期记忆。推荐在界面上把“本次排除”和 “排除并记住”分成两个操作;对话中出现“以后都不要”“记住这个规则”等明确表达时,也按“排除并记住”处理。普通的“删除这个风险点”默认只作用于当前报告,不直接改写公共文件。
点击“排除并记住”后的完整链路如下:
```text
业务人员确认“以后该专项都排除”
↓
当前报告立即移除风险点
↓
系统读取最新的公共 Skill 文件
↓
定位当前专项章节,例如“### 反洗钱”
↓
追加或合并排除规则(风险点 ID、名称、排除范围和原因)
↓
解析校验 Skill 文件,检查格式、重复规则和必要章节
↓
校验通过后上传新文件,并更新公共 ai_skill 的 skill_file 引用
↓
清理 Skill 缓存,后续专项自动使用新规则
```
写入公共文件的规则应尽量指向稳定的风险点 ID,同时保留可读名称和业务原因。例如:
```markdown
## 业务规则
### 反洗钱
#### 排除规则
- 风险点 ID:AML-SHEET1-1142
- 风险点:某类不适用风险点
- 规则:不纳入反洗钱审计专项
- 原因:业务人员确认该风险点与本单位反洗钱范围无关
```
因此,系统不是把一条临时指令单独存到数据库,而是自动把确认后的规则合并到这一份公共 Skill 文件中。其他人员再次生成反洗钱专项时,系统加载同一文件,先应用该章节规则,再进行风险点分析。
如果用户只说“本次不要”,则只修改当前内存结果和本次导出,不写入 Skill 文件;如果用户明确要求长期生效,才执行上述自动更新流程。
### 9.6 通过对话调整 Word 样式
用户可以在审计结果生成后直接用自然语言调整 Word 的排版。例如:
```text
正文改成宋体小四,行距 1.5 倍,段后空 6 磅;一级标题改成黑体四号。
```
这类请求只修改报告样式,不重新分析 4000 ~ 10000 条风险点。系统保留本次审计的结构化分析结果,先把对话解析为受控的样式配置,再用同一份结果重新渲染 Word:
```text
识别为“Word 样式调整”请求
↓
提取字体、字号、标题层级、行距、段前段后、缩进、页边距等参数
↓
校验参数范围并合并当前报告样式
↓
使用已有审计结果重新生成 Word
↓
返回新的 Word 文件,并保留原风险事实和分析依据
```
样式解析必须输出结构化配置,不能让模型直接生成 Word XML。首版允许调整的配置包括:
```json
{
"fontFamily": "宋体",
"bodyFontSizePt": 10.5,
"heading1FontSizePt": 14,
"heading2FontSizePt": 12,
"lineSpacing": 1.5,
"paragraphAfterPt": 6,
"firstLineIndentChars": 2,
"pageMargins": {
"topMm": 25.4,
"rightMm": 25.4,
"bottomMm": 25.4,
"leftMm": 25.4
}
}
```
用户说“这次 Word 这样排版”时,只影响当前报告;用户说“以后所有审计报告都这样”或明确点击 “保存为默认样式”时,系统才把样式规则写入公共 Skill 文件的“Word 输出样式”章节。例如:
```markdown
## Word 输出样式
### 公共默认样式
- 正文字体:宋体
- 正文大小:小四(10.5pt)
- 一级标题:黑体,四号(14pt)
- 行距:1.5 倍
- 段后:6 磅
```
如果后续需要某个专项使用不同样式,可以在公共文件中增加对应专项章节;没有专项样式时使用公共默认样式。样式规则只影响 Word 的展示,不得改变风险点、法规依据、来源、风险等级或分析结论。
### 9.7 运行时和失败保护规则
- 运行时只加载 `enabled=true` 的 `ai_skill` 对象。
- 公共 Skill 对象的 `audit_domain` 为空,表示该文件包含多个专项的通用规则和专项规则;运行时根据文件内部的专项章节匹配当前审计专项。
- Skill 文件解析失败、格式不支持或缺少必要段落时,不替换当前有效 Skill。
- 文件上传成功但没有返回可访问 URL 时,不更新 `ai_skill` 对象。
- 新 Skill 校验失败时保留旧文件和旧规则,不能让一次错误上传导致专项无法生成。
- 多人同时维护时,系统在写入前重新读取最新公共文件并合并规则,避免覆盖其他专项或其他人员刚保存的规则;不需要增加业务版本字段。
- 自动更新成功后,当前报告保留本次变更记录;自动更新失败时,当前报告仍可保留本次排除结果,但必须提示 “长期规则保存失败”,不能假装已经对后续专项生效。
- 最终报告只能引用知识库已有风险点,Skill 文件中的示例或说明不能被当作风险事实。
- Skill 内容不再拆成多个业务对象字段;对象只保存元数据和文件引用。
- Word 样式请求只允许修改白名单中的排版参数;不能通过样式指令修改或删除审计事实。
- 样式参数无法识别或超出范围时,先要求用户确认,不能静默采用不确定的格式。
- 样式重新生成失败时保留原报告和原样式,不覆盖已有文件。
### 9.8 验收标准
- `ai_skill` 只需要维护上述最小字段,业务规则全部可在 Skill 文件中查看和编辑。
- 一个 Skill 文件同时支持审计清单格式、主题扩展、检索分析规则和专项业务规则。
- 业务人员修改并上传文件后,后续同专项自动使用新规则。
- “排除并记住”会影响后续同专项;“本次不要”不会污染长期 Skill。
- 上传或解析失败不会覆盖当前有效 Skill。
- 反洗钱及其他审计专项均通过同一份公共 Skill 文件和同一套文件优先流程处理。
- 用户可以通过对话调整当前 Word 的字体、字号、行距等样式,不触发重复的风险分析。
- 用户明确保存为默认样式后,后续 Word 自动使用公共 Skill 文件中的样式规则。
本节描述的文件优先流程已经接入运行时:页面启动审计会话时读取 `skill_file`,生成和问答前解析公共规则,业务人员明确要求长期生效时重新读取最新文件、合并内容、上传新文件并更新 `ai_skill.skill_file` 引用。
## 10. 大规模知识库的 Loop / Map-Reduce 分析
正式环境风险点数量可能达到 4000 ~ 10000 条,不能把全部记录一次性放进模型上下文。在线工作台使用 `map_reduce_loop` 策略:
```text
4000~10000 条风险点
↓ 按字符预算切块(当前约 18000 字符/批)
候选初筛(批次并发,保留可能相关项)
↓ 合并候选风险点 ID
候选收敛循环(候选超过 96 条时继续分批压缩,最多 4 轮)
↓
最终复核(输出最终风险点 ID、理由、置信度)
↓
Word / Excel / Markdown 报告
```
实现约束:
- 每批只传结构化风险点摘要,按字符预算切块,不依赖固定条数;单条记录较长时仍能安全分批。
- 候选阶段允许适度保留,避免第一轮因分块造成漏召回;收敛阶段和最终阶段逐步去重、去除弱相关风险点。
- 批次采用有限并发(当前最多 4 批),避免一次性打满模型服务;单批失败会记录并继续其他批次。
- 单批请求有超时和一次重试;候选初筛失败批次超过半数时回退本地评分,避免用不完整结果生成报告。
- 多轮处理按稳定风险点 ID 去重统计;候选为空时跳过最终复核并明确记录“未发现相关风险点”。
- 最终结果只允许引用 Excel 中已有的风险点 ID,不能由模型生成新的风险事实。
- 分析结果中保存 `analysisTrace`,包括轮次、批次数、处理记录数和失败批次,便于审计追踪。
问答模式同样先走候选 Loop,只把语义分析得到的候选风险点交给最终问答模型;不会因为用户只是查询一个主题而把 4000 ~ 10000 条原始记录直接塞入上下文。问答导出的 Word 也只包含本次候选依据。
# 审计专项公共 Skill
本文件由历史项目中的以下 3 个 Skill 合并而成:
- `audit_checklist_format.md`
- `retrieval_rules.md`
- `keyword_expansion_rules.md`
## 审计清单格式规则
### 总原则
- 风险点、法规依据、风险点来源、风险等级必须来自本地 Excel 知识库,不得编造。
- 不得改写知识库中的“具体风险点描述”“法规依据”“风险点来源”“风险等级”原文。
- 可以对展示结构、层级标题、字段顺序进行优化,但不得改变事实内容。
- 每条风险点必须展示与用户输入审计项目的匹配度。
### 输出层级
审计内容必须按以下层级组织:
1. 业务板块
2. 一级项目
3. 二级项目
4. 风险点
相同业务板块必须合并在同一标题下;同一业务板块下相同一级项目必须合并;同一一级项目下相同二级项目必须合并。
### 审计清单要素
每次生成审计检查清单时,应包含以下要素:
- 审计项目
- 审计对象
- 审计内容
- 审计方法
- 审计人员
- 审计时间
- 注意事项
### 风险点字段
每个风险点必须包含:
- 匹配度
- 具体风险点描述
- 法规依据
- 风险点来源
- 风险等级
### 展示要求
#### 主界面摘要展示
- 审计项目应该展示业务人员输入的审计项目。
- 审计对象根据审计项目和审计内容进行简单提炼,参考公募基金公司组织架构列示部门。
- 对话界面中,审计内容按“业务板块 → 一级项目 → 二级项目”层级展示。
- 每个二级项目旁仅展示风险点个数(如“共 N 个风险点”),不直接展开所有风险点详情。
- 业务板块、一级项目可使用折叠/展开结构展示不同颗粒度。
- 审计方法按照可能涉及的审计技术手段进行列示。
#### 查看详情
- 每个二级项目提供“查看详情”展开面板,用户点击后可查看该二级项目下的全部风险点完整信息。
- 详情面板中按顺序展示每个风险点的:匹配度、具体风险点描述、法规依据、风险点来源、风险等级。
#### 匹配度排序与标注
- 优先展示匹配度高的风险点。
- 匹配度较低但被保留的风险点也应显示分数,便于业务人员判断是否采用。
- 如果知识库中没有足够相关内容,应明确提示知识库覆盖不足,而不是强行编造。
### Word 导出规则
- 导出 Word 文档前,系统要求用户输入最低匹配度阈值,界面显示为“请输入审计方案中想列示风险点的最低分值”。
- 用户可先通过“查看详情”浏览各风险点分值,确定合适的阈值后再输入。
- 仅匹配度 ≥ 用户设定阈值的风险点纳入 Word 文档。
- Word 文档中审计内容部分按“业务板块 → 一级项目 → 二级项目 → 风险点”完整展示(含通过阈值筛选的全部字段)。
## 检索与评分规则
### 总原则
- 每次检索必须对知识库中的**所有**文档进行评分,不得仅对 top-k 子集评分后截断。
- 匹配度低于阈值的检查点不在主结果中展示,但仍保留在会话状态中供后续调整指令使用。
### 匹配度阈值
- `MATCH_THRESHOLD: 80`
- 匹配度 ≥ 80 的检查点纳入最终输出结果。
- 匹配度 < 80 的检查点保留在 `all_scored_results` 中,供对话修正指令(保留/去掉/增加)操作。
### 评分公式
综合分 = 向量余弦相似度 + 来源约束加分 + 类别命中加分 + 关键词命中加分
#### 来源约束
- 当用户输入包含“来自 X”“来源为 X”“与 X 有关”等表达时,X 应作为风险点来源约束。
- 有明确来源约束时,应先限定“风险点来源”包含该来源的检查点,再进行主题相关性评分。
- 来源约束命中:`+0.36`
- 来源约束命中且同时命中有效主题关键词时,综合分至少提升至 `1.08`,确保直接匹配行不因向量分偏低被漏召回。
#### 类别加分
- 业务板块命中:`+0.18`
- 一级项目命中:`+0.12`
- 二级项目命中:`+0.12`
#### 关键词加分
- 二级项目包含关键词:`+0.12`
- 一级项目或业务板块包含关键词:`+0.09`
- 风险点来源包含关键词:`+0.16`
- 文本内容包含关键词:`+0.06`
#### 匹配度归一化
- 综合分 → 匹配度 = `min(100, round(综合分 / 1.3 * 100))`
- ≥85:高
- ≥70:较高
- ≥55:中
- <55:低
### 检索策略
1. 对全部文档计算向量余弦相似度(矩阵乘法,毫秒级)。
2. 如果用户明确指定来源,先按“风险点来源”字段进行硬过滤。
3. 对过滤后的文档计算来源、类别和关键词加分。
4. 合并得到综合分,归一化为 0-100 匹配度。
5. 按匹配度降序排列,筛选 ≥ 80 的文档作为输出结果。
6. 全部评分文档(含低于阈值的)存入 `all_scored_results` 供调整指令使用。
### 展示要求
- 主结果摘要中标注匹配度 ≥ 80 的条目数量与总检索条数,例如:“检索到 1450 条,匹配度 ≥ 80 分的有 47 条”。
- 审计内容摘要中,每个二级项目仅显示风险点个数,不展开详情。
- 用户可通过“查看详情”展开面板查看每个二级项目下的完整风险点信息(含匹配度、描述、法规依据、来源、等级)。
- 调整指令(如“去掉信息技术相关的”)基于 `all_scored_results` 操作,而非仅基于上次输出的子集。
### Word 导出阈值
- 导出 Word 文档时,系统要求用户设定最低匹配度阈值(默认为 `MATCH_THRESHOLD`)。
- 仅匹配度 ≥ 用户设定阈值的风险点被纳入 Word 文档输出。
- 用户应在导出前通过“查看详情”浏览各风险点分值,确定合适的阈值。
## LLM 关键词扩展规则
### 目标
根据用户输入的审计项目或问题,生成用于本地知识库检索和重排的扩展关键词。
### 关键词生成原则
- 关键词必须围绕用户输入的审计主题。
- 优先生成基金行业、审计、合规、风险管理语境下的专业词。
- 应包含同义词、近义词、监管常用表述、风险点常见表述。
- 关键词用于检索和排序,不得直接作为事实输出。
### 数量与长度
- 生成 10-20 个关键词。
- 每个关键词建议 2-8 个字。
- 用逗号分隔。
- 不要编号,不要解释。
### 示例
#### 反洗钱专项审计
可扩展为:
反洗钱, 洗钱风险, 可疑交易, 大额交易, 客户身份识别, 身份尽职调查, 客户风险等级, 黑名单, 制裁名单, 交易监控, 反洗钱培训, 监管报送
#### 员工行为管理专项审计
可扩展为:
员工行为, 从业人员, 投资行为, 通讯工具, 信息保密, 廉洁从业, 利益输送, 人员资质, 任免管理, 薪酬管理, 问责管理, 员工培训
#### 采购管理专项审计
可扩展为:
采购, 供应商, 招标, 询价, 合同, 验收, 付款, 服务机构, 外包, 第三方, 廉洁从业, 服务协议
### 禁止事项
- 不要生成与用户审计主题无关的宽泛词。
- 不要输出完整句子。
- 不要输出结论或法规解释。
import { UploadOutlined } from '@ant-design/icons'; import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
import { DataGrid } from '@heroui-pro/react/data-grid'; import { DataGrid } from '@heroui-pro/react/data-grid';
import { DropZone } from '@heroui-pro/react/drop-zone'; import { DropZone } from '@heroui-pro/react/drop-zone';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
...@@ -19,7 +19,7 @@ import { ...@@ -19,7 +19,7 @@ import {
} from '@heroui/react'; } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { AUDIT_MATCH_THRESHOLD } from './constants'; import { AUDIT_MATCH_THRESHOLD } from './constants';
import { groupAuditRecords } from './engine'; import { getAuditRecordKey, groupAuditRecords } from './engine';
import { parseAuditExcel } from './excel'; import { parseAuditExcel } from './excel';
import { import {
exportAuditExcel, exportAuditExcel,
...@@ -115,7 +115,14 @@ export function AuditKnowledgeActions({ ...@@ -115,7 +115,14 @@ export function AuditKnowledgeActions({
); );
} }
export function AuditPromptConfigDialog({ open, value, onSave, onCancel }) { export function AuditPromptConfigDialog({
open,
value,
skillObject,
onUploadSkill,
onSave,
onCancel,
}) {
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
const [context, setContext] = useState(''); const [context, setContext] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
...@@ -158,6 +165,62 @@ export function AuditPromptConfigDialog({ open, value, onSave, onCancel }) { ...@@ -158,6 +165,62 @@ export function AuditPromptConfigDialog({ open, value, onSave, onCancel }) {
<Modal.Heading>审计提示词配置</Modal.Heading> <Modal.Heading>审计提示词配置</Modal.Heading>
</Modal.Header> </Modal.Header>
<Modal.Body className="grid gap-6"> <Modal.Body className="grid gap-6">
{skillObject?.skills?.length ? (
<Surface className="grid gap-3 p-4" variant="tertiary">
<div className="flex flex-wrap items-center justify-between gap-2">
<strong>审计 Skill 组成</strong>
<Description>
{skillObject.businessRules?.length || 0} 条业务过滤规则
</Description>
</div>
<ul className="grid gap-2">
{skillObject.skills.map((skill) => (
<li key={skill.id}>
<strong>{skill.title}</strong>
<span className="ml-2 text-muted">
{skill.description}
</span>
</li>
))}
</ul>
{skillObject.businessRules?.length ? (
<div className="grid gap-1">
<strong>已保存的业务过滤</strong>
{skillObject.businessRules.map((rule) => (
<Description key={rule.id}>
{rule.domain || '全部专项'}:排除{' '}
{rule.removeTerms?.join('、') || rule.label}
</Description>
))}
</div>
) : null}
</Surface>
) : null}
<Surface className="grid gap-2 p-4" variant="secondary">
<strong>维护公共 Skill 文件</strong>
<Description>
上传 Markdown 或 JSON 后会解析校验,并替换公共 Skill
文件;后续所有专项自动使用新内容。
</Description>
<input
accept=".md,.markdown,.json"
type="file"
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
try {
await onUploadSkill?.(file);
toast.success('公共 Skill 文件已更新');
} catch (error) {
toast.danger(
`Skill 文件上传失败:${error.message || '未知错误'}`,
);
} finally {
event.target.value = '';
}
}}
/>
</Surface>
<TextField <TextField
fullWidth fullWidth
isRequired isRequired
...@@ -201,9 +264,10 @@ export function AuditPromptConfigDialog({ open, value, onSave, onCancel }) { ...@@ -201,9 +264,10 @@ export function AuditPromptConfigDialog({ open, value, onSave, onCancel }) {
); );
} }
function RiskDetail({ risk, index }) { function RiskDetail({ risk, index, onExclude }) {
const details = [ const details = [
['匹配度', `${risk.matchScore}分(${risk.matchLevel || '未分级'})`], ['匹配度', `${risk.matchScore}分(${risk.matchLevel || '未分级'})`],
...(risk.aiRationale ? [['AI 分析依据', risk.aiRationale]] : []),
['具体风险点描述', risk.riskDescription], ['具体风险点描述', risk.riskDescription],
['法规依据', risk.regulationBasis], ['法规依据', risk.regulationBasis],
['风险点来源', risk.riskSource], ['风险点来源', risk.riskSource],
...@@ -212,9 +276,22 @@ function RiskDetail({ risk, index }) { ...@@ -212,9 +276,22 @@ function RiskDetail({ risk, index }) {
return ( return (
<Surface className="grid gap-3 p-4" variant="tertiary"> <Surface className="grid gap-3 p-4" variant="tertiary">
<strong> <div className="flex flex-wrap items-center justify-between gap-2">
风险点 {index + 1}(源文件第 {risk.sourceRow || '未知'} 行) <strong>
</strong> 风险点 {index + 1}(源文件第 {risk.sourceRow || '未知'} 行)
</strong>
{onExclude ? (
<Button
aria-label="排除并记住该风险点"
size="sm"
variant="secondary"
onPress={() => onExclude(risk)}
>
<DeleteOutlined />
排除并记住
</Button>
) : null}
</div>
<dl className="grid gap-2"> <dl className="grid gap-2">
{details.map(([label, detail]) => ( {details.map(([label, detail]) => (
<div <div
...@@ -243,19 +320,31 @@ export function AuditChecklistResult({ ...@@ -243,19 +320,31 @@ export function AuditChecklistResult({
result, result,
aiContent = '', aiContent = '',
allowWordExport = false, allowWordExport = false,
onSaveSkillRule,
}) { }) {
const [threshold, setThreshold] = useState( const [threshold, setThreshold] = useState(
Number(result.threshold || AUDIT_MATCH_THRESHOLD), Number(result.threshold || AUDIT_MATCH_THRESHOLD),
); );
const [wordLoading, setWordLoading] = useState(false); const [wordLoading, setWordLoading] = useState(false);
const [excludedRiskIds, setExcludedRiskIds] = useState(new Set());
useEffect(() => { useEffect(() => {
setThreshold(Number(result.threshold || AUDIT_MATCH_THRESHOLD)); setThreshold(Number(result.threshold || AUDIT_MATCH_THRESHOLD));
setExcludedRiskIds(new Set());
}, [result.generatedAt, result.threshold]); }, [result.generatedAt, result.threshold]);
const effectiveResult = useMemo(
() => ({
...result,
records: (result.records || []).filter(
(record) => !excludedRiskIds.has(getAuditRecordKey(record)),
),
}),
[excludedRiskIds, result],
);
const visibleRecords = useMemo( const visibleRecords = useMemo(
() => filterAuditRecords(result, threshold), () => filterAuditRecords(effectiveResult, threshold),
[result, threshold], [effectiveResult, threshold],
); );
const groups = useMemo( const groups = useMemo(
() => groupAuditRecords(visibleRecords), () => groupAuditRecords(visibleRecords),
...@@ -270,7 +359,12 @@ export function AuditChecklistResult({ ...@@ -270,7 +359,12 @@ export function AuditChecklistResult({
const handleWordExport = async () => { const handleWordExport = async () => {
setWordLoading(true); setWordLoading(true);
try { try {
await exportAuditWord(result, aiContent, threshold); await exportAuditWord(
effectiveResult,
aiContent,
threshold,
effectiveResult.reportStyle,
);
} catch (error) { } catch (error) {
toast.danger(`Word 导出失败:${error.message || '未知错误'}`); toast.danger(`Word 导出失败:${error.message || '未知错误'}`);
} finally { } finally {
...@@ -278,6 +372,33 @@ export function AuditChecklistResult({ ...@@ -278,6 +372,33 @@ export function AuditChecklistResult({
} }
}; };
const handleExcludeRisk = async (risk) => {
const riskId = getAuditRecordKey(risk);
setExcludedRiskIds((current) => new Set([...current, riskId]));
try {
await onSaveSkillRule?.({
domain: result.analysis?.auditDomain || result.project,
riskPointIds: [getAuditRecordKey(risk)],
removeTerms: [risk.riskDescription || risk.level2Project].filter(
Boolean,
),
label: `排除:${
risk.riskDescription || risk.level2Project || '该风险点'
}`,
description: '业务人员从审计结果中排除该风险点。',
sourceQuery: result.request || result.project,
});
toast.success('风险点已从本次报告移除,并写入审计 Skill');
} catch (error) {
setExcludedRiskIds((current) => {
const next = new Set(current);
next.delete(riskId);
return next;
});
toast.danger(`保存 Skill 规则失败:${error.message || '未知错误'}`);
}
};
return ( return (
<section aria-label="审计风险点结果" className="grid gap-6"> <section aria-label="审计风险点结果" className="grid gap-6">
{!isQa ? ( {!isQa ? (
...@@ -295,6 +416,41 @@ export function AuditChecklistResult({ ...@@ -295,6 +416,41 @@ export function AuditChecklistResult({
</> </>
) : null} ) : null}
{!isQa && result.analysis ? (
<Surface className="grid gap-2 p-4" variant="tertiary">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4>AI 专项分析</h4>
<Chip size="sm">
置信度 {Math.round(Number(result.analysis.confidence || 0) * 100)}
</Chip>
</div>
<p>
扩展业务语义:
{result.analysis.expandedTopics?.join('、') || '模型未返回额外语义'}
</p>
{result.analysis.coverageNote ? (
<Description>{result.analysis.coverageNote}</Description>
) : null}
{result.skillRulesApplied?.length ? (
<Description>
已应用业务 Skill:{result.skillRulesApplied.join('、')}
</Description>
) : null}
{result.analysis.analysisTrace ? (
<Description>
已完成分批分析:
{result.analysis.analysisTrace.rounds?.length || 0} 轮, 共处理{' '}
{result.analysis.analysisTrace.recordsAnalyzed ||
result.totalCount}{' '}
条记录
{result.analysis.analysisTrace.failedBatches?.length
? `,${result.analysis.analysisTrace.failedBatches.length} 个批次失败后跳过`
: ''}
</Description>
) : null}
</Surface>
) : null}
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h4>{isQa ? '引用风险点' : '审计内容'}</h4> <h4>{isQa ? '引用风险点' : '审计内容'}</h4>
...@@ -356,6 +512,7 @@ export function AuditChecklistResult({ ...@@ -356,6 +512,7 @@ export function AuditChecklistResult({
index={riskIndex} index={riskIndex}
key={`${risk.id || risk.sourceRow}-${riskIndex}`} key={`${risk.id || risk.sourceRow}-${riskIndex}`}
risk={risk} risk={risk}
onExclude={!isQa ? handleExcludeRisk : null}
/> />
))} ))}
</Disclosure.Body> </Disclosure.Body>
...@@ -402,13 +559,17 @@ export function AuditChecklistResult({ ...@@ -402,13 +559,17 @@ export function AuditChecklistResult({
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
variant="secondary" variant="secondary"
onPress={() => exportAuditMarkdown(result, aiContent, threshold)} onPress={() =>
exportAuditMarkdown(effectiveResult, aiContent, threshold)
}
> >
Markdown Markdown
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
onPress={() => exportAuditExcel(result, aiContent, threshold)} onPress={() =>
exportAuditExcel(effectiveResult, aiContent, threshold)
}
> >
Excel Excel
</Button> </Button>
......
export const AUDIT_KNOWLEDGE_FILE_SUBJECT = 'audit_knowledge_file'; export const AUDIT_KNOWLEDGE_FILE_SUBJECT = 'audit_knowledge_file';
export const AUDIT_SKILL_SUBJECT = 'ai_skill';
export const AUDIT_MATCH_THRESHOLD = 80; export const AUDIT_MATCH_THRESHOLD = 80;
export const AUDIT_PROMPT_RECORD_LIMIT = 80; export const AUDIT_PROMPT_RECORD_LIMIT = 80;
......
...@@ -6,6 +6,11 @@ import { ...@@ -6,6 +6,11 @@ import {
AUDIT_PROMPT_RECORD_LIMIT, AUDIT_PROMPT_RECORD_LIMIT,
AUDIT_STOP_WORDS, AUDIT_STOP_WORDS,
} from './constants'; } from './constants';
import {
auditSkillRuleMatchesRecord,
collectAuditSkillInstructions,
} from './skills';
import { isAuditStyleRequest } from './style';
const GENERATE_PATTERNS = [ const GENERATE_PATTERNS = [
'生成', '生成',
...@@ -117,6 +122,247 @@ export function applyAuditQueryExpansion(query = '', payload = {}) { ...@@ -117,6 +122,247 @@ export function applyAuditQueryExpansion(query = '', payload = {}) {
}; };
} }
export function getAuditRecordKey(record = '') {
if (typeof record === 'string') return record;
const explicitKey = record.id || record.riskPointCode;
if (explicitKey) return String(explicitKey);
const sourceKey =
record.sheetName && record.sourceRow
? `${record.sheetName}:${record.sourceRow}`
: '';
return sourceKey || auditRecordIdentity(record, true);
}
function compactAuditRecord(record) {
return {
id: getAuditRecordKey(record),
businessSegment: record.businessSegment || '',
level1Project: record.level1Project || '',
level2Project: record.level2Project || '',
riskDescription: record.riskDescription || '',
regulationBasis: record.regulationBasis || '',
riskSource: record.riskSource || '',
riskLevel: record.riskLevel || '',
keywords: Array.isArray(record.keywords)
? record.keywords.join('、')
: record.keywords || '',
};
}
export function buildAuditAnalysisPrompt({
query = '',
records = [],
skillObject = null,
previousResult = null,
maxRecords = records.length,
analysisMode = 'candidate',
batchNumber = 1,
batchCount = 1,
}) {
const skill = collectAuditSkillInstructions(skillObject, query);
const context = records.slice(0, maxRecords).map(compactAuditRecord);
return [
'你是审计专项分析智能体。你的任务是理解业务人员的自然语言审计请求,并从结构化审计风险点上下文中选择相关风险点。',
'不要按单一关键词机械检索,要先识别审计主题、相关业务环节、控制目标、可疑点、数据对象和潜在责任部门,再依据风险点的完整语义做判断。',
'只能从 <risk_points> 中选择风险点 id;不得创造风险点、法规依据或事实。',
'如果某风险点与主题只存在弱关联,可以不选,并在 rationale 中说明覆盖不足。',
'如果存在业务 Skill 规则,必须自动排除规则指定的风险点,并在 appliedBusinessRules 中记录规则 id。',
analysisMode === 'candidate'
? '当前是候选初筛阶段:宁可保留可能相关的风险点,供后续汇总复核;不要因为上下文分块而漏掉候选。'
: analysisMode === 'consolidate'
? '当前是候选收敛阶段:只保留与专项主题有明确语义关系的风险点,删除弱相关、重复或泛化风险点。'
: '当前是最终复核阶段:只保留可以直接支持该专项审计的风险点,并为每条风险点给出简短分析依据。',
'请只输出 JSON,不要 Markdown、解释或代码围栏。',
'JSON 格式:',
'{"auditDomain":"","expandedTopics":[],"selectedRiskPointIds":[],"excludedRiskPointIds":[],"riskRationale":[],"appliedBusinessRules":[],"confidence":0,"coverageNote":""}',
'',
`用户审计请求:${query}`,
previousResult?.project ? `上一次审计专项:${previousResult.project}` : '',
skill.instructions.length
? `Skill 通用规则:\n${skill.instructions
.map((item) => `- ${item}`)
.join('\n')}`
: '',
skill.businessRules.length
? `Skill 业务过滤规则:\n${skill.businessRules
.map(
(rule) =>
`- ${rule.id}(专项:${
rule.domain || '全部'
}):排除 ${rule.removeTerms.join('、')}`,
)
.join('\n')}`
: 'Skill 业务过滤规则:无',
'',
`当前批次:${batchNumber}/${batchCount};批次记录数:${context.length};本轮总记录数:${records.length}`,
analysisMode === 'candidate'
? '候选阶段输出要求:返回本批次可能相关的风险点 ID,selectedRiskPointIds 可以适度偏宽。'
: '收敛阶段输出要求:返回本批次最终建议保留的风险点 ID,selectedRiskPointIds 应尽量去重且严格。',
`<risk_points count="${context.length}" total="${records.length}">`,
JSON.stringify(context),
'</risk_points>',
]
.filter(Boolean)
.join('\n');
}
function normalizeAnalysisList(value) {
return (Array.isArray(value) ? value : value ? [value] : [])
.map((item) => {
if (item && typeof item === 'object') {
return String(
item.id || item.riskPointId || item.risk_point_id || item.key || '',
).trim();
}
return String(item || '').trim();
})
.filter(Boolean);
}
export function normalizeAuditAnalysis(payload = {}, records = []) {
const available = new Map(
records.map((record) => [getAuditRecordKey(record), record]),
);
const selectedIds = normalizeAnalysisList(
payload.selectedRiskPointIds ||
payload.selected_risk_point_ids ||
payload.riskPointIds,
).filter((id) => available.has(id));
const excludedIds = normalizeAnalysisList(
payload.excludedRiskPointIds || payload.excluded_risk_point_ids,
).filter((id) => available.has(id));
const rawRationale = payload.riskRationale || payload.risk_rationale;
const rationaleItems = Array.isArray(rawRationale)
? rawRationale
: rawRationale && typeof rawRationale === 'object'
? Object.entries(rawRationale).map(([id, reason]) => ({ id, reason }))
: [];
const rationale = rationaleItems.length
? rationaleItems
.map((item) => {
if (!item || typeof item !== 'object') return null;
const id = String(
item.id || item.riskPointId || item.risk_point_id || '',
).trim();
return id && available.has(id)
? { id, reason: String(item.reason || item.rationale || '').trim() }
: null;
})
.filter(Boolean)
: [];
return {
auditDomain: String(
payload.auditDomain || payload.audit_domain || '',
).trim(),
expandedTopics: normalizeAnalysisList(
payload.expandedTopics || payload.expanded_topics || payload.topics,
).slice(0, 20),
selectedRiskPointIds: Array.from(new Set(selectedIds)),
excludedRiskPointIds: Array.from(new Set(excludedIds)),
riskRationale: rationale,
appliedBusinessRules: normalizeAnalysisList(
payload.appliedBusinessRules || payload.applied_business_rules,
),
confidence: Math.max(0, Math.min(1, Number(payload.confidence) || 0)),
coverageNote: String(
payload.coverageNote || payload.coverage_note || '',
).trim(),
analysisTrace: payload.analysisTrace || payload.analysis_trace || null,
};
}
export function buildAuditResultFromAnalysis(
query,
records,
analysis,
previousResult = null,
options = {},
) {
const isRefine = isAuditRefineRequest(query, previousResult);
const baseQuery = isRefine ? previousResult.project : query;
const instruction = isRefine
? parseRefineInstruction(query, previousResult.threshold)
: { keep: [], remove: [], add: [], threshold: AUDIT_MATCH_THRESHOLD };
const normalized = normalizeAuditAnalysis(analysis, records);
const selectedIds = new Set(normalized.selectedRiskPointIds);
normalized.excludedRiskPointIds.forEach((id) => selectedIds.delete(id));
const rationale = new Map(
normalized.riskRationale.map((item) => [item.id, item.reason]),
);
const analysisScore = Math.max(
instruction.threshold,
Math.round(normalized.confidence * 100) || instruction.threshold,
);
let selected = records
.filter((record) => selectedIds.has(getAuditRecordKey(record)))
.map((record) => ({
...record,
matchScore: analysisScore,
matchLevel: matchScoreLevel(analysisScore),
matchReasons: [
rationale.get(getAuditRecordKey(record)) || 'AI 语义分析命中',
],
aiRationale: rationale.get(getAuditRecordKey(record)) || '',
}));
const skillContext = collectAuditSkillInstructions(
options.skillObject,
baseQuery,
);
const effectiveSkillRules = options.skillRules || skillContext.businessRules;
const excludedBySkill = new Set();
effectiveSkillRules.forEach((rule) => {
selected = selected.filter((record) => {
const matches = auditSkillRuleMatchesRecord(record, rule);
if (matches) excludedBySkill.add(rule.id);
return !matches;
});
});
if (instruction.remove.length) {
selected = selected.filter(
(record) => !recordMatchesTerms(record, instruction.remove),
);
}
if (instruction.keep.length) {
selected = selected.filter((record) =>
recordMatchesTerms(record, instruction.keep),
);
}
const selectedIdsAfterRules = new Set(selected.map(getAuditRecordKey));
const analyzedRecords = records.map((record) => ({
...record,
aiSelected: selectedIds.has(getAuditRecordKey(record)),
aiExcluded: !selectedIdsAfterRules.has(getAuditRecordKey(record)),
skillExcluded:
selectedIds.has(getAuditRecordKey(record)) &&
!selectedIdsAfterRules.has(getAuditRecordKey(record)),
aiRationale: rationale.get(getAuditRecordKey(record)) || '',
}));
return {
version: 2,
kind: classifyAuditIntent(isRefine ? baseQuery : query),
project: baseQuery,
request: query,
refined: Boolean(isRefine),
instruction,
analysis: normalized,
expandedKeywords: normalized.expandedTopics,
skillRulesApplied: Array.from(
new Set([...normalized.appliedBusinessRules, ...excludedBySkill]),
),
threshold: instruction.threshold,
totalCount: records.length,
scoredCount: analyzedRecords.length,
matchedCount: selected.length,
records: selected,
allScoredRecords: analyzedRecords,
groups: groupAuditRecords(selected),
generatedAt: new Date().toISOString(),
selectedRiskPointIds: Array.from(selectedIdsAfterRules),
reportStyle: skillContext.styles,
};
}
export function normalizeRecordList(res) { export function normalizeRecordList(res) {
if (Array.isArray(res)) return res; if (Array.isArray(res)) return res;
if (Array.isArray(res?.records)) return res.records; if (Array.isArray(res?.records)) return res.records;
...@@ -519,6 +765,36 @@ export function classifyAuditIntent(query = '') { ...@@ -519,6 +765,36 @@ export function classifyAuditIntent(query = '') {
return 'checklist'; return 'checklist';
} }
export function classifyAuditAction(query = '', previousResult = null) {
const text = String(query || '');
if (previousResult && isAuditStyleRequest(text)) return 'style_adjust';
if (previousResult && isAuditRefineRequest(text, previousResult)) {
return 'refine_scope';
}
if (QA_PATTERNS.some((pattern) => text.includes(pattern))) return 'qa';
return 'new_audit';
}
export function isPersistentAuditRuleRequest(query = '') {
return /(以后|今后|长期|记住|保存到.?Skill|写入.?Skill|都不要|后续都)/i.test(
String(query),
);
}
export function buildAuditStylePrompt(changes = [], persisted = false) {
return [
'这是一次审计报告 Word 排版调整请求。',
changes.length
? `已应用的样式调整:${changes.join(';')}`
: '未识别到具体样式参数,请用户提供字体、字号或行距等明确要求。',
persisted
? '该样式已保存为公共 Skill 的默认样式。'
: '该调整仅作用于当前报告。',
'风险点、法规依据、来源、风险等级和审计分析结果保持不变。',
'请简短确认,不要重新生成审计风险点清单。',
].join('\n');
}
function cleanInstructionValue(value = '') { function cleanInstructionValue(value = '') {
return value return value
.replace( .replace(
...@@ -561,8 +837,22 @@ export function parseRefineInstruction(query = '', previousThreshold) { ...@@ -561,8 +837,22 @@ export function parseRefineInstruction(query = '', previousThreshold) {
return instruction; return instruction;
} }
function recordMatchesTerms(record, terms) { export function recordMatchesTerms(record, terms) {
return terms.some((term) => term && record.searchText.includes(term)); const text =
record?.searchText ||
[
record?.businessSegment,
record?.level1Project,
record?.level2Project,
record?.riskDescription,
record?.regulationBasis,
record?.riskSource,
record?.riskLevel,
record?.keywords,
]
.filter(Boolean)
.join(' ');
return (terms || []).some((term) => term && text.includes(term));
} }
function scoreAllRecords(records, query, extraKeywords = []) { function scoreAllRecords(records, query, extraKeywords = []) {
...@@ -612,24 +902,40 @@ export function buildAuditResult( ...@@ -612,24 +902,40 @@ export function buildAuditResult(
recordMatchesTerms(record, instruction.keep), recordMatchesTerms(record, instruction.keep),
); );
} }
const skillContext = collectAuditSkillInstructions(
options.skillObject,
baseQuery,
);
const skillRules = options.skillRules || skillContext.businessRules;
const skillRulesApplied = [];
skillRules.forEach((rule) => {
const beforeCount = allScored.length;
allScored = allScored.filter(
(record) => !auditSkillRuleMatchesRecord(record, rule),
);
if (allScored.length !== beforeCount) skillRulesApplied.push(rule.id);
});
const selected = allScored.filter( const selected = allScored.filter(
(record) => record.matchScore >= instruction.threshold, (record) => record.matchScore >= instruction.threshold,
); );
return { return {
version: 1, version: 2,
kind: classifyAuditIntent(isRefine ? baseQuery : query), kind: classifyAuditIntent(isRefine ? baseQuery : query),
project: baseQuery, project: baseQuery,
request: query, request: query,
refined: Boolean(isRefine), refined: Boolean(isRefine),
instruction, instruction,
expandedKeywords: options.extraKeywords || [], expandedKeywords: options.extraKeywords || [],
skillRulesApplied,
threshold: instruction.threshold, threshold: instruction.threshold,
totalCount: records.length, totalCount: records.length,
scoredCount: allScored.length, scoredCount: allScored.length,
matchedCount: selected.length, matchedCount: selected.length,
records: selected, records: selected,
allScoredRecords: allScored,
groups: groupAuditRecords(selected), groups: groupAuditRecords(selected),
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
reportStyle: skillContext.styles,
}; };
} }
...@@ -642,8 +948,13 @@ export function findPreviousAuditResult(messages = []) { ...@@ -642,8 +948,13 @@ export function findPreviousAuditResult(messages = []) {
return null; return null;
} }
export function buildGroundedAuditKnowledgePrompt(records = [], request = '') { export function buildGroundedAuditKnowledgePrompt(
const context = records records = [],
request = '',
maxRecords = AUDIT_PROMPT_RECORD_LIMIT,
) {
const selectedRecords = records.slice(0, maxRecords);
const context = selectedRecords
.map((record, index) => buildRiskPointMarkdown(record, index + 1)) .map((record, index) => buildRiskPointMarkdown(record, index + 1))
.join('\n\n'); .join('\n\n');
...@@ -658,7 +969,10 @@ export function buildGroundedAuditKnowledgePrompt(records = [], request = '') { ...@@ -658,7 +969,10 @@ export function buildGroundedAuditKnowledgePrompt(records = [], request = '') {
return [ return [
'以下“知识库原文”是回答用户问题时唯一允许使用的事实依据。', '以下“知识库原文”是回答用户问题时唯一允许使用的事实依据。',
`知识库共 ${records.length} 条记录。`, `知识库候选共 ${records.length} 条记录,本轮提供 ${selectedRecords.length} 条。`,
records.length > selectedRecords.length
? '候选已经过分批语义分析;当前上下文仅包含最终候选,不代表原始知识库只有这些记录。'
: '',
`用户请求:${request}`, `用户请求:${request}`,
'', '',
'硬性规则:', '硬性规则:',
...@@ -691,6 +1005,22 @@ export function buildAuditKnowledgePrompt(result) { ...@@ -691,6 +1005,22 @@ export function buildAuditKnowledgePrompt(result) {
) )
.join('\n\n'); .join('\n\n');
const stats = `知识库共 ${result.totalCount} 条,全量评分 ${result.scoredCount} 条,匹配度 >= ${result.threshold} 分的有 ${result.matchedCount} 条。`; const stats = `知识库共 ${result.totalCount} 条,全量评分 ${result.scoredCount} 条,匹配度 >= ${result.threshold} 分的有 ${result.matchedCount} 条。`;
const analysisSummary = result.analysis
? [
`AI 识别审计主题:${result.analysis.auditDomain || result.project}`,
`AI 扩展业务语义:${
(result.analysis.expandedTopics || []).join('、') || '无'
}`,
`AI 分析置信度:${Math.round(
Number(result.analysis.confidence || 0) * 100,
)}分`,
result.analysis.coverageNote
? `AI 覆盖说明:${result.analysis.coverageNote}`
: '',
]
.filter(Boolean)
.join('\n')
: '';
if (!records.length) { if (!records.length) {
return [ return [
...@@ -717,6 +1047,7 @@ export function buildAuditKnowledgePrompt(result) { ...@@ -717,6 +1047,7 @@ export function buildAuditKnowledgePrompt(result) {
return [ return [
'审计检查清单知识库上下文:', '审计检查清单知识库上下文:',
stats, stats,
analysisSummary,
AUDIT_CHECKLIST_RULES, AUDIT_CHECKLIST_RULES,
context, context,
'', '',
......
import JSZip from 'jszip'; import JSZip from 'jszip';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { groupAuditRecords } from './engine'; import { groupAuditRecords } from './engine';
import { DEFAULT_WORD_STYLE, normalizeWordStyle } from './style';
const SUMMARY_FIELDS = [ const SUMMARY_FIELDS = [
'审计项目', '审计项目',
...@@ -23,6 +24,22 @@ const WORD_SECTION_ORDER = [ ...@@ -23,6 +24,22 @@ const WORD_SECTION_ORDER = [
const WORD_FONT = '宋体'; const WORD_FONT = '宋体';
function pointsToHalfPoints(value) {
return String(Math.round(Number(value || 0) * 2));
}
function pointsToTwips(value) {
return String(Math.round(Number(value || 0) * 20));
}
function lineSpacingToTwips(value) {
return String(Math.round(Number(value || 1) * 240));
}
function mmToTwips(value) {
return String(Math.round(Number(value || 0) * 56.6929));
}
function escapeXml(value = '') { function escapeXml(value = '') {
return String(value) return String(value)
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
...@@ -93,6 +110,53 @@ export function buildChecklistMarkdown(result, aiContent = '', threshold) { ...@@ -93,6 +110,53 @@ export function buildChecklistMarkdown(result, aiContent = '', threshold) {
`审计项目描述:${result.project || summary.审计项目 || ''}`, `审计项目描述:${result.project || summary.审计项目 || ''}`,
'', '',
]; ];
if (result.analysis) {
lines.push(
'## AI 专项分析',
'',
`审计主题:${result.analysis.auditDomain || result.project || ''}`,
`扩展业务语义:${
(result.analysis.expandedTopics || []).join('、') || '(无)'
}`,
`分析置信度:${Math.round(
Number(result.analysis.confidence || 0) * 100,
)}分`,
`覆盖说明:${
result.analysis.coverageNote || '基于审计风险点知识库完成语义分析'
}`,
result.analysis.analysisTrace
? `分批分析:${
result.analysis.analysisTrace.rounds?.length || 0
} 轮,处理 ${
result.analysis.analysisTrace.recordsAnalyzed || result.totalCount
} 条记录${
result.analysis.analysisTrace.failedBatches?.length
? `,失败批次 ${result.analysis.analysisTrace.failedBatches.length}`
: ''
}`
: '',
'',
);
}
if (result.analysis?.analysisTrace) {
lines.push(
'## 分批分析记录',
'',
`策略:${result.analysis.analysisTrace.strategy || 'map_reduce_loop'}`,
`分析轮次:${result.analysis.analysisTrace.rounds?.length || 0}`,
`处理记录数:${
result.analysis.analysisTrace.recordsAnalyzed || result.totalCount
}`,
`失败批次:${result.analysis.analysisTrace.failedBatches?.length || 0}`,
'',
);
}
if (result.skillRulesApplied?.length) {
lines.push(
`已应用业务过滤规则:${result.skillRulesApplied.join('、')}`,
'',
);
}
WORD_SECTION_ORDER.forEach((field) => { WORD_SECTION_ORDER.forEach((field) => {
lines.push(`## ${field}`, ''); lines.push(`## ${field}`, '');
if (field !== '审计内容') { if (field !== '审计内容') {
...@@ -182,6 +246,7 @@ export function exportAuditExcel(result, aiContent, threshold) { ...@@ -182,6 +246,7 @@ export function exportAuditExcel(result, aiContent, threshold) {
法规依据: record.regulationBasis, 法规依据: record.regulationBasis,
风险点来源: record.riskSource, 风险点来源: record.riskSource,
风险等级: record.riskLevel, 风险等级: record.riskLevel,
AI分析依据: record.aiRationale,
来源行号: record.sourceRow, 来源行号: record.sourceRow,
})), })),
); );
...@@ -196,6 +261,7 @@ export function exportAuditExcel(result, aiContent, threshold) { ...@@ -196,6 +261,7 @@ export function exportAuditExcel(result, aiContent, threshold) {
{ wch: 50 }, { wch: 50 },
{ wch: 22 }, { wch: 22 },
{ wch: 12 }, { wch: 12 },
{ wch: 50 },
{ wch: 10 }, { wch: 10 },
]; ];
XLSX.utils.book_append_sheet(workbook, riskSheet, '风险点明细'); XLSX.utils.book_append_sheet(workbook, riskSheet, '风险点明细');
...@@ -239,6 +305,8 @@ function wordParagraph( ...@@ -239,6 +305,8 @@ function wordParagraph(
firstLine = '', firstLine = '',
leftIndent = '', leftIndent = '',
spacingAfter = '', spacingAfter = '',
line = '',
lineRule = 'auto',
} = {}, } = {},
) { ) {
const properties = [ const properties = [
...@@ -246,7 +314,11 @@ function wordParagraph( ...@@ -246,7 +314,11 @@ function wordParagraph(
alignment ? `<w:jc w:val="${alignment}"/>` : '', alignment ? `<w:jc w:val="${alignment}"/>` : '',
firstLine ? `<w:ind w:firstLine="${firstLine}"/>` : '', firstLine ? `<w:ind w:firstLine="${firstLine}"/>` : '',
leftIndent ? `<w:ind w:left="${leftIndent}"/>` : '', leftIndent ? `<w:ind w:left="${leftIndent}"/>` : '',
spacingAfter !== '' ? `<w:spacing w:after="${spacingAfter}"/>` : '', spacingAfter !== '' || line !== ''
? `<w:spacing${spacingAfter !== '' ? ` w:after="${spacingAfter}"` : ''}${
line !== '' ? ` w:line="${line}" w:lineRule="${lineRule}"` : ''
}/>`
: '',
].join(''); ].join('');
return `<w:p>${properties ? `<w:pPr>${properties}</w:pPr>` : ''}${ return `<w:p>${properties ? `<w:pPr>${properties}</w:pPr>` : ''}${
Array.isArray(runs) ? runs.join('') : runs Array.isArray(runs) ? runs.join('') : runs
...@@ -262,46 +334,85 @@ function formatWordDate(value = new Date()) { ...@@ -262,46 +334,85 @@ function formatWordDate(value = new Date()) {
)}`; )}`;
} }
function createWordStylesXml() { function createWordStylesXml(style = DEFAULT_WORD_STYLE) {
const fonts = `<w:rFonts w:ascii="${WORD_FONT}" w:eastAsia="${WORD_FONT}" w:hAnsi="${WORD_FONT}"/>`; const normalized = normalizeWordStyle(style);
const fonts = `<w:rFonts w:ascii="${normalized.fontFamily}" w:eastAsia="${normalized.fontFamily}" w:hAnsi="${normalized.fontFamily}"/>`;
const heading1Fonts = `<w:rFonts w:ascii="${normalized.heading1FontFamily}" w:eastAsia="${normalized.heading1FontFamily}" w:hAnsi="${normalized.heading1FontFamily}"/>`;
const heading2Fonts = `<w:rFonts w:ascii="${normalized.heading2FontFamily}" w:eastAsia="${normalized.heading2FontFamily}" w:hAnsi="${normalized.heading2FontFamily}"/>`;
const heading3Fonts = `<w:rFonts w:ascii="${normalized.heading3FontFamily}" w:eastAsia="${normalized.heading3FontFamily}" w:hAnsi="${normalized.heading3FontFamily}"/>`;
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"> <w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:docDefaults> <w:docDefaults>
<w:rPrDefault><w:rPr>${fonts}<w:sz w:val="22"/><w:szCs w:val="22"/><w:lang w:val="zh-CN" w:eastAsia="zh-CN"/></w:rPr></w:rPrDefault> <w:rPrDefault><w:rPr>${fonts}<w:sz w:val="${pointsToHalfPoints(
<w:pPrDefault><w:pPr><w:spacing w:after="200" w:line="276" w:lineRule="auto"/></w:pPr></w:pPrDefault> normalized.bodyFontSizePt,
)}"/><w:szCs w:val="${pointsToHalfPoints(
normalized.bodyFontSizePt,
)}"/><w:lang w:val="zh-CN" w:eastAsia="zh-CN"/></w:rPr></w:rPrDefault>
<w:pPrDefault><w:pPr><w:spacing w:after="${pointsToTwips(
normalized.paragraphAfterPt,
)}" w:line="${lineSpacingToTwips(
normalized.lineSpacing,
)}" w:lineRule="auto"/></w:pPr></w:pPrDefault>
</w:docDefaults> </w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style> <w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>
<w:style w:type="paragraph" w:styleId="Title"> <w:style w:type="paragraph" w:styleId="Title">
<w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/> <w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>
<w:pPr><w:pBdr><w:bottom w:val="single" w:sz="8" w:space="4" w:color="4F81BD"/></w:pBdr><w:spacing w:after="300" w:line="240" w:lineRule="auto"/><w:contextualSpacing/></w:pPr> <w:pPr><w:pBdr><w:bottom w:val="single" w:sz="8" w:space="4" w:color="4F81BD"/></w:pBdr><w:spacing w:after="300" w:line="240" w:lineRule="auto"/><w:contextualSpacing/></w:pPr>
<w:rPr>${fonts}<w:color w:val="17365D"/><w:spacing w:val="5"/><w:kern w:val="28"/><w:sz w:val="52"/><w:szCs w:val="52"/></w:rPr> <w:rPr>${heading1Fonts}<w:color w:val="17365D"/><w:spacing w:val="5"/><w:kern w:val="28"/><w:sz w:val="52"/><w:szCs w:val="52"/></w:rPr>
</w:style> </w:style>
<w:style w:type="paragraph" w:styleId="Heading1"> <w:style w:type="paragraph" w:styleId="Heading1">
<w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/> <w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>
<w:pPr><w:keepNext/><w:keepLines/><w:spacing w:before="480" w:after="0"/><w:outlineLvl w:val="0"/></w:pPr> <w:pPr><w:keepNext/><w:keepLines/><w:spacing w:before="480" w:after="0"/><w:outlineLvl w:val="0"/></w:pPr>
<w:rPr>${fonts}<w:b/><w:bCs/><w:color w:val="365F91"/><w:sz w:val="28"/><w:szCs w:val="28"/></w:rPr> <w:rPr>${heading1Fonts}<w:b/><w:bCs/><w:color w:val="365F91"/><w:sz w:val="${pointsToHalfPoints(
normalized.heading1FontSizePt,
)}"/><w:szCs w:val="${pointsToHalfPoints(
normalized.heading1FontSizePt,
)}"/></w:rPr>
</w:style> </w:style>
<w:style w:type="paragraph" w:styleId="Heading2"> <w:style w:type="paragraph" w:styleId="Heading2">
<w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/> <w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>
<w:pPr><w:keepNext/><w:keepLines/><w:spacing w:before="200" w:after="0"/><w:outlineLvl w:val="1"/></w:pPr> <w:pPr><w:keepNext/><w:keepLines/><w:spacing w:before="200" w:after="0"/><w:outlineLvl w:val="1"/></w:pPr>
<w:rPr>${fonts}<w:b/><w:bCs/><w:color w:val="4F81BD"/><w:sz w:val="24"/><w:szCs w:val="24"/></w:rPr> <w:rPr>${heading2Fonts}<w:b/><w:bCs/><w:color w:val="4F81BD"/><w:sz w:val="${pointsToHalfPoints(
normalized.heading2FontSizePt,
)}"/><w:szCs w:val="${pointsToHalfPoints(
normalized.heading2FontSizePt,
)}"/></w:rPr>
</w:style> </w:style>
<w:style w:type="paragraph" w:styleId="Heading3"> <w:style w:type="paragraph" w:styleId="Heading3">
<w:name w:val="heading 3"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/> <w:name w:val="heading 3"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>
<w:pPr><w:keepNext/><w:keepLines/><w:spacing w:before="200" w:after="0"/><w:outlineLvl w:val="2"/></w:pPr> <w:pPr><w:keepNext/><w:keepLines/><w:spacing w:before="200" w:after="0"/><w:outlineLvl w:val="2"/></w:pPr>
<w:rPr>${fonts}<w:b/><w:bCs/><w:color w:val="4F81BD"/><w:sz w:val="22"/><w:szCs w:val="22"/></w:rPr> <w:rPr>${heading3Fonts}<w:b/><w:bCs/><w:color w:val="4F81BD"/><w:sz w:val="${pointsToHalfPoints(
normalized.heading3FontSizePt,
)}"/><w:szCs w:val="${pointsToHalfPoints(
normalized.heading3FontSizePt,
)}"/></w:rPr>
</w:style> </w:style>
</w:styles>`; </w:styles>`;
} }
function wrapWordDocument(body) { function wrapWordDocument(body, style = DEFAULT_WORD_STYLE) {
const normalized = normalizeWordStyle(style);
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body.join( <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body.join(
'', '',
)}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1800" w:bottom="1440" w:left="1800" w:header="720" w:footer="720" w:gutter="0"/><w:cols w:space="720"/><w:docGrid w:linePitch="360"/></w:sectPr></w:body></w:document>`; )}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="${mmToTwips(
normalized.pageMarginsMm.top,
)}" w:right="${mmToTwips(
normalized.pageMarginsMm.right,
)}" w:bottom="${mmToTwips(
normalized.pageMarginsMm.bottom,
)}" w:left="${mmToTwips(
normalized.pageMarginsMm.left,
)}" w:header="720" w:footer="720" w:gutter="0"/><w:cols w:space="720"/><w:docGrid w:linePitch="360"/></w:sectPr></w:body></w:document>`;
} }
function createWordDocumentXml(result, aiContent, threshold) { function createWordDocumentXml(
result,
aiContent,
threshold,
style = DEFAULT_WORD_STYLE,
) {
const normalized = normalizeWordStyle(style || result?.reportStyle);
const summary = parseAuditSummary(aiContent, result.project); const summary = parseAuditSummary(aiContent, result.project);
const groups = groupAuditRecords(filterAuditRecords(result, threshold)); const groups = groupAuditRecords(filterAuditRecords(result, threshold));
const body = [ const body = [
...@@ -311,7 +422,7 @@ function createWordDocumentXml(result, aiContent, threshold) { ...@@ -311,7 +422,7 @@ function createWordDocumentXml(result, aiContent, threshold) {
}), }),
wordParagraph( wordParagraph(
wordRun(`生成时间:${formatWordDate()}`, { wordRun(`生成时间:${formatWordDate()}`, {
size: '18', size: pointsToHalfPoints(normalized.metaFontSizePt),
color: '808080', color: '808080',
}), }),
{ alignment: 'right' }, { alignment: 'right' },
...@@ -323,13 +434,76 @@ function createWordDocumentXml(result, aiContent, threshold) { ...@@ -323,13 +434,76 @@ function createWordDocumentXml(result, aiContent, threshold) {
wordParagraph('', { spacingAfter: '0' }), wordParagraph('', { spacingAfter: '0' }),
]; ];
if (result.analysis) {
body.push(
wordParagraph(wordRun('AI 专项分析'), { style: 'Heading1' }),
wordParagraph(
wordRun(
`审计主题:${
result.analysis.auditDomain || result.project || ''
}\n扩展业务语义:${
(result.analysis.expandedTopics || []).join('、') || '(无)'
}\n分析置信度:${Math.round(
Number(result.analysis.confidence || 0) * 100,
)}分\n覆盖说明:${
result.analysis.coverageNote || '基于审计风险点知识库完成语义分析'
}${
result.analysis.analysisTrace
? `\n分批分析:${
result.analysis.analysisTrace.rounds?.length || 0
} 轮,处理 ${
result.analysis.analysisTrace.recordsAnalyzed ||
result.totalCount
} 条记录${
result.analysis.analysisTrace.failedBatches?.length
? `,失败批次 ${result.analysis.analysisTrace.failedBatches.length}`
: ''
}`
: ''
}`,
),
{
firstLine: String(Math.round(normalized.firstLineIndentChars * 420)),
line: lineSpacingToTwips(normalized.lineSpacing),
spacingAfter: pointsToTwips(normalized.paragraphAfterPt),
},
),
);
}
if (result.skillRulesApplied?.length) {
body.push(
wordParagraph(wordRun('业务 Skill 过滤记录'), { style: 'Heading1' }),
wordParagraph(
wordRun(`已应用规则:${result.skillRulesApplied.join('、')}`, {
size: pointsToHalfPoints(normalized.bodyFontSizePt),
font: normalized.fontFamily,
}),
{
firstLine: String(Math.round(normalized.firstLineIndentChars * 420)),
line: lineSpacingToTwips(normalized.lineSpacing),
spacingAfter: pointsToTwips(normalized.paragraphAfterPt),
},
),
);
}
WORD_SECTION_ORDER.forEach((field) => { WORD_SECTION_ORDER.forEach((field) => {
body.push(wordParagraph(wordRun(field), { style: 'Heading1' })); body.push(wordParagraph(wordRun(field), { style: 'Heading1' }));
if (field !== '审计内容') { if (field !== '审计内容') {
body.push( body.push(
wordParagraph(wordRun(stripMarkdown(summary[field] || '')), { wordParagraph(
firstLine: '420', wordRun(stripMarkdown(summary[field] || ''), {
}), size: pointsToHalfPoints(normalized.bodyFontSizePt),
font: normalized.fontFamily,
}),
{
firstLine: String(
Math.round(normalized.firstLineIndentChars * 420),
),
line: lineSpacingToTwips(normalized.lineSpacing),
spacingAfter: pointsToTwips(normalized.paragraphAfterPt),
},
),
); );
return; return;
} }
...@@ -337,7 +511,9 @@ function createWordDocumentXml(result, aiContent, threshold) { ...@@ -337,7 +511,9 @@ function createWordDocumentXml(result, aiContent, threshold) {
if (!groups.length) { if (!groups.length) {
body.push( body.push(
wordParagraph(wordRun('(当前匹配度阈值下无风险点)'), { wordParagraph(wordRun('(当前匹配度阈值下无风险点)'), {
firstLine: '420', firstLine: String(Math.round(normalized.firstLineIndentChars * 420)),
line: lineSpacingToTwips(normalized.lineSpacing),
spacingAfter: pointsToTwips(normalized.paragraphAfterPt),
}), }),
); );
return; return;
...@@ -348,7 +524,10 @@ function createWordDocumentXml(result, aiContent, threshold) { ...@@ -348,7 +524,10 @@ function createWordDocumentXml(result, aiContent, threshold) {
wordParagraph( wordParagraph(
wordRun( wordRun(
`业务板块 ${businessIndex + 1}:${business.businessSegment}`, `业务板块 ${businessIndex + 1}:${business.businessSegment}`,
{ size: '24' }, {
size: pointsToHalfPoints(normalized.heading2FontSizePt),
font: normalized.heading2FontFamily,
},
), ),
{ style: 'Heading2' }, { style: 'Heading2' },
), ),
...@@ -357,7 +536,8 @@ function createWordDocumentXml(result, aiContent, threshold) { ...@@ -357,7 +536,8 @@ function createWordDocumentXml(result, aiContent, threshold) {
body.push( body.push(
wordParagraph( wordParagraph(
wordRun(`一级项目 ${level1Index + 1}:${level1.level1Project}`, { wordRun(`一级项目 ${level1Index + 1}:${level1.level1Project}`, {
size: '22', size: pointsToHalfPoints(normalized.heading3FontSizePt),
font: normalized.heading3FontFamily,
}), }),
{ style: 'Heading3' }, { style: 'Heading3' },
), ),
...@@ -367,7 +547,8 @@ function createWordDocumentXml(result, aiContent, threshold) { ...@@ -367,7 +547,8 @@ function createWordDocumentXml(result, aiContent, threshold) {
wordParagraph( wordParagraph(
wordRun(`二级项目 ${level2Index + 1}:${level2.level2Project}`, { wordRun(`二级项目 ${level2Index + 1}:${level2.level2Project}`, {
bold: true, bold: true,
size: '24', size: pointsToHalfPoints(normalized.heading2FontSizePt),
font: normalized.heading2FontFamily,
}), }),
{ leftIndent: '227' }, { leftIndent: '227' },
), ),
...@@ -382,10 +563,24 @@ function createWordDocumentXml(result, aiContent, threshold) { ...@@ -382,10 +563,24 @@ function createWordDocumentXml(result, aiContent, threshold) {
const detailParagraph = (label, value, color = '') => const detailParagraph = (label, value, color = '') =>
wordParagraph( wordParagraph(
[ [
wordRun(`${label}:`, { bold: true, size: '21' }), wordRun(`${label}:`, {
wordRun(value || '(无)', { color, size: '21' }), bold: true,
size: pointsToHalfPoints(normalized.bodyFontSizePt),
font: normalized.fontFamily,
}),
wordRun(value || '(无)', {
color,
size: pointsToHalfPoints(normalized.bodyFontSizePt),
font: normalized.fontFamily,
}),
], ],
{ leftIndent: '454' }, {
leftIndent: String(
Math.round(normalized.firstLineIndentChars * 227),
),
line: lineSpacingToTwips(normalized.lineSpacing),
spacingAfter: pointsToTwips(normalized.paragraphAfterPt),
},
); );
body.push( body.push(
wordParagraph( wordParagraph(
...@@ -393,7 +588,11 @@ function createWordDocumentXml(result, aiContent, threshold) { ...@@ -393,7 +588,11 @@ function createWordDocumentXml(result, aiContent, threshold) {
`风险点 ${riskIndex + 1}(源文件第 ${ `风险点 ${riskIndex + 1}(源文件第 ${
risk.sourceRow || '未知' risk.sourceRow || '未知'
} 行)`, } 行)`,
{ bold: true, size: '21' }, {
bold: true,
size: pointsToHalfPoints(normalized.bodyFontSizePt),
font: normalized.fontFamily,
},
), ),
{ leftIndent: '283' }, { leftIndent: '283' },
), ),
...@@ -405,6 +604,9 @@ function createWordDocumentXml(result, aiContent, threshold) { ...@@ -405,6 +604,9 @@ function createWordDocumentXml(result, aiContent, threshold) {
detailParagraph('法规依据', risk.regulationBasis), detailParagraph('法规依据', risk.regulationBasis),
detailParagraph('风险点来源', risk.riskSource), detailParagraph('风险点来源', risk.riskSource),
detailParagraph('风险等级', riskLevel, riskLevelColor), detailParagraph('风险等级', riskLevel, riskLevelColor),
...(risk.aiRationale
? [detailParagraph('AI 分析依据', risk.aiRationale)]
: []),
); );
}); });
}); });
...@@ -413,10 +615,10 @@ function createWordDocumentXml(result, aiContent, threshold) { ...@@ -413,10 +615,10 @@ function createWordDocumentXml(result, aiContent, threshold) {
}); });
}); });
return wrapWordDocument(body); return wrapWordDocument(body, normalized);
} }
async function createWordBlob(documentXml) { async function createWordBlob(documentXml, style = DEFAULT_WORD_STYLE) {
const zip = new JSZip(); const zip = new JSZip();
zip.file( zip.file(
'[Content_Types].xml', '[Content_Types].xml',
...@@ -430,7 +632,7 @@ async function createWordBlob(documentXml) { ...@@ -430,7 +632,7 @@ async function createWordBlob(documentXml) {
); );
const word = zip.folder('word'); const word = zip.folder('word');
word.file('document.xml', documentXml); word.file('document.xml', documentXml);
word.file('styles.xml', createWordStylesXml()); word.file('styles.xml', createWordStylesXml(style));
word word
.folder('_rels') .folder('_rels')
.file( .file(
...@@ -444,13 +646,21 @@ async function createWordBlob(documentXml) { ...@@ -444,13 +646,21 @@ async function createWordBlob(documentXml) {
}); });
} }
export async function createAuditWordBlob(result, aiContent, threshold) { export async function createAuditWordBlob(result, aiContent, threshold, style) {
return createWordBlob(createWordDocumentXml(result, aiContent, threshold)); return createWordBlob(
createWordDocumentXml(
result,
aiContent,
threshold,
style || result?.reportStyle,
),
style || result?.reportStyle,
);
} }
export async function exportAuditWord(result, aiContent, threshold) { export async function exportAuditWord(result, aiContent, threshold, style) {
downloadBlob( downloadBlob(
await createAuditWordBlob(result, aiContent, threshold), await createAuditWordBlob(result, aiContent, threshold, style),
`${safeFileName(result.project)}.docx`, `${safeFileName(result.project)}.docx`,
); );
} }
......
import {
DEFAULT_WORD_STYLE,
normalizeWordStyle,
parseWordStyleMarkdown,
wordStyleToMarkdown,
} from './style';
export const AUDIT_SKILL_SUBJECT = 'ai_skill';
export const AUDIT_SKILL_OBJECT_NAME = 'audit_specialist';
export const DEFAULT_AUDIT_SKILL_MARKDOWN = `# 审计专项公共 Skill
## 审计清单格式
- 审计内容按业务板块、一级项目、二级项目、风险点组织。
- 每条风险点保留具体风险点描述、法规依据、风险点来源和风险等级。
- 知识库覆盖不足时明确说明,不得编造风险点、法规或事实。
## 审计主题扩展
- 先理解用户的自然语言请求,再扩展相关业务环节、控制目标、可疑点、数据对象和责任部门。
- 扩展结果只用于理解审计意图和筛选知识库风险点,不得作为知识库事实输出。
## 检索与分析规则
- 优先进行语义分析,不以单一关键词命中作为主要业务决策。
- 最终只能引用知识库已有的风险点 ID、风险描述、法规依据、来源和风险等级。
## 业务规则
## Word 输出样式
### 公共默认样式
- 正文字体:宋体
- 正文大小:10.5pt
- 一级标题:黑体,14pt
- 二级标题:黑体,12pt
- 三级标题:黑体,11pt
- 行距:1.15 倍
- 段后:6 磅
- 首行缩进:2 字符
`;
export const DEFAULT_AUDIT_SKILL_OBJECT = {
name: AUDIT_SKILL_OBJECT_NAME,
title: '审计专项公共 Skill',
enabled: true,
description: '统一定义所有审计专项的分析、业务规则和 Word 输出样式。',
auditDomain: '',
skillFile: null,
content: DEFAULT_AUDIT_SKILL_MARKDOWN,
skills: [],
businessRules: [],
styles: DEFAULT_WORD_STYLE,
domainKnowledge: {},
updatedAt: '',
};
function asText(value) {
if (Array.isArray(value)) return value.filter(Boolean).join('、');
return value === undefined || value === null ? '' : String(value).trim();
}
function asArray(value) {
return Array.isArray(value) ? value : value ? [value] : [];
}
function firstNonEmpty(...values) {
return values.find(
(value) => value !== undefined && value !== null && value !== '',
);
}
function normalizeMatchText(value) {
return asText(value).replace(/[\s,。;、,.;::()()]+/g, '');
}
function normalizeAuditDomain(value) {
return normalizeMatchText(value).replace(
/生成|审计|专项|清单|检查|方案|我要|我想|开展|做一个|帮我/g,
'',
);
}
function normalizeFileDescriptor(value, fallbackName = '') {
const original = Array.isArray(value) ? value[0] : value;
if (typeof original === 'string')
return { name: fallbackName, url: original };
if (!original || typeof original !== 'object') return null;
const nested = [
original,
original.file,
original.response,
original.data,
original.result,
original.response?.data,
].filter((item) => item && typeof item === 'object');
const url = firstNonEmpty(
...nested.flatMap((item) => [
item.url,
item.href,
item.download_url,
item.downloadUrl,
item.path,
]),
);
if (!url) return null;
return {
name: firstNonEmpty(
...nested.flatMap((item) => [item.name, item.file_name, item.fileName]),
fallbackName,
),
url,
type: firstNonEmpty(
...nested.flatMap((item) => [item.type, item.mime_type, item.mimeType]),
),
size: firstNonEmpty(...nested.map((item) => item.size)),
lastModified: firstNonEmpty(
...nested.flatMap((item) => [item.lastModified, item.last_modified]),
),
};
}
function getRecordFields(record = {}) {
return (
record?._source?.fields ||
record?._source?.metadata ||
record?.fields ||
record?.metadata ||
{}
);
}
function cleanLine(line) {
return String(line || '')
.replace(/^\s*[-*+]\s+/, '')
.replace(/^\s*\d+[.)、]\s*/, '')
.trim();
}
function splitMarkdownSections(markdown = '') {
const sections = [];
let current = null;
let subsection = null;
String(markdown)
.replace(/\r/g, '')
.split('\n')
.forEach((line) => {
const h2 = line.match(/^##\s+(.+?)\s*$/);
const h3 = line.match(/^###\s+(.+?)\s*$/);
if (h2) {
current = { title: h2[1].trim(), lines: [], subsections: [] };
sections.push(current);
subsection = null;
return;
}
if (h3 && current) {
subsection = { title: h3[1].trim(), lines: [] };
current.subsections.push(subsection);
return;
}
if (current) (subsection || current).lines.push(line);
});
return sections;
}
function parseJsonSkill(value) {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
if (!parsed || typeof parsed !== 'object') return null;
return parsed;
} catch {
return null;
}
}
function parseRuleLines(domain, lines, fallbackIndex = 0) {
const clean = lines.map(cleanLine).filter(Boolean);
const riskPointIds = [];
const removeTerms = [];
let label = '';
let description = '';
clean.forEach((line) => {
const idMatch = line.match(
/(?:风险点\s*(?:ID|编号)|ID)\s*[::]\s*([^,,;;]+)/i,
);
if (idMatch) riskPointIds.push(idMatch[1].trim());
const riskMatch = line.match(
/^(?:风险点|排除|排除项|过滤|不纳入|不要|移除)\s*[::]\s*(.+)$/,
);
if (riskMatch) {
const value = riskMatch[1].trim();
if (/^(排除|过滤|不纳入|不要|移除)/.test(line)) removeTerms.push(value);
else label = value;
}
if (/^(?:规则|原因|说明)\s*[::]/.test(line)) {
description = line.replace(/^(?:规则|原因|说明)\s*[::]\s*/, '').trim();
}
});
const implicitExclusion = clean
.filter(
(line) => !/^#+/.test(line) && /排除|过滤|不纳入|不要|移除/.test(line),
)
.map((line) => line.replace(/^.*?[::]/, '').trim())
.filter((value) => value && !/^该风险点/.test(value));
removeTerms.push(...implicitExclusion);
const uniqueTerms = Array.from(new Set(removeTerms.filter(Boolean)));
if (!uniqueTerms.length && !riskPointIds.length) return null;
return {
id: `rule_${normalizeAuditDomain(domain) || 'global'}_${fallbackIndex + 1}`,
domain: asText(domain),
label: label || uniqueTerms.join('、') || riskPointIds.join('、'),
riskPointIds: Array.from(new Set(riskPointIds)),
removeTerms: uniqueTerms,
description,
enabled: true,
};
}
function parseBusinessRules(section) {
const rules = [];
section?.subsections?.forEach((subsection) => {
const parsed = parseRuleLines(
subsection.title,
subsection.lines,
rules.length,
);
if (parsed) rules.push(parsed);
});
return rules;
}
function normalizeJsonRule(rule, index = 0) {
const removeTerms = asArray(
rule.removeTerms || rule.remove_terms || rule.terms || rule.term,
)
.map(asText)
.filter(Boolean);
return {
id: asText(rule.id || rule.name) || `rule_${index + 1}`,
domain: asText(rule.domain || rule.auditDomain || rule.project),
label: asText(rule.label || rule.title || removeTerms.join('、')),
riskPointIds: asArray(rule.riskPointIds || rule.risk_point_ids || rule.ids)
.map(asText)
.filter(Boolean),
removeTerms,
description: asText(rule.description || rule.reason || rule.note),
sourceQuery: asText(rule.sourceQuery || rule.source_query),
enabled: rule.enabled !== false,
createdAt: rule.createdAt || rule.created_at || '',
updatedAt: rule.updatedAt || rule.updated_at || '',
};
}
export function parseSkillContent(
content = '',
fallback = DEFAULT_AUDIT_SKILL_MARKDOWN,
) {
const raw = String(content || '').trim();
const json =
raw.startsWith('{') || raw.startsWith('[') ? parseJsonSkill(raw) : null;
if (json) {
const sections = asArray(json.skills || json.sections).map(
(skill, index) => ({
id: asText(skill.id || skill.name || `section_${index + 1}`),
title: asText(skill.title || skill.name || `规则段落 ${index + 1}`),
description: asText(skill.description),
instructions: asArray(skill.instructions || skill.rules)
.map(asText)
.filter(Boolean),
enabled: skill.enabled !== false,
}),
);
const rules = asArray(json.businessRules || json.business_rules)
.map(normalizeJsonRule)
.filter((rule) => rule.enabled);
return {
content: raw,
skills: sections,
businessRules: rules,
styles: normalizeWordStyle(
json.styles || json.wordStyle || DEFAULT_WORD_STYLE,
),
domainKnowledge: json.domainKnowledge || json.domain_knowledge || {},
};
}
const source = raw || fallback;
const sections = splitMarkdownSections(source);
const skills = [];
const domainKnowledge = {};
let businessRules = [];
let styles = parseWordStyleMarkdown(source);
sections.forEach((section, index) => {
const normalizedTitle = section.title.replace(/[与和及]/g, '');
if (/业务规则/.test(section.title)) {
businessRules = businessRules.concat(parseBusinessRules(section));
return;
}
if (/Word.*样式|输出样式/.test(section.title)) {
styles = parseWordStyleMarkdown(
[
section.title,
...section.lines,
...section.subsections.flatMap((item) => [item.title, ...item.lines]),
].join('\n'),
styles,
);
return;
}
if (/主题|领域|业务知识/.test(section.title)) {
section.subsections.forEach((subsection) => {
domainKnowledge[subsection.title] = subsection.lines
.map(cleanLine)
.filter(Boolean);
});
}
const instructions = section.lines
.map(cleanLine)
.filter(Boolean)
.concat(
section.subsections.flatMap((subsection) =>
subsection.lines.map(cleanLine).filter(Boolean),
),
);
if (instructions.length) {
skills.push({
id: normalizedTitle || `section_${index + 1}`,
title: section.title,
description: '',
instructions,
enabled: true,
});
}
});
return {
content: source,
skills,
businessRules,
styles: normalizeWordStyle(styles),
domainKnowledge,
};
}
function normalizeSkillFromRecord(record, content = '') {
const fields = getRecordFields(record);
const rawContent = content || fields.content || fields.skill_content || '';
const parsed = parseSkillContent(rawContent || DEFAULT_AUDIT_SKILL_MARKDOWN);
const recordId = record?.name || record?._id || record?.id || '';
return {
...DEFAULT_AUDIT_SKILL_OBJECT,
name: recordId || AUDIT_SKILL_OBJECT_NAME,
recordId,
raw: record,
title:
asText(firstNonEmpty(record?.title, fields.title)) ||
DEFAULT_AUDIT_SKILL_OBJECT.title,
enabled: firstNonEmpty(record?.enabled, fields.enabled, true) !== false,
description: asText(firstNonEmpty(record?.description, fields.description)),
auditDomain: asText(
firstNonEmpty(
record?.audit_domain,
record?.auditDomain,
fields.audit_domain,
fields.auditDomain,
),
),
skillFile: normalizeFileDescriptor(
firstNonEmpty(
record?.skill_file,
record?.skillFile,
fields.skill_file,
fields.skillFile,
),
'audit_public.md',
),
content: parsed.content,
skills: parsed.skills,
businessRules: parsed.businessRules,
styles: parsed.styles,
domainKnowledge: parsed.domainKnowledge,
updatedAt: asText(firstNonEmpty(record?.updated_at, fields.updated_at)),
};
}
export function normalizeAuditSkillObject(record = {}) {
return normalizeSkillFromRecord(record, record?.content || '');
}
export async function fetchAuditSkillObject(queryRecords) {
const response = await queryRecords({
subject: AUDIT_SKILL_SUBJECT,
page: 0,
page_size: 50,
filter: {},
keyword: '',
full: true,
});
const candidates = [
response,
response?.records,
response?.items,
response?.list,
response?.data,
response?.data?.records,
response?.data?.items,
response?.result,
];
const rawRecords =
candidates.find(Array.isArray) ||
(response &&
typeof response === 'object' &&
(response.name || response._id || response.id || response.title)
? [response]
: []);
const records = rawRecords.map((record) => normalizeAuditSkillObject(record));
const enabledRecords = records
.filter((record) => record.enabled !== false)
.sort((left, right) => {
const leftCommon = left.name === AUDIT_SKILL_OBJECT_NAME ? 1 : 0;
const rightCommon = right.name === AUDIT_SKILL_OBJECT_NAME ? 1 : 0;
return rightCommon - leftCommon;
});
const skill =
enabledRecords[0] || records[0] || normalizeAuditSkillObject({});
if (skill.enabled === false) {
return {
...skill,
content: '',
skills: [],
businessRules: [],
styles: DEFAULT_WORD_STYLE,
domainKnowledge: {},
};
}
if (!skill.skillFile?.url) return skill;
const responseFile = await fetch(skill.skillFile.url, {
credentials: 'omit',
});
if (!responseFile.ok) {
throw new Error(`公共 Skill 文件读取失败(${responseFile.status})`);
}
const content = await responseFile.text();
if (!content.trim()) throw new Error('公共 Skill 文件为空');
const parsed = parseSkillContent(content);
return {
...skill,
content: parsed.content,
skills: parsed.skills,
businessRules: parsed.businessRules,
styles: parsed.styles,
domainKnowledge: parsed.domainKnowledge,
};
}
export function serializeAuditSkillObject(skillObject, fileDescriptor = null) {
const object = normalizeAuditSkillObject(skillObject);
return {
subject: AUDIT_SKILL_SUBJECT,
name: object.recordId || AUDIT_SKILL_OBJECT_NAME,
title: object.title || '审计专项公共 Skill',
skill_file: fileDescriptor || object.skillFile,
enabled: object.enabled !== false,
description: object.description || '',
audit_domain: object.auditDomain || '',
};
}
function ruleMatchesDomain(rule, query = '') {
if (!rule.domain) return true;
const domain = normalizeAuditDomain(rule.domain);
const queryDomain = normalizeAuditDomain(query);
if (!queryDomain) return false;
return (
queryDomain.includes(domain) ||
domain.includes(queryDomain) ||
domain
.split(/[、/\s]+/)
.filter((term) => term.length >= 2)
.some((term) => queryDomain.includes(term))
);
}
export function getApplicableAuditBusinessRules(skillObject, query = '') {
return (skillObject?.businessRules || []).filter(
(rule) => rule.enabled !== false && ruleMatchesDomain(rule, query),
);
}
export function collectAuditSkillInstructions(skillObject, query = '') {
if (skillObject?.enabled === false) {
return {
instructions: [],
businessRules: [],
excludedTerms: [],
styles: DEFAULT_WORD_STYLE,
};
}
const rules = getApplicableAuditBusinessRules(skillObject, query);
const normalizedQuery = normalizeAuditDomain(query);
const domainEntries = Object.entries(skillObject?.domainKnowledge || {})
.filter(([domain]) => {
const normalizedDomain = normalizeAuditDomain(domain);
return normalizedDomain && normalizedQuery.includes(normalizedDomain);
})
.flatMap(([, values]) => values);
const instructions = (skillObject?.skills || [])
.filter((skill) => skill.enabled !== false)
.flatMap((skill) => skill.instructions || []);
return {
instructions: Array.from(new Set([...instructions, ...domainEntries])),
businessRules: rules,
excludedTerms: Array.from(
new Set(rules.flatMap((rule) => rule.removeTerms || [])),
),
styles: normalizeWordStyle(skillObject?.styles || DEFAULT_WORD_STYLE),
};
}
function recordKey(record = {}) {
return String(
record.id ||
record.riskPointCode ||
(record.sheetName && record.sourceRow
? `${record.sheetName}:${record.sourceRow}`
: ''),
);
}
export function auditSkillRuleMatchesRecord(record, rule) {
const key = recordKey(record);
if (key && rule?.riskPointIds?.map(String).includes(key)) return true;
const text = [
record?.businessSegment,
record?.level1Project,
record?.level2Project,
record?.riskDescription,
record?.riskSource,
record?.searchText,
]
.map(normalizeMatchText)
.join('|');
return (rule?.removeTerms || []).some((term) => {
const normalized = normalizeMatchText(term);
return normalized && text.includes(normalized);
});
}
function renderRuleMarkdown(rule) {
const domain = rule.domain || '通用';
const lines = [
`### ${domain}`,
'',
'#### 排除规则',
rule.riskPointIds?.length
? `- 风险点 ID:${rule.riskPointIds.join('、')}`
: '',
rule.label ? `- 风险点:${rule.label.replace(/^排除:/, '')}` : '',
rule.removeTerms?.length ? `- 排除:${rule.removeTerms.join('、')}` : '',
rule.description ? `- 原因:${rule.description}` : '',
'',
];
return lines.filter((line, index) => line || index === 0).join('\n');
}
function upsertSection(content, heading, replacement) {
const source = String(content || '').trim();
const pattern = new RegExp(
`(^|\\n)##\\s+${heading}\\s*\\n[\\s\\S]*?(?=\\n##\\s+|$)`,
'i',
);
if (pattern.test(source))
return source.replace(pattern, `\n${replacement}\n`);
return `${source}\n\n${replacement}\n`.trim();
}
function appendBusinessRuleSection(content, rule) {
const source = String(content || DEFAULT_AUDIT_SKILL_MARKDOWN).trim();
const headingPattern = /(^|\n)##\s+业务规则\s*\n?/i;
const headingMatch = headingPattern.exec(source);
const renderedRule = renderRuleMarkdown(rule).trim();
if (!headingMatch) {
return `${source}\n\n## 业务规则\n\n${renderedRule}`.trim();
}
const sectionStart = headingMatch.index + headingMatch[1].length;
const afterHeading =
sectionStart + headingMatch[0].length - headingMatch[1].length;
const nextSection = source.slice(afterHeading).search(/\n##\s+/i);
const sectionEnd =
nextSection >= 0 ? afterHeading + nextSection : source.length;
const section = source.slice(sectionStart, sectionEnd).trimEnd();
const escapedDomain = String(rule.domain || '通用').replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&',
);
const domainMatch = new RegExp(
`(^|\\n)###\\s+${escapedDomain}\\s*\\n`,
'i',
).exec(section);
let nextSectionContent = section;
if (domainMatch) {
const domainStart = domainMatch.index + domainMatch[1].length;
const bodyStart =
domainStart + domainMatch[0].length - domainMatch[1].length;
const nextDomain = section.slice(bodyStart).search(/\n###\s+|\n##\s+/i);
const domainEnd = nextDomain >= 0 ? bodyStart + nextDomain : section.length;
const ruleBody = renderedRule.replace(/^###\s+[^\n]+\n?/, '').trim();
nextSectionContent = `${section
.slice(0, domainEnd)
.trimEnd()}\n\n${ruleBody}${section.slice(domainEnd)}`;
} else {
nextSectionContent = `${section}\n\n${renderedRule}`.trim();
}
return `${source.slice(0, sectionStart)}${nextSectionContent}${source.slice(
sectionEnd,
)}`.trim();
}
export function appendAuditBusinessRule(skillObject, rule) {
const current = normalizeAuditSkillObject(skillObject);
const normalized = {
id: rule?.id || `rule_${Date.now()}`,
domain: asText(rule?.domain),
label: asText(rule?.label || rule?.title || rule?.removeTerms?.join('、')),
riskPointIds: asArray(rule?.riskPointIds || rule?.risk_point_ids)
.map(asText)
.filter(Boolean),
removeTerms: asArray(rule?.removeTerms || rule?.remove_terms)
.map(asText)
.filter(Boolean),
description: asText(rule?.description || rule?.reason),
sourceQuery: asText(rule?.sourceQuery || rule?.source_query),
enabled: true,
updatedAt: new Date().toISOString(),
};
const existingRules = current.businessRules || [];
const sameRule = (item) =>
item &&
normalizeAuditDomain(item.domain) ===
normalizeAuditDomain(normalized.domain) &&
JSON.stringify(item.riskPointIds || []) ===
JSON.stringify(normalized.riskPointIds || []) &&
item.removeTerms?.join('|') === normalized.removeTerms.join('|');
const rules = [
...existingRules.filter((item) => !sameRule(item)),
normalized,
];
const content = existingRules.some((item) => sameRule(item))
? current.content || DEFAULT_AUDIT_SKILL_MARKDOWN
: appendBusinessRuleSection(
current.content || DEFAULT_AUDIT_SKILL_MARKDOWN,
normalized,
);
const parsed = parseSkillContent(content);
const mergedRules = parsed.businessRules.length
? parsed.businessRules
: rules;
return {
...current,
content,
skills: parsed.skills,
businessRules: mergedRules,
styles: parsed.styles,
domainKnowledge: parsed.domainKnowledge,
updatedAt: normalized.updatedAt,
};
}
export function appendAuditWordStyle(skillObject, style) {
const current = normalizeAuditSkillObject(skillObject);
const normalized = normalizeWordStyle(style);
const content = upsertSection(
current.content || DEFAULT_AUDIT_SKILL_MARKDOWN,
'Word 输出样式',
wordStyleToMarkdown(normalized),
);
const parsed = parseSkillContent(content);
return {
...current,
content,
styles: normalized,
skills: parsed.skills,
businessRules: parsed.businessRules,
domainKnowledge: parsed.domainKnowledge,
updatedAt: new Date().toISOString(),
};
}
export function auditSkillRuleIdentity(rule = {}) {
return [
rule.domain,
...(rule.riskPointIds || []),
...(rule.removeTerms || []),
]
.map(normalizeMatchText)
.filter(Boolean)
.join('|');
}
export const DEFAULT_WORD_STYLE = {
fontFamily: '宋体',
bodyFontSizePt: 10.5,
metaFontSizePt: 9,
heading1FontFamily: '黑体',
heading1FontSizePt: 14,
heading2FontFamily: '黑体',
heading2FontSizePt: 12,
heading3FontFamily: '黑体',
heading3FontSizePt: 11,
lineSpacing: 1.15,
paragraphAfterPt: 6,
firstLineIndentChars: 2,
pageMarginsMm: {
top: 25.4,
right: 25.4,
bottom: 25.4,
left: 25.4,
},
};
const FONT_NAMES = ['宋体', '黑体', '仿宋', '楷体', '微软雅黑', '等线'];
const SIZE_ALIASES = {
初号: 42,
小初: 36,
一号: 26,
小一: 24,
二号: 22,
小二: 18,
三号: 16,
小三: 15,
四号: 14,
小四: 12,
五号: 10.5,
小五: 9,
};
function clampNumber(value, min, max, fallback) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.min(max, Math.max(min, number));
}
function normalizeFont(value, fallback) {
const text = String(value || '').trim();
return FONT_NAMES.find((font) => text.includes(font)) || fallback;
}
export function normalizeWordStyle(style = {}) {
const source = style || {};
const margins = source.pageMarginsMm || source.pageMargins || {};
return {
...DEFAULT_WORD_STYLE,
...source,
fontFamily: normalizeFont(source.fontFamily, DEFAULT_WORD_STYLE.fontFamily),
bodyFontSizePt: clampNumber(
source.bodyFontSizePt,
8,
24,
DEFAULT_WORD_STYLE.bodyFontSizePt,
),
metaFontSizePt: clampNumber(
source.metaFontSizePt,
7,
18,
DEFAULT_WORD_STYLE.metaFontSizePt,
),
heading1FontFamily: normalizeFont(
source.heading1FontFamily,
DEFAULT_WORD_STYLE.heading1FontFamily,
),
heading1FontSizePt: clampNumber(
source.heading1FontSizePt,
10,
30,
DEFAULT_WORD_STYLE.heading1FontSizePt,
),
heading2FontFamily: normalizeFont(
source.heading2FontFamily,
DEFAULT_WORD_STYLE.heading2FontFamily,
),
heading2FontSizePt: clampNumber(
source.heading2FontSizePt,
9,
26,
DEFAULT_WORD_STYLE.heading2FontSizePt,
),
heading3FontFamily: normalizeFont(
source.heading3FontFamily,
DEFAULT_WORD_STYLE.heading3FontFamily,
),
heading3FontSizePt: clampNumber(
source.heading3FontSizePt,
9,
24,
DEFAULT_WORD_STYLE.heading3FontSizePt,
),
lineSpacing: clampNumber(
source.lineSpacing,
1,
3,
DEFAULT_WORD_STYLE.lineSpacing,
),
paragraphAfterPt: clampNumber(
source.paragraphAfterPt,
0,
36,
DEFAULT_WORD_STYLE.paragraphAfterPt,
),
firstLineIndentChars: clampNumber(
source.firstLineIndentChars,
0,
4,
DEFAULT_WORD_STYLE.firstLineIndentChars,
),
pageMarginsMm: {
top: clampNumber(
margins.top,
10,
50,
DEFAULT_WORD_STYLE.pageMarginsMm.top,
),
right: clampNumber(
margins.right,
10,
50,
DEFAULT_WORD_STYLE.pageMarginsMm.right,
),
bottom: clampNumber(
margins.bottom,
10,
50,
DEFAULT_WORD_STYLE.pageMarginsMm.bottom,
),
left: clampNumber(
margins.left,
10,
50,
DEFAULT_WORD_STYLE.pageMarginsMm.left,
),
},
};
}
function parseSize(value, fallback) {
const text = String(value || '').trim();
const alias = Object.keys(SIZE_ALIASES).find((key) => text.includes(key));
if (alias) return SIZE_ALIASES[alias];
const match = text.match(/(\d+(?:\.\d+)?)\s*(?:pt|磅|号)?/i);
return match ? Number(match[1]) : fallback;
}
function parseLineSpacing(value, fallback) {
const text = String(value || '').trim();
if (text.includes('单倍')) return 1;
if (text.includes('双倍') || text.includes('两倍')) return 2;
const match = text.match(/(\d+(?:\.\d+)?)\s*(?:倍|行)?/i);
return match ? Number(match[1]) : fallback;
}
function parsePoint(value, fallback) {
const match = String(value || '').match(/(\d+(?:\.\d+)?)/);
return match ? Number(match[1]) : fallback;
}
export function parseWordStyleMarkdown(
markdown = '',
fallback = DEFAULT_WORD_STYLE,
) {
const style = { ...normalizeWordStyle(fallback) };
const source = String(markdown || '');
const read = (pattern) => source.match(pattern)?.[1]?.trim() || '';
const bodyFont = read(/(?:正文字体|正文用字体)\s*[::]\s*([^\n]+)/i);
const bodySize = read(
/(?:正文(?:字号|大小)|正文字体大小)\s*[::]\s*([^\n]+)/i,
);
const heading1 = read(/(?:一级标题(?:字体|字号|大小)?)\s*[::]\s*([^\n]+)/i);
const heading2 = read(/(?:二级标题(?:字体|字号|大小)?)\s*[::]\s*([^\n]+)/i);
const heading3 = read(/(?:三级标题(?:字体|字号|大小)?)\s*[::]\s*([^\n]+)/i);
const line = read(/(?:行距|行间距)\s*[::]\s*([^\n]+)/i);
const after = read(/(?:段后|段后间距)\s*[::]\s*([^\n]+)/i);
const indent = read(/(?:首行缩进|首行)\s*[::]\s*([^\n]+)/i);
if (bodyFont) style.fontFamily = normalizeFont(bodyFont, style.fontFamily);
if (bodySize)
style.bodyFontSizePt = parseSize(bodySize, style.bodyFontSizePt);
if (heading1) {
style.heading1FontFamily = normalizeFont(
heading1,
style.heading1FontFamily,
);
style.heading1FontSizePt = parseSize(heading1, style.heading1FontSizePt);
}
if (heading2) {
style.heading2FontFamily = normalizeFont(
heading2,
style.heading2FontFamily,
);
style.heading2FontSizePt = parseSize(heading2, style.heading2FontSizePt);
}
if (heading3) {
style.heading3FontFamily = normalizeFont(
heading3,
style.heading3FontFamily,
);
style.heading3FontSizePt = parseSize(heading3, style.heading3FontSizePt);
}
if (line) style.lineSpacing = parseLineSpacing(line, style.lineSpacing);
if (after) style.paragraphAfterPt = parsePoint(after, style.paragraphAfterPt);
if (indent)
style.firstLineIndentChars = parsePoint(indent, style.firstLineIndentChars);
return normalizeWordStyle(style);
}
export function isAuditStyleRequest(query = '') {
return /(字体|字号|大小|行距|行间距|段前|段后|缩进|页边距|排版|格式|样式)/.test(
String(query),
);
}
export function isPersistentStyleRequest(query = '') {
return /(以后|今后|默认|保存为|记住|都这样|统一样式|长期)/.test(
String(query),
);
}
export function parseAuditStyleRequest(
query = '',
currentStyle = DEFAULT_WORD_STYLE,
) {
const text = String(query || '').trim();
const current = normalizeWordStyle(currentStyle);
if (!isAuditStyleRequest(text)) {
return { matched: false, style: current, changes: [], persist: false };
}
const style = { ...current };
const changes = [];
const add = (key, value, label) => {
if (value === undefined || value === null || value === style[key]) return;
style[key] = value;
changes.push(`${label}:${value}`);
};
const fontMatch = text.match(
/(?:字体|字形)(?:改成|设置为|调整为|用|为)?\s*(宋体|黑体|仿宋|楷体|微软雅黑|等线)/,
);
if (fontMatch) add('fontFamily', fontMatch[1], '正文字体');
const bodySizeMatch = text.match(
/正文(?:字体|字号|大小)?(?:改成|设置为|调整为|用|为)?\s*(初号|小初|一号|小一|二号|小二|三号|小三|四号|小四|五号|小五|\d+(?:\.\d+)?\s*(?:pt|磅|号))/,
);
if (bodySizeMatch)
add(
'bodyFontSizePt',
parseSize(bodySizeMatch[1], style.bodyFontSizePt),
'正文字号',
);
const headingMatch = text.match(
/(?:一级标题|标题1)(?:字体|字号|大小)?(?:改成|设置为|调整为|用|为)?\s*(?:(宋体|黑体|仿宋|楷体|微软雅黑|等线)\s*)?(初号|小初|一号|小一|二号|小二|三号|小三|四号|小四|五号|小五|\d+(?:\.\d+)?\s*(?:pt|磅|号))/,
);
if (headingMatch) {
if (headingMatch[1])
add('heading1FontFamily', headingMatch[1], '一级标题字体');
add(
'heading1FontSizePt',
parseSize(headingMatch[2], style.heading1FontSizePt),
'一级标题字号',
);
}
const lineMatch = text.match(
/(?:行距|行间距)(?:改成|设置为|调整为|为)?\s*(单倍|双倍|两倍|\d+(?:\.\d+)?\s*倍?)/,
);
if (lineMatch)
add(
'lineSpacing',
parseLineSpacing(lineMatch[1], style.lineSpacing),
'行距',
);
const afterMatch = text.match(
/(?:段后|段后间距)(?:空|设置为|调整为|为)?\s*(\d+(?:\.\d+)?)\s*(?:磅|pt)?/,
);
if (afterMatch) add('paragraphAfterPt', Number(afterMatch[1]), '段后');
const indentMatch = text.match(
/(?:首行缩进|首行)(?:设置为|调整为|为)?\s*(\d+(?:\.\d+)?)\s*(?:字符|字)?/,
);
if (indentMatch)
add('firstLineIndentChars', Number(indentMatch[1]), '首行缩进');
return {
matched: true,
style: normalizeWordStyle(style),
changes,
persist: isPersistentStyleRequest(text),
};
}
export function wordStyleToMarkdown(style = DEFAULT_WORD_STYLE) {
const normalized = normalizeWordStyle(style);
return [
'## Word 输出样式',
'',
'### 公共默认样式',
`- 正文字体:${normalized.fontFamily}`,
`- 正文大小:${normalized.bodyFontSizePt}pt`,
`- 一级标题:${normalized.heading1FontFamily},${normalized.heading1FontSizePt}pt`,
`- 二级标题:${normalized.heading2FontFamily},${normalized.heading2FontSizePt}pt`,
`- 三级标题:${normalized.heading3FontFamily},${normalized.heading3FontSizePt}pt`,
`- 行距:${normalized.lineSpacing} 倍`,
`- 段后:${normalized.paragraphAfterPt} 磅`,
`- 首行缩进:${normalized.firstLineIndentChars} 字符`,
].join('\n');
}
...@@ -29,15 +29,30 @@ import { ...@@ -29,15 +29,30 @@ import {
import { AUDIT_KNOWLEDGE_FILE_SUBJECT } from '../../features/audit/constants'; import { AUDIT_KNOWLEDGE_FILE_SUBJECT } from '../../features/audit/constants';
import { import {
applyAuditQueryExpansion, applyAuditQueryExpansion,
buildAuditAnalysisPrompt,
buildAuditKnowledgePrompt, buildAuditKnowledgePrompt,
buildAuditResult, buildAuditResult,
buildAuditResultFromAnalysis,
buildAuditStylePrompt,
buildGroundedAuditKnowledgePrompt, buildGroundedAuditKnowledgePrompt,
classifyAuditIntent, classifyAuditAction,
fetchCurrentAuditKnowledgeFile, fetchCurrentAuditKnowledgeFile,
findPreviousAuditResult, findPreviousAuditResult,
getAuditRecordKey,
isAuditRefineRequest, isAuditRefineRequest,
isPersistentAuditRuleRequest,
recordMatchesTerms,
} from '../../features/audit/engine'; } from '../../features/audit/engine';
import { parseAuditExcelFromRemote } from '../../features/audit/excel'; import { parseAuditExcelFromRemote } from '../../features/audit/excel';
import {
DEFAULT_AUDIT_SKILL_OBJECT,
appendAuditBusinessRule,
appendAuditWordStyle,
fetchAuditSkillObject,
parseSkillContent,
serializeAuditSkillObject,
} from '../../features/audit/skills';
import { parseAuditStyleRequest } from '../../features/audit/style';
import { import {
callMcpTool, callMcpTool,
createConversation, createConversation,
...@@ -56,6 +71,7 @@ import { ...@@ -56,6 +71,7 @@ import {
updateConversation, updateConversation,
updateRecord, updateRecord,
uploadAuditKnowledgeFile, uploadAuditKnowledgeFile,
uploadAuditSkillFile,
} from '../../services/DataService'; } from '../../services/DataService';
// 导入发票助手组件 // 导入发票助手组件
...@@ -282,6 +298,311 @@ async function expandAuditQuery(query, model, signal) { ...@@ -282,6 +298,311 @@ async function expandAuditQuery(query, model, signal) {
return payload ? applyAuditQueryExpansion(query, payload) : null; return payload ? applyAuditQueryExpansion(query, payload) : null;
} }
async function analyzeAuditRequest(
query,
model,
records,
skillObject,
previousResult,
signal,
) {
const asList = (value) =>
Array.isArray(value) ? value : value ? [value] : [];
const getIds = (item, key, snakeKey) =>
asList(item?.[key] || item?.[snakeKey]).flatMap((value) => {
if (value && typeof value === 'object') {
return value.id || value.riskPointId || value.risk_point_id || [];
}
return value;
});
const analysisBatchCharBudget = 18000;
const finalCandidateLimit = 96;
const maxConsolidationRounds = 4;
const maxConcurrentBatches = 4;
const batchTimeoutMs = 45000;
const maxBatchRetries = 1;
const packRecords = (input = []) => {
const batches = [];
let current = [];
let currentSize = 0;
input.forEach((record) => {
const recordSize = JSON.stringify(record).length + 2;
if (
current.length &&
currentSize + recordSize > analysisBatchCharBudget
) {
batches.push(current);
current = [];
currentSize = 0;
}
current.push(record);
currentSize += recordSize;
});
if (current.length) batches.push(current);
return batches.length ? batches : [[]];
};
const mergePayloads = (payloads = [], trace = null) => {
const rationaleMap = new Map();
const merged = {
auditDomain: '',
expandedTopics: [],
selectedRiskPointIds: [],
excludedRiskPointIds: [],
riskRationale: [],
appliedBusinessRules: [],
confidence: 0,
coverageNote: '',
};
payloads.forEach((item) => {
if (!item) return;
merged.auditDomain =
merged.auditDomain || item.auditDomain || item.audit_domain || '';
merged.expandedTopics = Array.from(
new Set([
...merged.expandedTopics,
...asList(item.expandedTopics || item.expanded_topics || item.topics),
]),
).slice(0, 20);
merged.selectedRiskPointIds.push(
...getIds(item, 'selectedRiskPointIds', 'selected_risk_point_ids'),
);
merged.excludedRiskPointIds.push(
...getIds(item, 'excludedRiskPointIds', 'excluded_risk_point_ids'),
);
const rawRationale = item.riskRationale || item.risk_rationale || {};
const rationaleItems = Array.isArray(rawRationale)
? rawRationale
: Object.entries(rawRationale).map(([id, reason]) => ({ id, reason }));
rationaleItems.forEach((reason) => {
const id = reason?.id || reason?.riskPointId || reason?.risk_point_id;
if (id)
rationaleMap.set(
String(id),
String(reason.reason || reason.rationale || ''),
);
});
merged.appliedBusinessRules = Array.from(
new Set([
...merged.appliedBusinessRules,
...asList(item.appliedBusinessRules || item.applied_business_rules),
]),
);
merged.confidence += Number(item.confidence) || 0;
merged.coverageNote = [
merged.coverageNote,
item.coverageNote || item.coverage_note,
]
.filter(Boolean)
.join(';');
});
merged.selectedRiskPointIds = Array.from(
new Set(merged.selectedRiskPointIds),
);
merged.excludedRiskPointIds = Array.from(
new Set(merged.excludedRiskPointIds),
);
merged.riskRationale = Array.from(rationaleMap, ([id, reason]) => ({
id,
reason,
}));
merged.confidence = payloads.length
? Math.max(0, Math.min(1, merged.confidence / payloads.length))
: 0;
if (trace) merged.analysisTrace = trace;
return merged;
};
const runBatchPass = async (inputRecords, analysisMode, round) => {
const batches = packRecords(inputRecords);
const payloads = [];
const failedBatches = [];
inputRecords.forEach((record) =>
analyzedRecordIds.add(getAuditRecordKey(record)),
);
for (
let offset = 0;
offset < batches.length;
offset += maxConcurrentBatches
) {
const window = batches.slice(offset, offset + maxConcurrentBatches);
const results = await Promise.all(
window.map(async (batch, windowIndex) => {
const batchNumber = offset + windowIndex + 1;
for (let attempt = 0; attempt <= maxBatchRetries; attempt += 1) {
let batchController;
let timeoutId;
const abortBatch = () => batchController?.abort();
try {
batchController = new AbortController();
if (signal) {
if (signal.aborted) {
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
throw signal.reason || abortError;
}
signal.addEventListener('abort', abortBatch, { once: true });
}
timeoutId = setTimeout(
() => batchController.abort(),
batchTimeoutMs,
);
const response = await llmChat(
{
model,
messages: [
{
role: 'system',
content: buildAuditAnalysisPrompt({
query,
records: batch,
skillObject,
previousResult,
maxRecords: batch.length,
analysisMode,
batchNumber,
batchCount: batches.length,
}),
},
{
role: 'user',
content: `请完成第 ${batchNumber}/${
batches.length
} 批审计风险点分析,并只返回 JSON。审计请求:${JSON.stringify(
query,
)}`,
},
],
enable_thinking: false,
temperature: 0,
max_tokens: 2400,
},
batchController.signal,
);
const payload = parseJsonObject(await readLlmText(response));
if (!payload) throw new Error('模型未返回有效 JSON');
return payload;
} catch (error) {
if (error?.name === 'AbortError' && signal?.aborted) throw error;
if (attempt >= maxBatchRetries) {
failedBatches.push({
batchNumber,
message:
error?.name === 'AbortError'
? `单批超时(${batchTimeoutMs / 1000} 秒)`
: error?.message || '分析失败',
});
console.warn(
`审计风险点第 ${batchNumber} 批 AI 分析失败,继续处理:`,
error,
);
}
} finally {
if (timeoutId) clearTimeout(timeoutId);
signal?.removeEventListener('abort', abortBatch);
}
}
return null;
}),
);
payloads.push(...results.filter(Boolean));
}
return {
payloads,
failedBatches,
batchCount: batches.length,
recordsAnalyzed: inputRecords.length,
};
};
const summarizePass = (pass) => ({
batchCount: pass.batchCount,
recordsAnalyzed: pass.recordsAnalyzed,
payloadCount: pass.payloads.length,
failedBatchCount: pass.failedBatches.length,
});
const trace = {
strategy: 'map_reduce_loop',
batchCharBudget: analysisBatchCharBudget,
maxConcurrentBatches,
rounds: [],
failedBatches: [],
recordsAnalyzed: 0,
};
const analyzedRecordIds = new Set();
const recordMap = new Map(
records.map((record) => [getAuditRecordKey(record), record]),
);
const getRecord = (id) => recordMap.get(String(id));
let pass = await runBatchPass(records, 'candidate', 0);
trace.rounds.push({ mode: 'candidate', ...summarizePass(pass) });
trace.failedBatches.push(...pass.failedBatches);
trace.recordsAnalyzed = analyzedRecordIds.size;
if (
!pass.payloads.length ||
pass.failedBatches.length > Math.ceil(pass.batchCount * 0.5)
) {
return null;
}
let mergedPass = mergePayloads(pass.payloads);
let candidateIds = new Set(mergedPass.selectedRiskPointIds);
let candidateRecords = Array.from(candidateIds)
.map(getRecord)
.filter(Boolean);
for (
let round = 1;
round <= maxConsolidationRounds &&
candidateRecords.length > finalCandidateLimit;
round += 1
) {
pass = await runBatchPass(candidateRecords, 'consolidate', round);
trace.rounds.push({ mode: 'consolidate', round, ...summarizePass(pass) });
trace.failedBatches.push(...pass.failedBatches);
trace.recordsAnalyzed = analyzedRecordIds.size;
if (
!pass.payloads.length ||
pass.failedBatches.length > Math.ceil(pass.batchCount * 0.5)
)
break;
mergedPass = mergePayloads(pass.payloads);
const nextCandidateIds = new Set(mergedPass.selectedRiskPointIds);
if (!nextCandidateIds.size || nextCandidateIds.size >= candidateIds.size)
break;
candidateIds = nextCandidateIds;
candidateRecords = Array.from(candidateIds).map(getRecord).filter(Boolean);
}
if (!candidateRecords.length) {
return mergePayloads([], {
...trace,
recordsAnalyzed: analyzedRecordIds.size,
note: '候选阶段未发现相关风险点,跳过最终复核。',
});
}
const finalPass = await runBatchPass(
candidateRecords,
'final',
trace.rounds.length,
);
trace.rounds.push({ mode: 'final', ...summarizePass(finalPass) });
trace.failedBatches.push(...finalPass.failedBatches);
trace.recordsAnalyzed = analyzedRecordIds.size;
const finalPayloads = finalPass.payloads.length
? finalPass.payloads
: [mergedPass];
const finalAnalysis = mergePayloads(finalPayloads, trace);
finalAnalysis.selectedRiskPointIds =
finalAnalysis.selectedRiskPointIds.filter((id) =>
candidateIds.has(String(id)),
);
return finalAnalysis;
}
export default forwardRef((props, ref) => { export default forwardRef((props, ref) => {
const { userInfo, conversation, onAddConversation } = props; const { userInfo, conversation, onAddConversation } = props;
const conversationAgent = useMemo( const conversationAgent = useMemo(
...@@ -324,8 +645,14 @@ export default forwardRef((props, ref) => { ...@@ -324,8 +645,14 @@ export default forwardRef((props, ref) => {
const [auditKnowledgeLoading, setAuditKnowledgeLoading] = useState(false); const [auditKnowledgeLoading, setAuditKnowledgeLoading] = useState(false);
const [auditRecords, setAuditRecords] = useState([]); const [auditRecords, setAuditRecords] = useState([]);
const [auditKnowledgeFile, setAuditKnowledgeFile] = useState(null); const [auditKnowledgeFile, setAuditKnowledgeFile] = useState(null);
const [auditSkillObject, setAuditSkillObject] = useState(
DEFAULT_AUDIT_SKILL_OBJECT,
);
const abortController = useRef(null); const abortController = useRef(null);
const auditRecordsRef = useRef([]); const auditRecordsRef = useRef([]);
const auditSkillRef = useRef(DEFAULT_AUDIT_SKILL_OBJECT);
const auditSkillLoadPromiseRef = useRef(null);
const auditSkillLoadedRef = useRef(false);
const auditSessionKeyRef = useRef(''); const auditSessionKeyRef = useRef('');
const auditLoadedSessionRef = useRef(''); const auditLoadedSessionRef = useRef('');
const auditLoadPromiseRef = useRef(null); const auditLoadPromiseRef = useRef(null);
...@@ -425,6 +752,130 @@ export default forwardRef((props, ref) => { ...@@ -425,6 +752,130 @@ export default forwardRef((props, ref) => {
[conversation?.name], [conversation?.name],
); );
const loadAuditSkill = useCallback(
async (force = false) => {
if (!force && auditSkillLoadedRef.current) {
return auditSkillRef.current;
}
if (!force && auditSkillLoadPromiseRef.current) {
return auditSkillLoadPromiseRef.current;
}
const promise = fetchAuditSkillObject(queryRecords)
.catch((error) => {
console.warn('审计 Skill 加载失败,使用内置 Skill:', error);
return DEFAULT_AUDIT_SKILL_OBJECT;
})
.then((skill) => {
const next = skill || DEFAULT_AUDIT_SKILL_OBJECT;
auditSkillRef.current = next;
auditSkillLoadedRef.current = true;
setAuditSkillObject(next);
return next;
});
auditSkillLoadPromiseRef.current = promise;
try {
return await promise;
} finally {
auditSkillLoadPromiseRef.current = null;
}
},
[queryRecords],
);
const persistAuditSkill = useCallback(
async (next, current, requestedFileName = '') => {
if (!next?.content?.trim()) throw new Error('Skill 文件内容为空');
const fileName =
requestedFileName || current?.skillFile?.name || 'audit_public.md';
const uploaded = await uploadAuditSkillFile(next.content, fileName);
if (!uploaded?.url) throw new Error('Skill 文件上传后没有可访问地址');
const payload = serializeAuditSkillObject(next, uploaded);
const recordId =
current?.recordId || current?.raw?.name || current?.raw?._id;
const saved = recordId
? await updateRecord(recordId, payload)
: await createRecord(payload);
const persisted = {
...next,
...payload,
recordId:
recordId ||
saved?.name ||
saved?._id ||
saved?.id ||
saved?.data?.name ||
saved?.data?._id ||
saved?.data?.id ||
payload.name,
raw: saved || next.raw,
skillFile: uploaded,
updatedAt: new Date().toISOString(),
};
auditSkillRef.current = persisted;
auditSkillLoadedRef.current = true;
setAuditSkillObject(persisted);
return persisted;
},
[],
);
const saveAuditSkillRule = useCallback(
async ({
domain,
removeTerms,
riskPointIds,
label,
description,
sourceQuery,
}) => {
const current = await fetchAuditSkillObject(queryRecords);
const next = appendAuditBusinessRule(current, {
domain,
removeTerms,
riskPointIds,
label,
description,
sourceQuery,
});
const persisted = await persistAuditSkill(next, current);
toast.success(
`已将“${label || removeTerms?.join('、')}”保存到公共 Skill 文件`,
);
return persisted;
},
[persistAuditSkill, queryRecords],
);
const saveAuditStyle = useCallback(
async (style) => {
const current = await fetchAuditSkillObject(queryRecords);
const next = appendAuditWordStyle(current, style);
return persistAuditSkill(next, current);
},
[persistAuditSkill, queryRecords],
);
const uploadAuditSkill = useCallback(
async (file) => {
if (!file) return;
const content = await file.text();
if (!content.trim()) throw new Error('Skill 文件为空');
const parsed = parseSkillContent(content);
if (!parsed.skills.length && !parsed.businessRules.length) {
throw new Error('Skill 文件未识别到有效规则章节');
}
const current = await fetchAuditSkillObject(queryRecords);
const next = {
...current,
...parsed,
content: parsed.content,
};
await persistAuditSkill(next, current, file.name);
toast.success(`公共 Skill 已更新:${file.name}`);
},
[persistAuditSkill, queryRecords],
);
const saveAuditKnowledgeFile = useCallback( const saveAuditKnowledgeFile = useCallback(
async ({ file, parsed }) => { async ({ file, parsed }) => {
const uploadedFile = await uploadAuditKnowledgeFile(file); const uploadedFile = await uploadAuditKnowledgeFile(file);
...@@ -792,44 +1243,176 @@ export default forwardRef((props, ref) => { ...@@ -792,44 +1243,176 @@ export default forwardRef((props, ref) => {
if (usesStrictAuditKnowledge) { if (usesStrictAuditKnowledge) {
const records = await loadAuditRecords(); const records = await loadAuditRecords();
const usesChecklistMode = const skillObject = await loadAuditSkill();
usesAuditDatabase || const previousResult = findPreviousAuditResult(messages);
(usesKnowledgeAgent && const auditAction = classifyAuditAction(message.content, previousResult);
classifyAuditIntent(message.content) === 'checklist');
if (usesChecklistMode) { if (auditAction === 'style_adjust' && previousResult) {
const previousResult = findPreviousAuditResult(messages); const styleRequest = parseAuditStyleRequest(
const query = message.content; message.content,
let queryExpansion = null; previousResult.reportStyle || skillObject.styles,
if (!isAuditRefineRequest(message.content, previousResult)) { );
let persisted = false;
if (
styleRequest.matched &&
styleRequest.changes.length &&
styleRequest.persist
) {
try {
await saveAuditStyle(styleRequest.style);
persisted = true;
} catch (error) {
console.warn('审计 Word 样式保存失败,保留本次调整:', error);
toast.warning('本次样式已应用,但保存到公共 Skill 失败');
}
}
const styledResult = {
...previousResult,
request: message.content,
reportStyle: styleRequest.style,
styleAdjusted: true,
generatedAt: new Date().toISOString(),
};
pendingAuditResultRef.current = styledResult;
auditKnowledgePrompt = buildAuditStylePrompt(
styleRequest.changes,
persisted,
);
} else {
const usesChecklistMode = auditAction !== 'qa';
if (usesChecklistMode) {
const query = message.content;
let result = null;
const isRefine = isAuditRefineRequest(
message.content,
previousResult,
);
if (isRefine && previousResult?.analysis) {
result = buildAuditResultFromAnalysis(
query,
records,
previousResult.analysis,
previousResult,
{ skillObject },
);
} else if (!isRefine) {
try {
const aiAnalysis = await analyzeAuditRequest(
message.content,
selectedModel,
records,
skillObject,
previousResult,
abortController.current?.signal,
);
if (aiAnalysis) {
result = buildAuditResultFromAnalysis(
query,
records,
aiAnalysis,
previousResult,
{ skillObject },
);
}
} catch (error) {
if (error?.name !== 'AbortError') {
console.warn('审计专项 AI 分析失败,回退本地评分:', error);
}
}
}
if (!result) {
let queryExpansion = null;
if (!isRefine) {
try {
queryExpansion = await expandAuditQuery(
message.content,
selectedModel,
abortController.current?.signal,
);
} catch (error) {
if (error?.name !== 'AbortError') {
console.warn('审计检索词 AI 扩展失败,回退本地规则:', error);
}
}
}
result = buildAuditResult(query, records, previousResult, {
extraKeywords: queryExpansion?.applied
? queryExpansion.expandedKeywords
: [],
skillObject,
});
if (queryExpansion) result.queryExpansion = queryExpansion;
}
if (
result.instruction?.remove?.length &&
isPersistentAuditRuleRequest(message.content)
) {
try {
const riskPointIds = records
.filter((record) =>
recordMatchesTerms(record, result.instruction.remove),
)
.map(getAuditRecordKey);
await saveAuditSkillRule({
domain: result.analysis?.auditDomain || result.project,
removeTerms: result.instruction.remove,
riskPointIds,
label: `过滤 ${result.instruction.remove.join('、')}`,
description: '业务人员在审计专项结果中明确要求排除的风险点。',
sourceQuery: message.content,
});
} catch (error) {
console.warn('审计 Skill 业务过滤规则保存失败:', error);
}
}
pendingAuditResultRef.current = result;
auditKnowledgePrompt = buildAuditKnowledgePrompt(result);
} else {
let result = null;
try { try {
queryExpansion = await expandAuditQuery( const aiAnalysis = await analyzeAuditRequest(
message.content, message.content,
selectedModel, selectedModel,
records,
skillObject,
previousResult,
abortController.current?.signal, abortController.current?.signal,
); );
if (aiAnalysis) {
result = buildAuditResultFromAnalysis(
message.content,
records,
aiAnalysis,
previousResult,
{ skillObject },
);
}
} catch (error) { } catch (error) {
if (error?.name !== 'AbortError') { if (error?.name !== 'AbortError') {
console.warn('审计检索词 AI 扩展失败,回退本地规则:', error); console.warn('审计问答 AI 分析失败,回退本地评分:', error);
} }
} }
if (!result) {
result = buildAuditResult(
message.content,
records,
previousResult,
{
skillObject,
},
);
}
result = { ...result, kind: 'qa' };
pendingAuditExportRef.current = {
question: message.content,
title: '审计知识库问答',
records: result.records || [],
};
auditKnowledgePrompt = buildGroundedAuditKnowledgePrompt(
result.records || [],
message.content,
);
} }
const result = buildAuditResult(query, records, previousResult, {
extraKeywords: queryExpansion?.applied
? queryExpansion.expandedKeywords
: [],
});
if (queryExpansion) result.queryExpansion = queryExpansion;
pendingAuditResultRef.current = result;
auditKnowledgePrompt = buildAuditKnowledgePrompt(result);
} else {
pendingAuditExportRef.current = {
question: message.content,
title: '审计知识库问答',
};
auditKnowledgePrompt = buildGroundedAuditKnowledgePrompt(
records,
message.content,
);
} }
} }
...@@ -900,6 +1483,19 @@ export default forwardRef((props, ref) => { ...@@ -900,6 +1483,19 @@ export default forwardRef((props, ref) => {
knowledgeBases: getActiveKnowledgeBases(message).map( knowledgeBases: getActiveKnowledgeBases(message).map(
serializeKnowledgeBase, serializeKnowledgeBase,
), ),
auditSkill:
conversationTopic?.name === AUDIT_TOPIC.name ||
['knowledge-copilot', 'audit-checklist'].includes(
conversationAgent?.key,
)
? {
name: auditSkillObject.name,
enabled: auditSkillObject.enabled !== false,
skillFileName: auditSkillObject.skillFile?.name || '',
businessRuleCount:
auditSkillObject.businessRules?.length || 0,
}
: undefined,
}; };
const data = { const data = {
...@@ -1129,11 +1725,15 @@ export default forwardRef((props, ref) => { ...@@ -1129,11 +1725,15 @@ export default forwardRef((props, ref) => {
if (usesAuditKnowledge) { if (usesAuditKnowledge) {
loadAuditRecords().catch(() => {}); loadAuditRecords().catch(() => {});
} }
if (usesAuditKnowledge) {
loadAuditSkill().catch(() => {});
}
} }
}, [ }, [
conversationAgent?.key, conversationAgent?.key,
conversationTopic?.name, conversationTopic?.name,
loadAuditRecords, loadAuditRecords,
loadAuditSkill,
userInfo, userInfo,
]); ]);
...@@ -1319,7 +1919,10 @@ export default forwardRef((props, ref) => { ...@@ -1319,7 +1919,10 @@ export default forwardRef((props, ref) => {
const renderAssistantExtras = (item) => { const renderAssistantExtras = (item) => {
if (item.message?.role !== 'assistant') return null; if (item.message?.role !== 'assistant') return null;
const allowWordExport = conversationAgent?.key === 'audit-checklist'; const allowWordExport =
conversationAgent?.key === 'audit-checklist' ||
conversationAgent?.key === 'knowledge-copilot' ||
conversationTopic?.name === AUDIT_TOPIC.name;
const auditExport = allowWordExport ? item.message.auditExport : null; const auditExport = allowWordExport ? item.message.auditExport : null;
return ( return (
...@@ -1328,6 +1931,7 @@ export default forwardRef((props, ref) => { ...@@ -1328,6 +1931,7 @@ export default forwardRef((props, ref) => {
<AuditChecklistResult <AuditChecklistResult
aiContent={item.message.content} aiContent={item.message.content}
allowWordExport={allowWordExport} allowWordExport={allowWordExport}
onSaveSkillRule={saveAuditSkillRule}
result={item.message.auditResult} result={item.message.auditResult}
/> />
) : null} ) : null}
...@@ -1335,7 +1939,7 @@ export default forwardRef((props, ref) => { ...@@ -1335,7 +1939,7 @@ export default forwardRef((props, ref) => {
<AuditWordDownload <AuditWordDownload
aiContent={item.message.content} aiContent={item.message.content}
question={auditExport.question} question={auditExport.question}
records={auditRecords} records={auditExport.records || auditRecords}
title={auditExport.title} title={auditExport.title}
/> />
) : null} ) : null}
...@@ -1410,6 +2014,8 @@ export default forwardRef((props, ref) => { ...@@ -1410,6 +2014,8 @@ export default forwardRef((props, ref) => {
/> />
<AuditPromptConfigDialog <AuditPromptConfigDialog
open={auditPromptOpen} open={auditPromptOpen}
skillObject={auditSkillObject}
onUploadSkill={uploadAuditSkill}
value={configuredAuditTopic?.metadata} value={configuredAuditTopic?.metadata}
onCancel={() => setAuditPromptOpen(false)} onCancel={() => setAuditPromptOpen(false)}
onSave={saveAuditPromptConfig} onSave={saveAuditPromptConfig}
......
...@@ -356,6 +356,31 @@ export async function uploadAuditKnowledgeFile(file) { ...@@ -356,6 +356,31 @@ export async function uploadAuditKnowledgeFile(file) {
return normalizeUploadedFile(payload, file); return normalizeUploadedFile(payload, file);
} }
export async function uploadAuditSkillFile(
content,
fileName = 'audit_public.md',
) {
const file =
content instanceof File
? content
: new File([String(content || '')], fileName, {
type: 'text/markdown;charset=utf-8',
lastModified: Date.now(),
});
const formData = new FormData();
formData.append('file', file, file.name);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Skill 文件上传失败(${response.status})`);
}
const payload = await response.json();
return normalizeUploadedFile(payload, file);
}
export function getUserInfo() { export function getUserInfo() {
const currentUser = localStorage.getItem('current_user'); const currentUser = localStorage.getItem('current_user');
return currentUser && JSON.parse(currentUser); return currentUser && JSON.parse(currentUser);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment