feat(ai): add tenant-safe hierarchical expense learning

This commit is contained in:
caoxiaozhu
2026-07-16 14:30:41 +08:00
parent 6bdf65bc24
commit ee88a36baf
65 changed files with 6909 additions and 232 deletions

View File

@@ -4,6 +4,7 @@ import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT,
forgetAiApplicationMemory,
normalizeAiApplicationMemory,
registerAiApplicationPreviewDecision,
resolveAiApplicationLearningReceipts,
runAiApplicationPreviewAction
@@ -148,6 +149,14 @@ async function testRegistrationUsesServerCanonicalPreview() {
status: 'active',
evidenceCount: 4,
approvedEvidenceCount: 3,
scope_type: 'department',
scope_id: 'department-shanghai-delivery',
scope_label: '上海交付部',
source: 'admin_managed_org',
priority: 200,
effective_confidence: 0.92,
conflicts: [{ scope_type: 'user', reason: 'lower_priority' }],
can_revoke: false,
message: '根据历史申请偏好填入'
}]
}
@@ -188,6 +197,14 @@ async function testRegistrationUsesServerCanonicalPreview() {
status: 'applied',
evidenceCount: 4,
approvedEvidenceCount: 3,
scopeType: 'department',
scopeId: 'department-shanghai-delivery',
scopeLabel: '上海交付部',
source: 'admin_managed_org',
priority: 200,
effectiveConfidence: 0.92,
conflicts: [{ scope_type: 'user', reason: 'lower_priority' }],
canRevoke: false,
message: '根据历史申请偏好填入'
}])
}
@@ -215,6 +232,14 @@ async function testLearningReceiptNormalizationAndForgetEndpoint() {
status: 'candidate',
evidenceCount: 2,
approvedEvidenceCount: 1,
scopeType: 'user',
scopeId: '',
scopeLabel: '个人偏好',
source: 'verified_user_history',
priority: 100,
effectiveConfidence: null,
conflicts: [],
canRevoke: true,
message: '再确认一次后可形成稳定偏好'
}])
@@ -238,6 +263,52 @@ async function testLearningReceiptNormalizationAndForgetEndpoint() {
assert.equal(capturedOptions.method, 'DELETE')
}
function testHierarchicalMemoryNormalizationKeepsCompatibilityAndPermissions() {
const enterpriseMemory = normalizeAiApplicationMemory({
memory_id: 'memory-enterprise-transport',
field_key: 'transport_mode',
value: '火车',
status: 'applied',
scope_type: 'enterprise',
scope_id: 'tenant-acme',
scope_label: '企业统一制度',
source: 'admin_managed_org',
priority: '300',
effective_confidence: '0.88',
conflicts: [{ scope_type: 'department', reason: 'lower_priority' }],
can_revoke: false
})
assert.deepEqual(enterpriseMemory, {
memoryId: 'memory-enterprise-transport',
fieldKey: 'transport_mode',
fieldLabel: '出行方式',
value: '火车',
status: 'applied',
evidenceCount: 0,
approvedEvidenceCount: 0,
scopeType: 'enterprise',
scopeId: 'tenant-acme',
scopeLabel: '企业统一制度',
source: 'admin_managed_org',
priority: 300,
effectiveConfidence: 0.88,
conflicts: [{ scope_type: 'department', reason: 'lower_priority' }],
canRevoke: false,
message: ''
})
const legacyPersonalMemory = normalizeAiApplicationMemory({
memoryId: 'memory-legacy-user',
fieldKey: 'transport_mode',
value: '飞机',
status: 'active'
})
assert.equal(legacyPersonalMemory.scopeType, 'user')
assert.equal(legacyPersonalMemory.source, 'verified_user_history')
assert.equal(legacyPersonalMemory.canRevoke, true)
assert.equal(legacyPersonalMemory.status, 'applied')
}
async function testEditDraftActionCarriesClaimAndEditableFields() {
let capturedOptions = null
@@ -327,6 +398,7 @@ async function run() {
await testSaveDraftActionUsesFastPreviewEndpoint()
await testRegistrationUsesServerCanonicalPreview()
await testLearningReceiptNormalizationAndForgetEndpoint()
testHierarchicalMemoryNormalizationKeepsCompatibilityAndPermissions()
await testEditDraftActionCarriesClaimAndEditableFields()
await testApplicationActionSourceCanBeConfiguredWithoutChangingWorkbenchDefaults()
console.log('ai-application-preview-actions tests passed')

View File

@@ -407,13 +407,48 @@ test('忘记偏好失败时不移除 memory也不改当前字段', async () =
assert.match(toasts.at(-1), /偏好服务暂不可用/)
})
test('申请预览 UI 解释已应用偏好并提供可访问的忘记口', () => {
test('组织记忆不允许普通用户调用忘记口', async () => {
const organizationMemory = {
memoryId: 'memory-enterprise-policy',
fieldKey: 'transport_mode',
fieldLabel: '出行方式',
value: '火车',
status: 'applied',
scopeType: 'enterprise',
canRevoke: false
}
const message = {
id: 'preview-enterprise-memory',
applicationPreview: createPreview({ memoryApplications: [organizationMemory] })
}
const { actions, toasts } = createActions()
let requestCount = 0
global.fetch = async () => {
requestCount += 1
throw new Error('不应发起组织记忆删除请求')
}
const forgotten = await actions.forgetApplicationPreviewMemory(message, organizationMemory)
assert.equal(forgotten, false)
assert.equal(requestCount, 0)
assert.equal(message.applicationPreview.memoryApplications.length, 1)
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, /个性化填充/)
assert.match(memoryPanelTemplate, /您仍可直接修改上方字段/)
assert.match(memoryPanelTemplate, /忘记此偏好/)
assert.match(memoryPanelTemplate, /v-if="canForgetMemory\(memory\)"/)
assert.match(memoryPanelTemplate, /memory\.scopeType === 'user' && memory\.canRevoke === true/)
assert.match(memoryPanelTemplate, /:aria-busy="ui\.isForgettingApplicationMemory\(memory\)"/)
assert.match(memoryPanelTemplate, /检测到同层级设置冲突/)
assert.doesNotMatch(memoryPanelTemplate, /JSON\.stringify\(memory\.conflicts/)
assert.match(memoryPanelTemplate, /resolveVisibleApplicationLearningReceipts\(props\.message\)/)
assert.match(memoryPanelTemplate, /ui\.resolveApplicationLearningReceiptTitle\(receipt\)/)
assert.doesNotMatch(messageItemTemplate, /已按历史偏好填入/)

View File

@@ -0,0 +1,190 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import {
buildOrganizationExpenseMemoryCreatePayload,
buildOrganizationExpenseMemoryRevokePayload,
buildOrganizationExpenseMemoryUpdatePayload,
createOrganizationExpenseMemory,
fetchOrganizationExpenseMemories,
normalizeOrganizationExpenseMemory,
revokeOrganizationExpenseMemory,
updateOrganizationExpenseMemory
} from '../src/services/expenseApplicationMemories.js'
const settingsModel = readFileSync(new URL('../src/utils/settingsModelHelper.js', import.meta.url), 'utf8')
const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8')
const settingsScript = readFileSync(new URL('../src/views/scripts/SettingsView.js', import.meta.url), 'utf8')
const memoryPanel = readFileSync(new URL('../src/views/ExpenseMemorySettingsPanel.vue', import.meta.url), 'utf8')
test('组织记忆 payload 限制低敏枚举、有效期和必填理由', () => {
assert.deepEqual(buildOrganizationExpenseMemoryCreatePayload({
scopeType: 'enterprise',
scopeId: 'must-not-be-sent',
value: '火车',
expiresInDays: 180,
reason: '统一企业基线',
requestId: 'create-enterprise-1'
}), {
scope_type: 'enterprise',
value: '火车',
expires_in_days: 180,
reason: '统一企业基线',
request_id: 'create-enterprise-1'
})
assert.deepEqual(buildOrganizationExpenseMemoryCreatePayload({
scopeType: 'department',
scopeId: 'organization-unit-stable-id',
value: '飞机',
expiresInDays: 90,
reason: '部门差旅基线',
requestId: 'create-department-1'
}), {
scope_type: 'department',
scope_id: 'organization-unit-stable-id',
value: '飞机',
expires_in_days: 90,
reason: '部门差旅基线',
request_id: 'create-department-1'
})
assert.deepEqual(buildOrganizationExpenseMemoryUpdatePayload({
value: '轮船',
expiresInDays: 120,
expectedGeneration: 3,
reason: '更新制度',
requestId: 'update-memory-3'
}), {
value: '轮船',
expires_in_days: 120,
expected_generation: 3,
reason: '更新制度',
request_id: 'update-memory-3'
})
assert.deepEqual(buildOrganizationExpenseMemoryRevokePayload({
expectedGeneration: 3,
reason: '制度已停用',
requestId: 'revoke-memory-3'
}), {
expected_generation: 3,
reason: '制度已停用',
request_id: 'revoke-memory-3'
})
assert.throws(
() => buildOrganizationExpenseMemoryCreatePayload({
scopeType: 'enterprise', value: '网约车', expiresInDays: 180, reason: '不应通过', requestId: 'invalid-value-1'
}),
/仅支持飞机、火车或轮船/
)
assert.throws(
() => buildOrganizationExpenseMemoryCreatePayload({
scopeType: 'enterprise', value: '火车', expiresInDays: 29, reason: '不应通过', requestId: 'invalid-days-1'
}),
/30 到 365 天/
)
assert.throws(
() => buildOrganizationExpenseMemoryCreatePayload({
scopeType: 'enterprise', value: '火车', expiresInDays: 180, reason: '', requestId: 'invalid-reason-1'
}),
/操作理由/
)
})
test('组织记忆 service 使用管理员接口并保留作用域和版本解释', async () => {
const requests = []
const originalFetch = globalThis.fetch
const responseMemory = {
id: 'memory/enterprise 1',
field_key: 'transport_mode',
value: '火车',
status: 'active',
scope_type: 'enterprise',
scope_id: 'tenant-1',
scope_label: '企业统一规则',
source: 'enterprise_policy_memory',
origin_type: 'admin_managed',
generation: 2,
policy_version: 'expense_application_transport_org_memory.v1',
expires_at: '2026-12-31T00:00:00Z',
managed_by: 'private-admin-identifier',
can_revoke: true
}
globalThis.fetch = async (url, options = {}) => {
requests.push({ url: String(url), options })
return {
ok: true,
status: 200,
async json() {
return requests.length === 1 ? { items: [responseMemory] } : responseMemory
}
}
}
try {
const items = await fetchOrganizationExpenseMemories()
await createOrganizationExpenseMemory({
scopeType: 'enterprise', value: '火车', expiresInDays: 180, reason: '创建企业记忆', requestId: 'create-memory-1'
})
await updateOrganizationExpenseMemory('memory/enterprise 1', {
value: '轮船', expiresInDays: 120, expectedGeneration: 2, reason: '更新记忆', requestId: 'update-memory-1'
})
await revokeOrganizationExpenseMemory('memory/enterprise 1', {
expectedGeneration: 2, reason: '撤销记忆', requestId: 'revoke-memory-1'
})
assert.equal(items[0].scopeType, 'enterprise')
assert.equal(items[0].generation, 2)
assert.equal(items[0].sourceLabel, '管理员显式维护')
assert.equal('managedBy' in items[0], false)
} finally {
globalThis.fetch = originalFetch
}
assert.equal(requests[0].url, '/api/v1/expense-application-memories/organization')
assert.equal(requests[1].options.method, 'POST')
assert.equal(
requests[2].url,
'/api/v1/expense-application-memories/organization/memory%2Fenterprise%201'
)
assert.equal(requests[2].options.method, 'PUT')
assert.equal(
requests[3].url,
'/api/v1/expense-application-memories/organization/memory%2Fenterprise%201/revoke'
)
assert.equal(requests[3].options.method, 'POST')
})
test('组织记忆规范化不将管理员标识带入展示模型', () => {
const memory = normalizeOrganizationExpenseMemory({
id: 'memory-department-1',
scope_type: 'department',
scope_id: 'organization-unit-1',
scope_label: '部门规则(财务部)',
status: 'active',
value: '飞机',
origin_type: 'admin_managed',
managed_by: 'sensitive-user-id'
})
assert.equal(memory.scopeId, 'organization-unit-1')
assert.equal(memory.scopeLabel, '部门规则(财务部)')
assert.equal(memory.sourceLabel, '管理员显式维护')
assert.equal('managedBy' in memory, false)
})
test('设置页接入无顶部保存按钮的 AI 记忆独立面板', () => {
assert.match(settingsModel, /id:\s*'aiMemory'[\s\S]*label:\s*'AI 记忆'[\s\S]*actionLabel:\s*''/)
assert.match(settingsModel, /aiMemory:\s*true/)
assert.match(settingsScript, /import ExpenseMemorySettingsPanel/)
assert.match(settingsScript, /ExpenseMemorySettingsPanel,/)
assert.match(settingsView, /activeSection === 'aiMemory'[\s\S]*<ExpenseMemorySettingsPanel \/>/)
assert.match(memoryPanel, /fetchEmployeeMeta\(\)/)
assert.match(memoryPanel, /item\?\.id && item\?\.unitType === 'department'/)
assert.match(memoryPanel, /value:\s*String\(item\.id\)/)
assert.match(memoryPanel, /飞机[、\s\S]*火车[、\s\S]*轮船/)
assert.match(memoryPanel, /min="30" max="365"/)
assert.match(memoryPanel, /第 \{\{ memory\.generation \}\} 版/)
assert.match(memoryPanel, /memory\.sourceLabel/)
assert.match(memoryPanel, /organization-memory-revoke:/)
assert.match(memoryPanel, /requestId:\s*revokeRequestId\.value/)
assert.doesNotMatch(memoryPanel, /managedBy|managed_by|managementReason|management_reason/)
})