fix(bom): tolerate partial image analysis failures

This commit is contained in:
WuYongJie 2026-08-05 16:27:07 +08:00
parent 82b478a1fe
commit 6fbc44f0d1
13 changed files with 649 additions and 138 deletions

View File

@ -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: '重点核对折叠机构和导轨' },
})

View File

@ -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<string, unknown> {
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<string[]>([])
const [recalculationInstruction, setRecalculationInstruction] = useState('')
const [failedFactDetailsOpen, setFailedFactDetailsOpen] = useState(false)
const scrollIntoView = useBomGsapScrollIntoView()
const assetIdByStoredUrl = new Map<string, string>()
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({
</BomInfoHint>
</div>
{failedFactNotice ? (
<div
className="mt-2 bg-[#fbf4e8] px-2.5 py-2 text-[9px] leading-4 text-[#795b31]"
role="status"
data-testid="bom-failed-image-facts-notice"
>
<div className="flex items-start gap-2">
<CircleAlert size={13} className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-bold">
{failedFactNotice.failedImages.length} 张图片未返回可用分析结果
</p>
<p className="mt-0.5 text-[#866b43]">
{failedFactNotice.successfulImageCount !== null
? `本次已使用 ${failedFactNotice.successfulImageCount}${failedFactNotice.totalImageCount !== null ? `/${failedFactNotice.totalImageCount}` : ''} 张图片事实继续核算。`
: '系统已保留其余可用图片事实继续核算。'}
</p>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
{retryableFailedFactAssetIds.length > 0 ? (
<button
type="button"
disabled={recalculating}
onClick={prepareFailedFactRetry}
className="font-bold underline decoration-[#795b31]/40 underline-offset-2 disabled:cursor-wait disabled:opacity-50"
>
仅重试失败图片
</button>
) : null}
<button
type="button"
onClick={() => setFailedFactDetailsOpen((open) => !open)}
aria-expanded={failedFactDetailsOpen}
className="font-semibold underline decoration-[#795b31]/30 underline-offset-2"
>
{failedFactDetailsOpen ? '收起图片清单' : '查看失败图片'}
</button>
</div>
{failedFactDetailsOpen ? (
<ul className="mt-1.5 space-y-1 border-t border-[#795b31]/15 pt-1.5 text-[#866b43]">
{failedFactNotice.failedImages.slice(0, 6).map((image) => (
<li key={image.imageId}>
<span className="font-semibold">{image.label}</span>
<span> · {image.message}</span>
</li>
))}
{failedFactNotice.failedImages.length > 6 ? (
<li>其余 {failedFactNotice.failedImages.length - 6} 张图片已记录,可在“补充分析图片”中重试。</li>
) : null}
</ul>
) : null}
</div>
</div>
</div>
) : null}
{failedRecalculationRun ? (
<div className="mt-2 flex items-start gap-2 bg-[#f8eeee] px-2.5 py-2 text-[9px] leading-4 text-[#8e4c48]">
<CircleAlert size={13} className="mt-0.5 shrink-0" />

View File

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

View File

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

View File

@ -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,

View File

@ -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<string, BomCostingFailedImageFact>,
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<BomCostingFactBatchProgress, 'snapshot' | 'call'>) => Promise<void>
@ -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 <T>(operation: () => Promise<T>) => {
@ -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<typeof validateFactBatchImages> | null = null
let lastError: unknown = null
let firstRequestError: unknown = null
const acceptedByImageId = new Map<
string,
z.infer<typeof aiImageFactResultSchema>
>()
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,

View File

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

View File

@ -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,

View File

@ -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,
'多模态分析服务鉴权失败,请联系管理员检查模型配置',

View File

@ -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') ||

View File

@ -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<void>
@ -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,
}),

View File

@ -238,6 +238,30 @@ function buildAnalysisImagePlan(
})
}
function buildFailedFactImagePayload(
snapshot: BomCostingSourceFactSnapshot | null,
analysisPlanByIdentity: Map<
string,
ReturnType<typeof buildAnalysisImagePlan>[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,

View File

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