From 2e3168bb99afd694501d828b610bd5e0700c2847 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 10 Aug 2026 15:42:16 +0800 Subject: [PATCH] fix(bom-costing): prefer product-wide explicit thickness facts --- .../bom-costing-image-fact-extractor.test.ts | 160 ++++++++++++++---- .../bom-costing-image-fact-extractor.ts | 78 +++++++-- .../bom-costing-multimodal-analyzer.ts | 4 +- 3 files changed, 197 insertions(+), 45 deletions(-) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.test.ts index 2f283ac1..962cf3ad 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.test.ts @@ -385,38 +385,94 @@ describe('BOM 逐图事实提取', () => { ) }) - it('优先读取卡尺板厚,并把图片比例反推限制为对应部件的低可信预估', async () => { - const detailUrl = 'https://img.example.com/caliper-detail.jpg' - const detailSnapshot: MarketDetailSnapshot = { + it('全商品任一图片有明确板厚时,舍弃其他图片产生的目测候选', async () => { + const markedMainUrl = 'https://img.example.com/marked-thickness-main.jpg' + const visualDetailUrl = 'https://img.example.com/visual-thickness-detail.jpg' + const markedSnapshot: MarketDetailSnapshot = { ...snapshot, - mainImages: [], + mainImages: [markedMainUrl], skuImages: [], - detailImages: [detailUrl], + detailImages: [visualDetailUrl], skus: snapshot.skus.map((sku) => ({ ...sku, imageUrl: null })), } let request: ReturnType | null = null jest.spyOn(global, 'fetch').mockImplementation(async (_input, init) => { request = readBatchRequest(init) + return createStreamingResponse(JSON.stringify({ + images: request.imageIds.map((imageId, index) => index === 0 + ? { + imageId, + observedTexts: ['柜体板厚 18mm'], + facts: [{ + kind: 'DIMENSION', + subject: '柜体板材明确板厚标注', + value: '板厚 18 mm', + relatedSkuIds: [], + basis: 'SOURCE_TEXT', + note: '主图右下角参数标注', + confidence: 0.97, + }], + } + : { + imageId, + observedTexts: [], + facts: [{ + kind: 'DIMENSION', + subject: '未标注侧板板厚', + value: '约 17 mm', + relatedSkuIds: [], + basis: '比例估算', + note: '以同图已标注柜体宽度为参照,仅估算侧板', + confidence: 0.88, + }], + }), + })) + }) + + const output = await extractBomCostingImageFacts({ + config: providerConfig, + snapshot: markedSnapshot, + }) + + expect(request?.systemPrompt).toContain('电子卡尺') + expect(request?.systemPrompt).toContain('不得扩展成所有门板、背板、侧板或层板') + expect(request?.prompt.boardThicknessEvidenceOrder).toHaveLength(4) + expect(request?.imageDetails).toEqual([ + { url: markedMainUrl, detail: 'auto' }, + { url: visualDetailUrl, detail: 'high' }, + ]) + expect(output.snapshot.imageFacts.flatMap((image) => image.facts)).toEqual([ + expect.objectContaining({ + subject: '柜体板材明确板厚标注', + basis: 'SOURCE_TEXT', + confidence: 0.97, + }), + ]) + }) + + it('全部图片都没有明确板厚时,才保留图片比例目测候选', async () => { + const detailUrl = 'https://img.example.com/visual-only-thickness.jpg' + const visualOnlySnapshot: MarketDetailSnapshot = { + ...snapshot, + mainImages: [], + skuImages: [], + detailImages: [detailUrl], + skus: snapshot.skus.map((sku) => ({ ...sku, imageUrl: null })), + } + jest.spyOn(global, 'fetch').mockImplementation(async (_input, init) => { + const request = readBatchRequest(init) return createStreamingResponse(JSON.stringify({ images: [{ imageId: request.imageIds[0], - observedTexts: ['纯实木隔板', '电子卡尺读数'], + observedTexts: [], facts: [{ kind: 'DIMENSION', - subject: '实木隔板板厚', - value: '电子卡尺显示 19.5 mm', - relatedSkuIds: [], - basis: 'SOURCE_TEXT', - note: '右上区域电子卡尺显示屏', - confidence: 0.96, - }, { - kind: 'DIMENSION', - subject: '未标注侧板板厚', + subject: '侧板板厚', value: '约 18 mm', relatedSkuIds: [], - basis: '比例估算', - note: '以同图已标注柜体宽度为参照,仅估算侧板,不适用于背板', - confidence: 0.88, + basis: 'VISUAL_ESTIMATE', + note: '以同图已标注柜体宽度为参照,存在透视误差', + confidence: 0.82, }], }], })) @@ -424,27 +480,73 @@ describe('BOM 逐图事实提取', () => { const output = await extractBomCostingImageFacts({ config: providerConfig, - snapshot: detailSnapshot, + snapshot: visualOnlySnapshot, }) - expect(request?.systemPrompt).toContain('电子卡尺') - expect(request?.systemPrompt).toContain('不得扩展成所有门板、背板、侧板或层板') - expect(request?.prompt.boardThicknessEvidenceOrder).toHaveLength(3) - expect(request?.imageDetails).toEqual([{ url: detailUrl, detail: 'high' }]) expect(output.snapshot.imageFacts[0]?.facts).toEqual([ expect.objectContaining({ - subject: '实木隔板板厚', - basis: 'SOURCE_TEXT', - confidence: 0.96, - }), - expect.objectContaining({ - subject: '未标注侧板板厚', + subject: '侧板板厚', basis: 'VISUAL_ESTIMATE', confidence: 0.45, }), ]) }) + it('仍有图片分析失败时不启用目测板厚,保留任务继续使用历史规格兜底', async () => { + const analyzedUrl = 'https://img.example.com/visual-candidate.jpg' + const failedUrl = 'https://img.example.com/unread-thickness-page.jpg' + const incompleteSnapshot: MarketDetailSnapshot = { + ...snapshot, + mainImages: [], + skuImages: [], + detailImages: [analyzedUrl, failedUrl], + skus: snapshot.skus.map((sku) => ({ ...sku, imageUrl: null })), + } + + const output = await extractBomCostingImageFacts({ + config: providerConfig, + snapshot: incompleteSnapshot, + reuseExistingFactsOnly: true, + existingSnapshot: { + schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION, + extractedAt: '2026-08-10T00:00:00.000Z', + imageFacts: [{ + imageId: 'OLD_1', + sourceImageUrl: analyzedUrl, + kind: 'DETAIL', + label: '旧详情图 1', + externalSkuIds: [], + observedTexts: [], + facts: [{ + factId: 'visual-thickness', + kind: 'DIMENSION', + subject: '侧板板厚', + value: '约 18 mm', + relatedSkuIds: [], + basis: 'VISUAL_ESTIMATE', + note: '按图片比例估算', + confidence: 0.45, + }], + }], + failedImages: [{ + imageId: 'OLD_2', + sourceImageUrl: failedUrl, + kind: 'DETAIL', + label: '旧详情图 2', + externalSkuIds: [], + reason: 'REQUEST_FAILED', + message: '图片分析服务暂时不可用', + attempts: 2, + }], + }, + }) + + expect(output.snapshot.imageFacts[0]?.facts).toEqual([]) + expect(output.snapshot.failedImages).toEqual([ + expect.objectContaining({ sourceImageUrl: failedUrl }), + ]) + }) + it('图片结果缺失时会用更严格的契约自动重试一次,而不是立即终止核算', async () => { const singleImageSnapshot: MarketDetailSnapshot = { ...snapshot, diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.ts index cc02dc64..4eabb3e8 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-image-fact-extractor.ts @@ -38,11 +38,11 @@ const FACT_EXTRACTION_SYSTEM_PROMPT = `你是家具竞品资料事实提取员 硬规则: 1. 只输出一个 JSON 对象,不要 Markdown、解释、代码围栏或额外文字。 2. 输入中的每个 imageId 必须返回且只返回一次;即使图片没有成本信息,也要返回空 facts。 -3. 本阶段不生成 BOM、不推断隐藏部件、不估算价格。唯一允许的受控估算是:没有任何明确板厚读数时,依据同一部件、同一张图片或明确连续长图中的已知尺寸参照,低可信估算该部件的板厚。 +3. 本阶段不生成 BOM、不推断隐藏部件、不估算价格。你可以记录“板厚视觉估算候选”,但系统只会在主图、SKU 图和全部详情图均成功分析,且没有任何明确板厚标注或测量读数时启用它;只要整条商品资料里存在明确板厚,或仍有图片分析失败,所有视觉估算候选都会被舍弃。 4. 图片直接可见的形状、部件、结构或机构使用 basis=IMAGE;尺寸标注、卡尺/标尺读数、材质说明、功能说明、参数表等图片文字使用 basis=SOURCE_TEXT;按图片比例得到的近似板厚使用 basis=VISUAL_ESTIMATE。 5. 板厚是必须专项检查的证据:逐张放大检查电子卡尺、游标卡尺、直尺、卷尺、厚度规、剖面、板边和尺寸线。显示屏或刻度即使旋转、较小或位于局部红框,也要先按正确方向读取,不得因不显眼而直接判定“无板厚”。 6. 卡尺或标尺有可读数值时,输出 kind=DIMENSION、basis=SOURCE_TEXT;subject 必须写清被测部件(例如“实木隔板板厚”),value 保留仪器读数和单位,note 写明卡尺/标尺位置。该读数只属于被测部件,不得扩展成所有门板、背板、侧板或层板的共同厚度。 -7. 某个部件没有明确板厚读数时,只有存在可核验的尺寸参照和清楚的同部件板边时才允许反推:输出 kind=DIMENSION、basis=VISUAL_ESTIMATE,value 必须使用“约 X mm”,note 必须写出所用参照尺寸、对应部件和误差原因,confidence 不得高于 0.45。不得用手指、人物或无固定尺寸的物体作唯一比例尺。 +7. 某张图没有明确板厚读数时,只有存在可核验的尺寸参照和清楚的同部件板边时才允许输出“视觉估算候选”:输出 kind=DIMENSION、basis=VISUAL_ESTIMATE,value 必须使用“约 X mm”,note 必须写出所用参照尺寸、对应部件和误差原因,confidence 不得高于 0.45。不得用手指、人物或无固定尺寸的物体作唯一比例尺;后续是否采用由系统在汇总全部图片后统一决定。 8. 没有明确读数,也没有可核验的比例参照时,不得编造具体板厚;可以记录板件存在、材质和“板厚不可判断”,但不要返回虚构数值。 9. 必须检查局部小图、红框区域、剖面图、折叠或运动示意、产品参数图和包装说明,不得把它们当成装饰。 10. 尺寸、重量及单位全部保留在 value 原文中。多维尺寸能确认长宽高含义时拆成多个事实,无法确认轴向时保留完整原文,不得强行压成单一数值。 @@ -371,8 +371,9 @@ function buildFactBatchUserText( optionalFields: '可选字段没有证据时省略或返回 null,不得填写 optional 等说明文字', }, boardThicknessEvidenceOrder: [ - '优先:读取卡尺、标尺、尺寸线或参数文字中的明确板厚,并只绑定被测部件', - '其次:仅在同一部件存在已知尺寸参照时,按图片比例输出约值,basis=VISUAL_ESTIMATE,confidence<=0.45', + '第一步:汇总主图、SKU 图和全部详情图中的板厚标注、参数文字、卡尺及标尺读数', + '第二步:只要整条商品资料存在任意明确板厚,优先采用明确数据并舍弃全部 VISUAL_ESTIMATE 候选', + '第三步:仅当全部图片均成功分析且都没有明确板厚时,才启用基于已知尺寸参照的 VISUAL_ESTIMATE,confidence<=0.45', '最后:没有可核验参照时不输出具体厚度,由后续公司历史资料层进行低可信兜底', ], productTruth: { @@ -634,6 +635,38 @@ function describeFactImageFailure( } } +function isExplicitBoardThicknessFact( + fact: BomCostingSourceFactSnapshot['imageFacts'][number]['facts'][number], +) { + if (fact.kind !== 'DIMENSION' || fact.basis === 'VISUAL_ESTIMATE') { + return false + } + const context = [ + fact.subject, + fact.value, + fact.note ?? '', + ].join(' ').normalize('NFKC') + const explicitThicknessLanguage = ( + /板厚|板材厚度|厚度|卡尺|厚度规|板边厚薄|加厚/i.test(context) || + /\d+(?:\.\d+)?\s*(?:mm|毫米|cm|厘米|厘)\s*(?:厚|实木板|板材|板)/i.test(context) || + /(?:隔板|层板|门板|背板|侧板|顶板|底板|面板)\s*(?:为|约|[::])?\s*\d+(?:\.\d+)?\s*(?:mm|毫米|cm|厘米|厘)/i.test( + context, + ) + ) + if (!explicitThicknessLanguage) return false + + return Array.from( + context.matchAll(/(\d+(?:\.\d+)?)\s*(mm|毫米|cm|厘米|厘)/gi), + ).some((match) => { + const numeric = Number(match[1]) + if (!Number.isFinite(numeric) || numeric <= 0) return false + const millimeters = /cm|厘米/i.test(match[2] ?? '') + ? numeric * 10 + : numeric + return millimeters <= 80 + }) +} + function createFactSnapshot( images: BomCostingMultimodalInputImage[], completedByIdentity: Map< @@ -643,18 +676,35 @@ function createFactSnapshot( failedByIdentity: Map, extractedAt = new Date().toISOString(), ) { + const imageFacts = images + .map((image) => + completedByIdentity.get(buildBomCostingImageIdentity(image.url)), + ) + .filter((image): image is NonNullable => Boolean(image)) + const hasExplicitBoardThickness = imageFacts.some((image) => + image.facts.some(isExplicitBoardThicknessFact), + ) + const failedImages = images + .map((image) => + failedByIdentity.get(buildBomCostingImageIdentity(image.url)), + ) + .filter((image): image is NonNullable => Boolean(image)) + const canUseVisualThicknessEstimate = ( + !hasExplicitBoardThickness && + failedImages.length === 0 && + imageFacts.length === images.length + ) return bomCostingSourceFactSnapshotSchema.parse({ schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION, - imageFacts: images - .map((image) => - completedByIdentity.get(buildBomCostingImageIdentity(image.url)), - ) - .filter(Boolean), - failedImages: images - .map((image) => - failedByIdentity.get(buildBomCostingImageIdentity(image.url)), - ) - .filter(Boolean), + imageFacts: canUseVisualThicknessEstimate + ? imageFacts + : imageFacts.map((image) => ({ + ...image, + facts: image.facts.filter( + (fact) => fact.basis !== 'VISUAL_ESTIMATE', + ), + })), + failedImages, extractedAt, }) } 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 26656536..abbd2cd6 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 @@ -72,7 +72,7 @@ const SYSTEM_PROMPT = `你是家具产品结构与成本工程师。你的任务 9. BOM 分类只能是 BOARD、STEEL、ACCESSORY、PACKAGING、PROCESSING、LABOR。 10. evidence 必须说明依据;引用图片时只能照抄逐图事实中的 url(这是系统图片引用,不得编造、改写或填写其他图片地址)。 11. confidence 必须在 0 到 1 之间。 -12. BOARD 的 lengthMm/widthMm 表示单块板件长宽,heightMm 只能表示板厚。板厚证据必须按部件绑定并按以下顺序使用:① 卡尺、标尺、尺寸线或参数文字的明确读数最优,填写 heightMm 和 materialSpec,evidence 保留 SOURCE_TEXT/IMAGE;② 输入事实 basis=VISUAL_ESTIMATE 时,可以填写约值,但 materialSpec 必须明确写“约 Xmm(图片比例估算)”,evidence.basis 必须改为 STRUCTURAL_INFERENCE,confidence 不得高于 0.45,note 必须保留参照尺寸、对应部件和误差原因;③ 两者都没有时省略 heightMm,并写明“板厚待历史规格低可信预估”。一块隔板的卡尺读数不得复制给门板、背板、侧板或其他未测部件,也不能把整机高度或部件外形高度填成板厚。若图片没有直接标出单块长宽,但 SKU 元数据或图片事实已经确认整机长宽高与柜体、桌体、层板等装配结构,应把主要板件拆成少量可计价行,依据装配关系保守推导单块长宽和数量;这类行的 evidence.basis 必须是 STRUCTURAL_INFERENCE,confidence 不得高于 0.45,note 必须写明“由整机尺寸与结构推导,待复核”。系统会在后续按基础资料库整板尺寸做裁切排版,模型不要自行填写板价。 +12. BOARD 的 lengthMm/widthMm 表示单块板件长宽,heightMm 只能表示板厚。系统已先汇总主图、SKU 图和全部详情图:只要整条商品资料存在明确板厚,VISUAL_ESTIMATE 候选就不会进入当前 facts;因此必须优先使用 facts 中卡尺、标尺、尺寸线或参数文字的明确读数,填写 heightMm 和 materialSpec,evidence 保留 SOURCE_TEXT/IMAGE。只有全商品没有任何明确板厚时,facts 才可能保留 basis=VISUAL_ESTIMATE 的约值;此时 materialSpec 必须明确写“约 Xmm(图片比例估算)”,evidence.basis 必须改为 STRUCTURAL_INFERENCE,confidence 不得高于 0.45,note 必须保留参照尺寸、对应部件和误差原因。两者都没有时省略 heightMm,并写明“板厚待历史规格低可信预估”。明确标注的适用范围仍以原文为准:只标隔板时不得复制给门板、背板、侧板或其他未标部件;标明“柜体通用板厚”时才可用于对应柜体板件。不能把整机高度或部件外形高度填成板厚。若图片没有直接标出单块长宽,但 SKU 元数据或图片事实已经确认整机长宽高与柜体、桌体、层板等装配结构,应把主要板件拆成少量可计价行,依据装配关系保守推导单块长宽和数量;这类行的 evidence.basis 必须是 STRUCTURAL_INFERENCE,confidence 不得高于 0.45,note 必须写明“由整机尺寸与结构推导,待复核”。系统会在后续按基础资料库整板尺寸做裁切排版,模型不要自行填写板价。 13. PACKAGING 必须按纸箱、泡沫、珍珠棉、护角、打包带等实际包材逐行拆分;packagingKind 必须与部件一致,尺寸表示单件包材规格,quantity 表示单套产品实际用量。没有直接包装证据时,根据整机尺寸、重量、易损部位和运输保护需要给出保守的最小包装方案,basis 使用 STRUCTURAL_INFERENCE,confidence 不得高于 0.55,并明确待确认项。 14. materialName 使用可用于资料库匹配的材种或材料名称,例如“橡胶木”“刨花板”“碳钢”“瓦楞纸箱”,不要把颜色、营销等级或整段商品描述当作材种。SKU 元数据明确为 FAS 级橡胶木、实木、无镜等时,不能默认替换为另一种材质或保留不应存在的镜子部件。 15. 每条 evidence 必须设置 basis:图片直接可见为 IMAGE;图片标注、详情文字或商品文案为 SOURCE_TEXT;SKU 名称、规格或属性为 SKU_METADATA;由装配或机构功能必需性推断的隐藏部件为 STRUCTURAL_INFERENCE。 @@ -95,7 +95,7 @@ const COMPACT_COSTING_SYSTEM_PROMPT = `你是家具产品成本工程师。当 1. 必须至少有一条材料项(BOARD、STEEL 或 ACCESSORY)、一条 PROCESSING、一条 LABOR 和一条 PACKAGING。 2. 每条必须包含 externalLineId、category、partName、quantity、unit、evidence、confidence。不得输出 estimatedUnitPrice 或 suggestedLossFactor;正式价格和损耗由后续公司数据规则层计算。 3. 公司基础资料库和历史 BOM 本地镜像是唯一正式价格来源。materialName 只写可匹配的材料,例如“橡胶木”“不锈钢”“银镜”“万向轮”“瓦楞纸箱”,不要把营销文案当材料。 -4. 板材有明确“单块板件”长宽时使用“㎡”并填写 lengthMm、widthMm。板厚按部件独立处理:卡尺、标尺、尺寸线或参数文字的明确读数优先,填写 heightMm 和 materialSpec,并保留 SOURCE_TEXT/IMAGE 证据;facts 中 basis=VISUAL_ESTIMATE 的约值是次级证据,可以填写 heightMm,但 materialSpec 必须写“约 Xmm(图片比例估算)”,evidence.basis 必须使用 STRUCTURAL_INFERENCE,confidence 不得高于 0.45,note 必须保留参照尺寸、部件范围与误差原因;没有这两类证据时省略 heightMm。某一隔板的读数不得套用到门板、背板、侧板或其他板件,不得把整机高度填成板厚。若图片没有直接标出单块长宽,但 SKU 元数据或图片事实已经确认整机长宽高与柜体、桌体、层板等装配结构,应把主要板件拆成少量可计价行,依据装配关系保守推导单块长宽和数量;这类行的 evidence.basis 必须是 STRUCTURAL_INFERENCE,confidence 不得高于 0.45,note 必须写明“由整机尺寸与结构推导,待复核”。只有连整机尺寸或结构都不足时,才使用“件”或“套”并保持成本待补。系统会在后续按整板尺寸与裁切利用率计算成本,模型不要自行填写板价。 +4. 板材有明确“单块板件”长宽时使用“㎡”并填写 lengthMm、widthMm。系统已先汇总主图、SKU 图和全部详情图:只要整条商品资料存在明确板厚,VISUAL_ESTIMATE 候选就不会进入 facts;必须优先使用卡尺、标尺、尺寸线或参数文字的明确读数,填写 heightMm 和 materialSpec,并保留 SOURCE_TEXT/IMAGE 证据。只有全商品没有任何明确板厚时,facts 才可能保留 basis=VISUAL_ESTIMATE 的约值;此时可以填写 heightMm,但 materialSpec 必须写“约 Xmm(图片比例估算)”,evidence.basis 必须使用 STRUCTURAL_INFERENCE,confidence 不得高于 0.45,note 必须保留参照尺寸、部件范围与误差原因。没有这两类证据时省略 heightMm。明确标注的适用范围以原文为准:只标隔板时不得套用到门板、背板、侧板或其他板件;标明柜体通用板厚时才可用于对应柜体板件。不得把整机高度填成板厚。若图片没有直接标出单块长宽,但 SKU 元数据或图片事实已经确认整机长宽高与柜体、桌体、层板等装配结构,应把主要板件拆成少量可计价行,依据装配关系保守推导单块长宽和数量;这类行的 evidence.basis 必须是 STRUCTURAL_INFERENCE,confidence 不得高于 0.45,note 必须写明“由整机尺寸与结构推导,待复核”。只有连整机尺寸或结构都不足时,才使用“件”或“套”并保持成本待补。系统会在后续按整板尺寸与裁切利用率计算成本,模型不要自行填写板价。 5. 只写与该 SKU 已确认结构直接相关的部件。隐藏五金、人工、包材可以作最小必要推断,但 evidence.basis 必须是 STRUCTURAL_INFERENCE,且 evidence.confidence 和该行 confidence 均不得高于 0.55。 6. evidence.imageUrl 只能使用输入 facts 中的 imageRef,不能编造地址。所有尺寸用毫米;没有证据的可选尺寸字段直接省略。 7. SKU 之间不允许组合、拆分、补造或遗漏。若材料、尺寸、是否含镜、部件或包装有差异,必须在本 SKU 的 BOM 中体现。