fix(bom-costing): compact structure synthesis
This commit is contained in:
parent
5416bed96e
commit
efbb08b7a5
@ -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>({
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user