merge: 发布 BOM 多模态事实传输契约修复

This commit is contained in:
WuYongJie 2026-08-03 09:25:04 +08:00
commit 668fa3c219
4 changed files with 234 additions and 50 deletions

View File

@ -60,7 +60,10 @@ export const bomCostingExtractedFactSchema = z.object({
z.array(z.string().trim().min(1).max(160)).max(200).default([]),
),
basis: z.enum(['IMAGE', 'SOURCE_TEXT']),
note: z.string().trim().min(1).max(1_000),
note: z.preprocess(
nullToUndefined,
z.string().trim().max(1_000).optional(),
),
confidence: z.preprocess(
parseFiniteJsonNumber,
z.number().finite().min(0).max(1),

View File

@ -243,6 +243,67 @@ export const aiBomCostingImageFactKindSchema = z.preprocess(
bomCostingImageFactKindSchema,
)
export const BOM_COSTING_AI_FACT_BASIS_CATALOG = [
{ code: 'IMAGE', meaning: '图片中直接可见的形状、部件、结构或机构' },
{ code: 'SOURCE_TEXT', meaning: '图片文字、尺寸标注、参数表或商品原文' },
] as const
const BOM_COSTING_AI_FACT_BASIS_ALIASES = {
IMAGE: [
'VISUAL',
'VISIBLE',
'PHOTO',
'IMAGE_EVIDENCE',
'DIRECT_VISUAL',
'图片',
'图像',
'视觉',
'直接可见',
],
SOURCE_TEXT: [
'TEXT',
'TEXTUAL',
'OCR',
'IMAGE_TEXT',
'SOURCE_COPY',
'ANNOTATION',
'文字',
'文本',
'图片文字',
'原文',
'标注',
],
} as const
const bomCostingAiFactBasisByAlias = new Map<string, 'IMAGE' | 'SOURCE_TEXT'>()
for (const { code } of BOM_COSTING_AI_FACT_BASIS_CATALOG) {
const aliases = [code, ...BOM_COSTING_AI_FACT_BASIS_ALIASES[code]]
for (const alias of aliases) {
const aliasKey = normalizeAiFactKindAliasKey(alias)
const existingCode = bomCostingAiFactBasisByAlias.get(aliasKey)
if (existingCode && existingCode !== code) {
throw new Error(
`BOM 事实依据别名 ${alias} 同时映射到 ${existingCode} 和 ${code}`,
)
}
bomCostingAiFactBasisByAlias.set(aliasKey, code)
}
}
export function normalizeBomCostingAiFactBasis(value: unknown) {
if (typeof value !== 'string') return value
const trimmed = value.trim()
if (!trimmed) return trimmed
return bomCostingAiFactBasisByAlias.get(
normalizeAiFactKindAliasKey(trimmed),
) ?? trimmed
}
export const aiBomCostingImageFactBasisSchema = z.preprocess(
normalizeBomCostingAiFactBasis,
z.enum(['IMAGE', 'SOURCE_TEXT']),
)
export const aiNullToUndefined = (value: unknown) =>
value === null ? undefined : value

View File

@ -11,6 +11,7 @@ import {
extractBomCostingImageFacts,
} from './bom-costing-image-fact-extractor'
import {
normalizeBomCostingAiFactBasis,
normalizeBomCostingAiFactKind,
} from './bom-costing-ai-output-contract'
import type { MarketDetailSnapshot } from './market-detail-client'
@ -84,11 +85,16 @@ function readBatchRequest(init?: RequestInit) {
const prompt = JSON.parse(content[0]?.text ?? '{}') as {
outputContractVersion?: string
allowedFactKinds?: Array<{ code: string; meaning: string }>
allowedFactBases?: Array<{ code: string; meaning: string }>
systemOwnedFields?: string[]
requiredOutputShape?: {
images?: Array<{ facts?: Array<Record<string, unknown>> }>
}
outputFieldRules?: {
factId?: string
kind?: string
basis?: string
note?: string
value?: string
multipleMeasurements?: string
}
@ -198,7 +204,7 @@ describe('BOM 逐图事实提取', () => {
imageId: batch.imageIds[0],
observedTexts: null,
facts: [{
factId: 'visible-material-1',
factId: 'model-id-must-be-ignored',
kind: 'MATERIAL',
subject: '桌面板',
value: '可见木纹表面',
@ -206,7 +212,7 @@ describe('BOM 逐图事实提取', () => {
unit: null,
relatedSkuIds: null,
basis: 'IMAGE',
note: '主图可见',
note: null,
confidence: 0.72,
}],
}],
@ -221,7 +227,7 @@ describe('BOM 逐图事实提取', () => {
expect(output.snapshot.imageFacts).toHaveLength(1)
expect(output.snapshot.imageFacts[0]?.observedTexts).toEqual([])
expect(output.snapshot.imageFacts[0]?.facts[0]).toEqual({
factId: 'visible-material-1',
factId: 'fact_001',
kind: 'MATERIAL',
subject: '桌面板',
value: '可见木纹表面',
@ -229,7 +235,7 @@ describe('BOM 逐图事实提取', () => {
unit: undefined,
relatedSkuIds: [],
basis: 'IMAGE',
note: '主图可见',
note: undefined,
confidence: 0.72,
})
})
@ -256,7 +262,7 @@ describe('BOM 逐图事实提取', () => {
subject: '整机尺寸',
value: '74×80×62cm',
relatedSkuIds: [],
basis: 'SOURCE_TEXT',
basis: 'OCR',
note: '图片标注',
confidence: 0.9,
},
@ -266,7 +272,7 @@ describe('BOM 逐图事实提取', () => {
subject: '主材',
value: '实木多层板',
relatedSkuIds: [],
basis: 'SOURCE_TEXT',
basis: 'source text',
note: '参数图文字',
confidence: 0.86,
},
@ -276,7 +282,7 @@ describe('BOM 逐图事实提取', () => {
subject: '桌板',
value: '可折叠展开',
relatedSkuIds: [],
basis: 'IMAGE',
basis: 'visual',
note: '结构示意图',
confidence: 0.78,
},
@ -303,15 +309,130 @@ describe('BOM 逐图事实提取', () => {
'SKU_DIFFERENCE',
'TEXT',
])
expect(request?.prompt.allowedFactBases?.map(({ code }) => code)).toEqual([
'IMAGE',
'SOURCE_TEXT',
])
expect(request?.prompt.systemOwnedFields).toEqual(['factId'])
expect(request?.prompt.outputFieldRules?.factId).toContain('系统')
expect(request?.prompt.outputFieldRules?.kind).toContain('逐字使用')
expect(request?.prompt.outputFieldRules?.basis).toContain('逐字使用')
expect(request?.prompt.outputFieldRules?.note).toContain('可选')
expect(output.snapshot.imageFacts[0]?.facts.map((fact) => fact.kind)).toEqual([
'DIMENSION',
'MATERIAL',
'MECHANISM',
])
expect(output.snapshot.imageFacts[0]?.facts.map((fact) => fact.basis)).toEqual([
'SOURCE_TEXT',
'SOURCE_TEXT',
'IMAGE',
])
expect(normalizeBomCostingAiFactKind('UNRELATED_MARKETING_SIGNAL')).toBe(
'UNRELATED_MARKETING_SIGNAL',
)
expect(normalizeBomCostingAiFactBasis('UNVERIFIED_SOURCE')).toBe(
'UNVERIFIED_SOURCE',
)
})
it('模型不返回事实标识或返回重复标识时仍由系统生成稳定且唯一的标识', async () => {
const singleImageSnapshot: MarketDetailSnapshot = {
...snapshot,
mainImages: ['https://img.example.com/system-owned-fact-id.jpg'],
skuImages: [],
detailImages: [],
skus: snapshot.skus.map((sku) => ({ ...sku, imageUrl: null })),
}
jest.spyOn(global, 'fetch').mockImplementation(async (_input, init) => {
const batch = readBatchRequest(init)
return createStreamingResponse(JSON.stringify({
images: [{
imageId: batch.imageIds[0],
observedTexts: [],
facts: [{
kind: 'PART',
subject: '顶板',
value: '可见一块顶板',
relatedSkuIds: [],
basis: 'IMAGE',
confidence: 0.8,
}, {
factId: 'duplicate-model-id',
kind: 'HARDWARE',
subject: '脚轮',
value: '可见四只脚轮',
relatedSkuIds: [],
basis: 'IMAGE',
confidence: 0.84,
}, {
factId: 'duplicate-model-id',
kind: 'MECHANISM',
subject: '顶板',
value: '可折叠展开',
relatedSkuIds: [],
basis: 'IMAGE',
confidence: 0.78,
}],
}],
}))
})
const output = await extractBomCostingImageFacts({
config: providerConfig,
snapshot: singleImageSnapshot,
})
expect(output.snapshot.imageFacts[0]?.facts.map((fact) => fact.factId)).toEqual([
'fact_001',
'fact_002',
'fact_003',
])
})
it('未知的核心事实依据不会被猜测或静默接收', async () => {
const singleImageSnapshot: MarketDetailSnapshot = {
...snapshot,
mainImages: ['https://img.example.com/unknown-basis.jpg'],
skuImages: [],
detailImages: [],
skus: snapshot.skus.map((sku) => ({ ...sku, imageUrl: null })),
}
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(
() => undefined,
)
jest.spyOn(global, 'fetch').mockImplementation(async (_input, init) => {
const batch = readBatchRequest(init)
return createStreamingResponse(JSON.stringify({
images: [{
imageId: batch.imageIds[0],
observedTexts: [],
facts: [{
kind: 'MATERIAL',
subject: '顶板',
value: '疑似实木多层板',
relatedSkuIds: [],
basis: 'UNVERIFIED_SOURCE',
confidence: 0.6,
}],
}],
}))
})
await expect(extractBomCostingImageFacts({
config: providerConfig,
snapshot: singleImageSnapshot,
})).rejects.toThrow('没有产生可保存的图片事实')
expect(warnSpy).toHaveBeenCalledWith(
'[BOM costing] image fact contract partially rejected',
expect.objectContaining({
rejectedIssues: expect.arrayContaining([
expect.stringContaining(
'facts.0.basis:received=UNVERIFIED_SOURCE',
),
]),
}),
)
})
it('以事实原文承载尺寸,忽略模型额外返回的歧义单值字段', async () => {
@ -362,7 +483,7 @@ describe('BOM 逐图事实提取', () => {
})
expect(request?.prompt.outputContractVersion).toBe(
'bom-costing/image-facts/v3',
'bom-costing/image-facts/v4',
)
expect(
request?.prompt.requiredOutputShape?.images?.[0]?.facts?.[0],
@ -376,12 +497,12 @@ describe('BOM 逐图事实提取', () => {
])
expect(output.snapshot.imageFacts[0]?.facts).toEqual([
expect.objectContaining({
factId: 'inner-width',
factId: 'fact_001',
value: '内空34.4cm',
confidence: 0.96,
}),
expect.objectContaining({
factId: 'overall-size-text',
factId: 'fact_002',
value: '74*80*62cm',
}),
])

View File

@ -11,8 +11,11 @@ import { buildBomCostingImageIdentity } from '@/lib/bom-costing/image-identity'
import { AppError, ErrorCode } from '@/server/core/errors'
import { buildBomCostingInstallationKnowledge } from './bom-costing-installation-knowledge'
import {
aiBomCostingImageFactBasisSchema,
aiBomCostingImageFactKindSchema,
aiConfidenceSchema,
aiOptionalTrimmedStringSchema,
BOM_COSTING_AI_FACT_BASIS_CATALOG,
BOM_COSTING_AI_FACT_KIND_CATALOG,
describeAiOutputIssues,
normalizeAiArray,
@ -41,14 +44,18 @@ const FACT_EXTRACTION_SYSTEM_PROMPT = `你是家具竞品资料事实提取员
7. relatedSkuIds 只能使用输入中的真实 SKU ID;无法绑定具体 SKU 时返回空数组。
8. 不得根据市场售价倒推成本,不得把颜色或营销等级当作基材。
9. confidence 必须在 0 到 1 之间;模糊、遮挡或仅能看见局部时应降低置信度并在 note 说明。
10. 同一图片中的每个 factId 必须唯一,使用简短稳定的英文或数字标识。
11. 每个事实的 kind 必须逐字使用下面列出的 code,不得自造同义词、中文名称或复合类型:
10. 不要返回 factId,事实标识由系统按图片和顺序生成。
11. note 是可选补充说明;没有额外说明时省略,不得为了填字段重复 subject 或 value。
12. 每个事实的 kind 必须逐字使用下面列出的 code,不得自造同义词、中文名称或复合类型:
${BOM_COSTING_AI_FACT_KIND_CATALOG.map(
({ code, meaning }) => `- ${code}:${meaning}`,
).join('\n')}
13. 每个事实的 basis 必须逐字使用下面列出的 code:
${BOM_COSTING_AI_FACT_BASIS_CATALOG.map(
({ code, meaning }) => `- ${code}:${meaning}`,
).join('\n')}`
const aiExtractedFactSchema = z.object({
factId: z.string().trim().min(1).max(160),
const aiExtractedFactTransportSchema = z.object({
kind: aiBomCostingImageFactKindSchema,
subject: z.string().trim().min(1).max(240),
value: z.string().trim().min(1).max(1_000),
@ -56,10 +63,10 @@ const aiExtractedFactSchema = z.object({
normalizeAiArray,
z.array(z.string().trim().min(1).max(160)).max(200),
),
basis: z.enum(['IMAGE', 'SOURCE_TEXT']),
note: z.string().trim().min(1).max(1_000),
basis: aiBomCostingImageFactBasisSchema,
note: aiOptionalTrimmedStringSchema(1_000),
confidence: aiConfidenceSchema,
}).transform((fact) => bomCostingExtractedFactSchema.parse(fact))
})
const aiImageFactResultSchema = z.object({
imageId: z.string().trim().min(1).max(80),
@ -69,9 +76,17 @@ const aiImageFactResultSchema = z.object({
),
facts: z.preprocess(
normalizeAiArray,
z.array(aiExtractedFactSchema).max(60),
z.array(aiExtractedFactTransportSchema).max(60),
),
})
}).transform((image) => ({
...image,
facts: image.facts.map((fact, index) =>
bomCostingExtractedFactSchema.parse({
...fact,
factId: `fact_${String(index + 1).padStart(3, '0')}`,
}),
),
}))
const aiFactBatchEnvelopeSchema = z.object({
images: z.array(z.unknown()).min(1).max(8),
@ -295,28 +310,30 @@ function buildFactBatchUserText(
analysisInstruction?.trim()
? '这是人工提出的复查重点。必须重点核对,但仍只能记录图片或商品原文能支持的事实;不得为了迎合要求而编造证据。'
: undefined,
outputContractVersion: 'bom-costing/image-facts/v3',
outputContractVersion: 'bom-costing/image-facts/v4',
allowedFactKinds: BOM_COSTING_AI_FACT_KIND_CATALOG,
allowedFactBases: BOM_COSTING_AI_FACT_BASIS_CATALOG,
systemOwnedFields: ['factId'],
requiredOutputShape: {
images: [{
imageId: 'IMG_0001',
observedTexts: ['内空34.4cm'],
facts: [{
factId: 'inner_width_1',
kind: 'DIMENSION',
subject: '侧面内空',
value: '图片标注内空34.4cm',
relatedSkuIds: [],
basis: 'SOURCE_TEXT',
note: '尺寸标注位于图片右侧',
confidence: 0.85,
}],
}],
},
outputFieldRules: {
imageId: '必须逐一使用本批 batchImages 中的真实 imageId,不得照抄示例',
factId: '同一图片内唯一,使用简短稳定的英文或数字标识',
factId: '不要返回;由系统按图片内事实顺序生成稳定标识',
kind: '必须逐字使用 allowedFactKinds 中的 code,不得输出中文名称、同义词或自造类型',
basis: '必须逐字使用 allowedFactBases 中的 code,不得自造类型',
note: '可选;只有额外的证据位置、遮挡或不确定性说明时才返回字符串,没有时省略或返回 null',
value:
'这是尺寸、重量、材质和机构信息的事实真相,必须保留图片原文中的数值与单位;不要另行返回 numericValue 或 unit',
multipleMeasurements:
@ -351,11 +368,10 @@ interface RejectedFactImage {
| 'DUPLICATE'
| 'INVALID_SHAPE'
| 'INVALID_SKU_REFERENCE'
| 'DUPLICATE_FACT_ID'
issueDetails: string[]
}
function describeRejectedFactKindValues(
function describeRejectedFactEnumValues(
error: z.ZodError,
rawImage: unknown,
) {
@ -364,24 +380,24 @@ function describeRejectedFactKindValues(
if (!Array.isArray(rawFacts)) return []
return error.issues.flatMap((issue) => {
const [factsKey, factIndex, kindKey] = issue.path
const [factsKey, factIndex, fieldKey] = issue.path
if (
factsKey !== 'facts' ||
typeof factIndex !== 'number' ||
kindKey !== 'kind'
(fieldKey !== 'kind' && fieldKey !== 'basis')
) {
return []
}
const rawFact = rawFacts[factIndex]
if (!rawFact || typeof rawFact !== 'object') return []
const rawKind = (rawFact as { kind?: unknown }).kind
if (typeof rawKind !== 'string') return []
const safeValue = rawKind
const rawValue = (rawFact as Record<string, unknown>)[fieldKey]
if (typeof rawValue !== 'string') return []
const safeValue = rawValue
.replace(/[\r\n\t]/g, ' ')
.trim()
.slice(0, 80)
return safeValue
? [`facts.${factIndex}.kind:received=${safeValue}`]
? [`facts.${factIndex}.${fieldKey}:received=${safeValue}`]
: []
})
}
@ -441,7 +457,7 @@ function validateFactBatchImages(
reason: 'INVALID_SHAPE',
issueDetails: [
...describeAiOutputIssues(parsed.error),
...describeRejectedFactKindValues(parsed.error, entries[0]),
...describeRejectedFactEnumValues(parsed.error, entries[0]),
],
})
continue
@ -459,16 +475,6 @@ function validateFactBatchImages(
continue
}
const factIds = parsed.data.facts.map((fact) => fact.factId)
if (new Set(factIds).size !== factIds.length) {
rejectedImages.push({
imageId: expectedImage.imageId,
reason: 'DUPLICATE_FACT_ID',
issueDetails: [],
})
continue
}
acceptedImages.push(parsed.data)
}
@ -490,13 +496,6 @@ function createTrustedImageFacts(
return expectedImages.map((expected) => {
const actual = actualById.get(expected.imageId)
if (!actual) throw new Error(`模型未返回 ${expected.imageId} 的图片事实`)
const factIds = new Set<string>()
actual.facts.forEach((fact) => {
if (factIds.has(fact.factId)) {
throw new Error(`${expected.imageId} 返回了重复 factId`)
}
factIds.add(fact.factId)
})
return {
imageId: expected.imageId,
sourceImageUrl: expected.url,