feat(ai): issue verified application preview decisions

This commit is contained in:
caoxiaozhu
2026-07-14 14:37:53 +08:00
parent a662cfe6c3
commit 5b24630710
32 changed files with 1976 additions and 217 deletions

View File

@@ -13,6 +13,8 @@ import { fetchExpenseClaims } from '../../services/reimbursements.js'
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT,
createAiApplicationRequestId,
registerAiApplicationPreviewDecision,
runAiApplicationPreviewAction
} from '../../services/aiApplicationPreviewActions.js'
import {
@@ -374,9 +376,20 @@ export function useWorkbenchAiApplicationPreviewFlow({
return false
}
const normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview)
let normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview)
const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT
const userText = String(options.userText || (isSubmit ? '直接提交' : '保存草稿')).trim()
const actionRequestId = String(
normalizedPreview.decisionActionRequestId || createAiApplicationRequestId('action')
).trim()
if (normalizedPreview.decisionId) {
normalizedPreview = normalizeApplicationPreview({
...normalizedPreview,
decisionActionRequestId: actionRequestId
})
targetMessage.applicationPreview = normalizedPreview
persistCurrentConversation()
}
if (isSubmit && !normalizedPreview.readyToSubmit) {
if (!options.skipUserMessage) {
@@ -454,12 +467,27 @@ export function useWorkbenchAiApplicationPreviewFlow({
applicationPreview: normalizedPreview,
currentUser: currentUser.value || {},
conversationId: conversationId.value,
draftPayload: targetMessage.draftPayload || options.draftPayload || null
draftPayload: targetMessage.draftPayload || options.draftPayload || null,
requestId: actionRequestId
})
const draftPayload = extractInlineApplicationDraftPayload(payload)
if (draftPayload) {
targetMessage.draftPayload = draftPayload
}
const nextDecisionId = String(payload?.result?.decision_id || '').trim()
targetMessage.applicationPreview = normalizeApplicationPreview({
...(targetMessage.applicationPreview || normalizedPreview),
...(nextDecisionId
? {
decisionId: nextDecisionId,
decisionExpiresAt: String(payload?.result?.decision_expires_at || '').trim(),
decisionTrackingStatus: 'registered'
}
: {
decisionTrackingStatus: normalizedPreview.decisionId ? 'consumed' : 'legacy'
}),
decisionActionRequestId: ''
})
targetMessage.suggestedActions = []
const detailActions = buildInlineApplicationDetailAction(draftPayload)
const nextTaskAction = buildApplicationPreviewNextTaskAction(targetMessage)
@@ -598,11 +626,26 @@ export function useWorkbenchAiApplicationPreviewFlow({
ontologyFields: options.ontologyFields
})
)
const content = buildLocalApplicationPreviewMessage(preview)
let registeredPreview = preview
try {
registeredPreview = await registerAiApplicationPreviewDecision({
applicationPreview: preview,
conversationId: conversationId.value,
message: previewSourceText,
requestId: createAiApplicationRequestId('preview')
})
} catch (error) {
console.warn('Application preview decision registration failed:', error)
registeredPreview = normalizeApplicationPreview({
...preview,
decisionTrackingStatus: 'degraded'
})
}
const content = buildLocalApplicationPreviewMessage(registeredPreview)
const previewMessage = createInlineMessage('assistant', content, {
id: pendingMessage.id,
applicationPreview: preview,
suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview),
applicationPreview: registeredPreview,
suggestedActions: buildInlineApplicationPreviewSuggestedActions(registeredPreview),
requestedSubmit: Boolean(options.requestedSubmit),
submitRequiresConfirmation: Boolean(options.submitRequiresConfirmation),
stewardRemainingTasks: Array.isArray(options.stewardRemainingTasks) ? options.stewardRemainingTasks : [],

View File

@@ -8,6 +8,54 @@ import {
export const AI_APPLICATION_ACTION_SAVE_DRAFT = 'ai_application_save_draft'
export const AI_APPLICATION_ACTION_SUBMIT = 'ai_application_submit'
export function createAiApplicationRequestId(prefix = 'application') {
const normalizedPrefix = normalizeText(prefix) || 'application'
if (typeof globalThis.crypto?.randomUUID === 'function') {
return `${normalizedPrefix}:${globalThis.crypto.randomUUID()}`
}
return `${normalizedPrefix}:${Date.now()}:${Math.random().toString(16).slice(2)}`
}
export function resolveAiApplicationPreviewDecisionId(preview = {}) {
return normalizeText(preview?.decisionId || preview?.decision_id)
}
export async function registerAiApplicationPreviewDecision({
applicationPreview,
conversationId = '',
message = '',
requestId = ''
} = {}, options = {}) {
const normalizedPreview = normalizeApplicationPreview(applicationPreview || {})
const issueRequestId = normalizeText(requestId) || createAiApplicationRequestId('preview')
const payload = await apiRequest('/reimbursements/application-previews', {
method: 'POST',
body: JSON.stringify({
message: normalizeText(message),
conversation_id: normalizeText(conversationId) || null,
request_id: issueRequestId
}),
timeoutMs: 30000,
timeoutMessage: '申请预览校验超时,已切换为普通核对模式。',
...options
})
const serverPreview = payload?.application_preview && typeof payload.application_preview === 'object'
? payload.application_preview
: {}
return normalizeApplicationPreview({
...normalizedPreview,
...serverPreview,
fields: {
...(normalizedPreview.fields || {}),
...(serverPreview.fields || {})
},
decisionId: normalizeText(payload?.decision_id || serverPreview.decisionId),
decisionSource: normalizeText(payload?.decision_source || serverPreview.decisionSource),
decisionExpiresAt: normalizeText(payload?.expires_at || serverPreview.decisionExpiresAt),
decisionTrackingStatus: 'registered'
})
}
function normalizeText(value) {
return String(value || '').trim()
}
@@ -56,7 +104,8 @@ export function buildAiApplicationPreviewActionPayload({
applicationPreview,
currentUser = {},
conversationId = '',
draftPayload = null
draftPayload = null,
requestId = ''
} = {}) {
const normalizedPreview = normalizeApplicationPreview(applicationPreview || {})
const message = buildAiApplicationPreviewActionText(actionType, normalizedPreview)
@@ -72,6 +121,8 @@ export function buildAiApplicationPreviewActionPayload({
: []
const draftClaimId = normalizeText(draftPayload?.claim_id || draftPayload?.claimId)
const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT
const decisionId = resolveAiApplicationPreviewDecisionId(normalizedPreview)
const actionRequestId = normalizeText(requestId || normalizedPreview.decisionActionRequestId)
const applicationEditableFields = Array.isArray(normalizedPreview.editableFields)
? normalizedPreview.editableFields.map((field) => normalizeText(field)).filter(Boolean)
: []
@@ -80,6 +131,9 @@ export function buildAiApplicationPreviewActionPayload({
source: 'user_message',
user_id: username,
conversation_id: normalizeText(conversationId) || null,
action_type: isSubmit ? 'submit' : 'save_draft',
...(decisionId ? { decision_id: decisionId } : {}),
...(decisionId && actionRequestId ? { request_id: actionRequestId } : {}),
message,
context_json: {
role_codes: roleCodes,

View File

@@ -257,6 +257,15 @@ function normalizeMessage(message = {}) {
content: safeString(message.content),
pending: false,
feedback: safeString(message.feedback),
text: safeString(message.text || message.content),
applicationPreview: message.applicationPreview && typeof message.applicationPreview === 'object'
? message.applicationPreview
: null,
draftPayload: message.draftPayload && typeof message.draftPayload === 'object'
? message.draftPayload
: null,
requestedSubmit: Boolean(message.requestedSubmit),
submitRequiresConfirmation: Boolean(message.submitRequiresConfirmation),
stewardPlan: message.stewardPlan && typeof message.stewardPlan === 'object'
? {
...message.stewardPlan,

View File

@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT,
registerAiApplicationPreviewDecision,
runAiApplicationPreviewAction
} from '../src/services/aiApplicationPreviewActions.js'
@@ -34,6 +35,7 @@ async function testSubmitActionUsesFastPreviewEndpoint() {
await runAiApplicationPreviewAction({
actionType: AI_APPLICATION_ACTION_SUBMIT,
applicationPreview: {
decisionId: 'decision-fast-submit',
fields: {
applicationType: '差旅费用申请',
time: '2026-07-01 至 2026-07-03',
@@ -55,7 +57,8 @@ async function testSubmitActionUsesFastPreviewEndpoint() {
}
},
currentUser: { username: 'zhangsan@example.com', name: '张三' },
conversationId: 'conversation-fast-submit'
conversationId: 'conversation-fast-submit',
requestId: 'action-fast-submit'
})
assert.equal(capturedUrl, '/api/v1/reimbursements/application-preview-action')
@@ -63,6 +66,9 @@ async function testSubmitActionUsesFastPreviewEndpoint() {
const body = JSON.parse(capturedOptions.body)
assert.equal(body.context_json.session_type, 'application')
assert.equal(body.context_json.application_stage, 'expense_application')
assert.equal(body.action_type, 'submit')
assert.equal(body.decision_id, 'decision-fast-submit')
assert.equal(body.request_id, 'action-fast-submit')
assert.equal(body.context_json.application_preview.fields.transportMode, '火车')
assert.deepEqual(body.context_json.application_preview.aiDecisionFeedback.changedFields, [
{
@@ -110,6 +116,59 @@ async function testSaveDraftActionUsesFastPreviewEndpoint() {
assert.equal(body.context_json.application_action, 'save_draft')
assert.equal(body.context_json.application_save_mode, true)
assert.equal(body.context_json.application_stage, 'expense_application')
assert.equal(body.action_type, 'save_draft')
assert.equal('decision_id' in body, false)
assert.equal('request_id' in body, false)
}
async function testRegistrationUsesServerCanonicalPreview() {
let capturedUrl = ''
let capturedOptions = null
global.fetch = async (url, options) => {
capturedUrl = String(url)
capturedOptions = options
return {
ok: true,
async json() {
return {
decision_id: 'decision-issued-1',
decision_source: 'rule',
expires_at: '2026-07-14T10:30:00Z',
application_preview: {
fields: {
reason: '服务端规范事由',
amount: '1800元'
}
}
}
}
}
}
const preview = await registerAiApplicationPreviewDecision({
applicationPreview: {
fields: {
reason: '客户端事由',
location: '上海'
}
},
conversationId: 'conversation-issued-1',
message: '去上海做客户现场实施',
requestId: 'preview-request-1'
})
assert.equal(capturedUrl, '/api/v1/reimbursements/application-previews')
assert.equal(capturedOptions.method, 'POST')
assert.deepEqual(JSON.parse(capturedOptions.body), {
message: '去上海做客户现场实施',
conversation_id: 'conversation-issued-1',
request_id: 'preview-request-1'
})
assert.equal(preview.decisionId, 'decision-issued-1')
assert.equal(preview.decisionSource, 'rule')
assert.equal(preview.fields.reason, '服务端规范事由')
assert.equal(preview.fields.location, '上海')
assert.equal(preview.decisionTrackingStatus, 'registered')
}
async function testEditDraftActionCarriesClaimAndEditableFields() {
@@ -167,6 +226,7 @@ async function testEditDraftActionCarriesClaimAndEditableFields() {
async function run() {
await testSubmitActionUsesFastPreviewEndpoint()
await testSaveDraftActionUsesFastPreviewEndpoint()
await testRegistrationUsesServerCanonicalPreview()
await testEditDraftActionCarriesClaimAndEditableFields()
console.log('ai-application-preview-actions tests passed')
}

View File

@@ -101,3 +101,45 @@ test('AI workbench conversation store preserves stewardRemainingTasks on message
const persistedMessage = conversation.messages.find((m) => m.id === 'a1')
assert.deepEqual(persistedMessage.stewardRemainingTasks, remainingTasks)
})
test('AI workbench conversation store preserves preview decision and retry context', () => {
installLocalStorageMock()
const user = { username: 'preview-user' }
const applicationPreview = {
decisionId: 'decision-preview-1',
decisionActionRequestId: 'action-preview-1',
fields: { reason: '客户现场实施' },
aiDecisionFeedback: {
schemaVersion: 1,
revision: 1,
changedFields: [
{ fieldKey: 'reason', suggestedValue: '客户拜访', finalValue: '客户现场实施' }
]
}
}
saveAiWorkbenchConversation(user, {
id: 'conv-preview-decision',
updatedAt: Date.now(),
messages: [
{ id: 'u1', role: 'user', content: '发起出差申请' },
{
id: 'a1',
role: 'assistant',
content: '申请核对表',
text: '申请核对表',
applicationPreview,
draftPayload: { claim_id: 'claim-preview-1' },
requestedSubmit: true,
submitRequiresConfirmation: true
}
]
})
const history = loadAiWorkbenchConversationHistory(user)
const restored = history[0].messages.find((message) => message.id === 'a1')
assert.deepEqual(restored.applicationPreview, applicationPreview)
assert.equal(restored.draftPayload.claim_id, 'claim-preview-1')
assert.equal(restored.requestedSubmit, true)
assert.equal(restored.submitRequiresConfirmation, true)
})

View File

@@ -4,6 +4,7 @@ import { trackApplicationPreviewFieldEdit } from '../src/utils/expenseApplicatio
function testTracksFirstExplicitEdit() {
const previous = {
decisionId: 'decision-feedback-1',
fields: {
reason: '客户拜访',
location: '上海'
@@ -27,12 +28,13 @@ function testTracksFirstExplicitEdit() {
}
])
assert.equal(tracked.aiDecisionFeedback.revision, 1)
assert.equal(tracked.decisionId, 'decision-feedback-1')
}
function testKeepsOriginalSuggestionAcrossRepeatedEdits() {
const first = trackApplicationPreviewFieldEdit(
{ fields: { transportMode: '火车' } },
{ fields: { transportMode: '飞机' } },
{ decisionId: 'decision-feedback-2', fields: { transportMode: '火车' } },
{ decisionId: 'decision-feedback-2', fields: { transportMode: '飞机' } },
'transportMode'
)
const second = trackApplicationPreviewFieldEdit(
@@ -48,6 +50,7 @@ function testKeepsOriginalSuggestionAcrossRepeatedEdits() {
finalValue: '自驾'
}
])
assert.equal(second.decisionId, 'decision-feedback-2')
}
function testRemovesDiffWhenUserRestoresSuggestion() {