Files
X-Financial/web/tests/ai-document-query-model.test.mjs
caoxiaozhu e5b03c6601 feat(web): 文档查询意图补充风险过滤与 X 天前范围
- aiDocumentQueryIntent 新增风险等级过滤(无/高/中/低/有风险)与 N 天前日期范围解析
- aiDocumentQueryModel/aiConversationHtmlRenderer 渲染适配风险过滤标签
- useWorkbenchAiDocumentQueryFlow/aiWorkbenchConversationStore 会话流转适配命令意图
- 更新 ai-document-query-model/ai-conversation-html-renderer/assistant-session-draft-delete 测试
2026-06-24 22:59:05 +08:00

364 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildAiDocumentQueryConditionSummary,
buildAiDocumentQueryMessage,
filterAiDocumentQueryRecords,
mergeAiDocumentQueryPayloads,
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'
const claims = [
{
id: 'claim-1',
claim_no: 'CL-20260221001',
document_type_code: 'reimbursement',
expense_type: 'travel',
status: 'submitted',
reason: '上海出差报销',
employee_name: '曹小筑',
department_name: '交付部',
location: '上海',
occurred_at: '2026-02-21T09:00:00Z',
updated_at: '2026-02-22T10:00:00Z',
amount: 1200
},
{
id: 'app-1',
claim_no: 'AP-20260220001',
document_type_code: 'application',
expense_type: 'travel_application',
status: 'approved',
reason: '辅助国网仿生产服务器部署',
employee_name: '曹小筑',
department_name: '交付部',
location: '上海',
occurred_at: '2026-02-20T09:00:00Z',
updated_at: '2026-02-20T10:00:00Z',
amount: 3000
},
{
id: 'claim-2',
claim_no: 'CL-20260305001',
document_type_code: 'reimbursement',
expense_type: 'office',
status: 'draft',
reason: '办公用品采购',
occurred_at: '2026-03-05T09:00:00Z',
amount: 500
}
]
test('AI document query intent detects my document list questions', () => {
const intent = resolveAiDocumentQueryIntent('我现在有哪些单据?', { today })
assert.equal(intent?.source, 'accessible')
assert.equal(intent?.documentType, 'all')
assert.equal(intent?.sourceLabel, '我可见的单据')
})
test('AI document query intent detects approval document questions', () => {
const intent = resolveAiDocumentQueryIntent('我有哪些审核单', { today })
assert.equal(intent?.source, 'approval')
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 })
assert.equal(intent?.source, 'mine')
assert.equal(intent?.sourceLabel, '我的单据')
})
test('AI document query merges own and approval records for generic document list questions', () => {
const intent = resolveAiDocumentQueryIntent('我现在有哪些单据?', { today })
const approvalClaims = [{
id: 'approval-1',
claim_no: 'CL-APPROVAL-001',
document_type_code: 'reimbursement',
expense_type: 'meeting',
status: 'pending',
approval_stage: '直属领导审批',
reason: '待我审核的会议费',
employee_name: '李文静',
department_name: '市场部',
occurred_at: '2026-02-22T09:00:00Z',
amount: 880
}]
const merged = mergeAiDocumentQueryPayloads(claims, { items: approvalClaims, querySource: 'approval' }, [claims[0]])
const records = filterAiDocumentQueryRecords(merged, intent)
assert.equal(intent?.source, 'accessible')
assert.deepEqual(
records.map((record) => record.documentNo),
['CL-20260305001', 'CL-APPROVAL-001', 'CL-20260221001', 'AP-20260220001']
)
})
test('AI document query filters by month and document type', () => {
const intent = resolveAiDocumentQueryIntent('我2月有哪些申请单', { today })
const records = filterAiDocumentQueryRecords(claims, intent)
assert.equal(intent?.documentType, 'application')
assert.equal(intent?.timeRange?.start, '2026-02-01')
assert.equal(intent?.timeRange?.end, '2026-02-28')
assert.deepEqual(records.map((record) => record.documentNo), ['AP-20260220001'])
})
test('AI document query filters by single day and document type', () => {
const intent = resolveAiDocumentQueryIntent('2月20日发生的申请单有哪些', { today })
const records = filterAiDocumentQueryRecords(claims, intent)
assert.equal(intent?.timeRange?.start, '2026-02-20')
assert.equal(intent?.timeRange?.end, '2026-02-20')
assert.deepEqual(records.map((record) => record.documentNo), ['AP-20260220001'])
})
test('AI document query combines natural-language filters', () => {
const intent = resolveAiDocumentQueryIntent('查一下2月审批中的差旅报销单金额超过1000上海相关的单据有哪些', { today })
const records = filterAiDocumentQueryRecords(claims, intent)
assert.equal(intent?.documentType, 'reimbursement')
assert.equal(intent?.timeRange?.label, '2026年2月')
assert.equal(intent?.statusFilter?.label, '审批中')
assert.equal(intent?.expenseTypeFilter?.label, '差旅费')
assert.equal(intent?.keywordFilter?.label, '上海')
assert.equal(intent?.amountFilter?.min, 1000)
assert.deepEqual(records.map((record) => record.documentNo), ['CL-20260221001'])
assert.match(buildAiDocumentQueryConditionSummary(intent), /状态:审批中/)
assert.match(buildAiDocumentQueryConditionSummary(intent), /费用类型:差旅费/)
assert.match(buildAiDocumentQueryConditionSummary(intent), /关键词:上海/)
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([
...claims,
{
id: 'no-date',
claim_no: 'CL-NO-DATE',
document_type_code: 'reimbursement',
expense_type: 'travel',
status: 'submitted',
reason: '缺少业务日期的单据',
amount: 800
}
], intent)
assert.deepEqual(records.map((record) => record.documentNo), ['CL-20260221001', 'AP-20260220001'])
})
test('AI document query message renders html document cards with detail actions', () => {
const intent = resolveAiDocumentQueryIntent('我2月有哪些单据', { today })
const message = buildAiDocumentQueryMessage(intent, claims)
assert.match(message, /### 已查询到相关单据/)
assert.match(message, /<!-- ai-trusted-html:start -->/)
assert.match(message, /<section class="ai-document-query-summary" aria-label="单据查询范围">/)
assert.match(message, /<span class="ai-document-query-summary__label">查询范围<\/span>/)
assert.match(message, /<strong class="ai-document-query-summary__scope">/)
assert.match(message, /<span class="ai-document-query-summary__count">/)
assert.match(message, /<section class="ai-document-card-list" aria-label="单据查询结果">/)
// 申请单 app-1 状态为 approved → is-success 语义类
assert.match(message, /<article class="ai-document-card ai-document-card--application is-success" aria-label="单据详情">/)
assert.match(message, /<header class="ai-document-card__head">/)
assert.match(message, /<span class="ai-document-card__status">已审批<\/span>/)
assert.match(message, /<strong class="ai-document-card__reason">差旅费用申请<\/strong>/)
assert.match(message, /<div class="ai-document-card__summary">/)
assert.match(message, /<div class="ai-document-card__details">/)
assert.match(message, /<span class="ai-document-card__label">日期<\/span>/)
assert.match(message, /2026-02-20/)
assert.match(message, /<span class="ai-document-card__label">预计金额<\/span>/)
assert.match(message, /<strong class="ai-document-card__value ai-document-card__amount">¥3,000\.00<\/strong>/)
assert.doesNotMatch(message, /<span class="ai-document-card__label">当前节点<\/span>/)
assert.match(message, /<span class="ai-document-card__label">单据类型<\/span>/)
assert.match(message, /<strong class="ai-document-card__value">申请单 · 差旅费用申请<\/strong>/)
assert.match(message, /<span class="ai-document-card__label">地点<\/span>/)
assert.match(message, /上海/)
assert.match(message, /<span class="ai-document-card__label">事由<\/span>/)
assert.match(message, /辅助国网仿生产服务器部署/)
assert.match(message, /<span class="ai-document-card__label">申请人<\/span>/)
assert.match(message, /<strong class="ai-document-card__value">曹小筑 · 交付部<\/strong>/)
assert.match(message, /<strong class="ai-document-card__value ai-document-card__number">AP-20260220001<\/strong>/)
assert.match(message, /<div class="ai-document-card__field ai-document-card__field--action">/)
assert.doesNotMatch(message, /ai-document-card__meta/)
assert.doesNotMatch(message, /ai-document-card__meta-item/)
assert.match(message, /href="#ai-open-document-detail:claim_id%3Dapp-1%26claim_no%3DAP-20260220001"/)
// 报销单 claim-1 状态为 submitted → is-pending 语义类
assert.match(message, /<article class="ai-document-card ai-document-card--reimbursement is-pending" aria-label="单据详情">/)
assert.match(message, /<span class="ai-document-card__status">审批中<\/span>/)
assert.match(message, /href="#ai-open-document-detail:claim_id%3Dclaim-1%26claim_no%3DCL-20260221001"/)
assert.doesNotMatch(message, />submitted</)
assert.doesNotMatch(message, /\| 单据编号 \|/)
assert.doesNotMatch(message, /^> /m)
assert.doesNotMatch(message, /\*\*查询范围\*\*/)
})
test('AI document query highlights approval-task cards and shows pending review status', () => {
const intent = resolveAiDocumentQueryIntent('我有哪些审核单', { today })
const message = buildAiDocumentQueryMessage(intent, [{
id: 'approval-1',
claim_no: 'AP-APPROVAL-001',
document_type_code: 'application',
expense_type: 'travel_application',
status: 'submitted',
approval_stage: '直属领导审批',
reason: '参加相关残联会议',
employee_name: '曹笑竹',
department_name: '技术部',
location: '上海',
occurred_at: '2026-02-20T09:00:00Z',
amount: 2120
}])
assert.match(message, /ai-document-card--application ai-document-card--approval-task is-pending/)
assert.match(message, /<span class="ai-document-card__status">待审批<\/span>/)
assert.match(message, /<span class="ai-document-card__label">预计金额<\/span>/)
assert.match(message, /<strong class="ai-document-card__value ai-document-card__amount">¥2,120\.00<\/strong>/)
assert.doesNotMatch(message, /<span class="ai-document-card__label">当前节点<\/span>/)
assert.match(message, /<span class="ai-document-card__label">单据类型<\/span>/)
assert.match(message, /<strong class="ai-document-card__value">申请单 · 差旅费用申请<\/strong>/)
assert.match(message, /href="#ai-open-document-detail:claim_id%3Dapproval-1%26claim_no%3DAP-APPROVAL-001"/)
})
test('AI document query html cards render as trusted card markup', () => {
const intent = resolveAiDocumentQueryIntent('我2月有哪些单据', { today })
const rendered = renderAiConversationHtml(buildAiDocumentQueryMessage(intent, claims))
assert.match(rendered, /<h3 class="ai-html-title">已查询到相关单据<\/h3>/)
assert.match(rendered, /<section class="ai-document-query-summary" aria-label="单据查询范围">/)
assert.match(rendered, /<section class="ai-document-card-list" aria-label="单据查询结果">/)
assert.match(rendered, /<article class="ai-document-card ai-document-card--application is-success" aria-label="单据详情">/)
assert.match(rendered, /class="ai-document-card__head"/)
assert.match(rendered, /class="ai-document-card__details"/)
assert.match(rendered, /class="ai-document-card__field"/)
assert.match(rendered, /class="ai-document-card__label"/)
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-document ai-document-card__action"/)
assert.match(rendered, /href="#ai-open-document-detail:claim_id%3Dclaim-1%26claim_no%3DCL-20260221001"/)
assert.doesNotMatch(rendered, /ai-document-card__meta/)
assert.doesNotMatch(rendered, /&lt;section class=&quot;ai-document-card-list/)
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([
'### 查询结果',
'',
'<!-- ai-trusted-html:start -->',
'<section class="ai-document-card-list" aria-label="单据查询结果" onclick="alert(1)">',
'<article class="ai-document-card"><script>alert(1)</script></article>',
'</section>',
'<!-- ai-trusted-html:end -->'
].join('\n'))
assert.match(rendered, /<h3 class="ai-html-title">查询结果<\/h3>/)
assert.doesNotMatch(rendered, /ai-document-card-list/)
assert.doesNotMatch(rendered, /<script>/)
assert.doesNotMatch(rendered, /onclick=/)
})