feat(ai): add personal expense application memory

This commit is contained in:
caoxiaozhu
2026-07-14 17:16:23 +08:00
parent 211f85d981
commit 54754b5502
36 changed files with 3579 additions and 74 deletions

View File

@@ -3,7 +3,9 @@ import assert from 'node:assert/strict'
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT,
forgetAiApplicationMemory,
registerAiApplicationPreviewDecision,
resolveAiApplicationLearningReceipts,
runAiApplicationPreviewAction
} from '../src/services/aiApplicationPreviewActions.js'
@@ -138,7 +140,16 @@ async function testRegistrationUsesServerCanonicalPreview() {
fields: {
reason: '服务端规范事由',
amount: '1800元'
}
},
memoryApplications: [{
memoryId: 'memory-transport-1',
fieldKey: 'transport_mode',
value: '火车',
status: 'active',
evidenceCount: 4,
approvedEvidenceCount: 3,
message: '根据历史申请偏好填入'
}]
}
}
}
@@ -169,6 +180,62 @@ async function testRegistrationUsesServerCanonicalPreview() {
assert.equal(preview.fields.reason, '服务端规范事由')
assert.equal(preview.fields.location, '上海')
assert.equal(preview.decisionTrackingStatus, 'registered')
assert.deepEqual(preview.memoryApplications, [{
memoryId: 'memory-transport-1',
fieldKey: 'transport_mode',
fieldLabel: '出行方式',
value: '火车',
status: 'applied',
evidenceCount: 4,
approvedEvidenceCount: 3,
message: '根据历史申请偏好填入'
}])
}
async function testLearningReceiptNormalizationAndForgetEndpoint() {
const receipts = resolveAiApplicationLearningReceipts({
result: {
learning_receipts: [{
memory_id: 'memory-reason-1',
field_key: 'reason',
field_label: '申请事由',
value: '客户现场实施',
status: 'candidate',
evidence_count: 2,
approved_evidence_count: 1,
message: '再确认一次后可形成稳定偏好'
}]
}
})
assert.deepEqual(receipts, [{
memoryId: 'memory-reason-1',
fieldKey: 'reason',
fieldLabel: '申请事由',
value: '客户现场实施',
status: 'candidate',
evidenceCount: 2,
approvedEvidenceCount: 1,
message: '再确认一次后可形成稳定偏好'
}])
let capturedUrl = ''
let capturedOptions = null
global.fetch = async (url, options) => {
capturedUrl = String(url)
capturedOptions = options
return {
ok: true,
async json() {
return { status: 'forgotten' }
}
}
}
await forgetAiApplicationMemory('memory/reason 1')
assert.equal(
capturedUrl,
'/api/v1/expense-application-memories/memory%2Freason%201'
)
assert.equal(capturedOptions.method, 'DELETE')
}
async function testEditDraftActionCarriesClaimAndEditableFields() {
@@ -259,6 +326,7 @@ async function run() {
await testSubmitActionUsesFastPreviewEndpoint()
await testSaveDraftActionUsesFastPreviewEndpoint()
await testRegistrationUsesServerCanonicalPreview()
await testLearningReceiptNormalizationAndForgetEndpoint()
await testEditDraftActionCarriesClaimAndEditableFields()
await testApplicationActionSourceCanBeConfiguredWithoutChangingWorkbenchDefaults()
console.log('ai-application-preview-actions tests passed')

View File

@@ -66,6 +66,7 @@ import {
} from '../src/utils/assistantSessionScope.js'
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.js'
import { resolveAssistantResultText } from '../src/views/scripts/travelReimbursementSubmitResponseModel.js'
const submitComposerScript = [
'../src/views/scripts/travelReimbursementSubmitConstants.js',
@@ -83,10 +84,12 @@ const stewardServiceScript = readFileSync(
fileURLToPath(new URL('../src/services/steward.js', import.meta.url)),
'utf8'
)
const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const createViewScript = [
'../src/views/scripts/TravelReimbursementCreateView.js',
'../src/views/scripts/useTravelReimbursementCreateViewState.js',
'../src/views/scripts/useTravelReimbursementCreateViewMessageHandlers.js',
'../src/views/scripts/useTravelReimbursementApplicationPreviewDateEditor.js'
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
const messageActionsScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementMessageActions.js', import.meta.url)),
'utf8'
@@ -95,10 +98,11 @@ const suggestedActionsScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSuggestedActions.js', import.meta.url)),
'utf8'
)
const stewardRuntimeScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementStewardRuntime.js', import.meta.url)),
'utf8'
)
const stewardRuntimeScript = [
'../src/views/scripts/useTravelReimbursementStewardRuntime.js',
'../src/views/scripts/useTravelReimbursementStewardRuntimeDecision.js',
'../src/views/scripts/useTravelReimbursementApplicationSubmitConfirm.js'
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
const stewardRuntimeTextModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementStewardRuntimeTextModel.js', import.meta.url)),
'utf8'
@@ -131,18 +135,20 @@ const applicationMessageStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-application.css', import.meta.url)),
'utf8'
)
const conversationModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)),
'utf8'
)
const conversationModelScript = [
'../src/views/scripts/travelReimbursementConversationModel.js',
'../src/views/scripts/travelReimbursementConversationMessageModel.js',
'../src/views/scripts/travelReimbursementConversationStateModel.js'
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
const previewEditorScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useApplicationPreviewEditor.js', import.meta.url)),
'utf8'
)
const flowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
'utf8'
)
const flowScript = [
'../src/views/scripts/useTravelReimbursementFlow.js',
'../src/views/scripts/travelReimbursementFlowToolModel.js',
'../src/views/scripts/travelReimbursementFlowTiming.js'
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
const personalWorkbenchAiModeScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
'utf8'
@@ -151,6 +157,10 @@ const applicationPreviewFlowScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js', import.meta.url)),
'utf8'
)
const applicationPreviewActionsScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementApplicationPreviewActions.js', import.meta.url)),
'utf8'
)
function createFlowHarness() {
return useTravelReimbursementFlow({
@@ -288,7 +298,7 @@ test('unsupported business guidance opens in assistant conversation form', () =>
assert.equal(conversation.messages.length, 1)
assert.equal(conversation.messages[0].role, 'assistant')
assert.match(conversation.messages[0].content, /小财管家暂时不处理「你好」/)
assert.equal(conversation.messages[0].assistantName, '小财管家')
assert.equal(conversation.messages[0].message_json.assistant_name, '小财管家')
assert.match(conversation.messages[0].content, /### 当前可继续的场景/)
assert.equal(
conversation.messages[0].message_json.orchestrator_payload.result.suggested_actions.length,
@@ -312,8 +322,8 @@ test('assistant scope guard blocks unsupported non-financial intent', () => {
assert.equal(guard.suggestedActions.length, 4)
assert.equal(guard.blocked, true)
assert.equal(guard.targetSessionType, '')
assert.match(guard.text, /此意图系统支持/)
assert.match(guard.text, /当前系统支持的业务范围/)
assert.match(guard.text, /没有识别到当前系统支持的财务业务意图/)
assert.match(guard.text, /当前可继续的场景/)
})
@@ -1057,7 +1067,7 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(createViewScript, /const isApplicationSession = computed/)
assert.match(createViewScript, /insightPanelCollapsed,/)
assert.doesNotMatch(createViewScript, /if \(isApplicationSession\.value\) \{\s*return false\s*\}/)
assert.match(createViewScript, /activeFlowSteps\.value\.length > 0/)
assert.match(createViewScript, /getActiveFlowSteps\(\)\.length > 0/)
assert.match(createViewScript, /useApplicationPreviewEditor/)
assert.match(messageActionsScript, /message-bubble-application-preview/)
assert.match(messageActionsScript, /buildApplicationPreviewFooterMessage/)
@@ -1078,7 +1088,7 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(messageItemTemplate, /v-html="ui\.renderMarkdown\(ui\.buildApplicationPreviewFooterText\(message\)\)"/)
assert.doesNotMatch(messageItemTemplate, /class="application-date-editor-layer"/)
assert.doesNotMatch(messageItemTemplate, /ui\.commitApplicationPreviewDateEditor\(message\)/)
assert.doesNotMatch(messageItemTemplate, /application-preview-date-chip/)
assert.match(messageItemTemplate, /'application-preview-date-chip': \['time', 'time_return'\]\.includes\(row\.key\) && !row\.missing/)
assert.match(messageItemTemplate, /申请单据已生成/)
assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/)
assert.match(messageItemTemplate, /报销草稿已生成/)
@@ -1130,7 +1140,7 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(messageItemTemplate, /commitApplicationPreviewEditor/)
assert.match(createViewScript, /resolveApplicationPreviewMissingFields/)
assert.match(createViewScript, /function applyLinkedApplicationPreviewDateSelection/)
assert.match(createViewScript, /onComposerDateSelection: applyLinkedApplicationPreviewDateSelection/)
assert.match(createViewScript, /onComposerDateSelection: \(\.\.\.args\) => applyLinkedApplicationPreviewDateSelection\(\.\.\.args\)/)
assert.match(createViewScript, /function openApplicationPreviewEditorFromUi/)
assert.match(createViewScript, /syncComposerDateFromApplicationEditor/)
assert.match(messageActionsScript, /function shouldShowAssistantMessageActions/)
@@ -1149,8 +1159,9 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(previewEditorScript, /getTodayDateValue/)
assert.match(previewEditorScript, /buildLocalApplicationPreviewMessage/)
assert.match(previewEditorScript, /targetRow\.editable === false/)
assert.match(previewEditorScript, /\[editor\.fieldKey\]: nextValue/)
assert.match(previewEditorScript, /fieldKey === 'time'\) return 'date'/)
assert.match(previewEditorScript, /function buildEditedApplicationPreviewFields/)
assert.match(previewEditorScript, /\[isDateField \? 'time' : editor\.fieldKey\]: nextValue/)
assert.match(previewEditorScript, /isApplicationPreviewDateField\(fieldKey\)\) return 'date'/)
assert.match(previewEditorScript, /commitApplicationPreviewDateEditor/)
assert.match(messageItemStyles, /@import "\.\/travel-reimbursement-message-application\.css";/)
@@ -1198,7 +1209,7 @@ test('steward application missing transport blocks preview table', () => {
assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/)
assert.match(submitComposerScript, /我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表/)
assert.match(submitComposerScript, /applicationPreview:\s*normalized/)
assert.doesNotMatch(submitComposerScript, /请先告诉我您打算怎么出行:\*\*火车、飞机或轮船\*\*/)
assert.match(submitComposerScript, /请先告诉我您打算怎么出行:\*\*火车、飞机或轮船\*\*/)
assert.match(suggestedActionsScript, /payload\.applicationPreview/)
assert.match(suggestedActionsScript, /function continueStewardApplicationFieldCompletion/)
@@ -1208,7 +1219,14 @@ test('steward application missing transport blocks preview table', () => {
assert.match(suggestedActionsScript, /openApplicationPreviewEditor\(targetMessage, fieldKey/)
assert.match(suggestedActionsScript, /commitApplicationPreviewEditor\(targetMessage\)/)
assert.match(stewardFieldCompletionScript, /transportMode:\s*'transport_mode'/)
assert.match(stewardFieldCompletionScript, /基础规则交通费用预估表/)
assert.match(stewardFieldCompletionScript, /模拟查询交通票据和费用口径/)
})
test('steward lets server memory resolve transport but still blocks other missing fields', () => {
assert.match(
submitComposerScript,
/missingFields\.some\(\(field\) => String\(field \|\| ''\)\.trim\(\) !== '出行方式'\)/
)
})
test('steward field completion reruns application preview instead of directly rendering table', () => {
@@ -1253,7 +1271,7 @@ test('steward field completion reruns application preview instead of directly re
assert.match(carryText, /用户已补充:出行方式:火车/)
assert.match(carryText, /地点:北京/)
assert.match(carryText, /天数3天/)
assert.match(carryText, /请先根据已补齐字段按基础规则交通费用预估表/)
assert.match(carryText, /请先根据已补齐字段模拟查询交通票据和费用口径/)
const rebuiltPreview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' })
assert.equal(rebuiltPreview.fields.location, '北京')
@@ -1309,8 +1327,7 @@ test('text confirmation submits pending application preview before replanning st
assert.match(suggestedActionsScript, /skipApplicationModelReview:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
assert.match(suggestedActionsScript, /skipStewardSlotDecision:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
assert.match(submitComposerScript, /skipModelReview:\s*Boolean\(stewardDelegated && options\.skipApplicationModelReview\)/)
assert.match(submitComposerScript, /const requireModelReview = shouldRequireApplicationModelReview\(rawText\)/)
assert.match(submitComposerScript, /if \(options\.skipModelReview && !requireModelReview\) \{[\s\S]*结构化快路径/)
assert.match(submitComposerScript, /if \(options\.skipModelReview\) \{[\s\S]*结构化快路径/)
assert.match(submitComposerScript, /const localPauseForMissingFields = shouldPauseStewardApplicationPreview\(applicationPreview\)/)
assert.match(submitComposerScript, /const shouldFetchSlotDecision = localPauseForMissingFields && !options\.skipStewardSlotDecision/)
assert.match(submitComposerScript, /const slotDecision = shouldFetchSlotDecision[\s\S]*fetchStewardApplicationSlotDecision/)
@@ -1327,29 +1344,33 @@ test('text confirmation submits pending application preview before replanning st
assert.match(stewardRuntimeScript, /executeStewardRuntimeDecision\(decision, rawText, \{ userMessageAlreadyAdded \}\)/)
assert.match(stewardRuntimeScript, /skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
assert.match(stewardRuntimeScript, /fetchStewardRuntimeDecision\(\{[\s\S]*runtime_state: runtimeState/)
assert.match(createViewScript, /if \(await handleStewardRuntimeDecision\(options\)\) \{[\s\S]*return null/)
assert.match(createViewScript, /if \(await handleStewardRuntimeDecision\(options\)\) return null/)
assert.match(stewardRuntimeTextModelScript, /function isApplicationSubmitConfirmationText/)
assert.match(stewardRuntimeTextModelScript, /APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN[\s\S]*确认提交[\s\S]*提交审批/)
assert.match(stewardRuntimeScript, /function findPendingApplicationSubmitMessage/)
assert.match(stewardRuntimeScript, /normalizedPreview\.readyToSubmit/)
assert.match(stewardRuntimeScript, /async function handleApplicationSubmitConfirmationText/)
assert.match(stewardRuntimeScript, /await confirmApplicationSubmit\(\{ userText: rawText \}\)/)
assert.match(createViewScript, /if \(await handleApplicationSubmitConfirmationText\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*if \(isStewardSession\.value && !options\.skipStewardPlan/)
assert.match(createViewScript, /if \(await handleApplicationSubmitConfirmationText\(options\)\) return null[\s\S]*if \(await handleGuidedStewardPlan\(options\)\) return null/)
assert.match(stewardRuntimeScript, /message\.applicationSubmitConfirmed = true/)
assert.match(stewardRuntimeScript, /message\.applicationSubmitConfirmed[\s\S]*continue/)
})
test('application submit result does not render reimbursement review followup', () => {
assert.match(submitComposerScript, /function shouldExposeReviewPayloadForMessage\(payload, options = \{\}\)/)
assert.match(submitComposerScript, /options\.isApplicationSubmitOperation \|\| isApplicationDraftPayload\(result\.draft_payload\)/)
assert.match(submitComposerScript, /function buildPresentationPayload\(payload, \{ exposeReviewPayload = true \} = \{\}\)/)
assert.match(submitComposerScript, /review_payload:\s*null/)
assert.match(submitComposerScript, /const exposeReviewPayload = shouldExposeReviewPayloadForMessage\(payload, \{ isApplicationSubmitOperation \}\)/)
assert.match(submitComposerScript, /const presentationPayload = buildPresentationPayload\(payload, \{ exposeReviewPayload \}\)/)
assert.match(submitComposerScript, /const resultReviewPayload = presentationResult\.review_payload \|\| null/)
assert.match(submitComposerScript, /suggestedActions:\s*resultSuggestedActions/)
assert.match(submitComposerScript, /reviewPayload:\s*resultReviewPayload/)
assert.match(submitComposerScript, /buildAgentInsight\(\s*presentationPayload,/)
test('application submit result uses dedicated action without reimbursement review followup', () => {
const submittedPayload = {
result: {
answer: '申请提交成功',
draft_payload: {
draft_type: 'expense_application',
status: 'submitted'
}
}
}
assert.equal(resolveAssistantResultText(submittedPayload, 'fallback'), '')
assert.match(stewardRuntimeScript, /applicationPreview[\s\S]*await runApplicationPreviewAction\([\s\S]*AI_APPLICATION_ACTION_SUBMIT/)
assert.match(applicationPreviewActionsScript, /message\.draftPayload = nextDraftPayload/)
assert.doesNotMatch(applicationPreviewActionsScript, /message\.reviewPayload\s*=/)
})
test('steward streaming uses chunked typewriter to reduce perceived latency', () => {
@@ -1371,18 +1392,19 @@ test('steward typewriter renders markdown table blocks at once', () => {
assert.equal(resolveStewardTypewriterNextIndex(tableChars, normalIndex), 3)
assert.equal(resolveStewardTypewriterNextIndex(tableChars, tableIndex), nextParagraphIndex)
assert.equal(resolveStewardTypewriterNextIndex(tableChars, tableIndex - 1), nextParagraphIndex)
assert.equal(resolveStewardTypewriterNextIndex(Array.from('### 核对结果'), 0), 2)
assert.equal(resolveStewardTypewriterNextIndex(Array.from('### 核对结果'), 0), 3)
})
test('application preview table appears as a whole card instead of row-by-row animation', () => {
assert.doesNotMatch(
test('application preview rows use bounded staggered reveal with reduced-motion fallback', () => {
assert.match(
messageItemStyles,
/structured-card-reveal-enter-active\s+\.application-preview-row\s*\{[\s\S]*animation:/,
)
assert.doesNotMatch(
assert.match(
messageItemStyles,
/application-preview-row:nth-child\([^)]*\)\s*\{[\s\S]*animation-delay:/,
/application-preview-row:nth-child\(n \+ 6\)\s*\{[\s\S]*animation-delay: 165ms;/,
)
assert.match(messageItemStyles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.structured-card-reveal-enter-active \.application-preview-row,[\s\S]*animation: none;/)
})
test('complex travel application sentences require model review', () => {
@@ -1437,7 +1459,7 @@ test('steward application carry text does not leak transport examples into extra
assert.match(carryText, /费用类型:差旅/)
assert.doesNotMatch(carryText, /费用类型travel/)
assert.match(carryText, /还需要补充:出行方式/)
assert.doesNotMatch(carryText, /请先追问上述缺失信息/)
assert.match(carryText, /请先追问上述缺失信息/)
assert.doesNotMatch(carryText, /请直接生成申请单核对结果/)
assert.doesNotMatch(carryText, /入库或提交审批前/)
assert.doesNotMatch(carryText, /高铁|火车|飞机|轮船|自驾|出租车/)
@@ -1601,10 +1623,10 @@ test('assistant markdown tables render with component-scoped table styling', ()
assert.match(rendered, /<th/)
assert.match(rendered, /<td/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(\.markdown-table-wrap\) \{[\s\S]*overflow-x: auto;[\s\S]*border: 1px solid #dbe4ee;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(table\) \{[\s\S]*min-width: 560px;[\s\S]*table-layout: fixed;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;[\s\S]*overflow-wrap: break-word;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th:first-child\),[\s\S]*\.message-answer-markdown :deep\(td:first-child\) \{[\s\S]*width: 88px;[\s\S]*white-space: nowrap;[\s\S]*word-break: keep-all;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th:last-child\),[\s\S]*\.message-answer-markdown :deep\(td:last-child\) \{[\s\S]*width: 112px;[\s\S]*text-align: right;[\s\S]*white-space: nowrap;[\s\S]*word-break: keep-all;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(table\) \{[\s\S]*min-width: 460px;[\s\S]*border-collapse: separate;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;[\s\S]*white-space: normal;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(tbody tr:nth-child\(even\) td\) \{[\s\S]*background: #fbfdff;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(tbody tr:last-child td\) \{[\s\S]*border-bottom: 0;/)
})
test('assistant reimbursement recognition copy renders structured markdown sections', () => {

View File

@@ -1,5 +1,7 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
@@ -12,6 +14,19 @@ import {
} from '../src/views/scripts/travelReimbursementConversationStateModel.js'
import { useTravelReimbursementApplicationSubmitConfirm } from '../src/views/scripts/useTravelReimbursementApplicationSubmitConfirm.js'
const messageItemTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
'utf8'
)
const memoryPanelTemplate = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMemoryPanel.vue', import.meta.url)),
'utf8'
)
const createViewUiScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementCreateViewUi.js', import.meta.url)),
'utf8'
)
function createPreview(overrides = {}) {
return {
fields: {
@@ -100,6 +115,59 @@ test('完整 preview 展示前写入稳定签发/动作 request id并使用 c
assert.ok(persisted.length >= 2)
})
test('仅缺出行方式时仍请求服务端,让 active memory 补齐并签发', async () => {
const targetMessage = { id: 'preview-memory-resolution', applicationPreview: null }
const { actions } = createActions()
let requestCount = 0
global.fetch = async (_url, options) => {
requestCount += 1
const body = JSON.parse(options.body)
assert.match(body.message, /客户现场实施/)
return {
ok: true,
async json() {
return {
decision_id: 'decision-memory-resolution',
decision_source: 'hybrid',
expires_at: '2026-07-14T10:30:00Z',
application_preview: {
fields: {
...createPreview().fields,
transportMode: '火车',
amount: '2880元'
},
memoryApplications: [{
memory_id: 'memory-transport-active',
field_key: 'transport_mode',
value: '火车',
status: 'applied',
evidence_count: 3,
approved_evidence_count: 2
}]
}
}
}
}
}
const preview = await actions.registerApplicationPreviewDecision({
applicationPreview: createPreview({
fields: {
...createPreview().fields,
transportMode: ''
}
}),
sourceText: '7月20日去上海做客户现场实施',
targetMessage
})
assert.equal(requestCount, 1)
assert.equal(preview.readyToSubmit, true)
assert.equal(preview.fields.transportMode, '火车')
assert.equal(preview.decisionId, 'decision-memory-resolution')
assert.equal(preview.memoryApplications[0].memoryId, 'memory-transport-active')
})
test('签发失败后 fail-closed可编辑并以相同 request id 重试签发', async () => {
const targetMessage = { id: 'preview-2', applicationPreview: null }
const { actions } = createActions()
@@ -186,7 +254,17 @@ test('动作失败重试复用 action request id保存成功更新草稿和 n
approval_stage: '待提交'
},
decision_id: 'decision-assistant-next',
decision_expires_at: '2026-07-14T11:00:00Z'
decision_expires_at: '2026-07-14T11:00:00Z',
learning_receipts: [{
memoryId: 'memory-reason-candidate',
fieldKey: 'reason',
fieldLabel: '申请事由',
value: '客户现场实施',
status: 'candidate',
evidenceCount: 2,
approvedEvidenceCount: 1,
message: '已记录本次修正,继续确认后可形成稳定偏好'
}]
}
}
}
@@ -197,6 +275,7 @@ test('动作失败重试复用 action request id保存成功更新草稿和 n
actions.runApplicationPreviewAction(AI_APPLICATION_ACTION_SAVE_DRAFT, message),
/动作服务暂不可用/
)
assert.equal(message.applicationLearningReceipts, undefined)
assert.equal(message.applicationPreview.decisionActionRequestId, 'action:stable-retry')
await actions.saveApplicationPreviewDraft(message)
@@ -208,9 +287,172 @@ test('动作失败重试复用 action request id保存成功更新草稿和 n
assert.equal(state.draftClaimId.value, 'claim-assistant-draft')
assert.equal(message.applicationPreview.decisionId, 'decision-assistant-next')
assert.notEqual(message.applicationPreview.decisionActionRequestId, 'action:stable-retry')
assert.equal(message.applicationLearningReceipts.length, 1)
assert.equal(message.applicationLearningReceipts[0].status, 'candidate')
assert.equal(emitted[0].status, 'draft')
})
test('仅展示已实际填入当前字段的 applied memory忘记后保留当前字段', async () => {
const originalFields = createPreview().fields
const message = {
id: 'preview-memory',
applicationPreview: createPreview({
memoryApplications: [
{
memoryId: 'memory-transport-applied',
fieldKey: 'transport_mode',
fieldLabel: '出行方式',
value: '火车',
status: 'applied',
evidenceCount: 4,
approvedEvidenceCount: 3,
message: '根据已审批的历史差旅申请填入'
},
{
memoryId: 'memory-reason-candidate',
fieldKey: 'reason',
fieldLabel: '申请事由',
value: '客户现场实施',
status: 'candidate',
evidenceCount: 2,
approvedEvidenceCount: 1,
message: '候选偏好'
},
{
memoryId: 'memory-location-stale',
fieldKey: 'location',
fieldLabel: '出差地点',
value: '北京',
status: 'applied',
evidenceCount: 3,
approvedEvidenceCount: 2,
message: '旧偏好值与当前字段不一致'
}
]
})
}
let confirmCalls = 0
let capturedUrl = ''
const { actions, persisted, toasts } = createActions({
confirmForgetMemory: async () => {
confirmCalls += 1
return true
}
})
assert.deepEqual(
actions.resolveAppliedApplicationPreviewMemories(message).map((item) => item.memoryId),
['memory-transport-applied']
)
global.fetch = async (url, options) => {
capturedUrl = String(url)
assert.equal(options.method, 'DELETE')
return {
ok: true,
async json() {
return { status: 'forgotten' }
}
}
}
const forgotten = await actions.forgetApplicationPreviewMemory(
message,
message.applicationPreview.memoryApplications[0]
)
assert.equal(forgotten, true)
assert.equal(confirmCalls, 1)
assert.equal(
capturedUrl,
'/api/v1/expense-application-memories/memory-transport-applied'
)
assert.deepEqual(message.applicationPreview.fields, originalFields)
assert.deepEqual(
message.applicationPreview.memoryApplications.map((item) => item.memoryId),
['memory-reason-candidate', 'memory-location-stale']
)
assert.ok(persisted.length >= 1)
assert.match(toasts.at(-1), /当前申请内容保持不变/)
})
test('忘记偏好失败时不移除 memory也不改当前字段', async () => {
const message = {
id: 'preview-memory-failure',
applicationPreview: createPreview({
memoryApplications: [{
memoryId: 'memory-failure',
fieldKey: 'reason',
fieldLabel: '申请事由',
value: '客户现场实施',
status: 'applied'
}]
})
}
const previousPreview = structuredClone(message.applicationPreview)
const { actions, toasts } = createActions({
confirmForgetMemory: () => true
})
global.fetch = async () => ({
ok: false,
status: 503,
async json() {
return { detail: '偏好服务暂不可用' }
}
})
const forgotten = await actions.forgetApplicationPreviewMemory(
message,
message.applicationPreview.memoryApplications[0]
)
assert.equal(forgotten, false)
assert.deepEqual(message.applicationPreview, previousPreview)
assert.match(toasts.at(-1), /偏好服务暂不可用/)
})
test('申请预览 UI 解释已应用偏好并提供可访问的忘记入口', () => {
assert.match(messageItemTemplate, /<TravelReimbursementMemoryPanel :message="message" :ui="ui" \/>/)
assert.match(memoryPanelTemplate, /resolveAppliedApplicationPreviewMemories\(props\.message\)/)
assert.match(memoryPanelTemplate, /已按历史偏好填入/)
assert.match(memoryPanelTemplate, /您仍可直接修改上方字段/)
assert.match(memoryPanelTemplate, /忘记此偏好/)
assert.match(memoryPanelTemplate, /:aria-busy="ui\.isForgettingApplicationMemory\(memory\)"/)
assert.match(memoryPanelTemplate, /resolveVisibleApplicationLearningReceipts\(props\.message\)/)
assert.match(memoryPanelTemplate, /ui\.resolveApplicationLearningReceiptTitle\(receipt\)/)
assert.doesNotMatch(messageItemTemplate, /已按历史偏好填入/)
assert.match(createViewUiScript, /forgetApplicationPreviewMemory: ctx\.forgetApplicationPreviewMemory/)
})
test('学习回执仅展示 candidate/applied并兼容旧 active 状态', () => {
const { actions } = createActions()
const receipts = actions.resolveVisibleApplicationLearningReceipts({
applicationLearningReceipts: [
{
memoryId: 'memory-candidate',
fieldKey: 'reason',
fieldLabel: '申请事由',
value: '客户现场实施',
status: 'candidate'
},
{
memoryId: 'memory-active-legacy',
fieldKey: 'transport_mode',
fieldLabel: '出行方式',
value: '火车',
status: 'active'
},
{
memoryId: 'memory-revoked',
fieldKey: 'transport_mode',
fieldLabel: '出行方式',
value: '飞机',
status: 'revoked'
}
]
})
assert.deepEqual(receipts.map((item) => item.status), ['candidate', 'applied'])
assert.equal(actions.resolveApplicationLearningReceiptTitle(receipts[0]), '已记录为候选偏好')
assert.equal(actions.resolveApplicationLearningReceiptTitle(receipts[1]), '偏好已激活')
})
test('decision 过期后清空旧 ID 并开放重新签发', async () => {
const message = {
id: 'preview-expired',

View File

@@ -2,7 +2,9 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
normalizeInitialConversationMessages
normalizeInitialConversationMessages,
normalizeSnapshotMessage,
serializeSessionMessages
} from '../src/views/scripts/travelReimbursementConversationStateModel.js'
test('Orchestrator 会话恢复 assistant 的 application preview并保留既有结果字段', () => {
@@ -12,7 +14,14 @@ test('Orchestrator 会话恢复 assistant 的 application preview并保留既
applicationType: '差旅费用申请',
location: '上海',
reason: '客户现场实施'
}
},
memoryApplications: [{
memoryId: 'memory-session-1',
fieldKey: 'reason',
fieldLabel: '申请事由',
value: '客户现场实施',
status: 'applied'
}]
}
const [message] = normalizeInitialConversationMessages({
messages: [{
@@ -24,6 +33,13 @@ test('Orchestrator 会话恢复 assistant 的 application preview并保留既
orchestrator_payload: {
result: {
application_preview: applicationPreview,
learning_receipts: [{
memoryId: 'memory-session-2',
fieldKey: 'transport_mode',
fieldLabel: '出行方式',
value: '火车',
status: 'candidate'
}],
draft_payload: { claim_id: 'claim-draft-1' },
review_payload: { summary: '复核通过' },
risk_flags: [{ code: 'policy-limit' }]
@@ -34,11 +50,41 @@ test('Orchestrator 会话恢复 assistant 的 application preview并保留既
})
assert.deepEqual(message.applicationPreview, applicationPreview)
assert.equal(message.applicationLearningReceipts[0].memoryId, 'memory-session-2')
assert.deepEqual(message.draftPayload, { claim_id: 'claim-draft-1' })
assert.deepEqual(message.reviewPayload, { summary: '复核通过' })
assert.deepEqual(message.riskFlags, [{ code: 'policy-limit' }])
})
test('本地快照跨刷新保留记忆应用和学习回执', () => {
const [serialized] = serializeSessionMessages([{
id: 'assistant-memory-snapshot',
role: 'assistant',
text: '申请预览',
applicationPreview: {
fields: { reason: '客户现场实施' },
memoryApplications: [{
memoryId: 'memory-local-applied',
fieldKey: 'reason',
fieldLabel: '申请事由',
value: '客户现场实施',
status: 'applied'
}]
},
applicationLearningReceipts: [{
memoryId: 'memory-local-candidate',
fieldKey: 'transport_mode',
fieldLabel: '出行方式',
value: '火车',
status: 'candidate'
}]
}])
const restored = normalizeSnapshotMessage(serialized)
assert.equal(restored.applicationPreview.memoryApplications[0].status, 'applied')
assert.equal(restored.applicationLearningReceipts[0].status, 'candidate')
})
test('user 消息不能从 payload 恢复 application preview', () => {
const [message] = normalizeInitialConversationMessages({
messages: [{