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 } }