feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
@@ -1,15 +1,28 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { buildExpenseSceneSelectionActions } from '../src/utils/expenseAssistantActions.js'
|
||||
import { buildExpenseSceneSelectionMessage } from '../src/views/scripts/travelReimbursementConversationModel.js'
|
||||
import { normalizeInlineApplicationStatusLabel } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
|
||||
|
||||
const aiMode = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/business/PersonalWorkbenchAiMode.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
function readSource(path) {
|
||||
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
||||
}
|
||||
|
||||
const aiModeRuntimeDir = fileURLToPath(new URL('../src/composables/workbenchAiMode/', import.meta.url))
|
||||
const applicationPreviewFlow = readSource('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js')
|
||||
const applicationPreviewModel = readSource('../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js')
|
||||
const aiMode = [
|
||||
readSource('../src/components/business/PersonalWorkbenchAiMode.vue'),
|
||||
readSource('../src/components/business/PersonalWorkbenchAiMode.template.html'),
|
||||
readSource('../src/utils/aiDocumentQueryModel.js'),
|
||||
...readdirSync(aiModeRuntimeDir)
|
||||
.filter((file) => file.endsWith('.js'))
|
||||
.sort()
|
||||
.map((file) => readSource(`../src/composables/workbenchAiMode/${file}`))
|
||||
].join('\n')
|
||||
|
||||
test('expense scene selection message asks for type first and mentions application gate', () => {
|
||||
const text = buildExpenseSceneSelectionMessage('帮我发起一笔报销,并检查需要准备哪些票据材料。')
|
||||
@@ -32,10 +45,10 @@ test('expense scene actions mark travel and meal as requiring application', () =
|
||||
assert.equal(transport.payload.next_session_type, 'expense')
|
||||
})
|
||||
|
||||
test('AI mode quick reimbursement card opens scene selection before steward plan', () => {
|
||||
test('AI mode quick reimbursement card enters the reimbursement association gate before steward plan', () => {
|
||||
assert.match(
|
||||
aiMode,
|
||||
/function runAiModeAction\(item\) {[\s\S]{0,220}pushInlineExpenseSceneSelectionPrompt\(item\.prompt, item\.label\)/
|
||||
/function runAiModeAction\(item\) {[\s\S]{0,300}expenseFlow\.startAiReimbursementAssociationGate\(item\.prompt, item\.label\)/
|
||||
)
|
||||
})
|
||||
|
||||
@@ -53,27 +66,28 @@ test('AI mode offers an inline application shortcut when no candidate applicatio
|
||||
assert.match(aiMode, /buildLocalApplicationPreviewMessage/)
|
||||
assert.match(aiMode, /refreshApplicationPreviewEstimate/)
|
||||
assert.match(aiMode, /applicationPreview:\s*preview/)
|
||||
assert.match(aiMode, /suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\(preview\)/)
|
||||
assert.match(aiMode, /suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\(registeredPreview\)/)
|
||||
assert.doesNotMatch(aiMode, /function startAiApplicationDraft/)
|
||||
assert.doesNotMatch(aiMode, /buildAiApplicationStepPrompt/)
|
||||
})
|
||||
|
||||
test('AI mode steward reimbursement action opens expense scene selection locally', () => {
|
||||
test('AI mode steward reimbursement action enters the association gate while standalone flow keeps scene selection', () => {
|
||||
assert.match(aiMode, /buildExpenseSceneSelectionMessage/)
|
||||
assert.match(aiMode, /buildExpenseSceneSelectionActions/)
|
||||
assert.match(aiMode, /SESSION_TYPE_EXPENSE/)
|
||||
assert.match(aiMode, /function pushInlineExpenseSceneSelectionPrompt/)
|
||||
assert.match(aiMode, /payload\?\.session_type[\s\S]*SESSION_TYPE_EXPENSE/)
|
||||
assert.match(aiMode, /pushInlineExpenseSceneSelectionPrompt\(carryText, action\.label\)/)
|
||||
assert.match(aiMode, /action\?\.payload\?\.session_type[\s\S]*SESSION_TYPE_EXPENSE/)
|
||||
assert.match(aiMode, /expenseFlow\.startAiReimbursementAssociationGate\(carryText, action\.label\)/)
|
||||
assert.match(
|
||||
aiMode,
|
||||
/SESSION_TYPE_EXPENSE[\s\S]{0,140}pushInlineExpenseSceneSelectionPrompt\(carryText, action\.label\)[\s\S]{0,40}return/
|
||||
/SESSION_TYPE_EXPENSE && carryText === '我要报销'[\s\S]{0,160}expenseFlow\.startAiReimbursementAssociationGate\(carryText, action\.label\)[\s\S]{0,40}return/
|
||||
)
|
||||
assert.match(aiMode, /SKIP_REQUIRED_APPLICATION_LINK_ACTION[\s\S]{0,180}pushInlineExpenseSceneSelectionPrompt/)
|
||||
})
|
||||
|
||||
test('AI mode attaches required application lookup result before steward planning', () => {
|
||||
assert.match(aiMode, /async function attachAiRequiredApplicationGate\(planRequest, prompt\)/)
|
||||
assert.match(aiMode, /fetchExpenseClaims\(\)/)
|
||||
assert.match(aiMode, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.match(aiMode, /filterRequiredApplicationCandidates\(claims, 'travel', currentUser\.value \|\| \{\}\)/)
|
||||
assert.match(aiMode, /required_application_gate/)
|
||||
assert.match(aiMode, /await attachAiRequiredApplicationGate\(planRequest, prompt\)/)
|
||||
@@ -95,7 +109,8 @@ test('AI mode handles document query prompts locally before steward planning', (
|
||||
assert.match(aiMode, /查询业务单据接口/)
|
||||
assert.match(aiMode, /组合筛选单据/)
|
||||
assert.match(aiMode, /if \(await handleAiDocumentQueryIntent\(prompt, pendingMessage\)\) \{[\s\S]*return[\s\S]*\}/)
|
||||
assert.match(aiMode, /emit\('open-document', buildAiDocumentDetailRequest\(detailReference\)\)/)
|
||||
assert.match(aiMode, /await ensureAiDocumentDetailStillAvailable\(detailRequest\)/)
|
||||
assert.match(aiMode, /emit\('open-document', detailRequest\)/)
|
||||
})
|
||||
|
||||
test('AI mode asks for manual confirmation before generating application preview table', () => {
|
||||
@@ -114,8 +129,8 @@ test('AI mode asks for manual confirmation before generating application preview
|
||||
})
|
||||
|
||||
test('AI mode shows pending feedback before async application preview estimate refresh', () => {
|
||||
const startPreviewFunction = aiMode.match(
|
||||
/async function startAiApplicationPreview[\s\S]*?\n}\n\nfunction requestDeleteCurrentConversation/
|
||||
const startPreviewFunction = applicationPreviewFlow.match(
|
||||
/async function startAiApplicationPreview[\s\S]*?\n }\n\n return \{/
|
||||
)?.[0] || ''
|
||||
|
||||
assert.match(startPreviewFunction, /const pendingMessage = createInlineMessage\(\s*'assistant',\s*'正在生成申请核对表/)
|
||||
@@ -142,8 +157,8 @@ test('AI mode handles application preview save and submit through buttons or tex
|
||||
assert.match(aiMode, /function executeInlineApplicationPreviewAction\(actionType, sourceMessage = null, options = \{\}\)/)
|
||||
assert.match(aiMode, /function confirmInlineApplicationSubmit\(\)/)
|
||||
assert.match(aiMode, /function cancelInlineApplicationSubmitConfirm\(\)/)
|
||||
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt\)/)
|
||||
assert.match(aiMode, /if \(handleInlineApplicationPreviewTextAction\(cleanPrompt\)\) \{[\s\S]*return[\s\S]*\}/)
|
||||
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt, applicationPreviewEstimatePending\)/)
|
||||
assert.match(aiMode, /if \(applicationFlow\.handleInlineApplicationPreviewTextAction\(cleanPrompt, applicationPreviewEstimatePending\)\) \{[\s\S]*return[\s\S]*\}/)
|
||||
assert.match(aiMode, /\[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT\]\.includes\(actionType\)/)
|
||||
assert.match(aiMode, /normalizedPreview\.readyToSubmit/)
|
||||
assert.match(aiMode, /fetchExpenseClaims\(\{ page: 1, pageSize: 100 \}\)/)
|
||||
@@ -155,22 +170,23 @@ test('AI mode handles application preview save and submit through buttons or tex
|
||||
test('AI mode keeps missing application fields editable in the preview table without quick template action', () => {
|
||||
assert.match(aiMode, /function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\)/)
|
||||
assert.match(aiMode, /label:\s*'保存草稿'/)
|
||||
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt\)/)
|
||||
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt, applicationPreviewEstimatePending\)/)
|
||||
assert.doesNotMatch(aiMode, /label:\s*'快速模板'/)
|
||||
assert.doesNotMatch(aiMode, /action_type:\s*'prefill_composer'/)
|
||||
assert.doesNotMatch(aiMode, /buildInlineApplicationPreviewTemplatePrefill/)
|
||||
assert.doesNotMatch(aiMode, /applyInlineApplicationPreviewTemplateText/)
|
||||
})
|
||||
|
||||
test('AI mode waits for submit confirmation before adding submit action to the conversation', () => {
|
||||
const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction')
|
||||
const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart)
|
||||
const executeBlock = aiMode.slice(executeStart, executeEnd)
|
||||
const confirmGateIndex = executeBlock.indexOf('if (isSubmit && !options.confirmed)')
|
||||
test('AI mode confirms a new direct submit before adding the action while allowing contextual saved-draft submit', () => {
|
||||
const executeStart = applicationPreviewFlow.indexOf('async function executeInlineApplicationPreviewAction')
|
||||
const executeEnd = applicationPreviewFlow.indexOf('\n function handleInlineApplicationPreviewTextAction', executeStart)
|
||||
const executeBlock = applicationPreviewFlow.slice(executeStart, executeEnd)
|
||||
const confirmGateIndex = executeBlock.indexOf('if (isSubmit && !options.confirmed && !shouldSubmitSavedDraftDirectly)')
|
||||
const requestConfirmIndex = executeBlock.indexOf('requestInlineApplicationSubmitConfirmation', confirmGateIndex)
|
||||
const confirmedActionPushIndex = executeBlock.indexOf('pushInlineApplicationActionUserMessage(userText)', requestConfirmIndex)
|
||||
|
||||
assert.ok(confirmGateIndex >= 0, '直接提交应先进入确认分支')
|
||||
assert.match(executeBlock, /const shouldSubmitSavedDraftDirectly = isSubmit/)
|
||||
assert.ok(confirmGateIndex >= 0, '非上下文草稿提交应先进入确认分支')
|
||||
assert.ok(requestConfirmIndex > confirmGateIndex, '直接提交确认分支应先打开确认弹窗')
|
||||
assert.ok(confirmedActionPushIndex > requestConfirmIndex, '确认弹窗打开前不应追加“直接提交”用户消息')
|
||||
assert.match(
|
||||
@@ -178,22 +194,22 @@ test('AI mode waits for submit confirmation before adding submit action to the c
|
||||
/requestInlineApplicationSubmitConfirmation\(targetMessage,\s*\{\s*\.\.\.options,\s*userText\s*\}\)/
|
||||
)
|
||||
|
||||
const confirmStart = aiMode.indexOf('function confirmInlineApplicationSubmit()')
|
||||
const confirmEnd = aiMode.indexOf('\nasync function runInlineApplicationSubmitPrecheck', confirmStart)
|
||||
const confirmBlock = aiMode.slice(confirmStart, confirmEnd)
|
||||
const confirmStart = applicationPreviewFlow.indexOf('function confirmInlineApplicationSubmit()')
|
||||
const confirmEnd = applicationPreviewFlow.indexOf('\n async function runInlineApplicationSubmitPrecheck', confirmStart)
|
||||
const confirmBlock = applicationPreviewFlow.slice(confirmStart, confirmEnd)
|
||||
assert.match(confirmBlock, /userText:\s*context\.userText \|\| '直接提交'/)
|
||||
assert.match(confirmBlock, /skipUserMessage:\s*false/)
|
||||
|
||||
const cancelStart = aiMode.indexOf('function cancelInlineApplicationSubmitConfirm()')
|
||||
const cancelEnd = aiMode.indexOf('\nfunction confirmInlineApplicationSubmit', cancelStart)
|
||||
const cancelBlock = aiMode.slice(cancelStart, cancelEnd)
|
||||
const cancelStart = applicationPreviewFlow.indexOf('function cancelInlineApplicationSubmitConfirm()')
|
||||
const cancelEnd = applicationPreviewFlow.indexOf('\n function confirmInlineApplicationSubmit', cancelStart)
|
||||
const cancelBlock = applicationPreviewFlow.slice(cancelStart, cancelEnd)
|
||||
assert.doesNotMatch(cancelBlock, /pushInlineUserMessage|pushInlineApplicationActionUserMessage/)
|
||||
})
|
||||
|
||||
test('AI mode formats saved application draft as a detail table without continuing submit flow', () => {
|
||||
assert.match(aiMode, /function buildInlineApplicationResultTable\(draftPayload = \{\}, options = \{\}\)/)
|
||||
assert.match(aiMode, /function normalizeInlineApplicationStatusLabel\(value, fallback = ''\)/)
|
||||
assert.match(aiMode, /submitted:\s*'审批中'/)
|
||||
assert.equal(normalizeInlineApplicationStatusLabel('submitted'), '审批中')
|
||||
assert.match(aiMode, /const statusLabel = normalizeInlineApplicationStatusLabel\(info\.statusLabel, options\.statusLabel\)/)
|
||||
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/)
|
||||
assert.doesNotMatch(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \| 操作 \|/)
|
||||
@@ -204,9 +220,9 @@ test('AI mode formats saved application draft as a detail table without continui
|
||||
assert.match(aiMode, /function buildInlineApplicationDetailAction\(draftPayload = \{\}\)/)
|
||||
assert.match(aiMode, /action_type:\s*'open_application_detail'/)
|
||||
|
||||
const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText')
|
||||
const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart)
|
||||
const resultBlock = aiMode.slice(resultStart, resultEnd)
|
||||
const resultStart = applicationPreviewModel.indexOf('function buildInlineApplicationPreviewActionResultText')
|
||||
const resultEnd = applicationPreviewModel.indexOf('\nexport function buildInlineApplicationDetailAction', resultStart)
|
||||
const resultBlock = applicationPreviewModel.slice(resultStart, resultEnd)
|
||||
const submitBranchIndex = resultBlock.indexOf('actionType === AI_APPLICATION_ACTION_SUBMIT')
|
||||
const saveBranchIndex = resultBlock.indexOf("'### 申请草稿已保存'")
|
||||
const saveBranch = resultBlock.slice(saveBranchIndex)
|
||||
@@ -219,15 +235,16 @@ test('AI mode formats saved application draft as a detail table without continui
|
||||
)
|
||||
assert.doesNotMatch(saveBranch, /进入审批流程/)
|
||||
|
||||
const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction')
|
||||
const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart)
|
||||
const executeBlock = aiMode.slice(executeStart, executeEnd)
|
||||
const executeStart = applicationPreviewFlow.indexOf('async function executeInlineApplicationPreviewAction')
|
||||
const executeEnd = applicationPreviewFlow.indexOf('\n function handleInlineApplicationPreviewTextAction', executeStart)
|
||||
const executeBlock = applicationPreviewFlow.slice(executeStart, executeEnd)
|
||||
assert.match(executeBlock, /targetMessage\.suggestedActions = \[\]/)
|
||||
assert.doesNotMatch(
|
||||
executeBlock,
|
||||
/targetMessage\.suggestedActions = isSubmit[\s\S]*buildInlineApplicationPreviewSuggestedActions\(targetMessage\.applicationPreview, draftPayload\)/
|
||||
)
|
||||
assert.match(executeBlock, /suggestedActions:\s*buildInlineApplicationDetailAction\(draftPayload\)/)
|
||||
assert.match(executeBlock, /const detailActions = buildInlineApplicationDetailAction\(draftPayload\)/)
|
||||
assert.match(executeBlock, /suggestedActions:\s*shouldAutoContinueNextTask[\s\S]*detailActions/)
|
||||
})
|
||||
|
||||
test('AI mode locks application preview actions while estimate refresh is pending', () => {
|
||||
@@ -236,13 +253,13 @@ test('AI mode locks application preview actions while estimate refresh is pendin
|
||||
aiMode,
|
||||
/function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\) \{[\s\S]*if \(isApplicationPreviewEstimatePendingPreview\(applicationPreview\)\) \{[\s\S]*return \[\]/
|
||||
)
|
||||
assert.match(aiMode, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value\)/)
|
||||
assert.match(aiMode, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value \|\| isAiModeReceiptRecognitionPending\.value\)/)
|
||||
assert.match(aiMode, /:disabled="isAiModeInputLocked"/)
|
||||
assert.match(aiMode, /v-if="canShowInlineSuggestedActions\(message\)"/)
|
||||
assert.match(aiMode, /:disabled="isInlineSuggestedActionDisabled\(action, message\)"/)
|
||||
assert.match(
|
||||
aiMode,
|
||||
/message\.suggestedActions = \[\][\s\S]*const committed = await commitApplicationPreviewEditor\(message\)/
|
||||
/message\.suggestedActions = \[\][\s\S]*const committed = await commitBaseApplicationPreviewEditor\(message\)/
|
||||
)
|
||||
assert.match(
|
||||
aiMode,
|
||||
|
||||
Reference in New Issue
Block a user