feat(web): 文档查询意图补充风险过滤与 X 天前范围

- aiDocumentQueryIntent 新增风险等级过滤(无/高/中/低/有风险)与 N 天前日期范围解析
- aiDocumentQueryModel/aiConversationHtmlRenderer 渲染适配风险过滤标签
- useWorkbenchAiDocumentQueryFlow/aiWorkbenchConversationStore 会话流转适配命令意图
- 更新 ai-document-query-model/ai-conversation-html-renderer/assistant-session-draft-delete 测试
This commit is contained in:
caoxiaozhu
2026-06-24 22:59:05 +08:00
parent 3eb78d343a
commit e5b03c6601
8 changed files with 517 additions and 39 deletions

View File

@@ -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/)
})
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', () => {
const rendered = renderAiConversationHtml([
'### 图片材料',

View File

@@ -9,6 +9,8 @@ import {
resolveAiDocumentQueryIntent
} from '../src/utils/aiDocumentQueryModel.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'
@@ -68,6 +70,21 @@ test('AI document query intent detects approval document questions', () => {
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', () => {
const intent = resolveAiDocumentQueryIntent('我名下有哪些单据?', { today })
@@ -136,6 +153,66 @@ test('AI document query combines natural-language filters', () => {
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', () => {
const intent = resolveAiDocumentQueryIntent('我2月有哪些单据', { today })
const records = filterAiDocumentQueryRecords([
@@ -246,6 +323,28 @@ test('AI document query html cards render as trusted card markup', () => {
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', () => {
const rendered = renderAiConversationHtml([
'### 查询结果',

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
import {
markAiWorkbenchConversationDraftDeleted,
markAiWorkbenchConversationDocumentDeleted,
loadAiWorkbenchConversationHistory,
saveAiWorkbenchConversation
} 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)
})
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', () => {
const appShellScript = readFileSync(
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),