Compare commits
3 Commits
a0f6d9f702
...
a12c4bea64
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a12c4bea64 | ||
|
|
e5b03c6601 | ||
|
|
3eb78d343a |
@@ -0,0 +1,215 @@
|
|||||||
|
# AI 工作台统一意图识别框架设计
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
AI 工作台当前的输入识别分散在多个前端 flow 中:申请预览、报销草稿、单据查询、草稿删除提示各自判断输入。这样的结构能快速修复单点问题,但会让“删除 3 天前的草稿”“审核合规没有风险的申请”这类组合型请求继续变成关键词补丁。
|
||||||
|
|
||||||
|
本设计把自然语言输入先统一解析成结构化 `IntentFrame`,再根据目标是否明确、安全等级和业务边界决定下一步动作。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 让工作台所有自然语言输入先进入统一意图框架,不再在各业务 flow 中散落判断。
|
||||||
|
- 支持动作、对象、筛选条件、上下文指代、安全等级的组合识别。
|
||||||
|
- 对删除、审核、驳回等高风险动作固定走“筛选候选 + 详情确认”,禁止自然语言直接执行。
|
||||||
|
- 保留当前会话内的快速指代能力,例如“删除刚才那个草稿”能定位最近创建的草稿。
|
||||||
|
- 对带筛选条件的请求,例如“删除 3 天前的草稿”“审核无风险申请”,先展示思考过程和候选列表。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 第一版不引入 LangGraph,也不把前端本地识别迁到后端状态机。
|
||||||
|
- 第一版不做自然语言直接批量删除、批量审核或批量驳回。
|
||||||
|
- 第一版不改后端审批、删除接口的权限模型。
|
||||||
|
- 第一版不重写现有申请预览和报销草稿流程,只把入口识别前置统一。
|
||||||
|
|
||||||
|
## 总体架构
|
||||||
|
|
||||||
|
统一意图识别分三层:
|
||||||
|
|
||||||
|
1. `IntentFrame Parser`:把用户输入解析为结构化意图。
|
||||||
|
2. `Target Resolver`:结合当前会话、最近动作和筛选条件,判断目标是否唯一。
|
||||||
|
3. `Action Policy`:根据动作风险决定直接查询、展示候选、要求澄清或阻断。
|
||||||
|
|
||||||
|
输入链路应调整为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
用户输入
|
||||||
|
-> IntentFrame Parser
|
||||||
|
-> Action Policy
|
||||||
|
-> Target Resolver
|
||||||
|
-> 业务 flow
|
||||||
|
- 查询候选
|
||||||
|
- 打开详情确认
|
||||||
|
- 进入申请/报销流程
|
||||||
|
- 要求补充条件
|
||||||
|
```
|
||||||
|
|
||||||
|
## IntentFrame 数据结构
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
action: 'query' | 'delete' | 'approve' | 'reject' | 'create' | 'update' | 'ask_policy',
|
||||||
|
objectType: 'draft' | 'application' | 'reimbursement' | 'approval_task' | 'receipt' | 'document',
|
||||||
|
filters: {
|
||||||
|
timeRange: null,
|
||||||
|
status: null,
|
||||||
|
risk: null,
|
||||||
|
documentType: null,
|
||||||
|
amount: null,
|
||||||
|
keyword: null
|
||||||
|
},
|
||||||
|
targetMode: 'current_context' | 'filtered_candidates' | 'ambiguous',
|
||||||
|
safetyLevel: 'read_only' | 'confirm_required' | 'blocked',
|
||||||
|
confidence: 0,
|
||||||
|
normalizedQuery: ''
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段含义
|
||||||
|
|
||||||
|
- `action` 表示用户想做什么,例如查、删、审核、驳回、创建或咨询规则。
|
||||||
|
- `objectType` 表示动作对象,例如草稿、申请单、报销单、待审任务或票据。
|
||||||
|
- `filters` 表示筛选条件,必须可以复用到单据查询 flow。
|
||||||
|
- `targetMode` 表示目标定位方式:
|
||||||
|
- `current_context`:明确指向当前会话最近对象。
|
||||||
|
- `filtered_candidates`:需要查询候选列表。
|
||||||
|
- `ambiguous`:条件不足,需要澄清。
|
||||||
|
- `safetyLevel` 表示动作安全级别:
|
||||||
|
- `read_only`:可直接查询或解释。
|
||||||
|
- `confirm_required`:只展示候选或详情入口,不直接执行。
|
||||||
|
- `blocked`:存在批量破坏性风险或越权风险,必须阻断。
|
||||||
|
- `normalizedQuery` 是给现有查询 flow 使用的可读查询句,例如“我的 3 天前草稿单据”。
|
||||||
|
|
||||||
|
## 识别策略
|
||||||
|
|
||||||
|
### 动作识别
|
||||||
|
|
||||||
|
- 查询:查、看、列出、有哪些、找一下。
|
||||||
|
- 删除:删除、删掉、移除、作废、撤销。
|
||||||
|
- 审核:审核、审批、处理待办、去审批。
|
||||||
|
- 驳回:驳回、退回、拒绝。
|
||||||
|
- 创建:新建、发起、申请、我要报销。
|
||||||
|
- 更新:补充、修改、改成、填入。
|
||||||
|
- 规则咨询:怎么走、能不能、规则、制度、政策、标准。
|
||||||
|
|
||||||
|
### 对象识别
|
||||||
|
|
||||||
|
- 草稿:草稿、未提交、刚才保存的单据。
|
||||||
|
- 申请单:申请、申请单、出差申请、费用申请。
|
||||||
|
- 报销单:报销、报销单、费用报销。
|
||||||
|
- 待审任务:待办、待我审核、待审批、审核单。
|
||||||
|
- 票据:发票、票据、附件、图片。
|
||||||
|
|
||||||
|
### 筛选条件识别
|
||||||
|
|
||||||
|
- 时间:今天、昨天、3 天前、近 7 天、上周、本月、具体日期、日期范围。
|
||||||
|
- 状态:草稿、审批中、已通过、已驳回、待补充。
|
||||||
|
- 风险:无风险、低风险、中风险、高风险、合规、异常、超标。
|
||||||
|
- 金额:超过 1000、500 以下、100 到 300。
|
||||||
|
- 关键词:地点、事由、人员、部门、单号等自由文本。
|
||||||
|
|
||||||
|
## 目标解析规则
|
||||||
|
|
||||||
|
### 当前上下文直达
|
||||||
|
|
||||||
|
当用户使用“刚才那个”“当前”“这个”“上面那个”这类指代,并且当前会话中能找到最近的可操作对象时,`targetMode` 为 `current_context`。
|
||||||
|
|
||||||
|
例子:
|
||||||
|
|
||||||
|
- “删除刚才那个草稿”
|
||||||
|
- “打开这个申请单”
|
||||||
|
- “继续刚才的报销草稿”
|
||||||
|
|
||||||
|
即便目标唯一,删除、审核、驳回仍然只打开详情页或确认入口。
|
||||||
|
|
||||||
|
### 筛选候选
|
||||||
|
|
||||||
|
当用户输入包含时间、风险、金额、状态、类型等筛选条件时,`targetMode` 必须为 `filtered_candidates`。
|
||||||
|
|
||||||
|
例子:
|
||||||
|
|
||||||
|
- “删除 3 天前的草稿”
|
||||||
|
- “审核合规没有风险的申请”
|
||||||
|
- “找一下上海相关的低风险待审申请”
|
||||||
|
|
||||||
|
这类请求必须进入单据查询 flow,展示候选结果,不能直接套最近草稿。
|
||||||
|
|
||||||
|
### 条件不足澄清
|
||||||
|
|
||||||
|
当动作高风险但目标既不唯一,也没有足够筛选条件时,`targetMode` 为 `ambiguous`。
|
||||||
|
|
||||||
|
例子:
|
||||||
|
|
||||||
|
- “把草稿删了”
|
||||||
|
- “帮我审核一下”
|
||||||
|
- “退回这个单”
|
||||||
|
|
||||||
|
系统应提示用户选择候选或补充条件。
|
||||||
|
|
||||||
|
## Action Policy
|
||||||
|
|
||||||
|
| 动作 | 安全等级 | 第一版行为 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 查询 | `read_only` | 直接查询并展示结果 |
|
||||||
|
| 规则咨询 | `read_only` | 走政策/规则解释,不进入单据查询 |
|
||||||
|
| 删除 | `confirm_required` | 展示候选或打开详情页确认,不直接删除 |
|
||||||
|
| 审核通过 | `confirm_required` | 展示待审候选或打开审核详情,不直接通过 |
|
||||||
|
| 驳回/退回 | `confirm_required` | 展示待审候选或打开审核详情,不直接驳回 |
|
||||||
|
| 批量删除/批量审核 | `blocked` | 阻断并要求用户选择具体单据 |
|
||||||
|
|
||||||
|
## 用户可见思考过程
|
||||||
|
|
||||||
|
筛选型命令必须复用现有查询 thinking events,并补充动作意图说明:
|
||||||
|
|
||||||
|
1. 解析自然语言动作和筛选条件。
|
||||||
|
2. 判断操作风险和目标定位方式。
|
||||||
|
3. 查询业务单据接口。
|
||||||
|
4. 按条件组合筛选候选。
|
||||||
|
5. 展示候选卡片和下一步入口。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
解析:识别到“删除”是高风险动作,对象是“草稿”,时间条件是“3 天前”。
|
||||||
|
策略:不会直接删除,将先查询我的草稿候选。
|
||||||
|
结果:命中 2 张草稿,请打开详情页确认删除目标。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件边界
|
||||||
|
|
||||||
|
- 新增 `workbenchIntentFrameModel.js`:负责解析用户输入到 `IntentFrame`。
|
||||||
|
- 新增 `workbenchIntentActionPolicy.js`:负责动作安全策略和下一步路由判断。
|
||||||
|
- 调整 `workbenchAiCommandIntentModel.js`:保留会话内最近草稿解析,但不再单独拥有顶层意图判断。
|
||||||
|
- 调整 `useWorkbenchAiCommandIntents.js`:基于 `IntentFrame` 分发到当前上下文直达或候选查询。
|
||||||
|
- 调整 `aiDocumentQueryIntent.js` 和 `aiDocumentQueryModel.js`:补充风险筛选、相对日期和筛选摘要。
|
||||||
|
- 补充前端测试,覆盖组合型输入和安全边界。
|
||||||
|
|
||||||
|
## 迁移步骤
|
||||||
|
|
||||||
|
1. 先为 `IntentFrame` 解析补测试:
|
||||||
|
- “删除刚才那个草稿”解析为 `delete + draft + current_context + confirm_required`。
|
||||||
|
- “删除 3 天前的草稿”解析为 `delete + draft + filtered_candidates + confirm_required`。
|
||||||
|
- “审核合规没有风险的申请”解析为 `approve + application + risk:none + filtered_candidates + confirm_required`。
|
||||||
|
- “审批规则怎么走”解析为 `ask_policy`,不进入单据查询。
|
||||||
|
2. 实现 `workbenchIntentFrameModel.js`,不接入 UI。
|
||||||
|
3. 实现风险和相对日期筛选能力,让查询 flow 能承接筛选型命令。
|
||||||
|
4. 接入 `useWorkbenchAiCommandIntents.js`:
|
||||||
|
- 当前上下文直达:生成详情确认入口。
|
||||||
|
- 筛选候选:调用 `handleAiDocumentQueryIntent(normalizedQuery, pendingMessage)`。
|
||||||
|
- 条件不足:提示补充条件或查询可选候选。
|
||||||
|
5. 移除或降级旧的散落正则,让顶层输入先走统一框架。
|
||||||
|
6. 跑定向测试、相邻测试、前端构建和 5173 工作台烟测。
|
||||||
|
|
||||||
|
## 验收标准
|
||||||
|
|
||||||
|
- 输入“删除刚才那个草稿”时,系统定位当前会话最近草稿,并只打开详情确认入口。
|
||||||
|
- 输入“删除 3 天前的草稿”时,系统展示筛选思考过程和草稿候选,不使用最近草稿快捷路径。
|
||||||
|
- 输入“审核合规没有风险的申请”时,系统查询待我审核申请,并筛选无风险候选。
|
||||||
|
- 输入“审批规则怎么走”时,不进入单据查询。
|
||||||
|
- 删除、审核、驳回均不通过自然语言直接执行最终动作。
|
||||||
|
- 新增/调整测试全部通过,前端构建通过,`git diff --check` 无空白错误。
|
||||||
|
|
||||||
|
## 后续演进
|
||||||
|
|
||||||
|
- 当后端 steward planner 能稳定输出同等 `IntentFrame` 时,可以把 Parser 层迁到后端,前端只保留策略兜底。
|
||||||
|
- 如果需要跨会话目标记忆,可把最近创建/保存草稿写入会话快照,并附带过期时间和用户确认边界。
|
||||||
|
- 如果未来引入 LangGraph,应把它用于多步状态编排,而不是替代 `IntentFrame` schema 本身。
|
||||||
@@ -396,8 +396,62 @@
|
|||||||
- 验证:`node --test web/tests/workbench-ai-intent-planner-model.test.mjs`、`node --test web/tests/workbench-ai-application-gate-model.test.mjs`、`node --test web/tests/workbench-ai-application-context-submit.test.mjs`、`node --test web/tests/steward-plan-model-pending-flow.test.mjs` 均通过;`git diff --check -- web/src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js web/tests/workbench-ai-intent-planner-model.test.mjs` 无输出。
|
- 验证:`node --test web/tests/workbench-ai-intent-planner-model.test.mjs`、`node --test web/tests/workbench-ai-application-gate-model.test.mjs`、`node --test web/tests/workbench-ai-application-context-submit.test.mjs`、`node --test web/tests/steward-plan-model-pending-flow.test.mjs` 均通过;`git diff --check -- web/src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js web/tests/workbench-ai-intent-planner-model.test.mjs` 无输出。
|
||||||
- 影响:用户输入“2026-02-20 至 2026-02-23,去上海出差,辅助国网仿生产服务器部署,交通火车”时,如果门禁已查明没有可关联申请单,前端会把“应先申请单据”直接落到申请预览链路,不再表现成只会反复识别意图。
|
- 影响:用户输入“2026-02-20 至 2026-02-23,去上海出差,辅助国网仿生产服务器部署,交通火车”时,如果门禁已查明没有可关联申请单,前端会把“应先申请单据”直接落到申请预览链路,不再表现成只会反复识别意图。
|
||||||
|
|
||||||
|
- 22:04:我修复了 AI 工作台短句意图漏识别,覆盖“删除草稿”和“我要审核/待办审批”这两类高频输入。
|
||||||
|
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main`,`HEAD..@{u}` 与 `@{u}..HEAD` 均未输出新提交;本轮修改集中在 AI 工作台命令意图、待审单据查询识别和对应前端测试。
|
||||||
|
- 根因:工作台输入链路目前优先进入模型规划,但前端本地可执行意图只覆盖申请预览、报销和单据查询的较完整问法;“我要审核”没有命中 `resolveAiDocumentQueryIntent()` 的待审查询触发词,“删除草稿”则没有上下文命令入口,容易退回普通 steward 规划或被当作报销填槽文本。
|
||||||
|
- 修改:新增 `workbenchAiCommandIntentModel.js` 和 `useWorkbenchAiCommandIntents.js`,把“删除草稿 / 删除这个申请单 / 把刚才保存的草稿删掉”等短句识别为上下文命令;命中最近草稿时只给“查看草稿详情”动作,要求用户在详情页二次确认删除,不直接执行破坏性删除。
|
||||||
|
- 修改:`usePersonalWorkbenchAiMode.js` 接入命令意图 composable,并让删除草稿意图优先于报销草稿填槽处理;未找到最近草稿时自动转为“我的草稿单据”查询,帮助用户先选中目标。
|
||||||
|
- 修改:`aiDocumentQueryIntent.js` 扩展“我要审核 / 我要审批 / 待办审批 / 去审核 / 处理审批”等短句到待我审核单据查询,同时排除“审批规则怎么走”这类政策咨询,避免误打到单据列表。
|
||||||
|
- 测试:先补红灯测试复现缺口;实现后 `node --test web/tests/workbench-ai-command-intent-model.test.mjs web/tests/ai-document-query-model.test.mjs` 通过 19/19;相邻 `workbench-ai-action-router.test.mjs` 和 `workbench-ai-intent-planner-model.test.mjs` 通过;`npm --prefix web run build` 通过;`git diff --check -- ...` 无输出。
|
||||||
|
- 真页烟测:当前 `x-financial-local-linux` 正在监听 5173,`curl -I http://127.0.0.1:5173/app/workbench` 返回 200,说明本地 AI 工作台入口可访问。
|
||||||
|
- 影响:用户在 AI 工作台直接输入“删除草稿”时,会被引导到最近草稿详情并保留删除确认边界;输入“我要审核”或“待办审批”时,会直接查询待我审核单据,不再表现为没识别到意图。
|
||||||
|
|
||||||
|
- 22:31:我把 AI 工作台意图识别从场景补丁收敛成统一 Intent Frame 方案,并按方案完成第一版前端接线。
|
||||||
|
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main`,`HEAD..@{u}` 与 `@{u}..HEAD` 均未输出新提交;本轮在 22:04 短句修复基础上继续新增 Markdown 方案、统一意图框架、动作策略、筛选能力和前端测试。
|
||||||
|
- 文档:新增 `2026-06-24-workbench-intent-frame-design.md`,明确 `IntentFrame Parser -> Target Resolver -> Action Policy -> 业务 flow` 的总体方案;用户确认只需要 `.md` 后,我清理了临时 DOCX 产物,只保留 Markdown 文档。
|
||||||
|
- 修改:新增 `workbenchIntentFrameModel.js`,把输入统一解析为 `action / objectType / filters / targetMode / safetyLevel / normalizedQuery`;覆盖“删除刚才那个草稿”“删除 3 天前的草稿”“审核合规没有风险的申请”和“审批规则怎么走”。
|
||||||
|
- 修改:新增 `workbenchIntentActionPolicy.js`,把删除、审核、驳回固定为 `confirm_required`;当前上下文目标只打开详情确认,筛选型目标只查询候选,不通过自然语言直接执行副作用。
|
||||||
|
- 修改:`useWorkbenchAiCommandIntents.js` 先走统一 Intent Frame,再分发到最近草稿详情确认或候选查询;并排除“删除附件/票据”这类非单据删除,避免抢走附件流程。
|
||||||
|
- 修改:`aiDocumentQueryIntent.js` 和 `aiDocumentQueryModel.js` 增加“3 天前”相对日期、无风险/高/中/低风险筛选、`我的草稿单据` 查询来源识别和风险摘要展示;“审批规则怎么走”仍保持规则咨询,不进入单据查询。
|
||||||
|
- 测试:先补红灯测试复现缺口;实现后 `node --test web/tests/workbench-intent-frame-model.test.mjs` 通过 4/4,`node --test web/tests/ai-document-query-model.test.mjs` 通过 16/16;相邻 `workbench-ai-command-intent-model.test.mjs`、`workbench-ai-action-router.test.mjs`、`workbench-ai-intent-planner-model.test.mjs` 均通过。
|
||||||
|
- 验证:`npm --prefix web run build` 通过,保留既有 Rollup pure 注释与 chunk size warning;`git diff --check -- ...` 无输出;`curl -I http://127.0.0.1:5173/app/workbench` 返回 200。
|
||||||
|
- 影响:后续“删除 3 天前的草稿”会先转成“我的 3天前 草稿单据”候选查询;“审核合规没有风险的申请”会先转成“待我审核 无风险 申请单”候选查询;“刚才那个草稿”仍可在当前会话内定位最近草稿,但只给详情确认入口。
|
||||||
|
|
||||||
|
- 22:36:我修复了“删除申请单草稿”虽然识别到删除动作,但前端没有显示删除思考过程的问题。
|
||||||
|
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main`,`HEAD..@{u}` 与 `@{u}..HEAD` 均未输出新提交;本轮继续修改统一 Intent Frame 查询句、单据查询 thinking 和对应回归测试。
|
||||||
|
- 根因:`resolveWorkbenchIntentFrame()` 能解析出 `action=delete`,但 `normalizedQuery` 被压成“我的草稿单据”,丢了“申请单”筛选;同时 `handleAiDocumentQueryIntent()` 只接收查询句,不接收原始 `commandFrame`,导致 thinking 只展示“解析筛选条件”,没有展示“删除是高风险动作,不会直接执行,会先筛选候选”。
|
||||||
|
- 修改:`workbenchIntentFrameModel.js` 保留“草稿 + 申请单”的组合筛选,`删除申请单草稿` 现在生成 `我的 草稿 申请单`,避免查询阶段退化成全部草稿。
|
||||||
|
- 修改:`aiDocumentQueryModel.js` 新增 `buildAiDocumentQueryThinkingEvents()`,当传入 `commandFrame` 且 `safetyLevel=confirm_required` 时,首个 thinking 事件固定为“识别高风险操作意图”,文案明确删除/审核不会直接执行,会先筛选候选。
|
||||||
|
- 修改:`useWorkbenchAiDocumentQueryFlow.js` 支持第三个参数 `{ commandFrame }`,`useWorkbenchAiCommandIntents.js` 在候选查询时传入统一意图帧,让查询 thinking 保留原始动作语义。
|
||||||
|
- 测试:新增回归覆盖 `删除申请单草稿`,断言查询句保留 `申请单` 与 `草稿`,且 thinking 首条包含“删除 / 不会直接执行 / 先筛选候选”;相关 `workbench-intent-frame-model.test.mjs` 6/6 通过。
|
||||||
|
- 验证:`node --test web/tests/workbench-intent-frame-model.test.mjs web/tests/workbench-ai-command-intent-model.test.mjs web/tests/ai-document-query-model.test.mjs` 通过 27/27;相邻 `workbench-ai-action-router.test.mjs` 和 `workbench-ai-intent-planner-model.test.mjs` 通过 17/17;`npm --prefix web run build` 通过;`git diff --check -- ...` 无输出;5173 工作台路由返回 200。
|
||||||
|
- 影响:用户输入“删除申请单草稿”时,不会只看到普通查询筛选;系统会先展示删除动作的高风险策略思考,再查询“我的草稿申请单”候选,并保留详情页二次确认边界。
|
||||||
|
|
||||||
|
- 22:42:我补强了高风险命令的最终候选结果提示,让用户在卡片结果里也能看到“不会直接删除/审核”和快捷入口说明。
|
||||||
|
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main`,`HEAD..@{u}` 与 `@{u}..HEAD` 均未输出新提交;本轮继续修改 `aiDocumentQueryModel.js`、查询 flow 和统一意图回归测试。
|
||||||
|
- 根因:22:36 只把删除动作写进 thinking 事件,但最终候选结果仍是普通单据卡片和“查看详情”按钮;用户如果只看结果卡片,会看不到“系统不会直接删除,请先进入详情确认”的明确操作边界。
|
||||||
|
- 修改:`buildAiDocumentQueryMessage()` 支持 `commandFrame`,当命令为删除/审核/驳回等 `confirm_required` 动作时,在候选卡片上方插入高风险操作提示:系统不会直接删除/审核相关单据,需点击下方候选单据里的快捷按钮并进入详情核对后再操作。
|
||||||
|
- 修改:候选单据卡片的操作按钮会随命令动作改文案:删除为“进入详情确认删除”,审核为“进入详情确认审核”,驳回为“进入详情确认驳回”;普通查询仍保持“查看详情”。
|
||||||
|
- 修改:`useWorkbenchAiDocumentQueryFlow.js` 在生成最终查询消息时传入 `commandFrame`,保证真实工作台路径能展示同一套提示和按钮文案。
|
||||||
|
- 测试:新增回归断言 `删除申请单草稿` 的结果消息包含“系统不会直接删除相关单据 / 请点击下方候选单据里的快捷按钮 / 进入单据详情核对后再操作”,并出现 `进入详情确认删除` 按钮文案。
|
||||||
|
- 验证:`node --test web/tests/workbench-intent-frame-model.test.mjs web/tests/workbench-ai-command-intent-model.test.mjs web/tests/ai-document-query-model.test.mjs` 通过 28/28;相邻 `workbench-ai-action-router.test.mjs` 和 `workbench-ai-intent-planner-model.test.mjs` 通过 17/17;`npm --prefix web run build` 通过;`git diff --check -- ...` 无输出;5173 工作台路由返回 200。
|
||||||
|
- 影响:用户输入“删除申请单草稿”后,既能在 thinking 里看到删除动作识别,也能在最终结果卡片里看到删除确认边界和实体快捷入口,不会误以为系统已经直接删除或只能普通查看。
|
||||||
|
|
||||||
|
- 22:49:我根据截图修复了“删除申请单草稿”结果页只剩“已查询到相关单据”、高风险提示和候选卡片没有显示的问题。
|
||||||
|
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main`,`HEAD..@{u}` 与 `@{u}..HEAD` 均未输出新提交;工作区仍是本轮 AI 工作台意图识别和结果渲染相关未提交改动。
|
||||||
|
- 根因:高风险提示块里使用了 `<p>` 标签,但 `conversationTrustedHtml` 的 trusted HTML 白名单不允许 `<p>`;安全渲染会把包含该标签的整段 trusted HTML 丢弃,所以页面只保留 Markdown 标题,看起来像没有继续删除思考。
|
||||||
|
- 修改:`aiDocumentQueryModel.js` 将高风险提示正文改为白名单已允许的 `<div>`,不扩大 trusted HTML 标签面;`personal-workbench-ai-mode.css` 增加提示块样式,让“系统不会直接删除相关单据”和后续详情确认说明稳定显示在候选卡片上方。
|
||||||
|
- 测试:先新增渲染级红灯测试复现截图,确认渲染后只剩标题;修复后该测试断言 `ai-document-command-guidance`、`系统不会直接删除相关单据`、`进入详情确认删除` 和 `ai-document-card-list` 都保留在最终 HTML 中。
|
||||||
|
- 验证:`node --test web/tests/ai-document-query-model.test.mjs` 通过 17/17;`node --test web/tests/ai-document-query-model.test.mjs web/tests/workbench-intent-frame-model.test.mjs web/tests/workbench-ai-command-intent-model.test.mjs web/tests/workbench-ai-action-router.test.mjs web/tests/workbench-ai-intent-planner-model.test.mjs` 通过 46/46;`npm --prefix web run build` 通过;`git diff --check -- ...` 无输出;`curl -I http://127.0.0.1:5173/app/workbench` 返回 200。
|
||||||
|
- 影响:用户输入“删除申请单草稿”后,结果标题下方会恢复高风险说明、候选单据卡片和“进入详情确认删除”快捷按钮;系统仍不会直接删除单据,而是要求进入详情核对后再操作。
|
||||||
|
|
||||||
## 遗留问题
|
## 遗留问题
|
||||||
|
|
||||||
|
- 22:49:本轮用渲染级回归测试确认 trusted HTML 已恢复,但还没有做真实浏览器点击级回放和截图复核。建议后续在 5173 输入“删除申请单草稿”,确认真实主题下提示块、候选卡片和按钮没有拥挤或错位。
|
||||||
|
- 22:42:本轮仍未做真实浏览器点击级回放确认高风险提示在 AI 工作台卡片里的视觉效果。建议后续在 5173 输入“删除申请单草稿”,截图确认提示块、候选卡片和“进入详情确认删除”按钮在当前主题下没有拥挤或错位。
|
||||||
|
- 22:36:本轮仍未做浏览器点击级回放确认“删除申请单草稿”的实际 thinking 卡片视觉状态。建议后续在真实 5173 工作台输入该句,确认首条 thinking 为删除高风险策略、后续才是查询和筛选候选。
|
||||||
|
- 22:31:本轮已完成统一意图框架的模型测试、查询筛选测试、相邻前端测试、构建和 5173 路由烟测,但没有做浏览器点击级回放确认“删除 3 天前的草稿 / 审核合规没有风险的申请”的最终候选卡片。建议后续在真实工作台输入这两句,截图确认 thinking、候选列表和详情入口。
|
||||||
|
- 22:04:本轮完成了前端行为测试、构建和 5173 路由烟测,但没有做浏览器点击级回放确认输入“删除草稿 / 我要审核”的最终卡片状态。建议后续在真实浏览器里回放这两个短句,截图确认动作卡片和待审单据列表符合预期。
|
||||||
- 09:41:当前 Skill 是新建在项目级 `.codex/skills` 目录里,本轮可以通过文件检查验证结构,但是否被未来会话自动加载还依赖 Codex 对项目 Skill 的刷新机制。建议后续新开会话或下一次任务时确认 Skill 列表是否出现 `agent-change-log`。
|
- 09:41:当前 Skill 是新建在项目级 `.codex/skills` 目录里,本轮可以通过文件检查验证结构,但是否被未来会话自动加载还依赖 Codex 对项目 Skill 的刷新机制。建议后续新开会话或下一次任务时确认 Skill 列表是否出现 `agent-change-log`。
|
||||||
- 09:43:`.codex/` 曾被 `.gitignore` 整体忽略,新建的 `agent-change-log` 默认不会进入普通提交范围;09:44 已改成只放行该 Skill。建议后续如果还新增其他项目 Skill,也按同样方式逐个显式放行,别一次性开放整个 `.codex`。
|
- 09:43:`.codex/` 曾被 `.gitignore` 整体忽略,新建的 `agent-change-log` 默认不会进入普通提交范围;09:44 已改成只放行该 Skill。建议后续如果还新增其他项目 Skill,也按同样方式逐个显式放行,别一次性开放整个 `.codex`。
|
||||||
- 10:01:当前工作区已有大量未提交改动,且本地 `main` ahead 1。建议后续如果真的发现 upstream 新提交,先用 fetch 和 `HEAD..@{u}` 写日志;只有在工作区干净、可快进时再执行 `git pull --ff-only`,避免把其他智能体提交和本地半成品混到一起。
|
- 10:01:当前工作区已有大量未提交改动,且本地 `main` ahead 1。建议后续如果真的发现 upstream 新提交,先用 fetch 和 `HEAD..@{u}` 写日志;只有在工作区干净、可快进时再执行 `git pull --ff-only`,避免把其他智能体提交和本地半成品混到一起。
|
||||||
@@ -488,6 +542,15 @@
|
|||||||
- [x] ~~让 AI 模式模型规划等待期间持续追加 thinking 事件,而不是静态显示“思考中”后一次性出现步骤。~~(完成于 17:26,证据:真页干净会话 thinking 数量从 2 条逐步增长到 6 条,相关前端测试 33/33 通过)
|
- [x] ~~让 AI 模式模型规划等待期间持续追加 thinking 事件,而不是静态显示“思考中”后一次性出现步骤。~~(完成于 17:26,证据:真页干净会话 thinking 数量从 2 条逐步增长到 6 条,相关前端测试 33/33 通过)
|
||||||
- [x] ~~修复 AI 工作台“保存草稿”后的上下文提交短句,让“提交这个单据”复用最近申请草稿而不是重新规划。~~(完成于 17:40,证据:`workbench-ai-application-context-submit.test.mjs` 与 `workbench-ai-application-gate-model.test.mjs` 通过)
|
- [x] ~~修复 AI 工作台“保存草稿”后的上下文提交短句,让“提交这个单据”复用最近申请草稿而不是重新规划。~~(完成于 17:40,证据:`workbench-ai-application-context-submit.test.mjs` 与 `workbench-ai-application-gate-model.test.mjs` 通过)
|
||||||
- [x] ~~修复“完整出差信息但未说申请/报销”时只停在候选流程确认的问题,让唯一的“先发起出差申请”候选流直接进入申请预览计划。~~(完成于 17:48,证据:`workbench-ai-intent-planner-model.test.mjs` 新增回归通过,相邻 4 组前端测试通过)
|
- [x] ~~修复“完整出差信息但未说申请/报销”时只停在候选流程确认的问题,让唯一的“先发起出差申请”候选流直接进入申请预览计划。~~(完成于 17:48,证据:`workbench-ai-intent-planner-model.test.mjs` 新增回归通过,相邻 4 组前端测试通过)
|
||||||
|
- [x] ~~修复 AI 工作台“删除草稿”和“我要审核/待办审批”短句意图漏识别。~~(完成于 22:04,证据:`node --test web/tests/workbench-ai-command-intent-model.test.mjs web/tests/ai-document-query-model.test.mjs` 通过 19/19,前端 build 通过)
|
||||||
|
- [ ] 在真实 5173 AI 工作台分别输入“删除草稿”和“我要审核”,确认删除草稿只打开详情确认入口、审核短句直接返回待我审核单据卡片。(来源:22:04 短句意图修复)
|
||||||
|
- [x] ~~落地 AI 工作台统一意图识别 Markdown 设计文档,并实现第一版 Intent Frame + Action Policy。~~(完成于 22:31,证据:`2026-06-24-workbench-intent-frame-design.md` 已新增,`workbench-intent-frame-model.test.mjs` 4/4 与 `ai-document-query-model.test.mjs` 16/16 通过)
|
||||||
|
- [ ] 在真实 5173 AI 工作台分别输入“删除 3 天前的草稿”和“审核合规没有风险的申请”,确认系统先筛选候选而不是直接执行删除/审核。(来源:22:31 统一 Intent Frame 接线)
|
||||||
|
- [x] ~~修复“删除申请单草稿”不显示删除动作思考的问题。~~(完成于 22:36,证据:`workbench-intent-frame-model.test.mjs` 新增回归通过,相关前端测试 44/44 通过,前端 build 通过)
|
||||||
|
- [ ] 在真实 5173 AI 工作台输入“删除申请单草稿”,确认首条 thinking 明确显示“删除”高风险策略,且结果只展示申请单草稿候选。(来源:22:36 删除动作 thinking 修复)
|
||||||
|
- [x] ~~为高风险删除/审核候选结果补充用户可见确认边界说明和实体快捷按钮文案。~~(完成于 22:42,证据:`workbench-intent-frame-model.test.mjs` 新增结果提示回归通过,相关前端测试 45/45 通过,前端 build 通过)
|
||||||
|
- [x] ~~修复“删除申请单草稿”高风险提示和候选卡片被 trusted HTML 安全渲染整块丢弃的问题。~~(完成于 22:49,证据:新增渲染级红灯测试后修复,相关前端测试 46/46 通过,前端 build 通过)
|
||||||
|
- [ ] 在真实 5173 AI 工作台输入“删除申请单草稿”,确认结果卡片上方显示高风险说明,候选单据按钮显示“进入详情确认删除”。(来源:22:42 高风险结果提示补强)
|
||||||
- [ ] 单独修复或重写 `workbench-ai-mode-switch.test.mjs` 的旧静态结构断言,使它适配 `WorkbenchAiFileStrip` 和 OCR composable 拆分后的真实代码。(来源:17:26 额外测试发现)
|
- [ ] 单独修复或重写 `workbench-ai-mode-switch.test.mjs` 的旧静态结构断言,使它适配 `WorkbenchAiFileStrip` 和 OCR composable 拆分后的真实代码。(来源:17:26 额外测试发现)
|
||||||
- [ ] 单独修复或重写 `workbench-ai-mode-expense-scene-action.test.mjs` 与 `expense-application-fast-preview.test.mjs` 中继续扫描旧 Vue 单文件的静态断言,改为覆盖 composable/model 的行为测试。(来源:17:40 上下文提交验证)
|
- [ ] 单独修复或重写 `workbench-ai-mode-expense-scene-action.test.mjs` 与 `expense-application-fast-preview.test.mjs` 中继续扫描旧 Vue 单文件的静态断言,改为覆盖 composable/model 的行为测试。(来源:17:40 上下文提交验证)
|
||||||
- [ ] 在真实 5173 AI 工作台回放“2026-02-20 至 2026-02-23,去上海出差,辅助国网仿生产服务器部署,交通火车”,确认唯一申请候选流直接生成申请核对表。(来源:17:48 无动作话术直进申请预览修复)
|
- [ ] 在真实 5173 AI 工作台回放“2026-02-20 至 2026-02-23,去上海出差,辅助国网仿生产服务器部署,交通火车”,确认唯一申请候选流直接生成申请核对表。(来源:17:48 无动作话术直进申请预览修复)
|
||||||
|
|||||||
Binary file not shown.
@@ -1450,6 +1450,31 @@
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-document-command-guidance) {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
padding: 13px 15px;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.28);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 251, 235, 0.82);
|
||||||
|
color: #78350f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-document-command-guidance__title) {
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 860;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-document-command-guidance__body) {
|
||||||
|
color: #78350f;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 680;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-document-card-list) {
|
.workbench-ai-answer-markdown :deep(.ai-document-card-list) {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js'
|
|||||||
import {
|
import {
|
||||||
isReimbursementCreationIntent
|
isReimbursementCreationIntent
|
||||||
} from './workbenchAiApplicationGateModel.js'
|
} from './workbenchAiApplicationGateModel.js'
|
||||||
|
import { useWorkbenchAiCommandIntents } from './useWorkbenchAiCommandIntents.js'
|
||||||
import {
|
import {
|
||||||
buildRuleFallbackWorkbenchAiIntentPlan,
|
buildRuleFallbackWorkbenchAiIntentPlan,
|
||||||
normalizeWorkbenchAiIntentPlan,
|
normalizeWorkbenchAiIntentPlan,
|
||||||
@@ -164,6 +165,24 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
scrollInlineConversationToBottom
|
scrollInlineConversationToBottom
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const commandIntents = useWorkbenchAiCommandIntents({
|
||||||
|
activateInlineConversation,
|
||||||
|
activeConversationTitle,
|
||||||
|
assistantDraft,
|
||||||
|
clearAiModeFiles: filesFlow.clearAiModeFiles,
|
||||||
|
closeWorkbenchDatePicker,
|
||||||
|
conversationId,
|
||||||
|
conversationMessages,
|
||||||
|
createInlineMessage,
|
||||||
|
documentQueryFlow,
|
||||||
|
inlineConversationAutoScrollPinned,
|
||||||
|
persistCurrentConversation,
|
||||||
|
removeWorkbenchDateTag,
|
||||||
|
scrollInlineConversationToBottom,
|
||||||
|
searchConversationId: AI_SEARCH_CONVERSATION_ID,
|
||||||
|
sending
|
||||||
|
})
|
||||||
|
|
||||||
const attachmentFlow = useWorkbenchAiAttachmentAssociationFlow({
|
const attachmentFlow = useWorkbenchAiAttachmentAssociationFlow({
|
||||||
aiAttachmentAssociationRuntime,
|
aiAttachmentAssociationRuntime,
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -728,8 +747,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aiExpenseDraft.value && !isAiExpenseDraftComplete(aiExpenseDraft.value)) {
|
if (commandIntents.handleInlineDraftDeletionIntent(cleanPrompt, entry)) {
|
||||||
expenseFlow.advanceAiExpenseDraft(cleanPrompt, files)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,6 +755,11 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (aiExpenseDraft.value && !isAiExpenseDraftComplete(aiExpenseDraft.value)) {
|
||||||
|
expenseFlow.advanceAiExpenseDraft(cleanPrompt, files)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldRequestWorkbenchAiIntentPlan(cleanPrompt)) {
|
if (shouldRequestWorkbenchAiIntentPlan(cleanPrompt)) {
|
||||||
void executeModelPlannedWorkbenchIntent(cleanPrompt, entry, files)
|
void executeModelPlannedWorkbenchIntent(cleanPrompt, entry, files)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
buildWorkbenchDraftDeletionGuidance,
|
||||||
|
isWorkbenchDraftDeletionIntent,
|
||||||
|
resolveLatestWorkbenchDraftPayload
|
||||||
|
} from './workbenchAiCommandIntentModel.js'
|
||||||
|
import { resolveWorkbenchIntentActionRoute } from './workbenchIntentActionPolicy.js'
|
||||||
|
import { resolveWorkbenchIntentFrame } from './workbenchIntentFrameModel.js'
|
||||||
|
|
||||||
|
export function useWorkbenchAiCommandIntents({
|
||||||
|
activateInlineConversation,
|
||||||
|
activeConversationTitle,
|
||||||
|
assistantDraft,
|
||||||
|
clearAiModeFiles,
|
||||||
|
closeWorkbenchDatePicker,
|
||||||
|
conversationId,
|
||||||
|
conversationMessages,
|
||||||
|
createInlineMessage,
|
||||||
|
documentQueryFlow,
|
||||||
|
inlineConversationAutoScrollPinned,
|
||||||
|
persistCurrentConversation,
|
||||||
|
removeWorkbenchDateTag,
|
||||||
|
scrollInlineConversationToBottom,
|
||||||
|
searchConversationId,
|
||||||
|
sending
|
||||||
|
}) {
|
||||||
|
function prepareInlineCommandConversation(cleanPrompt, entry = {}) {
|
||||||
|
if (conversationId.value === searchConversationId) {
|
||||||
|
conversationId.value = ''
|
||||||
|
conversationMessages.value = []
|
||||||
|
activeConversationTitle.value = ''
|
||||||
|
}
|
||||||
|
activateInlineConversation({
|
||||||
|
title: entry.label || cleanPrompt.slice(0, 18) || '新对话'
|
||||||
|
})
|
||||||
|
inlineConversationAutoScrollPinned.value = true
|
||||||
|
conversationMessages.value.push(createInlineMessage('user', cleanPrompt))
|
||||||
|
assistantDraft.value = ''
|
||||||
|
removeWorkbenchDateTag()
|
||||||
|
closeWorkbenchDatePicker()
|
||||||
|
clearAiModeFiles()
|
||||||
|
scrollInlineConversationToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInlineDraftDeletionIntent(cleanPrompt, entry = {}) {
|
||||||
|
const frame = resolveWorkbenchIntentFrame(cleanPrompt)
|
||||||
|
const route = resolveWorkbenchIntentActionRoute(frame)
|
||||||
|
const legacyDraftDelete = isWorkbenchDraftDeletionIntent(cleanPrompt)
|
||||||
|
const commandObjectSupported = !frame || frame.action !== 'delete' || (
|
||||||
|
['draft', 'application', 'reimbursement', 'document'].includes(frame.objectType)
|
||||||
|
)
|
||||||
|
const handlesWorkbenchCommand = (
|
||||||
|
commandObjectSupported && (
|
||||||
|
route.nextStep === 'open_context_confirm' ||
|
||||||
|
route.nextStep === 'query_candidates'
|
||||||
|
) ||
|
||||||
|
legacyDraftDelete
|
||||||
|
)
|
||||||
|
if (!handlesWorkbenchCommand) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
prepareInlineCommandConversation(cleanPrompt, entry)
|
||||||
|
const draftPayload = frame?.targetMode === 'current_context' || legacyDraftDelete
|
||||||
|
? resolveLatestWorkbenchDraftPayload(conversationMessages.value)
|
||||||
|
: null
|
||||||
|
if (route.nextStep === 'open_context_confirm' && draftPayload) {
|
||||||
|
const guidance = buildWorkbenchDraftDeletionGuidance(draftPayload)
|
||||||
|
conversationMessages.value.push(createInlineMessage('assistant', guidance.content, {
|
||||||
|
suggestedActions: guidance.suggestedActions
|
||||||
|
}))
|
||||||
|
persistCurrentConversation()
|
||||||
|
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryPrompt = route.queryPrompt || frame?.normalizedQuery || '我的草稿单据'
|
||||||
|
const pendingText = frame?.safetyLevel === 'confirm_required'
|
||||||
|
? '正在先筛选候选单据,不会直接执行删除或审核动作...'
|
||||||
|
: '正在查询匹配条件的单据...'
|
||||||
|
const pendingMessage = createInlineMessage('assistant', pendingText, {
|
||||||
|
pending: true
|
||||||
|
})
|
||||||
|
conversationMessages.value.push(pendingMessage)
|
||||||
|
persistCurrentConversation()
|
||||||
|
sending.value = true
|
||||||
|
void documentQueryFlow.handleAiDocumentQueryIntent(queryPrompt, pendingMessage, {
|
||||||
|
commandFrame: frame
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
sending.value = false
|
||||||
|
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleInlineDraftDeletionIntent
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { nextTick } from 'vue'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildAiDocumentQueryConditionSummary,
|
buildAiDocumentQueryConditionSummary,
|
||||||
|
buildAiDocumentQueryThinkingEvents,
|
||||||
buildAiDocumentQueryMessage,
|
buildAiDocumentQueryMessage,
|
||||||
filterAiDocumentQueryRecords,
|
filterAiDocumentQueryRecords,
|
||||||
mergeAiDocumentQueryPayloads,
|
mergeAiDocumentQueryPayloads,
|
||||||
@@ -100,33 +101,20 @@ export function useWorkbenchAiDocumentQueryFlow({
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAiDocumentQueryIntent(prompt, pendingMessage) {
|
async function handleAiDocumentQueryIntent(prompt, pendingMessage, options = {}) {
|
||||||
const intent = resolveAiDocumentQueryIntent(prompt)
|
const intent = resolveAiDocumentQueryIntent(prompt)
|
||||||
if (!intent) {
|
if (!intent) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const conditionSummary = buildAiDocumentQueryConditionSummary(intent)
|
const conditionSummary = buildAiDocumentQueryConditionSummary(intent)
|
||||||
let thinkingEvents = [
|
let thinkingEvents = buildAiDocumentQueryThinkingEvents(intent, {
|
||||||
{
|
commandFrame: options.commandFrame
|
||||||
eventId: 'document-query-parse',
|
}).map((event) => (
|
||||||
title: '解析自然语言筛选条件',
|
event.eventId === 'document-query-fetch'
|
||||||
content: `正在从您的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}。`,
|
? { ...event, content: resolveAiDocumentQueryFetchPendingText(intent) }
|
||||||
status: 'running'
|
: event
|
||||||
},
|
))
|
||||||
{
|
|
||||||
eventId: 'document-query-fetch',
|
|
||||||
title: '查询业务单据接口',
|
|
||||||
content: resolveAiDocumentQueryFetchPendingText(intent),
|
|
||||||
status: 'pending'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
eventId: 'document-query-filter',
|
|
||||||
title: '组合筛选单据',
|
|
||||||
content: '等待接口返回后,再按已识别条件做二次筛选。',
|
|
||||||
status: 'pending'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
||||||
await waitForAiDocumentQueryStep()
|
await waitForAiDocumentQueryStep()
|
||||||
|
|
||||||
@@ -163,7 +151,9 @@ export function useWorkbenchAiDocumentQueryFlow({
|
|||||||
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
||||||
await waitForAiDocumentQueryStep()
|
await waitForAiDocumentQueryStep()
|
||||||
|
|
||||||
const finalMessageText = buildAiDocumentQueryMessage(intent, payload)
|
const finalMessageText = buildAiDocumentQueryMessage(intent, payload, {
|
||||||
|
commandFrame: options.commandFrame
|
||||||
|
})
|
||||||
thinkingEvents = completeAiDocumentQueryEvent(
|
thinkingEvents = completeAiDocumentQueryEvent(
|
||||||
thinkingEvents,
|
thinkingEvents,
|
||||||
'document-query-filter',
|
'document-query-filter',
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
const DRAFT_DELETION_ACTION_PATTERN = /删除|删掉|删了|移除|作废|撤销/
|
||||||
|
const DRAFT_DELETION_TARGET_PATTERN = (
|
||||||
|
/草稿|这个单据|这张单据|当前单据|当前申请|当前报销|刚才保存的草稿|刚才的草稿|上面的单据|最近的单据|申请单|报销单/
|
||||||
|
)
|
||||||
|
const NON_DRAFT_DELETE_TARGET_PATTERN = /附件|票据|发票|图片|文件|明细|费用行/
|
||||||
|
const DELETABLE_DRAFT_STATUS = new Set(['', 'draft', 'pending', '待提交', '草稿'])
|
||||||
|
const SUBMITTED_OR_FINAL_STATUS = new Set([
|
||||||
|
'submitted',
|
||||||
|
'approved',
|
||||||
|
'completed',
|
||||||
|
'paid',
|
||||||
|
'archived',
|
||||||
|
'deleted',
|
||||||
|
'rejected',
|
||||||
|
'returned',
|
||||||
|
'审批中',
|
||||||
|
'已审批',
|
||||||
|
'已完成',
|
||||||
|
'已付款',
|
||||||
|
'已归档',
|
||||||
|
'已删除',
|
||||||
|
'已驳回',
|
||||||
|
'已退回'
|
||||||
|
])
|
||||||
|
|
||||||
|
function normalizeCompactText(value = '') {
|
||||||
|
return String(value || '').replace(/\s+/g, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDraftDocumentType(payload = {}, claimNo = '') {
|
||||||
|
const rawType = String(payload.document_type || payload.documentType || payload.draft_type || payload.draftType || '').trim()
|
||||||
|
if (/application|expense_application|申请/.test(rawType)) {
|
||||||
|
return 'application'
|
||||||
|
}
|
||||||
|
if (/reimbursement|expense|报销/.test(rawType)) {
|
||||||
|
return 'reimbursement'
|
||||||
|
}
|
||||||
|
return /^A/i.test(String(claimNo || '').trim()) ? 'application' : 'reimbursement'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDraftPayload(payload = null, sourceText = '') {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const claimId = String(payload.claim_id || payload.claimId || payload.id || '').trim()
|
||||||
|
const claimNo = String(payload.claim_no || payload.claimNo || payload.document_no || payload.documentNo || '').trim()
|
||||||
|
if (!claimId && !claimNo) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const status = String(payload.status || payload.status_label || payload.statusLabel || '').trim()
|
||||||
|
if (SUBMITTED_OR_FINAL_STATUS.has(status.toLowerCase()) || SUBMITTED_OR_FINAL_STATUS.has(status)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!DELETABLE_DRAFT_STATUS.has(status.toLowerCase()) && !DELETABLE_DRAFT_STATUS.has(status) && !/草稿/.test(sourceText)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
claimId,
|
||||||
|
claimNo,
|
||||||
|
status: status || 'draft',
|
||||||
|
documentType: normalizeDraftDocumentType(payload, claimNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDraftPayloadFromSuggestedActions(message = {}) {
|
||||||
|
const actions = Array.isArray(message?.suggestedActions) ? message.suggestedActions : []
|
||||||
|
for (const action of [...actions].reverse()) {
|
||||||
|
const actionType = String(action?.action_type || action?.actionType || '').trim()
|
||||||
|
if (actionType !== 'open_application_detail') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const payload = normalizeDraftPayload(action.payload, String(message.content || message.text || ''))
|
||||||
|
if (payload) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkbenchDraftDeletionIntent(prompt = '') {
|
||||||
|
const compact = normalizeCompactText(prompt)
|
||||||
|
if (!compact || !DRAFT_DELETION_ACTION_PATTERN.test(compact)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (NON_DRAFT_DELETE_TARGET_PATTERN.test(compact) && !/草稿|单据|申请单|报销单/.test(compact)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return DRAFT_DELETION_TARGET_PATTERN.test(compact)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLatestWorkbenchDraftPayload(messages = []) {
|
||||||
|
const safeMessages = Array.isArray(messages) ? messages : []
|
||||||
|
for (const message of [...safeMessages].reverse()) {
|
||||||
|
const sourceText = String(message?.content || message?.text || '')
|
||||||
|
const actionPayload = extractDraftPayloadFromSuggestedActions(message)
|
||||||
|
if (actionPayload) {
|
||||||
|
return actionPayload
|
||||||
|
}
|
||||||
|
const draftPayload = normalizeDraftPayload(message?.draftPayload, sourceText)
|
||||||
|
if (draftPayload) {
|
||||||
|
return draftPayload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWorkbenchDraftDeletionGuidance(draftPayload = {}) {
|
||||||
|
const claimNo = String(draftPayload.claimNo || draftPayload.claim_no || '').trim()
|
||||||
|
const claimId = String(draftPayload.claimId || draftPayload.claim_id || '').trim()
|
||||||
|
const documentType = String(draftPayload.documentType || draftPayload.document_type || 'reimbursement').trim()
|
||||||
|
const reference = claimNo || claimId || '最近这张草稿'
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
'### 已识别到您想删除草稿',
|
||||||
|
`我找到了最近这张草稿:**${reference}**。`,
|
||||||
|
'删除草稿会影响单据和附件关联,我不会直接替您删除。请先打开详情页,在详情页点击 **删除草稿** 并完成二次确认。'
|
||||||
|
].join('\n\n'),
|
||||||
|
suggestedActions: [{
|
||||||
|
label: claimNo ? `查看草稿 ${claimNo}` : '查看草稿详情',
|
||||||
|
description: '打开详情页后可点击删除草稿并二次确认。',
|
||||||
|
icon: 'mdi mdi-open-in-new',
|
||||||
|
action_type: 'open_application_detail',
|
||||||
|
payload: {
|
||||||
|
claim_id: claimId,
|
||||||
|
claim_no: claimNo,
|
||||||
|
document_type: documentType
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const QUERY_CANDIDATE_ACTIONS = new Set(['delete', 'approve', 'reject', 'query'])
|
||||||
|
|
||||||
|
export function resolveWorkbenchIntentActionRoute(frame = null) {
|
||||||
|
if (!frame) {
|
||||||
|
return { nextStep: 'pass_through' }
|
||||||
|
}
|
||||||
|
if (frame.safetyLevel === 'blocked') {
|
||||||
|
return { nextStep: 'blocked', reason: '高风险批量动作需要先选择具体单据。' }
|
||||||
|
}
|
||||||
|
if (frame.action === 'ask_policy') {
|
||||||
|
return { nextStep: 'pass_through' }
|
||||||
|
}
|
||||||
|
if (frame.targetMode === 'current_context' && frame.safetyLevel === 'confirm_required') {
|
||||||
|
return { nextStep: 'open_context_confirm' }
|
||||||
|
}
|
||||||
|
if (frame.targetMode === 'filtered_candidates' && QUERY_CANDIDATE_ACTIONS.has(frame.action)) {
|
||||||
|
return {
|
||||||
|
nextStep: 'query_candidates',
|
||||||
|
queryPrompt: frame.normalizedQuery || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { nextStep: 'pass_through' }
|
||||||
|
}
|
||||||
211
web/src/composables/workbenchAiMode/workbenchIntentFrameModel.js
Normal file
211
web/src/composables/workbenchAiMode/workbenchIntentFrameModel.js
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { formatDate, parseDate } from '../../utils/aiDocumentQueryText.js'
|
||||||
|
|
||||||
|
const CONFIRM_REQUIRED_ACTIONS = new Set(['delete', 'approve', 'reject'])
|
||||||
|
|
||||||
|
function compactText(value = '') {
|
||||||
|
return String(value || '').replace(/\s+/g, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveToday(options = {}) {
|
||||||
|
return parseDate(options.today) || new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftDay(today, offset) {
|
||||||
|
const date = new Date(today.getTime())
|
||||||
|
date.setUTCDate(date.getUTCDate() + offset)
|
||||||
|
return formatDate(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAction(text = '') {
|
||||||
|
if (/(标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗)/.test(text)) {
|
||||||
|
return 'ask_policy'
|
||||||
|
}
|
||||||
|
if (/删除|删掉|删了|移除|作废|撤销/.test(text)) {
|
||||||
|
return 'delete'
|
||||||
|
}
|
||||||
|
if (/驳回|退回|拒绝/.test(text)) {
|
||||||
|
return 'reject'
|
||||||
|
}
|
||||||
|
if (/审核|审批|通过|处理待办|去审批|去审核/.test(text)) {
|
||||||
|
return 'approve'
|
||||||
|
}
|
||||||
|
if (/新建|发起|创建|我要报销|申请/.test(text)) {
|
||||||
|
return 'create'
|
||||||
|
}
|
||||||
|
if (/补充|修改|改成|填入/.test(text)) {
|
||||||
|
return 'update'
|
||||||
|
}
|
||||||
|
if (/查|看|列出|有哪些|找一下/.test(text)) {
|
||||||
|
return 'query'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveObjectType(text = '') {
|
||||||
|
if (/草稿|未提交/.test(text)) {
|
||||||
|
return 'draft'
|
||||||
|
}
|
||||||
|
if (/待办|待审|待审核|待审批|审核单|审批单/.test(text)) {
|
||||||
|
return 'approval_task'
|
||||||
|
}
|
||||||
|
if (/申请单|申请/.test(text)) {
|
||||||
|
return 'application'
|
||||||
|
}
|
||||||
|
if (/报销单|报销/.test(text)) {
|
||||||
|
return 'reimbursement'
|
||||||
|
}
|
||||||
|
if (/票据|发票|附件|图片|文件/.test(text)) {
|
||||||
|
return 'receipt'
|
||||||
|
}
|
||||||
|
if (/单据|单子/.test(text)) {
|
||||||
|
return 'document'
|
||||||
|
}
|
||||||
|
return 'document'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTimeFilter(text = '', options = {}) {
|
||||||
|
const today = resolveToday(options)
|
||||||
|
const daysAgo = text.match(/(?<days>\d{1,3})天前/)
|
||||||
|
if (daysAgo?.groups?.days) {
|
||||||
|
const days = Math.max(0, Number(daysAgo.groups.days))
|
||||||
|
const value = shiftDay(today, -days)
|
||||||
|
return { start: value, end: value, label: `${days}天前` }
|
||||||
|
}
|
||||||
|
if (/昨天/.test(text)) {
|
||||||
|
const value = shiftDay(today, -1)
|
||||||
|
return { start: value, end: value, label: '昨天' }
|
||||||
|
}
|
||||||
|
if (/今天|今日/.test(text)) {
|
||||||
|
const value = shiftDay(today, 0)
|
||||||
|
return { start: value, end: value, label: '今天' }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRiskFilter(text = '') {
|
||||||
|
if (/无风险|没有风险|暂无风险|无异常|合规/.test(text)) {
|
||||||
|
return { level: 'none', label: '无风险' }
|
||||||
|
}
|
||||||
|
if (/高风险/.test(text)) {
|
||||||
|
return { level: 'high', label: '高风险' }
|
||||||
|
}
|
||||||
|
if (/中风险/.test(text)) {
|
||||||
|
return { level: 'medium', label: '中风险' }
|
||||||
|
}
|
||||||
|
if (/低风险/.test(text)) {
|
||||||
|
return { level: 'low', label: '低风险' }
|
||||||
|
}
|
||||||
|
if (/风险|异常|超标/.test(text)) {
|
||||||
|
return { level: 'has', label: '有风险' }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStatusFilter(text = '', objectType = '') {
|
||||||
|
if (objectType === 'draft' || /草稿|未提交/.test(text)) {
|
||||||
|
return { keys: ['draft'], label: '草稿' }
|
||||||
|
}
|
||||||
|
if (/待审|待审核|待审批|审批中|审核中/.test(text)) {
|
||||||
|
return { keys: ['submitted', 'pending'], label: '审批中' }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDocumentType(text = '', objectType = '') {
|
||||||
|
if (objectType === 'application' || /申请单|申请/.test(text)) {
|
||||||
|
return 'application'
|
||||||
|
}
|
||||||
|
if (objectType === 'reimbursement' || /报销单|报销/.test(text)) {
|
||||||
|
return 'reimbursement'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasContextReference(text = '') {
|
||||||
|
return /刚才|当前|这个|这张|上面|最近/.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExplicitFilter(filters = {}) {
|
||||||
|
return Boolean(filters.timeRange || filters.risk || filters.amount || filters.keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTargetMode(action = '', text = '', filters = {}) {
|
||||||
|
if (!CONFIRM_REQUIRED_ACTIONS.has(action)) {
|
||||||
|
return 'filtered_candidates'
|
||||||
|
}
|
||||||
|
if (hasContextReference(text) && !hasExplicitFilter(filters)) {
|
||||||
|
return 'current_context'
|
||||||
|
}
|
||||||
|
return 'filtered_candidates'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSafetyLevel(action = '') {
|
||||||
|
if (CONFIRM_REQUIRED_ACTIONS.has(action)) {
|
||||||
|
return 'confirm_required'
|
||||||
|
}
|
||||||
|
return 'read_only'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNormalizedQuery({ action, objectType, filters }) {
|
||||||
|
if ((objectType === 'draft' || action === 'delete') && !filters.timeRange?.label && !filters.risk?.label && !filters.documentType) {
|
||||||
|
return '我的草稿单据'
|
||||||
|
}
|
||||||
|
const parts = []
|
||||||
|
if (action === 'approve' || objectType === 'approval_task') {
|
||||||
|
parts.push('待我审核')
|
||||||
|
} else if (objectType === 'draft' || action === 'delete') {
|
||||||
|
parts.push('我的')
|
||||||
|
}
|
||||||
|
if (filters.timeRange?.label) {
|
||||||
|
parts.push(filters.timeRange.label)
|
||||||
|
}
|
||||||
|
if (filters.risk?.label) {
|
||||||
|
parts.push(filters.risk.label)
|
||||||
|
}
|
||||||
|
if (filters.status?.label && (filters.documentType || objectType !== 'draft')) {
|
||||||
|
parts.push(filters.status.label)
|
||||||
|
}
|
||||||
|
if (filters.documentType === 'application') {
|
||||||
|
parts.push('申请单')
|
||||||
|
} else if (filters.documentType === 'reimbursement') {
|
||||||
|
parts.push('报销单')
|
||||||
|
} else if (objectType === 'draft') {
|
||||||
|
parts.push('草稿单据')
|
||||||
|
} else {
|
||||||
|
parts.push('单据')
|
||||||
|
}
|
||||||
|
return parts.join(' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWorkbenchIntentFrame(prompt = '', options = {}) {
|
||||||
|
const text = compactText(prompt)
|
||||||
|
if (!text) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const action = resolveAction(text)
|
||||||
|
if (!action) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const objectType = resolveObjectType(text)
|
||||||
|
const filters = {
|
||||||
|
timeRange: resolveTimeFilter(text, options),
|
||||||
|
status: resolveStatusFilter(text, objectType),
|
||||||
|
risk: resolveRiskFilter(text),
|
||||||
|
documentType: resolveDocumentType(text, objectType),
|
||||||
|
amount: null,
|
||||||
|
keyword: null
|
||||||
|
}
|
||||||
|
const safetyLevel = resolveSafetyLevel(action)
|
||||||
|
const targetMode = action === 'ask_policy'
|
||||||
|
? 'ambiguous'
|
||||||
|
: resolveTargetMode(action, text, filters)
|
||||||
|
return {
|
||||||
|
action,
|
||||||
|
objectType,
|
||||||
|
filters,
|
||||||
|
targetMode,
|
||||||
|
safetyLevel,
|
||||||
|
confidence: 0.86,
|
||||||
|
normalizedQuery: action === 'ask_policy' ? String(prompt || '').trim() : buildNormalizedQuery({ action, objectType, filters })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
|
|
||||||
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
|
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
|
||||||
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
|
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
|
||||||
|
const DELETED_DOCUMENT_DETAIL_HREF_PREFIX = '#ai-deleted-document-detail:'
|
||||||
|
|
||||||
function escapeHtml(value = '') {
|
function escapeHtml(value = '') {
|
||||||
return String(value)
|
return String(value)
|
||||||
@@ -39,6 +40,10 @@ function isDocumentDetailHref(href = '') {
|
|||||||
return String(href || '').trim().startsWith(DOCUMENT_DETAIL_HREF_PREFIX)
|
return String(href || '').trim().startsWith(DOCUMENT_DETAIL_HREF_PREFIX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDeletedDocumentDetailHref(href = '') {
|
||||||
|
return String(href || '').trim().startsWith(DELETED_DOCUMENT_DETAIL_HREF_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeImageSrc(src = '') {
|
function sanitizeImageSrc(src = '') {
|
||||||
const value = String(src || '').trim()
|
const value = String(src || '').trim()
|
||||||
if (/^(https?:\/\/|blob:|\/)/i.test(value)) {
|
if (/^(https?:\/\/|blob:|\/)/i.test(value)) {
|
||||||
@@ -63,6 +68,17 @@ function renderLinkHtml(label = '', href = '') {
|
|||||||
'</span>'
|
'</span>'
|
||||||
].join('')
|
].join('')
|
||||||
}
|
}
|
||||||
|
if (isDeletedDocumentDetailHref(href)) {
|
||||||
|
return [
|
||||||
|
'<span',
|
||||||
|
' class="ai-html-action-link ai-html-action-link-document is-disabled"',
|
||||||
|
' data-ai-action="deleted-document-detail"',
|
||||||
|
' aria-disabled="true"',
|
||||||
|
'>',
|
||||||
|
label,
|
||||||
|
'</span>'
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
if (isApplicationDetailHref(href)) {
|
if (isApplicationDetailHref(href)) {
|
||||||
return [
|
return [
|
||||||
`<a href="${sanitizedHref}"`,
|
`<a href="${sanitizedHref}"`,
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ const EXPENSE_TYPE_FILTERS = [
|
|||||||
{ label: '软件服务费', codes: ['software'], pattern: /软件|服务费|订阅/ }
|
{ label: '软件服务费', codes: ['software'], pattern: /软件|服务费|订阅/ }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const RISK_FILTERS = [
|
||||||
|
{ label: '无风险', level: 'none', pattern: /无风险|没有风险|暂无风险|无异常|合规/ },
|
||||||
|
{ label: '高风险', level: 'high', pattern: /高风险/ },
|
||||||
|
{ label: '中风险', level: 'medium', pattern: /中风险/ },
|
||||||
|
{ label: '低风险', level: 'low', pattern: /低风险/ },
|
||||||
|
{ label: '有风险', level: 'has', pattern: /风险|异常|超标/ }
|
||||||
|
]
|
||||||
|
|
||||||
function resolveToday(options = {}) {
|
function resolveToday(options = {}) {
|
||||||
return parseDate(options.today) || new Date()
|
return parseDate(options.today) || new Date()
|
||||||
}
|
}
|
||||||
@@ -82,6 +90,15 @@ function resolveTimeRange(prompt, options = {}) {
|
|||||||
return { start: value, end: value, label: '昨天' }
|
return { start: value, end: value, label: '昨天' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const daysAgo = text.match(/(?<days>\d{1,3})天前/)
|
||||||
|
if (daysAgo?.groups?.days) {
|
||||||
|
const days = Math.max(0, Number(daysAgo.groups.days))
|
||||||
|
const date = new Date(today.getTime())
|
||||||
|
date.setUTCDate(date.getUTCDate() - days)
|
||||||
|
const value = formatDate(date)
|
||||||
|
return { start: value, end: value, label: `${days}天前` }
|
||||||
|
}
|
||||||
|
|
||||||
if (/本月|这个月|当月/.test(text)) {
|
if (/本月|这个月|当月/.test(text)) {
|
||||||
return buildMonthRange(today.getUTCFullYear(), today.getUTCMonth() + 1)
|
return buildMonthRange(today.getUTCFullYear(), today.getUTCMonth() + 1)
|
||||||
}
|
}
|
||||||
@@ -129,6 +146,11 @@ function resolveExpenseTypeFilter(prompt) {
|
|||||||
return EXPENSE_TYPE_FILTERS.find((item) => item.pattern.test(text)) || null
|
return EXPENSE_TYPE_FILTERS.find((item) => item.pattern.test(text)) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRiskFilter(prompt) {
|
||||||
|
const text = compactText(prompt)
|
||||||
|
return RISK_FILTERS.find((item) => item.pattern.test(text)) || null
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAmountText(value = '') {
|
function normalizeAmountText(value = '') {
|
||||||
const matched = compactText(value).replace(/,/g, '').match(/-?\d+(?:\.\d+)?/)
|
const matched = compactText(value).replace(/,/g, '').match(/-?\d+(?:\.\d+)?/)
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
@@ -191,13 +213,13 @@ function resolveKeywordFilter(prompt) {
|
|||||||
|
|
||||||
function resolveSource(prompt) {
|
function resolveSource(prompt) {
|
||||||
const text = compactText(prompt)
|
const text = compactText(prompt)
|
||||||
if (/审核单|审批单|待审|待审核|待审批|我审批|我审核/.test(text)) {
|
if (/审核单|审批单|待办|待审|待审核|待审批|我审批|我审核|我要审批|我要审核|去审批|去审核|处理审批|处理审核/.test(text)) {
|
||||||
return {
|
return {
|
||||||
source: 'approval',
|
source: 'approval',
|
||||||
sourceLabel: '待我审核的单据'
|
sourceLabel: '待我审核的单据'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (/我名下|我发起|我提交|我创建|我的申请|我的报销/.test(text)) {
|
if (/我名下|我发起|我提交|我创建|我的/.test(text)) {
|
||||||
return {
|
return {
|
||||||
source: 'mine',
|
source: 'mine',
|
||||||
sourceLabel: '我的单据'
|
sourceLabel: '我的单据'
|
||||||
@@ -211,7 +233,13 @@ function resolveSource(prompt) {
|
|||||||
|
|
||||||
export function resolveAiDocumentQueryIntent(prompt, options = {}) {
|
export function resolveAiDocumentQueryIntent(prompt, options = {}) {
|
||||||
const text = compactText(prompt)
|
const text = compactText(prompt)
|
||||||
if (!text || !/(单据|单子|申请单|报销单|审核单|审批单|待审|待审批|待审核)/.test(text)) {
|
if (!text || !/(单据|单子|申请单|报销单|审核单|审批单|待办|待审|待审批|待审核|我要审核|我要审批|去审核|去审批|处理审核|处理审批|草稿)/.test(text)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/(标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗)/.test(text) &&
|
||||||
|
!/(单据|单子|申请单|报销单|审核单|审批单|待办|待审|待审批|待审核)/.test(text)
|
||||||
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
if (/(发起|创建|新增|填写|生成|申请出差|我要报销|提交).*(单据|申请单|报销单)?/.test(text)) {
|
if (/(发起|创建|新增|填写|生成|申请出差|我要报销|提交).*(单据|申请单|报销单)?/.test(text)) {
|
||||||
@@ -223,6 +251,7 @@ export function resolveAiDocumentQueryIntent(prompt, options = {}) {
|
|||||||
const expenseTypeFilter = resolveExpenseTypeFilter(text)
|
const expenseTypeFilter = resolveExpenseTypeFilter(text)
|
||||||
const keywordFilter = resolveKeywordFilter(prompt)
|
const keywordFilter = resolveKeywordFilter(prompt)
|
||||||
const amountFilter = resolveAmountFilter(text)
|
const amountFilter = resolveAmountFilter(text)
|
||||||
|
const riskFilter = resolveRiskFilter(text)
|
||||||
return {
|
return {
|
||||||
...source,
|
...source,
|
||||||
documentType,
|
documentType,
|
||||||
@@ -235,6 +264,7 @@ export function resolveAiDocumentQueryIntent(prompt, options = {}) {
|
|||||||
statusFilter,
|
statusFilter,
|
||||||
expenseTypeFilter,
|
expenseTypeFilter,
|
||||||
keywordFilter,
|
keywordFilter,
|
||||||
amountFilter
|
amountFilter,
|
||||||
|
riskFilter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { extractExpenseClaimItems } from '../services/reimbursements.js'
|
|||||||
import { buildAiDocumentDetailHref } from './aiDocumentDetailReference.js'
|
import { buildAiDocumentDetailHref } from './aiDocumentDetailReference.js'
|
||||||
import { isApplicationDocumentNo } from './documentClassification.js'
|
import { isApplicationDocumentNo } from './documentClassification.js'
|
||||||
import { compactText, normalizeDateText, normalizeText, parseDate } from './aiDocumentQueryText.js'
|
import { compactText, normalizeDateText, normalizeText, parseDate } from './aiDocumentQueryText.js'
|
||||||
|
import { filterActionableRiskFlags, isRiskSummaryWithRisk, normalizeRiskFlagTone } from './riskFlags.js'
|
||||||
|
|
||||||
export { resolveAiDocumentQueryIntent } from './aiDocumentQueryIntent.js'
|
export { resolveAiDocumentQueryIntent } from './aiDocumentQueryIntent.js'
|
||||||
|
|
||||||
@@ -36,6 +37,26 @@ const TYPE_LABELS = {
|
|||||||
other: '其他费用'
|
other: '其他费用'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMMAND_ACTION_LABELS = {
|
||||||
|
delete: '删除',
|
||||||
|
approve: '审核通过',
|
||||||
|
reject: '驳回/退回'
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMAND_ACTION_VERBS = {
|
||||||
|
delete: '删除',
|
||||||
|
approve: '审核',
|
||||||
|
reject: '驳回或退回'
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMAND_OBJECT_LABELS = {
|
||||||
|
draft: '草稿',
|
||||||
|
application: '申请单',
|
||||||
|
reimbursement: '报销单',
|
||||||
|
approval_task: '待审任务',
|
||||||
|
document: '单据'
|
||||||
|
}
|
||||||
|
|
||||||
const MONEY_FORMATTER = new Intl.NumberFormat('zh-CN', {
|
const MONEY_FORMATTER = new Intl.NumberFormat('zh-CN', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'CNY',
|
currency: 'CNY',
|
||||||
@@ -155,6 +176,54 @@ function resolveRecordQuerySource(claim = {}, intent = {}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRiskFlags(claim = {}) {
|
||||||
|
if (Array.isArray(claim.risk_flags_json)) {
|
||||||
|
return claim.risk_flags_json
|
||||||
|
}
|
||||||
|
if (Array.isArray(claim.riskFlags)) {
|
||||||
|
return claim.riskFlags
|
||||||
|
}
|
||||||
|
if (Array.isArray(claim.review_flags)) {
|
||||||
|
return claim.review_flags
|
||||||
|
}
|
||||||
|
if (Array.isArray(claim.reviewFlags)) {
|
||||||
|
return claim.reviewFlags
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRiskSummary(claim = {}) {
|
||||||
|
return normalizeText(
|
||||||
|
claim.risk_summary ||
|
||||||
|
claim.riskSummary ||
|
||||||
|
claim.risk ||
|
||||||
|
claim.risk_label ||
|
||||||
|
claim.riskLabel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRecordRiskMeta(claim = {}) {
|
||||||
|
const flags = filterActionableRiskFlags(resolveRiskFlags(claim))
|
||||||
|
const summary = resolveRiskSummary(claim)
|
||||||
|
if (!flags.length && !isRiskSummaryWithRisk(summary)) {
|
||||||
|
return { hasRisk: false, riskTone: 'none', riskLabel: '无风险' }
|
||||||
|
}
|
||||||
|
const tones = flags.map((flag) => normalizeRiskFlagTone(flag))
|
||||||
|
const riskTone = tones.includes('high')
|
||||||
|
? 'high'
|
||||||
|
: tones.includes('medium')
|
||||||
|
? 'medium'
|
||||||
|
: tones.includes('low')
|
||||||
|
? 'low'
|
||||||
|
: 'medium'
|
||||||
|
const riskLabel = riskTone === 'high'
|
||||||
|
? '高风险'
|
||||||
|
: riskTone === 'medium'
|
||||||
|
? '中风险'
|
||||||
|
: '低风险'
|
||||||
|
return { hasRisk: true, riskTone, riskLabel }
|
||||||
|
}
|
||||||
|
|
||||||
function isApprovalTaskClaim(claim = {}, intent = {}) {
|
function isApprovalTaskClaim(claim = {}, intent = {}) {
|
||||||
return resolveRecordQuerySource(claim, intent) === 'approval'
|
return resolveRecordQuerySource(claim, intent) === 'approval'
|
||||||
}
|
}
|
||||||
@@ -381,6 +450,7 @@ function normalizeRecord(claim = {}, intent = {}) {
|
|||||||
const rawStatusLabel = resolveStatusLabel(claim)
|
const rawStatusLabel = resolveStatusLabel(claim)
|
||||||
const querySource = resolveRecordQuerySource(claim, intent)
|
const querySource = resolveRecordQuerySource(claim, intent)
|
||||||
const isApprovalTask = isApprovalTaskClaim(claim, intent)
|
const isApprovalTask = isApprovalTaskClaim(claim, intent)
|
||||||
|
const riskMeta = resolveRecordRiskMeta(claim)
|
||||||
const statusLabel = isApprovalTask && isPendingApprovalStatus(statusKey, rawStatusLabel)
|
const statusLabel = isApprovalTask && isPendingApprovalStatus(statusKey, rawStatusLabel)
|
||||||
? '待审批'
|
? '待审批'
|
||||||
: rawStatusLabel
|
: rawStatusLabel
|
||||||
@@ -402,6 +472,9 @@ function normalizeRecord(claim = {}, intent = {}) {
|
|||||||
statusKey,
|
statusKey,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
statusTone: resolveStatusTone(statusLabel),
|
statusTone: resolveStatusTone(statusLabel),
|
||||||
|
hasRisk: riskMeta.hasRisk,
|
||||||
|
riskTone: riskMeta.riskTone,
|
||||||
|
riskLabel: riskMeta.riskLabel,
|
||||||
querySource,
|
querySource,
|
||||||
isApprovalTask,
|
isApprovalTask,
|
||||||
reason,
|
reason,
|
||||||
@@ -417,7 +490,8 @@ function normalizeRecord(claim = {}, intent = {}) {
|
|||||||
departmentLabel,
|
departmentLabel,
|
||||||
locationLabel,
|
locationLabel,
|
||||||
typeLabel,
|
typeLabel,
|
||||||
statusLabel
|
statusLabel,
|
||||||
|
riskMeta.riskLabel
|
||||||
].join(' '))
|
].join(' '))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -459,6 +533,19 @@ function matchesAmountFilter(record = {}, amountFilter = null) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchesRiskFilter(record = {}, riskFilter = null) {
|
||||||
|
if (!riskFilter?.level) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (riskFilter.level === 'none') {
|
||||||
|
return !record.hasRisk
|
||||||
|
}
|
||||||
|
if (riskFilter.level === 'has') {
|
||||||
|
return record.hasRisk
|
||||||
|
}
|
||||||
|
return record.hasRisk && record.riskTone === riskFilter.level
|
||||||
|
}
|
||||||
|
|
||||||
export function filterAiDocumentQueryRecords(claimsPayload, intent = {}) {
|
export function filterAiDocumentQueryRecords(claimsPayload, intent = {}) {
|
||||||
const rows = extractExpenseClaimItems(claimsPayload)
|
const rows = extractExpenseClaimItems(claimsPayload)
|
||||||
.map((claim) => normalizeRecord(claim, intent))
|
.map((claim) => normalizeRecord(claim, intent))
|
||||||
@@ -472,6 +559,7 @@ export function filterAiDocumentQueryRecords(claimsPayload, intent = {}) {
|
|||||||
.filter((record) => matchesExpenseTypeFilter(record, intent?.expenseTypeFilter))
|
.filter((record) => matchesExpenseTypeFilter(record, intent?.expenseTypeFilter))
|
||||||
.filter((record) => matchesKeywordFilter(record, intent?.keywordFilter))
|
.filter((record) => matchesKeywordFilter(record, intent?.keywordFilter))
|
||||||
.filter((record) => matchesAmountFilter(record, intent?.amountFilter))
|
.filter((record) => matchesAmountFilter(record, intent?.amountFilter))
|
||||||
|
.filter((record) => matchesRiskFilter(record, intent?.riskFilter))
|
||||||
.sort((left, right) => toTimestamp(right.dateKey) - toTimestamp(left.dateKey))
|
.sort((left, right) => toTimestamp(right.dateKey) - toTimestamp(left.dateKey))
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
@@ -492,7 +580,40 @@ function buildDocumentCardFieldHtml(label = '', value = '', options = {}) {
|
|||||||
].join('')
|
].join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDocumentCardHtml(record = {}) {
|
function resolveCommandDetailActionLabel(commandFrame = null) {
|
||||||
|
if (!commandFrame || commandFrame.safetyLevel !== 'confirm_required') {
|
||||||
|
return '查看详情'
|
||||||
|
}
|
||||||
|
if (commandFrame.action === 'delete') {
|
||||||
|
return '进入详情确认删除'
|
||||||
|
}
|
||||||
|
if (commandFrame.action === 'approve') {
|
||||||
|
return '进入详情确认审核'
|
||||||
|
}
|
||||||
|
if (commandFrame.action === 'reject') {
|
||||||
|
return '进入详情确认驳回'
|
||||||
|
}
|
||||||
|
return '进入详情确认'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCommandConfirmationGuidanceHtml(commandFrame = null) {
|
||||||
|
if (!commandFrame || commandFrame.safetyLevel !== 'confirm_required') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const actionVerb = COMMAND_ACTION_VERBS[commandFrame.action] || '操作'
|
||||||
|
const ctaLabel = resolveCommandDetailActionLabel(commandFrame)
|
||||||
|
return [
|
||||||
|
'<section class="ai-document-command-guidance" aria-label="高风险操作提示">',
|
||||||
|
'<strong class="ai-document-command-guidance__title">需要先确认目标单据</strong>',
|
||||||
|
'<div class="ai-document-command-guidance__body">',
|
||||||
|
`系统不会直接${escapeHtml(actionVerb)}相关单据。`,
|
||||||
|
`如果您希望继续${escapeHtml(actionVerb)}单据,请点击下方候选单据里的快捷按钮“${escapeHtml(ctaLabel)}”,进入单据详情核对后再操作。`,
|
||||||
|
'</div>',
|
||||||
|
'</section>'
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDocumentCardHtml(record = {}, options = {}) {
|
||||||
const href = buildAiDocumentDetailHref(record)
|
const href = buildAiDocumentDetailHref(record)
|
||||||
const typeClass = record.documentType === 'application' ? 'application' : 'reimbursement'
|
const typeClass = record.documentType === 'application' ? 'application' : 'reimbursement'
|
||||||
const approvalTaskClass = record.isApprovalTask ? ' ai-document-card--approval-task' : ''
|
const approvalTaskClass = record.isApprovalTask ? ' ai-document-card--approval-task' : ''
|
||||||
@@ -503,12 +624,14 @@ function buildDocumentCardHtml(record = {}) {
|
|||||||
const ownerText = [record.ownerLabel, record.departmentLabel]
|
const ownerText = [record.ownerLabel, record.departmentLabel]
|
||||||
.filter((item) => item && item !== '未显示')
|
.filter((item) => item && item !== '未显示')
|
||||||
.join(' · ') || '未显示'
|
.join(' · ') || '未显示'
|
||||||
|
const detailActionLabel = resolveCommandDetailActionLabel(options.commandFrame)
|
||||||
const summaryHtml = [
|
const summaryHtml = [
|
||||||
buildDocumentCardFieldHtml('日期', record.time || '待补充'),
|
buildDocumentCardFieldHtml('日期', record.time || '待补充'),
|
||||||
buildDocumentCardFieldHtml(amountLabel, record.amountLabel, { valueClass: 'ai-document-card__amount' })
|
buildDocumentCardFieldHtml(amountLabel, record.amountLabel, { valueClass: 'ai-document-card__amount' })
|
||||||
].join('')
|
].join('')
|
||||||
const detailsHtml = [
|
const detailsHtml = [
|
||||||
buildDocumentCardFieldHtml('地点', record.locationLabel || '待补充'),
|
buildDocumentCardFieldHtml('地点', record.locationLabel || '待补充'),
|
||||||
|
buildDocumentCardFieldHtml('风险', record.riskLabel || '无风险'),
|
||||||
buildDocumentCardFieldHtml('单据编号', record.documentNo || '未编号单据', { valueClass: 'ai-document-card__number' }),
|
buildDocumentCardFieldHtml('单据编号', record.documentNo || '未编号单据', { valueClass: 'ai-document-card__number' }),
|
||||||
buildDocumentCardFieldHtml('事由', record.reason || '待补充'),
|
buildDocumentCardFieldHtml('事由', record.reason || '待补充'),
|
||||||
buildDocumentCardFieldHtml('申请人', ownerText),
|
buildDocumentCardFieldHtml('申请人', ownerText),
|
||||||
@@ -516,7 +639,7 @@ function buildDocumentCardHtml(record = {}) {
|
|||||||
'<div class="ai-document-card__field ai-document-card__field--action">',
|
'<div class="ai-document-card__field ai-document-card__field--action">',
|
||||||
'<span class="ai-document-card__label">操作</span>',
|
'<span class="ai-document-card__label">操作</span>',
|
||||||
href
|
href
|
||||||
? `<a class="ai-html-action-link ai-html-action-link-document ai-document-card__action" data-ai-action="open-document-detail" href="${escapeHtml(href)}">查看详情</a>`
|
? `<a class="ai-html-action-link ai-html-action-link-document ai-document-card__action" data-ai-action="open-document-detail" href="${escapeHtml(href)}">${escapeHtml(detailActionLabel)}</a>`
|
||||||
: '<span class="ai-document-card__value">暂无详情</span>',
|
: '<span class="ai-document-card__value">暂无详情</span>',
|
||||||
'</div>'
|
'</div>'
|
||||||
].join(''),
|
].join(''),
|
||||||
@@ -556,11 +679,13 @@ function buildDocumentQuerySummaryHtml(scopeText = '', totalCount = 0, visibleCo
|
|||||||
|
|
||||||
function buildDocumentCardsHtml(records = [], options = {}) {
|
function buildDocumentCardsHtml(records = [], options = {}) {
|
||||||
const querySummaryHtml = options.querySummaryHtml || ''
|
const querySummaryHtml = options.querySummaryHtml || ''
|
||||||
|
const commandGuidanceHtml = buildCommandConfirmationGuidanceHtml(options.commandFrame)
|
||||||
return [
|
return [
|
||||||
'<!-- ai-trusted-html:start -->',
|
'<!-- ai-trusted-html:start -->',
|
||||||
|
commandGuidanceHtml,
|
||||||
querySummaryHtml,
|
querySummaryHtml,
|
||||||
'<section class="ai-document-card-list" aria-label="单据查询结果">',
|
'<section class="ai-document-card-list" aria-label="单据查询结果">',
|
||||||
...records.map((record) => buildDocumentCardHtml(record)),
|
...records.map((record) => buildDocumentCardHtml(record, options)),
|
||||||
'</section>',
|
'</section>',
|
||||||
'<!-- ai-trusted-html:end -->'
|
'<!-- ai-trusted-html:end -->'
|
||||||
].join('\n')
|
].join('\n')
|
||||||
@@ -574,7 +699,8 @@ function buildQueryScopeText(intent = {}) {
|
|||||||
intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '',
|
intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '',
|
||||||
intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.label}` : '',
|
intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.label}` : '',
|
||||||
intent.keywordFilter?.label ? `关键词:${intent.keywordFilter.label}` : '',
|
intent.keywordFilter?.label ? `关键词:${intent.keywordFilter.label}` : '',
|
||||||
intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : ''
|
intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : '',
|
||||||
|
intent.riskFilter?.label ? `风险:${intent.riskFilter.label}` : ''
|
||||||
].filter(Boolean).join(' / ')
|
].filter(Boolean).join(' / ')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,12 +712,52 @@ export function buildAiDocumentQueryConditionSummary(intent = {}) {
|
|||||||
intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '',
|
intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '',
|
||||||
intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.label}` : '',
|
intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.label}` : '',
|
||||||
intent.keywordFilter?.label ? `关键词:${intent.keywordFilter.label}` : '',
|
intent.keywordFilter?.label ? `关键词:${intent.keywordFilter.label}` : '',
|
||||||
intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : ''
|
intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : '',
|
||||||
|
intent.riskFilter?.label ? `风险:${intent.riskFilter.label}` : ''
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
return conditions.join(';')
|
return conditions.join(';')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = []) {
|
function buildAiDocumentCommandPolicyEvent(commandFrame = null) {
|
||||||
|
if (!commandFrame || commandFrame.safetyLevel !== 'confirm_required') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const actionLabel = COMMAND_ACTION_LABELS[commandFrame.action] || '高风险操作'
|
||||||
|
const objectLabel = COMMAND_OBJECT_LABELS[commandFrame.objectType] || '单据'
|
||||||
|
return {
|
||||||
|
eventId: 'document-command-policy',
|
||||||
|
title: '识别高风险操作意图',
|
||||||
|
content: `识别到用户想执行“${actionLabel}”动作,对象是“${objectLabel}”。这类动作不会直接执行,我会先筛选候选单据,再让用户进入详情页确认。`,
|
||||||
|
status: 'running'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAiDocumentQueryThinkingEvents(intent = {}, options = {}) {
|
||||||
|
const conditionSummary = buildAiDocumentQueryConditionSummary(intent)
|
||||||
|
return [
|
||||||
|
buildAiDocumentCommandPolicyEvent(options.commandFrame),
|
||||||
|
{
|
||||||
|
eventId: 'document-query-parse',
|
||||||
|
title: '解析自然语言筛选条件',
|
||||||
|
content: `正在从您的问题里提取查询来源、单据类型、时间、状态、费用类型、风险、关键词和金额条件。当前识别:${conditionSummary}。`,
|
||||||
|
status: 'running'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 'document-query-fetch',
|
||||||
|
title: '查询业务单据接口',
|
||||||
|
content: '等待调用业务单据接口。',
|
||||||
|
status: 'pending'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventId: 'document-query-filter',
|
||||||
|
title: '组合筛选单据',
|
||||||
|
content: '等待接口返回后,再按已识别条件做二次筛选。',
|
||||||
|
status: 'pending'
|
||||||
|
}
|
||||||
|
].filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = [], options = {}) {
|
||||||
const records = filterAiDocumentQueryRecords(claimsPayload, intent)
|
const records = filterAiDocumentQueryRecords(claimsPayload, intent)
|
||||||
const visibleRecords = records.slice(0, DOCUMENT_QUERY_LIMIT)
|
const visibleRecords = records.slice(0, DOCUMENT_QUERY_LIMIT)
|
||||||
const scopeText = buildQueryScopeText(intent)
|
const scopeText = buildQueryScopeText(intent)
|
||||||
@@ -610,7 +776,8 @@ export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = []) {
|
|||||||
'### 已查询到相关单据',
|
'### 已查询到相关单据',
|
||||||
'',
|
'',
|
||||||
buildDocumentCardsHtml(visibleRecords, {
|
buildDocumentCardsHtml(visibleRecords, {
|
||||||
querySummaryHtml: buildDocumentQuerySummaryHtml(scopeText, records.length, visibleRecords.length)
|
querySummaryHtml: buildDocumentQuerySummaryHtml(scopeText, records.length, visibleRecords.length),
|
||||||
|
commandFrame: options.commandFrame
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ const MAX_CONVERSATION_HISTORY = 30
|
|||||||
const MAX_STORED_MESSAGES = 80
|
const MAX_STORED_MESSAGES = 80
|
||||||
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
|
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
|
||||||
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
|
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
|
||||||
|
const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
|
||||||
|
const DELETED_DOCUMENT_DETAIL_HREF_PREFIX = '#ai-deleted-document-detail:'
|
||||||
const APPLICATION_DETAIL_MARKDOWN_LINK_RE = /\[([^\]]+)\]\((#ai-open-application-detail:[^)]+)\)/g
|
const APPLICATION_DETAIL_MARKDOWN_LINK_RE = /\[([^\]]+)\]\((#ai-open-application-detail:[^)]+)\)/g
|
||||||
|
const DOCUMENT_DETAIL_MARKDOWN_LINK_RE = /\[([^\]]+)\]\((#ai-open-document-detail:[^)]+)\)/g
|
||||||
|
|
||||||
function safeString(value) {
|
function safeString(value) {
|
||||||
return String(value || '').trim()
|
return String(value || '').trim()
|
||||||
@@ -25,12 +28,12 @@ function collectDeletedDraftIdentifiers(payload = {}) {
|
|||||||
].map((item) => normalizeIdentifier(item)).filter(Boolean))
|
].map((item) => normalizeIdentifier(item)).filter(Boolean))
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeApplicationDetailHref(href = '') {
|
function decodeDetailHref(href = '', prefix = '') {
|
||||||
const value = safeString(href)
|
const value = safeString(href)
|
||||||
if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) {
|
if (!value.startsWith(prefix)) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const encodedReference = value.slice(APPLICATION_DETAIL_HREF_PREFIX.length)
|
const encodedReference = value.slice(prefix.length)
|
||||||
if (!encodedReference) {
|
if (!encodedReference) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -54,10 +57,22 @@ function decodeApplicationDetailHref(href = '') {
|
|||||||
return [...identifiers]
|
return [...identifiers]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeApplicationDetailHref(href = '') {
|
||||||
|
return decodeDetailHref(href, APPLICATION_DETAIL_HREF_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeDocumentDetailHref(href = '') {
|
||||||
|
return decodeDetailHref(href, DOCUMENT_DETAIL_HREF_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
function applicationDetailHrefMatchesDeletedDraft(href = '', identifiers = new Set()) {
|
function applicationDetailHrefMatchesDeletedDraft(href = '', identifiers = new Set()) {
|
||||||
return decodeApplicationDetailHref(href).some((item) => identifiers.has(item))
|
return decodeApplicationDetailHref(href).some((item) => identifiers.has(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function documentDetailHrefMatchesDeletedDocument(href = '', identifiers = new Set()) {
|
||||||
|
return decodeDocumentDetailHref(href).some((item) => identifiers.has(item))
|
||||||
|
}
|
||||||
|
|
||||||
function buildDeletedApplicationDetailHref(href = '') {
|
function buildDeletedApplicationDetailHref(href = '') {
|
||||||
const value = safeString(href)
|
const value = safeString(href)
|
||||||
if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) {
|
if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) {
|
||||||
@@ -66,6 +81,14 @@ function buildDeletedApplicationDetailHref(href = '') {
|
|||||||
return `${DELETED_APPLICATION_DETAIL_HREF_PREFIX}${value.slice(APPLICATION_DETAIL_HREF_PREFIX.length)}`
|
return `${DELETED_APPLICATION_DETAIL_HREF_PREFIX}${value.slice(APPLICATION_DETAIL_HREF_PREFIX.length)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDeletedDocumentDetailHref(href = '') {
|
||||||
|
const value = safeString(href)
|
||||||
|
if (!value.startsWith(DOCUMENT_DETAIL_HREF_PREFIX)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return `${DELETED_DOCUMENT_DETAIL_HREF_PREFIX}${value.slice(DOCUMENT_DETAIL_HREF_PREFIX.length)}`
|
||||||
|
}
|
||||||
|
|
||||||
function markApplicationDetailLinksDeleted(content = '', identifiers = new Set()) {
|
function markApplicationDetailLinksDeleted(content = '', identifiers = new Set()) {
|
||||||
let changed = false
|
let changed = false
|
||||||
const nextContent = String(content || '').replace(APPLICATION_DETAIL_MARKDOWN_LINK_RE, (match, _label, href) => {
|
const nextContent = String(content || '').replace(APPLICATION_DETAIL_MARKDOWN_LINK_RE, (match, _label, href) => {
|
||||||
@@ -82,6 +105,33 @@ function markApplicationDetailLinksDeleted(content = '', identifiers = new Set()
|
|||||||
return { content: nextContent, changed }
|
return { content: nextContent, changed }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markDocumentDetailLinksDeleted(content = '', identifiers = new Set()) {
|
||||||
|
let changed = false
|
||||||
|
const afterDocumentLinks = String(content || '').replace(DOCUMENT_DETAIL_MARKDOWN_LINK_RE, (match, _label, href) => {
|
||||||
|
if (!documentDetailHrefMatchesDeletedDocument(href, identifiers)) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
const deletedHref = buildDeletedDocumentDetailHref(href)
|
||||||
|
if (!deletedHref) {
|
||||||
|
return '单据已删除'
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
return `[单据已删除](${deletedHref})`
|
||||||
|
})
|
||||||
|
const nextContent = afterDocumentLinks.replace(APPLICATION_DETAIL_MARKDOWN_LINK_RE, (match, _label, href) => {
|
||||||
|
if (!applicationDetailHrefMatchesDeletedDraft(href, identifiers)) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
const deletedHref = buildDeletedApplicationDetailHref(href)
|
||||||
|
if (!deletedHref) {
|
||||||
|
return '单据已删除'
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
return `[单据已删除](${deletedHref})`
|
||||||
|
})
|
||||||
|
return { content: nextContent, changed }
|
||||||
|
}
|
||||||
|
|
||||||
function actionMatchesDeletedDraft(action = {}, identifiers = new Set()) {
|
function actionMatchesDeletedDraft(action = {}, identifiers = new Set()) {
|
||||||
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||||
return [
|
return [
|
||||||
@@ -98,7 +148,8 @@ function actionMatchesDeletedDraft(action = {}, identifiers = new Set()) {
|
|||||||
function markSuggestedActionsDeleted(actions = [], identifiers = new Set()) {
|
function markSuggestedActionsDeleted(actions = [], identifiers = new Set()) {
|
||||||
let changed = false
|
let changed = false
|
||||||
const nextActions = (Array.isArray(actions) ? actions : []).map((action) => {
|
const nextActions = (Array.isArray(actions) ? actions : []).map((action) => {
|
||||||
if (String(action?.action_type || '').trim() !== 'open_application_detail') {
|
const actionType = String(action?.action_type || '').trim()
|
||||||
|
if (!['open_application_detail', 'open_document_detail'].includes(actionType)) {
|
||||||
return action
|
return action
|
||||||
}
|
}
|
||||||
if (!actionMatchesDeletedDraft(action, identifiers)) {
|
if (!actionMatchesDeletedDraft(action, identifiers)) {
|
||||||
@@ -111,7 +162,9 @@ function markSuggestedActionsDeleted(actions = [], identifiers = new Set()) {
|
|||||||
description: '草稿单据已经删除,请重新再次申请。',
|
description: '草稿单据已经删除,请重新再次申请。',
|
||||||
icon: 'mdi mdi-trash-can-outline',
|
icon: 'mdi mdi-trash-can-outline',
|
||||||
disabled: true,
|
disabled: true,
|
||||||
action_type: 'deleted_application_detail'
|
action_type: actionType === 'open_document_detail'
|
||||||
|
? 'deleted_document_detail'
|
||||||
|
: 'deleted_application_detail'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return { actions: nextActions, changed }
|
return { actions: nextActions, changed }
|
||||||
@@ -132,6 +185,21 @@ function buildDraftDeletedMessage(payload = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDocumentDeletedMessage(payload = {}) {
|
||||||
|
const claimNo = safeString(payload.claimNo || payload.claim_no || payload.documentNo || payload.document_no)
|
||||||
|
return {
|
||||||
|
id: `document-deleted-${safeString(payload.claimId || payload.claim_id || payload.id || claimNo) || Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
`单据${claimNo ? ` ${claimNo}` : ''} 已经删除或不可访问。`,
|
||||||
|
'该历史入口已失效,请返回单据列表重新查询。'
|
||||||
|
].join('\n\n'),
|
||||||
|
feedback: '',
|
||||||
|
stewardPlan: null,
|
||||||
|
suggestedActions: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function conversationHasDeletionNotice(messages = [], identifiers = new Set()) {
|
function conversationHasDeletionNotice(messages = [], identifiers = new Set()) {
|
||||||
return messages.some((message) => {
|
return messages.some((message) => {
|
||||||
const content = safeString(message?.content)
|
const content = safeString(message?.content)
|
||||||
@@ -139,6 +207,13 @@ function conversationHasDeletionNotice(messages = [], identifiers = new Set()) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function conversationHasDocumentDeletionNotice(messages = [], identifiers = new Set()) {
|
||||||
|
return messages.some((message) => {
|
||||||
|
const content = safeString(message?.content)
|
||||||
|
return content.includes('已经删除或不可访问') && [...identifiers].some((item) => content.includes(item))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function resolveUserStorageKey(user = {}) {
|
function resolveUserStorageKey(user = {}) {
|
||||||
const identity = safeString(user.username || user.email || user.name || 'anonymous')
|
const identity = safeString(user.username || user.email || user.name || 'anonymous')
|
||||||
return `${STORAGE_KEY_PREFIX}:${identity || 'anonymous'}`
|
return `${STORAGE_KEY_PREFIX}:${identity || 'anonymous'}`
|
||||||
@@ -329,3 +404,46 @@ export function markAiWorkbenchConversationDraftDeleted(user = {}, payload = {})
|
|||||||
writeStoredList(user, nextList)
|
writeStoredList(user, nextList)
|
||||||
return loadAiWorkbenchConversationHistory(user)
|
return loadAiWorkbenchConversationHistory(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function markAiWorkbenchConversationDocumentDeleted(user = {}, payload = {}) {
|
||||||
|
const identifiers = collectDeletedDraftIdentifiers(payload)
|
||||||
|
if (!identifiers.size) {
|
||||||
|
return loadAiWorkbenchConversationHistory(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextList = readStoredList(user).map((conversation) => {
|
||||||
|
const normalized = normalizeConversation(conversation)
|
||||||
|
let conversationChanged = false
|
||||||
|
const messages = normalized.messages.map((message) => {
|
||||||
|
const contentResult = markDocumentDetailLinksDeleted(message.content, identifiers)
|
||||||
|
const actionsResult = markSuggestedActionsDeleted(message.suggestedActions, identifiers)
|
||||||
|
if (!contentResult.changed && !actionsResult.changed) {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
conversationChanged = true
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content: contentResult.content,
|
||||||
|
suggestedActions: actionsResult.actions
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!conversationChanged) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conversationHasDocumentDeletionNotice(messages, identifiers)) {
|
||||||
|
messages.push(buildDocumentDeletedMessage(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
desc: '单据已经删除或不可访问。',
|
||||||
|
messages,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
writeStoredList(user, nextList)
|
||||||
|
return loadAiWorkbenchConversationHistory(user)
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,6 +115,15 @@ test('AI conversation renderer renders document detail action links as buttons',
|
|||||||
assert.doesNotMatch(rendered, /target="_blank"[\s\S]{0,120}#ai-open-document-detail/)
|
assert.doesNotMatch(rendered, /target="_blank"[\s\S]{0,120}#ai-open-document-detail/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('AI conversation renderer renders deleted document detail actions as disabled buttons', () => {
|
||||||
|
const rendered = renderAiConversationHtml('[单据已删除](#ai-deleted-document-detail:claim-deleted-1)')
|
||||||
|
|
||||||
|
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-document is-disabled"/)
|
||||||
|
assert.match(rendered, /aria-disabled="true"/)
|
||||||
|
assert.match(rendered, /data-ai-action="deleted-document-detail"/)
|
||||||
|
assert.doesNotMatch(rendered, /href="#ai-deleted-document-detail/)
|
||||||
|
})
|
||||||
|
|
||||||
test('AI conversation renderer renders images as html and rejects unsafe image sources', () => {
|
test('AI conversation renderer renders images as html and rejects unsafe image sources', () => {
|
||||||
const rendered = renderAiConversationHtml([
|
const rendered = renderAiConversationHtml([
|
||||||
'### 图片材料',
|
'### 图片材料',
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
resolveAiDocumentQueryIntent
|
resolveAiDocumentQueryIntent
|
||||||
} from '../src/utils/aiDocumentQueryModel.js'
|
} from '../src/utils/aiDocumentQueryModel.js'
|
||||||
import { renderAiConversationHtml } from '../src/utils/aiConversationHtmlRenderer.js'
|
import { renderAiConversationHtml } from '../src/utils/aiConversationHtmlRenderer.js'
|
||||||
|
import { resolveWorkbenchIntentActionRoute } from '../src/composables/workbenchAiMode/workbenchIntentActionPolicy.js'
|
||||||
|
import { resolveWorkbenchIntentFrame } from '../src/composables/workbenchAiMode/workbenchIntentFrameModel.js'
|
||||||
|
|
||||||
const today = '2026-06-20'
|
const today = '2026-06-20'
|
||||||
|
|
||||||
@@ -68,6 +70,21 @@ test('AI document query intent detects approval document questions', () => {
|
|||||||
assert.equal(intent?.sourceLabel, '待我审核的单据')
|
assert.equal(intent?.sourceLabel, '待我审核的单据')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('AI document query intent detects short approval workbench commands', () => {
|
||||||
|
const reviewIntent = resolveAiDocumentQueryIntent('我要审核', { today })
|
||||||
|
const todoIntent = resolveAiDocumentQueryIntent('待办审批', { today })
|
||||||
|
|
||||||
|
assert.equal(reviewIntent?.source, 'approval')
|
||||||
|
assert.equal(reviewIntent?.documentType, 'all')
|
||||||
|
assert.equal(reviewIntent?.sourceLabel, '待我审核的单据')
|
||||||
|
assert.equal(todoIntent?.source, 'approval')
|
||||||
|
assert.equal(todoIntent?.sourceLabel, '待我审核的单据')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI document query intent keeps approval policy questions out of document query', () => {
|
||||||
|
assert.equal(resolveAiDocumentQueryIntent('审批规则怎么走', { today }), null)
|
||||||
|
})
|
||||||
|
|
||||||
test('AI document query keeps explicit own-document scope separate from accessible documents', () => {
|
test('AI document query keeps explicit own-document scope separate from accessible documents', () => {
|
||||||
const intent = resolveAiDocumentQueryIntent('我名下有哪些单据?', { today })
|
const intent = resolveAiDocumentQueryIntent('我名下有哪些单据?', { today })
|
||||||
|
|
||||||
@@ -136,6 +153,66 @@ test('AI document query combines natural-language filters', () => {
|
|||||||
assert.match(buildAiDocumentQueryConditionSummary(intent), /金额:不少于1000元/)
|
assert.match(buildAiDocumentQueryConditionSummary(intent), /金额:不少于1000元/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('AI document query filters draft candidates by relative day', () => {
|
||||||
|
const intent = resolveAiDocumentQueryIntent('我的 3天前 草稿单据', { today: '2026-06-24' })
|
||||||
|
const records = filterAiDocumentQueryRecords([
|
||||||
|
{
|
||||||
|
id: 'draft-3-days-ago',
|
||||||
|
claim_no: 'AP-20260621001',
|
||||||
|
document_type_code: 'application',
|
||||||
|
status: 'draft',
|
||||||
|
reason: '三天前保存的申请草稿',
|
||||||
|
created_at: '2026-06-21T09:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'draft-yesterday',
|
||||||
|
claim_no: 'AP-20260623001',
|
||||||
|
document_type_code: 'application',
|
||||||
|
status: 'draft',
|
||||||
|
reason: '昨天保存的申请草稿',
|
||||||
|
created_at: '2026-06-23T09:00:00Z'
|
||||||
|
}
|
||||||
|
], intent)
|
||||||
|
|
||||||
|
assert.equal(intent?.source, 'mine')
|
||||||
|
assert.equal(intent?.statusFilter?.label, '草稿')
|
||||||
|
assert.equal(intent?.timeRange?.start, '2026-06-21')
|
||||||
|
assert.equal(intent?.timeRange?.end, '2026-06-21')
|
||||||
|
assert.deepEqual(records.map((record) => record.documentNo), ['AP-20260621001'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI document query filters no-risk approval application candidates', () => {
|
||||||
|
const intent = resolveAiDocumentQueryIntent('待我审核 无风险 申请单', { today })
|
||||||
|
const payload = [{
|
||||||
|
id: 'approval-no-risk',
|
||||||
|
claim_no: 'AP-NORISK-001',
|
||||||
|
document_type_code: 'application',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
status: 'submitted',
|
||||||
|
reason: '合规差旅申请',
|
||||||
|
created_at: '2026-06-20T09:00:00Z',
|
||||||
|
risk_flags_json: [],
|
||||||
|
risk_summary: '无'
|
||||||
|
}, {
|
||||||
|
id: 'approval-high-risk',
|
||||||
|
claim_no: 'AP-RISK-001',
|
||||||
|
document_type_code: 'application',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
status: 'submitted',
|
||||||
|
reason: '住宿超标申请',
|
||||||
|
created_at: '2026-06-20T10:00:00Z',
|
||||||
|
risk_flags_json: [{ severity: 'high', summary: '住宿超标' }],
|
||||||
|
risk_summary: '住宿超标'
|
||||||
|
}]
|
||||||
|
const records = filterAiDocumentQueryRecords({ items: payload, querySource: 'approval' }, intent)
|
||||||
|
|
||||||
|
assert.equal(intent?.source, 'approval')
|
||||||
|
assert.equal(intent?.documentType, 'application')
|
||||||
|
assert.equal(intent?.riskFilter?.level, 'none')
|
||||||
|
assert.match(buildAiDocumentQueryConditionSummary(intent), /风险:无风险/)
|
||||||
|
assert.deepEqual(records.map((record) => record.documentNo), ['AP-NORISK-001'])
|
||||||
|
})
|
||||||
|
|
||||||
test('AI document query excludes undated rows when a time condition is present', () => {
|
test('AI document query excludes undated rows when a time condition is present', () => {
|
||||||
const intent = resolveAiDocumentQueryIntent('我2月有哪些单据?', { today })
|
const intent = resolveAiDocumentQueryIntent('我2月有哪些单据?', { today })
|
||||||
const records = filterAiDocumentQueryRecords([
|
const records = filterAiDocumentQueryRecords([
|
||||||
@@ -246,6 +323,28 @@ test('AI document query html cards render as trusted card markup', () => {
|
|||||||
assert.doesNotMatch(rendered, /<blockquote>/)
|
assert.doesNotMatch(rendered, /<blockquote>/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('AI document query keeps high-risk command guidance in trusted rendered markup', () => {
|
||||||
|
const frame = resolveWorkbenchIntentFrame('删除申请单草稿', { today: '2026-06-24' })
|
||||||
|
const route = resolveWorkbenchIntentActionRoute(frame)
|
||||||
|
const intent = resolveAiDocumentQueryIntent(route.queryPrompt, { today: '2026-06-24' })
|
||||||
|
const rendered = renderAiConversationHtml(buildAiDocumentQueryMessage(intent, [{
|
||||||
|
id: 'draft-application-1',
|
||||||
|
claim_no: 'AP-DRAFT-001',
|
||||||
|
document_type_code: 'application',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
status: 'draft',
|
||||||
|
reason: '测试申请草稿',
|
||||||
|
created_at: '2026-06-24T09:00:00Z'
|
||||||
|
}], { commandFrame: frame }))
|
||||||
|
|
||||||
|
assert.match(rendered, /<h3 class="ai-html-title">已查询到相关单据<\/h3>/)
|
||||||
|
assert.match(rendered, /<section class="ai-document-command-guidance" aria-label="高风险操作提示">/)
|
||||||
|
assert.match(rendered, /系统不会直接删除相关单据/)
|
||||||
|
assert.match(rendered, /进入单据详情核对后再操作/)
|
||||||
|
assert.match(rendered, /<section class="ai-document-card-list" aria-label="单据查询结果">/)
|
||||||
|
assert.match(rendered, /进入详情确认删除/)
|
||||||
|
})
|
||||||
|
|
||||||
test('AI document query trusted html rejects unsafe card markup', () => {
|
test('AI document query trusted html rejects unsafe card markup', () => {
|
||||||
const rendered = renderAiConversationHtml([
|
const rendered = renderAiConversationHtml([
|
||||||
'### 查询结果',
|
'### 查询结果',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
markAiWorkbenchConversationDraftDeleted,
|
markAiWorkbenchConversationDraftDeleted,
|
||||||
|
markAiWorkbenchConversationDocumentDeleted,
|
||||||
loadAiWorkbenchConversationHistory,
|
loadAiWorkbenchConversationHistory,
|
||||||
saveAiWorkbenchConversation
|
saveAiWorkbenchConversation
|
||||||
} from '../src/utils/aiWorkbenchConversationStore.js'
|
} from '../src/utils/aiWorkbenchConversationStore.js'
|
||||||
@@ -120,6 +121,54 @@ test('deleting an application draft marks AI workbench detail links as unavailab
|
|||||||
assert.equal(loadAiWorkbenchConversationHistory(user)[0].messages.length, 2)
|
assert.equal(loadAiWorkbenchConversationHistory(user)[0].messages.length, 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('deleted document detail links are disabled for reopened AI conversations', () => {
|
||||||
|
installWindowStub()
|
||||||
|
const user = { username: 'zhangsan@example.com' }
|
||||||
|
|
||||||
|
saveAiWorkbenchConversation(user, {
|
||||||
|
id: 'conversation-document-delete-candidate',
|
||||||
|
title: '删除申请单草稿',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'assistant-document-candidates',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
'### 已查询到相关单据',
|
||||||
|
'',
|
||||||
|
'[进入详情确认删除](#ai-open-document-detail:claim_id%3Dclaim-draft-2%26claim_no%3DAP-DRAFT-002)'
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextHistory = markAiWorkbenchConversationDocumentDeleted(user, {
|
||||||
|
claimId: 'claim-draft-2',
|
||||||
|
claimNo: 'AP-DRAFT-002'
|
||||||
|
})
|
||||||
|
const conversation = nextHistory.find((item) => item.id === 'conversation-document-delete-candidate')
|
||||||
|
|
||||||
|
assert.ok(conversation)
|
||||||
|
assert.match(conversation.messages[0].content, /#ai-deleted-document-detail:/)
|
||||||
|
assert.doesNotMatch(conversation.messages[0].content, /#ai-open-document-detail:/)
|
||||||
|
assert.match(conversation.messages[0].content, /\[单据已删除\]/)
|
||||||
|
assert.match(conversation.messages.at(-1).content, /单据 AP-DRAFT-002 已经删除或不可访问/)
|
||||||
|
assert.match(conversation.messages.at(-1).content, /该历史入口已失效,请返回单据列表重新查询/)
|
||||||
|
assert.equal(loadAiWorkbenchConversationHistory(user)[0].messages.length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI workbench validates document detail links before opening stale history entries', () => {
|
||||||
|
const aiMode = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.match(aiMode, /fetchExpenseClaimDetail/)
|
||||||
|
assert.match(aiMode, /markAiWorkbenchConversationDocumentDeleted/)
|
||||||
|
assert.match(aiMode, /async function handleAiAnswerMarkdownClick/)
|
||||||
|
assert.match(aiMode, /await ensureAiDocumentDetailStillAvailable/)
|
||||||
|
assert.match(aiMode, /已将这条历史入口标记为不可查看/)
|
||||||
|
})
|
||||||
|
|
||||||
test('saving a draft keeps the financial assistant open for continued work', () => {
|
test('saving a draft keeps the financial assistant open for continued work', () => {
|
||||||
const appShellScript = readFileSync(
|
const appShellScript = readFileSync(
|
||||||
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
||||||
|
|||||||
104
web/tests/workbench-ai-command-intent-model.test.mjs
Normal file
104
web/tests/workbench-ai-command-intent-model.test.mjs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildWorkbenchDraftDeletionGuidance,
|
||||||
|
isWorkbenchDraftDeletionIntent,
|
||||||
|
resolveLatestWorkbenchDraftPayload
|
||||||
|
} from '../src/composables/workbenchAiMode/workbenchAiCommandIntentModel.js'
|
||||||
|
|
||||||
|
const personalWorkbenchAiModeScript = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const commandIntentsScript = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiCommandIntents.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
test('workbench command intent detects draft deletion phrases without broad delete matching', () => {
|
||||||
|
assert.equal(isWorkbenchDraftDeletionIntent('删除草稿'), true)
|
||||||
|
assert.equal(isWorkbenchDraftDeletionIntent('把刚才保存的草稿删掉'), true)
|
||||||
|
assert.equal(isWorkbenchDraftDeletionIntent('删除这个申请单'), true)
|
||||||
|
assert.equal(isWorkbenchDraftDeletionIntent('删除附件'), false)
|
||||||
|
assert.equal(isWorkbenchDraftDeletionIntent('草稿还要补什么'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench command intent resolves latest draft payload from conversation context', () => {
|
||||||
|
const payload = resolveLatestWorkbenchDraftPayload([
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
draftPayload: {
|
||||||
|
claim_id: 'old-draft',
|
||||||
|
claim_no: 'AOLD001',
|
||||||
|
status: 'draft'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
suggestedActions: [{
|
||||||
|
action_type: 'open_application_detail',
|
||||||
|
payload: {
|
||||||
|
claim_id: 'latest-draft',
|
||||||
|
claim_no: 'ALATEST1',
|
||||||
|
status: 'draft'
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.deepEqual(payload, {
|
||||||
|
claimId: 'latest-draft',
|
||||||
|
claimNo: 'ALATEST1',
|
||||||
|
status: 'draft',
|
||||||
|
documentType: 'application'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench command intent skips submitted documents when deleting draft', () => {
|
||||||
|
const payload = resolveLatestWorkbenchDraftPayload([
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
draftPayload: {
|
||||||
|
claim_id: 'submitted-application',
|
||||||
|
claim_no: 'ASUBMIT1',
|
||||||
|
status: 'submitted'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.equal(payload, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench draft deletion guidance opens detail instead of deleting directly', () => {
|
||||||
|
const guidance = buildWorkbenchDraftDeletionGuidance({
|
||||||
|
claimId: 'latest-draft',
|
||||||
|
claimNo: 'ALATEST1',
|
||||||
|
documentType: 'application'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.match(guidance.content, /已识别到您想删除草稿/)
|
||||||
|
assert.match(guidance.content, /不会直接替您删除/)
|
||||||
|
assert.equal(guidance.suggestedActions.length, 1)
|
||||||
|
assert.equal(guidance.suggestedActions[0].action_type, 'open_application_detail')
|
||||||
|
assert.equal(guidance.suggestedActions[0].payload.claim_id, 'latest-draft')
|
||||||
|
assert.equal(guidance.suggestedActions[0].payload.claim_no, 'ALATEST1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench draft deletion intent is wired before draft slot continuation', () => {
|
||||||
|
assert.match(commandIntentsScript, /isWorkbenchDraftDeletionIntent/)
|
||||||
|
assert.match(commandIntentsScript, /function handleInlineDraftDeletionIntent\(cleanPrompt, entry = \{\}\)/)
|
||||||
|
assert.match(commandIntentsScript, /resolveLatestWorkbenchDraftPayload\(conversationMessages\.value\)/)
|
||||||
|
assert.match(commandIntentsScript, /buildWorkbenchDraftDeletionGuidance\(draftPayload\)/)
|
||||||
|
assert.match(personalWorkbenchAiModeScript, /useWorkbenchAiCommandIntents/)
|
||||||
|
|
||||||
|
const startIndex = personalWorkbenchAiModeScript.indexOf('function startInlineConversation')
|
||||||
|
const startBlock = personalWorkbenchAiModeScript.slice(startIndex)
|
||||||
|
const deleteIntentIndex = startBlock.indexOf('if (commandIntents.handleInlineDraftDeletionIntent(cleanPrompt, entry))')
|
||||||
|
const draftContinuationIndex = startBlock.indexOf('if (aiExpenseDraft.value && !isAiExpenseDraftComplete(aiExpenseDraft.value))')
|
||||||
|
|
||||||
|
assert.ok(deleteIntentIndex >= 0)
|
||||||
|
assert.ok(draftContinuationIndex > deleteIntentIndex)
|
||||||
|
})
|
||||||
116
web/tests/workbench-intent-frame-model.test.mjs
Normal file
116
web/tests/workbench-intent-frame-model.test.mjs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveWorkbenchIntentFrame
|
||||||
|
} from '../src/composables/workbenchAiMode/workbenchIntentFrameModel.js'
|
||||||
|
import {
|
||||||
|
resolveWorkbenchIntentActionRoute
|
||||||
|
} from '../src/composables/workbenchAiMode/workbenchIntentActionPolicy.js'
|
||||||
|
import {
|
||||||
|
buildAiDocumentQueryMessage,
|
||||||
|
buildAiDocumentQueryThinkingEvents,
|
||||||
|
resolveAiDocumentQueryIntent
|
||||||
|
} from '../src/utils/aiDocumentQueryModel.js'
|
||||||
|
|
||||||
|
const today = '2026-06-24'
|
||||||
|
|
||||||
|
test('workbench intent frame resolves contextual draft deletion as confirm-only current target', () => {
|
||||||
|
const frame = resolveWorkbenchIntentFrame('请删除刚才那个草稿', { today })
|
||||||
|
|
||||||
|
assert.equal(frame?.action, 'delete')
|
||||||
|
assert.equal(frame?.objectType, 'draft')
|
||||||
|
assert.equal(frame?.targetMode, 'current_context')
|
||||||
|
assert.equal(frame?.safetyLevel, 'confirm_required')
|
||||||
|
assert.equal(frame?.filters.status?.label, '草稿')
|
||||||
|
assert.equal(frame?.normalizedQuery, '我的草稿单据')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench intent frame sends filtered draft deletion to candidate search', () => {
|
||||||
|
const frame = resolveWorkbenchIntentFrame('删除3天前的草稿', { today })
|
||||||
|
const route = resolveWorkbenchIntentActionRoute(frame)
|
||||||
|
|
||||||
|
assert.equal(frame?.action, 'delete')
|
||||||
|
assert.equal(frame?.objectType, 'draft')
|
||||||
|
assert.equal(frame?.targetMode, 'filtered_candidates')
|
||||||
|
assert.equal(frame?.safetyLevel, 'confirm_required')
|
||||||
|
assert.equal(frame?.filters.timeRange?.start, '2026-06-21')
|
||||||
|
assert.equal(frame?.filters.timeRange?.end, '2026-06-21')
|
||||||
|
assert.equal(frame?.normalizedQuery, '我的 3天前 草稿单据')
|
||||||
|
assert.equal(route.nextStep, 'query_candidates')
|
||||||
|
assert.equal(route.queryPrompt, '我的 3天前 草稿单据')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench intent frame preserves application draft deletion filters', () => {
|
||||||
|
const frame = resolveWorkbenchIntentFrame('删除申请单草稿', { today })
|
||||||
|
const route = resolveWorkbenchIntentActionRoute(frame)
|
||||||
|
const queryIntent = resolveAiDocumentQueryIntent(route.queryPrompt, { today })
|
||||||
|
|
||||||
|
assert.equal(frame?.action, 'delete')
|
||||||
|
assert.equal(frame?.objectType, 'draft')
|
||||||
|
assert.equal(frame?.filters.documentType, 'application')
|
||||||
|
assert.equal(frame?.filters.status?.label, '草稿')
|
||||||
|
assert.equal(frame?.targetMode, 'filtered_candidates')
|
||||||
|
assert.equal(frame?.safetyLevel, 'confirm_required')
|
||||||
|
assert.equal(route.queryPrompt, '我的 草稿 申请单')
|
||||||
|
assert.equal(queryIntent?.source, 'mine')
|
||||||
|
assert.equal(queryIntent?.documentType, 'application')
|
||||||
|
assert.equal(queryIntent?.statusFilter?.label, '草稿')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench document query thinking exposes destructive action policy before filtering', () => {
|
||||||
|
const frame = resolveWorkbenchIntentFrame('删除申请单草稿', { today })
|
||||||
|
const intent = resolveAiDocumentQueryIntent(resolveWorkbenchIntentActionRoute(frame).queryPrompt, { today })
|
||||||
|
const events = buildAiDocumentQueryThinkingEvents(intent, { commandFrame: frame })
|
||||||
|
|
||||||
|
assert.equal(events[0].eventId, 'document-command-policy')
|
||||||
|
assert.match(events[0].title, /识别高风险操作意图/)
|
||||||
|
assert.match(events[0].content, /删除/)
|
||||||
|
assert.match(events[0].content, /不会直接执行/)
|
||||||
|
assert.match(events[0].content, /先筛选候选/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench high-risk command result explains confirmation boundary and labels detail shortcut', () => {
|
||||||
|
const frame = resolveWorkbenchIntentFrame('删除申请单草稿', { today })
|
||||||
|
const intent = resolveAiDocumentQueryIntent(resolveWorkbenchIntentActionRoute(frame).queryPrompt, { today })
|
||||||
|
const message = buildAiDocumentQueryMessage(intent, [{
|
||||||
|
id: 'draft-application-1',
|
||||||
|
claim_no: 'AP-DRAFT-001',
|
||||||
|
document_type_code: 'application',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
status: 'draft',
|
||||||
|
reason: '测试申请草稿',
|
||||||
|
created_at: '2026-06-24T09:00:00Z',
|
||||||
|
risk_flags_json: [],
|
||||||
|
risk_summary: '无'
|
||||||
|
}], { commandFrame: frame })
|
||||||
|
|
||||||
|
assert.match(message, /系统不会直接删除相关单据/)
|
||||||
|
assert.match(message, /请点击下方候选单据里的快捷按钮/)
|
||||||
|
assert.match(message, /进入单据详情核对后再操作/)
|
||||||
|
assert.match(message, />进入详情确认删除</)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench intent frame resolves compliant no-risk approval request as filtered approval candidates', () => {
|
||||||
|
const frame = resolveWorkbenchIntentFrame('审核合规没有风险的申请', { today })
|
||||||
|
const route = resolveWorkbenchIntentActionRoute(frame)
|
||||||
|
|
||||||
|
assert.equal(frame?.action, 'approve')
|
||||||
|
assert.equal(frame?.objectType, 'application')
|
||||||
|
assert.equal(frame?.targetMode, 'filtered_candidates')
|
||||||
|
assert.equal(frame?.safetyLevel, 'confirm_required')
|
||||||
|
assert.equal(frame?.filters.risk?.level, 'none')
|
||||||
|
assert.equal(frame?.filters.documentType, 'application')
|
||||||
|
assert.equal(frame?.normalizedQuery, '待我审核 无风险 申请单')
|
||||||
|
assert.equal(route.nextStep, 'query_candidates')
|
||||||
|
assert.equal(route.queryPrompt, '待我审核 无风险 申请单')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('workbench intent frame keeps approval policy questions out of document actions', () => {
|
||||||
|
const frame = resolveWorkbenchIntentFrame('审批规则怎么走', { today })
|
||||||
|
const route = resolveWorkbenchIntentActionRoute(frame)
|
||||||
|
|
||||||
|
assert.equal(frame?.action, 'ask_policy')
|
||||||
|
assert.equal(frame?.safetyLevel, 'read_only')
|
||||||
|
assert.equal(route.nextStep, 'pass_through')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user