merge: scope BOM structure synthesis requests
This commit is contained in:
commit
707feb213a
@ -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 &&
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user