feat: 新增风险规则生成引擎与知识图谱可视化
后端新增风险规则自动生成和模板执行服务,支持从规则资产 批量生成并持久化风险规则文件;知识库入库日志增强图谱 查询和本地 RAG 回退,前端审计页面增加风险规则模型和流 程图组件,知识入库面板拆分为图谱可视化子组件,报销创 建页面增加引导式流程模型,更新知识库索引数据。
This commit is contained in:
189
web/tests/travel-reimbursement-guided-flow.test.mjs
Normal file
189
web/tests/travel-reimbursement-guided-flow.test.mjs
Normal file
@@ -0,0 +1,189 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
EXPENSE_WELCOME_QUICK_ACTIONS,
|
||||
SESSION_TYPE_EXPENSE,
|
||||
SESSION_TYPE_KNOWLEDGE,
|
||||
buildWelcomeQuickActions
|
||||
} from '../src/views/scripts/travelReimbursementConversationModel.js'
|
||||
import {
|
||||
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
|
||||
GUIDED_ACTION_CONTINUE_FILLING,
|
||||
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
||||
GUIDED_ACTION_PROCESS_INTERRUPTION,
|
||||
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
||||
GUIDED_ACTION_SELECT_QUERY_MODE,
|
||||
GUIDED_ACTION_SELECT_QUERY_STATUS,
|
||||
GUIDED_ACTION_START_REIMBURSEMENT,
|
||||
GUIDED_ACTION_START_STATUS_QUERY,
|
||||
GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||||
GUIDED_FLOW_MODE_STATUS_QUERY,
|
||||
applyGuidedReimbursementAnswer,
|
||||
buildGuidedExpenseTypeActions,
|
||||
buildGuidedInterruptionActions,
|
||||
buildGuidedQueryModeActions,
|
||||
buildGuidedQueryStatusActions,
|
||||
buildGuidedReimbursementSummaryText,
|
||||
buildGuidedReviewConfirmationActions,
|
||||
buildGuidedReviewSubmitOptions,
|
||||
buildGuidedStatusQueryText,
|
||||
buildGuidedStepPromptText,
|
||||
createEmptyGuidedFlowState,
|
||||
createGuidedReimbursementState,
|
||||
createGuidedStatusQueryState,
|
||||
isGuidedReimbursementReadyForReview,
|
||||
normalizeGuidedFlowState,
|
||||
selectGuidedExpenseType,
|
||||
selectGuidedQueryMode,
|
||||
shouldConfirmGuidedInterruption
|
||||
} from '../src/views/scripts/travelReimbursementGuidedFlowModel.js'
|
||||
|
||||
const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const guidedFlowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementGuidedFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const sessionStateScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('welcome quick actions are reduced to three guided local actions', () => {
|
||||
assert.deepEqual(
|
||||
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
|
||||
['快速发起报销', '查询单据状态', '差旅计算器']
|
||||
)
|
||||
assert.deepEqual(
|
||||
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.action),
|
||||
[
|
||||
GUIDED_ACTION_START_REIMBURSEMENT,
|
||||
GUIDED_ACTION_START_STATUS_QUERY,
|
||||
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR
|
||||
]
|
||||
)
|
||||
assert.ok(EXPENSE_WELCOME_QUICK_ACTIONS.every((item) => !item.prompt))
|
||||
assert.equal(buildWelcomeQuickActions(SESSION_TYPE_EXPENSE).length, 3)
|
||||
assert.notDeepEqual(
|
||||
buildWelcomeQuickActions(SESSION_TYPE_KNOWLEDGE).map((item) => item.label),
|
||||
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
|
||||
'knowledge hot questions should stay independent'
|
||||
)
|
||||
})
|
||||
|
||||
test('guided reimbursement asks type first and walks travel fields in order', () => {
|
||||
const typeActions = buildGuidedExpenseTypeActions()
|
||||
assert.deepEqual(
|
||||
typeActions.map((action) => action.label),
|
||||
['差旅费', '交通费', '住宿费', '业务招待费', '办公用品费', '其他费用']
|
||||
)
|
||||
assert.ok(typeActions.every((action) => action.action_type === GUIDED_ACTION_SELECT_EXPENSE_TYPE))
|
||||
|
||||
let state = createGuidedReimbursementState()
|
||||
assert.equal(state.mode, GUIDED_FLOW_MODE_REIMBURSEMENT)
|
||||
assert.equal(state.stepKey, 'expense_type')
|
||||
|
||||
state = selectGuidedExpenseType(state, 'travel')
|
||||
assert.equal(state.stepKey, 'reason')
|
||||
assert.match(buildGuidedStepPromptText(state), /第 1 步:事由/)
|
||||
|
||||
state = applyGuidedReimbursementAnswer(state, '去上海支持上海电力部署项目')
|
||||
assert.equal(state.stepKey, 'location')
|
||||
state = applyGuidedReimbursementAnswer(state, '上海')
|
||||
assert.equal(state.stepKey, 'time_range')
|
||||
state = applyGuidedReimbursementAnswer(state, '2026-05-20 至 2026-05-23,出差 3 天')
|
||||
assert.equal(state.stepKey, 'amount')
|
||||
state = applyGuidedReimbursementAnswer(state, '待核算')
|
||||
assert.equal(state.stepKey, 'attachments')
|
||||
state = applyGuidedReimbursementAnswer(state, '稍后上传')
|
||||
|
||||
assert.ok(isGuidedReimbursementReadyForReview(state))
|
||||
assert.match(buildGuidedReimbursementSummaryText(state), /已完成“差旅费”的引导填写/)
|
||||
assert.deepEqual(
|
||||
buildGuidedReviewConfirmationActions().map((action) => action.action_type),
|
||||
[GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW]
|
||||
)
|
||||
|
||||
const submitOptions = buildGuidedReviewSubmitOptions(state)
|
||||
assert.equal(submitOptions.systemGenerated, true)
|
||||
assert.equal(submitOptions.extraContext.expense_scene_selection.expense_type, 'travel')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.expense_type, '差旅费')
|
||||
assert.match(submitOptions.rawText, /事由:去上海支持上海电力部署项目/)
|
||||
assert.match(submitOptions.rawText, /出差时间\/天数:2026-05-20 至 2026-05-23,出差 3 天/)
|
||||
})
|
||||
|
||||
test('guided reimbursement interrupts suspicious questions before expensive flow', () => {
|
||||
const state = selectGuidedExpenseType(createGuidedReimbursementState(), 'transport')
|
||||
assert.equal(shouldConfirmGuidedInterruption('送客户去机场', state), false)
|
||||
assert.equal(shouldConfirmGuidedInterruption('帮我查询一下上周的报销状态?', state), true)
|
||||
assert.deepEqual(
|
||||
buildGuidedInterruptionActions().map((action) => action.action_type),
|
||||
[GUIDED_ACTION_CONTINUE_FILLING, GUIDED_ACTION_PROCESS_INTERRUPTION]
|
||||
)
|
||||
})
|
||||
|
||||
test('status query guide collects a query mode before calling existing query flow', () => {
|
||||
let state = createGuidedStatusQueryState()
|
||||
assert.equal(state.mode, GUIDED_FLOW_MODE_STATUS_QUERY)
|
||||
assert.equal(state.stepKey, 'query_mode')
|
||||
assert.ok(buildGuidedQueryModeActions().every((action) => action.action_type === GUIDED_ACTION_SELECT_QUERY_MODE))
|
||||
|
||||
state = selectGuidedQueryMode(state, 'status')
|
||||
assert.equal(state.stepKey, 'status_value')
|
||||
assert.ok(buildGuidedQueryStatusActions().every((action) => action.action_type === GUIDED_ACTION_SELECT_QUERY_STATUS))
|
||||
assert.equal(buildGuidedStatusQueryText(state, '已归档'), '帮我查询已归档的报销单据,筛选最近的 5 条记录')
|
||||
|
||||
const keywordState = selectGuidedQueryMode(createGuidedStatusQueryState(), 'keyword')
|
||||
assert.equal(keywordState.stepKey, 'query_value')
|
||||
assert.equal(
|
||||
buildGuidedStatusQueryText(keywordState, '上海电力'),
|
||||
'帮我查询地点或事由包含“上海电力”的报销单据状态,筛选最近的 5 条记录'
|
||||
)
|
||||
})
|
||||
|
||||
test('guided flow state is serializable and restored through session state', () => {
|
||||
const empty = createEmptyGuidedFlowState()
|
||||
assert.deepEqual(normalizeGuidedFlowState({ mode: 'bad' }), empty)
|
||||
assert.deepEqual(
|
||||
normalizeGuidedFlowState({
|
||||
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||||
stepKey: 'amount',
|
||||
expenseType: 'travel',
|
||||
values: {
|
||||
amount: 200,
|
||||
attachment_names: ['a.pdf', '', 'a.pdf']
|
||||
},
|
||||
pendingInterruptionText: '查询状态?'
|
||||
}),
|
||||
{
|
||||
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||||
stepKey: 'amount',
|
||||
expenseType: 'travel',
|
||||
values: {
|
||||
amount: '200',
|
||||
attachment_names: ['a.pdf']
|
||||
},
|
||||
pendingInterruptionText: '查询状态?'
|
||||
}
|
||||
)
|
||||
|
||||
assert.match(sessionStateScript, /guidedFlowState:\s*normalizeGuidedFlowState\(state\.guidedFlowState\)/)
|
||||
assert.match(sessionStateScript, /runtimeRefs\.guidedFlowState\?\.value/)
|
||||
assert.match(sessionStateScript, /guidedFlowState,\s*\n\s*insightPanelCollapsed/)
|
||||
assert.match(sessionStateScript, /function refreshWelcomeQuickActions/)
|
||||
assert.match(sessionStateScript, /buildWelcomeQuickActions\(/)
|
||||
})
|
||||
|
||||
test('guided flow is local until final confirmation or collected query handoff', () => {
|
||||
assert.doesNotMatch(guidedFlowScript, /runOrchestrator/)
|
||||
assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/)
|
||||
assert.doesNotMatch(guidedFlowScript, /review_action:\s*['"]save_draft['"]/)
|
||||
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
|
||||
assert.match(guidedFlowScript, /submitExistingComposer\(submitOptions\)/)
|
||||
assert.match(guidedFlowScript, /submitExistingComposer\(\{[\s\S]*pendingText:\s*'正在查询单据状态\.\.\.'/)
|
||||
})
|
||||
Reference in New Issue
Block a user