fix(bom): tolerate partial image analysis failures
This commit is contained in:
parent
82b478a1fe
commit
6fbc44f0d1
@ -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: '重点核对折叠机构和导轨' },
|
||||
})
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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
|
||||
>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
'多模态分析服务鉴权失败,请联系管理员检查模型配置',
|
||||
|
||||
@ -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') ||
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user