feat(ai): add tenant-safe hierarchical expense learning
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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, /已按历史偏好填入/)
|
||||
|
||||
190
web/tests/settings-expense-memory-section.test.mjs
Normal file
190
web/tests/settings-expense-memory-section.test.mjs
Normal 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/)
|
||||
})
|
||||
Reference in New Issue
Block a user