import { buildReviewSlotMap, resolveDocumentTypeLabel, resolveExpenseTypeCode } from './travelReimbursementReviewModel.js' import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js' const SCENARIO_LABELS = { expense: '报销', accounts_receivable: '应收', accounts_payable: '应付', knowledge: '知识', unknown: '通用' } const INTENT_LABELS = { query: '查询', explain: '解释', compare: '对比', risk_check: '风险检查', draft: '信息核对', operate: '动作请求' } function resolveStatusLabel(status) { if (status === 'succeeded') return '已完成' if (status === 'blocked') return '已阻断' return '处理中' } function resolveStatusTone(status) { if (status === 'succeeded') return 'success' if (status === 'blocked') return 'warning' return 'note' } export const MAX_ATTACHMENTS = 10 export const MAX_OCR_DOCUMENTS = 10 export const VISIBLE_ATTACHMENT_CHIPS = 2 export const ATTACHMENT_ASSOCIATION_CONFIRM_HREF = '#confirm-attachment-association' export function buildUnsavedDraftAttachmentConfirmationMessage({ fileNames = [] } = {}) { const names = (Array.isArray(fileNames) ? fileNames : []) .map((item) => String(item || '').trim()) .filter(Boolean) const attachmentLine = names.length ? `本次待归集附件:${names.length} 份(${names.join('、')})` : '本次待归集附件:待识别' return [ '当前这笔报销信息还没有保存为草稿。', '', '如果继续上传票据,我需要先把当前已识别的信息保存成一张草稿单据,再识别并归集本次附件。', '', attachmentLine, '', '', `如果 **[确定](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})**,我会先保存这笔未保存单据,再把此次上传的附件归集到该单据。` ].join('\n').trim() } export function normalizeOcrDocuments(payload) { const documents = Array.isArray(payload?.documents) ? payload.documents : [] return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({ filename: item.filename, summary: item.summary, text: String(item.text || ''), avg_score: Number(item.avg_score || 0), line_count: Number(item.line_count || 0), document_type: String(item.document_type || 'other').trim() || 'other', document_type_label: String(item.document_type_label || '').trim(), scene_code: String(item.scene_code || 'other').trim() || 'other', scene_label: String(item.scene_label || '').trim(), preview_kind: String(item.preview_kind || '').trim(), preview_data_url: String(item.preview_data_url || '').trim(), preview_url: String(item.preview_url || '').trim(), receipt_id: String(item.receipt_id || item.receiptId || '').trim(), receipt_status: String(item.receipt_status || item.receiptStatus || '').trim(), receipt_preview_url: String(item.receipt_preview_url || item.receiptPreviewUrl || '').trim(), receipt_source_url: String(item.receipt_source_url || item.receiptSourceUrl || '').trim(), document_fields: Array.isArray(item.document_fields) ? item.document_fields .map((field) => ({ key: String(field?.key || '').trim(), label: String(field?.label || '').trim(), value: String(field?.value || '').trim() })) .filter((field) => field.key && field.label && field.value) : [], warnings: Array.isArray(item.warnings) ? item.warnings : [] })) } function defineFileReceiptId(file, receiptId) { const normalizedReceiptId = String(receiptId || '').trim() if (!file || !normalizedReceiptId) { return false } try { Object.defineProperty(file, 'receiptId', { value: normalizedReceiptId, enumerable: false, configurable: true }) return true } catch { try { file.receiptId = normalizedReceiptId return String(file.receiptId || '').trim() === normalizedReceiptId } catch { return false } } } export function attachReceiptFolderIdsToFiles(files = [], payload = null) { const safeFiles = Array.isArray(files) ? files : [] const documents = Array.isArray(payload?.documents) ? payload.documents : [] let attachedCount = 0 safeFiles.slice(0, documents.length).forEach((file, index) => { const document = documents[index] || {} const receiptId = String(document.receipt_id || document.receiptId || '').trim() if (receiptId && defineFileReceiptId(file, receiptId)) { attachedCount += 1 } }) return attachedCount } export async function collectReceiptFiles({ files = [], recognizedAttachmentData = null, recognizeOcrFiles, timeoutMs = 90000, timeoutMessage = '票据 OCR 识别超时,已继续使用附件名称处理。' } = {}) { const safeFiles = Array.isArray(files) ? files : [] const reusedData = recognizedAttachmentData && typeof recognizedAttachmentData === 'object' ? recognizedAttachmentData : null if (reusedData) { const ocrDocuments = Array.isArray(reusedData.ocrDocuments) ? [...reusedData.ocrDocuments] : [] const ocrPayload = reusedData.ocrPayload || { documents: ocrDocuments } attachReceiptFolderIdsToFiles(safeFiles, ocrPayload) return { ocrPayload, ocrSummary: String(reusedData.ocrSummary || '').trim() || buildOcrSummaryFromDocuments(ocrDocuments), ocrDocuments, ocrFilePreviews: Array.isArray(reusedData.ocrFilePreviews) ? [...reusedData.ocrFilePreviews] : [] } } if (typeof recognizeOcrFiles !== 'function') { throw new Error('票据采集服务未配置。') } const ocrPayload = await recognizeOcrFiles(safeFiles, { timeoutMs, timeoutMessage }) attachReceiptFolderIdsToFiles(safeFiles, ocrPayload) return { ocrPayload, ocrSummary: buildOcrSummary(ocrPayload), ocrDocuments: normalizeOcrDocuments(ocrPayload), ocrFilePreviews: buildOcrFilePreviews(ocrPayload) } } export function buildOcrSummary(payload) { return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload)) } export function buildOcrSummaryFromDocuments(documents) { return (Array.isArray(documents) ? documents : []) .slice(0, MAX_OCR_DOCUMENTS) .map((item) => { const filename = String(item?.filename || '').trim() const summary = String(item?.summary || item?.text || '').trim() if (filename && summary) { return `${filename}:${summary}` } return filename || summary }) .filter(Boolean) .join(';') } function resolveAssociationDocumentTypeLabel(document) { const explicitLabel = String(document?.document_type_label || '').trim() if (explicitLabel) { return explicitLabel } const sceneLabel = String(document?.scene_label || '').trim() if (sceneLabel) { return sceneLabel } const typeLabel = resolveDocumentTypeLabel(document?.document_type) return String(typeLabel || '').trim() || '其他票据' } function buildAssociationDocumentContentLines(document) { const fields = Array.isArray(document?.document_fields) ? document.document_fields : [] const fieldLines = fields .map((field) => { const label = String(field?.label || '').trim() const value = String(field?.value || '').trim() return label && value ? `- ${label}:${value}` : '' }) .filter(Boolean) if (fieldLines.length) { return fieldLines.slice(0, 8) } const summary = String(document?.summary || document?.text || '').trim() if (summary) { return [`- 识别内容:${summary}`] } return ['- 识别内容:暂未提取到结构化字段,请以票据原件为准。'] } function buildAssociationDocumentCard(lines) { return (Array.isArray(lines) ? lines : []) .map((line) => String(line || '').trim() ? `> ${line}` : '>') .join('\n') } export function buildAttachmentAssociationConfirmationMessage({ claimNo = '', claimTitle = '', fileNames = [], ocrDocuments = [] } = {}) { const documents = Array.isArray(ocrDocuments) && ocrDocuments.length ? ocrDocuments : (Array.isArray(fileNames) ? fileNames : []) .map((filename) => ({ filename })) .filter((item) => String(item.filename || '').trim()) const targetLines = [ claimNo ? `- 草稿单号:${claimNo}` : '', claimTitle ? `- 单据说明:${claimTitle}` : '', `- 本次待归集附件:${documents.length || fileNames.length || 0} 份` ].filter(Boolean) const documentBlocks = documents.map((document, index) => { const filename = String(document?.filename || '').trim() || `附件 ${index + 1}` const typeLabel = resolveAssociationDocumentTypeLabel(document) const contentLines = buildAssociationDocumentContentLines(document) .map((line) => String(line || '').replace(/^-\s*/, '')) return buildAssociationDocumentCard([ `**附件 ${index + 1}:${filename}**`, '', `附件类型:${typeLabel}`, '', ...contentLines ]) }) return [ '已识别附件信息:', '', documentBlocks.join('\n\n'), '', '', '请问是否确定将票据信息归集到单据:', '', targetLines.join('\n'), '', '', `如果 **[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})** 该信息,我将直接将票据进行归集。` ] .join('\n') .replace(/\n{4,}/g, '\n\n\n') .trim() } export function normalizeReviewDocumentFieldKey(label) { const compact = String(label || '').replace(/\s+/g, '').toLowerCase() if (!compact) return '' if ( ['金额', '价税合计', '合计', '总额', '总计', '票价', '支付金额', '实付金额', '实收金额'].some((token) => compact.includes(token.toLowerCase()) ) ) { return 'amount' } if (['日期', '时间', '开票日期', '发生时间'].some((token) => compact.includes(token.toLowerCase()))) { return 'date' } if (['商户', '酒店', '销售方', '开票方', '收款方'].some((token) => compact.includes(token.toLowerCase()))) { return 'merchant_name' } if (['票据号码', '发票号码', '票号', '单号', '订单号'].some((token) => compact.includes(token.toLowerCase()))) { return 'invoice_number' } if (compact.includes('发票代码')) { return 'invoice_code' } if (compact.includes('车次') || compact.includes('航班')) { return 'trip_no' } if (compact.includes('行程') || compact.includes('路线')) { return 'route' } return compact } export function buildOcrDocumentsFromReviewPayload(reviewPayload) { const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => { const fields = Array.isArray(item?.fields) ? item.fields .map((field) => { const label = String(field?.label || '').trim() const value = String(field?.value || '').trim() if (!label || !value) { return null } return { key: normalizeReviewDocumentFieldKey(label), label, value } }) .filter(Boolean) : [] return { filename: String(item?.filename || '').trim(), summary: String(item?.summary || '').trim(), text: [ String(item?.scene_label || '').trim(), String(item?.summary || '').trim(), ...fields.map((field) => `${field.label}:${field.value}`) ] .filter(Boolean) .join(' ') .slice(0, 240), avg_score: Number(item?.avg_score || 0), document_type: String(item?.document_type || 'other').trim() || 'other', document_type_label: resolveDocumentTypeLabel(item?.document_type), scene_code: resolveExpenseTypeCode(item?.suggested_expense_type), scene_label: String(item?.scene_label || '').trim(), preview_kind: String(item?.preview_kind || '').trim(), preview_data_url: String(item?.preview_data_url || '').trim(), preview_url: String(item?.preview_url || '').trim(), document_fields: fields, warnings: Array.isArray(item?.warnings) ? item.warnings : [] } }).filter((item) => item.filename) } export function mergeUploadAttachmentNames(existingNames, incomingNames) { const merged = [] const seen = new Set() for (const value of [...(existingNames || []), ...(incomingNames || [])]) { const normalized = String(value || '').trim() if (!normalized || seen.has(normalized)) continue seen.add(normalized) merged.push(normalized) if (merged.length >= MAX_ATTACHMENTS) { break } } return merged } export function mergeUploadOcrDocuments(existingDocuments, incomingDocuments) { const merged = [] const seen = new Set() for (const item of [...(existingDocuments || []), ...(incomingDocuments || [])]) { const filename = String(item?.filename || '').trim() if (!filename || seen.has(filename)) continue seen.add(filename) merged.push(item) if (merged.length >= MAX_OCR_DOCUMENTS) { break } } return merged } export function inferPreviewKind(file) { const mediaType = String(file?.type || '').toLowerCase() const filename = String(file?.name || '').toLowerCase() if (mediaType.startsWith('image/') || /\.(png|jpg|jpeg|webp|bmp)$/i.test(filename)) { return 'image' } if (mediaType.includes('pdf') || /\.pdf$/i.test(filename)) { return 'pdf' } return 'file' } export function buildFilePreviews(files, previewRegistry) { return files.map((file) => { const kind = inferPreviewKind(file) if (!['image', 'pdf'].includes(kind)) { return { filename: file.name, kind } } const url = URL.createObjectURL(file) previewRegistry.push(url) return { filename: file.name, kind, url } }) } export function resolveDocumentPreview(filePreviews, filename) { if (!Array.isArray(filePreviews)) return null const matches = filePreviews.filter((item) => item.filename === filename) if (!matches.length) { return null } return ( matches.find((item) => item.kind === 'image' && item.url) || matches.find((item) => item.url) || matches[0] ) } export function isTemporaryPreviewUrl(url) { return String(url || '').trim().toLowerCase().startsWith('blob:') } export function buildFileIdentity(file) { return [file?.name, file?.size, file?.lastModified, file?.type].join('__') } export function mergeFilesWithLimit(existingFiles, incomingFiles, limit = MAX_ATTACHMENTS) { const nextFiles = [] const seen = new Set() for (const file of Array.isArray(existingFiles) ? existingFiles : []) { const key = buildFileIdentity(file) if (seen.has(key)) continue seen.add(key) nextFiles.push(file) } let duplicateCount = 0 let overflowCount = 0 for (const file of Array.isArray(incomingFiles) ? incomingFiles : []) { const key = buildFileIdentity(file) if (seen.has(key)) { duplicateCount += 1 continue } if (nextFiles.length >= limit) { overflowCount += 1 continue } seen.add(key) nextFiles.push(file) } return { files: nextFiles, duplicateCount, overflowCount } } export function mergeFilePreviews(existingPreviews, incomingPreviews) { const result = [] const indexByKey = new Map() for (const preview of [...(existingPreviews || []), ...(incomingPreviews || [])]) { const key = [preview?.filename, preview?.kind].join('__') if (!preview?.filename) continue const existingIndex = indexByKey.get(key) if (existingIndex === undefined) { indexByKey.set(key, result.length) result.push(preview) continue } const existingPreview = result[existingIndex] const nextUrl = String(preview?.url || '').trim() const existingUrl = String(existingPreview?.url || '').trim() if (nextUrl && (!existingUrl || isTemporaryPreviewUrl(existingUrl) || nextUrl !== existingUrl)) { result[existingIndex] = preview } } return result } export function filterPersistableFilePreviews(filePreviews) { return (Array.isArray(filePreviews) ? filePreviews : []) .filter((preview) => { const filename = String(preview?.filename || '').trim() const url = String(preview?.url || '').trim() return filename && !isTemporaryPreviewUrl(url) }) } function inferPreviewKindFromUrl(url) { const normalized = String(url || '').trim().toLowerCase() if (!normalized) return '' if (normalized.startsWith('data:image/') || /\.(png|jpg|jpeg|webp|bmp)(?:[?#].*)?$/i.test(normalized)) { return 'image' } if (normalized.startsWith('data:application/pdf') || /\.pdf(?:[?#].*)?$/i.test(normalized)) { return 'pdf' } return '' } function resolveDocumentPreviewKind(item) { const explicit = String(item?.preview_kind || '').trim() if (explicit) { return explicit } return inferPreviewKindFromUrl(String(item?.preview_url || item?.preview_data_url || '').trim()) } export function buildOcrFilePreviews(payload) { const documents = Array.isArray(payload?.documents) ? payload.documents : [] return documents .map((item) => ({ filename: String(item?.filename || '').trim(), kind: resolveDocumentPreviewKind(item), url: String(item?.preview_url || item?.preview_data_url || '').trim() })) .filter((item) => item.filename && item.kind === 'image' && item.url) } export function buildReviewFilePreviewsFromReviewPayload(reviewPayload) { const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] return documents .map((item) => ({ filename: String(item?.filename || '').trim(), kind: resolveDocumentPreviewKind(item), url: String(item?.preview_url || item?.preview_data_url || '').trim() })) .filter((item) => item.filename && item.kind === 'image' && item.url) } export function buildReviewFilePreviewsFromMessages(messages) { const previews = [] for (const message of Array.isArray(messages) ? messages : []) { previews.push(...buildReviewFilePreviewsFromReviewPayload(message?.reviewPayload)) } return mergeFilePreviews([], previews) } export function resolveAttachmentPreviewKind(metadata) { const explicitKind = String(metadata?.preview_kind || '').trim() if (explicitKind) { return explicitKind } const mediaType = String(metadata?.media_type || '').trim().toLowerCase() if (mediaType.startsWith('image/')) { return 'image' } if (mediaType === 'application/pdf') { return 'pdf' } return '' } export function extractReviewAttachmentNames(reviewPayload) { const documentNames = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards.map((item) => String(item?.filename || '').trim()).filter(Boolean) : [] if (documentNames.length) { return documentNames } const slotMap = buildReviewSlotMap(reviewPayload) const attachmentValue = String(slotMap.attachments?.value || '').trim() if (!attachmentValue) { return [] } return attachmentValue.split(/[、,,]/).map((item) => item.trim()).filter(Boolean) } export function buildErrorInsight(error, fileNames = []) { return { intent: 'agent', metricLabel: '运行状态', metricValue: '失败', title: '智能体调用失败', summary: error?.message || '无法连接后端 Orchestrator。', agent: { runId: '未生成', selectedAgent: 'orchestrator', scenario: '未知', intent: '未知', permissionLevel: 'unknown', routeReason: 'request_failed', requiresConfirmation: false, degraded: false, fileNames, citations: [], suggestedActions: [], queryPayload: null, draftPayload: null, reviewPayload: null, riskFlags: [], toolCount: 0, failedToolCount: 0, selectedCapabilityCodes: [], filePreviews: [], statusLabel: '失败', statusTone: 'note' } } } export function buildAgentInsight(payload, fileNames = [], filePreviews = []) { const trace = payload?.trace_summary || {} const result = payload?.result || {} const statusLabel = resolveStatusLabel(payload?.status) return { intent: 'agent', metricLabel: '运行状态', metricValue: statusLabel, title: result?.draft_payload?.title || `${SCENARIO_LABELS[trace?.scenario] || '通用'}${INTENT_LABELS[trace?.intent] || '处理'}结果`, summary: result?.answer || result?.message || '智能体已完成处理。', agent: { runId: payload?.run_id || '未生成', selectedAgent: payload?.selected_agent || 'orchestrator', scenario: SCENARIO_LABELS[trace?.scenario] || trace?.scenario || '未知', intent: INTENT_LABELS[trace?.intent] || trace?.intent || '未知', permissionLevel: payload?.permission_level || 'unknown', routeReason: payload?.route_reason || 'unknown', requiresConfirmation: Boolean(payload?.requires_confirmation), degraded: Boolean(trace?.degraded), fileNames, citations: Array.isArray(result?.citations) ? result.citations : [], suggestedActions: Array.isArray(result?.suggested_actions) ? result.suggested_actions : [], queryPayload: normalizeExpenseQueryPayload(result?.query_payload), draftPayload: result?.draft_payload || null, reviewPayload: result?.review_payload || null, riskFlags: Array.isArray(result?.risk_flags) ? result.risk_flags : [], toolCount: Number(trace?.tool_count || 0), failedToolCount: Number(trace?.failed_tool_count || 0), selectedCapabilityCodes: Array.isArray(trace?.selected_capability_codes) ? trace.selected_capability_codes : [], filePreviews, statusLabel, statusTone: resolveStatusTone(payload?.status) } } }