feat(web): AI 意图规划置信度阈值与动作策略细化

- 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 测试
This commit is contained in:
caoxiaozhu
2026-06-25 10:55:49 +08:00
parent 6b0756a55f
commit 59353308a2
12 changed files with 418 additions and 32 deletions

View File

@@ -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 = []) {

View File

@@ -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()

View File

@@ -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 || '保存草稿'

View File

@@ -43,7 +43,8 @@ export function resolveInlineTravelApplicationRequest(prompt = '') {
expenseType: 'travel',
expenseTypeLabel: '差旅费',
sourceText,
autoSubmit: /直接提交|提交申请|确认提交|提交审批/.test(compact)
autoSubmit: false,
requestedSubmit: /直接提交|提交申请|确认提交|提交审批/.test(compact)
}
}

View File

@@ -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
}

View File

@@ -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),

View File

@@ -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' }

View File

@@ -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 })
}