feat(web): AI 工作台命令意图解析与动作策略
- 新增 workbenchIntentFrameModel,解析删除/审批/查询/咨询政策等意图帧,含风险等级、目标模式(当前上下文/筛选候选)与日期归一化 - 新增 workbenchIntentActionPolicy,按意图帧路由下一步(直通/拦截批量/上下文确认/查询候选) - 新增 workbenchAiCommandIntentModel 与 useWorkbenchAiCommandIntents,识别草稿删除意图并解析最近草稿载荷生成引导 - usePersonalWorkbenchAiMode 接入命令意图处理,personal-workbench-ai-mode.css 适配 - 新增 command-intent-model/intent-frame-model 测试
This commit is contained in:
@@ -1450,6 +1450,31 @@
|
||||
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) {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
|
||||
@@ -38,6 +38,7 @@ import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js'
|
||||
import {
|
||||
isReimbursementCreationIntent
|
||||
} from './workbenchAiApplicationGateModel.js'
|
||||
import { useWorkbenchAiCommandIntents } from './useWorkbenchAiCommandIntents.js'
|
||||
import {
|
||||
buildRuleFallbackWorkbenchAiIntentPlan,
|
||||
normalizeWorkbenchAiIntentPlan,
|
||||
@@ -164,6 +165,24 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
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({
|
||||
aiAttachmentAssociationRuntime,
|
||||
conversationId,
|
||||
@@ -728,8 +747,7 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
return
|
||||
}
|
||||
|
||||
if (aiExpenseDraft.value && !isAiExpenseDraftComplete(aiExpenseDraft.value)) {
|
||||
expenseFlow.advanceAiExpenseDraft(cleanPrompt, files)
|
||||
if (commandIntents.handleInlineDraftDeletionIntent(cleanPrompt, entry)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -737,6 +755,11 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
return
|
||||
}
|
||||
|
||||
if (aiExpenseDraft.value && !isAiExpenseDraftComplete(aiExpenseDraft.value)) {
|
||||
expenseFlow.advanceAiExpenseDraft(cleanPrompt, files)
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldRequestWorkbenchAiIntentPlan(cleanPrompt)) {
|
||||
void executeModelPlannedWorkbenchIntent(cleanPrompt, entry, files)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
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