perf(bom-costing): compact product synthesis facts

This commit is contained in:
WuYongJie 2026-08-04 21:22:19 +08:00
parent b32fc52427
commit 634a2dad9d
2 changed files with 143 additions and 3 deletions

View File

@ -1004,6 +1004,54 @@ describe('BOM 多模态分析器', () => {
expect(JSON.stringify(compactInput)).not.toContain('不属于当前批次')
})
it('高密度详情事实只保留最有成本价值的一组产品级证据', () => {
const factSnapshot = createCompleteFactSnapshot(snapshot)
const factKinds = [
'DIMENSION',
'MATERIAL',
'STRUCTURE',
'MECHANISM',
'HARDWARE',
'PART',
'PROCESS',
'PACKAGING',
'WEIGHT',
'SKU_DIFFERENCE',
'TEXT',
] as const
factSnapshot.imageFacts.push(...Array.from({ length: 18 }, (_, index) => ({
imageId: `DETAIL_DENSE_${index + 1}`,
sourceImageUrl: `https://img.example.com/detail-dense-${index + 1}.jpg`,
kind: 'DETAIL' as const,
label: `高密度详情图 ${index + 1}`,
externalSkuIds: [],
observedTexts: [],
facts: [{
factId: `fact_dense_${index + 1}`,
kind: factKinds[index % factKinds.length]!,
subject: `部件 ${index + 1}`,
value: `事实 ${index + 1}`,
relatedSkuIds: [],
basis: 'IMAGE' as const,
confidence: 0.8,
}],
})))
const compactInput = buildSynthesisFactInput(
factSnapshot,
[snapshot.skus[0]!],
)
const detailImages = compactInput.images.filter(
(image) => image.kind === 'DETAIL',
)
const retainedKinds = new Set(
detailImages.flatMap((image) => image.facts.map((fact) => fact.k)),
)
expect(detailImages).toHaveLength(14)
expect([...factKinds].every((kind) => retainedKinds.has(kind))).toBe(true)
})
it('复用图片事实时,GPT-5 最终汇总在网关截断前结束等待', async () => {
const timeoutSpy = jest
.spyOn(AbortSignal, 'timeout')

View File

@ -75,7 +75,7 @@ const SYSTEM_PROMPT = `你是家具产品结构与成本工程师。你的任务
14. 每条 evidence 必须设置 basis:图片直接可见为 IMAGE;图片标注、详情文字或商品文案为 SOURCE_TEXT;SKU 名称、规格或属性为 SKU_METADATA;由装配或机构功能必需性推断的隐藏部件为 STRUCTURAL_INFERENCE。
15. STRUCTURAL_INFERENCE 只允许用于实现已确认结构或功能所必需的隐藏五金、连接件或保护包材;必须写明推断原因、数量估算和待确认项,evidence.confidence 与 BOM 行 confidence 都不得高于 0.55。
16. 安装知识只用于提醒检查,不是当前商品的事实。不得因为知识库列出了某种五金,就在缺少当前商品结构或功能依据时把它写进 BOM。
17. 逐图事实已经覆盖尺寸标注、局部小图、剖面图、结构示意、折叠前后状态和产品信息图;合并时不得遗漏这些事实。
17. 系统会从已归档的逐图事实中挑选与 BOM 最相关的产品级证据,并附上当前批次关联的 SKU 图片事实;不得遗漏本次输入中给出的事实。
18. SKU 名称或属性中的“折叠、升降、推拉、抽屉、脚轮、电动”等功能词必须触发对应机构检查;同时区分收拢态、展开态与整机尺寸。
19. 对板件之间、玻璃与框架、抽屉与柜体、门板与柜体等装配界面,必须检查连接方式;看不清时可以给出低置信度的必需连接件估算,但不得编造具体型号。
20. 每个结构组必须至少包含材料类(BOARD、STEEL、ACCESSORY 中至少一条)、PROCESSING、LABOR 和 PACKAGING,确保系统能立即形成一套出厂成本预估;不确定项用低置信度估算并在 evidence 中说明,不得直接省略整个成本组。`
@ -237,6 +237,20 @@ const DEFAULT_SYNTHESIS_BATCH_CONCURRENCY = 3
const MAX_EXPLICIT_FACT_SKU_IDS = 20
const MAX_SYNTHESIS_CONTRACT_ATTEMPTS = 2
const SYNTHESIS_IMAGE_REFERENCE_PREFIX = 'bom-image:'
const MAX_PRODUCT_LEVEL_FACT_IMAGES = 14
const PRODUCT_LEVEL_FACT_KIND_WEIGHTS: Record<string, number> = {
DIMENSION: 8,
MATERIAL: 8,
STRUCTURE: 7,
MECHANISM: 7,
HARDWARE: 7,
PART: 6,
PROCESS: 5,
PACKAGING: 5,
WEIGHT: 5,
SKU_DIFFERENCE: 4,
TEXT: 1,
}
function describeHighCardinalitySkuCatalog(skus: MarketSkuSnapshot[]) {
const valuesByAttribute = new Map<string, Set<string>>()
@ -335,11 +349,87 @@ function createSynthesisImageReferenceMap(
)
}
function getSynthesisFactSignature(
fact: BomCostingSourceFactSnapshot['imageFacts'][number]['facts'][number],
) {
return JSON.stringify([
fact.kind,
fact.subject,
fact.value,
fact.numericValue ?? null,
fact.unit ?? null,
fact.basis,
])
}
function selectProductLevelSynthesisImageIds(
factSnapshot: BomCostingSourceFactSnapshot,
) {
const selectedIds = new Set(
factSnapshot.imageFacts
.filter((image) => image.kind === 'MAIN')
.map((image) => image.imageId),
)
const remainingDetails = factSnapshot.imageFacts
.filter((image) => image.kind === 'DETAIL')
.map((image, originalIndex) => ({ image, originalIndex }))
const seenFactSignatures = new Set<string>()
const seenFactKinds = new Set<string>()
for (
let selectedCount = 0;
selectedCount < MAX_PRODUCT_LEVEL_FACT_IMAGES && remainingDetails.length > 0;
selectedCount += 1
) {
let bestIndex = 0
let bestScore = Number.NEGATIVE_INFINITY
for (const [index, candidate] of remainingDetails.entries()) {
const candidateSignatures = new Set<string>()
let score = 0
let newKindScore = 0
for (const fact of candidate.image.facts) {
const signature = getSynthesisFactSignature(fact)
if (candidateSignatures.has(signature)) continue
candidateSignatures.add(signature)
const weight = PRODUCT_LEVEL_FACT_KIND_WEIGHTS[fact.kind] ?? 1
if (!seenFactKinds.has(fact.kind)) newKindScore += weight
score += seenFactSignatures.has(signature) ? weight : weight * 3
}
// A detail image with no structured fact can still contain a small,
// useful text fragment. Keep it behind all structured evidence.
if (candidateSignatures.size === 0 && candidate.image.observedTexts.length > 0) {
score = 0.5
}
// First retain at least one image for every available fact category.
// This prevents a large number of dimensions or parts from crowding out
// a lower-volume but cost-relevant category such as packaging.
if (newKindScore > 0) score += 10_000 + newKindScore
if (
score > bestScore ||
(score === bestScore && candidate.originalIndex < remainingDetails[bestIndex]!.originalIndex)
) {
bestIndex = index
bestScore = score
}
}
const [selected] = remainingDetails.splice(bestIndex, 1)
if (!selected) break
selectedIds.add(selected.image.imageId)
selected.image.facts.forEach((fact) => {
seenFactSignatures.add(getSynthesisFactSignature(fact))
seenFactKinds.add(fact.kind)
})
}
return selectedIds
}
export function buildSynthesisFactInput(
factSnapshot: BomCostingSourceFactSnapshot,
skus: MarketSkuSnapshot[],
) {
const batchSkuIds = new Set(skus.map((sku) => sku.externalSkuId))
const productLevelImageIds = selectProductLevelSynthesisImageIds(factSnapshot)
return {
schemaVersion: factSnapshot.schemaVersion,
fieldReference: {
@ -348,8 +438,10 @@ export function buildSynthesisFactInput(
},
images: factSnapshot.imageFacts
.filter((image) => {
if (image.kind !== 'SKU') return true
return image.externalSkuIds.some((skuId) => batchSkuIds.has(skuId))
if (image.kind === 'SKU') {
return image.externalSkuIds.some((skuId) => batchSkuIds.has(skuId))
}
return productLevelImageIds.has(image.imageId)
})
.map((image) => {
const batchImageSkuIds = image.externalSkuIds.filter((skuId) =>