diff --git a/web/src/assets/styles/components/personal-workbench-ai-mode.css b/web/src/assets/styles/components/personal-workbench-ai-mode.css index 21c6696..2a32c1a 100644 --- a/web/src/assets/styles/components/personal-workbench-ai-mode.css +++ b/web/src/assets/styles/components/personal-workbench-ai-mode.css @@ -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; diff --git a/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js b/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js index 10cb2d3..dfb04c0 100644 --- a/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js +++ b/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js @@ -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 diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiCommandIntents.js b/web/src/composables/workbenchAiMode/useWorkbenchAiCommandIntents.js new file mode 100644 index 0000000..05be506 --- /dev/null +++ b/web/src/composables/workbenchAiMode/useWorkbenchAiCommandIntents.js @@ -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 + } +} diff --git a/web/src/composables/workbenchAiMode/workbenchAiCommandIntentModel.js b/web/src/composables/workbenchAiMode/workbenchAiCommandIntentModel.js new file mode 100644 index 0000000..edeb2d8 --- /dev/null +++ b/web/src/composables/workbenchAiMode/workbenchAiCommandIntentModel.js @@ -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 + } + }] + } +} diff --git a/web/src/composables/workbenchAiMode/workbenchIntentActionPolicy.js b/web/src/composables/workbenchAiMode/workbenchIntentActionPolicy.js new file mode 100644 index 0000000..1c1673b --- /dev/null +++ b/web/src/composables/workbenchAiMode/workbenchIntentActionPolicy.js @@ -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' } +} diff --git a/web/src/composables/workbenchAiMode/workbenchIntentFrameModel.js b/web/src/composables/workbenchAiMode/workbenchIntentFrameModel.js new file mode 100644 index 0000000..d16e30b --- /dev/null +++ b/web/src/composables/workbenchAiMode/workbenchIntentFrameModel.js @@ -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(/(?\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 }) + } +} diff --git a/web/tests/workbench-ai-command-intent-model.test.mjs b/web/tests/workbench-ai-command-intent-model.test.mjs new file mode 100644 index 0000000..c4f2571 --- /dev/null +++ b/web/tests/workbench-ai-command-intent-model.test.mjs @@ -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) +}) diff --git a/web/tests/workbench-intent-frame-model.test.mjs b/web/tests/workbench-intent-frame-model.test.mjs new file mode 100644 index 0000000..a773184 --- /dev/null +++ b/web/tests/workbench-intent-frame-model.test.mjs @@ -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, />进入详情确认删除 { + 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') +})