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

@@ -0,0 +1,439 @@
import { ref } from 'vue'
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,
buildGuidedInterruptionText,
buildGuidedQueryModeActions,
buildGuidedQueryPromptText,
buildGuidedQueryStatusActions,
buildGuidedReimbursementStartText,
buildGuidedReimbursementSummaryText,
buildGuidedReviewConfirmationActions,
buildGuidedReviewSubmitOptions,
buildGuidedStatusQueryStartText,
buildGuidedStatusQueryText,
buildGuidedStepPromptText,
createEmptyGuidedFlowState,
createGuidedReimbursementState,
createGuidedStatusQueryState,
getCurrentGuidedStep,
isGuidedFlowActive,
isGuidedReimbursementReadyForReview,
normalizeGuidedFlowState,
resolveGuidedExpenseTypeFromText,
resolveGuidedQueryModeFromText,
selectGuidedExpenseType,
selectGuidedQueryMode,
shouldConfirmGuidedInterruption
} from './travelReimbursementGuidedFlowModel.js'
function normalizeText(value) {
return String(value || '').trim()
}
function buildFileNames(files) {
return Array.from(files || [])
.map((file) => normalizeText(file?.name))
.filter(Boolean)
}
function mergePendingFiles(currentFiles, nextFiles) {
const merged = [...Array.from(currentFiles || [])]
Array.from(nextFiles || []).forEach((file) => {
const name = normalizeText(file?.name)
if (!name) return
const duplicated = merged.some((item) => normalizeText(item?.name) === name && Number(item?.size || 0) === Number(file?.size || 0))
if (!duplicated) {
merged.push(file)
}
})
return merged
}
export function useTravelReimbursementGuidedFlow({
guidedFlowState,
messages,
composerDraft,
attachedFiles,
composerBusinessTimeTags,
composerBusinessTimeDraftTouched,
fileInputRef,
submitting,
reviewActionBusy,
sessionSwitchBusy,
createMessage,
nextTick,
scrollToBottom,
persistSessionState,
clearAttachedFiles,
adjustComposerTextareaHeight,
buildComposerBusinessTimeContext,
openTravelCalculator,
lockSuggestedActionMessage,
submitExistingComposer,
toast
}) {
const guidedPendingFiles = ref([])
function persistAndScroll() {
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight?.()
scrollToBottom?.()
})
}
function clearComposerRuntime() {
composerDraft.value = ''
clearAttachedFiles?.()
if (fileInputRef?.value) {
fileInputRef.value.value = ''
}
if (composerBusinessTimeTags) {
composerBusinessTimeTags.value = []
}
if (composerBusinessTimeDraftTouched) {
composerBusinessTimeDraftTouched.value = false
}
}
function pushAssistant(text, extras = {}) {
messages.value.push(createMessage('assistant', text, [], extras))
}
function pushUser(text, attachmentNames = []) {
const normalizedText = normalizeText(text)
messages.value.push(createMessage('user', normalizedText || `上传 ${attachmentNames.length} 份附件`, attachmentNames))
}
function resetGuidedFlowState() {
guidedFlowState.value = createEmptyGuidedFlowState()
guidedPendingFiles.value = []
}
function startGuidedReimbursement() {
guidedFlowState.value = createGuidedReimbursementState()
guidedPendingFiles.value = []
pushAssistant(buildGuidedReimbursementStartText(), {
meta: ['引导式报销'],
suggestedActions: buildGuidedExpenseTypeActions()
})
persistAndScroll()
}
function startGuidedStatusQuery() {
guidedFlowState.value = createGuidedStatusQueryState()
guidedPendingFiles.value = []
pushAssistant(buildGuidedStatusQueryStartText(), {
meta: ['引导式查询'],
suggestedActions: buildGuidedQueryModeActions()
})
persistAndScroll()
}
function handleGuidedShortcut(shortcut) {
const actionType = normalizeText(shortcut?.action)
if (actionType === GUIDED_ACTION_START_REIMBURSEMENT) {
startGuidedReimbursement()
return true
}
if (actionType === GUIDED_ACTION_START_STATUS_QUERY) {
startGuidedStatusQuery()
return true
}
if (actionType === GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR) {
openTravelCalculator?.()
pushAssistant('差旅计算器已打开。你可以直接填写目的地、天数和金额,我会按规则中心标准帮你测算。', {
meta: ['差旅计算器']
})
persistAndScroll()
return true
}
return false
}
function buildAnswerText(rawText, state) {
const text = normalizeText(rawText)
if (text) {
return text
}
const currentStep = getCurrentGuidedStep(state)
if (currentStep?.key === 'time_range') {
const businessTimeContext = buildComposerBusinessTimeContext?.()
return normalizeText(businessTimeContext?.time_range || businessTimeContext?.business_time)
}
return ''
}
function pushNextReimbursementPrompt() {
pushAssistant(buildGuidedStepPromptText(guidedFlowState.value), {
meta: ['引导式报销']
})
}
function pushReimbursementSummary() {
pushAssistant(buildGuidedReimbursementSummaryText(guidedFlowState.value), {
meta: ['待生成核对信息'],
suggestedActions: buildGuidedReviewConfirmationActions()
})
}
function handleReimbursementAnswer(answerText, files) {
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
const currentStep = getCurrentGuidedStep(currentState)
const fileNames = buildFileNames(files)
if (currentState.stepKey === 'expense_type') {
const expenseType = resolveGuidedExpenseTypeFromText(answerText)
if (!expenseType) {
pushAssistant('我还需要先确认报销类型。请点击下面最贴近的费用场景后,我再继续问下一项。', {
meta: ['等待选择报销类型'],
suggestedActions: buildGuidedExpenseTypeActions()
})
return
}
guidedFlowState.value = selectGuidedExpenseType(currentState, expenseType)
pushNextReimbursementPrompt()
return
}
if (!currentStep) {
pushAssistant(buildGuidedReimbursementStartText(), {
meta: ['引导式报销'],
suggestedActions: buildGuidedExpenseTypeActions()
})
return
}
if (!answerText && fileNames.length && currentStep.key !== 'attachments') {
guidedPendingFiles.value = mergePendingFiles(guidedPendingFiles.value, files)
pushAssistant([
`我已先记录 ${fileNames.length} 份附件。`,
'',
`当前还需要补充:${currentStep.summaryLabel}`,
currentStep.prompt
].join('\n'), {
meta: ['已记录附件']
})
return
}
if (fileNames.length) {
guidedPendingFiles.value = mergePendingFiles(guidedPendingFiles.value, files)
}
guidedFlowState.value = applyGuidedReimbursementAnswer(currentState, answerText, fileNames)
if (isGuidedReimbursementReadyForReview(guidedFlowState.value)) {
pushReimbursementSummary()
return
}
pushNextReimbursementPrompt()
}
async function runStatusQuery(queryText, skipUserMessage = true) {
const normalizedQuery = normalizeText(queryText)
resetGuidedFlowState()
clearComposerRuntime()
persistAndScroll()
if (!normalizedQuery) {
return true
}
await submitExistingComposer({
rawText: normalizedQuery,
userText: normalizedQuery,
pendingText: '正在查询单据状态...',
skipUserMessage
})
return true
}
async function handleStatusQueryAnswer(answerText) {
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
if (currentState.stepKey === 'query_mode') {
const queryMode = resolveGuidedQueryModeFromText(answerText)
if (!queryMode) {
pushAssistant(buildGuidedStatusQueryStartText(), {
meta: ['引导式查询'],
suggestedActions: buildGuidedQueryModeActions()
})
return true
}
guidedFlowState.value = selectGuidedQueryMode(currentState, queryMode)
const actions = guidedFlowState.value.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : []
pushAssistant(buildGuidedQueryPromptText(guidedFlowState.value), {
meta: ['引导式查询'],
suggestedActions: actions
})
return true
}
const queryText = buildGuidedStatusQueryText(currentState, answerText)
return runStatusQuery(queryText, true)
}
async function handleGuidedComposerSubmit(options = {}) {
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
if (!isGuidedFlowActive(currentState)) {
return false
}
if (options.systemGenerated || normalizeText(options.extraContext?.review_action)) {
return false
}
if (submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) {
return true
}
const files = Array.from(options.files ?? attachedFiles.value ?? [])
const fileNames = buildFileNames(files)
const answerText = buildAnswerText(options.rawText ?? composerDraft.value, currentState)
if (!answerText && !fileNames.length) {
return true
}
pushUser(answerText, fileNames)
if (shouldConfirmGuidedInterruption(answerText, currentState) && !fileNames.length) {
guidedFlowState.value = {
...currentState,
pendingInterruptionText: answerText
}
pushAssistant(buildGuidedInterruptionText(answerText), {
meta: ['等待确认是否打断'],
suggestedActions: buildGuidedInterruptionActions()
})
clearComposerRuntime()
persistAndScroll()
return true
}
if (currentState.mode === GUIDED_FLOW_MODE_REIMBURSEMENT) {
handleReimbursementAnswer(answerText, files)
clearComposerRuntime()
persistAndScroll()
return true
}
if (currentState.mode === GUIDED_FLOW_MODE_STATUS_QUERY) {
clearComposerRuntime()
persistAndScroll()
await handleStatusQueryAnswer(answerText)
return true
}
return false
}
async function handleGuidedSuggestedAction(message, action) {
const actionType = normalizeText(action?.action_type)
if (!actionType) {
return false
}
const guidedActionTypes = new Set([
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
GUIDED_ACTION_CONTINUE_FILLING,
GUIDED_ACTION_PROCESS_INTERRUPTION,
GUIDED_ACTION_SELECT_QUERY_MODE,
GUIDED_ACTION_SELECT_QUERY_STATUS
])
if (!guidedActionTypes.has(actionType)) {
return false
}
if (submitting.value || reviewActionBusy.value || sessionSwitchBusy.value || message?.suggestedActionsLocked) {
return true
}
if (!lockSuggestedActionMessage(message, action)) {
return true
}
if (actionType === GUIDED_ACTION_SELECT_EXPENSE_TYPE) {
const expenseType = normalizeText(action?.payload?.expense_type)
const expenseTypeLabel = normalizeText(action?.payload?.expense_type_label || action?.label)
guidedFlowState.value = selectGuidedExpenseType(guidedFlowState.value, expenseType)
pushUser(`选择${expenseTypeLabel || '报销类型'}`)
pushNextReimbursementPrompt()
persistAndScroll()
return true
}
if (actionType === GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW) {
const submitOptions = buildGuidedReviewSubmitOptions(guidedFlowState.value, guidedPendingFiles.value)
resetGuidedFlowState()
persistAndScroll()
await submitExistingComposer(submitOptions)
return true
}
if (actionType === GUIDED_ACTION_CONTINUE_FILLING) {
const pendingState = {
...normalizeGuidedFlowState(guidedFlowState.value),
pendingInterruptionText: ''
}
guidedFlowState.value = pendingState
if (pendingState.mode === GUIDED_FLOW_MODE_STATUS_QUERY) {
pushAssistant(buildGuidedQueryPromptText(pendingState), {
meta: ['引导式查询'],
suggestedActions: pendingState.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : []
})
} else {
pushNextReimbursementPrompt()
}
persistAndScroll()
return true
}
if (actionType === GUIDED_ACTION_PROCESS_INTERRUPTION) {
const pendingText = normalizeText(guidedFlowState.value?.pendingInterruptionText)
resetGuidedFlowState()
persistAndScroll()
await submitExistingComposer({
rawText: pendingText,
userText: pendingText,
pendingText: '正在处理你的问题...',
skipUserMessage: true
})
return true
}
if (actionType === GUIDED_ACTION_SELECT_QUERY_MODE) {
const queryMode = normalizeText(action?.payload?.query_mode)
const queryModeLabel = normalizeText(action?.payload?.query_mode_label || action?.label)
guidedFlowState.value = selectGuidedQueryMode(guidedFlowState.value, queryMode)
pushUser(`选择${queryModeLabel || '查询方式'}`)
pushAssistant(buildGuidedQueryPromptText(guidedFlowState.value), {
meta: ['引导式查询'],
suggestedActions: guidedFlowState.value.stepKey === 'status_value' ? buildGuidedQueryStatusActions() : []
})
persistAndScroll()
return true
}
if (actionType === GUIDED_ACTION_SELECT_QUERY_STATUS) {
const statusLabel = normalizeText(action?.payload?.query_status_label || action?.label)
pushUser(`选择${statusLabel || '单据状态'}`)
const queryText = buildGuidedStatusQueryText(guidedFlowState.value, statusLabel)
await runStatusQuery(queryText, true)
return true
}
return false
}
return {
handleGuidedShortcut,
handleGuidedComposerSubmit,
handleGuidedSuggestedAction,
resetGuidedFlowState
}
}