diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx index 9dcddef7..d2959dd5 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx @@ -1070,7 +1070,31 @@ describe('BomCostingWorkbenchClient', () => { completedAt: now, createdAt: now, }], - taskEvents: [], + taskEvents: [{ + id: 'event-partial-fact-result', + eventType: 'RESULT_SAVED', + status: 'READY', + progress: 100, + message: '已使用可用图片事实完成核算', + providerTaskId: 'task-recalculate', + payload: { + runId: 'run-initial', + factAnalysisId: 'facts-1', + factAnalysis: { + totalImageCount: 2, + successfulImageCount: 1, + failedImages: [{ + assetId: 'asset-detail', + imageId: 'IMG_0002', + label: '商品详情图 1', + kind: 'DETAIL', + message: '模型返回格式不完整,已记录为待重试', + attempts: 2, + }], + }, + }, + createdAt: now, + }], sourceSnapshot: { id: 'source-recalculate', status: 'READY', @@ -1218,7 +1242,16 @@ describe('BomCostingWorkbenchClient', () => { ) expect(screen.queryByText('当前案例')).not.toBeInTheDocument() expect(screen.getByLabelText('已完成')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /^重新核算/ })) + expect( + screen.getByTestId('bom-failed-image-facts-notice'), + ).toHaveTextContent('1 张图片未返回可用分析结果') + expect(screen.getByText('本次已使用 1/2 张图片事实继续核算。')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '查看失败图片' })) + expect(screen.getByText('商品详情图 1')).toBeInTheDocument() + expect( + screen.getByTestId('bom-failed-image-facts-notice'), + ).toHaveTextContent('模型返回格式不完整,已记录为待重试') + fireEvent.click(screen.getByRole('button', { name: '仅重试失败图片' })) expect( screen.getByTestId('bom-recalculation-action-dock'), ).toBeInTheDocument() @@ -1257,10 +1290,7 @@ describe('BomCostingWorkbenchClient', () => { screen.getByRole('button', { name: '查看按现有事实重算说明' }), ) expect(reuseFactsExplanation.closest('[role="tooltip"]')).not.toHaveClass('sr-only') - fireEvent.click(screen.getByRole('button', { name: '补充分析图片' })) - fireEvent.click( - screen.getByRole('button', { name: '选择商品详情图 1进行复查' }), - ) + expect(screen.getByText('已选 1 张')).toBeInTheDocument() fireEvent.change(screen.getByPlaceholderText(/重点核对折叠机构/), { target: { value: '重点核对折叠机构和导轨' }, }) diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx index 37476bbe..6773101e 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx @@ -347,6 +347,86 @@ function formatDuration(milliseconds: number | null) { return `${minutes} 分 ${seconds} 秒` } +interface FailedFactImageNotice { + assetId: string | null + imageId: string + label: string + kind: 'MAIN' | 'SKU' | 'DETAIL' | null + message: string + attempts: number | null +} + +interface FailedFactAnalysisNotice { + runId: string | null + factAnalysisId: string | null + totalImageCount: number | null + successfulImageCount: number | null + failedImages: FailedFactImageNotice[] +} + +function isPayloadRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function readPayloadText(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +function readPayloadNumber(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function parseFailedFactAnalysisNotice( + payload: unknown, +): FailedFactAnalysisNotice | null { + if (!isPayloadRecord(payload)) return null + const source = isPayloadRecord(payload.factAnalysis) + ? payload.factAnalysis + : payload + if (!Array.isArray(source.failedImages)) return null + const failedImages = source.failedImages.flatMap((item) => { + if (!isPayloadRecord(item)) return [] + const imageId = readPayloadText(item.imageId) + if (!imageId) return [] + const kind = readPayloadText(item.kind) + const normalizedKind: FailedFactImageNotice['kind'] = + kind === 'MAIN' || kind === 'SKU' || kind === 'DETAIL' + ? kind + : null + return [{ + assetId: readPayloadText(item.assetId) || null, + imageId, + label: readPayloadText(item.label) || `图片 ${imageId}`, + kind: normalizedKind, + message: readPayloadText(item.message) || '这张图片尚未得到可用分析结果', + attempts: readPayloadNumber(item.attempts), + }] + }) + if (failedImages.length === 0) return null + return { + runId: readPayloadText(payload.runId) || null, + factAnalysisId: readPayloadText(payload.factAnalysisId) || null, + totalImageCount: readPayloadNumber(source.totalImageCount), + successfulImageCount: readPayloadNumber( + source.successfulImageCount ?? source.imageCount, + ), + failedImages, + } +} + +function getLatestFailedFactAnalysisNotice(detail: BomCostingCaseDetail) { + const latestRun = detail.analysisRuns?.[0] ?? null + return detail.taskEvents + .map((event) => parseFailedFactAnalysisNotice(event.payload)) + .find((notice) => { + if (!notice || !latestRun) return Boolean(notice) + return ( + notice.runId === latestRun.id || + notice.factAnalysisId === latestRun.factAnalysisId + ) + }) ?? null +} + function formatActivityTime(value: string | null) { if (!value) return '--' const date = new Date(value) @@ -858,6 +938,7 @@ function ResultCanvas({ const [selectedReanalysisAssetIds, setSelectedReanalysisAssetIds] = useState([]) const [recalculationInstruction, setRecalculationInstruction] = useState('') + const [failedFactDetailsOpen, setFailedFactDetailsOpen] = useState(false) const scrollIntoView = useBomGsapScrollIntoView() const assetIdByStoredUrl = new Map() for (const asset of sourceAssets) { @@ -884,6 +965,12 @@ function ResultCanvas({ recalculationOpen && recalculationMode === 'REANALYZE_SELECTED' const selectedReanalysisAssetIdSet = new Set(selectedReanalysisAssetIds) const latestAnalysisRun = detail.analysisRuns?.[0] ?? null + const failedFactNotice = getLatestFailedFactAnalysisNotice(detail) + const retryableFailedFactAssetIds = Array.from(new Set( + failedFactNotice?.failedImages.flatMap((image) => + image.assetId ? [image.assetId] : [], + ) ?? [], + )) const failedRecalculationRun = latestAnalysisRun && latestAnalysisRun.revision > 1 && @@ -915,6 +1002,12 @@ function ResultCanvas({ setRecalculationMode('REUSE_FACTS') setSelectedReanalysisAssetIds([]) } + const prepareFailedFactRetry = () => { + if (retryableFailedFactAssetIds.length === 0) return + setRecalculationOpen(true) + setRecalculationMode('REANALYZE_SELECTED') + setSelectedReanalysisAssetIds(retryableFailedFactAssetIds) + } const recalculationActivity = deriveBomCostingImageActivity(detail) const toggleReanalysisAsset = (assetId: string) => { @@ -1914,6 +2007,61 @@ function ResultCanvas({ + {failedFactNotice ? ( +
+
+ +
+

+ {failedFactNotice.failedImages.length} 张图片未返回可用分析结果 +

+

+ {failedFactNotice.successfulImageCount !== null + ? `本次已使用 ${failedFactNotice.successfulImageCount}${failedFactNotice.totalImageCount !== null ? `/${failedFactNotice.totalImageCount}` : ''} 张图片事实继续核算。` + : '系统已保留其余可用图片事实继续核算。'} +

+
+ {retryableFailedFactAssetIds.length > 0 ? ( + + ) : null} + +
+ {failedFactDetailsOpen ? ( +
    + {failedFactNotice.failedImages.slice(0, 6).map((image) => ( +
  • + {image.label} + · {image.message} +
  • + ))} + {failedFactNotice.failedImages.length > 6 ? ( +
  • 其余 {failedFactNotice.failedImages.length - 6} 张图片已记录,可在“补充分析图片”中重试。
  • + ) : null} +
+ ) : null} +
+
+
+ ) : null} + {failedRecalculationRun ? (
diff --git a/furniture-3d-system/src/lib/bom-costing/fact-contracts.ts b/furniture-3d-system/src/lib/bom-costing/fact-contracts.ts index f2197dd2..dffd4880 100644 --- a/furniture-3d-system/src/lib/bom-costing/fact-contracts.ts +++ b/furniture-3d-system/src/lib/bom-costing/fact-contracts.ts @@ -80,9 +80,36 @@ export const bomCostingImageFactRecordSchema = z.object({ facts: z.array(bomCostingExtractedFactSchema).max(60).default([]), }) +// Image analysis is intentionally fault tolerant. A model can fail to return +// one image while the rest of the evidence remains usable, so the snapshot +// keeps a durable, user-visible retry ledger instead of discarding good facts. +export const bomCostingImageFactFailureReasonSchema = z.enum([ + 'MISSING', + 'DUPLICATE', + 'INVALID_SHAPE', + 'UNREADABLE_ENVELOPE', + 'REQUEST_FAILED', + 'NOT_ANALYZED', +]) + +export const bomCostingFailedImageFactSchema = z.object({ + imageId: z.string().trim().min(1).max(80), + sourceImageUrl: competitorResourceUrlSchema, + kind: z.enum(['MAIN', 'SKU', 'DETAIL']), + label: z.string().trim().min(1).max(240), + externalSkuIds: z.array(z.string().trim().min(1).max(160)).max(5_000).default([]), + reason: bomCostingImageFactFailureReasonSchema, + message: z.string().trim().min(1).max(500), + attempts: z.number().int().min(1).max(20).default(1), +}) + export const bomCostingSourceFactSnapshotSchema = z.object({ schemaVersion: z.literal(BOM_COSTING_FACT_SCHEMA_VERSION), imageFacts: z.array(bomCostingImageFactRecordSchema).max(5_000), + // Default keeps existing persisted snapshots readable. READY means the + // extraction pass has settled; failedImages tells the UI which images can be + // retried without re-running the whole product. + failedImages: z.array(bomCostingFailedImageFactSchema).max(5_000).default([]), extractedAt: z.iso.datetime(), }) @@ -92,6 +119,9 @@ export type BomCostingExtractedFact = z.infer< export type BomCostingImageFactRecord = z.infer< typeof bomCostingImageFactRecordSchema > +export type BomCostingFailedImageFact = z.infer< + typeof bomCostingFailedImageFactSchema +> export type BomCostingSourceFactSnapshot = z.infer< typeof bomCostingSourceFactSnapshotSchema > diff --git a/furniture-3d-system/src/server/repositories/bom-costing.repository.ts b/furniture-3d-system/src/server/repositories/bom-costing.repository.ts index a4b1d657..dd2f7682 100644 --- a/furniture-3d-system/src/server/repositories/bom-costing.repository.ts +++ b/furniture-3d-system/src/server/repositories/bom-costing.repository.ts @@ -466,11 +466,11 @@ export class BomCostingRepository { orderBy: { revision: 'desc' }, }) if ( - latest?.assetFingerprint === input.assetFingerprint && - ( - latest.status !== BOM_COSTING_FACT_ANALYSIS_STATUS.READY || - latest.analyzedImageCount === input.totalImageCount - ) + // Keep the durable partial snapshot regardless of its current state. + // READY can include a small retry ledger; FAILED/PROCESSING can already + // contain valid facts saved batch by batch. The worker will claim it and + // analyse only missing images, instead of losing that evidence. + latest?.assetFingerprint === input.assetFingerprint ) { return latest } diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.test.ts index 7a3e936f..9e67196f 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.test.ts @@ -137,6 +137,7 @@ function createExistingSnapshot( observedTexts: [], facts: [], })), + failedImages: [], extractedAt: new Date().toISOString(), } } @@ -567,7 +568,7 @@ describe('BOM 逐图事实提取', () => { await expect(extractBomCostingImageFacts({ config: providerConfig, snapshot: singleImageSnapshot, - })).rejects.toThrow('没有产生可保存的图片事实') + })).rejects.toThrow('未能从任何图片获得可用分析事实') expect(warnSpy).toHaveBeenCalledWith( '[BOM costing] image fact contract partially rejected', expect.objectContaining({ @@ -707,15 +708,24 @@ describe('BOM 逐图事实提取', () => { })) }) - await expect(extractBomCostingImageFacts({ + const firstOutput = await extractBomCostingImageFacts({ config: providerConfig, snapshot: threeImageSnapshot, onBatchCompleted: async (progress) => { partialSnapshot = progress.snapshot }, - })).rejects.toThrow('其余 2 张已保存,继续处理时只分析未完成图片') + }) + expect(firstOutput.snapshot.imageFacts).toHaveLength(2) + expect(firstOutput.snapshot.failedImages).toEqual([ + expect.objectContaining({ + imageId: 'IMG_0003', + reason: 'INVALID_SHAPE', + attempts: 2, + }), + ]) expect(partialSnapshot?.imageFacts).toHaveLength(2) + expect(partialSnapshot?.failedImages).toHaveLength(1) expect(warnSpy).toHaveBeenCalledWith( '[BOM costing] image fact contract partially rejected', expect.objectContaining({ @@ -726,6 +736,22 @@ describe('BOM 逐图事实提取', () => { ]), }), ) + const reuseOnlyOutput = await extractBomCostingImageFacts({ + config: providerConfig, + snapshot: threeImageSnapshot, + existingSnapshot: partialSnapshot ?? undefined, + reuseExistingFactsOnly: true, + }) + + expect(sentBatches).toHaveLength(2) + expect(reuseOnlyOutput.snapshot.imageFacts).toHaveLength(2) + expect(reuseOnlyOutput.snapshot.failedImages).toHaveLength(1) + expect(reuseOnlyOutput.trace).toMatchObject({ + batchCount: 0, + reusedImageCount: 2, + analyzedImageCount: 0, + failedImageCount: 1, + }) const output = await extractBomCostingImageFacts({ config: providerConfig, snapshot: threeImageSnapshot, diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.ts index 813f9059..9527e71d 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.ts @@ -5,6 +5,7 @@ import { BOM_COSTING_FACT_SCHEMA_VERSION, bomCostingExtractedFactSchema, bomCostingSourceFactSnapshotSchema, + type BomCostingFailedImageFact, type BomCostingSourceFactSnapshot, } from '@/lib/bom-costing/fact-contracts' import { buildBomCostingImageIdentity } from '@/lib/bom-costing/image-identity' @@ -115,9 +116,11 @@ export interface BomCostingFactBatchProgress { totalBatches: number batchImages: BomCostingMultimodalInputImage[] completedImageCount: number + failedImageCount: number + batchFailedImages: BomCostingFailedImageFact[] totalImageCount: number snapshot: BomCostingSourceFactSnapshot - call: BomCostingMultimodalCallResult + call: BomCostingMultimodalCallResult | null } export interface BomCostingFactExtractionTrace { @@ -126,6 +129,7 @@ export interface BomCostingFactExtractionTrace { batchCount: number reusedImageCount: number analyzedImageCount: number + failedImageCount: number durationMs: number promptTokens: number | null completionTokens: number | null @@ -574,12 +578,52 @@ function createTrustedImageFacts( }) } +function createFailedImageFact(input: { + image: BomCostingMultimodalInputImage + reason: BomCostingFailedImageFact['reason'] + message: string + attempts: number +}) { + return { + imageId: input.image.imageId, + sourceImageUrl: input.image.url, + kind: input.image.kind, + label: input.image.label, + externalSkuIds: input.image.externalSkuIds, + reason: input.reason, + message: input.message, + attempts: Math.min(Math.max(input.attempts, 1), 20), + } satisfies BomCostingFailedImageFact +} + +function describeFactImageFailure( + reason: BomCostingFailedImageFact['reason'], + attempts: number, +) { + const retryText = `已自动重试 ${attempts} 次` + switch (reason) { + case 'MISSING': + return `模型没有返回这张图片的分析结果,${retryText}` + case 'DUPLICATE': + return `模型重复返回了这张图片的分析结果,${retryText}` + case 'INVALID_SHAPE': + return `模型返回的这张图片结果格式不完整,${retryText}` + case 'UNREADABLE_ENVELOPE': + return `模型返回的图片分析内容无法读取,${retryText}` + case 'REQUEST_FAILED': + return `图片分析服务暂时不可用,${retryText}` + case 'NOT_ANALYZED': + return '该图片尚未得到可用的分析结果' + } +} + function createFactSnapshot( images: BomCostingMultimodalInputImage[], completedByIdentity: Map< string, BomCostingSourceFactSnapshot['imageFacts'][number] >, + failedByIdentity: Map, extractedAt = new Date().toISOString(), ) { return bomCostingSourceFactSnapshotSchema.parse({ @@ -589,24 +633,15 @@ function createFactSnapshot( completedByIdentity.get(buildBomCostingImageIdentity(image.url)), ) .filter(Boolean), + failedImages: images + .map((image) => + failedByIdentity.get(buildBomCostingImageIdentity(image.url)), + ) + .filter(Boolean), extractedAt, }) } -function describeRejectedFactBatch( - batchIndex: number, - acceptedImageCount: number, - rejectedImageCount: number, -) { - if (acceptedImageCount === 0) { - return `第 ${batchIndex} 批有 ${rejectedImageCount} 张图片结果格式不完整,没有产生可保存的图片事实` - } - return ( - `第 ${batchIndex} 批有 ${rejectedImageCount} 张图片结果格式不完整;` + - `其余 ${acceptedImageCount} 张已保存,继续处理时只分析未完成图片` - ) -} - function logRejectedFactBatch(input: { batchIndex: number totalBatches: number @@ -700,6 +735,10 @@ export async function extractBomCostingImageFacts(input: { config: BomCostingMultimodalProviderConfig snapshot: MarketDetailSnapshot existingSnapshot?: BomCostingSourceFactSnapshot | null + // A normal recalculation must only use saved facts. Failed images remain + // visible to the user and can be retried explicitly through selected-image + // reanalysis; they must not silently turn "reuse" into a fresh image call. + reuseExistingFactsOnly?: boolean analysisInstruction?: string signal?: AbortSignal onBatchStarted?: (progress: Omit) => Promise @@ -743,8 +782,28 @@ export async function extractBomCostingImageFacts(input: { }] as const] }), ) + const failedByIdentity = new Map( + images.flatMap((image) => { + const identity = buildBomCostingImageIdentity(image.url) + if (completedByIdentity.has(identity)) return [] + const existing = (input.existingSnapshot?.failedImages ?? []) + .find((failedImage) => + buildBomCostingImageIdentity(failedImage.sourceImageUrl) === identity, + ) + if (!existing) return [] + return [[identity, { + ...existing, + imageId: image.imageId, + sourceImageUrl: image.url, + kind: image.kind, + label: image.label, + externalSkuIds: image.externalSkuIds, + }] as const] + }), + ) const missingImages = images.filter( (image) => + !input.reuseExistingFactsOnly && !completedByIdentity.has(buildBomCostingImageIdentity(image.url)), ) const batchSize = readBomCostingBoundedInteger( @@ -780,6 +839,7 @@ export async function extractBomCostingImageFacts(input: { let analyzedImageCount = 0 let nextBatchOffset = 0 let firstError: unknown = null + let firstBatchFailureMessage: string | null = null let commitQueue = Promise.resolve() const withCommitLock = async (operation: () => Promise) => { @@ -807,6 +867,8 @@ export async function extractBomCostingImageFacts(input: { totalBatches: batches.length, batchImages, completedImageCount: completedByIdentity.size, + failedImageCount: failedByIdentity.size, + batchFailedImages: [], totalImageCount: images.length, }) }) @@ -838,22 +900,54 @@ export async function extractBomCostingImageFacts(input: { let call: BomCostingMultimodalCallResult | null = null let validation: ReturnType | null = null + let lastError: unknown = null + let firstRequestError: unknown = null + const acceptedByImageId = new Map< + string, + z.infer + >() const callsForBatch: BomCostingMultimodalCallResult[] = [] + let attemptedRequestCount = 0 for ( let contractAttempt = 1; contractAttempt <= MAX_FACT_CONTRACT_ATTEMPTS; contractAttempt += 1 ) { - const candidateCall = await callBomCostingMultimodalModel({ - config: input.config, - operation: 'IMAGE_FACTS', - systemPrompt: FACT_EXTRACTION_SYSTEM_PROMPT, - userContent: buildUserContent(contractAttempt > 1), - maxTokens, - timeoutMs, - signal: input.signal, - }) + attemptedRequestCount += 1 + let candidateCall: BomCostingMultimodalCallResult + try { + candidateCall = await callBomCostingMultimodalModel({ + config: input.config, + operation: 'IMAGE_FACTS', + systemPrompt: FACT_EXTRACTION_SYSTEM_PROMPT, + userContent: buildUserContent(contractAttempt > 1), + maxTokens, + timeoutMs, + signal: input.signal, + }) + } catch (error) { + if (input.signal?.aborted) { + throw input.signal.reason instanceof Error + ? input.signal.reason + : new AppError( + ErrorCode.BOM_COSTING_INVALID_PROVIDER, + '图片分析任务已停止,已完成结果可在稍后继续使用', + ) + } + lastError = error + firstRequestError ??= error + console.warn('[BOM costing] image fact request failed', { + batchIndex, + totalBatches: batches.length, + imageCount: batchImages.length, + contractAttempt, + maxContractAttempts: MAX_FACT_CONTRACT_ATTEMPTS, + errorName: error instanceof Error ? error.name : 'UnknownError', + }) + continue + } callsForBatch.push(candidateCall) + call = candidateCall try { const rawImages = aiFactBatchEnvelopeSchema.parse( parseBomCostingJsonObject(candidateCall.content), @@ -864,21 +958,16 @@ export async function extractBomCostingImageFacts(input: { rawImages, ) - // A response can be valid JSON while still omitting one image or - // returning one malformed image object. Retry the bounded batch once - // with an explicit repair contract before treating it as a real - // partial failure. Keep the most complete usable response so a bad - // retry never discards facts already validated in the first response. - if ( - !validation || - candidateValidation.acceptedImages.length >= - validation.acceptedImages.length - ) { - call = candidateCall - validation = candidateValidation - } - if (candidateValidation.rejectedImages.length === 0) break + // A model can return a different valid subset on each repair attempt. + // Merge every individually validated image so a bad retry never throws + // away good facts returned by an earlier attempt. + validation = candidateValidation + candidateValidation.acceptedImages.forEach((image) => { + acceptedByImageId.set(image.imageId, image) + }) + if (acceptedByImageId.size === batchImages.length) break } catch (error) { + lastError = error logUnreadableFactEnvelope({ batchIndex, totalBatches: batches.length, @@ -892,35 +981,64 @@ export async function extractBomCostingImageFacts(input: { }) } } - if (!call || !validation) { - throw new AppError( - ErrorCode.BOM_COSTING_INVALID_PROVIDER, - `第 ${batchIndex} 批图片事实无法读取,本次没有产生可保存的图片结果`, - ) - } - if (validation.normalizedSkuReferences.length > 0) { + if (validation?.normalizedSkuReferences.length) { logNormalizedFactSkuReferences({ batchIndex, totalBatches: batches.length, - model: call.model, - callId: call.id, + model: call?.model ?? input.config.model, + callId: call?.id ?? null, normalizedSkuReferences: validation.normalizedSkuReferences, }) } const acceptedImageIds = new Set( - validation.acceptedImages.map((image) => image.imageId), + acceptedByImageId.keys(), ) const acceptedBatchImages = batchImages.filter((image) => acceptedImageIds.has(image.imageId), ) const trustedFacts = createTrustedImageFacts( acceptedBatchImages, - validation.acceptedImages, + Array.from(acceptedByImageId.values()), ) + const rejectedByImageId = new Map( + (validation?.rejectedImages ?? []).map((image) => [ + image.imageId, + image, + ] as const), + ) + const failureAttemptCount = Math.max(attemptedRequestCount, 1) + const batchFailedImages = batchImages.flatMap((image) => { + if (acceptedImageIds.has(image.imageId)) return [] + const identity = buildBomCostingImageIdentity(image.url) + const rejected = rejectedByImageId.get(image.imageId) + const reason = rejected?.reason ?? ( + call + ? 'UNREADABLE_ENVELOPE' + : 'REQUEST_FAILED' + ) + const previousAttempts = failedByIdentity.get(identity)?.attempts ?? 0 + return [createFailedImageFact({ + image, + reason, + message: describeFactImageFailure( + reason, + previousAttempts + failureAttemptCount, + ), + attempts: previousAttempts + failureAttemptCount, + })] + }) await withCommitLock(async () => { trustedFacts.forEach((image) => { + const identity = buildBomCostingImageIdentity(image.sourceImageUrl) completedByIdentity.set( + identity, + image, + ) + failedByIdentity.delete(identity) + }) + batchFailedImages.forEach((image) => { + failedByIdentity.set( buildBomCostingImageIdentity(image.sourceImageUrl), image, ) @@ -934,39 +1052,53 @@ export async function extractBomCostingImageFacts(input: { batchCall.completionTokens, ) } - if (trustedFacts.length > 0) { - await input.onBatchCompleted?.({ - batchIndex, - totalBatches: batches.length, - batchImages: acceptedBatchImages, - completedImageCount: completedByIdentity.size, - totalImageCount: images.length, - snapshot: createFactSnapshot(images, completedByIdentity), - call, - }) - } + await input.onBatchCompleted?.({ + batchIndex, + totalBatches: batches.length, + batchImages, + completedImageCount: completedByIdentity.size, + failedImageCount: failedByIdentity.size, + batchFailedImages, + totalImageCount: images.length, + snapshot: createFactSnapshot( + images, + completedByIdentity, + failedByIdentity, + ), + call, + }) }) - if (validation.rejectedImages.length === 0) return - logRejectedFactBatch({ - batchIndex, - totalBatches: batches.length, - batchImageCount: batchImages.length, - model: call.model, - callId: call.id, - finishReason: call.finishReason, - rejectedImages: validation.rejectedImages, - unexpectedImageIds: validation.unexpectedImageIds, - unreadableImageCount: validation.unreadableImageCount, - }) - throw new AppError( - ErrorCode.BOM_COSTING_INVALID_PROVIDER, - describeRejectedFactBatch( + if (batchFailedImages.length > 0) { + const userFacingFailure = + firstRequestError instanceof Error + ? firstRequestError + : lastError + if (!firstBatchFailureMessage && userFacingFailure instanceof Error) { + firstBatchFailureMessage = userFacingFailure.message + } + logRejectedFactBatch({ batchIndex, - trustedFacts.length, - validation.rejectedImages.length, - ), - ) + totalBatches: batches.length, + batchImageCount: batchImages.length, + model: call?.model ?? input.config.model, + callId: call?.id ?? null, + finishReason: call?.finishReason ?? null, + rejectedImages: batchFailedImages.map((image) => ({ + imageId: image.imageId, + reason: image.reason === 'UNREADABLE_ENVELOPE' || + image.reason === 'REQUEST_FAILED' || + image.reason === 'NOT_ANALYZED' + ? 'MISSING' + : image.reason, + issueDetails: + rejectedByImageId.get(image.imageId)?.issueDetails ?? [], + })), + unexpectedImageIds: validation?.unexpectedImageIds ?? [], + unreadableImageCount: validation?.unreadableImageCount ?? + (lastError ? 1 : 0), + }) + } } const runWorker = async () => { @@ -994,18 +1126,28 @@ export async function extractBomCostingImageFacts(input: { const snapshot = createFactSnapshot( images, completedByIdentity, + failedByIdentity, batches.length > 0 ? new Date().toISOString() : input.existingSnapshot?.extractedAt ?? new Date().toISOString(), ) + if (snapshot.imageFacts.length === 0) { + throw new AppError( + ErrorCode.BOM_COSTING_INVALID_PROVIDER, + firstBatchFailureMessage + ? `${firstBatchFailureMessage};${snapshot.failedImages.length} 张图片已记录为待重试` + : `未能从任何图片获得可用分析事实;${snapshot.failedImages.length} 张图片已记录为待重试`, + ) + } return { snapshot, trace: { model: input.config.model, callIds, batchCount: batches.length, - reusedImageCount: images.length - missingImages.length, + reusedImageCount: completedByIdentity.size - analyzedImageCount, analyzedImageCount, + failedImageCount: snapshot.failedImages.length, durationMs: Date.now() - startedAt, promptTokens, completionTokens, diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts index 954a0200..1f91a998 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts @@ -468,7 +468,7 @@ describe('BOM 多模态分析器', () => { await expect(new BomCostingMultimodalAnalyzer().analyze({ sourceUrl: snapshot.detailUrl, snapshot, - })).rejects.toThrow('多模态分析返回了多个完整核算结果,无法确定唯一结果') + })).rejects.toThrow('多模态分析返回的产品结构信息不完整,请重新处理') }) it('保留模型返回的结构推断证据类型,供后续人工确认', async () => { @@ -749,7 +749,7 @@ describe('BOM 多模态分析器', () => { sourceUrl: snapshot.detailUrl, snapshot, }), - ).rejects.toThrow('SKU 覆盖不一致') + ).rejects.toThrow('多模态分析返回的 SKU 信息不完整,请重新处理') }) it('单批结果首次漏掉 SKU 时只重试该批,并接受第二次完整结果', async () => { @@ -1288,7 +1288,7 @@ describe('BOM 多模态分析器', () => { expect(fetchSpy).toHaveBeenCalledTimes(1) }) - it('模型网关暂时不可用时返回客户可读错误且不自动重试', async () => { + it('模型网关暂时不可用时记录待重试图片并返回客户可读错误', async () => { const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue( new Response(JSON.stringify({ error: { message: 'The service is temporarily unavailable.' }, @@ -1301,7 +1301,7 @@ describe('BOM 多模态分析器', () => { snapshot, }), ).rejects.toThrow('多模态分析服务暂时不可用,请稍后重新发起') - expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy).toHaveBeenCalledTimes(2) }) it('组合型 SKU 超过 600 条上限时说明选项结构并在付费模型前停止', async () => { @@ -1368,7 +1368,7 @@ describe('BOM 多模态分析器', () => { expect(fetchSpy).not.toHaveBeenCalled() }) - it('流式输出因长度上限截断时保留明确错误且不自动重试', async () => { + it('流式输出因长度上限截断时保留明确错误并记录待重试图片', async () => { const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue( createStreamingResponse( JSON.stringify(createFactBatchPayload(snapshot)), @@ -1384,6 +1384,6 @@ describe('BOM 多模态分析器', () => { ).rejects.toThrow( '多模态分析输出达到长度上限,本次未保存不完整结果;已完成的图片事实不会重复分析', ) - expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy).toHaveBeenCalledTimes(2) }) }) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts index fd76c703..4cb03407 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts @@ -764,9 +764,17 @@ function describeAnalysisPayloadError(error: unknown): string { ? '多模态分析返回的 SKU 信息不完整,请重新处理' : '多模态分析返回的产品结构信息不完整,请重新处理' } - return error instanceof Error - ? error.message - : '多模态分析返回的信息不完整,请重新处理' + // Do not leak a JSON parser implementation detail to the task state. Every + // non-schema parsing/coverage error here means the model answer is not a + // safely usable structured BOM yet, so the shared recovery path can retry + // from the saved SKU checkpoint. + if ( + error instanceof Error && + /(?:SKU|规格|覆盖)/i.test(error.message) + ) { + return '多模态分析返回的 SKU 信息不完整,请重新处理' + } + return '多模态分析返回的产品结构信息不完整,请重新处理' } function parseUniqueAnalysisPayload(content: string) { @@ -891,6 +899,7 @@ export interface BomCostingMultimodalAnalyzeInput { sourceUrl: string snapshot: MarketDetailSnapshot existingFactSnapshot?: BomCostingSourceFactSnapshot | null + reuseExistingFactsOnly?: boolean synthesisCheckpoint?: BomCostingSynthesisCheckpoint | null analysisInstruction?: string signal?: AbortSignal @@ -949,6 +958,7 @@ export class BomCostingMultimodalAnalyzer { config, snapshot: input.snapshot, existingSnapshot: input.existingFactSnapshot, + reuseExistingFactsOnly: input.reuseExistingFactsOnly, analysisInstruction: input.analysisInstruction, signal: input.signal, onBatchStarted: input.onFactBatchStarted, @@ -1217,16 +1227,14 @@ export class BomCostingMultimodalAnalyzer { await withCommitLock(async () => { completedBatchResults[batchIndex] = synthesisResult completedSkuCount += skuBatch.length - const completedBatchCount = completedBatchResults.filter(Boolean).length - const synthesisCheckpoint = - completedBatchCount % 4 === 0 || - completedBatchCount === skuBatches.length - ? createSynthesisCheckpoint({ - snapshot: input.snapshot, - skuBatches, - completedBatchResults, - }) - : null + // Every completed SKU batch is durable. If a later model response is + // malformed or times out, automatic recovery resumes from this exact + // point instead of asking the model to redo already valid SKU output. + const synthesisCheckpoint = createSynthesisCheckpoint({ + snapshot: input.snapshot, + skuBatches, + completedBatchResults, + }) await input.onSynthesisBatchCompleted?.({ batchIndex: batchIndex + 1, totalBatches: skuBatches.length, diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.test.ts index 3935bf08..9ef36e02 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.test.ts @@ -88,6 +88,14 @@ describe('BOM 多模态网关', () => { ErrorCode.BOM_COSTING_INVALID_PROVIDER, '多模态分析返回的 SKU 信息不完整,请重新处理', ))).toBe(true) + expect(isBomCostingMultimodalTransientError(new AppError( + ErrorCode.BOM_COSTING_INVALID_PROVIDER, + '多模态分析返回的产品结构信息不完整,请重新处理', + ))).toBe(true) + expect(isBomCostingMultimodalTransientError(new AppError( + ErrorCode.BOM_COSTING_INVALID_PROVIDER, + '多模态分析输出达到长度上限,本次未保存不完整结果;已完成的图片事实不会重复分析', + ))).toBe(true) expect(isBomCostingMultimodalTransientError(new AppError( ErrorCode.BOM_COSTING_INVALID_PROVIDER, '多模态分析服务鉴权失败,请联系管理员检查模型配置', diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.ts index 27b649ea..1fbbdfc9 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.ts @@ -384,6 +384,7 @@ export function isBomCostingMultimodalTransientError(error: unknown) { error.message.startsWith('多模态 BOM 分析超时') || error.message.startsWith('多模态分析服务处理超时') || error.message.startsWith('多模态分析服务暂时无法连接') || + error.message.startsWith('多模态分析输出达到长度上限') || error.message.startsWith('多模态分析服务没有返回可读取的结果') || error.message.startsWith('多模态分析服务未返回可读取的结果流') || error.message.startsWith('多模态分析服务返回 400') || diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.test.ts index 74ac3ad1..e7d3dac9 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.test.ts @@ -895,7 +895,7 @@ describe('BomCostingProcessingService', () => { expect(bomCostingRepository.failSourceFactAnalysis).not.toHaveBeenCalled() }) - it('逐图事实保存完成后最终合成失败,不把事实缓存回退为失败', async () => { + it('逐图事实保存完成后汇总返回格式异常,会从已保存事实自动续跑', async () => { mockRunAnalysis.mockImplementation(async (input) => { const analysisInput = input as { onFactExtractionCompleted?: (result: unknown) => Promise @@ -913,8 +913,14 @@ describe('BomCostingProcessingService', () => { completionTokens: 80, }, }) - throw new Error('多模态分析服务处理超时(524)') + throw new AppError( + ErrorCode.BOM_COSTING_INVALID_PROVIDER, + '多模态分析返回的产品结构信息不完整,请重新处理', + ) }) + const requeueSpy = jest + .spyOn(bomCostingRepository, 'requeueTransientMultimodalFailure') + .mockResolvedValue(true) const { BomCostingProcessingService } = await import( './bom-costing-processing.service' ) @@ -929,13 +935,12 @@ describe('BomCostingProcessingService', () => { }), ) expect(bomCostingRepository.failSourceFactAnalysis).not.toHaveBeenCalled() - expect(bomCostingRepository.failCase).toHaveBeenCalledWith( - { - caseId: 'case-1', - analysisRunId: 'analysis-run-1', - message: '多模态分析服务处理超时(524)', - }, - ) + expect(requeueSpy).toHaveBeenCalledWith({ + caseId: 'case-1', + analysisRunId: 'analysis-run-1', + message: '多模态分析返回的产品结构信息不完整,请重新处理', + }) + expect(bomCostingRepository.failCase).not.toHaveBeenCalled() }) it('逐图事实已完整时直接复用,只执行最终结构合成', async () => { @@ -967,7 +972,11 @@ describe('BomCostingProcessingService', () => { expect(bomCostingRepository.claimSourceFactAnalysis).not.toHaveBeenCalled() expect(mockRunAnalysis).toHaveBeenCalledWith( expect.objectContaining({ - existingFactSnapshot: factSnapshot, + existingFactSnapshot: { + ...factSnapshot, + failedImages: [], + }, + reuseExistingFactsOnly: true, onFactBatchStarted: undefined, onFactBatchCompleted: undefined, }), diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts index d2a79089..829ef0b6 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-processing.service.ts @@ -238,6 +238,30 @@ function buildAnalysisImagePlan( }) } +function buildFailedFactImagePayload( + snapshot: BomCostingSourceFactSnapshot | null, + analysisPlanByIdentity: Map< + string, + ReturnType[number] + >, +) { + return (snapshot?.failedImages ?? []).map((image) => { + const plan = analysisPlanByIdentity.get( + buildBomCostingImageIdentity(image.sourceImageUrl), + ) + return { + assetId: plan?.assetId ?? null, + sequence: plan?.sequence ?? null, + imageId: image.imageId, + label: image.label, + kind: image.kind, + reason: image.reason, + message: image.message, + attempts: image.attempts, + } + }) +} + async function persistArchiveProgress( caseId: string, progress: BomCostingArchiveProgress, @@ -537,6 +561,7 @@ export class BomCostingProcessingService { ] as const), ) let existingFactSnapshot: BomCostingSourceFactSnapshot | null = null + let reuseExistingFactsOnly = false if ( archivedSource && marketSnapshot && @@ -617,6 +642,14 @@ export class BomCostingProcessingService { ), ), ), + failedImages: (baseSnapshot.failedImages ?? []).filter( + (image) => + !selectedIdentities.has( + buildBomCostingImageIdentity( + image.sourceImageUrl, + ), + ), + ), }) : null factAnalysis = @@ -654,15 +687,24 @@ export class BomCostingProcessingService { existingFactSnapshot = parseSourceFactSnapshot( factAnalysis.factSnapshot, ) + reuseExistingFactsOnly = true + const failedFactImages = buildFailedFactImagePayload( + existingFactSnapshot, + analysisPlanByIdentity, + ) await bomCostingRepository.updateCaseStage(caseId, { eventType: 'MODEL_FACT_CACHE_REUSED', status: BomCostingCaseStatus.ANALYZING, progress: 76, - message: `已复用 ${factAnalysis.analyzedImageCount} 张图片的分析事实`, + message: failedFactImages.length > 0 + ? `已复用 ${factAnalysis.analyzedImageCount} 张图片事实;${failedFactImages.length} 张待重试` + : `已复用 ${factAnalysis.analyzedImageCount} 张图片的分析事实`, payload: { factAnalysisId: factAnalysis.id, analyzedImageCount: factAnalysis.analyzedImageCount, totalImageCount: factAnalysis.totalImageCount, + failedImageCount: failedFactImages.length, + failedImages: failedFactImages, batchCount: factAnalysis.batchCount, model: factAnalysis.model, runId: analysisRunId, @@ -695,15 +737,24 @@ export class BomCostingProcessingService { existingFactSnapshot = parseSourceFactSnapshot( readyFactAnalysis.factSnapshot, ) + reuseExistingFactsOnly = true + const failedFactImages = buildFailedFactImagePayload( + existingFactSnapshot, + analysisPlanByIdentity, + ) await bomCostingRepository.updateCaseStage(caseId, { eventType: 'MODEL_FACT_CACHE_REUSED', status: BomCostingCaseStatus.ANALYZING, progress: 76, - message: `已复用 ${readyFactAnalysis.analyzedImageCount} 张图片的分析事实`, + message: failedFactImages.length > 0 + ? `已复用 ${readyFactAnalysis.analyzedImageCount} 张图片事实;${failedFactImages.length} 张待重试` + : `已复用 ${readyFactAnalysis.analyzedImageCount} 张图片的分析事实`, payload: { factAnalysisId: readyFactAnalysis.id, analyzedImageCount: readyFactAnalysis.analyzedImageCount, totalImageCount: readyFactAnalysis.totalImageCount, + failedImageCount: failedFactImages.length, + failedImages: failedFactImages, batchCount: readyFactAnalysis.batchCount, model: readyFactAnalysis.model, runId: analysisRunId, @@ -713,13 +764,20 @@ export class BomCostingProcessingService { } } } + const reusableFailedFactImages = buildFailedFactImagePayload( + existingFactSnapshot, + analysisPlanByIdentity, + ) + let completedFactSnapshot = existingFactSnapshot await bomCostingRepository.updateCaseStage(caseId, { eventType: 'MODEL_ANALYSIS_STARTED', status: BomCostingCaseStatus.ANALYZING, progress: existingFactSnapshot ? 76 : 52, message: analysisRun.mode === BomCostingAnalysisRunMode.REUSE_FACTS - ? '已复用逐图事实,正在重新汇总产品结构与成本信息' + ? reusableFailedFactImages.length > 0 + ? `已复用逐图事实,${reusableFailedFactImages.length} 张图片待重试;正在重新汇总产品结构与成本信息` + : '已复用逐图事实,正在重新汇总产品结构与成本信息' : existingFactSnapshot ? '逐图事实已就绪,准备合并产品结构与成本信息' : analysisRun.mode === @@ -736,6 +794,8 @@ export class BomCostingProcessingService { imageCount: analysisInputStats.imageCount, archivedImageCount: archivedSource?.storedImageCount ?? 0, skuCount: analysisInputStats.inputSkuCount, + failedImageCount: reusableFailedFactImages.length, + failedImages: reusableFailedFactImages, analysisImages, reusedAnalysisImages: existingFactSnapshot?.imageFacts.flatMap((image) => { @@ -755,6 +815,7 @@ export class BomCostingProcessingService { sourceUrl: record.sourceUrl, snapshot: marketSnapshot, existingFactSnapshot, + reuseExistingFactsOnly, synthesisCheckpoint: readBomCostingSynthesisCheckpoint( analysisRun.resultSnapshot, ), @@ -782,6 +843,7 @@ export class BomCostingProcessingService { batchIndex: progress.batchIndex, totalBatches: progress.totalBatches, completedImageCount: progress.completedImageCount, + failedImageCount: progress.failedImageCount, totalImageCount: progress.totalImageCount, batchImages: progress.batchImages.map((image) => ({ ...analysisPlanByIdentity.get( @@ -815,16 +877,27 @@ export class BomCostingProcessingService { confidence: fact.confidence, })), })) + const failedImages = (progress.batchFailedImages ?? []).map((image) => ({ + ...analysisPlanByIdentity.get( + buildBomCostingImageIdentity(image.sourceImageUrl), + ), + imageId: image.imageId, + label: image.label, + kind: image.kind, + reason: image.reason, + message: image.message, + attempts: image.attempts, + })) await bomCostingRepository.saveSourceFactAnalysisBatch({ id: factAnalysisId!, factSnapshot: progress.snapshot as unknown as Prisma.InputJsonValue, analyzedImageCount: progress.completedImageCount, totalImageCount: progress.totalImageCount, - model: progress.call.model, - promptTokens: progress.call.promptTokens ?? 0, - completionTokens: progress.call.completionTokens ?? 0, - durationMs: progress.call.durationMs, + model: progress.call?.model ?? modelName ?? '', + promptTokens: progress.call?.promptTokens ?? 0, + completionTokens: progress.call?.completionTokens ?? 0, + durationMs: progress.call?.durationMs ?? 0, }) const caseProgress = 52 + @@ -837,17 +910,20 @@ export class BomCostingProcessingService { eventType: 'MODEL_FACT_BATCH_COMPLETED', status: BomCostingCaseStatus.ANALYZING, progress: caseProgress, - message: - `已分析 ${progress.completedImageCount}/${progress.totalImageCount} 张图片`, + message: (progress.failedImageCount ?? 0) > 0 + ? `已分析 ${progress.completedImageCount}/${progress.totalImageCount} 张图片;${progress.failedImageCount} 张待重试` + : `已分析 ${progress.completedImageCount}/${progress.totalImageCount} 张图片`, payload: { factAnalysisId, batchIndex: progress.batchIndex, totalBatches: progress.totalBatches, completedImageCount: progress.completedImageCount, totalImageCount: progress.totalImageCount, - callId: progress.call.id, - model: progress.call.model, - durationMs: progress.call.durationMs, + callId: progress.call?.id ?? null, + model: progress.call?.model ?? modelName, + durationMs: progress.call?.durationMs ?? 0, + failedImageCount: progress.failedImageCount ?? 0, + failedImages, batchImages: progress.batchImages.map((image) => ({ ...analysisPlanByIdentity.get( buildBomCostingImageIdentity(image.url), @@ -860,6 +936,11 @@ export class BomCostingProcessingService { } : undefined, onFactExtractionCompleted: async (result) => { + completedFactSnapshot = result.snapshot + const failedImages = buildFailedFactImagePayload( + result.snapshot, + analysisPlanByIdentity, + ) if (factAnalysisOwnedByTask && factAnalysisId) { await bomCostingRepository.completeSourceFactAnalysis({ id: factAnalysisId, @@ -875,11 +956,17 @@ export class BomCostingProcessingService { eventType: 'MODEL_FACT_EXTRACTION_COMPLETED', status: BomCostingCaseStatus.ANALYZING, progress: 77, - message: - `全部 ${result.snapshot.imageFacts.length} 张图片事实已就绪`, + message: failedImages.length > 0 + ? `已就绪 ${result.snapshot.imageFacts.length}/${analysisInputStats.imageCount} 张图片事实;${failedImages.length} 张待重试,继续汇总成本` + : `全部 ${result.snapshot.imageFacts.length} 张图片事实已就绪`, payload: { factAnalysisId, + runId: analysisRunId, imageCount: result.snapshot.imageFacts.length, + totalImageCount: analysisInputStats.imageCount, + successfulImageCount: result.snapshot.imageFacts.length, + failedImageCount: failedImages.length, + failedImages, analyzedImageCount: result.trace.analyzedImageCount, reusedImageCount: result.trace.reusedImageCount, batchCount: result.trace.batchCount, @@ -889,7 +976,14 @@ export class BomCostingProcessingService { eventType: 'MODEL_SYNTHESIS_STARTED', status: BomCostingCaseStatus.ANALYZING, progress: 78, - message: '正在合并逐图事实并生成各 SKU 的结构化 BOM', + message: failedImages.length > 0 + ? `正在基于已就绪图片事实汇总各 SKU 的结构化 BOM;${failedImages.length} 张图片待重试` + : '正在合并逐图事实并生成各 SKU 的结构化 BOM', + payload: { + runId: analysisRunId, + factAnalysisId, + failedImageCount: failedImages.length, + }, }) }, onSynthesisBatchStarted: async (progress) => { @@ -981,6 +1075,10 @@ export class BomCostingProcessingService { preparedSkus, }) const stats = buildResultStats(preparedSkus) + const completedFailedFactImages = buildFailedFactImagePayload( + completedFactSnapshot, + analysisPlanByIdentity, + ) await bomCostingRepository.updateCaseStage(caseId, { eventType: 'PRICE_MATCH_COMPLETED', status: BomCostingCaseStatus.ANALYZING, @@ -997,6 +1095,15 @@ export class BomCostingProcessingService { skus: preparedSkus, eventPayload: { ...stats, + runId: analysisRunId, + factAnalysisId, + factAnalysis: { + totalImageCount: analysisInputStats.imageCount, + successfulImageCount: + completedFactSnapshot?.imageFacts.length ?? 0, + failedImageCount: completedFailedFactImages.length, + failedImages: completedFailedFactImages, + }, model: output.trace.model, callId: output.trace.callId, durationMs: output.trace.durationMs, diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-provider.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-provider.ts index 047785a7..9787e46e 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-provider.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-provider.ts @@ -37,6 +37,7 @@ export interface CompetitorAnalysisProvider { sourceUrl: string snapshot: MarketDetailSnapshot | null existingFactSnapshot?: BomCostingSourceFactSnapshot | null + reuseExistingFactsOnly?: boolean synthesisCheckpoint?: BomCostingSynthesisCheckpoint | null analysisInstruction?: string signal?: AbortSignal @@ -328,6 +329,7 @@ class MarketDetailMultimodalProvider implements CompetitorAnalysisProvider { sourceUrl: string snapshot: MarketDetailSnapshot | null existingFactSnapshot?: BomCostingSourceFactSnapshot | null + reuseExistingFactsOnly?: boolean synthesisCheckpoint?: BomCostingSynthesisCheckpoint | null analysisInstruction?: string signal?: AbortSignal