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:
@@ -2,6 +2,7 @@ import { nextTick } from 'vue'
|
||||
|
||||
import {
|
||||
buildAiDocumentQueryConditionSummary,
|
||||
buildAiDocumentQueryThinkingEvents,
|
||||
buildAiDocumentQueryMessage,
|
||||
filterAiDocumentQueryRecords,
|
||||
mergeAiDocumentQueryPayloads,
|
||||
@@ -100,33 +101,20 @@ export function useWorkbenchAiDocumentQueryFlow({
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
async function handleAiDocumentQueryIntent(prompt, pendingMessage) {
|
||||
async function handleAiDocumentQueryIntent(prompt, pendingMessage, options = {}) {
|
||||
const intent = resolveAiDocumentQueryIntent(prompt)
|
||||
if (!intent) {
|
||||
return false
|
||||
}
|
||||
|
||||
const conditionSummary = buildAiDocumentQueryConditionSummary(intent)
|
||||
let thinkingEvents = [
|
||||
{
|
||||
eventId: 'document-query-parse',
|
||||
title: '解析自然语言筛选条件',
|
||||
content: `正在从您的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}。`,
|
||||
status: 'running'
|
||||
},
|
||||
{
|
||||
eventId: 'document-query-fetch',
|
||||
title: '查询业务单据接口',
|
||||
content: resolveAiDocumentQueryFetchPendingText(intent),
|
||||
status: 'pending'
|
||||
},
|
||||
{
|
||||
eventId: 'document-query-filter',
|
||||
title: '组合筛选单据',
|
||||
content: '等待接口返回后,再按已识别条件做二次筛选。',
|
||||
status: 'pending'
|
||||
}
|
||||
]
|
||||
let thinkingEvents = buildAiDocumentQueryThinkingEvents(intent, {
|
||||
commandFrame: options.commandFrame
|
||||
}).map((event) => (
|
||||
event.eventId === 'document-query-fetch'
|
||||
? { ...event, content: resolveAiDocumentQueryFetchPendingText(intent) }
|
||||
: event
|
||||
))
|
||||
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
||||
await waitForAiDocumentQueryStep()
|
||||
|
||||
@@ -163,7 +151,9 @@ export function useWorkbenchAiDocumentQueryFlow({
|
||||
await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
|
||||
await waitForAiDocumentQueryStep()
|
||||
|
||||
const finalMessageText = buildAiDocumentQueryMessage(intent, payload)
|
||||
const finalMessageText = buildAiDocumentQueryMessage(intent, payload, {
|
||||
commandFrame: options.commandFrame
|
||||
})
|
||||
thinkingEvents = completeAiDocumentQueryEvent(
|
||||
thinkingEvents,
|
||||
'document-query-filter',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-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 = '') {
|
||||
return String(value)
|
||||
@@ -39,6 +40,10 @@ function isDocumentDetailHref(href = '') {
|
||||
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 = '') {
|
||||
const value = String(src || '').trim()
|
||||
if (/^(https?:\/\/|blob:|\/)/i.test(value)) {
|
||||
@@ -63,6 +68,17 @@ function renderLinkHtml(label = '', href = '') {
|
||||
'</span>'
|
||||
].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)) {
|
||||
return [
|
||||
`<a href="${sanitizedHref}"`,
|
||||
|
||||
@@ -23,6 +23,14 @@ const EXPENSE_TYPE_FILTERS = [
|
||||
{ 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 = {}) {
|
||||
return parseDate(options.today) || new Date()
|
||||
}
|
||||
@@ -82,6 +90,15 @@ function resolveTimeRange(prompt, options = {}) {
|
||||
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)) {
|
||||
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
|
||||
}
|
||||
|
||||
function resolveRiskFilter(prompt) {
|
||||
const text = compactText(prompt)
|
||||
return RISK_FILTERS.find((item) => item.pattern.test(text)) || null
|
||||
}
|
||||
|
||||
function normalizeAmountText(value = '') {
|
||||
const matched = compactText(value).replace(/,/g, '').match(/-?\d+(?:\.\d+)?/)
|
||||
if (!matched) {
|
||||
@@ -191,13 +213,13 @@ function resolveKeywordFilter(prompt) {
|
||||
|
||||
function resolveSource(prompt) {
|
||||
const text = compactText(prompt)
|
||||
if (/审核单|审批单|待审|待审核|待审批|我审批|我审核/.test(text)) {
|
||||
if (/审核单|审批单|待办|待审|待审核|待审批|我审批|我审核|我要审批|我要审核|去审批|去审核|处理审批|处理审核/.test(text)) {
|
||||
return {
|
||||
source: 'approval',
|
||||
sourceLabel: '待我审核的单据'
|
||||
}
|
||||
}
|
||||
if (/我名下|我发起|我提交|我创建|我的申请|我的报销/.test(text)) {
|
||||
if (/我名下|我发起|我提交|我创建|我的/.test(text)) {
|
||||
return {
|
||||
source: 'mine',
|
||||
sourceLabel: '我的单据'
|
||||
@@ -211,7 +233,13 @@ function resolveSource(prompt) {
|
||||
|
||||
export function resolveAiDocumentQueryIntent(prompt, options = {}) {
|
||||
const text = compactText(prompt)
|
||||
if (!text || !/(单据|单子|申请单|报销单|审核单|审批单|待审|待审批|待审核)/.test(text)) {
|
||||
if (!text || !/(单据|单子|申请单|报销单|审核单|审批单|待办|待审|待审批|待审核|我要审核|我要审批|去审核|去审批|处理审核|处理审批|草稿)/.test(text)) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
/(标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗)/.test(text) &&
|
||||
!/(单据|单子|申请单|报销单|审核单|审批单|待办|待审|待审批|待审核)/.test(text)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (/(发起|创建|新增|填写|生成|申请出差|我要报销|提交).*(单据|申请单|报销单)?/.test(text)) {
|
||||
@@ -223,6 +251,7 @@ export function resolveAiDocumentQueryIntent(prompt, options = {}) {
|
||||
const expenseTypeFilter = resolveExpenseTypeFilter(text)
|
||||
const keywordFilter = resolveKeywordFilter(prompt)
|
||||
const amountFilter = resolveAmountFilter(text)
|
||||
const riskFilter = resolveRiskFilter(text)
|
||||
return {
|
||||
...source,
|
||||
documentType,
|
||||
@@ -235,6 +264,7 @@ export function resolveAiDocumentQueryIntent(prompt, options = {}) {
|
||||
statusFilter,
|
||||
expenseTypeFilter,
|
||||
keywordFilter,
|
||||
amountFilter
|
||||
amountFilter,
|
||||
riskFilter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { extractExpenseClaimItems } from '../services/reimbursements.js'
|
||||
import { buildAiDocumentDetailHref } from './aiDocumentDetailReference.js'
|
||||
import { isApplicationDocumentNo } from './documentClassification.js'
|
||||
import { compactText, normalizeDateText, normalizeText, parseDate } from './aiDocumentQueryText.js'
|
||||
import { filterActionableRiskFlags, isRiskSummaryWithRisk, normalizeRiskFlagTone } from './riskFlags.js'
|
||||
|
||||
export { resolveAiDocumentQueryIntent } from './aiDocumentQueryIntent.js'
|
||||
|
||||
@@ -36,6 +37,26 @@ const TYPE_LABELS = {
|
||||
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', {
|
||||
style: 'currency',
|
||||
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 = {}) {
|
||||
return resolveRecordQuerySource(claim, intent) === 'approval'
|
||||
}
|
||||
@@ -381,6 +450,7 @@ function normalizeRecord(claim = {}, intent = {}) {
|
||||
const rawStatusLabel = resolveStatusLabel(claim)
|
||||
const querySource = resolveRecordQuerySource(claim, intent)
|
||||
const isApprovalTask = isApprovalTaskClaim(claim, intent)
|
||||
const riskMeta = resolveRecordRiskMeta(claim)
|
||||
const statusLabel = isApprovalTask && isPendingApprovalStatus(statusKey, rawStatusLabel)
|
||||
? '待审批'
|
||||
: rawStatusLabel
|
||||
@@ -402,6 +472,9 @@ function normalizeRecord(claim = {}, intent = {}) {
|
||||
statusKey,
|
||||
statusLabel,
|
||||
statusTone: resolveStatusTone(statusLabel),
|
||||
hasRisk: riskMeta.hasRisk,
|
||||
riskTone: riskMeta.riskTone,
|
||||
riskLabel: riskMeta.riskLabel,
|
||||
querySource,
|
||||
isApprovalTask,
|
||||
reason,
|
||||
@@ -417,7 +490,8 @@ function normalizeRecord(claim = {}, intent = {}) {
|
||||
departmentLabel,
|
||||
locationLabel,
|
||||
typeLabel,
|
||||
statusLabel
|
||||
statusLabel,
|
||||
riskMeta.riskLabel
|
||||
].join(' '))
|
||||
}
|
||||
}
|
||||
@@ -459,6 +533,19 @@ function matchesAmountFilter(record = {}, amountFilter = null) {
|
||||
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 = {}) {
|
||||
const rows = extractExpenseClaimItems(claimsPayload)
|
||||
.map((claim) => normalizeRecord(claim, intent))
|
||||
@@ -472,6 +559,7 @@ export function filterAiDocumentQueryRecords(claimsPayload, intent = {}) {
|
||||
.filter((record) => matchesExpenseTypeFilter(record, intent?.expenseTypeFilter))
|
||||
.filter((record) => matchesKeywordFilter(record, intent?.keywordFilter))
|
||||
.filter((record) => matchesAmountFilter(record, intent?.amountFilter))
|
||||
.filter((record) => matchesRiskFilter(record, intent?.riskFilter))
|
||||
.sort((left, right) => toTimestamp(right.dateKey) - toTimestamp(left.dateKey))
|
||||
|
||||
return rows
|
||||
@@ -492,7 +580,40 @@ function buildDocumentCardFieldHtml(label = '', value = '', options = {}) {
|
||||
].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 typeClass = record.documentType === 'application' ? 'application' : 'reimbursement'
|
||||
const approvalTaskClass = record.isApprovalTask ? ' ai-document-card--approval-task' : ''
|
||||
@@ -503,12 +624,14 @@ function buildDocumentCardHtml(record = {}) {
|
||||
const ownerText = [record.ownerLabel, record.departmentLabel]
|
||||
.filter((item) => item && item !== '未显示')
|
||||
.join(' · ') || '未显示'
|
||||
const detailActionLabel = resolveCommandDetailActionLabel(options.commandFrame)
|
||||
const summaryHtml = [
|
||||
buildDocumentCardFieldHtml('日期', record.time || '待补充'),
|
||||
buildDocumentCardFieldHtml(amountLabel, record.amountLabel, { valueClass: 'ai-document-card__amount' })
|
||||
].join('')
|
||||
const detailsHtml = [
|
||||
buildDocumentCardFieldHtml('地点', record.locationLabel || '待补充'),
|
||||
buildDocumentCardFieldHtml('风险', record.riskLabel || '无风险'),
|
||||
buildDocumentCardFieldHtml('单据编号', record.documentNo || '未编号单据', { valueClass: 'ai-document-card__number' }),
|
||||
buildDocumentCardFieldHtml('事由', record.reason || '待补充'),
|
||||
buildDocumentCardFieldHtml('申请人', ownerText),
|
||||
@@ -516,7 +639,7 @@ function buildDocumentCardHtml(record = {}) {
|
||||
'<div class="ai-document-card__field ai-document-card__field--action">',
|
||||
'<span class="ai-document-card__label">操作</span>',
|
||||
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>',
|
||||
'</div>'
|
||||
].join(''),
|
||||
@@ -556,11 +679,13 @@ function buildDocumentQuerySummaryHtml(scopeText = '', totalCount = 0, visibleCo
|
||||
|
||||
function buildDocumentCardsHtml(records = [], options = {}) {
|
||||
const querySummaryHtml = options.querySummaryHtml || ''
|
||||
const commandGuidanceHtml = buildCommandConfirmationGuidanceHtml(options.commandFrame)
|
||||
return [
|
||||
'<!-- ai-trusted-html:start -->',
|
||||
commandGuidanceHtml,
|
||||
querySummaryHtml,
|
||||
'<section class="ai-document-card-list" aria-label="单据查询结果">',
|
||||
...records.map((record) => buildDocumentCardHtml(record)),
|
||||
...records.map((record) => buildDocumentCardHtml(record, options)),
|
||||
'</section>',
|
||||
'<!-- ai-trusted-html:end -->'
|
||||
].join('\n')
|
||||
@@ -574,7 +699,8 @@ function buildQueryScopeText(intent = {}) {
|
||||
intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '',
|
||||
intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.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(' / ')
|
||||
}
|
||||
|
||||
@@ -586,12 +712,52 @@ export function buildAiDocumentQueryConditionSummary(intent = {}) {
|
||||
intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '',
|
||||
intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.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)
|
||||
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 visibleRecords = records.slice(0, DOCUMENT_QUERY_LIMIT)
|
||||
const scopeText = buildQueryScopeText(intent)
|
||||
@@ -610,7 +776,8 @@ export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = []) {
|
||||
'### 已查询到相关单据',
|
||||
'',
|
||||
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 APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-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 DOCUMENT_DETAIL_MARKDOWN_LINK_RE = /\[([^\]]+)\]\((#ai-open-document-detail:[^)]+)\)/g
|
||||
|
||||
function safeString(value) {
|
||||
return String(value || '').trim()
|
||||
@@ -25,12 +28,12 @@ function collectDeletedDraftIdentifiers(payload = {}) {
|
||||
].map((item) => normalizeIdentifier(item)).filter(Boolean))
|
||||
}
|
||||
|
||||
function decodeApplicationDetailHref(href = '') {
|
||||
function decodeDetailHref(href = '', prefix = '') {
|
||||
const value = safeString(href)
|
||||
if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) {
|
||||
if (!value.startsWith(prefix)) {
|
||||
return []
|
||||
}
|
||||
const encodedReference = value.slice(APPLICATION_DETAIL_HREF_PREFIX.length)
|
||||
const encodedReference = value.slice(prefix.length)
|
||||
if (!encodedReference) {
|
||||
return []
|
||||
}
|
||||
@@ -54,10 +57,22 @@ function decodeApplicationDetailHref(href = '') {
|
||||
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()) {
|
||||
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 = '') {
|
||||
const value = safeString(href)
|
||||
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)}`
|
||||
}
|
||||
|
||||
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()) {
|
||||
let changed = false
|
||||
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 }
|
||||
}
|
||||
|
||||
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()) {
|
||||
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
return [
|
||||
@@ -98,7 +148,8 @@ function actionMatchesDeletedDraft(action = {}, identifiers = new Set()) {
|
||||
function markSuggestedActionsDeleted(actions = [], identifiers = new Set()) {
|
||||
let changed = false
|
||||
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
|
||||
}
|
||||
if (!actionMatchesDeletedDraft(action, identifiers)) {
|
||||
@@ -111,7 +162,9 @@ function markSuggestedActionsDeleted(actions = [], identifiers = new Set()) {
|
||||
description: '草稿单据已经删除,请重新再次申请。',
|
||||
icon: 'mdi mdi-trash-can-outline',
|
||||
disabled: true,
|
||||
action_type: 'deleted_application_detail'
|
||||
action_type: actionType === 'open_document_detail'
|
||||
? 'deleted_document_detail'
|
||||
: 'deleted_application_detail'
|
||||
}
|
||||
})
|
||||
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()) {
|
||||
return messages.some((message) => {
|
||||
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 = {}) {
|
||||
const identity = safeString(user.username || user.email || user.name || 'anonymous')
|
||||
return `${STORAGE_KEY_PREFIX}:${identity || 'anonymous'}`
|
||||
@@ -329,3 +404,46 @@ export function markAiWorkbenchConversationDraftDeleted(user = {}, payload = {})
|
||||
writeStoredList(user, nextList)
|
||||
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/)
|
||||
})
|
||||
|
||||
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([
|
||||
'### 图片材料',
|
||||
|
||||
@@ -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([
|
||||
'### 查询结果',
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user