feat(web): AI 工作台意图规划与规划思考模型
- 新增 workbenchAiIntentPlannerModel,基于 LLM function_call 解析建单/草稿/提交意图,区分 model 与 rule_fallback 来源 - 新增 workbenchAiPlanningThinkingModel 合并规划思考事件流,按 eventId 去重合并 - application gate/preview 模型接入意图规划,usePersonalWorkbenchAiMode/useWorkbenchAiStewardFlow/useWorkbenchAiActionRouter 链路适配,支持上下文提交 - steward 服务与 stewardPlanModel 适配新动作结构,receipt-folder-view 微调样式 - 新增 intent-planner-model/application-context-submit/steward-actions-service 测试,更新 gate-model/action-router/plan-message-copy/fast-preview 测试
This commit is contained in:
@@ -51,6 +51,9 @@ import {
|
||||
import {
|
||||
shouldUseBudgetCompileReport
|
||||
} from '../src/views/scripts/budgetAssistantReportModel.js'
|
||||
import {
|
||||
buildInlineApplicationPreview
|
||||
} from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
|
||||
import { resolveStewardTypewriterNextIndex } from '../src/views/scripts/stewardTypewriter.js'
|
||||
import {
|
||||
ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||
@@ -140,6 +143,14 @@ const flowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const personalWorkbenchAiModeScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const applicationPreviewFlowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function createFlowHarness() {
|
||||
return useTravelReimbursementFlow({
|
||||
@@ -241,6 +252,32 @@ test('application intent uses local preview instead of immediate orchestrator ca
|
||||
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
|
||||
})
|
||||
|
||||
test('AI workbench routes compact travel direct-submit planner into application preview auto submit', () => {
|
||||
assert.match(personalWorkbenchAiModeScript, /buildRuleFallbackWorkbenchAiIntentPlan/)
|
||||
assert.match(personalWorkbenchAiModeScript, /normalizeWorkbenchAiIntentPlan/)
|
||||
assert.match(personalWorkbenchAiModeScript, /resolveExecutableTravelApplicationPlan/)
|
||||
assert.match(
|
||||
personalWorkbenchAiModeScript,
|
||||
/async function executeModelPlannedWorkbenchIntent\(cleanPrompt, entry = \{\}, files = \[\]\)/
|
||||
)
|
||||
assert.match(
|
||||
personalWorkbenchAiModeScript,
|
||||
/modelPlan = await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files\)/
|
||||
)
|
||||
assert.match(
|
||||
personalWorkbenchAiModeScript,
|
||||
/const rulePlan = buildRuleFallbackWorkbenchAiIntentPlan\(cleanPrompt\)/
|
||||
)
|
||||
assert.match(
|
||||
personalWorkbenchAiModeScript,
|
||||
/applicationFlow\.startAiApplicationPreview\([\s\S]*travelApplicationRequest\.expenseType[\s\S]*travelApplicationRequest\.expenseTypeLabel[\s\S]*travelApplicationRequest\.sourceText[\s\S]*ontologyFields:\s*travelApplicationRequest\.ontologyFields[\s\S]*autoSubmit:\s*travelApplicationRequest\.autoSubmit/
|
||||
)
|
||||
assert.doesNotMatch(personalWorkbenchAiModeScript, /fallbackIntentPlan/)
|
||||
assert.match(applicationPreviewFlowScript, /if \(options\.autoSubmit && normalizeApplicationPreview\(preview\)\.readyToSubmit\)/)
|
||||
assert.match(applicationPreviewFlowScript, /confirmed:\s*true/)
|
||||
assert.match(applicationPreviewFlowScript, /skipUserMessage:\s*true/)
|
||||
})
|
||||
|
||||
test('unsupported business guidance opens in assistant conversation form', () => {
|
||||
const conversation = buildUnsupportedBusinessScopeConversation('你好')
|
||||
|
||||
@@ -366,6 +403,20 @@ test('application preview renders ordered editable rows and submit text uses edi
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /系统预估费用:1900元/)
|
||||
})
|
||||
|
||||
test('application preview keeps compact direct-submit command out of business reason', () => {
|
||||
const preview = buildInlineApplicationPreview(
|
||||
'差旅费',
|
||||
'去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交',
|
||||
{ grade: 'P5' }
|
||||
)
|
||||
|
||||
assert.equal(preview.fields.location, '上海')
|
||||
assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署')
|
||||
assert.equal(preview.fields.transportMode, '火车')
|
||||
assert.equal(preview.readyToSubmit, false)
|
||||
assert.deepEqual(preview.missingFields, ['出发时间', '天数'])
|
||||
})
|
||||
|
||||
test('application estimate builds deterministic mock transport amount and total', () => {
|
||||
const trainEstimate = buildMockApplicationTransportEstimate({ transportMode: '高铁', location: '上海' })
|
||||
const datedTrainEstimate = buildMockApplicationTransportEstimate({
|
||||
|
||||
54
web/tests/steward-actions-service.test.mjs
Normal file
54
web/tests/steward-actions-service.test.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { executeStewardAction } from '../src/services/steward.js'
|
||||
|
||||
async function testExecuteStewardActionUsesActionEndpoint() {
|
||||
let capturedUrl = ''
|
||||
let capturedOptions = null
|
||||
|
||||
global.fetch = async (url, options) => {
|
||||
capturedUrl = String(url)
|
||||
capturedOptions = options
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
action_type: 'save_application_draft',
|
||||
status: 'succeeded',
|
||||
message: '申请草稿已保存。',
|
||||
result_payload: {
|
||||
draft_payload: {
|
||||
claim_id: 'claim-action-draft',
|
||||
claim_no: 'A12345678',
|
||||
status: 'draft'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await executeStewardAction({
|
||||
action_type: 'save_application_draft',
|
||||
message: '保存草稿',
|
||||
task: {
|
||||
task_id: 'task-app-1',
|
||||
task_type: 'expense_application'
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(capturedUrl, '/api/v1/steward/actions/execute')
|
||||
assert.equal(capturedOptions.method, 'POST')
|
||||
assert.equal(JSON.parse(capturedOptions.body).action_type, 'save_application_draft')
|
||||
assert.equal(payload.status, 'succeeded')
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await testExecuteStewardActionUsesActionEndpoint()
|
||||
console.log('steward actions service tests passed')
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -71,3 +71,55 @@ test('steward plan summary guides bare reimbursement intent into scene selection
|
||||
assert.match(action.description, /先进入报销助手选择具体费用类型/)
|
||||
assert.equal(action.payload.carry_text, '我要报销')
|
||||
})
|
||||
|
||||
test('steward suggested action carries server executable application action step', () => {
|
||||
const plan = {
|
||||
plan_id: 'plan-application-submit',
|
||||
tasks: [
|
||||
{
|
||||
task_id: 'task-app-1',
|
||||
task_type: 'expense_application',
|
||||
title: '上海出差申请',
|
||||
assigned_agent: 'application_assistant',
|
||||
requested_action: 'submit',
|
||||
ontology_fields: {
|
||||
expense_type: 'travel',
|
||||
time_range: '2026-02-20 至 2026-02-23',
|
||||
location: '上海',
|
||||
reason: '辅助国网仿生产服务器部署',
|
||||
transport_mode: 'train'
|
||||
},
|
||||
missing_fields: [],
|
||||
action_steps: [
|
||||
{ step_id: 'task-app-1:01', action_type: 'fill_application_fields', status: 'planned' },
|
||||
{ step_id: 'task-app-1:02', action_type: 'build_application_preview', status: 'planned' },
|
||||
{ step_id: 'task-app-1:03', action_type: 'validate_required_fields', status: 'planned' },
|
||||
{ step_id: 'task-app-1:04', action_type: 'run_duplicate_precheck', status: 'planned' },
|
||||
{
|
||||
step_id: 'task-app-1:05',
|
||||
action_type: 'submit_application',
|
||||
status: 'pending_confirmation',
|
||||
requires_confirmation: true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
confirmation_groups: [
|
||||
{
|
||||
confirmation_id: 'confirm-task-app-1',
|
||||
action_type: 'confirm_create_application',
|
||||
target_task_id: 'task-app-1'
|
||||
}
|
||||
],
|
||||
next_action: 'confirm_task'
|
||||
}
|
||||
|
||||
const [action] = buildStewardSuggestedActions(plan)
|
||||
|
||||
assert.equal(action.payload.steward_execute_action, true)
|
||||
assert.equal(action.payload.steward_action_type, 'submit_application')
|
||||
assert.equal(action.payload.steward_action_step.step_id, 'task-app-1:05')
|
||||
assert.equal(action.payload.steward_action_requires_confirmation, true)
|
||||
assert.equal(action.payload.steward_current_task.requested_action, 'submit')
|
||||
assert.equal(action.payload.steward_current_task.action_steps.at(-1).action_type, 'submit_application')
|
||||
})
|
||||
|
||||
@@ -245,3 +245,147 @@ test('workbench standalone draft action asks before creating a new reimbursement
|
||||
label: '独立新建报销单'
|
||||
})
|
||||
})
|
||||
|
||||
test('workbench steward executable submit action runs precheck before submit and writes result message', async () => {
|
||||
const requests = []
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = async (_url, options = {}) => {
|
||||
const body = JSON.parse(String(options.body || '{}'))
|
||||
requests.push(body)
|
||||
if (body.action_type === 'run_duplicate_precheck') {
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
action_type: 'run_duplicate_precheck',
|
||||
status: 'succeeded',
|
||||
message: '未发现重复或冲突申请,可以继续提交。',
|
||||
result_payload: {
|
||||
status: 'ok',
|
||||
blocking: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
action_type: 'submit_application',
|
||||
status: 'succeeded',
|
||||
message: '申请已提交审批。',
|
||||
result_payload: {
|
||||
draft_payload: {
|
||||
claim_id: 'claim-app-1',
|
||||
claim_no: 'A1BCDEF2'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const messages = []
|
||||
let messageSeq = 0
|
||||
const createInlineMessage = (role, content, options = {}) => ({
|
||||
id: options.id || `msg-${++messageSeq}`,
|
||||
role,
|
||||
content,
|
||||
pending: Boolean(options.pending),
|
||||
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : []
|
||||
})
|
||||
const replaceInlineMessage = (id, nextMessage) => {
|
||||
const index = messages.findIndex((item) => item.id === id)
|
||||
if (index >= 0) {
|
||||
messages.splice(index, 1, nextMessage)
|
||||
}
|
||||
}
|
||||
let persisted = false
|
||||
const router = useWorkbenchAiActionRouter({
|
||||
aiExpenseDraft: { value: null },
|
||||
applicationFlow: {
|
||||
isInlineSuggestedActionDisabled: () => false,
|
||||
executeInlineApplicationPreviewAction: () => {}
|
||||
},
|
||||
assistantDraft: { value: '' },
|
||||
attachmentFlow: {
|
||||
confirmAiAttachmentAssociation: () => {}
|
||||
},
|
||||
conversationMessages: { value: messages },
|
||||
createInlineMessage,
|
||||
emit: () => {},
|
||||
expenseFlow: {
|
||||
linkAiExpenseApplication: () => {},
|
||||
pushInlineExpenseSceneSelectionPrompt: () => {},
|
||||
startAiApplicationPreviewFromAction: () => {},
|
||||
startAiExpenseDraft: () => {}
|
||||
},
|
||||
focusAiModeInput: () => {},
|
||||
hasInlineAttachmentOcrDetails: () => false,
|
||||
persistCurrentConversation: () => {
|
||||
persisted = true
|
||||
},
|
||||
replaceInlineMessage,
|
||||
resolveLatestInlineUserPrompt: () => '2026-02-20 至 2026-02-23,去上海出差,交通火车,直接提交',
|
||||
scrollInlineConversationToBottom: () => {},
|
||||
selectedFiles: { value: [] },
|
||||
startInlineConversation: () => {},
|
||||
toast: () => {},
|
||||
toggleInlineAttachmentOcrDetails: () => {}
|
||||
})
|
||||
const sourceMessage = {
|
||||
suggestedActionsLocked: false
|
||||
}
|
||||
|
||||
await router.handleInlineSuggestedAction({
|
||||
label: '确认提交申请',
|
||||
action_type: 'switch_session',
|
||||
payload: {
|
||||
steward_execute_action: true,
|
||||
steward_plan_id: 'plan-submit-1',
|
||||
steward_action_type: 'submit_application',
|
||||
steward_action_requires_confirmation: true,
|
||||
steward_action_step: {
|
||||
step_id: 'task-app-1:05',
|
||||
action_type: 'submit_application',
|
||||
requires_confirmation: true
|
||||
},
|
||||
steward_current_task: {
|
||||
task_id: 'task-app-1',
|
||||
task_type: 'expense_application',
|
||||
assigned_agent: 'application_assistant',
|
||||
title: '上海出差申请',
|
||||
summary: '2026-02-20 至 2026-02-23 去上海出差,交通火车。',
|
||||
requested_action: 'submit',
|
||||
ontology_fields: {
|
||||
expense_type: 'travel',
|
||||
time_range: '2026-02-20 至 2026-02-23',
|
||||
location: '上海',
|
||||
reason: '辅助国网仿生产服务器部署',
|
||||
transport_mode: 'train'
|
||||
},
|
||||
missing_fields: [],
|
||||
action_steps: [
|
||||
{ step_id: 'task-app-1:04', action_type: 'run_duplicate_precheck' },
|
||||
{ step_id: 'task-app-1:05', action_type: 'submit_application', requires_confirmation: true }
|
||||
]
|
||||
},
|
||||
carry_text: '2026-02-20 至 2026-02-23,去上海出差,交通火车,直接提交'
|
||||
}
|
||||
}, sourceMessage)
|
||||
|
||||
assert.equal(requests.length, 2)
|
||||
assert.equal(requests[0].action_type, 'run_duplicate_precheck')
|
||||
assert.equal(requests[1].action_type, 'submit_application')
|
||||
assert.equal(requests[1].confirmed, true)
|
||||
assert.equal(requests[1].context_json.precheck_result.status, 'ok')
|
||||
assert.equal(sourceMessage.suggestedActionsLocked, true)
|
||||
assert.equal(persisted, true)
|
||||
assert.match(messages.at(-1).content, /申请已提交审批/)
|
||||
assert.equal(messages.at(-1).suggestedActions[0].action_type, 'open_application_detail')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
166
web/tests/workbench-ai-application-context-submit.test.mjs
Normal file
166
web/tests/workbench-ai-application-context-submit.test.mjs
Normal file
@@ -0,0 +1,166 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { useWorkbenchAiApplicationPreviewFlow } from '../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js'
|
||||
|
||||
function createRef(value) {
|
||||
return { value }
|
||||
}
|
||||
|
||||
function createInlineMessage(role, content, options = {}) {
|
||||
return {
|
||||
id: options.id || `msg-${Math.random().toString(16).slice(2)}`,
|
||||
role,
|
||||
content,
|
||||
text: content,
|
||||
paragraphs: String(content || '').split(/\n+/).filter(Boolean),
|
||||
pending: Boolean(options.pending),
|
||||
...options
|
||||
}
|
||||
}
|
||||
|
||||
function buildApplicationPreviewFlowHarness(messages) {
|
||||
const conversationMessages = createRef(messages)
|
||||
const applicationSubmitConfirmOpen = createRef(false)
|
||||
const applicationSubmitConfirmContext = createRef(null)
|
||||
const persisted = createRef(0)
|
||||
|
||||
const flow = useWorkbenchAiApplicationPreviewFlow({
|
||||
activateInlineConversation: () => {},
|
||||
applicationPreviewEditor: createRef({}),
|
||||
applicationSubmitConfirmContext,
|
||||
applicationSubmitConfirmOpen,
|
||||
assistantDraft: createRef(''),
|
||||
cancelApplicationPreviewEditor: () => {},
|
||||
clearAiModeFiles: () => {},
|
||||
closeWorkbenchDatePicker: () => {},
|
||||
commitApplicationPreviewEditor: async () => true,
|
||||
conversationId: createRef('conversation-context-submit'),
|
||||
conversationMessages,
|
||||
conversationStarted: createRef(true),
|
||||
createInlineMessage,
|
||||
currentUser: createRef({ username: 'zhangsan@example.com', name: '张三' }),
|
||||
handleApplicationPreviewEditorKeydown: () => {},
|
||||
inlineConversationAutoScrollPinned: createRef(true),
|
||||
isApplicationPreviewEditing: createRef(false),
|
||||
openApplicationPreviewEditor: () => {},
|
||||
persistCurrentConversation: () => { persisted.value += 1 },
|
||||
pushInlineApplicationActionUserMessage: (text) => {
|
||||
conversationMessages.value.push(createInlineMessage('user', text))
|
||||
},
|
||||
pushInlineUserMessage: (text) => {
|
||||
conversationMessages.value.push(createInlineMessage('user', text))
|
||||
},
|
||||
refreshApplicationPreviewEstimate: async (preview) => preview,
|
||||
removeWorkbenchDateTag: () => {},
|
||||
replaceInlineMessage: (id, nextMessage) => {
|
||||
const index = conversationMessages.value.findIndex((item) => item.id === id)
|
||||
if (index >= 0) {
|
||||
conversationMessages.value.splice(index, 1, nextMessage)
|
||||
} else {
|
||||
conversationMessages.value.push(nextMessage)
|
||||
}
|
||||
},
|
||||
resolveApplicationPreviewEditorDateMax: () => '',
|
||||
resolveApplicationPreviewEditorDateMin: () => '',
|
||||
resolveApplicationPreviewEditorControl: () => null,
|
||||
resolveApplicationPreviewEditorOptions: () => [],
|
||||
resolveInlineThinkingEvents: (message) => message?.stewardPlan?.thinkingEvents || [],
|
||||
resolveLatestInlineUserPrompt: () => '2026-02-20 至 2026-02-23,去上海出差,交通火车,保存草稿',
|
||||
scrollInlineConversationToBottom: () => {},
|
||||
sending: createRef(false),
|
||||
toast: () => {}
|
||||
})
|
||||
|
||||
return {
|
||||
applicationSubmitConfirmOpen,
|
||||
conversationMessages,
|
||||
flow,
|
||||
persisted
|
||||
}
|
||||
}
|
||||
|
||||
test('workbench saved application draft can be submitted by contextual text without re-planning', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const requests = []
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
const normalizedUrl = String(url)
|
||||
if (normalizedUrl.includes('/reimbursements/claims')) {
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { items: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (normalizedUrl.includes('/reimbursements/application-preview-action')) {
|
||||
const body = JSON.parse(String(options.body || '{}'))
|
||||
requests.push({ url: normalizedUrl, body })
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
status: 'succeeded',
|
||||
result: {
|
||||
draft_payload: {
|
||||
claim_id: 'claim-saved-draft',
|
||||
claim_no: 'A20260220',
|
||||
status: 'submitted',
|
||||
approval_stage: '直属领导审批'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected request: ${normalizedUrl}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const previewMessage = createInlineMessage('assistant', '申请核对表', {
|
||||
id: 'application-preview-1',
|
||||
applicationPreview: {
|
||||
readyToSubmit: true,
|
||||
fields: {
|
||||
applicationType: '差旅费用申请',
|
||||
time: '2026-02-20 至 2026-02-23',
|
||||
location: '上海',
|
||||
reason: '辅助国网仿生产服务器部署',
|
||||
days: '4天',
|
||||
transportMode: '火车',
|
||||
amount: '1200元'
|
||||
},
|
||||
missingFields: [],
|
||||
validationIssues: []
|
||||
},
|
||||
draftPayload: {
|
||||
claim_id: 'claim-saved-draft',
|
||||
claim_no: 'A20260220',
|
||||
status: 'draft'
|
||||
}
|
||||
})
|
||||
const harness = buildApplicationPreviewFlowHarness([
|
||||
createInlineMessage('user', '2026-02-20 至 2026-02-23,去上海出差,交通火车,保存草稿'),
|
||||
previewMessage,
|
||||
createInlineMessage('assistant', '### 申请草稿已保存', {
|
||||
draftPayload: previewMessage.draftPayload
|
||||
})
|
||||
])
|
||||
|
||||
const handled = harness.flow.handleInlineApplicationPreviewTextAction(
|
||||
'提交这个单据',
|
||||
createRef(false)
|
||||
)
|
||||
assert.equal(handled, true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
assert.equal(harness.applicationSubmitConfirmOpen.value, false)
|
||||
assert.equal(requests.length, 1)
|
||||
assert.equal(requests[0].body.context_json.application_edit_claim_id, 'claim-saved-draft')
|
||||
assert.equal(requests[0].body.context_json.application_edit_mode, true)
|
||||
assert.match(harness.conversationMessages.value.at(-1).content, /申请单据已生成/)
|
||||
assert.ok(harness.persisted.value > 0)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import test from 'node:test'
|
||||
import {
|
||||
isOrphanInlineApplicationPreviewMessage,
|
||||
isReimbursementCreationIntent,
|
||||
resolveInlineTravelApplicationRequest,
|
||||
resolveInlineApplicationPreviewTextAction,
|
||||
resolveLatestApplicationPreviewMessage,
|
||||
resolveLatestOrphanApplicationPreviewMessage
|
||||
@@ -28,9 +29,24 @@ test('workbench application gate resolves save and submit text actions consisten
|
||||
assert.equal(resolveInlineApplicationPreviewTextAction(' 先保存 '), AI_APPLICATION_ACTION_SAVE_DRAFT)
|
||||
assert.equal(resolveInlineApplicationPreviewTextAction('确认提交'), AI_APPLICATION_ACTION_SUBMIT)
|
||||
assert.equal(resolveInlineApplicationPreviewTextAction('直接提交'), AI_APPLICATION_ACTION_SUBMIT)
|
||||
assert.equal(resolveInlineApplicationPreviewTextAction('提交这个单据'), AI_APPLICATION_ACTION_SUBMIT)
|
||||
assert.equal(resolveInlineApplicationPreviewTextAction('提交这个申请单'), AI_APPLICATION_ACTION_SUBMIT)
|
||||
assert.equal(resolveInlineApplicationPreviewTextAction('继续修改'), '')
|
||||
})
|
||||
|
||||
test('workbench application gate detects compact travel application direct submit intent', () => {
|
||||
const request = resolveInlineTravelApplicationRequest('去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交')
|
||||
|
||||
assert.deepEqual(request, {
|
||||
expenseType: 'travel',
|
||||
expenseTypeLabel: '差旅费',
|
||||
sourceText: '去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交',
|
||||
autoSubmit: true
|
||||
})
|
||||
assert.equal(resolveInlineTravelApplicationRequest('去上海出差,辅助国网仿生产服务器部署,交通火车')?.autoSubmit, false)
|
||||
assert.equal(resolveInlineTravelApplicationRequest('帮我查询上海差旅标准'), null)
|
||||
})
|
||||
|
||||
test('workbench application gate resolves latest live or orphan preview message', () => {
|
||||
const messages = [
|
||||
{ id: 'user-1', role: 'user', content: '2月去上海出差' },
|
||||
|
||||
331
web/tests/workbench-ai-intent-planner-model.test.mjs
Normal file
331
web/tests/workbench-ai-intent-planner-model.test.mjs
Normal file
@@ -0,0 +1,331 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
WORKBENCH_AI_INTENT_SOURCE_MODEL,
|
||||
WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK,
|
||||
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
|
||||
WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT,
|
||||
WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK,
|
||||
WORKBENCH_AI_STEP_SUBMIT_APPLICATION,
|
||||
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
|
||||
buildRuleFallbackWorkbenchAiIntentPlan,
|
||||
normalizeWorkbenchAiIntentPlan,
|
||||
resolveExecutableTravelApplicationPlan,
|
||||
shouldRequestWorkbenchAiIntentPlan
|
||||
} from '../src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js'
|
||||
import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
|
||||
|
||||
const personalWorkbenchAiModeScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stewardFlowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const applicationPreviewFlowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const planningThinkingModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/workbenchAiMode/workbenchAiPlanningThinkingModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('workbench AI intent planner normalizes model travel application submit plan into executable steps', () => {
|
||||
const plan = normalizeWorkbenchAiIntentPlan({
|
||||
planning_source: 'llm_function_call',
|
||||
tasks: [{
|
||||
task_type: 'expense_application',
|
||||
assigned_agent: 'application_assistant',
|
||||
requested_action: 'submit',
|
||||
confidence: 0.91,
|
||||
ontology_fields: {
|
||||
time_range: '2026-02-20 至 2026-02-23',
|
||||
location: '上海',
|
||||
reason: '辅助国网仿生产服务器部署',
|
||||
transport_mode: '火车'
|
||||
}
|
||||
}]
|
||||
}, {
|
||||
prompt: '去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交'
|
||||
})
|
||||
|
||||
assert.equal(plan.source, WORKBENCH_AI_INTENT_SOURCE_MODEL)
|
||||
assert.equal(plan.intent, 'create_travel_application')
|
||||
assert.equal(plan.requestedAction, 'submit')
|
||||
assert.deepEqual(plan.steps, [
|
||||
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
|
||||
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
|
||||
WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK,
|
||||
WORKBENCH_AI_STEP_SUBMIT_APPLICATION
|
||||
])
|
||||
assert.deepEqual(plan.ontologyFields, {
|
||||
time_range: '2026-02-20 至 2026-02-23',
|
||||
location: '上海',
|
||||
reason: '辅助国网仿生产服务器部署',
|
||||
transport_mode: '火车'
|
||||
})
|
||||
assert.deepEqual(plan.slots, {
|
||||
timeRange: '2026-02-20 至 2026-02-23',
|
||||
location: '上海',
|
||||
reason: '辅助国网仿生产服务器部署',
|
||||
transportMode: '火车'
|
||||
})
|
||||
})
|
||||
|
||||
test('workbench AI intent planner prefers server action steps when present', () => {
|
||||
const plan = normalizeWorkbenchAiIntentPlan({
|
||||
planning_source: 'llm_function_call',
|
||||
tasks: [{
|
||||
task_type: 'expense_application',
|
||||
assigned_agent: 'application_assistant',
|
||||
requested_action: 'submit',
|
||||
confidence: 0.91,
|
||||
ontology_fields: {
|
||||
time_range: '2026-02-20 至 2026-02-23',
|
||||
location: '上海',
|
||||
reason: '辅助国网仿生产服务器部署',
|
||||
transport_mode: '火车'
|
||||
},
|
||||
action_steps: [
|
||||
{ action_type: 'fill_application_fields' },
|
||||
{ action_type: 'build_application_preview' },
|
||||
{ action_type: 'validate_required_fields' },
|
||||
{ action_type: 'save_application_draft' }
|
||||
]
|
||||
}]
|
||||
}, {
|
||||
prompt: '2026-02-20 至 2026-02-23,去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交'
|
||||
})
|
||||
|
||||
assert.deepEqual(plan.steps, [
|
||||
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
|
||||
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
|
||||
WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT
|
||||
])
|
||||
})
|
||||
|
||||
test('workbench AI intent planner falls back to rule plan for compact travel direct submit', () => {
|
||||
const plan = buildRuleFallbackWorkbenchAiIntentPlan('去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交')
|
||||
|
||||
assert.equal(plan.source, WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK)
|
||||
assert.equal(plan.intent, 'create_travel_application')
|
||||
assert.equal(plan.requestedAction, 'submit')
|
||||
assert.deepEqual(plan.steps, [
|
||||
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
|
||||
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
|
||||
WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK,
|
||||
WORKBENCH_AI_STEP_SUBMIT_APPLICATION
|
||||
])
|
||||
})
|
||||
|
||||
test('workbench AI intent planner detects compact travel save-draft variant before rules are enough', () => {
|
||||
const prompt = '2026-02-20 至 2026-02-23,上海出差,国网仿生产服务器部署,火车,保存草稿。'
|
||||
const plan = buildRuleFallbackWorkbenchAiIntentPlan(prompt)
|
||||
|
||||
assert.equal(shouldRequestWorkbenchAiIntentPlan(prompt), true)
|
||||
assert.equal(shouldRequestWorkbenchAiIntentPlan('帮我查询上海差旅标准'), true)
|
||||
assert.equal(shouldRequestWorkbenchAiIntentPlan('1'), false)
|
||||
assert.equal(plan.source, WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK)
|
||||
assert.equal(plan.intent, 'create_travel_application')
|
||||
assert.equal(plan.requestedAction, 'save_draft')
|
||||
assert.deepEqual(plan.steps, [
|
||||
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
|
||||
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
|
||||
WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT
|
||||
])
|
||||
assert.deepEqual(resolveExecutableTravelApplicationPlan(plan), {
|
||||
expenseType: 'travel',
|
||||
expenseTypeLabel: '差旅费',
|
||||
sourceText: prompt,
|
||||
ontologyFields: {},
|
||||
autoSubmit: false,
|
||||
autoSaveDraft: true
|
||||
})
|
||||
})
|
||||
|
||||
test('workbench AI intent planner turns model fields and action into executable application preview payload', () => {
|
||||
const prompt = '2026-02-20 至 2026-02-23,上海出差,国网仿生产服务器部署,火车,保存草稿。'
|
||||
const plan = normalizeWorkbenchAiIntentPlan({
|
||||
planning_source: 'llm_function_call',
|
||||
tasks: [{
|
||||
task_type: 'expense_application',
|
||||
assigned_agent: 'application_assistant',
|
||||
requested_action: 'save_draft',
|
||||
confidence: 0.95,
|
||||
ontology_fields: {
|
||||
time_range: '2026-02-20 至 2026-02-23',
|
||||
location: '上海',
|
||||
reason: '国网仿生产服务器部署',
|
||||
transport_mode: '火车'
|
||||
},
|
||||
missing_fields: []
|
||||
}]
|
||||
}, { prompt })
|
||||
|
||||
assert.deepEqual(resolveExecutableTravelApplicationPlan(plan), {
|
||||
expenseType: 'travel',
|
||||
expenseTypeLabel: '差旅费',
|
||||
sourceText: prompt,
|
||||
ontologyFields: {
|
||||
time_range: '2026-02-20 至 2026-02-23',
|
||||
location: '上海',
|
||||
reason: '国网仿生产服务器部署',
|
||||
transport_mode: '火车'
|
||||
},
|
||||
autoSubmit: false,
|
||||
autoSaveDraft: true
|
||||
})
|
||||
})
|
||||
|
||||
test('workbench AI intent planner turns single application candidate flow into executable preview payload', () => {
|
||||
const prompt = '2026-02-20 至 2026-02-23,去上海出差,辅助国网仿生产服务器部署,交通火车'
|
||||
const plan = normalizeWorkbenchAiIntentPlan({
|
||||
planning_source: 'rule_fallback',
|
||||
plan_status: 'needs_flow_confirmation',
|
||||
pending_flow_confirmation: {
|
||||
status: 'pending',
|
||||
candidate_flows: [{
|
||||
flow_id: 'travel_application',
|
||||
label: '先发起出差申请',
|
||||
confidence: 0.86,
|
||||
ontology_fields: {
|
||||
expense_type: 'travel',
|
||||
time_range: '2026-02-20 至 2026-02-23',
|
||||
location: '上海',
|
||||
reason: '辅助国网仿生产服务器部署',
|
||||
transport_mode: '火车'
|
||||
},
|
||||
missing_fields: []
|
||||
}]
|
||||
}
|
||||
}, { prompt })
|
||||
|
||||
assert.equal(plan.source, WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK)
|
||||
assert.equal(plan.intent, 'create_travel_application')
|
||||
assert.equal(plan.requestedAction, 'preview')
|
||||
assert.deepEqual(resolveExecutableTravelApplicationPlan(plan), {
|
||||
expenseType: 'travel',
|
||||
expenseTypeLabel: '差旅费',
|
||||
sourceText: prompt,
|
||||
ontologyFields: {
|
||||
expense_type: 'travel',
|
||||
time_range: '2026-02-20 至 2026-02-23',
|
||||
location: '上海',
|
||||
reason: '辅助国网仿生产服务器部署',
|
||||
transport_mode: '火车'
|
||||
},
|
||||
autoSubmit: false,
|
||||
autoSaveDraft: false
|
||||
})
|
||||
})
|
||||
|
||||
test('workbench AI application preview prefers model ontology fields over local text guesses', () => {
|
||||
const preview = buildInlineApplicationPreview(
|
||||
'差旅费',
|
||||
'2026-02-20 至 2026-02-23,上海出差,国网仿生产服务器部署,火车,保存草稿。',
|
||||
{ name: '李文静', grade: 'P5', location: '武汉' },
|
||||
{
|
||||
ontologyFields: {
|
||||
time_range: '2026-02-20 至 2026-02-23',
|
||||
location: '上海',
|
||||
reason: '国网仿生产服务器部署',
|
||||
transport_mode: '火车'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
|
||||
assert.equal(preview.fields.location, '上海')
|
||||
assert.equal(preview.fields.reason, '国网仿生产服务器部署')
|
||||
assert.equal(preview.fields.transportMode, '火车')
|
||||
})
|
||||
|
||||
test('workbench AI intent planner rejects policy question and resolves executable application request', () => {
|
||||
assert.equal(buildRuleFallbackWorkbenchAiIntentPlan('帮我查询上海差旅标准'), null)
|
||||
|
||||
const request = resolveExecutableTravelApplicationPlan(
|
||||
buildRuleFallbackWorkbenchAiIntentPlan('去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交')
|
||||
)
|
||||
|
||||
assert.deepEqual(request, {
|
||||
expenseType: 'travel',
|
||||
expenseTypeLabel: '差旅费',
|
||||
sourceText: '去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交',
|
||||
ontologyFields: {},
|
||||
autoSubmit: true,
|
||||
autoSaveDraft: false
|
||||
})
|
||||
})
|
||||
|
||||
test('workbench AI mode asks steward model plan before fallback execution', () => {
|
||||
assert.match(stewardFlowScript, /async function resolveInlineExecutionPlan\(prompt, entry = \{\}, files = \[\], options = \{\}\)/)
|
||||
assert.match(stewardFlowScript, /fetchStewardPlan\(planRequest/)
|
||||
assert.match(stewardFlowScript, /timeoutMs:\s*35000/)
|
||||
assert.match(personalWorkbenchAiModeScript, /async function executeModelPlannedWorkbenchIntent\(cleanPrompt, entry = \{\}, files = \[\]\)/)
|
||||
assert.match(personalWorkbenchAiModeScript, /await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files,\s*\{/)
|
||||
assert.match(personalWorkbenchAiModeScript, /normalizeWorkbenchAiIntentPlan\(modelPlan,\s*\{\s*prompt:\s*cleanPrompt/)
|
||||
assert.match(personalWorkbenchAiModeScript, /buildRuleFallbackWorkbenchAiIntentPlan\(cleanPrompt\)/)
|
||||
assert.match(personalWorkbenchAiModeScript, /shouldRequestWorkbenchAiIntentPlan\(cleanPrompt\)/)
|
||||
assert.match(personalWorkbenchAiModeScript, /resolveExecutableTravelApplicationPlan\(intentPlan\)/)
|
||||
assert.doesNotMatch(personalWorkbenchAiModeScript, /fallbackIntentPlan/)
|
||||
assert.match(personalWorkbenchAiModeScript, /autoSaveDraft:\s*travelApplicationRequest\.autoSaveDraft/)
|
||||
assert.match(personalWorkbenchAiModeScript, /ontologyFields:\s*travelApplicationRequest\.ontologyFields/)
|
||||
assert.match(applicationPreviewFlowScript, /options\.autoSaveDraft/)
|
||||
assert.match(applicationPreviewFlowScript, /ontologyFields:\s*options\.ontologyFields/)
|
||||
assert.match(applicationPreviewFlowScript, /executeInlineApplicationPreviewAction\(AI_APPLICATION_ACTION_SAVE_DRAFT/)
|
||||
assert.match(applicationPreviewFlowScript, /buildInlineApplicationPreview\([\s\S]*ontologyFields:\s*options\.ontologyFields/)
|
||||
assert.doesNotMatch(personalWorkbenchAiModeScript, /const travelApplicationRequest = resolveInlineTravelApplicationRequest\(cleanPrompt\)/)
|
||||
})
|
||||
|
||||
test('workbench AI mode shows a visible planning response before waiting for steward model plan', () => {
|
||||
assert.match(personalWorkbenchAiModeScript, /function startModelPlanningConversation\(cleanPrompt, entry = \{\}\)/)
|
||||
assert.match(personalWorkbenchAiModeScript, /conversationMessages\.value\.push\(createInlineMessage\('user', cleanPrompt\)\)/)
|
||||
assert.match(personalWorkbenchAiModeScript, /正在识别意图,准备拆解申请、报销和附件任务/)
|
||||
assert.match(
|
||||
personalWorkbenchAiModeScript,
|
||||
/const plannerPendingMessage = startModelPlanningConversation\(cleanPrompt, entry\)[\s\S]*await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files,\s*\{/
|
||||
)
|
||||
assert.match(
|
||||
personalWorkbenchAiModeScript,
|
||||
/pendingMessageId:\s*plannerPendingMessage\?\.id/
|
||||
)
|
||||
assert.match(applicationPreviewFlowScript, /options\.pendingMessageId/)
|
||||
})
|
||||
|
||||
test('workbench AI mode streams planning thinking into the pending message', () => {
|
||||
assert.match(planningThinkingModelScript, /buildModelPlanningProgressSchedule/)
|
||||
assert.match(planningThinkingModelScript, /判断办理意图/)
|
||||
assert.match(planningThinkingModelScript, /抽取关键信息/)
|
||||
assert.match(planningThinkingModelScript, /规划执行步骤/)
|
||||
assert.match(planningThinkingModelScript, /准备兜底策略/)
|
||||
assert.match(personalWorkbenchAiModeScript, /function startModelPlanningProgressUpdates\(messageId\)/)
|
||||
assert.match(personalWorkbenchAiModeScript, /globalThis\.setTimeout\(\(\) => \{\s*updateModelPlanningThinkingEvent\(messageId, event\)/)
|
||||
assert.match(personalWorkbenchAiModeScript, /const stopPlanningProgressUpdates = startModelPlanningProgressUpdates\(plannerPendingMessage\.id\)/)
|
||||
assert.match(personalWorkbenchAiModeScript, /stopPlanningProgressUpdates\(\)/)
|
||||
assert.match(
|
||||
personalWorkbenchAiModeScript,
|
||||
/await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files,\s*\{\s*pendingMessageId:\s*plannerPendingMessage\.id\s*\}\)/
|
||||
)
|
||||
assert.match(stewardFlowScript, /async function resolveInlineExecutionPlan\(prompt, entry = \{\}, files = \[\], options = \{\}\)/)
|
||||
assert.match(stewardFlowScript, /fetchInlineStewardPlan\(planningMessageId, planRequest,\s*\{[\s\S]*includeAnswerDelta:\s*false/)
|
||||
assert.match(applicationPreviewFlowScript, /mergeWorkbenchAiThinkingEvents\(previousThinkingEvents,\s*\[/)
|
||||
})
|
||||
|
||||
test('workbench AI mode reuses planning pending message for regular steward replies', () => {
|
||||
assert.match(
|
||||
personalWorkbenchAiModeScript,
|
||||
/stewardFlow\.requestInlineAssistantReply\(cleanPrompt, entry, files,\s*\{\s*pendingMessageId:\s*plannerPendingMessage\.id\s*\}\)/
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
personalWorkbenchAiModeScript,
|
||||
/replaceInlineMessage\(plannerPendingMessage\.id,\s*createInlineMessage\('assistant', '已完成意图识别,继续为您整理回复。'/
|
||||
)
|
||||
assert.match(stewardFlowScript, /async function requestInlineAssistantReply\(prompt, entry = \{\}, files = \[\], options = \{\}\)/)
|
||||
assert.match(stewardFlowScript, /const reusablePendingMessageId = String\(options\.pendingMessageId \|\| ''\)\.trim\(\)/)
|
||||
assert.match(stewardFlowScript, /reusablePendingMessageId \? replaceInlineMessage\(reusablePendingMessageId, pendingMessage\) : conversationMessages\.value\.push\(pendingMessage\)/)
|
||||
})
|
||||
Reference in New Issue
Block a user