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

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