merge: BOM tuple synthesis v6

This commit is contained in:
WuYongJie 2026-08-11 03:28:32 +08:00
commit 67ce313c9a
4 changed files with 183 additions and 120 deletions

View File

@ -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',

View File

@ -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])

View File

@ -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'],
])),
})),
}))
})

View File

@ -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,