From 59353308a27dee592f22146081500a9ecbdaba9f Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Thu, 25 Jun 2026 10:55:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20AI=20=E6=84=8F=E5=9B=BE=E8=A7=84?= =?UTF-8?q?=E5=88=92=E7=BD=AE=E4=BF=A1=E5=BA=A6=E9=98=88=E5=80=BC=E4=B8=8E?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E7=AD=96=E7=95=A5=E7=BB=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workbenchAiIntentPlannerModel 新增 WORKBENCH_AI_INTENT_CONFIDENCE_THRESHOLD 与 isLowConfidenceTravelApplicationPlan,shouldRequestWorkbenchAiIntentPlan 增加业务关键词前置过滤 - resolveExecutableTravelApplicationPlan 区分 requestedSubmit 与提交确认(submitRequiresConfirmation),autoSubmit 不再直接置真 - workbenchIntentActionPolicy 改用 policyDecision 路由(need_confirmation/query_candidates),透传 riskLevel/requiresSelection/requiresConfirmation - workbenchIntentFrameModel 补充 query 动作识别,usePersonalWorkbenchAiMode/useWorkbenchAiActionRouter/useWorkbenchAiApplicationPreviewFlow 接入低置信度与确认流程 - 更新 intent-planner-model/intent-frame-model/application-gate-model/fast-preview 测试 --- .../usePersonalWorkbenchAiMode.js | 125 +++++++++++++++++- .../useWorkbenchAiActionRouter.js | 13 ++ .../useWorkbenchAiApplicationPreviewFlow.js | 13 +- .../workbenchAiApplicationGateModel.js | 3 +- .../workbenchAiIntentPlannerModel.js | 35 ++++- .../workbenchAiMessageModel.js | 6 + .../workbenchIntentActionPolicy.js | 16 ++- .../workbenchIntentFrameModel.js | 63 ++++++++- .../expense-application-fast-preview.test.mjs | 9 +- ...rkbench-ai-application-gate-model.test.mjs | 4 +- ...workbench-ai-intent-planner-model.test.mjs | 111 +++++++++++++++- .../workbench-intent-frame-model.test.mjs | 52 ++++++++ 12 files changed, 418 insertions(+), 32 deletions(-) diff --git a/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js b/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js index dfb04c0..494a041 100644 --- a/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js +++ b/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js @@ -3,11 +3,12 @@ import { useSystemState } from '../useSystemState.js' import { useToast } from '../useToast.js' import { useWorkbenchComposerDate } from '../useWorkbenchComposerDate.js' import { fetchSettings } from '../../services/settings.js' -import { calculateTravelReimbursement } from '../../services/reimbursements.js' +import { calculateTravelReimbursement, fetchExpenseClaimDetail } from '../../services/reimbursements.js' import { useApplicationPreviewEditor } from '../../views/scripts/useApplicationPreviewEditor.js' import { deleteAiWorkbenchConversation, loadAiWorkbenchConversationHistory, + markAiWorkbenchConversationDocumentDeleted, saveAiWorkbenchConversation } from '../../utils/aiWorkbenchConversationStore.js' import { renderAiConversationHtml } from '../../utils/aiConversationHtmlRenderer.js' @@ -41,6 +42,7 @@ import { import { useWorkbenchAiCommandIntents } from './useWorkbenchAiCommandIntents.js' import { buildRuleFallbackWorkbenchAiIntentPlan, + isLowConfidenceTravelApplicationPlan, normalizeWorkbenchAiIntentPlan, resolveExecutableTravelApplicationPlan, shouldRequestWorkbenchAiIntentPlan @@ -558,6 +560,54 @@ export function usePersonalWorkbenchAiMode(props, emit) { function renderInlineConversationHtml(content) { return renderAiConversationHtml(content) } + function isUnavailableDocumentDetailError(error) { + const message = String(error?.message || '').trim() + return /not\s*found|不存在|已删除|不可访问|无权|forbidden|404/i.test(message) + } + + function resolveDocumentDetailLookupClaimId(detailRequest = {}) { + return String(detailRequest.claimId || detailRequest.claim_id || '').trim() + } + + function markActiveAiDocumentDetailLinkDeleted(detailRequest = {}) { + const nextHistory = markAiWorkbenchConversationDocumentDeleted(currentUser.value || {}, { + claimId: detailRequest.claimId, + claim_id: detailRequest.claimId, + claimNo: detailRequest.claimNo, + claim_no: detailRequest.claimNo, + documentNo: detailRequest.documentNo, + document_no: detailRequest.documentNo, + id: detailRequest.id + }) + emit('conversation-history-change', nextHistory) + const activeConversation = nextHistory.find((item) => ( + String(item.id || item.conversationId || '').trim() === String(conversationId.value || '').trim() + )) + if (activeConversation?.messages?.length) { + conversationMessages.value = activeConversation.messages.map((message) => normalizeRuntimeMessage(message)) + scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) + } + } + + async function ensureAiDocumentDetailStillAvailable(detailRequest = {}) { + const claimId = resolveDocumentDetailLookupClaimId(detailRequest) + if (!claimId) { + return true + } + try { + await fetchExpenseClaimDetail(claimId) + return true + } catch (error) { + if (!isUnavailableDocumentDetailError(error)) { + console.warn('AI document detail availability check failed, continuing navigation:', error) + return true + } + markActiveAiDocumentDetailLinkDeleted(detailRequest) + toast('该单据已经删除或不可访问,已将这条历史入口标记为不可查看。') + return false + } + } + function buildInlinePromptText(rawPrompt, files = []) { const prompt = buildWorkbenchPromptText(rawPrompt) if (prompt) return prompt @@ -671,11 +721,70 @@ export function usePersonalWorkbenchAiMode(props, emit) { pendingMessageId: plannerPendingMessage?.id, ontologyFields: travelApplicationRequest.ontologyFields, autoSubmit: travelApplicationRequest.autoSubmit, - autoSaveDraft: travelApplicationRequest.autoSaveDraft + autoSaveDraft: travelApplicationRequest.autoSaveDraft, + requestedSubmit: travelApplicationRequest.requestedSubmit, + submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation } ) } + function startModelPlannedTravelApplicationConfirmation(travelApplicationRequest, plan, plannerPendingMessage) { + const confirmText = buildLowConfidenceTravelApplicationConfirmationText(travelApplicationRequest, plan) + const confirmAction = { + label: '确认发起出差申请', + description: '根据上面识别到的信息生成出差申请预览。', + icon: 'mdi mdi-check-circle-outline', + action_type: 'ai_application_confirm_intent', + payload: { + ontologyFields: travelApplicationRequest.ontologyFields, + sourceText: travelApplicationRequest.sourceText, + autoSubmit: travelApplicationRequest.autoSubmit, + autoSaveDraft: travelApplicationRequest.autoSaveDraft, + requestedSubmit: travelApplicationRequest.requestedSubmit, + submitRequiresConfirmation: travelApplicationRequest.submitRequiresConfirmation + } + } + replaceInlineMessage(plannerPendingMessage.id, createInlineMessage('assistant', confirmText, { + id: plannerPendingMessage.id, + suggestedActions: [confirmAction], + stewardPlan: { + streamStatus: 'completed', + thinkingEvents: resolveInlineThinkingEvents(plannerPendingMessage).map((item) => ({ ...item, status: 'completed' })) + } + })) + persistCurrentConversation() + scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) + } + + function buildLowConfidenceTravelApplicationConfirmationText(request, plan) { + const fields = request.ontologyFields || {} + const summaryParts = [] + if (fields.time_range) { + summaryParts.push(`时间:${fields.time_range}`) + } + if (fields.location) { + summaryParts.push(`地点:${fields.location}`) + } + if (fields.reason) { + summaryParts.push(`事由:${fields.reason}`) + } + if (fields.transport_mode) { + summaryParts.push(`交通:${fields.transport_mode}`) + } + const summary = summaryParts.length ? `\n\n${summaryParts.join(';')}` : '' + const confidenceNote = Number.isFinite(Number(plan?.confidence)) + ? `(模型识别置信度较低,约 ${Math.round(Number(plan.confidence) * 100)}%)` + : '(模型识别置信度较低)' + return [ + '### 需要确认:您是要发起出差申请吗?', + '', + `小财管家把这句话理解成了“发起差旅申请”${confidenceNote},为避免误操作,先请您确认。`, + summary, + '', + '点击下方「确认发起出差申请」即可继续;如果理解有误,请补充说明您的实际需求。' + ].filter(Boolean).join('\n') + } + async function executeModelPlannedWorkbenchIntent(cleanPrompt, entry = {}, files = []) { let intentPlan = null let modelPlan = null @@ -703,6 +812,10 @@ export function usePersonalWorkbenchAiMode(props, emit) { const travelApplicationRequest = resolveExecutableTravelApplicationPlan(intentPlan) if (travelApplicationRequest) { + if (isLowConfidenceTravelApplicationPlan(intentPlan)) { + startModelPlannedTravelApplicationConfirmation(travelApplicationRequest, intentPlan, plannerPendingMessage) + return + } startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage) return } @@ -722,7 +835,7 @@ export function usePersonalWorkbenchAiMode(props, emit) { void stewardFlow.requestInlineAssistantReply(cleanPrompt, entry, files, { pendingMessageId: plannerPendingMessage.id }) } - function handleAiAnswerMarkdownClick(event) { + async function handleAiAnswerMarkdownClick(event) { const target = event?.target const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]') if (!link) { @@ -735,7 +848,11 @@ export function usePersonalWorkbenchAiMode(props, emit) { } event.preventDefault() event.stopPropagation() - emit('open-document', buildAiDocumentDetailRequest(detailReference)) + const detailRequest = buildAiDocumentDetailRequest(detailReference) + if (!(await ensureAiDocumentDetailStillAvailable(detailRequest))) { + return + } + emit('open-document', detailRequest) } function startInlineConversation(prompt, entry = {}, files = []) { diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js b/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js index d50998b..254dac4 100644 --- a/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js +++ b/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js @@ -80,6 +80,19 @@ export function useWorkbenchAiActionRouter({ }) return } + if (actionType === 'ai_application_confirm_intent') { + aiExpenseDraft.value = null + void applicationFlow.startAiApplicationPreview('travel', '差旅费', String(actionPayload.sourceText || '').trim(), { + userMessage: String(actionPayload.sourceText || '').trim() || '确认发起出差申请', + pushUserMessage: true, + ontologyFields: actionPayload.ontologyFields || {}, + autoSubmit: Boolean(actionPayload.autoSubmit), + autoSaveDraft: Boolean(actionPayload.autoSaveDraft), + requestedSubmit: Boolean(actionPayload.requestedSubmit), + submitRequiresConfirmation: Boolean(actionPayload.submitRequiresConfirmation) + }) + return + } if (actionType === 'open_application_detail') { const claimNo = String(actionPayload.claim_no || actionPayload.claimNo || '').trim() const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim() diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js b/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js index f89de40..7023fae 100644 --- a/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js +++ b/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js @@ -195,6 +195,9 @@ export function useWorkbenchAiApplicationPreviewFlow({ if (normalized.validationIssues?.length || normalized.missingFields?.length) { return buildApplicationPreviewFooterMessage(normalized) } + if (message?.submitRequiresConfirmation) { + return '已识别到您希望直接提交。系统不会自动提交申请,请先核对申请核对表;确认无误后,点击下方“直接提交”按钮再进入提交确认。' + } return '申请核对表已补齐,费用测算已同步。您仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。' } @@ -547,6 +550,8 @@ export function useWorkbenchAiApplicationPreviewFlow({ id: pendingMessage.id, applicationPreview: preview, suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview), + requestedSubmit: Boolean(options.requestedSubmit), + submitRequiresConfirmation: Boolean(options.submitRequiresConfirmation), stewardPlan: { streamStatus: 'completed', thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage)) @@ -554,13 +559,7 @@ export function useWorkbenchAiApplicationPreviewFlow({ text: content }) replaceInlineMessage(pendingMessage.id, previewMessage) - if (options.autoSubmit && normalizeApplicationPreview(preview).readyToSubmit) { - await executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, previewMessage, { - confirmed: true, - skipUserMessage: true, - userText: options.userMessage || '直接提交' - }) - } else if (options.autoSaveDraft) { + if (options.autoSaveDraft) { await executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SAVE_DRAFT, previewMessage, { skipUserMessage: true, userText: options.userMessage || '保存草稿' diff --git a/web/src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js b/web/src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js index 0d24565..df42844 100644 --- a/web/src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js +++ b/web/src/composables/workbenchAiMode/workbenchAiApplicationGateModel.js @@ -43,7 +43,8 @@ export function resolveInlineTravelApplicationRequest(prompt = '') { expenseType: 'travel', expenseTypeLabel: '差旅费', sourceText, - autoSubmit: /直接提交|提交申请|确认提交|提交审批/.test(compact) + autoSubmit: false, + requestedSubmit: /直接提交|提交申请|确认提交|提交审批/.test(compact) } } diff --git a/web/src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js b/web/src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js index a6fafc3..4b921ad 100644 --- a/web/src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js +++ b/web/src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js @@ -3,6 +3,8 @@ import { resolveInlineTravelApplicationRequest } from './workbenchAiApplicationG export const WORKBENCH_AI_INTENT_SOURCE_MODEL = 'llm_function_call' export const WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK = 'rule_fallback' +export const WORKBENCH_AI_INTENT_CONFIDENCE_THRESHOLD = 0.6 + export const WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW = 'build_application_preview' export const WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS = 'validate_required_fields' export const WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT = 'save_application_draft' @@ -233,7 +235,7 @@ export function buildRuleFallbackWorkbenchAiIntentPlan(prompt = '') { if (!request) { return null } - const requestedAction = request.autoSubmit ? 'submit' : normalizePromptAction(prompt) + const requestedAction = request.requestedSubmit ? 'submit' : normalizePromptAction(prompt) return { source: WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK, intent: TRAVEL_APPLICATION_INTENT, @@ -255,9 +257,16 @@ export function shouldRequestWorkbenchAiIntentPlan(prompt = '') { if (compact.length < 2 || /^[\d\s.,,。::;;!?!?-]+$/.test(compact)) { return false } + if (!WORKBENCH_AI_BUSINESS_KEYWORD_PATTERN.test(compact)) { + return false + } return true } +const WORKBENCH_AI_BUSINESS_KEYWORD_PATTERN = ( + /报销|报账|出差|差旅|申请|审批|审核|报销单|申请单|草稿|删除|提交|保存|查|看|找|列出|发起|新建|创建|驳回|退回|通过|多少|标准|制度|规则|政策/ +) + export function resolveExecutableTravelApplicationPlan(plan = null) { if (!plan || plan.intent !== TRAVEL_APPLICATION_INTENT) { return null @@ -265,12 +274,32 @@ export function resolveExecutableTravelApplicationPlan(plan = null) { if (!Array.isArray(plan.steps) || !plan.steps.includes(WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW)) { return null } + const requestedSubmit = plan.steps.includes(WORKBENCH_AI_STEP_SUBMIT_APPLICATION) return { expenseType: 'travel', expenseTypeLabel: '差旅费', sourceText: String(plan.sourceText || '').trim(), ontologyFields: normalizeOntologyFields(plan.ontologyFields || {}), - autoSubmit: plan.steps.includes(WORKBENCH_AI_STEP_SUBMIT_APPLICATION), - autoSaveDraft: plan.steps.includes(WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT) + autoSubmit: false, + autoSaveDraft: plan.steps.includes(WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT), + requestedSubmit, + submitRequiresConfirmation: requestedSubmit } } + +export function isLowConfidenceTravelApplicationPlan(plan = null) { + if (!plan || plan.intent !== TRAVEL_APPLICATION_INTENT) { + return false + } + if (plan.source === WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK) { + return false + } + if (plan.requestedAction === 'submit' || plan.requestedAction === 'save_draft') { + return false + } + const confidence = Number(plan.confidence) + if (!Number.isFinite(confidence)) { + return false + } + return confidence < WORKBENCH_AI_INTENT_CONFIDENCE_THRESHOLD +} diff --git a/web/src/composables/workbenchAiMode/workbenchAiMessageModel.js b/web/src/composables/workbenchAiMode/workbenchAiMessageModel.js index d1bb26b..49198da 100644 --- a/web/src/composables/workbenchAiMode/workbenchAiMessageModel.js +++ b/web/src/composables/workbenchAiMode/workbenchAiMessageModel.js @@ -150,6 +150,8 @@ export function createWorkbenchAiMessageRuntime() { ? hydrateInlineAttachmentAssociationSuggestedActions(suggestedActions, normalizedContent) : suggestedActions, applicationPreview: options.applicationPreview || null, + requestedSubmit: Boolean(options.requestedSubmit), + submitRequiresConfirmation: Boolean(options.submitRequiresConfirmation), draftPayload: options.draftPayload || null, attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(options.attachmentAssociationJob || null), linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(options.linkedReimbursementDraftJob || null), @@ -167,6 +169,8 @@ export function createWorkbenchAiMessageRuntime() { stewardPlan: message.stewardPlan || null, suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [], applicationPreview: message.applicationPreview || null, + requestedSubmit: Boolean(message.requestedSubmit), + submitRequiresConfirmation: Boolean(message.submitRequiresConfirmation), draftPayload: message.draftPayload || null, attachmentAssociationJob: message.attachmentAssociationJob || null, linkedReimbursementDraftJob: message.linkedReimbursementDraftJob || null, @@ -185,6 +189,8 @@ export function createWorkbenchAiMessageRuntime() { stewardPlan: message.stewardPlan || null, suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [], applicationPreview: message.applicationPreview || null, + requestedSubmit: Boolean(message.requestedSubmit), + submitRequiresConfirmation: Boolean(message.submitRequiresConfirmation), draftPayload: message.draftPayload || null, attachmentAssociationJob: normalizeInlineAttachmentAssociationJob(message.attachmentAssociationJob || null), linkedReimbursementDraftJob: normalizeInlineLinkedReimbursementDraftJob(message.linkedReimbursementDraftJob || null), diff --git a/web/src/composables/workbenchAiMode/workbenchIntentActionPolicy.js b/web/src/composables/workbenchAiMode/workbenchIntentActionPolicy.js index 1c1673b..0cbac37 100644 --- a/web/src/composables/workbenchAiMode/workbenchIntentActionPolicy.js +++ b/web/src/composables/workbenchAiMode/workbenchIntentActionPolicy.js @@ -10,13 +10,21 @@ export function resolveWorkbenchIntentActionRoute(frame = null) { 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.policyDecision === 'need_confirmation') { + return { + nextStep: 'open_context_confirm', + riskLevel: frame.riskLevel, + requiresSelection: Boolean(frame.requiresSelection), + requiresConfirmation: Boolean(frame.requiresConfirmation) + } } - if (frame.targetMode === 'filtered_candidates' && QUERY_CANDIDATE_ACTIONS.has(frame.action)) { + if (frame.policyDecision === 'query_candidates' && QUERY_CANDIDATE_ACTIONS.has(frame.action)) { return { nextStep: 'query_candidates', - queryPrompt: frame.normalizedQuery || '' + queryPrompt: frame.normalizedQuery || '', + riskLevel: frame.riskLevel, + requiresSelection: Boolean(frame.requiresSelection), + requiresConfirmation: Boolean(frame.requiresConfirmation) } } return { nextStep: 'pass_through' } diff --git a/web/src/composables/workbenchAiMode/workbenchIntentFrameModel.js b/web/src/composables/workbenchAiMode/workbenchIntentFrameModel.js index d16e30b..7a7a0ce 100644 --- a/web/src/composables/workbenchAiMode/workbenchIntentFrameModel.js +++ b/web/src/composables/workbenchAiMode/workbenchIntentFrameModel.js @@ -29,15 +29,15 @@ function resolveAction(text = '') { if (/审核|审批|通过|处理待办|去审批|去审核/.test(text)) { return 'approve' } + if (/查|看|列出|有哪些|找一下/.test(text)) { + return 'query' + } if (/新建|发起|创建|我要报销|申请/.test(text)) { return 'create' } if (/补充|修改|改成|填入/.test(text)) { return 'update' } - if (/查|看|列出|有哪些|找一下/.test(text)) { - return 'query' - } return null } @@ -146,6 +146,59 @@ function resolveSafetyLevel(action = '') { return 'read_only' } +function resolveRiskLevel(action = '') { + if (CONFIRM_REQUIRED_ACTIONS.has(action)) { + return 'high' + } + if (['create', 'update'].includes(action)) { + return 'low' + } + return 'read_only' +} + +function resolveExecutionPolicy({ action, targetMode, riskLevel }) { + if (action === 'ask_policy') { + return { + requiresCandidateSearch: false, + requiresSelection: false, + requiresConfirmation: false, + executionMode: 'answer_only', + policyDecision: 'answer_only' + } + } + + const highRisk = riskLevel === 'high' + const requiresCandidateSearch = targetMode === 'filtered_candidates' + const requiresSelection = highRisk && requiresCandidateSearch + const requiresConfirmation = highRisk + + if (requiresCandidateSearch) { + return { + requiresCandidateSearch, + requiresSelection, + requiresConfirmation, + executionMode: 'query_candidates', + policyDecision: 'query_candidates' + } + } + if (requiresConfirmation) { + return { + requiresCandidateSearch, + requiresSelection, + requiresConfirmation, + executionMode: 'need_confirmation', + policyDecision: 'need_confirmation' + } + } + return { + requiresCandidateSearch, + requiresSelection, + requiresConfirmation, + executionMode: 'pass_through', + policyDecision: 'pass_through' + } +} + function buildNormalizedQuery({ action, objectType, filters }) { if ((objectType === 'draft' || action === 'delete') && !filters.timeRange?.label && !filters.risk?.label && !filters.documentType) { return '我的草稿单据' @@ -199,12 +252,16 @@ export function resolveWorkbenchIntentFrame(prompt = '', options = {}) { const targetMode = action === 'ask_policy' ? 'ambiguous' : resolveTargetMode(action, text, filters) + const riskLevel = resolveRiskLevel(action) + const policy = resolveExecutionPolicy({ action, targetMode, riskLevel }) return { action, objectType, filters, targetMode, safetyLevel, + riskLevel, + ...policy, confidence: 0.86, normalizedQuery: action === 'ask_policy' ? String(prompt || '').trim() : buildNormalizedQuery({ action, objectType, filters }) } diff --git a/web/tests/expense-application-fast-preview.test.mjs b/web/tests/expense-application-fast-preview.test.mjs index ec108a5..7630a94 100644 --- a/web/tests/expense-application-fast-preview.test.mjs +++ b/web/tests/expense-application-fast-preview.test.mjs @@ -252,7 +252,7 @@ test('application intent uses local preview instead of immediate orchestrator ca assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/) }) -test('AI workbench routes compact travel direct-submit planner into application preview auto submit', () => { +test('AI workbench routes compact travel direct-submit planner into preview with confirmation required', () => { assert.match(personalWorkbenchAiModeScript, /buildRuleFallbackWorkbenchAiIntentPlan/) assert.match(personalWorkbenchAiModeScript, /normalizeWorkbenchAiIntentPlan/) assert.match(personalWorkbenchAiModeScript, /resolveExecutableTravelApplicationPlan/) @@ -262,7 +262,7 @@ test('AI workbench routes compact travel direct-submit planner into application ) assert.match( personalWorkbenchAiModeScript, - /modelPlan = await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files\)/ + /modelPlan = await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files,\s*\{/ ) assert.match( personalWorkbenchAiModeScript, @@ -272,8 +272,11 @@ test('AI workbench routes compact travel direct-submit planner into application personalWorkbenchAiModeScript, /applicationFlow\.startAiApplicationPreview\([\s\S]*travelApplicationRequest\.expenseType[\s\S]*travelApplicationRequest\.expenseTypeLabel[\s\S]*travelApplicationRequest\.sourceText[\s\S]*ontologyFields:\s*travelApplicationRequest\.ontologyFields[\s\S]*autoSubmit:\s*travelApplicationRequest\.autoSubmit/ ) + assert.match(personalWorkbenchAiModeScript, /requestedSubmit:\s*travelApplicationRequest\.requestedSubmit/) + assert.match(personalWorkbenchAiModeScript, /submitRequiresConfirmation:\s*travelApplicationRequest\.submitRequiresConfirmation/) assert.doesNotMatch(personalWorkbenchAiModeScript, /fallbackIntentPlan/) - assert.match(applicationPreviewFlowScript, /if \(options\.autoSubmit && normalizeApplicationPreview\(preview\)\.readyToSubmit\)/) + assert.doesNotMatch(applicationPreviewFlowScript, /if \(options\.autoSubmit && normalizeApplicationPreview\(preview\)\.readyToSubmit\)/) + assert.match(applicationPreviewFlowScript, /submitRequiresConfirmation:\s*Boolean\(options\.submitRequiresConfirmation\)/) assert.match(applicationPreviewFlowScript, /confirmed:\s*true/) assert.match(applicationPreviewFlowScript, /skipUserMessage:\s*true/) }) diff --git a/web/tests/workbench-ai-application-gate-model.test.mjs b/web/tests/workbench-ai-application-gate-model.test.mjs index ddaab02..64a1ff9 100644 --- a/web/tests/workbench-ai-application-gate-model.test.mjs +++ b/web/tests/workbench-ai-application-gate-model.test.mjs @@ -41,9 +41,11 @@ test('workbench application gate detects compact travel application direct submi expenseType: 'travel', expenseTypeLabel: '差旅费', sourceText: '去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交', - autoSubmit: true + autoSubmit: false, + requestedSubmit: true }) assert.equal(resolveInlineTravelApplicationRequest('去上海出差,辅助国网仿生产服务器部署,交通火车')?.autoSubmit, false) + assert.equal(resolveInlineTravelApplicationRequest('去上海出差,辅助国网仿生产服务器部署,交通火车')?.requestedSubmit, false) assert.equal(resolveInlineTravelApplicationRequest('帮我查询上海差旅标准'), null) }) diff --git a/web/tests/workbench-ai-intent-planner-model.test.mjs b/web/tests/workbench-ai-intent-planner-model.test.mjs index 7cf08cc..45b7566 100644 --- a/web/tests/workbench-ai-intent-planner-model.test.mjs +++ b/web/tests/workbench-ai-intent-planner-model.test.mjs @@ -12,11 +12,13 @@ import { WORKBENCH_AI_STEP_SUBMIT_APPLICATION, WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS, buildRuleFallbackWorkbenchAiIntentPlan, + isLowConfidenceTravelApplicationPlan, normalizeWorkbenchAiIntentPlan, resolveExecutableTravelApplicationPlan, shouldRequestWorkbenchAiIntentPlan } from '../src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js' import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js' +import { createWorkbenchAiMessageRuntime } from '../src/composables/workbenchAiMode/workbenchAiMessageModel.js' const personalWorkbenchAiModeScript = readFileSync( fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)), @@ -144,7 +146,9 @@ test('workbench AI intent planner detects compact travel save-draft variant befo sourceText: prompt, ontologyFields: {}, autoSubmit: false, - autoSaveDraft: true + autoSaveDraft: true, + requestedSubmit: false, + submitRequiresConfirmation: false }) }) @@ -178,7 +182,9 @@ test('workbench AI intent planner turns model fields and action into executable transport_mode: '火车' }, autoSubmit: false, - autoSaveDraft: true + autoSaveDraft: true, + requestedSubmit: false, + submitRequiresConfirmation: false }) }) @@ -220,7 +226,9 @@ test('workbench AI intent planner turns single application candidate flow into e transport_mode: '火车' }, autoSubmit: false, - autoSaveDraft: false + autoSaveDraft: false, + requestedSubmit: false, + submitRequiresConfirmation: false }) }) @@ -245,7 +253,7 @@ test('workbench AI application preview prefers model ontology fields over local assert.equal(preview.fields.transportMode, '火车') }) -test('workbench AI intent planner rejects policy question and resolves executable application request', () => { +test('workbench AI intent planner rejects policy question and requires confirmation for direct-submit request', () => { assert.equal(buildRuleFallbackWorkbenchAiIntentPlan('帮我查询上海差旅标准'), null) const request = resolveExecutableTravelApplicationPlan( @@ -257,11 +265,30 @@ test('workbench AI intent planner rejects policy question and resolves executabl expenseTypeLabel: '差旅费', sourceText: '去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交', ontologyFields: {}, - autoSubmit: true, - autoSaveDraft: false + autoSubmit: false, + autoSaveDraft: false, + requestedSubmit: true, + submitRequiresConfirmation: true }) }) +test('workbench AI message runtime persists direct-submit confirmation metadata', () => { + const { createInlineMessage, normalizeRuntimeMessage, serializeRuntimeMessage } = createWorkbenchAiMessageRuntime() + const message = createInlineMessage('assistant', '申请核对表', { + applicationPreview: { fields: { location: '上海' } }, + requestedSubmit: true, + submitRequiresConfirmation: true + }) + + const serialized = serializeRuntimeMessage(message) + assert.equal(serialized.requestedSubmit, true) + assert.equal(serialized.submitRequiresConfirmation, true) + + const normalized = normalizeRuntimeMessage(serialized) + assert.equal(normalized.requestedSubmit, true) + assert.equal(normalized.submitRequiresConfirmation, true) +}) + test('workbench AI mode asks steward model plan before fallback execution', () => { assert.match(stewardFlowScript, /async function resolveInlineExecutionPlan\(prompt, entry = \{\}, files = \[\], options = \{\}\)/) assert.match(stewardFlowScript, /fetchStewardPlan\(planRequest/) @@ -274,8 +301,11 @@ test('workbench AI mode asks steward model plan before fallback execution', () = assert.match(personalWorkbenchAiModeScript, /resolveExecutableTravelApplicationPlan\(intentPlan\)/) assert.doesNotMatch(personalWorkbenchAiModeScript, /fallbackIntentPlan/) assert.match(personalWorkbenchAiModeScript, /autoSaveDraft:\s*travelApplicationRequest\.autoSaveDraft/) + assert.match(personalWorkbenchAiModeScript, /requestedSubmit:\s*travelApplicationRequest\.requestedSubmit/) + assert.match(personalWorkbenchAiModeScript, /submitRequiresConfirmation:\s*travelApplicationRequest\.submitRequiresConfirmation/) assert.match(personalWorkbenchAiModeScript, /ontologyFields:\s*travelApplicationRequest\.ontologyFields/) assert.match(applicationPreviewFlowScript, /options\.autoSaveDraft/) + assert.doesNotMatch(applicationPreviewFlowScript, /options\.autoSubmit && normalizeApplicationPreview\(preview\)\.readyToSubmit/) assert.match(applicationPreviewFlowScript, /ontologyFields:\s*options\.ontologyFields/) assert.match(applicationPreviewFlowScript, /executeInlineApplicationPreviewAction\(AI_APPLICATION_ACTION_SAVE_DRAFT/) assert.match(applicationPreviewFlowScript, /buildInlineApplicationPreview\([\s\S]*ontologyFields:\s*options\.ontologyFields/) @@ -329,3 +359,72 @@ test('workbench AI mode reuses planning pending message for regular steward repl assert.match(stewardFlowScript, /const reusablePendingMessageId = String\(options\.pendingMessageId \|\| ''\)\.trim\(\)/) assert.match(stewardFlowScript, /reusablePendingMessageId \? replaceInlineMessage\(reusablePendingMessageId, pendingMessage\) : conversationMessages\.value\.push\(pendingMessage\)/) }) + +test('isLowConfidenceTravelApplicationPlan gates preview behind confirmation when model confidence is low', () => { + const lowConfidenceModelPlan = normalizeWorkbenchAiIntentPlan({ + planning_source: 'llm_function_call', + tasks: [{ + task_type: 'expense_application', + assigned_agent: 'application_assistant', + requested_action: 'preview', + confidence: 0.4, + ontology_fields: { location: '上海', reason: '部署' } + }] + }, { prompt: '上海那边好像要过去一趟搞部署' }) + + assert.equal(lowConfidenceModelPlan.source, WORKBENCH_AI_INTENT_SOURCE_MODEL) + assert.equal(isLowConfidenceTravelApplicationPlan(lowConfidenceModelPlan), true) + + const highConfidenceModelPlan = normalizeWorkbenchAiIntentPlan({ + planning_source: 'llm_function_call', + tasks: [{ + task_type: 'expense_application', + assigned_agent: 'application_assistant', + requested_action: 'preview', + confidence: 0.9, + ontology_fields: { location: '上海' } + }] + }, { prompt: '去上海出差' }) + + assert.equal(isLowConfidenceTravelApplicationPlan(highConfidenceModelPlan), false) + + const ruleFallbackPlan = buildRuleFallbackWorkbenchAiIntentPlan('去上海出差,辅助部署,交通火车') + assert.equal(ruleFallbackPlan.source, WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK) + assert.equal(isLowConfidenceTravelApplicationPlan(ruleFallbackPlan), false) + + const explicitSubmitLowConfidence = normalizeWorkbenchAiIntentPlan({ + planning_source: 'llm_function_call', + tasks: [{ + task_type: 'expense_application', + assigned_agent: 'application_assistant', + requested_action: 'submit', + confidence: 0.3, + ontology_fields: { location: '上海' } + }] + }, { prompt: '直接提交' }) + + assert.equal(explicitSubmitLowConfidence.requestedAction, 'submit') + assert.equal(isLowConfidenceTravelApplicationPlan(explicitSubmitLowConfidence), false) + + assert.equal(isLowConfidenceTravelApplicationPlan(null), false) +}) + +test('workbench AI mode routes low confidence travel application plan to confirmation prompt', () => { + assert.match(personalWorkbenchAiModeScript, /isLowConfidenceTravelApplicationPlan\(intentPlan\)/) + assert.match(personalWorkbenchAiModeScript, /function startModelPlannedTravelApplicationConfirmation\(/) + assert.match(personalWorkbenchAiModeScript, /action_type:\s*'ai_application_confirm_intent'/) + assert.match(personalWorkbenchAiModeScript, /需要确认:您是要发起出差申请吗/) +}) + +test('shouldRequestWorkbenchAiIntentPlan skips chitchat and only triggers on business keywords', () => { + assert.equal(shouldRequestWorkbenchAiIntentPlan('你好'), false) + assert.equal(shouldRequestWorkbenchAiIntentPlan('谢谢'), false) + assert.equal(shouldRequestWorkbenchAiIntentPlan('嗯'), false) + assert.equal(shouldRequestWorkbenchAiIntentPlan('ok'), false) + assert.equal(shouldRequestWorkbenchAiIntentPlan('1'), false) + + assert.equal(shouldRequestWorkbenchAiIntentPlan('帮我查报销'), true) + assert.equal(shouldRequestWorkbenchAiIntentPlan('我要出差'), true) + assert.equal(shouldRequestWorkbenchAiIntentPlan('帮我查询上海差旅标准'), true) + assert.equal(shouldRequestWorkbenchAiIntentPlan('删除3天前的草稿'), true) +}) diff --git a/web/tests/workbench-intent-frame-model.test.mjs b/web/tests/workbench-intent-frame-model.test.mjs index a773184..078737d 100644 --- a/web/tests/workbench-intent-frame-model.test.mjs +++ b/web/tests/workbench-intent-frame-model.test.mjs @@ -22,6 +22,12 @@ test('workbench intent frame resolves contextual draft deletion as confirm-only assert.equal(frame?.objectType, 'draft') assert.equal(frame?.targetMode, 'current_context') assert.equal(frame?.safetyLevel, 'confirm_required') + assert.equal(frame?.riskLevel, 'high') + assert.equal(frame?.requiresCandidateSearch, false) + assert.equal(frame?.requiresSelection, false) + assert.equal(frame?.requiresConfirmation, true) + assert.equal(frame?.executionMode, 'need_confirmation') + assert.equal(frame?.policyDecision, 'need_confirmation') assert.equal(frame?.filters.status?.label, '草稿') assert.equal(frame?.normalizedQuery, '我的草稿单据') }) @@ -34,11 +40,19 @@ test('workbench intent frame sends filtered draft deletion to candidate search', assert.equal(frame?.objectType, 'draft') assert.equal(frame?.targetMode, 'filtered_candidates') assert.equal(frame?.safetyLevel, 'confirm_required') + assert.equal(frame?.riskLevel, 'high') + assert.equal(frame?.requiresCandidateSearch, true) + assert.equal(frame?.requiresSelection, true) + assert.equal(frame?.requiresConfirmation, true) + assert.equal(frame?.executionMode, 'query_candidates') + assert.equal(frame?.policyDecision, 'query_candidates') 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天前 草稿单据') + assert.equal(route.requiresSelection, true) + assert.equal(route.requiresConfirmation, true) }) test('workbench intent frame preserves application draft deletion filters', () => { @@ -52,6 +66,11 @@ test('workbench intent frame preserves application draft deletion filters', () = assert.equal(frame?.filters.status?.label, '草稿') assert.equal(frame?.targetMode, 'filtered_candidates') assert.equal(frame?.safetyLevel, 'confirm_required') + assert.equal(frame?.riskLevel, 'high') + assert.equal(frame?.requiresCandidateSearch, true) + assert.equal(frame?.requiresSelection, true) + assert.equal(frame?.requiresConfirmation, true) + assert.equal(frame?.executionMode, 'query_candidates') assert.equal(route.queryPrompt, '我的 草稿 申请单') assert.equal(queryIntent?.source, 'mine') assert.equal(queryIntent?.documentType, 'application') @@ -99,11 +118,19 @@ test('workbench intent frame resolves compliant no-risk approval request as filt assert.equal(frame?.objectType, 'application') assert.equal(frame?.targetMode, 'filtered_candidates') assert.equal(frame?.safetyLevel, 'confirm_required') + assert.equal(frame?.riskLevel, 'high') + assert.equal(frame?.requiresCandidateSearch, true) + assert.equal(frame?.requiresSelection, true) + assert.equal(frame?.requiresConfirmation, true) + assert.equal(frame?.executionMode, 'query_candidates') + assert.equal(frame?.policyDecision, 'query_candidates') 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, '待我审核 无风险 申请单') + assert.equal(route.requiresSelection, true) + assert.equal(route.requiresConfirmation, true) }) test('workbench intent frame keeps approval policy questions out of document actions', () => { @@ -112,5 +139,30 @@ test('workbench intent frame keeps approval policy questions out of document act assert.equal(frame?.action, 'ask_policy') assert.equal(frame?.safetyLevel, 'read_only') + assert.equal(frame?.riskLevel, 'read_only') + assert.equal(frame?.requiresCandidateSearch, false) + assert.equal(frame?.requiresSelection, false) + assert.equal(frame?.requiresConfirmation, false) + assert.equal(frame?.executionMode, 'answer_only') + assert.equal(frame?.policyDecision, 'answer_only') assert.equal(route.nextStep, 'pass_through') }) + +test('workbench intent frame keeps rules as policy guardrails instead of executable side effects', () => { + const highRiskFrame = resolveWorkbenchIntentFrame('审核合规没有风险的申请', { today }) + const highRiskRoute = resolveWorkbenchIntentActionRoute(highRiskFrame) + const queryFrame = resolveWorkbenchIntentFrame('查3天前的申请单', { today }) + const queryRoute = resolveWorkbenchIntentActionRoute(queryFrame) + + assert.equal(highRiskFrame?.policyDecision, 'query_candidates') + assert.equal(highRiskFrame?.requiresSelection, true) + assert.equal(highRiskFrame?.requiresConfirmation, true) + assert.notEqual(highRiskRoute.nextStep, 'execute_allowed') + + assert.equal(queryFrame?.riskLevel, 'read_only') + assert.equal(queryFrame?.requiresCandidateSearch, true) + assert.equal(queryFrame?.requiresSelection, false) + assert.equal(queryFrame?.requiresConfirmation, false) + assert.equal(queryFrame?.policyDecision, 'query_candidates') + assert.equal(queryRoute.nextStep, 'query_candidates') +})