fix(bom-costing): compact structure synthesis

This commit is contained in:
WuYongJie 2026-08-11 02:20:39 +08:00
parent 5416bed96e
commit efbb08b7a5
4 changed files with 324 additions and 112 deletions

View File

@ -163,6 +163,44 @@ describe('BOM 多模态网关', () => {
expect(cancelReason).toBe('completed')
})
it('收到代码围栏包裹的完整 JSON 后也会结束,不再把有效结果等到超时', async () => {
let cancelReason: unknown
const encoder = new TextEncoder()
const keepAliveStream = new ReadableStream<Uint8Array>({
start(streamController) {
streamController.enqueue(encoder.encode(
'data: {"id":"call-fenced-json","model":"gpt-5.6-terra","choices":[{"delta":{"content":"```json\\n{\\"ok\\":true}\\n```"},"finish_reason":null}]}\n\n',
))
},
cancel(reason) {
cancelReason = reason
},
})
jest.spyOn(global, 'fetch').mockResolvedValue(
new Response(keepAliveStream, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
}),
)
const result = await callBomCostingMultimodalModel({
config: {
baseUrl: 'https://ai.example.com/v1',
apiKey: 'test-key',
model: 'gpt-5.6-terra',
},
operation: 'SYNTHESIS',
systemPrompt: 'test',
userContent: 'test',
maxTokens: 100,
timeoutMs: 5_000,
})
expect(result.content).toBe('```json\n{"ok":true}\n```')
expect(result.completedFromJsonPayload).toBe(true)
expect(cancelReason).toBe('completed')
})
it('截断 JSON 不会被当成完整结果,仍按超时进入恢复流程', async () => {
const encoder = new TextEncoder()
const truncatedStream = new ReadableStream<Uint8Array>({

View File

@ -274,10 +274,8 @@ async function readUpstreamErrorSummary(
}
function isCompleteJsonObjectPayload(content: string) {
const trimmed = content.trim()
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return false
try {
const parsed = JSON.parse(trimmed)
const parsed = JSON.parse(stripBomCostingJsonFence(content))
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
} catch {
return false

View File

@ -133,11 +133,13 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => {
structureGroup?: { skuIds: string[] }
facts?: Array<{ factId: string; relatedSkuIds: string[] }>
}
if (userPayload.outputContractVersion === 'structure-blueprint/draft-v1') {
if (userPayload.outputContractVersion === 'structure-blueprint/review-v1') {
return streamingResponse(JSON.stringify({
baselineAccepted: false,
productFamily: { code: 'WARDROBE', label: '衣柜类', confidence: 0.8 },
mechanisms: [],
structureGroups: [{
groupPatches: [],
replacementStructureGroups: [{
structureGroupKey: 'wardrobe-sliding',
label: '推拉门衣柜',
skuIds: snapshot.skus.map((sku) => sku.externalSkuId),
@ -167,29 +169,23 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => {
return {
externalSkuId: sku.externalSkuId,
dimensions: { lengthMm: widthMm, widthMm: 620, heightMm: 2200 },
bomLines: [
{
externalLineId: 'enclosure-side', category: 'BOARD', partName: '左右侧板',
materialName: '橡木', lengthMm: 2200, widthMm: 620, heightMm: 19.95,
quantity: 2, unit: '块', estimatedUnitPrice: 9_999,
suggestedLossFactor: 1.8, evidence: inferredEvidence, confidence: 0.5,
},
{
externalLineId: 'processing-main', category: 'PROCESSING', partName: '主体加工',
quantity: 1, unit: '套', estimatedUnitPrice: 888,
evidence: inferredEvidence, confidence: 0.5,
},
{
externalLineId: 'labor-assembly', category: 'LABOR', partName: '生产装配人工',
quantity: 1, unit: '套', estimatedUnitPrice: 777,
evidence: inferredEvidence, confidence: 0.5,
},
{
externalLineId: 'packaging-set', category: 'PACKAGING', partName: '整套运输包材',
materialName: '瓦楞纸箱', quantity: 1, unit: '套', estimatedUnitPrice: 666,
evidence: inferredEvidence, confidence: 0.5,
},
],
bomLines: componentSlots.map((slot) => ({
externalLineId: slot.componentKey,
materialName: 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,
})),
}
}),
}))
@ -277,6 +273,10 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => {
'BLUEPRINT',
'INSTANTIATE',
]))
expect(checkpoints.some((checkpoint) => (
checkpoint.skus.length === 1
&& checkpoint.completedGroupKeys.length === 0
))).toBe(true)
expect(checkpoints.at(-1)).toMatchObject({
completedGroupKeys: ['wardrobe-sliding'],
skus: expect.arrayContaining([
@ -292,9 +292,11 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => {
callIndex += 1
if (callIndex === 1) {
return streamingResponse(JSON.stringify({
baselineAccepted: false,
productFamily: { code: 'WARDROBE', label: '衣柜类', confidence: 0.8 },
mechanisms: [],
structureGroups: [{
groupPatches: [],
replacementStructureGroups: [{
structureGroupKey: 'wardrobe-sliding',
label: '推拉门衣柜',
skuIds: snapshot.skus.map((sku) => sku.externalSkuId),
@ -370,11 +372,13 @@ describe('BOM v6 结构蓝图与 SKU 展开', () => {
outputContractVersion?: string
skuTruth?: Array<{ externalSkuId: string }>
}
if (userPayload.outputContractVersion === 'structure-blueprint/draft-v1') {
if (userPayload.outputContractVersion === 'structure-blueprint/review-v1') {
return streamingResponse(JSON.stringify({
baselineAccepted: false,
productFamily: { code: 'WARDROBE', label: '衣柜类', confidence: 0.8 },
mechanisms: [],
structureGroups: [{
groupPatches: [],
replacementStructureGroups: [{
structureGroupKey: 'wardrobe-sliding',
label: '推拉门衣柜',
skuIds: ['sku-1'],

View File

@ -3,7 +3,6 @@ import 'server-only'
import { z } from 'zod'
import {
BOM_COSTING_ANALYSIS_SCHEMA_VERSION,
BOM_COSTING_STRUCTURE_BLUEPRINT_SCHEMA_VERSION,
bomCostingStructureBlueprintV1Schema,
bomCostingStructureComponentSlotSchema,
bomCostingStructureQuestionSchema,
@ -18,7 +17,6 @@ import type { MarketDetailSnapshot, MarketSkuSnapshot } from './market-detail-cl
import {
buildBomCostingStructureKnowledge,
buildDeterministicStructureBlueprint,
BOM_COSTING_STRUCTURE_KNOWLEDGE_VERSION,
} from './bom-costing-structure-knowledge'
import {
callBomCostingMultimodalModel,
@ -39,18 +37,19 @@ const MAX_BLUEPRINT_RELATIONS = 80
const MAX_BLUEPRINT_QUESTIONS = 16
const MAX_SKUS_PER_INSTANTIATION_CALL = 1
const BLUEPRINT_SYSTEM_PROMPT = `你是家具产品结构工程师。根据系统保存的商品事实、真实 SKU 和版本化结构知识,先建立产品级结构蓝图,不输出价格,不输出每个 SKU 的最终 BOM。
const BLUEPRINT_SYSTEM_PROMPT = `你是家具产品结构工程师。系统已经根据商品事实和版本化结构知识建立 baselineBlueprint。你只审查并返回必要修正,不输出价格,不输出每个 SKU 的最终 BOM,也不要原样抄回 baselineBlueprint。
硬规则:
1. 只输出一个 JSON 对象,不要 Markdown 或解释。
2. skuIds 必须完整覆盖 skuTruth,每个真实 SKU 出现且只出现一次;不得组合、拆分、补造 SKU。
3. structureGroupKey 表示共同装配拓扑,允许同组 SKU 尺寸不同;材质、门数、机构、是否含镜或套餐组成不同且改变部件时必须分组。
4. componentKey 在同一结构组内唯一,表示兄弟 SKU 共享的部件角色;尺寸变化不能令核心 componentKey 消失。
5. 当前商品直接事实高于结构知识。结构知识只是检查清单;缺少直接证据时 confidence 不得高于 0.55,并产生 unresolvedQuestions。
6. 明确尺寸或板厚只绑定其原始适用部件;没有“柜体通用”等明确范围时不得扩散到其他板件。
7. 每个结构组至少包含主体材料、PROCESSING、LABOR 和 PACKAGING 的必要槽位。
8. quantityRule 和 dimensionRule 必须写成可执行的中文推导规则,不得填写价格或损耗。
9. 在 baselineBlueprint 上做最小必要修正,不要重新发散设计产品。每组最多 48 个部件、80 条关系和 16 个待确认问题;只保留影响 BOM 的信息。`
2. baselineAccepted=true 表示没有必要修正;groupPatches 只写发生变化的结构组和字段。
3. 只有直接事实证明分组拓扑错误时才使用 replacementStructureGroups;一旦使用,skuIds 必须完整覆盖 skuTruth,每个真实 SKU 出现且只出现一次,不得组合、拆分、补造 SKU。
4. structureGroupKey 表示共同装配拓扑,允许同组 SKU 尺寸不同;材质、门数、机构、是否含镜或套餐组成不同且改变部件时必须分组。
5. componentKey 在同一结构组内唯一,表示兄弟 SKU 共享的部件角色;尺寸变化不能令核心 componentKey 消失。
6. 当前商品直接事实高于结构知识。结构知识只是检查清单;缺少直接证据时 confidence 不得高于 0.55,并产生 unresolvedQuestions。
7. 明确尺寸或板厚只绑定其原始适用部件;没有“柜体通用”等明确范围时不得扩散到其他板件。
8. 每个结构组至少包含主体材料、PROCESSING、LABOR 和 PACKAGING 的必要槽位。
9. quantityRule 和 dimensionRule 必须写成可执行的中文推导规则,不得填写价格或损耗。
10. 不要重复输出未改变的部件、关系或问题。每组最多 48 个部件、80 条关系和 16 个待确认问题;只保留影响 BOM 的信息。`
const VERIFICATION_SYSTEM_PROMPT = `你是家具结构证据复核员。当前只核对给定问题和图片,不重做整个产品,不输出价格。
@ -61,25 +60,26 @@ const INSTANTIATION_SYSTEM_PROMPT = `你是家具 BOM 结构工程师。根据
硬规则:
1. 只输出一个 JSON 对象,不要 Markdown 或解释。
2. externalSkuId 必须完整覆盖本批 skuTruth,每个 SKU 一次;不得组合或补造 SKU。
3. 每个 required componentSlot 都必须生成一条 BOM,externalLineId 必须等于 componentKey。同组兄弟 SKU 使用相同核心 componentKey。
3. 每个 required componentSlot 都必须生成一条 bomLine,externalLineId 必须等于 componentKey。同组兄弟 SKU 使用相同核心 componentKey。category、partName 和完整 evidence 由系统从蓝图补齐,不要回写这些重复字段。
4. 每条 SKU 的尺寸、数量、材质和套餐组成独立计算。颜色不改变结构时不能让基础部件消失。
5. BOARD 的 lengthMm/widthMm 是单块板件尺寸,heightMm 只能是板厚。明确板厚只应用于证据指定部件;目测厚度 confidence 不高于 0.45。
6. 由整机尺寸推导板件时,evidence.basis 使用 STRUCTURAL_INFERENCE,factIds 引用支持事实,derivation 写清公式或装配关系,reviewReason 写明未知项。
6. 每行只返回 externalLineId、quantity、unit、材料/工艺、尺寸、evidenceFactIds、derivation、reviewReason 和 confidence。由整机尺寸推导板件时,derivation 写清公式或装配关系,reviewReason 写明未知项。
7. 公司资料库是后续唯一正式价格来源;不得输出价格、售价倒推或损耗系数。`
const blueprintDraftSchema = z.object({
productFamily: z.object({
const blueprintProductFamilySchema = z.object({
code: z.string().trim().min(1).max(120),
label: z.string().trim().min(1).max(240),
confidence: z.number().finite().min(0).max(1),
}),
mechanisms: z.array(z.object({
})
const blueprintMechanismSchema = z.object({
code: z.string().trim().min(1).max(120),
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(24).default([]),
structureGroups: z.array(z.object({
})
const blueprintGroupDraftSchema = 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),
@ -100,7 +100,32 @@ const blueprintDraftSchema = z.object({
unresolvedQuestions: z.array(bomCostingStructureQuestionSchema)
.max(MAX_BLUEPRINT_QUESTIONS)
.default([]),
})).min(1).max(64),
})
const blueprintComponentUpdateSchema = bomCostingStructureComponentSlotSchema
.partial()
.extend({ componentKey: z.string().trim().min(1).max(120) })
const blueprintReviewSchema = z.object({
baselineAccepted: z.boolean(),
productFamily: blueprintProductFamilySchema.optional(),
mechanisms: z.array(blueprintMechanismSchema).max(24).optional(),
groupPatches: z.array(z.object({
structureGroupKey: z.string().trim().min(1).max(160),
label: z.string().trim().min(1).max(240).optional(),
componentUpdates: z.array(blueprintComponentUpdateSchema)
.max(MAX_BLUEPRINT_COMPONENT_SLOTS)
.default([]),
addedComponentSlots: z.array(bomCostingStructureComponentSlotSchema)
.max(MAX_BLUEPRINT_COMPONENT_SLOTS)
.default([]),
removedComponentKeys: z.array(z.string().trim().min(1).max(120))
.max(MAX_BLUEPRINT_COMPONENT_SLOTS)
.default([]),
relations: blueprintGroupDraftSchema.shape.relations.optional(),
unresolvedQuestions: blueprintGroupDraftSchema.shape.unresolvedQuestions.optional(),
})).max(64).default([]),
replacementStructureGroups: z.array(blueprintGroupDraftSchema).min(1).max(64).optional(),
})
const verificationPayloadSchema = z.object({
@ -122,6 +147,25 @@ const verificationPayloadSchema = z.object({
})).max(100).default([]),
})
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(),
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({
@ -136,31 +180,13 @@ const instantiatedSkuSchema = z.object({
heightMm: z.number().finite().positive().optional(),
weightKg: z.number().finite().positive().optional(),
}).optional(),
bomLines: z.array(competitorBomLineSchema).min(1).max(80),
bomLines: z.array(compactInstantiatedBomLineSchema).min(1).max(80),
})
const instantiationPayloadSchema = z.object({
skus: z.array(instantiatedSkuSchema).min(1).max(MAX_SKUS_PER_INSTANTIATION_CALL),
})
function removeModelPricing(
payload: z.infer<typeof instantiationPayloadSchema>,
): z.infer<typeof instantiationPayloadSchema> {
return {
skus: payload.skus.map((sku) => ({
...sku,
bomLines: sku.bomLines.map((line) => {
const {
estimatedUnitPrice: _estimatedUnitPrice,
suggestedLossFactor: _suggestedLossFactor,
...structureLine
} = line
return structureLine
}),
})),
}
}
const BOM_COSTING_STRUCTURE_CHECKPOINT_KIND =
'BOM_COSTING_STRUCTURE_CHECKPOINT' as const
const BOM_COSTING_STRUCTURE_CHECKPOINT_VERSION = 1 as const
@ -351,6 +377,51 @@ function recordResponse(trace: BomCostingStructureTrace, response: BomCostingMul
trace.completionTokens = addNullable(trace.completionTokens, response.completionTokens)
}
function applyBlueprintReview(input: {
baseline: BomCostingStructureBlueprintV1
review: z.infer<typeof blueprintReviewSchema>
factSnapshot: BomCostingSourceFactSnapshot
}) {
const groups = input.review.replacementStructureGroups
? input.review.replacementStructureGroups
: input.baseline.structureGroups.map((group) => {
const patch = input.review.groupPatches.find(
(candidate) => candidate.structureGroupKey === group.structureGroupKey,
)
if (!patch) return group
const removed = new Set(patch.removedComponentKeys)
const updates = new Map(
patch.componentUpdates.map((slot) => [slot.componentKey, slot] as const),
)
const currentSlots = group.componentSlots
.filter((slot) => !removed.has(slot.componentKey))
.map((slot) => ({ ...slot, ...updates.get(slot.componentKey) }))
const knownKeys = new Set(currentSlots.map((slot) => slot.componentKey))
const addedSlots = patch.addedComponentSlots.filter(
(slot) => !knownKeys.has(slot.componentKey),
)
return {
...group,
label: patch.label ?? group.label,
componentSlots: [...currentSlots, ...addedSlots],
relations: patch.relations ?? group.relations,
unresolvedQuestions:
patch.unresolvedQuestions ?? group.unresolvedQuestions,
}
})
const reviewed = bomCostingStructureBlueprintV1Schema.parse({
...input.baseline,
productFamily: input.review.productFamily ?? input.baseline.productFamily,
mechanisms: input.review.mechanisms ?? input.baseline.mechanisms,
structureGroups: groups,
})
return bomCostingStructureBlueprintV1Schema.parse(
sanitizeBlueprintFactIds(reviewed, input.factSnapshot),
)
}
async function generateBlueprint(input: {
config: BomCostingMultimodalProviderConfig
snapshot: MarketDetailSnapshot
@ -371,7 +442,14 @@ async function generateBlueprint(input: {
systemPrompt: BLUEPRINT_SYSTEM_PROMPT,
userContent: JSON.stringify({
task: '检查并最小修正 baselineBlueprint;只确定共享拓扑,不展开逐 SKU BOM。',
outputContractVersion: 'structure-blueprint/draft-v1',
outputContractVersion: 'structure-blueprint/review-v1',
outputShape: {
baselineAccepted: 'boolean',
productFamily: '仅发生变化时返回',
mechanisms: '仅发生变化时返回',
groupPatches: '只返回有变化的结构组与字段;无变化返回空数组',
replacementStructureGroups: '仅直接事实证明整体分组错误时返回',
},
contractRetry: attempt > 1,
productTruth: {
itemId: input.snapshot.itemId,
@ -384,26 +462,21 @@ async function generateBlueprint(input: {
structureKnowledge: knowledge,
reviewInstruction: input.analysisInstruction?.trim() || undefined,
}),
maxTokens: 4_500,
maxTokens: 2_400,
timeoutMs: getTimeoutMs(),
retrySynthesisTimeout: true,
signal: input.signal,
})
recordResponse(input.trace, response)
try {
const draft = parseSingle(response.content, blueprintDraftSchema)
const parsed = bomCostingStructureBlueprintV1Schema.parse({
schemaVersion: BOM_COSTING_STRUCTURE_BLUEPRINT_SCHEMA_VERSION,
knowledgeVersion: BOM_COSTING_STRUCTURE_KNOWLEDGE_VERSION,
...draft,
verificationRounds: [],
generatedAt: new Date().toISOString(),
const review = parseSingle(response.content, blueprintReviewSchema)
const reviewed = applyBlueprintReview({
baseline: baselineBlueprint,
review,
factSnapshot: input.factSnapshot,
})
const sanitized = bomCostingStructureBlueprintV1Schema.parse(
sanitizeBlueprintFactIds(parsed, input.factSnapshot),
)
assertBlueprintCoverage(sanitized, input.snapshot)
return sanitized
assertBlueprintCoverage(reviewed, input.snapshot)
return reviewed
} catch (error) {
if (attempt >= MAX_BLUEPRINT_CONTRACT_ATTEMPTS) throw error
}
@ -622,28 +695,74 @@ function groupFacts(
))
}
function imageUrlByFactId(factSnapshot: BomCostingSourceFactSnapshot) {
return new Map(factSnapshot.imageFacts.flatMap((image) => (
image.facts.map((fact) => [fact.factId, image.sourceImageUrl] as const)
function hydrateInstantiationLines(input: {
lines: z.infer<typeof compactInstantiatedBomLineSchema>[]
group: BomCostingStructureBlueprintV1['structureGroups'][number]
factSnapshot: BomCostingSourceFactSnapshot
}) {
const slotByKey = new Map(
input.group.componentSlots.map((slot) => [slot.componentKey, slot] as const),
)
const factById = new Map(input.factSnapshot.imageFacts.flatMap((image) => (
image.facts.map((fact) => [fact.factId, { fact, imageUrl: image.sourceImageUrl }] as const)
)))
}
function hydrateLineEvidence(
lines: z.infer<typeof competitorBomLineSchema>[],
factSnapshot: BomCostingSourceFactSnapshot,
) {
const urlByFact = imageUrlByFactId(factSnapshot)
return lines.map((line) => ({
...line,
evidence: line.evidence.map((evidence) => {
const factIds = (evidence.factIds ?? []).filter((factId) => urlByFact.has(factId))
return {
...evidence,
return input.lines.map((line) => {
const slot = slotByKey.get(line.externalLineId)
if (!slot) throw new Error(`${line.externalLineId}不属于当前结构蓝图`)
const factIds = Array.from(new Set([
...line.evidenceFactIds,
...slot.evidenceFactIds,
])).filter((factId) => factById.has(factId))
const factRecords = factIds.flatMap((factId) => {
const record = factById.get(factId)
return record ? [record] : []
})
const basis = factRecords.some(({ fact }) => fact.basis === 'IMAGE')
? 'IMAGE' as const
: factRecords.some(({ fact }) => fact.basis === 'SOURCE_TEXT')
? 'SOURCE_TEXT' as const
: 'STRUCTURAL_INFERENCE' as const
const evidenceConfidence = factRecords.length > 0
? 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)
const imageUrl = factRecords[0]?.imageUrl
const derivation = line.derivation || [
slot.quantityRule,
slot.dimensionRule,
].filter(Boolean).join(';')
const reviewReason = line.reviewReason || slot.reviewFlags.join(';')
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,
lengthMm: line.lengthMm,
widthMm: line.widthMm,
heightMm: line.heightMm,
quantity: line.quantity,
unit: line.unit,
weightKg: line.weightKg,
evidence: [{
basis,
imageUrl,
note: slot.reviewFlags[0],
factIds,
imageUrl: evidence.imageUrl ?? (factIds[0] ? urlByFact.get(factIds[0]) : undefined),
}
}),
}))
derivation,
reviewReason,
confidence,
}],
confidence,
})
})
}
function assertInstantiation(input: {
@ -744,12 +863,16 @@ async function instantiateBlueprint(input: {
const output: CompetitorAnalysisResultV2['skus'] = checkpointMatches
? [...input.checkpoint!.skus]
: []
const completedSkuIds = new Set(output.map((sku) => sku.externalSkuId))
const completedGroupKeys = new Set(
checkpointMatches ? input.checkpoint!.completedGroupKeys : [],
)
for (const group of input.blueprint.structureGroups) {
if (completedGroupKeys.has(group.structureGroupKey)) continue
const groupSkus = group.skuIds.map((skuId) => sourceById.get(skuId)).filter(Boolean) as MarketSkuSnapshot[]
const groupSkus = group.skuIds
.filter((skuId) => !completedSkuIds.has(skuId))
.map((skuId) => sourceById.get(skuId))
.filter(Boolean) as MarketSkuSnapshot[]
for (const skuBatch of splitBatch(groupSkus, MAX_SKUS_PER_INSTANTIATION_CALL)) {
await input.onStage?.({
stage: 'INSTANTIATE',
@ -766,12 +889,41 @@ async function instantiateBlueprint(input: {
systemPrompt: INSTANTIATION_SYSTEM_PROMPT,
userContent: JSON.stringify({
task: '按结构蓝图为本批每个 SKU 独立生成 BOM。',
outputContractVersion: 'structure-instantiation/v1',
outputContractVersion: 'structure-instantiation/compact-v1',
outputShape: {
skus: [{
externalSkuId: '真实 SKU ID',
dimensions: '当前 SKU 整机尺寸,可缺省未知项',
bomLines: [{
externalLineId: '必须等于 componentKey',
quantity: '数量',
unit: '单位',
materialName: '材料名,可选',
lengthMm: '单件长度,可选',
widthMm: '单件宽度,可选',
heightMm: '板件厚度或单件高度,可选',
evidenceFactIds: '只引用输入 factId',
derivation: '简短公式或装配关系',
reviewReason: '未知项,可为空',
confidence: '0 到 1',
}],
}],
},
contractRetry: attempt > 1,
productTruth: { title: input.snapshot.title, shopName: input.snapshot.shopName },
structureGroup: {
...group,
structureGroupKey: group.structureGroupKey,
label: group.label,
skuIds: skuBatch.map((sku) => sku.externalSkuId),
componentSlots: group.componentSlots.map((slot) => ({
componentKey: slot.componentKey,
required: slot.required,
quantityRule: slot.quantityRule,
dimensionRule: slot.dimensionRule,
materialScope: slot.materialScope,
evidenceFactIds: slot.evidenceFactIds,
confidence: slot.confidence,
})),
},
skuTruth: skuBatch.map(serializeSku),
facts: groupFacts(
@ -780,7 +932,7 @@ async function instantiateBlueprint(input: {
),
reviewInstruction: input.analysisInstruction?.trim() || undefined,
}),
maxTokens: 4_000,
maxTokens: 2_200,
timeoutMs: getTimeoutMs(),
retrySynthesisTimeout: true,
signal: input.signal,
@ -788,9 +940,7 @@ async function instantiateBlueprint(input: {
recordResponse(input.trace, response)
input.trace.instantiationCallCount += 1
try {
parsed = removeModelPricing(
parseSingle(response.content, instantiationPayloadSchema),
)
parsed = parseSingle(response.content, instantiationPayloadSchema)
assertInstantiation({ group, skuBatch, output: parsed })
break
} catch (error) {
@ -804,6 +954,7 @@ async function instantiateBlueprint(input: {
if (failure || !parsed) {
for (const sku of skuBatch) {
output.push(buildFallbackStructuredSku({ sku, group, failure }))
completedSkuIds.add(sku.externalSkuId)
if (!input.trace.fallbackSkuIds.includes(sku.externalSkuId)) {
input.trace.fallbackSkuIds.push(sku.externalSkuId)
}
@ -813,6 +964,14 @@ async function instantiateBlueprint(input: {
structureGroupKey: group.structureGroupKey,
message: `${group.label}展开未完成,已保留低可信必要部件并继续其他 SKU`,
})
await input.onCheckpoint?.({
kind: BOM_COSTING_STRUCTURE_CHECKPOINT_KIND,
version: BOM_COSTING_STRUCTURE_CHECKPOINT_VERSION,
sourceSkuIds,
blueprint: input.blueprint,
completedGroupKeys: Array.from(completedGroupKeys),
skus: [...output],
})
continue
}
for (const result of parsed.skus) {
@ -827,9 +986,22 @@ async function instantiateBlueprint(input: {
dimensions: result.dimensions,
weightKg: result.weightKg,
packaging: result.packaging,
bomLines: hydrateLineEvidence(result.bomLines, input.factSnapshot),
bomLines: hydrateInstantiationLines({
lines: result.bomLines,
group,
factSnapshot: input.factSnapshot,
}),
})
completedSkuIds.add(source.externalSkuId)
}
await input.onCheckpoint?.({
kind: BOM_COSTING_STRUCTURE_CHECKPOINT_KIND,
version: BOM_COSTING_STRUCTURE_CHECKPOINT_VERSION,
sourceSkuIds,
blueprint: input.blueprint,
completedGroupKeys: Array.from(completedGroupKeys),
skus: [...output],
})
}
completedGroupKeys.add(group.structureGroupKey)
await input.onCheckpoint?.({
@ -838,7 +1010,7 @@ async function instantiateBlueprint(input: {
sourceSkuIds,
blueprint: input.blueprint,
completedGroupKeys: Array.from(completedGroupKeys),
skus: output,
skus: [...output],
})
}
const expected = input.snapshot.skus.map((sku) => sku.externalSkuId).sort()