feat: 重构报销单AI预审流程并添加平台风险规则引擎

- 将AI验审改为AI预审,高风险不再拦截而是随单流转给审批人复核
- 新增平台风险规则评估引擎,支持事由过短、票据异常、重复发票等多种评估器
- 用户上下文增加部门信息(department_name),认证流程同步关联组织架构
- 规则scenario_json改为中文标签(差旅/费用科目),统一场景分类
- 新增orchestrator审核流程测试用例
- 前端更新审计视图、差旅报销等相关页面
This commit is contained in:
caoxiaozhu
2026-05-20 09:36:01 +08:00
parent 2574bc81d1
commit 57957d11a0
23 changed files with 2109 additions and 553 deletions

View File

@@ -209,17 +209,11 @@ const FLOW_STEP_FALLBACKS = {
runningText: '正在识别票据附件...',
completedText: '票据识别完成'
},
agent: {
title: '智能体编排',
tool: 'UserAgent',
runningText: '正在调用财务智能体...',
completedText: '智能体处理完成'
},
result: {
title: '生成结果',
tool: 'ResultGenerator',
runningText: '正在生成解释与草稿...',
completedText: '结果已生成'
'expense-claim-draft': {
title: '报销草稿处理',
tool: 'database.expense_claims.save_or_submit',
runningText: '正在根据识别结果更新草稿和右侧核对信息...',
completedText: '草稿和核对信息已更新'
}
}
const ASSISTANT_DISPLAY_NAME = '财务助手'
@@ -345,41 +339,6 @@ function formatMessageTime(value) {
})
}
function createFlowSteps(options = {}) {
const keys = []
if (options.includeIntent) {
keys.push('intent')
}
if (options.includeOcr) {
keys.push('ocr')
}
if (options.includeExtraction) {
keys.push('extraction')
}
if (options.includeAgent) {
keys.push('agent')
}
if (options.includeResult) {
keys.push('result')
}
return keys.map((key, index) => {
const definition = FLOW_STEP_FALLBACKS[key] || {}
return {
key,
index: index + 1,
title: definition.title || '智能体工具调用',
tool: definition.tool || 'AgentTool',
status: FLOW_STEP_STATUS_PENDING,
detail: '',
durationMs: null,
startedAt: 0,
finishedAt: 0,
error: ''
}
})
}
function formatSemanticEntityValue(entity) {
const normalizedValue = String(entity?.normalized_value || '').trim()
const rawValue = String(entity?.value || '').trim()
@@ -577,11 +536,8 @@ function formatFlowDuration(ms) {
if (!Number.isFinite(numericValue) || numericValue < 0) {
return '--'
}
if (numericValue < 100) {
return '<0.1s'
}
if (numericValue < 1000) {
return `${(numericValue / 1000).toFixed(1)}s`
return `${Math.max(0.1, numericValue / 1000).toFixed(1)}s`
}
if (numericValue < 10000) {
return `${(numericValue / 1000).toFixed(1)}s`
@@ -639,34 +595,6 @@ function resolveToolCallDurationMs(toolCall, index, toolCalls, run) {
return finishedAt - startedAt
}
function resolveResultStepDurationMs(run) {
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
if (!runFinishedAt) {
return null
}
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
const semanticFinishedAt = (
toolCalls
.map((item, index) => {
const startedAt = parseFlowTimestamp(item?.created_at)
const durationMs = resolveToolCallDurationMs(item, index, toolCalls, run)
if (!startedAt || !durationMs) {
return 0
}
return startedAt + durationMs
})
.filter((value) => value > 0)
.sort((left, right) => right - left)[0]
) || parseFlowTimestamp(run?.started_at)
if (!semanticFinishedAt || runFinishedAt <= semanticFinishedAt) {
return null
}
return runFinishedAt - semanticFinishedAt
}
function sanitizeRequest(request) {
if (!request || typeof request !== 'object') return null
@@ -1559,7 +1487,7 @@ function buildDraftSavedPayload({
status: String(draftPayload?.status || '').trim(),
approvalStage: String(draftPayload?.approval_stage || '').trim(),
person: String(currentUser?.name || '').trim() || '当前用户',
dept: String(currentUser?.role || '').trim() || '待补充部门',
dept: String(currentUser?.department || currentUser?.departmentName || '').trim() || '待补充部门',
entity: String(linkedRequest?.entity || '').trim() || 'Northstar China Ltd.',
typeCode,
typeLabel,
@@ -2525,11 +2453,103 @@ function buildReviewRiskSummary(reviewPayload) {
function buildReviewRiskItems(reviewPayload) {
return resolveReviewRiskBriefs(reviewPayload)
.map((brief) => String(brief?.content || '').trim())
.map((brief) => {
const title = String(brief?.title || '').trim()
const content = String(brief?.content || '').trim()
if (title && content) return `${title}${content}`
return content || title
})
.filter(Boolean)
.slice(0, 4)
}
function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) {
const state = inlineState || createEmptyInlineReviewState()
if (slotKey === 'expense_type') return String(state.expense_type || '').trim()
if (slotKey === 'customer_name') return String(state.customer_name || '').trim()
if (slotKey === 'time_range') return String(state.occurred_date || '').trim()
if (slotKey === 'location') return String(state.location || '').trim()
if (slotKey === 'merchant_name') return String(state.merchant_name || '').trim()
if (slotKey === 'amount') return String(state.amount || '').trim()
if (slotKey === 'reason') return String(state.reason_value || state.scene_label || '').trim()
if (slotKey === 'participants') return String(state.participants || '').trim()
if (slotKey === 'attachments') {
return String(state.attachment_names || '').trim()
|| (Number(state.attachment_count || 0) > 0 ? `${Number(state.attachment_count)} 份附件` : '')
|| (Number(state.pending_attachment_count || 0) > 0 ? `${Number(state.pending_attachment_count)} 份待上传附件` : '')
}
return ''
}
function buildLocallySyncedReviewActions(reviewPayload, canProceed) {
const actions = Array.isArray(reviewPayload?.confirmation_actions)
? reviewPayload.confirmation_actions.map((item) => ({ ...item }))
: []
const actionTypes = new Set(actions.map((item) => String(item?.action_type || '').trim()))
const associationPending = actionTypes.has('link_to_existing_draft') || actionTypes.has('create_new_claim_from_documents')
if (!canProceed || associationPending) {
return actions
}
return [
...actions.filter((item) => !['save_draft', 'next_step'].includes(String(item?.action_type || '').trim())),
{
label: '继续下一步',
action_type: 'next_step',
description: '当前信息已齐全,进入 AI 预审、风险校验和审批路径确认。',
emphasis: 'primary'
}
]
}
function buildLocallySyncedReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
if (!reviewPayload || typeof reviewPayload !== 'object') {
return reviewPayload
}
const nextSlotCards = (Array.isArray(reviewPayload.slot_cards) ? reviewPayload.slot_cards : []).map((slot) => {
const value = resolveInlineReviewSlotValue(slot.key, inlineState)
const required = Boolean(slot.required)
const filled = Boolean(value)
return {
...slot,
value: value || slot.value || '',
normalized_value: value || slot.normalized_value || '',
raw_value: value || slot.raw_value || '',
source: filled ? 'user_form' : slot.source,
source_label: filled ? '用户修改' : slot.source_label,
confidence: filled ? Math.max(Number(slot.confidence || 0), 0.98) : Number(slot.confidence || 0),
confirmed: filled || Boolean(slot.confirmed),
status: required && !filled ? 'missing' : filled ? 'identified' : slot.status,
hint: required && !filled ? slot.hint : ''
}
})
const missingSlots = nextSlotCards
.filter((slot) => slot.required && slot.status === 'missing')
.map((slot) => slot.label || slot.key)
const canProceed = missingSlots.length === 0 && (Array.isArray(reviewPayload.claim_groups) ? reviewPayload.claim_groups.length > 0 : true)
return {
...reviewPayload,
can_proceed: canProceed,
missing_slots: missingSlots,
slot_cards: nextSlotCards,
confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed)
}
}
function buildLocalReviewCompletionMessage(reviewPayload) {
const missingSlots = Array.isArray(reviewPayload?.missing_slots) ? reviewPayload.missing_slots : []
if (reviewPayload?.can_proceed && !missingSlots.length) {
return '当前所有必填信息已处理完成,可以点击“继续下一步”进入 AI 预审。'
}
if (missingSlots.length) {
return `当前还剩 ${missingSlots.length} 项待补充:${missingSlots.join('、')}`
}
return '当前信息已保存,可以继续核对右侧状态。'
}
function normalizeInlineReviewComparableState(state) {
const source = state && typeof state === 'object' ? state : {}
return {
@@ -2583,6 +2603,49 @@ function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = [])
return lines
}
function buildInlineReviewChangePhrases(baseState, nextState, pendingFiles = []) {
const base = normalizeInlineReviewComparableState(baseState)
const next = normalizeInlineReviewComparableState(nextState)
const fieldConfigs = [
{ key: 'occurred_date', label: '发生时间', format: (value) => value || '待补充' },
{ key: 'amount', label: '金额', format: (value) => formatAmountDisplay(value) || '待补充' },
{ key: 'scene_label', label: '场景', format: (value) => value || '待补充' },
{ key: 'customer_name', label: '关联客户', format: (value) => value || '待补充' },
{ key: 'location', label: '业务地点', format: (value) => value || '待补充' },
{ key: 'merchant_name', label: '酒店/商户', format: (value) => value || '待补充' },
{ key: 'participants', label: '同行人员', format: (value) => value || '待补充' },
{ key: 'expense_type', label: '报销分类', format: (value) => value || '待补充' }
]
const phrases = fieldConfigs.reduce((result, item) => {
if (base[item.key] !== next[item.key]) {
result.push(`${item.label}修改为 ${item.format(next[item.key])}`)
}
return result
}, [])
if (base.attachment_names !== next.attachment_names || pendingFiles.length) {
phrases.push(`票据修改为 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`)
}
return phrases
}
function buildLocalReviewSavedMessage(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) {
const phrases = buildInlineReviewChangePhrases(baseState, nextState, pendingFiles)
const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts)
if (documentLines.length) {
phrases.push(`${documentLines.length} 张票据识别信息更新为最新修改`)
}
if (!phrases.length) {
return '右侧核对信息已保存。'
}
return `已将${phrases.join('')}`
}
function buildInlineReviewUserText(baseState, nextState, pendingFiles = []) {
const lines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles)
if (!lines.length) {
@@ -2894,7 +2957,7 @@ export default {
const flowRunId = ref('')
const flowStartedAt = ref(0)
const flowFinishedAt = ref(0)
const flowSteps = ref(createFlowSteps())
const flowSteps = ref([])
const flowRefreshBusy = ref(false)
const flowTick = ref(Date.now())
let flowTickTimer = 0
@@ -3415,7 +3478,7 @@ export default {
flowRunId.value = ''
flowStartedAt.value = 0
flowFinishedAt.value = 0
flowSteps.value = createFlowSteps()
flowSteps.value = []
}
function adjustComposerTextareaHeight() {
@@ -3454,22 +3517,14 @@ export default {
}
}
function resetFlowRun(options = {}) {
function resetFlowRun() {
clearFlowSimulationTimers()
flowRunId.value = ''
flowStartedAt.value = Date.now()
flowFinishedAt.value = 0
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
insightPanelCollapsed.value = false
const hasText = Boolean(String(options.rawText || '').trim())
const attachmentCount = Number(options.attachmentCount || 0)
flowSteps.value = createFlowSteps({
includeIntent: hasText,
includeOcr: attachmentCount > 0,
includeExtraction: hasText || attachmentCount > 0,
includeAgent: true,
includeResult: true
})
flowSteps.value = []
}
function findFlowDefinition(key) {
@@ -3511,13 +3566,6 @@ export default {
const existingStep = flowSteps.value.find((step) => step.key === key)
if (!existingStep) {
const nextStep = createFlowStep(key, patch)
const resultIndex = flowSteps.value.findIndex((step) => step.key === 'result')
if (resultIndex !== -1 && key !== 'result') {
const nextSteps = [...flowSteps.value]
nextSteps.splice(resultIndex, 0, nextStep)
flowSteps.value = normalizeFlowStepIndexes(nextSteps)
return
}
flowSteps.value = normalizeFlowStepIndexes([...flowSteps.value, nextStep])
return
}
@@ -3529,11 +3577,15 @@ export default {
function startFlowStep(key, patch = {}) {
const normalizedPatch = normalizeFlowStepPatch(key, patch)
const explicitStartedAt = Number(normalizedPatch.startedAt)
const startedAt = Number.isFinite(explicitStartedAt) && explicitStartedAt > 0
? explicitStartedAt
: Date.now()
upsertFlowStep(key, {
...normalizedPatch,
status: FLOW_STEP_STATUS_RUNNING,
detail: normalizedPatch.detail,
startedAt: Date.now(),
startedAt,
finishedAt: 0,
durationMs: null,
error: ''
@@ -3544,14 +3596,16 @@ export default {
const now = Date.now()
const definition = findFlowDefinition(key)
const currentStep = flowSteps.value.find((step) => step.key === key)
const startedAt = currentStep?.startedAt || now
const explicitDuration = Number(durationMs)
const hasExplicitDuration = Number.isFinite(explicitDuration) && explicitDuration >= 0
const startedAt = currentStep?.startedAt || (hasExplicitDuration ? Math.max(0, now - explicitDuration) : now)
upsertFlowStep(key, {
...patch,
status: FLOW_STEP_STATUS_COMPLETED,
detail: detail || definition?.completedText || '',
startedAt,
finishedAt: now,
durationMs: Number.isFinite(Number(durationMs)) ? Number(durationMs) : now - startedAt,
durationMs: hasExplicitDuration ? explicitDuration : Math.max(0, now - startedAt),
error: ''
})
}
@@ -3601,7 +3655,12 @@ export default {
function failCurrentFlowStep(error) {
clearFlowSimulationTimers()
const currentStep = runningFlowStep.value || flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_PENDING)
failFlowStep(currentStep?.key || 'result', error?.message || '智能体调用失败', error?.message || '')
failFlowStep(
currentStep?.key || 'orchestrator-error',
error?.message || '智能体调用失败',
error?.message || '',
currentStep ? {} : { title: '流程调用', tool: 'Orchestrator' }
)
}
function startSemanticFlowPreview(rawText, options = {}) {
@@ -3646,9 +3705,63 @@ export default {
flowSimulationTimers.push(startExtractionTimer)
}
function startReviewActionFlowStep(reviewAction) {
if (reviewAction !== 'next_step') {
return
}
startFlowStep('pre-submit-review', {
title: 'AI预审与风险识别',
tool: 'ExpenseClaimService.submit_claim',
detail: '正在校验财务规则、风险规则和审批路径...'
})
}
function startExpenseClaimDraftFlowStep(reviewAction, options = {}) {
if (isKnowledgeSession.value) {
return
}
if (reviewAction === 'next_step') {
startReviewActionFlowStep(reviewAction)
return
}
const attachmentCount = Math.max(0, Number(options.attachmentCount || 0))
const configs = {
save_draft: {
title: '报销草稿保存',
detail: '正在保存当前核对结果...'
},
link_to_existing_draft: {
title: '票据关联草稿',
detail: '正在把本次票据关联到现有草稿...'
},
create_new_claim_from_documents: {
title: '新建报销草稿',
detail: '正在根据当前票据新建报销草稿...'
}
}
const config = configs[reviewAction] || {
title: '报销草稿处理',
detail: attachmentCount
? '正在根据 OCR 结果更新草稿和右侧核对信息...'
: '正在更新草稿和右侧核对信息...'
}
startFlowStep('expense-claim-draft', {
title: config.title,
tool: 'database.expense_claims.save_or_submit',
detail: config.detail
})
}
function resolveToolCallFlowMeta(toolCall, index) {
const toolType = String(toolCall?.tool_type || '').toLowerCase()
const toolName = String(toolCall?.tool_name || '').toLowerCase()
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
? toolCall.response_json
: {}
const responseMessage = String(response.message || '').trim()
const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}`
if (toolType.includes('rule')) {
return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' }
@@ -3660,7 +3773,15 @@ export default {
return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' }
}
if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) {
return { key, title: '报销草稿处理', tool: toolCall?.tool_name || 'ExpenseClaimService' }
if (
response.submission_blocked ||
String(response.status || '').trim() === 'submitted' ||
responseMessage.includes('AI预审') ||
responseMessage.includes('审批')
) {
return { key: 'pre-submit-review', title: 'AI预审与风险识别', tool: 'ExpenseClaimService.submit_claim' }
}
return { key: 'expense-claim-draft', title: '报销草稿处理', tool: toolCall?.tool_name || 'ExpenseClaimService' }
}
if (toolType.includes('database')) {
return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' }
@@ -3675,6 +3796,12 @@ export default {
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
? toolCall.response_json
: {}
if (String(response.status || '').trim() === 'submitted') {
return `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
}
if (response.submission_blocked) {
return String(response.message || '').trim() || 'AI预审发现待补充项暂未提交审批'
}
return (
String(response.message || response.summary || response.result_summary || '').trim()
|| String(toolCall?.tool_name || '').trim()
@@ -3687,22 +3814,17 @@ export default {
if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) {
clearFlowSimulationTimers()
const semanticDurations = resolveSemanticPhaseDurations(run)
const intentStep = flowSteps.value.find((step) => step.key === 'intent')
const extractionStep = flowSteps.value.find((step) => step.key === 'extraction')
completePendingFlowStep(
'intent',
summarizeSemanticIntentDetail(run.semantic_parse),
semanticDurations.intentMs
intentStep?.startedAt ? null : semanticDurations.intentMs
)
completePendingFlowStep(
'extraction',
summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}),
semanticDurations.extractionMs
)
}
if (flowSteps.value.some((step) => step.key === 'agent')) {
completePendingFlowStep(
'agent',
toolCalls.length ? `已完成 ${toolCalls.length} 个工具调用` : FLOW_STEP_FALLBACKS.agent.completedText
extractionStep?.startedAt ? null : semanticDurations.extractionMs
)
}
@@ -3734,12 +3856,10 @@ export default {
return
}
flowSteps.value
.filter((step) => step.key !== 'result' && ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
.filter((step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
.forEach((step) => {
completeFlowStep(step.key, resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED }))
})
startFlowStep('result', '正在返回处理结果...')
completeFlowStep('result', '结果已返回到对话区', resolveResultStepDurationMs(run))
flowFinishedAt.value = Date.now()
if (reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) {
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
@@ -4419,7 +4539,7 @@ export default {
}
}
async function saveInlineReviewChanges() {
function saveInlineReviewChanges() {
if (!activeReviewPayload.value || !reviewHasUnsavedChanges.value || reviewActionBusy.value) return
if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) {
@@ -4429,28 +4549,36 @@ export default {
reviewActionBusy.value = true
try {
const fields = mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value)
const documentCorrectionMessage = buildReviewDocumentCorrectionMessage(
const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, reviewInlineForm.value)
const messageText = `${buildLocalReviewSavedMessage(
reviewInlineBaseForm.value,
reviewInlineForm.value,
reviewInlinePendingFiles.value,
reviewDocumentBaseDrafts.value,
reviewDocumentDrafts.value
)
await submitComposer({
rawText: [buildReviewCorrectionMessage(fields), documentCorrectionMessage].filter(Boolean).join('\n'),
userText: buildReviewSubmitUserText(
reviewInlineBaseForm.value,
reviewInlineForm.value,
reviewInlinePendingFiles.value,
reviewDocumentBaseDrafts.value,
reviewDocumentDrafts.value
),
pendingText: '正在保存修改并刷新右侧核对信息...',
files: reviewInlinePendingFiles.value,
systemGenerated: true,
extraContext: {
review_action: 'edit_review',
review_form_values: buildReviewFormValues(fields),
review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value)
)} ${buildLocalReviewCompletionMessage(nextReviewPayload)}`
reviewInlineBaseFields.value = cloneReviewEditFields(fields)
reviewInlineBaseForm.value = { ...reviewInlineForm.value }
reviewDocumentBaseDrafts.value = cloneReviewDocumentDrafts(reviewDocumentDrafts.value)
if (latestReviewMessage.value) {
latestReviewMessage.value.reviewPayload = nextReviewPayload
}
if (currentInsight.value?.agent) {
currentInsight.value = {
...currentInsight.value,
agent: {
...currentInsight.value.agent,
reviewPayload: nextReviewPayload
}
}
})
}
messages.value.push(createMessage('assistant', messageText, [], {
meta: ['本地修改'],
draftPayload: latestReviewMessage.value?.draftPayload || null,
reviewPayload: nextReviewPayload
}))
nextTick(scrollToBottom)
} finally {
reviewActionBusy.value = false
}
@@ -4517,6 +4645,7 @@ export default {
const extraContext = options.extraContext && typeof options.extraContext === 'object'
? { ...options.extraContext }
: {}
const reviewAction = String(extraContext.review_action || '').trim()
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
const hasExistingDocumentEvent =
Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0
@@ -4527,14 +4656,14 @@ export default {
hasExistingDocumentEvent &&
!resolvedUploadDisposition &&
!options.skipUploadDecisionPrompt &&
!String(extraContext.review_action || '').trim()
!reviewAction
) {
uploadDecisionDialogOpen.value = true
return null
}
resetFlowRun({ rawText, attachmentCount: files.length })
if (rawText) {
resetFlowRun()
if (rawText && !reviewAction) {
startFlowStep('intent', '正在识别业务意图...')
startSemanticFlowPreview(rawText, { attachmentCount: files.length })
}
@@ -4589,17 +4718,18 @@ export default {
let ocrFilePreviews = []
if (files.length) {
startFlowStep('ocr', `正在识别 ${files.length} 份附件...`)
const ocrStartedAt = Date.now()
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
try {
ocrPayload = await recognizeOcrFiles(files)
ocrSummary = buildOcrSummary(ocrPayload)
ocrDocuments = normalizeOcrDocuments(ocrPayload)
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
rememberFilePreviews(ocrFilePreviews)
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`)
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
} catch (error) {
console.warn('OCR request failed:', error)
completeFlowStep('ocr', 'OCR识别失败已继续使用附件名称')
completeFlowStep('ocr', 'OCR识别失败已继续使用附件名称', Date.now() - ocrStartedAt)
}
}
@@ -4619,16 +4749,9 @@ export default {
extraContext.review_action = 'create_new_claim_from_documents'
}
const runningExtractionStep = flowSteps.value.find(
(step) => step.key === 'extraction' && step.status === FLOW_STEP_STATUS_RUNNING
)
if (runningExtractionStep) {
completeFlowStep(
'extraction',
runningExtractionStep.detail || FLOW_STEP_FALLBACKS.extraction.completedText
)
}
startFlowStep('agent', FLOW_STEP_FALLBACKS.agent.runningText)
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
attachmentCount: effectiveFileNames.length
})
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
const payload = await runOrchestrator(
@@ -4642,6 +4765,8 @@ export default {
is_admin: Boolean(user.isAdmin),
name: user.name || '',
role: user.role || '',
department: user.department || user.departmentName || '',
department_name: user.department || user.departmentName || '',
position: user.position || '',
grade: user.grade || '',
...buildClientTimeContext(),
@@ -4749,7 +4874,10 @@ export default {
}
function openEditReviewDialog(message) {
reviewEditFields.value = cloneReviewEditFields(message?.reviewPayload?.edit_fields)
const sourceFields = reviewInlineBaseFields.value.length
? mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value)
: cloneReviewEditFields(message?.reviewPayload?.edit_fields)
reviewEditFields.value = cloneReviewEditFields(sourceFields)
reviewActionMessageId.value = String(message?.id || '')
reviewEditDialogOpen.value = true
}
@@ -4761,22 +4889,46 @@ export default {
reviewActionMessageId.value = ''
}
async function applyEditedReview() {
function applyEditedReview() {
if (reviewActionBusy.value) return
reviewActionBusy.value = true
try {
const fields = cloneReviewEditFields(reviewEditFields.value)
await submitComposer({
rawText: buildReviewCorrectionMessage(fields),
userText: '我已修改识别信息,请按最新内容更新。',
pendingText: '正在根据修改内容重新识别...',
systemGenerated: true,
extraContext: {
review_action: 'edit_review',
review_form_values: buildReviewFormValues(fields)
}
const nextInlineState = buildInlineReviewState({
...(activeReviewPayload.value || {}),
edit_fields: fields
})
const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState)
const messageText = `${buildLocalReviewSavedMessage(
reviewInlineForm.value,
nextInlineState,
[],
reviewDocumentBaseDrafts.value,
reviewDocumentDrafts.value
)} ${buildLocalReviewCompletionMessage(nextReviewPayload)}`
reviewInlineForm.value = { ...nextInlineState }
reviewInlineBaseForm.value = { ...nextInlineState }
reviewInlineBaseFields.value = cloneReviewEditFields(fields)
if (latestReviewMessage.value) {
latestReviewMessage.value.reviewPayload = nextReviewPayload
}
if (currentInsight.value?.agent) {
currentInsight.value = {
...currentInsight.value,
agent: {
...currentInsight.value.agent,
reviewPayload: nextReviewPayload
}
}
}
messages.value.push(createMessage('assistant', messageText, [], {
meta: ['本地修改'],
draftPayload: latestReviewMessage.value?.draftPayload || null,
reviewPayload: nextReviewPayload
}))
nextTick(scrollToBottom)
} finally {
reviewActionBusy.value = false
}