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 51b574f4..254b2ba1 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 @@ -315,6 +315,18 @@ async function readStreamingChatCompletion( if (!receivedDoneMarker && buffer.trim()) { buffer.split(/\r?\n/).forEach(consumeLine) } + } catch (error) { + if (typeof error === 'object' && error !== null) { + Object.defineProperty(error, 'bomStreamMetrics', { + value: { + responseContentChars: content.length, + pendingBufferChars: buffer.length, + receivedDoneMarker, + }, + configurable: true, + }) + } + throw error } finally { signal?.removeEventListener('abort', cancelReaderOnAbort) if (receivedDoneMarker) { @@ -548,6 +560,15 @@ export async function callBomCostingMultimodalModel(input: { } catch (error) { const taskDeadlineError = readTaskDeadlineError(requestSignal) if (taskDeadlineError) throw taskDeadlineError + const streamMetrics = typeof error === 'object' && error !== null + ? (error as { + bomStreamMetrics?: { + responseContentChars?: number + pendingBufferChars?: number + receivedDoneMarker?: boolean + } + }).bomStreamMetrics + : undefined console.error('[BOM costing] multimodal response stream failed', { operation: input.operation, model: input.config.model, @@ -555,6 +576,9 @@ export async function callBomCostingMultimodalModel(input: { durationMs: Date.now() - startedAt, errorName: readErrorName(error) ?? 'UnknownError', errorCode: readTransportErrorCode(error), + responseContentChars: streamMetrics?.responseContentChars ?? null, + pendingBufferChars: streamMetrics?.pendingBufferChars ?? null, + receivedDoneMarker: streamMetrics?.receivedDoneMarker ?? false, }) if ( attempt < maxAttempts && 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 7b55d8cd..53f78a17 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 @@ -118,6 +118,11 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => { const snapshot = snapshotWithSkus(7) const stages: BomCostingStructureStageProgress[] = [] const checkpoints: BomCostingStructureCheckpoint[] = [] + const instantiationRequests: Array<{ + skuTruth: Array<{ externalSkuId: string }> + structureGroup: { skuIds: string[] } + facts: Array<{ factId: string; relatedSkuIds: string[] }> + }> = [] const fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(async (_url, init) => { const body = JSON.parse(String(init?.body)) as { messages: Array<{ content: string }> @@ -125,6 +130,8 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => { const userPayload = JSON.parse(body.messages[1]?.content ?? '{}') as { outputContractVersion?: string skuTruth?: Array<{ externalSkuId: string; name: string }> + structureGroup?: { skuIds: string[] } + facts?: Array<{ factId: string; relatedSkuIds: string[] }> } if (userPayload.outputContractVersion === 'structure-blueprint/draft-v1') { return streamingResponse(JSON.stringify({ @@ -141,6 +148,11 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => { })) } const skuTruth = userPayload.skuTruth ?? [] + instantiationRequests.push({ + skuTruth, + structureGroup: userPayload.structureGroup ?? { skuIds: [] }, + facts: userPayload.facts ?? [], + }) return streamingResponse(JSON.stringify({ skus: skuTruth.map((sku) => { const widthMm = Number(sku.name.match(/(\d+)cm/)?.[1] ?? 140) * 10 @@ -189,7 +201,44 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => { snapshot, factSnapshot: { schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION, - imageFacts: [], + imageFacts: [ + { + imageId: 'common-image', + sourceImageUrl: 'https://img.example.com/common.jpg', + kind: 'DETAIL', + label: '商品通用尺寸', + externalSkuIds: [], + observedTexts: [], + facts: [{ + factId: 'fact-common', + kind: 'DIMENSION', + subject: '柜体高度', + value: '2200mm', + numericValue: 2200, + unit: 'mm', + relatedSkuIds: [], + basis: 'IMAGE', + confidence: 0.9, + }], + }, + ...snapshot.skus.map((sku) => ({ + imageId: `image-${sku.externalSkuId}`, + sourceImageUrl: `https://img.example.com/${sku.externalSkuId}.jpg`, + kind: 'SKU' as const, + label: sku.name, + externalSkuIds: [sku.externalSkuId], + observedTexts: [], + facts: [{ + factId: `fact-${sku.externalSkuId}`, + kind: 'DIMENSION' as const, + subject: '柜体宽度', + value: sku.size ?? '', + relatedSkuIds: [sku.externalSkuId], + basis: 'IMAGE' as const, + confidence: 0.9, + }], + })), + ], failedImages: [], extractedAt: new Date().toISOString(), }, @@ -197,7 +246,7 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => { onCheckpoint: async (checkpoint) => { checkpoints.push(checkpoint) }, }) - expect(fetchSpy).toHaveBeenCalledTimes(4) + expect(fetchSpy).toHaveBeenCalledTimes(8) expect(output.result.schemaVersion).toBe('competitor-analysis/v2') expect(output.result.skus).toHaveLength(7) expect(output.result.skus.map((sku) => sku.externalSkuId)).toEqual( @@ -210,7 +259,20 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => { expect(output.result.skus.flatMap((sku) => sku.bomLines).every((line) => ( line.estimatedUnitPrice === undefined && line.suggestedLossFactor === undefined ))).toBe(true) - expect(output.trace.instantiationCallCount).toBe(3) + expect(output.trace.instantiationCallCount).toBe(7) + expect(instantiationRequests).toHaveLength(7) + for (const request of instantiationRequests) { + expect(request.skuTruth).toHaveLength(1) + const skuId = request.skuTruth[0]?.externalSkuId + expect(request.structureGroup.skuIds).toEqual([skuId]) + expect(request.facts.map((fact) => fact.factId)).toEqual(expect.arrayContaining([ + 'fact-common', + `fact-${skuId}`, + ])) + expect(request.facts.every((fact) => ( + fact.relatedSkuIds.length === 0 || fact.relatedSkuIds.includes(String(skuId)) + ))).toBe(true) + } expect(stages.map((stage) => stage.stage)).toEqual(expect.arrayContaining([ 'BLUEPRINT', 'INSTANTIATE', 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 30f40c7a..6e3bb691 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 @@ -33,7 +33,11 @@ const MAX_BLUEPRINT_CONTRACT_ATTEMPTS = 2 const MAX_VERIFICATION_ROUNDS_PER_GROUP = 3 const MAX_QUESTIONS_PER_VERIFICATION_ROUND = 8 const MAX_IMAGES_PER_VERIFICATION_ROUND = 4 -const MAX_SKUS_PER_INSTANTIATION_CALL = 3 +const MAX_BLUEPRINT_FACTS = 120 +const MAX_BLUEPRINT_COMPONENT_SLOTS = 48 +const MAX_BLUEPRINT_RELATIONS = 80 +const MAX_BLUEPRINT_QUESTIONS = 16 +const MAX_SKUS_PER_INSTANTIATION_CALL = 1 const BLUEPRINT_SYSTEM_PROMPT = `你是家具产品结构工程师。根据系统保存的商品事实、真实 SKU 和版本化结构知识,先建立产品级结构蓝图,不输出价格,不输出每个 SKU 的最终 BOM。 @@ -45,7 +49,8 @@ const BLUEPRINT_SYSTEM_PROMPT = `你是家具产品结构工程师。根据系 5. 当前商品直接事实高于结构知识。结构知识只是检查清单;缺少直接证据时 confidence 不得高于 0.55,并产生 unresolvedQuestions。 6. 明确尺寸或板厚只绑定其原始适用部件;没有“柜体通用”等明确范围时不得扩散到其他板件。 7. 每个结构组至少包含主体材料、PROCESSING、LABOR 和 PACKAGING 的必要槽位。 -8. quantityRule 和 dimensionRule 必须写成可执行的中文推导规则,不得填写价格或损耗。` +8. quantityRule 和 dimensionRule 必须写成可执行的中文推导规则,不得填写价格或损耗。 +9. 在 baselineBlueprint 上做最小必要修正,不要重新发散设计产品。每组最多 48 个部件、80 条关系和 16 个待确认问题;只保留影响 BOM 的信息。` const VERIFICATION_SYSTEM_PROMPT = `你是家具结构证据复核员。当前只核对给定问题和图片,不重做整个产品,不输出价格。 @@ -73,12 +78,14 @@ const blueprintDraftSchema = z.object({ label: z.string().trim().min(1).max(240), evidenceFactIds: z.array(z.string().trim().min(1).max(160)).max(100).default([]), confidence: z.number().finite().min(0).max(1), - })).max(100).default([]), + })).max(24).default([]), structureGroups: z.array(z.object({ structureGroupKey: z.string().trim().min(1).max(160), label: z.string().trim().min(1).max(240), skuIds: z.array(z.string().trim().min(1).max(160)).min(1).max(600), - componentSlots: z.array(bomCostingStructureComponentSlotSchema).min(1).max(500), + componentSlots: z.array(bomCostingStructureComponentSlotSchema) + .min(1) + .max(MAX_BLUEPRINT_COMPONENT_SLOTS), relations: z.array(z.object({ relationType: z.enum([ 'CONTAINS', 'SUPPORTS', 'PARTITIONS', 'COVERS', 'CONNECTS', @@ -89,9 +96,11 @@ const blueprintDraftSchema = z.object({ rule: z.string().trim().min(1).max(1_000), evidenceFactIds: z.array(z.string().trim().min(1).max(160)).max(100).default([]), confidence: z.number().finite().min(0).max(1), - })).max(1_000).default([]), - unresolvedQuestions: z.array(bomCostingStructureQuestionSchema).max(100).default([]), - })).min(1).max(200), + })).max(MAX_BLUEPRINT_RELATIONS).default([]), + unresolvedQuestions: z.array(bomCostingStructureQuestionSchema) + .max(MAX_BLUEPRINT_QUESTIONS) + .default([]), + })).min(1).max(64), }) const verificationPayloadSchema = z.object({ @@ -127,7 +136,7 @@ const instantiatedSkuSchema = z.object({ heightMm: z.number().finite().positive().optional(), weightKg: z.number().finite().positive().optional(), }).optional(), - bomLines: z.array(competitorBomLineSchema).min(1).max(500), + bomLines: z.array(competitorBomLineSchema).min(1).max(80), }) const instantiationPayloadSchema = z.object({ @@ -238,7 +247,10 @@ function serializeSku(sku: MarketSkuSnapshot) { } } -function compactFacts(snapshot: BomCostingSourceFactSnapshot, maximum = 320) { +function compactFacts( + snapshot: BomCostingSourceFactSnapshot, + maximum = MAX_BLUEPRINT_FACTS, +) { return snapshot.imageFacts .flatMap((image) => image.facts.map((fact) => ({ factId: fact.factId, @@ -348,13 +360,17 @@ async function generateBlueprint(input: { trace: BomCostingStructureTrace }) { const knowledge = buildBomCostingStructureKnowledge(input.snapshot) + const baselineBlueprint = buildDeterministicStructureBlueprint({ + snapshot: input.snapshot, + factSnapshot: input.factSnapshot, + }) for (let attempt = 1; attempt <= MAX_BLUEPRINT_CONTRACT_ATTEMPTS; attempt += 1) { const response = await callBomCostingMultimodalModel({ config: input.config, operation: 'SYNTHESIS', systemPrompt: BLUEPRINT_SYSTEM_PROMPT, userContent: JSON.stringify({ - task: '建立整个商品的共享结构蓝图;先确定拓扑,再交给后续步骤逐 SKU 展开。', + task: '检查并最小修正 baselineBlueprint;只确定共享拓扑,不展开逐 SKU BOM。', outputContractVersion: 'structure-blueprint/draft-v1', contractRetry: attempt > 1, productTruth: { @@ -363,11 +379,12 @@ async function generateBlueprint(input: { shopName: input.snapshot.shopName, }, skuTruth: input.snapshot.skus.map(serializeSku), - facts: compactFacts(input.factSnapshot), + facts: compactFacts(input.factSnapshot, MAX_BLUEPRINT_FACTS), + baselineBlueprint, structureKnowledge: knowledge, reviewInstruction: input.analysisInstruction?.trim() || undefined, }), - maxTokens: 8_000, + maxTokens: 4_500, timeoutMs: getTimeoutMs(), retrySynthesisTimeout: true, signal: input.signal, @@ -600,7 +617,7 @@ function groupFacts( skuIds: string[], ) { const skuSet = new Set(skuIds) - return compactFacts(factSnapshot, 260).filter((fact) => ( + return compactFacts(factSnapshot, MAX_BLUEPRINT_FACTS).filter((fact) => ( fact.relatedSkuIds.length === 0 || fact.relatedSkuIds.some((skuId) => skuSet.has(skuId)) )) } @@ -752,12 +769,18 @@ async function instantiateBlueprint(input: { outputContractVersion: 'structure-instantiation/v1', contractRetry: attempt > 1, productTruth: { title: input.snapshot.title, shopName: input.snapshot.shopName }, - structureGroup: group, + structureGroup: { + ...group, + skuIds: skuBatch.map((sku) => sku.externalSkuId), + }, skuTruth: skuBatch.map(serializeSku), - facts: groupFacts(input.factSnapshot, group.skuIds), + facts: groupFacts( + input.factSnapshot, + skuBatch.map((sku) => sku.externalSkuId), + ), reviewInstruction: input.analysisInstruction?.trim() || undefined, }), - maxTokens: Math.min(12_000, Math.max(5_000, skuBatch.length * 2_500)), + maxTokens: 4_000, timeoutMs: getTimeoutMs(), retrySynthesisTimeout: true, signal: input.signal,