From 4f1571d5084d02401aa186a3c932e134c256e102 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 11 Aug 2026 03:27:15 +0800 Subject: [PATCH] fix(bom): compact sku synthesis into tuples --- .../bom-costing-multimodal-gateway.test.ts | 4 +- .../bom-costing-multimodal-gateway.ts | 12 +- .../bom-costing-structure-blueprint.test.ts | 96 +++++---- .../bom-costing-structure-blueprint.ts | 191 ++++++++++++------ 4 files changed, 183 insertions(+), 120 deletions(-) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.test.ts index d7f58cd5..a60ddf06 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.test.ts @@ -11,7 +11,7 @@ describe('BOM 多模态网关', () => { jest.restoreAllMocks() }) - it('结构汇总默认请求一次性 JSON,并读取标准非流式响应', async () => { + it('供应商忽略流式偏好返回标准 JSON 时仍能读取结果', async () => { const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue( new Response(JSON.stringify({ id: 'call-json-response', @@ -43,7 +43,7 @@ describe('BOM 多模态网关', () => { const requestBody = JSON.parse(String(fetchSpy.mock.calls[0]?.[1]?.body)) as { stream?: boolean } - expect(requestBody.stream).toBe(false) + expect(requestBody.stream).toBe(true) expect(result).toMatchObject({ id: 'call-json-response', model: 'gpt-5.6-terra', diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.ts index 5c37ea90..58aefb35 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-multimodal-gateway.ts @@ -622,12 +622,12 @@ export async function callBomCostingMultimodalModel(input: { for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { let response: Response - // The UI streams durable stage events, not raw model tokens. Synthesis only - // needs one strict JSON object, so asking the provider for a regular JSON - // response avoids OpenAI-compatible gateways that emit a partial SSE body - // and then keep the socket open for minutes. Image analysis keeps streaming - // because those responses can be substantially larger. - const requestStreamingResponse = input.operation === 'IMAGE_FACTS' + // Production verification shows this provider starts and completes small + // streamed JSON responses reliably, while its buffered response mode may + // not return headers before the deadline. Keep SSE as the requested mode; + // the content-type branch below still accepts a regular JSON response from + // providers that ignore this preference. + const requestStreamingResponse = true const timeoutSignal = AbortSignal.timeout(input.timeoutMs) const requestSignal = input.signal ? AbortSignal.any([timeoutSignal, input.signal]) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.test.ts index 6f6b5885..4c6ebf5f 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.test.ts @@ -119,7 +119,7 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => { const stages: BomCostingStructureStageProgress[] = [] const checkpoints: BomCostingStructureCheckpoint[] = [] const instantiationRequests: Array<{ - skuTruth: Array<{ externalSkuId: string }> + skuTruth: Array<{ id: string }> structureGroup: { skuIds: string[] } facts: Array<{ factId: string; relatedSkuIds: string[] }> }> = [] @@ -129,7 +129,7 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => { } const userPayload = JSON.parse(body.messages[1]?.content ?? '{}') as { outputContractVersion?: string - skuTruth?: Array<{ externalSkuId: string; name: string }> + skuTruth?: Array<{ id: string; name: string }> structureGroup?: { skuIds: string[] } facts?: Array<{ factId: string; relatedSkuIds: string[] }> } @@ -158,34 +158,23 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => { return streamingResponse(JSON.stringify({ skus: skuTruth.map((sku) => { const widthMm = Number(sku.name.match(/(\d+)cm/)?.[1] ?? 140) * 10 - const inferredEvidence = [{ - basis: 'STRUCTURAL_INFERENCE', - note: '依据柜体高和深度推导', - factIds: [], - derivation: '侧板高度=整机高度;侧板宽度=柜体深度', - reviewReason: '未发现侧板单独尺寸标注', - confidence: 0.5, - }] return { - externalSkuId: sku.externalSkuId, - dimensions: { lengthMm: widthMm, widthMm: 620, heightMm: 2200 }, - bomLines: componentSlots.map((slot) => ({ - externalLineId: slot.componentKey, - materialName: slot.category === 'BOARD' + sku: sku.id, + d: [widthMm, 620, 2200], + lines: componentSlots.map((slot) => ([ + slot.componentKey, + slot.componentKey === 'enclosure-side' ? 2 : 1, + slot.category === 'BOARD' ? '块' : '套', + slot.category === 'BOARD' ? 2200 : null, + slot.category === 'BOARD' ? 620 : null, + slot.category === 'BOARD' ? 19.95 : null, + slot.category === 'BOARD' ? '橡木' : slot.category === 'PACKAGING' ? '瓦楞纸箱' - : undefined, - lengthMm: slot.category === 'BOARD' ? 2200 : undefined, - widthMm: slot.category === 'BOARD' ? 620 : undefined, - heightMm: slot.category === 'BOARD' ? 19.95 : undefined, - quantity: slot.componentKey === 'enclosure-side' ? 2 : 1, - unit: slot.category === 'BOARD' ? '块' : '套', - evidenceFactIds: ['fact-common'], - derivation: inferredEvidence[0]?.derivation, - reviewReason: inferredEvidence[0]?.reviewReason, - confidence: 0.5, - })), + : null, + ['fact-common'], + ])), } }), })) @@ -217,6 +206,23 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => { confidence: 0.9, }], }, + ...Array.from({ length: 30 }, (_, index) => ({ + imageId: `noise-image-${index + 1}`, + sourceImageUrl: `https://img.example.com/noise-${index + 1}.jpg`, + kind: 'DETAIL' as const, + label: `低优先级说明 ${index + 1}`, + externalSkuIds: [], + observedTexts: [], + facts: [{ + factId: `fact-noise-${index + 1}`, + kind: 'TEXT' as const, + subject: '营销说明', + value: `说明 ${index + 1}`, + relatedSkuIds: [], + basis: 'SOURCE_TEXT' as const, + confidence: 0.4, + }], + })), ...snapshot.skus.map((sku) => ({ imageId: `image-${sku.externalSkuId}`, sourceImageUrl: `https://img.example.com/${sku.externalSkuId}.jpg`, @@ -259,7 +265,8 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => { expect(instantiationRequests).toHaveLength(7) for (const request of instantiationRequests) { expect(request.skuTruth).toHaveLength(1) - const skuId = request.skuTruth[0]?.externalSkuId + expect(request.facts).toHaveLength(24) + const skuId = request.skuTruth[0]?.id expect(request.structureGroup.skuIds).toEqual([skuId]) expect(request.facts.map((fact) => fact.factId)).toEqual(expect.arrayContaining([ 'fact-common', @@ -370,7 +377,7 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => { : content.find((item) => item.type === 'text')?.text ?? '{}' const userPayload = JSON.parse(userText) as { outputContractVersion?: string - skuTruth?: Array<{ externalSkuId: string }> + skuTruth?: Array<{ id: string }> } if (userPayload.outputContractVersion === 'structure-blueprint/review-v1') { return streamingResponse(JSON.stringify({ @@ -415,27 +422,18 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => { } return streamingResponse(JSON.stringify({ skus: (userPayload.skuTruth ?? []).map((sku) => ({ - externalSkuId: sku.externalSkuId, - dimensions: { lengthMm: 1_400, widthMm: 620, heightMm: 2_200 }, - bomLines: componentSlots.map((slot) => ({ - externalLineId: slot.componentKey, - category: slot.category, - partName: slot.partName, - materialName: slot.category === 'BOARD' ? '橡木' : '', - lengthMm: slot.category === 'BOARD' ? 2_200 : undefined, - widthMm: slot.category === 'BOARD' ? 620 : undefined, - heightMm: slot.category === 'BOARD' ? 18 : undefined, - quantity: slot.componentKey === 'enclosure-side' ? 2 : 1, - unit: slot.category === 'BOARD' ? '块' : '套', - evidence: [{ - basis: 'STRUCTURAL_INFERENCE', - factIds: ['fact-1'], - derivation: slot.dimensionRule, - reviewReason: '板厚未解决', - confidence: 0.3, - }], - confidence: 0.3, - })), + sku: sku.id, + d: [1_400, 620, 2_200], + lines: componentSlots.map((slot) => ([ + slot.componentKey, + slot.componentKey === 'enclosure-side' ? 2 : 1, + slot.category === 'BOARD' ? '块' : '套', + slot.category === 'BOARD' ? 2_200 : null, + slot.category === 'BOARD' ? 620 : null, + slot.category === 'BOARD' ? 18 : null, + slot.category === 'BOARD' ? '橡木' : null, + ['fact-1'], + ])), })), })) }) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.ts index 00461508..a590e58c 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-structure-blueprint.ts @@ -36,6 +36,7 @@ const MAX_BLUEPRINT_COMPONENT_SLOTS = 48 const MAX_BLUEPRINT_RELATIONS = 80 const MAX_BLUEPRINT_QUESTIONS = 16 const MAX_SKUS_PER_INSTANTIATION_CALL = 1 +const MAX_INSTANTIATION_FACTS = 24 const BLUEPRINT_SYSTEM_PROMPT = `你是家具产品结构工程师。系统已经根据商品事实和版本化结构知识建立 baselineBlueprint。你只审查并返回必要修正,不输出价格,不输出每个 SKU 的最终 BOM,也不要原样抄回 baselineBlueprint。 @@ -59,11 +60,11 @@ const INSTANTIATION_SYSTEM_PROMPT = `你是家具 BOM 结构工程师。根据 硬规则: 1. 只输出一个 JSON 对象,不要 Markdown 或解释。 -2. externalSkuId 必须完整覆盖本批 skuTruth,每个 SKU 一次;不得组合或补造 SKU。 -3. 每个 required componentSlot 都必须生成一条 bomLine,externalLineId 必须等于 componentKey。同组兄弟 SKU 使用相同核心 componentKey。category、partName 和完整 evidence 由系统从蓝图补齐,不要回写这些重复字段。 +2. skus[].sku 必须完整覆盖本批 skuTruth 中的 id,每个 SKU 一次;不得组合或补造 SKU。 +3. 每个 required componentSlot 都必须生成一条 lines 元组,元组第 1 项必须等于 componentKey。同组兄弟 SKU 使用相同核心 componentKey。部件名称、分类、推导说明和可信度由系统补齐,不要重复输出。 4. 每条 SKU 的尺寸、数量、材质和套餐组成独立计算。颜色不改变结构时不能让基础部件消失。 -5. BOARD 的 lengthMm/widthMm 是单块板件尺寸,heightMm 只能是板厚。明确板厚只应用于证据指定部件;目测厚度 confidence 不高于 0.45。 -6. 每行只返回 externalLineId、quantity、unit、材料/工艺、尺寸、evidenceFactIds、derivation、reviewReason 和 confidence。由整机尺寸推导板件时,derivation 写清公式或装配关系,reviewReason 写明未知项。 +5. BOARD 的长/宽是单块板件尺寸,厚只能是板厚。明确板厚只应用于证据指定部件;未知数值必须用 null,不得编造。 +6. d 固定为 [整机长,整机宽或深,整机高];每条 lines 固定为 [componentKey,数量,单位,长mm或null,宽mm或null,厚mm或null,材质或工艺或null,[直接证据factId]]。除该固定元组外不要输出任何行字段。 7. 公司资料库是后续唯一正式价格来源;不得输出价格、售价倒推或损耗系数。` const blueprintProductFamilySchema = z.object({ @@ -149,42 +150,66 @@ const verificationPayloadSchema = z.object({ const compactInstantiatedBomLineSchema = z.object({ externalLineId: z.string().trim().min(1).max(120), - materialCode: z.string().trim().max(120).optional(), - materialName: z.string().trim().max(200).optional(), - materialSpec: z.string().trim().max(200).optional(), - processName: z.string().trim().max(200).optional(), - packagingKind: z.string().trim().max(120).optional(), + descriptor: z.string().trim().max(200).optional(), lengthMm: z.number().finite().positive().optional(), widthMm: z.number().finite().positive().optional(), heightMm: z.number().finite().positive().optional(), quantity: z.number().finite().positive(), unit: z.string().trim().min(1).max(40), - weightKg: z.number().finite().positive().optional(), evidenceFactIds: z.array(z.string().trim().min(1).max(160)).max(30).default([]), - derivation: z.string().trim().max(800).default(''), - reviewReason: z.string().trim().max(500).default(''), - confidence: z.number().finite().min(0).max(1), }) -const instantiatedSkuSchema = z.object({ - externalSkuId: z.string().trim().min(1).max(160), - dimensions: z.object({ - lengthMm: z.number().finite().positive().optional(), - widthMm: z.number().finite().positive().optional(), - heightMm: z.number().finite().positive().optional(), - }).default({}), - weightKg: z.number().finite().positive().optional(), - packaging: z.object({ - lengthMm: z.number().finite().positive().optional(), - widthMm: z.number().finite().positive().optional(), - heightMm: z.number().finite().positive().optional(), - weightKg: z.number().finite().positive().optional(), - }).optional(), - bomLines: z.array(compactInstantiatedBomLineSchema).min(1).max(80), -}) +const nullablePositiveNumberSchema = z.number().finite().positive().nullable() + +const compactInstantiatedBomLineTupleSchema = z.tuple([ + z.string().trim().min(1).max(120), + z.number().finite().positive(), + z.string().trim().min(1).max(40), + nullablePositiveNumberSchema, + nullablePositiveNumberSchema, + nullablePositiveNumberSchema, + z.string().trim().max(200).nullable(), + z.array(z.string().trim().min(1).max(160)).max(30), +]).transform(([ + externalLineId, + quantity, + unit, + lengthMm, + widthMm, + heightMm, + descriptor, + evidenceFactIds, +]) => compactInstantiatedBomLineSchema.parse({ + externalLineId, + quantity, + unit, + lengthMm: lengthMm ?? undefined, + widthMm: widthMm ?? undefined, + heightMm: heightMm ?? undefined, + descriptor: descriptor || undefined, + evidenceFactIds, +})) + +const instantiatedSkuTupleSchema = z.object({ + sku: z.string().trim().min(1).max(160), + d: z.tuple([ + nullablePositiveNumberSchema, + nullablePositiveNumberSchema, + nullablePositiveNumberSchema, + ]), + lines: z.array(compactInstantiatedBomLineTupleSchema).min(1).max(80), +}).transform(({ sku, d, lines }) => ({ + externalSkuId: sku, + dimensions: { + lengthMm: d[0] ?? undefined, + widthMm: d[1] ?? undefined, + heightMm: d[2] ?? undefined, + }, + bomLines: lines, +})) const instantiationPayloadSchema = z.object({ - skus: z.array(instantiatedSkuSchema).min(1).max(MAX_SKUS_PER_INSTANTIATION_CALL), + skus: z.array(instantiatedSkuTupleSchema).min(1).max(MAX_SKUS_PER_INSTANTIATION_CALL), }) const BOM_COSTING_STRUCTURE_CHECKPOINT_KIND = @@ -691,9 +716,45 @@ function groupFacts( skuIds: string[], ) { const skuSet = new Set(skuIds) - return compactFacts(factSnapshot, MAX_BLUEPRINT_FACTS).filter((fact) => ( - fact.relatedSkuIds.length === 0 || fact.relatedSkuIds.some((skuId) => skuSet.has(skuId)) - )) + const kindPriority = new Map([ + ['DIMENSION', 10], + ['MATERIAL', 9], + ['PART', 8], + ['STRUCTURE', 8], + ['MECHANISM', 7], + ['HARDWARE', 6], + ['PACKAGING', 5], + ['PROCESS', 5], + ['WEIGHT', 4], + ['SKU_DIFFERENCE', 4], + ['TEXT', 1], + ]) + return compactFacts(factSnapshot, 500) + .filter((fact) => ( + fact.relatedSkuIds.length === 0 || + fact.relatedSkuIds.some((skuId) => skuSet.has(skuId)) + )) + .sort((left, right) => { + const leftSpecific = left.relatedSkuIds.some((skuId) => skuSet.has(skuId)) ? 1 : 0 + const rightSpecific = right.relatedSkuIds.some((skuId) => skuSet.has(skuId)) ? 1 : 0 + if (leftSpecific !== rightSpecific) return rightSpecific - leftSpecific + const kindDifference = (kindPriority.get(right.kind) ?? 0) - + (kindPriority.get(left.kind) ?? 0) + if (kindDifference !== 0) return kindDifference + return right.confidence - left.confidence + }) + .slice(0, MAX_INSTANTIATION_FACTS) + .map((fact) => ({ + factId: fact.factId, + relatedSkuIds: fact.relatedSkuIds, + kind: fact.kind, + subject: fact.subject, + value: fact.value, + numericValue: fact.numericValue, + unit: fact.unit, + basis: fact.basis, + confidence: fact.confidence, + })) } function hydrateInstantiationLines(input: { @@ -728,30 +789,39 @@ function hydrateInstantiationLines(input: { ? Math.max(...factRecords.map(({ fact }) => fact.confidence)) : slot.confidence const confidence = basis === 'STRUCTURAL_INFERENCE' - ? Math.min(0.55, line.confidence, slot.confidence) - : Math.min(1, line.confidence, evidenceConfidence) + ? Math.min(0.55, slot.confidence) + : Math.min(1, slot.confidence, evidenceConfidence) const imageUrl = factRecords[0]?.imageUrl - const derivation = line.derivation || [ + const derivation = [ slot.quantityRule, slot.dimensionRule, ].filter(Boolean).join(';') - const reviewReason = line.reviewReason || slot.reviewFlags.join(';') + const missingBoardDimensions = slot.category === 'BOARD' && ( + line.lengthMm === undefined || + line.widthMm === undefined || + line.heightMm === undefined + ) + const reviewReason = [ + ...slot.reviewFlags, + factRecords.length === 0 ? '当前行依据共享结构规则推导' : null, + missingBoardDimensions ? '板件尺寸或板厚仍需复核' : null, + ].filter(Boolean).join(';') + const descriptorFields = slot.category === 'PROCESSING' + ? { processName: line.descriptor } + : slot.category === 'PACKAGING' + ? { packagingKind: line.descriptor } + : { materialName: line.descriptor } return competitorBomLineSchema.parse({ externalLineId: line.externalLineId, category: slot.category, partName: slot.partName, - materialCode: line.materialCode, - materialName: line.materialName, - materialSpec: line.materialSpec, - processName: line.processName, - packagingKind: line.packagingKind, + ...descriptorFields, lengthMm: line.lengthMm, widthMm: line.widthMm, heightMm: line.heightMm, quantity: line.quantity, unit: line.unit, - weightKg: line.weightKg, evidence: [{ basis, imageUrl, @@ -890,24 +960,16 @@ async function instantiateBlueprint(input: { systemPrompt: INSTANTIATION_SYSTEM_PROMPT, userContent: JSON.stringify({ task: '按结构蓝图为本批每个 SKU 独立生成 BOM。', - outputContractVersion: 'structure-instantiation/compact-v1', + outputContractVersion: 'structure-instantiation/tuple-v1', outputShape: { skus: [{ - externalSkuId: '真实 SKU ID', - dimensions: '当前 SKU 整机尺寸,可缺省未知项', - bomLines: [{ - externalLineId: '必须等于 componentKey', - quantity: '数量', - unit: '单位', - materialName: '材料名,可选', - lengthMm: '单件长度,可选', - widthMm: '单件宽度,可选', - heightMm: '板件厚度或单件高度,可选', - evidenceFactIds: '只引用输入 factId', - derivation: '简短公式或装配关系', - reviewReason: '未知项,可为空', - confidence: '0 到 1', - }], + sku: '真实 SKU ID', + d: ['整机长mm或null', '整机宽/深mm或null', '整机高mm或null'], + lines: [[ + 'componentKey', '数量', '单位', + '长mm或null', '宽mm或null', '厚mm或null', + '材质/工艺或null', ['直接证据factId'], + ]], }], }, contractRetry: attempt > 1, @@ -926,14 +988,19 @@ async function instantiateBlueprint(input: { confidence: slot.confidence, })), }, - skuTruth: skuBatch.map(serializeSku), + skuTruth: skuBatch.map((sku) => ({ + id: sku.externalSkuId, + name: sku.name, + size: sku.size, + attributes: sku.attributes, + })), facts: groupFacts( input.factSnapshot, skuBatch.map((sku) => sku.externalSkuId), ), reviewInstruction: input.analysisInstruction?.trim() || undefined, }), - maxTokens: 2_200, + maxTokens: 1_200, timeoutMs: getTimeoutMs(45_000), retrySynthesisTimeout: false, signal: input.signal, @@ -985,8 +1052,6 @@ async function instantiateBlueprint(input: { imageUrl: source.imageUrl ?? undefined, attributes: { ...source.attributes }, dimensions: result.dimensions, - weightKg: result.weightKg, - packaging: result.packaging, bomLines: hydrateInstantiationLines({ lines: result.bomLines, group,