2026-06-24 10:42:50 +08:00
|
|
|
import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
|
2026-07-16 10:23:23 +08:00
|
|
|
import {
|
|
|
|
|
buildAttachmentAssociationCandidateActions,
|
|
|
|
|
isAttachmentAssociationConfirmationResult,
|
|
|
|
|
resolveAttachmentAssociationTarget
|
|
|
|
|
} from '../../utils/attachmentAssociationJobModel.js'
|
|
|
|
|
import {
|
|
|
|
|
fetchAttachmentAssociationJob,
|
|
|
|
|
normalizeAttachmentAssociationJob
|
|
|
|
|
} from '../../services/attachmentAssociationJobs.js'
|
2026-06-24 10:42:50 +08:00
|
|
|
|
|
|
|
|
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 = {}) {
|
2026-07-16 10:23:23 +08:00
|
|
|
return normalizeAttachmentAssociationJob(job)
|
2026-06-24 10:42:50 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-07-16 10:23:23 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-06-24 10:42:50 +08:00
|
|
|
if (normalizedJob.status === 'succeeded') {
|
2026-07-16 10:23:23 +08:00
|
|
|
const target = resolveAttachmentAssociationTarget(normalizedJob)
|
2026-06-24 10:42:50 +08:00
|
|
|
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
|
2026-07-16 10:23:23 +08:00
|
|
|
claimNo: target.claimNo,
|
2026-06-24 10:42:50 +08:00
|
|
|
fileNames,
|
|
|
|
|
uploadedCount: normalizedJob.uploadedCount,
|
2026-07-16 10:23:23 +08:00
|
|
|
skippedCount: normalizedJob.skippedCount,
|
|
|
|
|
applicationClaimNo: normalizedJob.applicationClaimNo,
|
|
|
|
|
confidence: normalizedJob.confidence,
|
|
|
|
|
matchReasons: normalizedJob.matchReasons,
|
|
|
|
|
riskItems: normalizedJob.riskItems,
|
|
|
|
|
confirmationRequired: Boolean(
|
|
|
|
|
normalizedJob.draftPayload?.confirmationRequired
|
|
|
|
|
?? normalizedJob.draftPayload?.confirmation_required
|
|
|
|
|
)
|
2026-06-24 10:42:50 +08:00
|
|
|
})
|
|
|
|
|
await streamOrSetInlineAssistantContent(messageId, finalMessageText)
|
|
|
|
|
replaceJobMessage(messageId, finalMessageText, {
|
|
|
|
|
attachmentAssociationJob: normalizedJob,
|
|
|
|
|
attachmentOcrDetails,
|
|
|
|
|
stewardPlan: {
|
|
|
|
|
streamStatus: 'completed',
|
|
|
|
|
thinkingEvents: buildThinkingEvents('completed')
|
|
|
|
|
},
|
|
|
|
|
suggestedActions: buildDetailActions({
|
2026-07-16 10:23:23 +08:00
|
|
|
claimId: target.claimId,
|
|
|
|
|
claimNo: target.claimNo
|
2026-06-24 10:42:50 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
notifyRequestUpdated?.({
|
2026-07-16 10:23:23 +08:00
|
|
|
claimId: target.claimId,
|
|
|
|
|
claimNo: target.claimNo,
|
2026-06-24 10:42:50 +08:00
|
|
|
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,
|
2026-07-16 10:23:23 +08:00
|
|
|
resumePendingJobs,
|
|
|
|
|
updateJobMessage
|
2026-06-24 10:42:50 +08:00
|
|
|
}
|
|
|
|
|
}
|