Files
X-Financial/web/src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationJobs.js

270 lines
8.8 KiB
JavaScript

import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
import {
buildAttachmentAssociationCandidateActions,
isAttachmentAssociationConfirmationResult,
resolveAttachmentAssociationTarget
} from '../../utils/attachmentAssociationJobModel.js'
import {
fetchAttachmentAssociationJob,
normalizeAttachmentAssociationJob
} from '../../services/attachmentAssociationJobs.js'
const ATTACHMENT_ASSOCIATION_JOB_POLL_INTERVAL_MS = 1200
const ATTACHMENT_ASSOCIATION_JOB_MAX_POLLS = 90
const ATTACHMENT_ASSOCIATION_JOB_PENDING_STATUSES = new Set(['queued', 'running'])
export function useWorkbenchAiAttachmentAssociationJobs({
conversationMessages,
createInlineMessage,
persistCurrentConversation,
replaceInlineMessage,
streamOrSetInlineAssistantContent,
notifyRequestUpdated,
toast,
buildDetailActions,
buildThinkingEvents
}) {
const activeJobPolls = new Set()
function delay(milliseconds) {
return new Promise((resolve) => {
globalThis.setTimeout(resolve, milliseconds)
})
}
function normalizeJob(job = {}) {
return normalizeAttachmentAssociationJob(job)
}
function isPending(job = {}) {
return ATTACHMENT_ASSOCIATION_JOB_PENDING_STATUSES.has(String(job?.status || '').trim())
}
function extractReceiptIdsFromOcrDocuments(documents = []) {
return Array.from(new Set(
(Array.isArray(documents) ? documents : [])
.map((document) => String(document?.receipt_id || document?.receiptId || '').trim())
.filter(Boolean)
))
}
function buildRunningMessage(job = {}, fileNames = []) {
const names = fileNames.filter(Boolean)
const attachmentLabel = names.length
? `${names.length} 份:${names.slice(0, 2).join('、')}${names.length > 2 ? ' 等' : ''}`
: '已识别票据附件'
const statusText = String(job?.message || '').trim() || '正在后台匹配并归集票据附件。'
return [
'我已收到附件关联请求,正在后台继续处理。',
'',
`本次附件:${attachmentLabel}`,
`处理状态:${statusText}`,
'',
'您可以先离开当前会话,回来后我会继续查询任务结果。'
].join('\n')
}
function buildFailedMessage(job = {}) {
return String(job?.message || job?.error || '').trim() || '自动归集失败,请补充说明或重新上传附件后再试。'
}
function findMessage(messageId) {
return conversationMessages.value.find((message) => message.id === messageId) || null
}
function replaceJobMessage(messageId, content, options = {}) {
const sourceMessage = findMessage(messageId)
if (!sourceMessage) {
return false
}
replaceInlineMessage(
messageId,
createInlineMessage('assistant', content, {
id: messageId,
attachmentAssociationJob: options.attachmentAssociationJob || sourceMessage?.attachmentAssociationJob || null,
attachmentOcrDetails: options.attachmentOcrDetails || sourceMessage?.attachmentOcrDetails || null,
pending: Boolean(options.pending),
stewardPlan: options.stewardPlan || null,
suggestedActions: options.suggestedActions || []
})
)
return true
}
async function updateJobMessage({
job,
messageId,
fileNames = [],
attachmentOcrDetails = null
}) {
const normalizedJob = normalizeJob(job)
if (!normalizedJob) {
return false
}
if (!findMessage(messageId)) {
return true
}
if (isAttachmentAssociationConfirmationResult(normalizedJob)) {
const confirmationMessage = aiAttachmentAssociationModel.buildAiAttachmentAssociationConfirmationResultMessage({
job: normalizedJob,
fileNames
})
await streamOrSetInlineAssistantContent(messageId, confirmationMessage)
replaceJobMessage(messageId, confirmationMessage, {
attachmentAssociationJob: normalizedJob,
attachmentOcrDetails,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: buildThinkingEvents('completed')
},
suggestedActions: buildAttachmentAssociationCandidateActions(normalizedJob)
})
persistCurrentConversation()
return true
}
if (normalizedJob.status === 'succeeded') {
const target = resolveAttachmentAssociationTarget(normalizedJob)
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
claimNo: target.claimNo,
fileNames,
uploadedCount: normalizedJob.uploadedCount,
skippedCount: normalizedJob.skippedCount,
applicationClaimNo: normalizedJob.applicationClaimNo,
confidence: normalizedJob.confidence,
matchReasons: normalizedJob.matchReasons,
riskItems: normalizedJob.riskItems,
confirmationRequired: Boolean(
normalizedJob.draftPayload?.confirmationRequired
?? normalizedJob.draftPayload?.confirmation_required
)
})
await streamOrSetInlineAssistantContent(messageId, finalMessageText)
replaceJobMessage(messageId, finalMessageText, {
attachmentAssociationJob: normalizedJob,
attachmentOcrDetails,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: buildThinkingEvents('completed')
},
suggestedActions: buildDetailActions({
claimId: target.claimId,
claimNo: target.claimNo
})
})
notifyRequestUpdated?.({
claimId: target.claimId,
claimNo: target.claimNo,
source: 'ai-workbench-attachment-association-job',
uploadedCount: normalizedJob.uploadedCount,
skippedCount: normalizedJob.skippedCount
})
persistCurrentConversation()
return true
}
if (normalizedJob.status === 'failed') {
replaceJobMessage(messageId, buildFailedMessage(normalizedJob), {
attachmentAssociationJob: normalizedJob,
attachmentOcrDetails,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: buildThinkingEvents('failed')
}
})
persistCurrentConversation()
return true
}
replaceJobMessage(messageId, buildRunningMessage(normalizedJob, fileNames), {
attachmentAssociationJob: normalizedJob,
attachmentOcrDetails,
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: buildThinkingEvents('running')
}
})
persistCurrentConversation()
return false
}
async function pollJob({
jobId,
messageId,
fileNames = [],
attachmentOcrDetails = null,
initialJob = null
} = {}) {
const normalizedJobId = String(jobId || '').trim()
if (!normalizedJobId || activeJobPolls.has(normalizedJobId)) {
return
}
activeJobPolls.add(normalizedJobId)
try {
let currentJob = initialJob ? normalizeJob(initialJob) : null
if (currentJob) {
const done = await updateJobMessage({ job: currentJob, messageId, fileNames, attachmentOcrDetails })
if (done) {
return
}
}
for (let index = 0; index < ATTACHMENT_ASSOCIATION_JOB_MAX_POLLS; index += 1) {
await delay(ATTACHMENT_ASSOCIATION_JOB_POLL_INTERVAL_MS)
currentJob = normalizeJob(await fetchAttachmentAssociationJob(normalizedJobId))
if (!currentJob) {
throw new Error('附件关联任务不存在或已失效。')
}
const done = await updateJobMessage({ job: currentJob, messageId, fileNames, attachmentOcrDetails })
if (done) {
return
}
}
throw new Error('附件关联任务仍在后台处理中,稍后回到会话会继续刷新结果。')
} catch (error) {
const message = error?.message || '自动归集状态查询失败,请稍后回到会话查看。'
replaceJobMessage(messageId, message, {
attachmentAssociationJob: {
jobId: normalizedJobId,
status: 'failed',
message,
error: message
},
attachmentOcrDetails,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: buildThinkingEvents('failed')
}
})
toast(message)
persistCurrentConversation()
} finally {
activeJobPolls.delete(normalizedJobId)
}
}
function resumePendingJobs() {
conversationMessages.value.forEach((message) => {
const job = normalizeJob(message.attachmentAssociationJob || null)
if (!job || !isPending(job)) {
return
}
void pollJob({
jobId: job.jobId,
messageId: message.id,
fileNames: message.attachmentOcrDetails?.fileNames || [],
attachmentOcrDetails: message.attachmentOcrDetails || null,
initialJob: job
})
})
}
return {
buildRunningMessage,
extractReceiptIdsFromOcrDocuments,
normalizeJob,
pollJob,
resumePendingJobs,
updateJobMessage
}
}