feat: 新增风险规则生成引擎与知识图谱可视化

后端新增风险规则自动生成和模板执行服务,支持从规则资产
批量生成并持久化风险规则文件;知识库入库日志增强图谱
查询和本地 RAG 回退,前端审计页面增加风险规则模型和流
程图组件,知识入库面板拆分为图谱可视化子组件,报销创
建页面增加引导式流程模型,更新知识库索引数据。
This commit is contained in:
caoxiaozhu
2026-05-23 19:54:42 +08:00
parent 5b388d08c0
commit 575f093c74
63 changed files with 35497 additions and 1517 deletions

View File

@@ -18,10 +18,12 @@ const sampleRows = [
id: 'EXP-001',
typeCode: 'travel',
type: '差旅费',
archiveTypeCode: 'reimbursement',
archiveType: '报销',
department: '研发部',
archiveMonth: '2026-05',
archiveMonthLabel: '2026年05月',
archiveTab: '差旅报销',
archiveTab: '报销归档',
hasRisk: true,
riskTone: 'high',
risk: '2条',
@@ -31,10 +33,12 @@ const sampleRows = [
id: 'EXP-002',
typeCode: 'entertainment',
type: '业务招待费',
archiveTypeCode: 'reimbursement',
archiveType: '报销',
department: '销售部',
archiveMonth: '2026-04',
archiveMonthLabel: '2026年04月',
archiveTab: '招待报销',
archiveTab: '报销归档',
hasRisk: false,
riskTone: 'none',
risk: '0条',
@@ -80,14 +84,27 @@ test('applyArchiveListFilters supports department and archive month', () => {
assert.equal(filtered[0].id, 'EXP-002')
})
test('build filter options are derived from loaded rows', () => {
test('applyArchiveListFilters supports the unified reimbursement archive tab', () => {
const reimbursementRows = applyArchiveListFilters(sampleRows, {
tab: '报销归档'
})
const oldTravelRows = applyArchiveListFilters(sampleRows, {
tab: '差旅报销'
})
assert.equal(reimbursementRows.length, 2)
assert.equal(oldTravelRows.length, 0)
})
test('build filter options are derived from archive types', () => {
const typeLabels = buildTypeFilterOptions(sampleRows).map((item) => item.label)
const typeValues = buildTypeFilterOptions(sampleRows).map((item) => item.value)
const departmentLabels = buildDepartmentFilterOptions(sampleRows).map((item) => item.label)
const monthOptions = buildArchiveMonthFilterOptions(sampleRows)
assert.equal(typeLabels[0], '全部类型')
assert.ok(typeLabels.includes('差旅费'))
assert.ok(typeLabels.includes('业务招待费'))
assert.equal(typeLabels[0], '全部归档类型')
assert.deepEqual(typeValues, ['all', 'reimbursement'])
assert.deepEqual(typeLabels, ['全部归档类型', '报销'])
assert.equal(departmentLabels[0], '全部部门')
assert.ok(departmentLabels.includes('研发部'))
assert.ok(departmentLabels.includes('销售部'))

View File

@@ -32,3 +32,26 @@ test('archive center is wired into navigation and api client', () => {
assert.match(navigationScript, /id:\s*'archive'/)
assert.match(reimbursementsService, /\/reimbursements\/claims\/archives/)
})
test('archive center uses generic archive category and type wording', () => {
const archiveView = readFileSync(
fileURLToPath(new URL('../src/views/ArchiveCenterView.vue', import.meta.url)),
'utf8'
)
const archiveScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/ArchiveCenterView.js', import.meta.url)),
'utf8'
)
assert.match(archiveScript, /const tabs = \[ARCHIVE_TAB_ALL, ARCHIVE_TAB_REIMBURSEMENT\]/)
assert.match(archiveScript, /const ARCHIVE_TAB_REIMBURSEMENT = '报销归档'/)
assert.match(archiveScript, /archiveType:\s*ARCHIVE_TYPE_REIMBURSEMENT/)
assert.match(archiveScript, /archiveTypeCode:\s*ARCHIVE_TYPE_REIMBURSEMENT_CODE/)
assert.doesNotMatch(archiveScript, /'差旅报销'/)
assert.doesNotMatch(archiveScript, /'招待报销'/)
assert.doesNotMatch(archiveScript, /'其他费用'/)
assert.match(archiveView, /placeholder="搜索单号、申请人、部门、归档类型\.\.\."/)
assert.match(archiveView, /<th>归档类型<\/th>/)
assert.match(archiveView, /\{\{\s*row\.archiveType\s*\}\}/)
assert.doesNotMatch(archiveView, /<th>报销类型<\/th>/)
})

View File

@@ -26,8 +26,26 @@ function buildRun() {
chunk_count: 5,
entity_count: 3,
relation_count: 2,
entities: ['远光软件', '支出管理'],
relations: [{ source: '远光软件', target: '支出管理', type: '关联' }]
entities: [
{
name: '远光软件',
type: 'ORGANIZATION',
description: '远光软件是支出管理制度的公司主体。',
descriptions: ['远光软件是支出管理制度的公司主体。'],
properties: { created_at: '2026-05-23' }
},
'支出管理'
],
relations: [
{
source: '远光软件',
target: '支出管理',
type: '约束',
description: '通过制度约束支出审批。',
keywords: ['制度', '审批'],
weight: 2.5
}
]
},
documents: [
{
@@ -40,7 +58,8 @@ function buildRun() {
chunk_count: 3,
entity_count: 2,
relation_count: 1,
chunks: [{ id: 'chunk-1', order: 0, tokens: 21, summary: '支出管理范围' }],
chunks: [{ id: 'chunk-1', order: 0, tokens: 21, summary: '支出管理范围', excerpt: '支出管理范围正文' }],
entity_chunks: [{ entity: '支出管理', chunk_ids: ['chunk-1'] }],
sections: [{ title: '第一章 总则', excerpt: '适用于公司支出。' }],
events: [{ at: '2026-05-22T08:00:00Z', level: 'info', message: '完成' }]
},
@@ -75,8 +94,18 @@ function testBuildsInteractiveModel() {
assert.equal(model.documents.length, 2)
assert.equal(model.documents[0].statusLabel, '已完成')
assert.equal(model.documents[0].chunks[0].summary, '支出管理范围')
assert.equal(model.documents[0].chunks[0].excerpt, '支出管理范围正文')
assert.deepEqual(model.documents[0].entityChunks, [{ entity: '支出管理', chunkIds: ['chunk-1'] }])
assert.deepEqual(model.documents[1].chunks, [])
assert.deepEqual(model.documents[1].sections, [])
assert.deepEqual(model.documents[1].events, [])
assert.equal(model.graph.entityCount, 3)
assert.equal(model.graph.entities[0].name, '远光软件')
assert.equal(model.graph.entities[0].type, 'ORGANIZATION')
assert.equal(model.graph.entities[0].descriptions[0], '远光软件是支出管理制度的公司主体。')
assert.equal(model.graph.relations[0].source, '远光软件')
assert.equal(model.graph.relations[0].description, '通过制度约束支出审批。')
assert.deepEqual(model.graph.relations[0].keywords, ['制度', '审批'])
assert.equal(model.metrics[1].value, '5')
}

View File

@@ -38,3 +38,8 @@ test('composer keeps backend raw text but displays structured user message', ()
assert.match(submitComposerScript, /const rawText = resolveComposerSubmitText\(options\.rawText\)\.trim\(\)/)
assert.match(submitComposerScript, /resolveComposerDisplaySubmitText\(rawText\)/)
})
test('knowledge questions keep enough request time for LightRAG retrieval', () => {
assert.match(submitComposerScript, /timeoutMs:\s*75000/)
assert.match(submitComposerScript, /知识问答仍在检索整理/)
})

View 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*'正在查询单据状态\.\.\.'/)
})