fix(bom-costing): prefer product-wide explicit thickness facts

This commit is contained in:
WuYongJie 2026-08-10 15:42:16 +08:00
parent d630a73433
commit 2e3168bb99
3 changed files with 197 additions and 45 deletions

View File

@ -385,66 +385,168 @@ 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<typeof readBatchRequest> | null = null
jest.spyOn(global, 'fetch').mockImplementation(async (_input, init) => {
request = readBatchRequest(init)
return createStreamingResponse(JSON.stringify({
images: [{
imageId: request.imageIds[0],
observedTexts: ['纯实木隔板', '电子卡尺读数'],
images: request.imageIds.map((imageId, index) => index === 0
? {
imageId,
observedTexts: ['柜体板厚 18mm'],
facts: [{
kind: 'DIMENSION',
subject: '实木隔板板厚',
value: '电子卡尺显示 19.5 mm',
subject: '柜体板材明确板厚标注',
value: '板厚 18 mm',
relatedSkuIds: [],
basis: 'SOURCE_TEXT',
note: '右上区域电子卡尺显示屏',
confidence: 0.96,
}, {
note: '主图右下角参数标注',
confidence: 0.97,
}],
}
: {
imageId,
observedTexts: [],
facts: [{
kind: 'DIMENSION',
subject: '未标注侧板板厚',
value: '约 18 mm',
value: '约 17 mm',
relatedSkuIds: [],
basis: '比例估算',
note: '以同图已标注柜体宽度为参照,仅估算侧板,不适用于背板',
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: [],
facts: [{
kind: 'DIMENSION',
subject: '侧板板厚',
value: '约 18 mm',
relatedSkuIds: [],
basis: 'VISUAL_ESTIMATE',
note: '以同图已标注柜体宽度为参照,存在透视误差',
confidence: 0.82,
}],
}],
}))
})
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,

View File

@ -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<string, BomCostingFailedImageFact>,
extractedAt = new Date().toISOString(),
) {
return bomCostingSourceFactSnapshotSchema.parse({
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
imageFacts: images
const imageFacts = images
.map((image) =>
completedByIdentity.get(buildBomCostingImageIdentity(image.url)),
)
.filter(Boolean),
failedImages: images
.filter((image): image is NonNullable<typeof image> => Boolean(image))
const hasExplicitBoardThickness = imageFacts.some((image) =>
image.facts.some(isExplicitBoardThicknessFact),
)
const failedImages = images
.map((image) =>
failedByIdentity.get(buildBomCostingImageIdentity(image.url)),
)
.filter(Boolean),
.filter((image): image is NonNullable<typeof image> => Boolean(image))
const canUseVisualThicknessEstimate = (
!hasExplicitBoardThickness &&
failedImages.length === 0 &&
imageFacts.length === images.length
)
return bomCostingSourceFactSnapshotSchema.parse({
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
imageFacts: canUseVisualThicknessEstimate
? imageFacts
: imageFacts.map((image) => ({
...image,
facts: image.facts.filter(
(fact) => fact.basis !== 'VISUAL_ESTIMATE',
),
})),
failedImages,
extractedAt,
})
}

View File

@ -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 中体现。