diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts index 4cb2e951..f1cf9f6f 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.test.ts @@ -1004,6 +1004,54 @@ describe('BOM 多模态分析器', () => { expect(JSON.stringify(compactInput)).not.toContain('不属于当前批次') }) + it('高密度详情事实只保留最有成本价值的一组产品级证据', () => { + const factSnapshot = createCompleteFactSnapshot(snapshot) + const factKinds = [ + 'DIMENSION', + 'MATERIAL', + 'STRUCTURE', + 'MECHANISM', + 'HARDWARE', + 'PART', + 'PROCESS', + 'PACKAGING', + 'WEIGHT', + 'SKU_DIFFERENCE', + 'TEXT', + ] as const + factSnapshot.imageFacts.push(...Array.from({ length: 18 }, (_, index) => ({ + imageId: `DETAIL_DENSE_${index + 1}`, + sourceImageUrl: `https://img.example.com/detail-dense-${index + 1}.jpg`, + kind: 'DETAIL' as const, + label: `高密度详情图 ${index + 1}`, + externalSkuIds: [], + observedTexts: [], + facts: [{ + factId: `fact_dense_${index + 1}`, + kind: factKinds[index % factKinds.length]!, + subject: `部件 ${index + 1}`, + value: `事实 ${index + 1}`, + relatedSkuIds: [], + basis: 'IMAGE' as const, + confidence: 0.8, + }], + }))) + + const compactInput = buildSynthesisFactInput( + factSnapshot, + [snapshot.skus[0]!], + ) + const detailImages = compactInput.images.filter( + (image) => image.kind === 'DETAIL', + ) + const retainedKinds = new Set( + detailImages.flatMap((image) => image.facts.map((fact) => fact.k)), + ) + + expect(detailImages).toHaveLength(14) + expect([...factKinds].every((kind) => retainedKinds.has(kind))).toBe(true) + }) + it('复用图片事实时,GPT-5 最终汇总在网关截断前结束等待', async () => { const timeoutSpy = jest .spyOn(AbortSignal, 'timeout') diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts index 7fe99433..b8419fb8 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-analyzer.ts @@ -75,7 +75,7 @@ const SYSTEM_PROMPT = `你是家具产品结构与成本工程师。你的任务 14. 每条 evidence 必须设置 basis:图片直接可见为 IMAGE;图片标注、详情文字或商品文案为 SOURCE_TEXT;SKU 名称、规格或属性为 SKU_METADATA;由装配或机构功能必需性推断的隐藏部件为 STRUCTURAL_INFERENCE。 15. STRUCTURAL_INFERENCE 只允许用于实现已确认结构或功能所必需的隐藏五金、连接件或保护包材;必须写明推断原因、数量估算和待确认项,evidence.confidence 与 BOM 行 confidence 都不得高于 0.55。 16. 安装知识只用于提醒检查,不是当前商品的事实。不得因为知识库列出了某种五金,就在缺少当前商品结构或功能依据时把它写进 BOM。 -17. 逐图事实已经覆盖尺寸标注、局部小图、剖面图、结构示意、折叠前后状态和产品信息图;合并时不得遗漏这些事实。 +17. 系统会从已归档的逐图事实中挑选与 BOM 最相关的产品级证据,并附上当前批次关联的 SKU 图片事实;不得遗漏本次输入中给出的事实。 18. SKU 名称或属性中的“折叠、升降、推拉、抽屉、脚轮、电动”等功能词必须触发对应机构检查;同时区分收拢态、展开态与整机尺寸。 19. 对板件之间、玻璃与框架、抽屉与柜体、门板与柜体等装配界面,必须检查连接方式;看不清时可以给出低置信度的必需连接件估算,但不得编造具体型号。 20. 每个结构组必须至少包含材料类(BOARD、STEEL、ACCESSORY 中至少一条)、PROCESSING、LABOR 和 PACKAGING,确保系统能立即形成一套出厂成本预估;不确定项用低置信度估算并在 evidence 中说明,不得直接省略整个成本组。` @@ -237,6 +237,20 @@ const DEFAULT_SYNTHESIS_BATCH_CONCURRENCY = 3 const MAX_EXPLICIT_FACT_SKU_IDS = 20 const MAX_SYNTHESIS_CONTRACT_ATTEMPTS = 2 const SYNTHESIS_IMAGE_REFERENCE_PREFIX = 'bom-image:' +const MAX_PRODUCT_LEVEL_FACT_IMAGES = 14 +const PRODUCT_LEVEL_FACT_KIND_WEIGHTS: Record = { + DIMENSION: 8, + MATERIAL: 8, + STRUCTURE: 7, + MECHANISM: 7, + HARDWARE: 7, + PART: 6, + PROCESS: 5, + PACKAGING: 5, + WEIGHT: 5, + SKU_DIFFERENCE: 4, + TEXT: 1, +} function describeHighCardinalitySkuCatalog(skus: MarketSkuSnapshot[]) { const valuesByAttribute = new Map>() @@ -335,11 +349,87 @@ function createSynthesisImageReferenceMap( ) } +function getSynthesisFactSignature( + fact: BomCostingSourceFactSnapshot['imageFacts'][number]['facts'][number], +) { + return JSON.stringify([ + fact.kind, + fact.subject, + fact.value, + fact.numericValue ?? null, + fact.unit ?? null, + fact.basis, + ]) +} + +function selectProductLevelSynthesisImageIds( + factSnapshot: BomCostingSourceFactSnapshot, +) { + const selectedIds = new Set( + factSnapshot.imageFacts + .filter((image) => image.kind === 'MAIN') + .map((image) => image.imageId), + ) + const remainingDetails = factSnapshot.imageFacts + .filter((image) => image.kind === 'DETAIL') + .map((image, originalIndex) => ({ image, originalIndex })) + const seenFactSignatures = new Set() + const seenFactKinds = new Set() + + for ( + let selectedCount = 0; + selectedCount < MAX_PRODUCT_LEVEL_FACT_IMAGES && remainingDetails.length > 0; + selectedCount += 1 + ) { + let bestIndex = 0 + let bestScore = Number.NEGATIVE_INFINITY + for (const [index, candidate] of remainingDetails.entries()) { + const candidateSignatures = new Set() + let score = 0 + let newKindScore = 0 + for (const fact of candidate.image.facts) { + const signature = getSynthesisFactSignature(fact) + if (candidateSignatures.has(signature)) continue + candidateSignatures.add(signature) + const weight = PRODUCT_LEVEL_FACT_KIND_WEIGHTS[fact.kind] ?? 1 + if (!seenFactKinds.has(fact.kind)) newKindScore += weight + score += seenFactSignatures.has(signature) ? weight : weight * 3 + } + // A detail image with no structured fact can still contain a small, + // useful text fragment. Keep it behind all structured evidence. + if (candidateSignatures.size === 0 && candidate.image.observedTexts.length > 0) { + score = 0.5 + } + // First retain at least one image for every available fact category. + // This prevents a large number of dimensions or parts from crowding out + // a lower-volume but cost-relevant category such as packaging. + if (newKindScore > 0) score += 10_000 + newKindScore + if ( + score > bestScore || + (score === bestScore && candidate.originalIndex < remainingDetails[bestIndex]!.originalIndex) + ) { + bestIndex = index + bestScore = score + } + } + const [selected] = remainingDetails.splice(bestIndex, 1) + if (!selected) break + selectedIds.add(selected.image.imageId) + selected.image.facts.forEach((fact) => { + seenFactSignatures.add(getSynthesisFactSignature(fact)) + seenFactKinds.add(fact.kind) + }) + } + + return selectedIds +} + export function buildSynthesisFactInput( factSnapshot: BomCostingSourceFactSnapshot, skus: MarketSkuSnapshot[], ) { const batchSkuIds = new Set(skus.map((sku) => sku.externalSkuId)) + const productLevelImageIds = selectProductLevelSynthesisImageIds(factSnapshot) return { schemaVersion: factSnapshot.schemaVersion, fieldReference: { @@ -348,8 +438,10 @@ export function buildSynthesisFactInput( }, images: factSnapshot.imageFacts .filter((image) => { - if (image.kind !== 'SKU') return true - return image.externalSkuIds.some((skuId) => batchSkuIds.has(skuId)) + if (image.kind === 'SKU') { + return image.externalSkuIds.some((skuId) => batchSkuIds.has(skuId)) + } + return productLevelImageIds.has(image.imageId) }) .map((image) => { const batchImageSkuIds = image.externalSkuIds.filter((skuId) =>