feat: 新增风险规则生成引擎与知识图谱可视化
后端新增风险规则自动生成和模板执行服务,支持从规则资产 批量生成并持久化风险规则文件;知识库入库日志增强图谱 查询和本地 RAG 回退,前端审计页面增加风险规则模型和流 程图组件,知识入库面板拆分为图谱可视化子组件,报销创 建页面增加引导式流程模型,更新知识库索引数据。
This commit is contained in:
439
web/src/views/scripts/useTravelReimbursementGuidedFlow.js
Normal file
439
web/src/views/scripts/useTravelReimbursementGuidedFlow.js
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user