merge: 商品抠图配置合并上游专用抠图模型目录 + BOM 核算 agent 旁路引擎与文本脱敏契约
- ai-config: 任一协议模式合并网关与异步两份模型目录,抠图模型置顶标记,选中自动对齐协议 - bom-costing/agent: 估算引擎(契约/工具/循环/网关适配/组装器,27 测试)旁路试验版 - lib: 预估文本对外脱敏契约共享模块(public-estimate-text) - scripts: shadow 偏差报表与 agent 试跑工具(工程域专用)
This commit is contained in:
commit
9533c2e4ec
326
furniture-3d-system/scripts/report-bom-cost-shadow-divergence.ts
Normal file
326
furniture-3d-system/scripts/report-bom-cost-shadow-divergence.ts
Normal file
@ -0,0 +1,326 @@
|
||||
import { loadEnvConfig } from '@next/env'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import type { DynamicBomRealtimeMarketQuote } from '../src/lib/bom-costing/dynamic-bom-cost-domain'
|
||||
|
||||
loadEnvConfig(process.cwd())
|
||||
|
||||
/**
|
||||
* 工程侧 shadow 报表:对比每个 SKU 场景里“整件锚定预估”与“结构拆解预估”的偏差。
|
||||
* 数据全部来自 BomCostingScenario.realtimeMarketQuote(计算时已持久化),不改动产品链路,
|
||||
* 输出只进工程域(stdout / --json),不进入前端工作台。
|
||||
*/
|
||||
|
||||
type CliOptions = {
|
||||
json: boolean
|
||||
topLimit: number
|
||||
}
|
||||
|
||||
const DEFAULT_TOP_LIMIT = 15
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const options: CliOptions = { json: false, topLimit: DEFAULT_TOP_LIMIT }
|
||||
for (const argument of argv) {
|
||||
if (argument === '--json') options.json = true
|
||||
else if (argument.startsWith('--top=')) {
|
||||
const topLimit = Number(argument.slice('--top='.length))
|
||||
if (!Number.isSafeInteger(topLimit) || topLimit < 1 || topLimit > 200) {
|
||||
throw new Error('--top must be an integer between 1 and 200')
|
||||
}
|
||||
options.topLimit = topLimit
|
||||
} else if (argument === '--help' || argument === '-h') {
|
||||
console.log([
|
||||
'用法: npm run report:bom-cost-shadow-divergence [--json] [--top=N]',
|
||||
'',
|
||||
' --json 以 JSON 输出完整报表(便于入库或对比)',
|
||||
' --top=N 偏差排行条数,默认 15',
|
||||
].join('\n'))
|
||||
process.exit(0)
|
||||
} else {
|
||||
throw new Error(`未知参数:${argument}(--help 查看用法)`)
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
type ScenarioClassification =
|
||||
| 'ANCHORED_WITH_STRUCTURE'
|
||||
| 'ANCHORED_STRUCTURE_MISSING'
|
||||
| 'SIGNAL_UNAVAILABLE'
|
||||
| 'SIGNAL_NOT_CONFIGURED'
|
||||
|
||||
type ScenarioRow = {
|
||||
scenarioId: string
|
||||
caseId: string
|
||||
caseStatus: string
|
||||
sourceHost: string
|
||||
categorySkill: string
|
||||
externalSkuId: string
|
||||
createdAt: string
|
||||
classification: ScenarioClassification
|
||||
quoteStatus: string
|
||||
anchorCost: number | null
|
||||
structuralCost: number | null
|
||||
divergencePct: number | null
|
||||
}
|
||||
|
||||
function classifyScenario(quote: DynamicBomRealtimeMarketQuote): {
|
||||
classification: ScenarioClassification
|
||||
anchorCost: number | null
|
||||
structuralCost: number | null
|
||||
divergencePct: number | null
|
||||
} {
|
||||
const anchorCost = numberOrNull(quote.procurementCost)
|
||||
const structuralCost = numberOrNull(quote.structuralCost)
|
||||
if (quote.status === 'AVAILABLE' && anchorCost !== null && anchorCost > 0) {
|
||||
if (structuralCost === null) {
|
||||
return { classification: 'ANCHORED_STRUCTURE_MISSING', anchorCost, structuralCost, divergencePct: null }
|
||||
}
|
||||
return {
|
||||
classification: 'ANCHORED_WITH_STRUCTURE',
|
||||
anchorCost,
|
||||
structuralCost,
|
||||
divergencePct: Number((((structuralCost - anchorCost) / anchorCost) * 100).toFixed(2)),
|
||||
}
|
||||
}
|
||||
return {
|
||||
classification: quote.status === 'NOT_CONFIGURED' ? 'SIGNAL_NOT_CONFIGURED' : 'SIGNAL_UNAVAILABLE',
|
||||
anchorCost,
|
||||
structuralCost,
|
||||
divergencePct: null,
|
||||
}
|
||||
}
|
||||
|
||||
function percentile(sortedValues: number[], p: number) {
|
||||
if (sortedValues.length === 0) return null
|
||||
const index = Math.min(
|
||||
sortedValues.length - 1,
|
||||
Math.max(0, Math.ceil((p / 100) * sortedValues.length) - 1),
|
||||
)
|
||||
return sortedValues[index]
|
||||
}
|
||||
|
||||
function median(values: number[]) {
|
||||
return percentile(values, 50)
|
||||
}
|
||||
|
||||
function buildReport(rows: ScenarioRow[], topLimit: number) {
|
||||
const byClassification = rows.reduce<Record<string, number>>((acc, row) => {
|
||||
acc[row.classification] = (acc[row.classification] ?? 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
const divergences = rows
|
||||
.filter((row) => row.divergencePct !== null)
|
||||
.map((row) => row.divergencePct as number)
|
||||
const absSorted = [...divergences.map(Math.abs)].sort((left, right) => left - right)
|
||||
const signedSorted = [...divergences].sort((left, right) => left - right)
|
||||
const buckets = {
|
||||
within10Pct: absSorted.filter((value) => value <= 10).length,
|
||||
between10And30Pct: absSorted.filter((value) => value > 10 && value <= 30).length,
|
||||
over30Pct: absSorted.filter((value) => value > 30).length,
|
||||
}
|
||||
const byCase = new Map<string, {
|
||||
caseId: string
|
||||
caseStatus: string
|
||||
sourceHost: string
|
||||
total: number
|
||||
anchored: number
|
||||
divergenceSum: number
|
||||
divergenceCount: number
|
||||
}>()
|
||||
for (const row of rows) {
|
||||
const entry = byCase.get(row.caseId) ?? {
|
||||
caseId: row.caseId,
|
||||
caseStatus: row.caseStatus,
|
||||
sourceHost: row.sourceHost,
|
||||
total: 0,
|
||||
anchored: 0,
|
||||
divergenceSum: 0,
|
||||
divergenceCount: 0,
|
||||
}
|
||||
entry.total += 1
|
||||
if (row.classification === 'ANCHORED_WITH_STRUCTURE' || row.classification === 'ANCHORED_STRUCTURE_MISSING') {
|
||||
entry.anchored += 1
|
||||
}
|
||||
if (row.divergencePct !== null) {
|
||||
entry.divergenceSum += Math.abs(row.divergencePct)
|
||||
entry.divergenceCount += 1
|
||||
}
|
||||
byCase.set(row.caseId, entry)
|
||||
}
|
||||
const byCategorySkill = new Map<string, ScenarioRow[]>()
|
||||
for (const row of rows) {
|
||||
const key = row.categorySkill || '未解析品类'
|
||||
const group = byCategorySkill.get(key) ?? []
|
||||
group.push(row)
|
||||
byCategorySkill.set(key, group)
|
||||
}
|
||||
const categorySkillRollups = Array.from(byCategorySkill.entries())
|
||||
.map(([categorySkill, groupRows]) => {
|
||||
const groupDivergences = groupRows
|
||||
.flatMap((row) => row.divergencePct === null ? [] : [Math.abs(row.divergencePct)])
|
||||
.sort((left, right) => left - right)
|
||||
return {
|
||||
categorySkill,
|
||||
total: groupRows.length,
|
||||
anchored: groupRows.filter((row) => row.classification === 'ANCHORED_WITH_STRUCTURE').length,
|
||||
anchoredStructureMissing: groupRows.filter((row) => row.classification === 'ANCHORED_STRUCTURE_MISSING').length,
|
||||
signalUnavailable: groupRows.filter((row) => row.classification === 'SIGNAL_UNAVAILABLE').length,
|
||||
signalNotConfigured: groupRows.filter((row) => row.classification === 'SIGNAL_NOT_CONFIGURED').length,
|
||||
comparableCount: groupDivergences.length,
|
||||
meanAbsPct: groupDivergences.length === 0
|
||||
? null
|
||||
: Number((groupDivergences.reduce((sum, value) => sum + value, 0) / groupDivergences.length).toFixed(2)),
|
||||
medianAbsPct: median(groupDivergences),
|
||||
}
|
||||
})
|
||||
.sort((left, right) => right.total - left.total)
|
||||
return {
|
||||
scenarioCount: rows.length,
|
||||
byClassification,
|
||||
byCategorySkill: categorySkillRollups,
|
||||
divergence: {
|
||||
comparableCount: divergences.length,
|
||||
meanAbsPct: divergences.length === 0
|
||||
? null
|
||||
: Number((absSorted.reduce((sum, value) => sum + value, 0) / absSorted.length).toFixed(2)),
|
||||
medianAbsPct: median(absSorted),
|
||||
p80AbsPct: percentile(absSorted, 80),
|
||||
minSignedPct: signedSorted[0] ?? null,
|
||||
maxSignedPct: signedSorted[signedSorted.length - 1] ?? null,
|
||||
buckets,
|
||||
},
|
||||
topDivergences: [...rows]
|
||||
.filter((row) => row.divergencePct !== null)
|
||||
.sort((left, right) => Math.abs(right.divergencePct as number) - Math.abs(left.divergencePct as number))
|
||||
.slice(0, topLimit),
|
||||
byCase: [...byCase.values()].sort((left, right) => right.total - left.total),
|
||||
generatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function printHumanReport(report: ReturnType<typeof buildReport>) {
|
||||
console.log(`BOM 成本 shadow 偏差报表(生成于 ${report.generatedAt})`)
|
||||
console.log('')
|
||||
console.log(`场景总数:${report.scenarioCount}`)
|
||||
for (const [classification, count] of Object.entries(report.byClassification)) {
|
||||
console.log(` ${classification}: ${count}`)
|
||||
}
|
||||
const divergence = report.divergence
|
||||
console.log('')
|
||||
console.log(`可对比(锚定 + 结构双价齐全):${divergence.comparableCount} 个 SKU`)
|
||||
if (divergence.comparableCount > 0) {
|
||||
console.log(` 偏差(结构-锚定)/锚定 均值 |d| ${divergence.meanAbsPct}%,中位 ${divergence.medianAbsPct}%,P80 ${divergence.p80AbsPct}%`)
|
||||
console.log(` 区间 ${divergence.minSignedPct}% ~ ${divergence.maxSignedPct}%`)
|
||||
console.log(` 分布 |d|≤10%: ${divergence.buckets.within10Pct},10%<|d|≤30%: ${divergence.buckets.between10And30Pct},|d|>30%: ${divergence.buckets.over30Pct}`)
|
||||
}
|
||||
console.log('')
|
||||
console.log('偏差排行(|d| 降序,前 ' + report.topDivergences.length + ' 条):')
|
||||
for (const row of report.topDivergences) {
|
||||
console.log(
|
||||
` ${row.createdAt.slice(0, 10)} case=${row.caseId.slice(-8)} sku=${row.externalSkuId} 锚定=${row.anchorCost} 结构=${row.structuralCost} d=${row.divergencePct}%`,
|
||||
)
|
||||
}
|
||||
console.log('')
|
||||
console.log(`按品类 Skill 聚合(${report.byCategorySkill.length} 个):`)
|
||||
console.log(' 品类 | SKU总数 | 双价可比 | 结构缺价靠锚定 | 无实时信号 | 未配置 | 中位|d|')
|
||||
for (const rollup of report.byCategorySkill) {
|
||||
console.log(
|
||||
` ${rollup.categorySkill} | ${rollup.total} | ${rollup.comparableCount} | ${rollup.anchoredStructureMissing} | ${rollup.signalUnavailable} | ${rollup.signalNotConfigured} | ${rollup.medianAbsPct === null ? '--' : rollup.medianAbsPct + '%'}`,
|
||||
)
|
||||
}
|
||||
console.log('')
|
||||
console.log(`按案例聚合(${report.byCase.length} 个案例,前 20):`)
|
||||
for (const entry of report.byCase.slice(0, 20)) {
|
||||
const avgAbs = entry.divergenceCount === 0
|
||||
? '--'
|
||||
: (entry.divergenceSum / entry.divergenceCount).toFixed(2)
|
||||
console.log(
|
||||
` case=${entry.caseId.slice(-8)} [${entry.caseStatus}] ${entry.sourceHost} SKU=${entry.total} 锚定=${entry.anchored} 平均|d|=${avgAbs}%`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
const { prisma } = await import('../src/lib/prisma')
|
||||
try {
|
||||
const scenarios = await prisma.bomCostingScenario.findMany({
|
||||
where: {
|
||||
kind: 'SAME_MATERIAL',
|
||||
isCurrent: true,
|
||||
case: { deletedAt: null },
|
||||
realtimeMarketQuote: { not: Prisma.DbNull },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
realtimeMarketQuote: true,
|
||||
case: { select: { id: true, status: true, sourceHost: true } },
|
||||
sku: { select: { externalSkuId: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
const rows: ScenarioRow[] = []
|
||||
let skippedMalformed = 0
|
||||
const analysisRuns = await prisma.bomCostingAnalysisRun.findMany({
|
||||
where: { caseId: { in: [...new Set(scenarios.map((scenario) => scenario.case.id))] } },
|
||||
select: {
|
||||
caseId: true,
|
||||
status: true,
|
||||
resolvedCategorySkillId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
const categorySkillFallback = new Map<string, string>()
|
||||
const categorySkillReady = new Map<string, string>()
|
||||
for (const run of analysisRuns) {
|
||||
if (!categorySkillFallback.has(run.caseId)) {
|
||||
categorySkillFallback.set(run.caseId, run.resolvedCategorySkillId)
|
||||
}
|
||||
if (run.status === 'READY' && !categorySkillReady.has(run.caseId)) {
|
||||
categorySkillReady.set(run.caseId, run.resolvedCategorySkillId)
|
||||
}
|
||||
}
|
||||
const categorySkillByCase = new Map([...categorySkillFallback, ...categorySkillReady])
|
||||
for (const scenario of scenarios) {
|
||||
const quote = scenario.realtimeMarketQuote as unknown as DynamicBomRealtimeMarketQuote | null
|
||||
if (!quote || typeof quote !== 'object' || typeof quote.status !== 'string') {
|
||||
skippedMalformed += 1
|
||||
continue
|
||||
}
|
||||
const classified = classifyScenario(quote)
|
||||
rows.push({
|
||||
scenarioId: scenario.id,
|
||||
caseId: scenario.case.id,
|
||||
caseStatus: scenario.case.status,
|
||||
sourceHost: scenario.case.sourceHost,
|
||||
categorySkill: categorySkillByCase.get(scenario.case.id) ?? '',
|
||||
externalSkuId: scenario.sku.externalSkuId,
|
||||
createdAt: scenario.createdAt.toISOString(),
|
||||
quoteStatus: quote.status,
|
||||
...classified,
|
||||
})
|
||||
}
|
||||
const report = buildReport(rows, options.topLimit)
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ ...report, skippedMalformed }, null, 2))
|
||||
} else {
|
||||
if (skippedMalformed > 0) {
|
||||
console.log(`跳过无法解析的 quote 记录:${skippedMalformed} 条`)
|
||||
}
|
||||
printHumanReport(report)
|
||||
}
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exit(1)
|
||||
})
|
||||
222
furniture-3d-system/scripts/run-bom-costing-agent-trial.ts
Normal file
222
furniture-3d-system/scripts/run-bom-costing-agent-trial.ts
Normal file
@ -0,0 +1,222 @@
|
||||
import { loadEnvConfig } from '@next/env'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import type { DynamicBomRealtimeMarketQuote } from '../src/lib/bom-costing/dynamic-bom-cost-domain'
|
||||
import type { BomCostingAgentRunResult } from '../src/server/services/bom-costing/agent/agent-contracts'
|
||||
|
||||
loadEnvConfig(process.cwd())
|
||||
|
||||
// server 服务模块带 server-only 守卫,脚本进程需要空 stub(与 sync-oa-board 相同模式)。
|
||||
async function ensureServerOnlyStub() {
|
||||
const stubDir = path.join(process.cwd(), 'node_modules', 'server-only')
|
||||
await mkdir(stubDir, { recursive: true })
|
||||
await writeFile(path.join(stubDir, 'index.js'), 'module.exports = {}\n', 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 成本预估 agent 旁路试跑(工程域专用,不写任何业务表、不进管线):
|
||||
* 对指定案例/SKU 运行 agent 估算,并与现行引擎落库结果对比——
|
||||
* 展示价(可能为锚定)、结构拆解价、锚定采购价三方对照。
|
||||
* 替换 1688 方案前,必须先在本脚本上完成足够样本的实测验收。
|
||||
*/
|
||||
|
||||
type CliOptions = {
|
||||
caseId: string | null
|
||||
latestCount: number | null
|
||||
skuId: string | null
|
||||
maxImages: number
|
||||
json: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_IMAGES = 8
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const options: CliOptions = {
|
||||
caseId: null,
|
||||
latestCount: null,
|
||||
skuId: null,
|
||||
maxImages: DEFAULT_MAX_IMAGES,
|
||||
json: false,
|
||||
}
|
||||
for (const argument of argv) {
|
||||
if (argument === '--help' || argument === '-h') {
|
||||
console.log([
|
||||
'用法: npm run trial:bom-costing-agent -- --case=<案例ID> [--sku=<SKU记录ID>] [--max-images=N] [--json]',
|
||||
' 或: npm run trial:bom-costing-agent -- --latest=N (最近 N 个 READY 案例的主 SKU)',
|
||||
'',
|
||||
' --case=<id> 指定案例 ID(与 --latest 二选一)',
|
||||
' --sku=<id> 指定 SKU 记录 ID(默认取排序最前的 SKU)',
|
||||
' --max-images=N 送入模型的图片上限,默认 8',
|
||||
' --json 以 JSON 输出完整结果(含 trace)',
|
||||
'',
|
||||
'注意:本脚本会发起真实模型调用(有费用),并读取案例归档图片。',
|
||||
].join('\n'))
|
||||
process.exit(0)
|
||||
} else if (argument.startsWith('--case=')) {
|
||||
options.caseId = argument.slice('--case='.length).trim()
|
||||
} else if (argument.startsWith('--latest=')) {
|
||||
const count = Number(argument.slice('--latest='.length))
|
||||
if (!Number.isSafeInteger(count) || count < 1 || count > 20) {
|
||||
throw new Error('--latest must be an integer between 1 and 20')
|
||||
}
|
||||
options.latestCount = count
|
||||
} else if (argument.startsWith('--sku=')) {
|
||||
options.skuId = argument.slice('--sku='.length).trim()
|
||||
} else if (argument.startsWith('--max-images=')) {
|
||||
const maxImages = Number(argument.slice('--max-images='.length))
|
||||
if (!Number.isSafeInteger(maxImages) || maxImages < 1 || maxImages > 16) {
|
||||
throw new Error('--max-images must be an integer between 1 and 16')
|
||||
}
|
||||
options.maxImages = maxImages
|
||||
} else if (argument === '--json') {
|
||||
options.json = true
|
||||
} else {
|
||||
throw new Error(`未知参数:${argument}(--help 查看用法)`)
|
||||
}
|
||||
}
|
||||
if (!options.caseId && options.latestCount === null) {
|
||||
throw new Error('必须指定 --case=<案例ID> 或 --latest=N')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function agentTotalFromResult(result: BomCostingAgentRunResult) {
|
||||
if (!result.categoryTotals) return null
|
||||
let total = 0
|
||||
for (const [key, value] of Object.entries(result.categoryTotals)) {
|
||||
if (key === 'unresolvedLineCount' || typeof value !== 'number') continue
|
||||
total += value
|
||||
}
|
||||
return Math.round(total * 100) / 100
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
await ensureServerOnlyStub()
|
||||
const { prisma } = await import('../src/lib/prisma')
|
||||
const { runBomCostingAgentTrialForSku } = await import(
|
||||
'../src/server/services/bom-costing/agent/bom-costing-agent-runner'
|
||||
)
|
||||
try {
|
||||
let targets: Array<{ caseId: string; skuId: string | null }>
|
||||
if (options.caseId) {
|
||||
targets = [{ caseId: options.caseId, skuId: options.skuId }]
|
||||
} else {
|
||||
const cases = await prisma.bomCostingCase.findMany({
|
||||
where: { status: 'READY', deletedAt: null },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: options.latestCount!,
|
||||
select: { id: true, skus: { orderBy: { sortOrder: 'asc' }, take: 1, select: { id: true } } },
|
||||
})
|
||||
targets = cases.map((sourceCase) => ({
|
||||
caseId: sourceCase.id,
|
||||
skuId: sourceCase.skus[0]?.id ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
const reports: unknown[] = []
|
||||
for (const [index, target] of targets.entries()) {
|
||||
let skuId = target.skuId
|
||||
if (!skuId) {
|
||||
skuId = await prisma.bomCostingSku.findFirst({
|
||||
where: { caseId: target.caseId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: { id: true },
|
||||
}).then((sku) => sku?.id ?? null)
|
||||
}
|
||||
if (!skuId) {
|
||||
console.log(`[${index + 1}/${targets.length}] 案例 ${target.caseId} 没有 SKU,跳过`)
|
||||
continue
|
||||
}
|
||||
const scenario = await prisma.bomCostingScenario.findFirst({
|
||||
where: { skuId, kind: 'SAME_MATERIAL', isCurrent: true },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
totalCost: true,
|
||||
estimateLow: true,
|
||||
estimateHigh: true,
|
||||
realtimeMarketQuote: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
const quote = (scenario?.realtimeMarketQuote ?? null) as unknown as DynamicBomRealtimeMarketQuote | null
|
||||
const displayedTotal = scenario?.totalCost === null || scenario?.totalCost === undefined
|
||||
? null
|
||||
: Number(scenario.totalCost)
|
||||
const structuralCost = quote?.structuralCost ?? null
|
||||
const anchorCost = quote?.procurementCost ?? null
|
||||
|
||||
console.log(`\n[${index + 1}/${targets.length}] case=${target.caseId.slice(-8)} sku=${skuId.slice(-8)} 运行中…`)
|
||||
try {
|
||||
const trial = await runBomCostingAgentTrialForSku({
|
||||
caseId: target.caseId,
|
||||
skuId,
|
||||
maxImages: options.maxImages,
|
||||
})
|
||||
const agentTotal = agentTotalFromResult(trial.result)
|
||||
const divergence =
|
||||
agentTotal !== null && displayedTotal !== null && displayedTotal > 0
|
||||
? Number((((agentTotal - displayedTotal) / displayedTotal) * 100).toFixed(2))
|
||||
: null
|
||||
const report = {
|
||||
caseId: trial.caseId,
|
||||
skuId: trial.skuId,
|
||||
externalSkuId: trial.externalSkuId,
|
||||
skillCode: trial.skillCode,
|
||||
selectedImageCount: trial.selectedImageCount,
|
||||
agent: {
|
||||
status: trial.result.status,
|
||||
total: agentTotal,
|
||||
estimateRange: trial.result.estimate?.estimateRange ?? null,
|
||||
categoryTotals: trial.result.categoryTotals,
|
||||
warnings: trial.result.warnings,
|
||||
usage: trial.result.usage,
|
||||
trace: trial.result.trace,
|
||||
},
|
||||
currentEngine: {
|
||||
scenarioStatus: scenario?.status ?? null,
|
||||
displayedTotal,
|
||||
structuralCost,
|
||||
anchorCost,
|
||||
scenarioCreatedAt: scenario?.createdAt?.toISOString() ?? null,
|
||||
},
|
||||
divergenceVsDisplayedPct: divergence,
|
||||
}
|
||||
reports.push(report)
|
||||
if (options.json) continue
|
||||
console.log(` agent:${trial.result.status},总额 ¥${agentTotal ?? '--'},` +
|
||||
`图片 ${trial.selectedImageCount} 张,skill=${trial.skillCode ?? '无'}`)
|
||||
console.log(` agent 分类:${JSON.stringify(trial.result.categoryTotals)}`)
|
||||
console.log(` 现行引擎:展示 ¥${displayedTotal ?? '--'},结构 ¥${structuralCost ?? '--'},锚定 ¥${anchorCost ?? '--'}`)
|
||||
console.log(` agent vs 展示价偏差:${divergence === null ? '--' : `${divergence}%`}`)
|
||||
if (trial.result.warnings.length > 0) {
|
||||
console.log(` 警告:${trial.result.warnings.join(';').slice(0, 300)}`)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.log(` 运行失败:${message}`)
|
||||
reports.push({
|
||||
caseId: target.caseId,
|
||||
skuId,
|
||||
error: message,
|
||||
currentEngine: { displayedTotal, structuralCost, anchorCost },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify({ generatedAt: new Date().toISOString(), reports }, null, 2))
|
||||
} else {
|
||||
const completed = reports.filter((report) => 'agent' in (report as Record<string, unknown>))
|
||||
console.log(`\n完成:${completed.length}/${targets.length},明细可用 --json 查看(含 trace)`)
|
||||
}
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exit(1)
|
||||
})
|
||||
@ -62,11 +62,11 @@ describe('AI 服务配置模型选择', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试并获取模型' }))
|
||||
|
||||
await waitFor(() => expect(modelSelect).toBeEnabled())
|
||||
fireEvent.click(modelSelect)
|
||||
expect(screen.getByRole('option', { name: 'GPT-5.6' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: 'GPT-5.6 Mini' })).toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('未获取到模型列表,可手动输入')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(modelSelect, { target: { value: 'gpt-5.6' } })
|
||||
fireEvent.click(screen.getByRole('option', { name: 'GPT-5.6' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/admin/test-connection', {
|
||||
@ -117,15 +117,16 @@ describe('AI 服务配置模型选择', () => {
|
||||
fireEvent.change(screen.getByPlaceholderText('如:生产环境、测试 Key'), { target: { value: '通用抠图' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('https://ai.yydd.ai'), { target: { value: 'https://api.example.com/v1' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('extimg_xxx... / sk-...'), { target: { value: 'secret' } })
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '接口协议' }), {
|
||||
target: { value: 'openai-compatible-image-edit' },
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('combobox', { name: '接口协议' }))
|
||||
expect(screen.getByRole('option', { name: '异步媒体任务' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('option', { name: 'OpenAI 兼容图片编辑' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试并获取模型' }))
|
||||
|
||||
const modelSelect = screen.getByRole('combobox', { name: '模型' })
|
||||
await waitFor(() => expect(modelSelect).toBeEnabled())
|
||||
fireEvent.change(modelSelect, { target: { value: 'image-edit-v3' } })
|
||||
fireEvent.click(modelSelect)
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Image Edit V3' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/admin/test-connection', expect.objectContaining({
|
||||
@ -143,4 +144,59 @@ describe('AI 服务配置模型选择', () => {
|
||||
extra: { apiMode: 'openai-compatible-image-edit' },
|
||||
})
|
||||
})
|
||||
|
||||
it('商品抠图目录合并上游抠图模型,选中后自动切换异步媒体任务协议', async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
ok: true,
|
||||
status: 200,
|
||||
latency: 150,
|
||||
models: [
|
||||
{ value: 'remove-background', label: '智能抠图(remove-background)', source: 'async-matting' },
|
||||
{ value: 'gpt-image-2', label: 'GPT-Image-2', source: 'gateway' },
|
||||
],
|
||||
endpointPath: '/api/external/image/models',
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
global.fetch = fetchMock as typeof fetch
|
||||
const onSave = jest.fn()
|
||||
|
||||
render(
|
||||
<ConfigForm
|
||||
serviceType="image-background-removal"
|
||||
onSave={onSave}
|
||||
onCancel={jest.fn()}
|
||||
saving={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('如:生产环境、测试 Key'), { target: { value: '线上抠图' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('https://ai.yydd.ai'), { target: { value: 'https://api.vip98k.com' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('extimg_xxx... / sk-...'), { target: { value: 'secret' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试并获取模型' }))
|
||||
|
||||
const modelSelect = screen.getByRole('combobox', { name: '模型' })
|
||||
await waitFor(() => expect(modelSelect).toBeEnabled())
|
||||
fireEvent.click(modelSelect)
|
||||
|
||||
const mattingOption = screen.getByRole('option', { name: '【抠图】智能抠图(remove-background)' })
|
||||
const gatewayOption = screen.getByRole('option', { name: 'GPT-Image-2' })
|
||||
// 抠图模型排在通用模型之前,便于直接选用
|
||||
expect(mattingOption.compareDocumentPosition(gatewayOption) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
|
||||
fireEvent.click(mattingOption)
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
name: '线上抠图',
|
||||
baseUrl: 'https://api.vip98k.com',
|
||||
apiKey: 'secret',
|
||||
model: 'remove-background',
|
||||
extra: { apiMode: 'async-media-task' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import {
|
||||
@ -44,7 +45,7 @@ interface ProviderConfig {
|
||||
type ActiveTab = 'ai-render' | 'prompt-optimizer' | 'image-background-removal'
|
||||
|
||||
interface TestResult { status: 'idle' | 'testing' | 'success' | 'error'; message: string }
|
||||
interface ModelOption { value: string; label: string }
|
||||
interface ModelOption { value: string; label: string; source?: 'gateway' | 'async-matting' }
|
||||
|
||||
type BackgroundRemovalApiMode =
|
||||
| 'external-image-gateway'
|
||||
@ -176,6 +177,7 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: {
|
||||
return typeof record.value === 'string' && typeof record.label === 'string'
|
||||
})
|
||||
: []
|
||||
// 后端已按 抠图优先 排序;这里仅透传来源标记供选型时对齐协议。
|
||||
setModels(fetchedModels)
|
||||
setModelsLoaded(true)
|
||||
setModel((currentModel) => {
|
||||
@ -209,10 +211,13 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: {
|
||||
</div>
|
||||
{serviceType === 'image-background-removal' && (
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-bold uppercase tracking-[0.1em] text-[#6a7a7d]" htmlFor="background-removal-api-mode">接口协议</label>
|
||||
<select
|
||||
id="background-removal-api-mode"
|
||||
<label className="mb-1 block text-[10px] font-bold uppercase tracking-[0.1em] text-[#6a7a7d]">接口协议</label>
|
||||
<Select
|
||||
aria-label="接口协议"
|
||||
tone="obsidian"
|
||||
size="small"
|
||||
containerClassName="space-y-0"
|
||||
className="h-8 min-h-8 rounded-[6px] bg-white/5 text-xs ring-white/[0.06] focus:border-[#47646b]/40"
|
||||
value={backgroundRemovalApiMode}
|
||||
onChange={(event) => {
|
||||
setBackgroundRemovalApiMode(event.target.value as BackgroundRemovalApiMode)
|
||||
@ -221,12 +226,11 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: {
|
||||
setModels([])
|
||||
setTestResult({ status: 'idle', message: '' })
|
||||
}}
|
||||
className="h-8 w-full rounded-[6px] bg-white/5 px-3 text-xs text-[#edf4f2] outline-none ring-1 ring-white/[0.06] focus:ring-[#47646b]/40"
|
||||
>
|
||||
{(Object.entries(BACKGROUND_REMOVAL_API_MODE_META) as Array<[BackgroundRemovalApiMode, { label: string; desc: string }]>).map(([value, meta]) => (
|
||||
<option key={value} value={value} className="bg-[#111719] text-[#edf4f2]">{meta.label}</option>
|
||||
))}
|
||||
</select>
|
||||
options={(Object.entries(BACKGROUND_REMOVAL_API_MODE_META) as Array<[BackgroundRemovalApiMode, { label: string; desc: string }]>).map(([value, meta]) => ({
|
||||
value,
|
||||
label: meta.label,
|
||||
}))}
|
||||
/>
|
||||
<p className="mt-1 text-[10px] leading-4 text-[#5a6a6d]">{BACKGROUND_REMOVAL_API_MODE_META[backgroundRemovalApiMode].desc}</p>
|
||||
</div>
|
||||
)}
|
||||
@ -245,23 +249,43 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: {
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-bold uppercase tracking-[0.1em] text-[#6a7a7d]">模型</label>
|
||||
<select
|
||||
<Select
|
||||
aria-label="模型"
|
||||
tone="obsidian"
|
||||
size="small"
|
||||
searchable
|
||||
searchPlaceholder="搜索模型"
|
||||
emptyText="没有匹配的模型"
|
||||
containerClassName="space-y-0"
|
||||
className="h-8 min-h-8 rounded-[6px] bg-white/5 text-xs ring-white/[0.06] focus:border-[#47646b]/40"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value
|
||||
setModel(nextValue)
|
||||
const picked = models.find((item) => item.value === nextValue)
|
||||
if (picked?.source === 'async-matting') {
|
||||
setBackgroundRemovalApiMode('async-media-task')
|
||||
} else if (picked?.source === 'gateway' && backgroundRemovalApiMode === 'async-media-task') {
|
||||
setBackgroundRemovalApiMode('external-image-gateway')
|
||||
}
|
||||
}}
|
||||
disabled={!modelsLoaded || models.length === 0}
|
||||
className="h-8 w-full rounded-[6px] bg-white/5 px-3 text-xs text-[#edf4f2] outline-none ring-1 ring-white/[0.06] disabled:cursor-not-allowed disabled:text-[#6a7a7d] focus:ring-[#47646b]/40"
|
||||
>
|
||||
<option value="" className="bg-[#111719] text-[#6a7a7d]">
|
||||
{modelsLoaded ? '-- 请选择模型 --' : '请先测试并获取模型列表'}
|
||||
</option>
|
||||
{!modelsLoaded && model && (
|
||||
<option value={model} className="bg-[#111719] text-[#edf4f2]">当前配置:{model}</option>
|
||||
)}
|
||||
{models.map((item) => (
|
||||
<option key={item.value} value={item.value} className="bg-[#111719] text-[#edf4f2]">{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
placeholder={modelsLoaded ? '请选择模型' : '请先测试并获取模型列表'}
|
||||
options={[
|
||||
...(!modelsLoaded && model ? [{ value: model, label: `当前配置:${model}` }] : []),
|
||||
...[...models]
|
||||
.sort((left, right) => (
|
||||
(left.source === 'async-matting' ? 0 : 1) - (right.source === 'async-matting' ? 0 : 1)
|
||||
))
|
||||
.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.source === 'async-matting' ? `【抠图】${item.label}` : item.label,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
{serviceType === 'image-background-removal' && models.some((item) => item.source === 'async-matting') && (
|
||||
<p className="mt-1 text-[10px] leading-4 text-[#5a6a6d]">标记【抠图】的是上游专用抠图模型,选用后自动切换为异步媒体任务协议。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -76,6 +76,10 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
value,
|
||||
onChange,
|
||||
children,
|
||||
// onClick/onKeyDown 由组件内部的触发按钮接管,不透传(其 select 元素
|
||||
// 事件签名与 button 不兼容,且外部场景不应覆盖交互实现)。
|
||||
onClick: _ignoredOnClick,
|
||||
onKeyDown: _ignoredOnKeyDown,
|
||||
...props
|
||||
}, _ref) => {
|
||||
|
||||
@ -256,6 +260,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-label={label}
|
||||
{...(props as unknown as React.ComponentProps<'button'>)}
|
||||
>
|
||||
<span className={cn(
|
||||
'truncate text-left',
|
||||
|
||||
@ -0,0 +1,167 @@
|
||||
import { BomCostingReferenceSyncStatus, Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export class BomCostingAgentRepository {
|
||||
async findCaseForAgentTrial(caseId: string) {
|
||||
const sourceCase = await prisma.bomCostingCase.findUnique({
|
||||
where: { id: caseId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
sourceHost: true,
|
||||
status: true,
|
||||
deletedAt: true,
|
||||
analysisRuns: {
|
||||
select: { status: true, resolvedCategorySkillId: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!sourceCase || sourceCase.deletedAt !== null) return null
|
||||
let skillCode: string | null = sourceCase.analysisRuns.find((run) => run.status === 'READY')?.resolvedCategorySkillId
|
||||
?? sourceCase.analysisRuns[0]?.resolvedCategorySkillId
|
||||
?? null
|
||||
if (skillCode === '') skillCode = null
|
||||
return {
|
||||
id: sourceCase.id,
|
||||
title: sourceCase.title,
|
||||
sourceHost: sourceCase.sourceHost,
|
||||
status: sourceCase.status,
|
||||
skillCode,
|
||||
}
|
||||
}
|
||||
|
||||
async findSkuForAgentTrial(skuId: string) {
|
||||
return prisma.bomCostingSku.findUnique({
|
||||
where: { id: skuId },
|
||||
select: {
|
||||
id: true,
|
||||
caseId: true,
|
||||
externalSkuId: true,
|
||||
name: true,
|
||||
lengthMm: true,
|
||||
widthMm: true,
|
||||
heightMm: true,
|
||||
weightKg: true,
|
||||
packageWeightKg: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async listPrimarySkuIdForCase(caseId: string) {
|
||||
const sku = await prisma.bomCostingSku.findFirst({
|
||||
where: { caseId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: { id: true },
|
||||
})
|
||||
return sku?.id ?? null
|
||||
}
|
||||
|
||||
async listArchivedAssetsForCase(caseId: string) {
|
||||
const sourceCase = await prisma.bomCostingCase.findUnique({
|
||||
where: { id: caseId },
|
||||
select: { sourceSnapshotId: true, deletedAt: true },
|
||||
})
|
||||
if (!sourceCase || sourceCase.deletedAt !== null || !sourceCase.sourceSnapshotId) return []
|
||||
return prisma.bomCostingSourceAsset.findMany({
|
||||
where: {
|
||||
sourceSnapshotId: sourceCase.sourceSnapshotId,
|
||||
status: 'STORED',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
status: true,
|
||||
originalUrl: true,
|
||||
storedUrl: true,
|
||||
externalSkuIds: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
}
|
||||
|
||||
async searchReferenceRows(input: {
|
||||
category?: string
|
||||
materialKeywords?: string[]
|
||||
limit?: number
|
||||
}): Promise<Array<{
|
||||
sourceRowId: string
|
||||
sourceSku: string
|
||||
category: string
|
||||
categoryCode: string
|
||||
partName: string
|
||||
materialName: string
|
||||
unit: string
|
||||
unitPrice: Prisma.Decimal
|
||||
amount: Prisma.Decimal | null
|
||||
}>> {
|
||||
const run = await prisma.bomCostingReferenceSyncRun.findFirst({
|
||||
where: { status: BomCostingReferenceSyncStatus.SUCCEEDED },
|
||||
orderBy: [{ completedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
select: { id: true },
|
||||
})
|
||||
if (!run) return []
|
||||
const filters: Prisma.BomCostingReferenceRowWhereInput[] = []
|
||||
if (input.category?.trim()) {
|
||||
const category = input.category.trim()
|
||||
filters.push({
|
||||
OR: [
|
||||
{ categoryCode: { equals: category, mode: 'insensitive' } },
|
||||
{ category: { contains: category, mode: 'insensitive' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
const keywords = (input.materialKeywords ?? [])
|
||||
.map((keyword) => keyword.trim())
|
||||
.filter(Boolean)
|
||||
if (keywords.length > 0) {
|
||||
filters.push({
|
||||
OR: keywords.flatMap((keyword) => [
|
||||
{ materialName: { contains: keyword, mode: 'insensitive' } },
|
||||
{ materialCode: { contains: keyword, mode: 'insensitive' } },
|
||||
]),
|
||||
})
|
||||
}
|
||||
const limit = Math.min(Math.max(Math.trunc(input.limit ?? 20), 1), 50)
|
||||
const rows = await prisma.bomCostingReferenceRow.findMany({
|
||||
where: {
|
||||
syncRunId: run.id,
|
||||
unitPrice: { gt: 0 },
|
||||
...(filters.length > 0 ? { AND: filters } : {}),
|
||||
},
|
||||
select: {
|
||||
sourceRowId: true,
|
||||
sourceSku: true,
|
||||
category: true,
|
||||
categoryCode: true,
|
||||
partName: true,
|
||||
materialName: true,
|
||||
unit: true,
|
||||
unitPrice: true,
|
||||
amount: true,
|
||||
},
|
||||
orderBy: [{ materialName: 'asc' }, { sourceSku: 'asc' }],
|
||||
take: limit,
|
||||
})
|
||||
return rows.flatMap((row) => row.unitPrice === null ? [] : [{ ...row, unitPrice: row.unitPrice }])
|
||||
}
|
||||
|
||||
async listEnabledMaterialOptions() {
|
||||
return prisma.bomCostingMaterialOption.findMany({
|
||||
where: { enabled: true },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
category: true,
|
||||
aliases: true,
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const bomCostingAgentRepository = new BomCostingAgentRepository()
|
||||
@ -188,6 +188,74 @@ describe('ProviderModelCatalogService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('商品抠图默认协议合并网关与异步抠图目录,抠图模型排前且带来源', async () => {
|
||||
const fetchMock = jest.fn()
|
||||
.mockResolvedValueOnce(createResponse(200, {
|
||||
data: {
|
||||
models: [{ id: 'gpt-image-2', modelId: 'gpt-image-2', displayName: 'GPT Image 2' }],
|
||||
},
|
||||
}))
|
||||
.mockResolvedValueOnce(createResponse(200, {
|
||||
data: {
|
||||
sections: [{
|
||||
id: 'task',
|
||||
models: [{
|
||||
id: 'remove-background',
|
||||
display_name: '智能抠图',
|
||||
available: true,
|
||||
input_modes: ['file', 'url'],
|
||||
output_modes: ['image'],
|
||||
invocation: { path: '/extensions/v1/tasks' },
|
||||
}],
|
||||
}],
|
||||
},
|
||||
}))
|
||||
global.fetch = fetchMock as typeof fetch
|
||||
|
||||
const result = await service.fetchCatalog({
|
||||
serviceType: 'image-background-removal',
|
||||
baseUrl: 'https://api.vip98k.com',
|
||||
apiKey: 'secret',
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.models).toEqual([
|
||||
{ value: 'remove-background', label: '智能抠图(remove-background)', source: 'async-matting' },
|
||||
{ value: 'gpt-image-2', label: 'GPT Image 2', source: 'gateway' },
|
||||
])
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://api.vip98k.com/api/external/image/models',
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://api.vip98k.com/api/v2/catalog/models',
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('商品抠图目录密钥无权限时中断后续目录尝试', async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue(createResponse(401, {
|
||||
error: { message: 'unauthorized' },
|
||||
}))
|
||||
global.fetch = fetchMock as typeof fetch
|
||||
|
||||
const result = await service.fetchCatalog({
|
||||
serviceType: 'image-background-removal',
|
||||
baseUrl: 'https://image.example.com',
|
||||
apiKey: 'bad-secret',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
status: 401,
|
||||
models: [],
|
||||
error: 'API 密钥无权读取模型列表,请检查密钥权限',
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('异步媒体任务目录会返回智能抠图等图片处理模型', async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue(createResponse(200, {
|
||||
data: {
|
||||
|
||||
@ -0,0 +1,254 @@
|
||||
import type { DynamicBomEstimateRange } from '@/lib/bom-costing/dynamic-bom-cost-domain'
|
||||
|
||||
export const BOM_COSTING_AGENT_PROTOCOL_VERSION = 'bom-costing-agent/v1' as const
|
||||
|
||||
export type BomCostingAgentEstimateCategory =
|
||||
| 'BOARD'
|
||||
| 'STEEL'
|
||||
| 'ACCESSORY'
|
||||
| 'PACKAGING'
|
||||
| 'PROCESSING'
|
||||
| 'LABOR'
|
||||
|
||||
export interface BomCostingAgentEstimateLine {
|
||||
lineKey: string
|
||||
category: BomCostingAgentEstimateCategory
|
||||
partName: string
|
||||
material: string | null
|
||||
quantity: number
|
||||
unit: string
|
||||
unitPrice: number | null
|
||||
evidence: string[]
|
||||
}
|
||||
|
||||
export interface BomCostingAgentEstimate {
|
||||
structureSummary: string
|
||||
lines: BomCostingAgentEstimateLine[]
|
||||
confidence: number
|
||||
estimateRange: DynamicBomEstimateRange
|
||||
openQuestions: string[]
|
||||
}
|
||||
|
||||
export type BomCostingAgentCategoryTotals = Partial<
|
||||
Record<BomCostingAgentEstimateCategory, number>
|
||||
> & { unresolvedLineCount: number }
|
||||
|
||||
export interface BomCostingAgentActionTool {
|
||||
type: 'tool'
|
||||
tool: string
|
||||
arguments: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface BomCostingAgentActionFinal {
|
||||
type: 'final'
|
||||
estimate: BomCostingAgentEstimate
|
||||
}
|
||||
|
||||
export type BomCostingAgentAction = BomCostingAgentActionTool | BomCostingAgentActionFinal
|
||||
|
||||
export interface BomCostingAgentBudget {
|
||||
maxRounds: number
|
||||
maxModelCalls: number
|
||||
maxWallClockMs: number
|
||||
}
|
||||
|
||||
export const DEFAULT_BOM_COSTING_AGENT_BUDGET: BomCostingAgentBudget = {
|
||||
maxRounds: 12,
|
||||
maxModelCalls: 16,
|
||||
maxWallClockMs: 6 * 60_000,
|
||||
}
|
||||
|
||||
export type BomCostingAgentStepActionType =
|
||||
| 'tool'
|
||||
| 'final'
|
||||
| 'parse_error'
|
||||
| 'budget_exhausted'
|
||||
|
||||
export interface BomCostingAgentStepTrace {
|
||||
round: number
|
||||
actionType: BomCostingAgentStepActionType
|
||||
tool?: string
|
||||
toolArguments?: unknown
|
||||
toolResultSummary?: string
|
||||
modelCallMs?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type BomCostingAgentRunStatus =
|
||||
| 'COMPLETED'
|
||||
| 'PARTIAL'
|
||||
| 'BUDGET_EXHAUSTED'
|
||||
| 'FAILED'
|
||||
|
||||
export interface BomCostingAgentRunResult {
|
||||
status: BomCostingAgentRunStatus
|
||||
estimate: BomCostingAgentEstimate | null
|
||||
categoryTotals: BomCostingAgentCategoryTotals | null
|
||||
warnings: string[]
|
||||
trace: BomCostingAgentStepTrace[]
|
||||
usage: {
|
||||
rounds: number
|
||||
modelCalls: number
|
||||
elapsedMs: number
|
||||
}
|
||||
}
|
||||
|
||||
const ESTIMATE_CATEGORIES: ReadonlySet<string> = new Set([
|
||||
'BOARD',
|
||||
'STEEL',
|
||||
'ACCESSORY',
|
||||
'PACKAGING',
|
||||
'PROCESSING',
|
||||
'LABOR',
|
||||
])
|
||||
|
||||
function extractJsonPayload(content: string) {
|
||||
const fenced = content.match(/```(?:json)?\s*([\s\S]*?)```/i)
|
||||
const candidate = (fenced ? fenced[1] : content).trim()
|
||||
const start = candidate.indexOf('{')
|
||||
if (start === -1) return null
|
||||
// 模型可能在一轮里拼接多个 JSON 对象(网关不总是截断);用括号配平
|
||||
// 取第一个完整对象,而不是首 { 到末 } 的非法整体。
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let escaped = false
|
||||
for (let index = start; index < candidate.length; index += 1) {
|
||||
const char = candidate[index]
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if (char === '\\') {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = !inString
|
||||
continue
|
||||
}
|
||||
if (inString) continue
|
||||
if (char === '{') depth += 1
|
||||
else if (char === '}') {
|
||||
depth -= 1
|
||||
if (depth === 0) return candidate.slice(start, index + 1)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseEstimateLine(value: unknown): BomCostingAgentEstimateLine | null {
|
||||
if (typeof value !== 'object' || value === null) return null
|
||||
const record = value as Record<string, unknown>
|
||||
const category = typeof record.category === 'string' ? record.category.toUpperCase() : ''
|
||||
if (!ESTIMATE_CATEGORIES.has(category)) return null
|
||||
const quantity = typeof record.quantity === 'number' ? record.quantity : Number.NaN
|
||||
const unitPrice =
|
||||
typeof record.unitPrice === 'number' && Number.isFinite(record.unitPrice) && record.unitPrice >= 0
|
||||
? record.unitPrice
|
||||
: null
|
||||
if (!Number.isFinite(quantity) || quantity <= 0) return null
|
||||
return {
|
||||
lineKey: typeof record.lineKey === 'string' && record.lineKey.trim() ? record.lineKey.trim() : '',
|
||||
category: category as BomCostingAgentEstimateCategory,
|
||||
partName: typeof record.partName === 'string' ? record.partName : '',
|
||||
material: typeof record.material === 'string' && record.material.trim() ? record.material : null,
|
||||
quantity,
|
||||
unit: typeof record.unit === 'string' && record.unit.trim() ? record.unit : '',
|
||||
unitPrice,
|
||||
evidence: Array.isArray(record.evidence)
|
||||
? record.evidence.filter((item): item is string => typeof item === 'string' && item.trim() !== '')
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBomCostingAgentEstimate(value: unknown): BomCostingAgentEstimate | null {
|
||||
if (typeof value !== 'object' || value === null) return null
|
||||
const record = value as Record<string, unknown>
|
||||
const confidence = typeof record.confidence === 'number' ? record.confidence : Number.NaN
|
||||
const range = record.estimateRange
|
||||
const min = typeof range === 'object' && range !== null ? (range as Record<string, unknown>).min : undefined
|
||||
const max = typeof range === 'object' && range !== null ? (range as Record<string, unknown>).max : undefined
|
||||
if (
|
||||
!Number.isFinite(confidence) || confidence < 0 || confidence > 1 ||
|
||||
typeof min !== 'number' || typeof max !== 'number' || !(min <= max)
|
||||
) return null
|
||||
const lines = Array.isArray(record.lines)
|
||||
? record.lines.flatMap((line) => {
|
||||
const parsed = parseEstimateLine(line)
|
||||
return parsed ? [parsed] : []
|
||||
})
|
||||
: []
|
||||
return {
|
||||
structureSummary: typeof record.structureSummary === 'string' ? record.structureSummary : '',
|
||||
lines,
|
||||
confidence,
|
||||
estimateRange: { min, max },
|
||||
openQuestions: Array.isArray(record.openQuestions)
|
||||
? record.openQuestions.filter((item): item is string => typeof item === 'string')
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBomCostingAgentAction(content: string): {
|
||||
action: BomCostingAgentAction | null
|
||||
error: string | null
|
||||
} {
|
||||
const payload = extractJsonPayload(content)
|
||||
if (!payload) {
|
||||
return { action: null, error: '输出中未找到 JSON 对象' }
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(payload)
|
||||
} catch (error) {
|
||||
return { action: null, error: `JSON 解析失败:${error instanceof Error ? error.message : '未知错误'}` }
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return { action: null, error: '动作必须是 JSON 对象' }
|
||||
}
|
||||
const record = parsed as Record<string, unknown>
|
||||
if (record.type === 'tool') {
|
||||
if (typeof record.tool !== 'string' || !record.tool.trim()) {
|
||||
return { action: null, error: 'tool 动作缺少 tool 字段' }
|
||||
}
|
||||
return {
|
||||
action: {
|
||||
type: 'tool',
|
||||
tool: record.tool.trim(),
|
||||
arguments:
|
||||
typeof record.arguments === 'object' && record.arguments !== null
|
||||
? record.arguments as Record<string, unknown>
|
||||
: {},
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
if (record.type === 'final') {
|
||||
const estimate = parseBomCostingAgentEstimate(record.estimate)
|
||||
if (!estimate) {
|
||||
return { action: null, error: 'final 动作的 estimate 未通过契约校验(confidence/区间/行格式)' }
|
||||
}
|
||||
return { action: { type: 'final', estimate }, error: null }
|
||||
}
|
||||
return { action: null, error: '动作 type 只能是 tool 或 final' }
|
||||
}
|
||||
|
||||
// 总额由代码从行汇总,模型只给行;缺价行计入 unresolved,不参与总额。
|
||||
export function summarizeBomCostingAgentEstimate(
|
||||
estimate: BomCostingAgentEstimate,
|
||||
): BomCostingAgentCategoryTotals {
|
||||
const totals: BomCostingAgentCategoryTotals = { unresolvedLineCount: 0 }
|
||||
for (const line of estimate.lines) {
|
||||
if (line.unitPrice === null) {
|
||||
totals.unresolvedLineCount += 1
|
||||
continue
|
||||
}
|
||||
const subtotal = line.unitPrice * line.quantity
|
||||
totals[line.category] = roundMoney((totals[line.category] ?? 0) + subtotal)
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
function roundMoney(value: number) {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
import {
|
||||
callBomCostingMultimodalModel,
|
||||
getBomCostingMultimodalProviderConfig,
|
||||
type BomCostingMultimodalProviderConfig,
|
||||
} from '../bom-costing-multimodal-gateway'
|
||||
import { createGatewayBomCostingAgentModelCaller } from './agent-model-caller'
|
||||
|
||||
jest.mock('../bom-costing-multimodal-gateway', () => ({
|
||||
callBomCostingMultimodalModel: jest.fn(),
|
||||
getBomCostingMultimodalProviderConfig: jest.fn(),
|
||||
}))
|
||||
|
||||
const callMock = jest.mocked(callBomCostingMultimodalModel)
|
||||
const resolveConfigMock = jest.mocked(getBomCostingMultimodalProviderConfig)
|
||||
|
||||
const fixtureConfig: BomCostingMultimodalProviderConfig = {
|
||||
baseUrl: 'https://model.internal/v1',
|
||||
apiKey: 'test-key',
|
||||
model: 'test-model',
|
||||
}
|
||||
|
||||
function successResult(content: string) {
|
||||
return {
|
||||
id: null,
|
||||
model: 'test-model',
|
||||
content,
|
||||
promptTokens: null,
|
||||
completionTokens: null,
|
||||
durationMs: 10,
|
||||
finishReason: null,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('createGatewayBomCostingAgentModelCaller', () => {
|
||||
it('带图片的轮次按 IMAGE_FACTS 调用,纯文本轮次按 SYNTHESIS 调用', async () => {
|
||||
callMock.mockResolvedValue(successResult('{"type":"final","estimate":{}}'))
|
||||
const caller = createGatewayBomCostingAgentModelCaller({ config: fixtureConfig })
|
||||
await caller({
|
||||
systemPrompt: 's',
|
||||
userContent: [
|
||||
{ type: 'text', text: '商品资料' },
|
||||
{ type: 'image_url', image_url: { url: 'https://img' } },
|
||||
],
|
||||
})
|
||||
await caller({ systemPrompt: 's', userContent: [{ type: 'text', text: '工具结果…' }] })
|
||||
|
||||
expect(callMock).toHaveBeenCalledTimes(2)
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ operation: 'IMAGE_FACTS' }))
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ operation: 'SYNTHESIS' }))
|
||||
})
|
||||
|
||||
it('透传 systemPrompt/userContent,并带默认 maxTokens 与显式超时', async () => {
|
||||
callMock.mockResolvedValue(successResult('{}'))
|
||||
const caller = createGatewayBomCostingAgentModelCaller({ config: fixtureConfig })
|
||||
const result = await caller({ systemPrompt: 'sys', userContent: 'plain', timeoutMs: 5_000 })
|
||||
expect(callMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
config: fixtureConfig,
|
||||
systemPrompt: 'sys',
|
||||
userContent: 'plain',
|
||||
maxTokens: 4096,
|
||||
timeoutMs: 5_000,
|
||||
}))
|
||||
expect(result).toEqual({ content: '{}', durationMs: 10 })
|
||||
})
|
||||
|
||||
it('未显式给 config 时惰性解析,服务未配置则抛错', async () => {
|
||||
resolveConfigMock.mockResolvedValue(null)
|
||||
const caller = createGatewayBomCostingAgentModelCaller()
|
||||
await expect(caller({ systemPrompt: 's', userContent: 't' }))
|
||||
.rejects.toThrow('多模态分析服务未配置')
|
||||
|
||||
resolveConfigMock.mockResolvedValue(fixtureConfig)
|
||||
callMock.mockResolvedValue(successResult('{}'))
|
||||
await caller({ systemPrompt: 's', userContent: 't' })
|
||||
expect(resolveConfigMock).toHaveBeenCalledTimes(2)
|
||||
expect(callMock).toHaveBeenCalledWith(expect.objectContaining({ config: fixtureConfig }))
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,39 @@
|
||||
import {
|
||||
callBomCostingMultimodalModel,
|
||||
getBomCostingMultimodalProviderConfig,
|
||||
type BomCostingMultimodalProviderConfig,
|
||||
type BomCostingMultimodalUserContent,
|
||||
} from '../bom-costing-multimodal-gateway'
|
||||
import type { BomCostingAgentModelCaller } from './bom-costing-agent-loop'
|
||||
|
||||
export const BOM_COSTING_AGENT_MAX_TOKENS = 4096
|
||||
export const BOM_COSTING_AGENT_TIMEOUT_MS = 120_000
|
||||
|
||||
function hasImageContent(userContent: BomCostingMultimodalUserContent) {
|
||||
return Array.isArray(userContent) && userContent.some((part) => part.type === 'image_url')
|
||||
}
|
||||
|
||||
// agent 每轮输出是紧凑 JSON 动作;带图片的轮次按 IMAGE_FACTS 语义调用,
|
||||
// 纯文本(工具观察回填后的续轮)按 SYNTHESIS 语义调用,复用网关的
|
||||
// 推理力度与重试策略。
|
||||
export function createGatewayBomCostingAgentModelCaller(input?: {
|
||||
config?: BomCostingMultimodalProviderConfig
|
||||
maxTokens?: number
|
||||
defaultTimeoutMs?: number
|
||||
}): BomCostingAgentModelCaller {
|
||||
return async ({ systemPrompt, userContent, timeoutMs }) => {
|
||||
const config = input?.config ?? await getBomCostingMultimodalProviderConfig()
|
||||
if (!config) {
|
||||
throw new Error('多模态分析服务未配置,无法运行 agent 估算')
|
||||
}
|
||||
const result = await callBomCostingMultimodalModel({
|
||||
config,
|
||||
operation: hasImageContent(userContent) ? 'IMAGE_FACTS' : 'SYNTHESIS',
|
||||
systemPrompt,
|
||||
userContent,
|
||||
maxTokens: input?.maxTokens ?? BOM_COSTING_AGENT_MAX_TOKENS,
|
||||
timeoutMs: timeoutMs ?? input?.defaultTimeoutMs ?? BOM_COSTING_AGENT_TIMEOUT_MS,
|
||||
})
|
||||
return { content: result.content, durationMs: result.durationMs }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import { getBomCostingCategorySkill } from '../category-skills'
|
||||
import { renderBomCostingAgentSkillKnowledge, renderBomCostingAgentSkillKnowledgeByCode } from './agent-skill-knowledge'
|
||||
|
||||
describe('renderBomCostingAgentSkillKnowledge', () => {
|
||||
it('渲染真实 registry skill 的完整知识块', () => {
|
||||
const skill = getBomCostingCategorySkill('CABINET_BOOKSHELF')
|
||||
expect(skill).not.toBeNull()
|
||||
const knowledge = renderBomCostingAgentSkillKnowledge(skill!)
|
||||
|
||||
expect(knowledge).toContain('【品类】')
|
||||
expect(knowledge).toContain(skill!.label)
|
||||
expect(knowledge).toContain(`Skill ${skill!.version}`)
|
||||
expect(knowledge).toContain('【识别信号】')
|
||||
expect(knowledge).toContain('【必备组件】')
|
||||
expect(skill!.requiredComponents.length).toBeGreaterThan(0)
|
||||
const firstRequired = skill!.requiredComponents[0]!
|
||||
expect(knowledge).toContain(`${firstRequired.partName}×${firstRequired.stableQuantity}`)
|
||||
if (skill!.optionalComponents.length > 0) {
|
||||
expect(knowledge).toContain('【可选组件】')
|
||||
}
|
||||
expect(knowledge).toContain('【校验规则】')
|
||||
})
|
||||
|
||||
it('必备组件行包含数量与尺寸规则的关键信息', () => {
|
||||
const skill = getBomCostingCategorySkill('WARDROBE')
|
||||
expect(skill).not.toBeNull()
|
||||
const knowledge = renderBomCostingAgentSkillKnowledge(skill!)
|
||||
const withDimensionRule = skill!.requiredComponents.find((component) => component.dimensionRule)
|
||||
if (withDimensionRule) {
|
||||
expect(knowledge).toContain(withDimensionRule.dimensionRule)
|
||||
}
|
||||
const withMaterialScope = skill!.requiredComponents.find((component) => component.materialScope)
|
||||
if (withMaterialScope) {
|
||||
expect(knowledge).toContain(withMaterialScope.materialScope)
|
||||
}
|
||||
})
|
||||
|
||||
it('结构差异只在存在时渲染', () => {
|
||||
const skill = getBomCostingCategorySkill('STORAGE_RACK')
|
||||
expect(skill).not.toBeNull()
|
||||
const knowledge = renderBomCostingAgentSkillKnowledge(skill!)
|
||||
if (skill!.differences.some((difference) => difference.groupKind === 'STRUCTURE')) {
|
||||
expect(knowledge).toContain('【结构差异】')
|
||||
} else {
|
||||
expect(knowledge).not.toContain('【结构差异】')
|
||||
}
|
||||
})
|
||||
|
||||
it('未知品类编码返回 null', () => {
|
||||
expect(renderBomCostingAgentSkillKnowledgeByCode('NOT_A_SKILL')).toBeNull()
|
||||
expect(renderBomCostingAgentSkillKnowledgeByCode(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('按编码渲染与直接传入 skill 等价', () => {
|
||||
const skill = getBomCostingCategorySkill('TABLE')
|
||||
expect(renderBomCostingAgentSkillKnowledgeByCode('TABLE'))
|
||||
.toBe(renderBomCostingAgentSkillKnowledge(skill!))
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,76 @@
|
||||
import {
|
||||
getBomCostingCategorySkill,
|
||||
type BomCostingCategorySkill,
|
||||
} from '../category-skills'
|
||||
|
||||
function renderComponent(skill: BomCostingCategorySkill['requiredComponents'][number]) {
|
||||
const parts = [
|
||||
`- ${skill.partName}×${skill.stableQuantity}${skill.unit ? `(${skill.unit})` : ''}`,
|
||||
]
|
||||
if (skill.quantityRule) parts.push(`数量规则:${skill.quantityRule}`)
|
||||
if (skill.dimensionRule) parts.push(`尺寸规则:${skill.dimensionRule}`)
|
||||
if (skill.materialScope) parts.push(`材质:${skill.materialScope}`)
|
||||
if (skill.primaryMaterialSlot) parts.push('继承主材质槽位')
|
||||
return ` ${parts.join(';')}`
|
||||
}
|
||||
|
||||
export function renderBomCostingAgentSkillKnowledge(skill: BomCostingCategorySkill): string {
|
||||
const sections: string[] = []
|
||||
sections.push(`【品类】${skill.label}(${skill.code},Skill ${skill.version})`)
|
||||
|
||||
const positiveTerms = skill.positiveSignals
|
||||
.flatMap((signal) => signal.terms.slice(0, 4))
|
||||
.slice(0, 16)
|
||||
const exclusionTerms = skill.exclusionSignals
|
||||
.flatMap((signal) => signal.terms.slice(0, 3))
|
||||
.slice(0, 10)
|
||||
sections.push(`【识别信号】命中示例:${positiveTerms.join('、') || '无'}`)
|
||||
if (exclusionTerms.length > 0) {
|
||||
sections.push(`【排除信号】出现即不应判为本品类:${exclusionTerms.join('、')}`)
|
||||
}
|
||||
|
||||
if (skill.requiredComponents.length > 0) {
|
||||
sections.push(
|
||||
['【必备组件】(缺任一项需在 openQuestions 说明)']
|
||||
.concat(skill.requiredComponents.map(renderComponent))
|
||||
.join('\n'),
|
||||
)
|
||||
}
|
||||
if (skill.optionalComponents.length > 0) {
|
||||
const optionalLines = skill.optionalComponents.map((component) => {
|
||||
const rendered = renderComponent(component).trim()
|
||||
const triggers = component.keywords.slice(0, 4).join('、')
|
||||
return ` ${rendered}${triggers ? `;激活线索:${triggers}` : ''}`
|
||||
})
|
||||
sections.push(['【可选组件】(必须有可见证据或激活线索才计入)'].concat(optionalLines).join('\n'))
|
||||
}
|
||||
|
||||
const structureDifferences = skill.differences
|
||||
.filter((difference) => difference.groupKind === 'STRUCTURE')
|
||||
.slice(0, 12)
|
||||
if (structureDifferences.length > 0) {
|
||||
sections.push(
|
||||
['【结构差异】(改拓扑的变体,按商品文字/图片确认)']
|
||||
.concat(structureDifferences.map((difference) =>
|
||||
` ${difference.description}${difference.terms.length > 0 ? `(线索:${difference.terms.slice(0, 4).join('、')})` : ''}`,
|
||||
))
|
||||
.join('\n'),
|
||||
)
|
||||
}
|
||||
|
||||
if (skill.validations.length > 0) {
|
||||
sections.push(
|
||||
['【校验规则】(final 前自查)']
|
||||
.concat(skill.validations.map((validation) =>
|
||||
` [${validation.severity}] ${validation.rule}`,
|
||||
))
|
||||
.join('\n'),
|
||||
)
|
||||
}
|
||||
return sections.join('\n\n')
|
||||
}
|
||||
|
||||
export function renderBomCostingAgentSkillKnowledgeByCode(code: string | null | undefined) {
|
||||
const skill = getBomCostingCategorySkill(code)
|
||||
return skill ? renderBomCostingAgentSkillKnowledge(skill) : null
|
||||
}
|
||||
@ -0,0 +1,117 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { bomCostingAgentRepository } from '@/server/repositories/bom-costing-agent.repository'
|
||||
import { bomCosting1688ImageSearchService } from '../bom-costing-1688-image-search'
|
||||
import { createDefaultBomCostingAgentToolDeps } from './agent-tool-providers'
|
||||
|
||||
jest.mock('@/server/repositories/bom-costing-agent.repository', () => ({
|
||||
bomCostingAgentRepository: {
|
||||
listArchivedAssetsForCase: jest.fn(),
|
||||
searchReferenceRows: jest.fn(),
|
||||
listEnabledMaterialOptions: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('../bom-costing-1688-image-search', () => ({
|
||||
bomCosting1688ImageSearchService: {
|
||||
findRealtimeQuote: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const repositoryMock = jest.mocked(bomCostingAgentRepository)
|
||||
const realtimeMock = jest.mocked(bomCosting1688ImageSearchService)
|
||||
|
||||
describe('createDefaultBomCostingAgentToolDeps', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('listArchivedImages 映射归档素材,存储地址优先于原始地址并带 SKU 标签', async () => {
|
||||
repositoryMock.listArchivedAssetsForCase.mockResolvedValue([
|
||||
{ id: 'a1', kind: 'MAIN' as const, status: 'STORED' as const, originalUrl: 'https://expiring/original', storedUrl: 'https://oss/stored', externalSkuIds: ['SKU-1'], sortOrder: 0 },
|
||||
{ id: 'a2', kind: 'DETAIL' as const, status: 'STORED' as const, originalUrl: 'https://expiring/only', storedUrl: '', externalSkuIds: [], sortOrder: 1 },
|
||||
])
|
||||
const deps = createDefaultBomCostingAgentToolDeps()
|
||||
const images = await deps.listArchivedImages({ caseId: 'case-1' })
|
||||
expect(repositoryMock.listArchivedAssetsForCase).toHaveBeenCalledWith('case-1')
|
||||
expect(images).toEqual([
|
||||
{ assetId: 'a1', kind: 'MAIN', url: 'https://oss/stored', status: 'STORED', skuTags: ['SKU-1'] },
|
||||
{ assetId: 'a2', kind: 'DETAIL', url: 'https://expiring/only', status: 'STORED', skuTags: [] },
|
||||
])
|
||||
})
|
||||
|
||||
it('searchReferencePrices 把 Decimal 映射为数字并保留 null 金额', async () => {
|
||||
repositoryMock.searchReferenceRows.mockResolvedValue([
|
||||
{
|
||||
sourceRowId: 'row-1',
|
||||
sourceSku: 'SKU-A',
|
||||
category: '板材',
|
||||
categoryCode: '01',
|
||||
partName: '侧板',
|
||||
materialName: '颗粒板 18mm',
|
||||
unit: '块',
|
||||
unitPrice: new Prisma.Decimal('25.50'),
|
||||
amount: new Prisma.Decimal('51.00'),
|
||||
},
|
||||
{
|
||||
sourceRowId: 'row-2',
|
||||
sourceSku: 'SKU-B',
|
||||
category: '五金',
|
||||
categoryCode: '02',
|
||||
partName: '三合一连接件',
|
||||
materialName: '锌合金',
|
||||
unit: '个',
|
||||
unitPrice: new Prisma.Decimal('0.4'),
|
||||
amount: null,
|
||||
},
|
||||
])
|
||||
const deps = createDefaultBomCostingAgentToolDeps()
|
||||
const candidates = await deps.searchReferencePrices({ category: 'BOARD', materialKeywords: ['颗粒板'] }, { caseId: 'c' })
|
||||
expect(repositoryMock.searchReferenceRows).toHaveBeenCalledWith({
|
||||
category: 'BOARD',
|
||||
materialKeywords: ['颗粒板'],
|
||||
limit: undefined,
|
||||
})
|
||||
expect(candidates).toEqual([
|
||||
{ referenceSku: 'SKU-A', partName: '侧板', materialName: '颗粒板 18mm', unit: '块', unitPrice: 25.5, amount: 51, sourceRowId: 'row-1' },
|
||||
{ referenceSku: 'SKU-B', partName: '三合一连接件', materialName: '锌合金', unit: '个', unitPrice: 0.4, amount: null, sourceRowId: 'row-2' },
|
||||
])
|
||||
})
|
||||
|
||||
it('listMaterialOptions 过滤掉非字符串别名', async () => {
|
||||
repositoryMock.listEnabledMaterialOptions.mockResolvedValue([
|
||||
{ id: 'm1', code: 'PB18', name: '颗粒板 18mm', category: 'BOARD', aliases: ['刨花板', '', 42] },
|
||||
])
|
||||
const deps = createDefaultBomCostingAgentToolDeps()
|
||||
const options = await deps.listMaterialOptions({ caseId: 'c' })
|
||||
expect(options).toEqual([{ id: 'm1', name: '颗粒板 18mm', aliases: ['刨花板'] }])
|
||||
})
|
||||
|
||||
it('searchRealtimeMarket 只透出摘要字段,warnings 与 offers 不进转录', async () => {
|
||||
realtimeMock.findRealtimeQuote.mockResolvedValue({
|
||||
schemaVersion: 'bom-costing-1688-realtime/v1',
|
||||
source: '1688_IMAGE_SEARCH',
|
||||
status: 'AVAILABLE',
|
||||
sourceImageUrl: 'https://img',
|
||||
searchedAt: '2026-08-21T00:00:00.000Z',
|
||||
procurementRate: 0.9,
|
||||
marketPrice: 100,
|
||||
procurementCost: 90,
|
||||
estimateRange: { min: 85, max: 95 },
|
||||
confidence: 0.8,
|
||||
comparableOfferCount: 5,
|
||||
returnedOfferCount: 8,
|
||||
warnings: ['1688 实时同款价格中位数 ¥100'],
|
||||
offers: [{ supplier: '某店铺' }],
|
||||
} as never)
|
||||
const deps = createDefaultBomCostingAgentToolDeps()
|
||||
const summary = await deps.searchRealtimeMarket({ imageUrl: 'https://img' }, { caseId: 'c' })
|
||||
expect(realtimeMock.findRealtimeQuote).toHaveBeenCalledWith({ imageUrl: 'https://img' })
|
||||
expect(summary).toEqual({
|
||||
status: 'AVAILABLE',
|
||||
procurementCost: 90,
|
||||
estimateRange: { min: 85, max: 95 },
|
||||
comparableOfferCount: 5,
|
||||
})
|
||||
expect(Object.keys(summary as Record<string, unknown>)).not.toContain('warnings')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,56 @@
|
||||
import { bomCostingAgentRepository } from '@/server/repositories/bom-costing-agent.repository'
|
||||
import { bomCosting1688ImageSearchService } from '../bom-costing-1688-image-search'
|
||||
import type { BomCostingAgentToolDeps } from './agent-tools'
|
||||
|
||||
// 工具接线层:repository 只出原始行,这里负责映射成 agent 可消费的摘要。
|
||||
// 实时行情工具刻意只透出状态/采购参考价/区间/可比数,warnings 与 offers
|
||||
// 留在服务端内部,避免进入 agent 转录后流向前端。
|
||||
export function createDefaultBomCostingAgentToolDeps(): BomCostingAgentToolDeps {
|
||||
return {
|
||||
listArchivedImages: async (context) => {
|
||||
const assets = await bomCostingAgentRepository.listArchivedAssetsForCase(context.caseId)
|
||||
return assets.map((asset) => ({
|
||||
assetId: asset.id,
|
||||
kind: asset.kind,
|
||||
url: asset.storedUrl || asset.originalUrl,
|
||||
status: asset.status,
|
||||
skuTags: Array.isArray(asset.externalSkuIds)
|
||||
? (asset.externalSkuIds as unknown[]).filter((item): item is string =>
|
||||
typeof item === 'string' && item.trim() !== '')
|
||||
: [],
|
||||
}))
|
||||
},
|
||||
searchReferencePrices: async (input) => {
|
||||
const rows = await bomCostingAgentRepository.searchReferenceRows(input)
|
||||
return rows.map((row) => ({
|
||||
referenceSku: row.sourceSku,
|
||||
partName: row.partName,
|
||||
materialName: row.materialName,
|
||||
unit: row.unit,
|
||||
unitPrice: Number(row.unitPrice),
|
||||
amount: row.amount === null ? null : Number(row.amount),
|
||||
sourceRowId: row.sourceRowId,
|
||||
}))
|
||||
},
|
||||
listMaterialOptions: async () => {
|
||||
const options = await bomCostingAgentRepository.listEnabledMaterialOptions()
|
||||
return options.map((option) => ({
|
||||
id: option.id,
|
||||
name: option.name,
|
||||
aliases: Array.isArray(option.aliases)
|
||||
? (option.aliases as unknown[]).filter((item): item is string =>
|
||||
typeof item === 'string' && item.trim() !== '')
|
||||
: [],
|
||||
}))
|
||||
},
|
||||
searchRealtimeMarket: async ({ imageUrl }) => {
|
||||
const quote = await bomCosting1688ImageSearchService.findRealtimeQuote({ imageUrl })
|
||||
return {
|
||||
status: quote.status,
|
||||
procurementCost: quote.procurementCost,
|
||||
estimateRange: quote.estimateRange,
|
||||
comparableOfferCount: quote.comparableOfferCount,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,185 @@
|
||||
import {
|
||||
calculateBoardBatchCuttingPlan,
|
||||
type BoardBatchCuttingPlan,
|
||||
type BoardCuttingPartInput,
|
||||
type BoardSheetInput,
|
||||
} from '@/lib/bom-costing/board-sheet-cutting'
|
||||
|
||||
export interface BomCostingAgentToolContext {
|
||||
caseId: string
|
||||
skuId?: string
|
||||
}
|
||||
|
||||
export interface BomCostingAgentToolRuntime {
|
||||
name: string
|
||||
description: string
|
||||
execute: (input: unknown, context: BomCostingAgentToolContext) => Promise<unknown>
|
||||
}
|
||||
|
||||
export interface BomCostingAgentArchivedImage {
|
||||
assetId: string
|
||||
kind: 'MAIN' | 'SKU' | 'DETAIL'
|
||||
url: string
|
||||
status: string
|
||||
skuTags: string[]
|
||||
}
|
||||
|
||||
export interface BomCostingAgentReferenceCandidate {
|
||||
referenceSku: string
|
||||
partName: string
|
||||
materialName: string
|
||||
unit: string
|
||||
unitPrice: number
|
||||
amount: number | null
|
||||
sourceRowId: string
|
||||
}
|
||||
|
||||
export interface BomCostingAgentMaterialOption {
|
||||
id: string
|
||||
name: string
|
||||
aliases: string[]
|
||||
}
|
||||
|
||||
export interface BomCostingAgentRealtimeQuoteSummary {
|
||||
status: 'AVAILABLE' | 'NO_COMPARABLE_OFFER' | 'NOT_CONFIGURED' | 'IMAGE_UNAVAILABLE' | 'FAILED'
|
||||
procurementCost: number | null
|
||||
estimateRange: { min: number; max: number } | null
|
||||
comparableOfferCount: number
|
||||
}
|
||||
|
||||
export interface BomCostingAgentToolDeps {
|
||||
listArchivedImages: (context: BomCostingAgentToolContext) => Promise<BomCostingAgentArchivedImage[]>
|
||||
searchReferencePrices: (
|
||||
input: { category?: string; materialKeywords?: string[]; limit?: number },
|
||||
context: BomCostingAgentToolContext,
|
||||
) => Promise<BomCostingAgentReferenceCandidate[]>
|
||||
listMaterialOptions: (context: BomCostingAgentToolContext) => Promise<BomCostingAgentMaterialOption[]>
|
||||
searchRealtimeMarket: (
|
||||
input: { imageUrl: string },
|
||||
context: BomCostingAgentToolContext,
|
||||
) => Promise<BomCostingAgentRealtimeQuoteSummary>
|
||||
}
|
||||
|
||||
interface BoardSheetEstimateInput {
|
||||
sheet?: {
|
||||
substrateId?: unknown
|
||||
materialName?: unknown
|
||||
sheetLengthMm?: unknown
|
||||
sheetWidthMm?: unknown
|
||||
fullSheetPrice?: unknown
|
||||
}
|
||||
panels?: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
function positiveNumber(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
|
||||
}
|
||||
|
||||
function parseBoardSheetEstimateInput(input: unknown): {
|
||||
sheet: BoardSheetInput
|
||||
panels: BoardCuttingPartInput[]
|
||||
} | null {
|
||||
if (typeof input !== 'object' || input === null) return null
|
||||
const record = input as BoardSheetEstimateInput
|
||||
const sheetRecord = record.sheet
|
||||
if (typeof sheetRecord !== 'object' || sheetRecord === null) return null
|
||||
const sheetLengthMm = positiveNumber(sheetRecord.sheetLengthMm)
|
||||
const sheetWidthMm = positiveNumber(sheetRecord.sheetWidthMm)
|
||||
const fullSheetPrice = positiveNumber(sheetRecord.fullSheetPrice)
|
||||
if (sheetLengthMm === null || sheetWidthMm === null || fullSheetPrice === null) return null
|
||||
const panels = Array.isArray(record.panels)
|
||||
? record.panels.flatMap((panel, index) => {
|
||||
const lengthMm = positiveNumber(panel.lengthMm)
|
||||
const widthMm = positiveNumber(panel.widthMm)
|
||||
const quantity = positiveNumber(panel.quantity)
|
||||
if (lengthMm === null || widthMm === null || quantity === null) return []
|
||||
return [{
|
||||
lineId: typeof panel.lineId === 'string' && panel.lineId ? panel.lineId : `panel-${index + 1}`,
|
||||
lengthMm,
|
||||
widthMm,
|
||||
quantity,
|
||||
}]
|
||||
})
|
||||
: []
|
||||
if (panels.length === 0) return null
|
||||
return {
|
||||
sheet: {
|
||||
substrateId: typeof sheetRecord.substrateId === 'string' ? sheetRecord.substrateId : '',
|
||||
materialName: typeof sheetRecord.materialName === 'string' ? sheetRecord.materialName : '',
|
||||
sheetLengthMm,
|
||||
sheetWidthMm,
|
||||
fullSheetPrice,
|
||||
},
|
||||
panels,
|
||||
}
|
||||
}
|
||||
|
||||
export function createEstimateBoardSheetsTool(): BomCostingAgentToolRuntime {
|
||||
return {
|
||||
name: 'estimate_board_sheets',
|
||||
description:
|
||||
'按整张开料估算板材成本。输入 sheet(板材长宽 mm 与整张价格)与 panels(每块板件长宽 mm 与数量),' +
|
||||
'返回张数、利用率、每行分摊成本。用于 BOARD 类行的定价与交叉验证。',
|
||||
execute: async (input) => {
|
||||
const parsed = parseBoardSheetEstimateInput(input)
|
||||
if (!parsed) {
|
||||
return { error: '输入不合法:需要 sheet{sheetLengthMm,sheetWidthMm,fullSheetPrice} 与 panels[{lengthMm,widthMm,quantity}]' }
|
||||
}
|
||||
const plan: BoardBatchCuttingPlan | null = calculateBoardBatchCuttingPlan(parsed.sheet, parsed.panels)
|
||||
if (!plan) return { error: '开料计算未返回有效方案(板件过多或尺寸超出安全上限)' }
|
||||
return {
|
||||
rulesetVersion: plan.rulesetVersion,
|
||||
selectedBatchSize: plan.selectedBatchSize,
|
||||
sheetCount: plan.selectedBatch.sheetCount,
|
||||
totalSheetCost: plan.selectedBatch.totalSheetCost,
|
||||
perUnitSheetCost: plan.selectedBatch.perUnitSheetCost,
|
||||
utilization: plan.selectedBatch.utilization,
|
||||
allocations: plan.selectedBatch.allocations,
|
||||
warnings: plan.warnings,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createBomCostingAgentTools(deps: BomCostingAgentToolDeps) {
|
||||
return [
|
||||
{
|
||||
name: 'list_archived_images',
|
||||
description: '列出本案例已归档的商品图片(主图/SKU 图/详情图)与对象存储访问地址。',
|
||||
execute: (_input: unknown, context: BomCostingAgentToolContext) => deps.listArchivedImages(context),
|
||||
},
|
||||
{
|
||||
name: 'search_reference_prices',
|
||||
description:
|
||||
'按分类或材质关键词检索公司历史 BOM 价格候选(来源为本地镜像)。输入 {category?, materialKeywords?, limit?}。',
|
||||
execute: (input: unknown, context: BomCostingAgentToolContext) => {
|
||||
const record = typeof input === 'object' && input !== null ? input as Record<string, unknown> : {}
|
||||
return deps.searchReferencePrices({
|
||||
category: typeof record.category === 'string' ? record.category : undefined,
|
||||
materialKeywords: Array.isArray(record.materialKeywords)
|
||||
? record.materialKeywords.filter((item): item is string => typeof item === 'string')
|
||||
: undefined,
|
||||
limit: typeof record.limit === 'number' ? record.limit : undefined,
|
||||
}, context)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_material_options',
|
||||
description: '列出公司启用材质目录(材质身份的唯一来源,含别名)。',
|
||||
execute: (_input: unknown, context: BomCostingAgentToolContext) => deps.listMaterialOptions(context),
|
||||
},
|
||||
{
|
||||
name: 'search_realtime_market',
|
||||
description:
|
||||
'按商品图检索实时市场同款行情摘要(状态、整件采购参考价、区间、可比报价数)。仅作为交叉验证信号,不能替代逐行定价。',
|
||||
execute: (input: unknown, context: BomCostingAgentToolContext) => {
|
||||
const record = typeof input === 'object' && input !== null ? input as Record<string, unknown> : {}
|
||||
if (typeof record.imageUrl !== 'string' || !record.imageUrl) {
|
||||
return Promise.resolve({ error: '输入不合法:需要 imageUrl' })
|
||||
}
|
||||
return deps.searchRealtimeMarket({ imageUrl: record.imageUrl }, context)
|
||||
},
|
||||
},
|
||||
createEstimateBoardSheetsTool(),
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,270 @@
|
||||
import {
|
||||
parseBomCostingAgentAction,
|
||||
summarizeBomCostingAgentEstimate,
|
||||
type BomCostingAgentEstimate,
|
||||
} from './agent-contracts'
|
||||
import { createBomCostingAgentTools, createEstimateBoardSheetsTool } from './agent-tools'
|
||||
import { runBomCostingAgentLoop, type BomCostingAgentModelCaller } from './bom-costing-agent-loop'
|
||||
|
||||
function validEstimate(overrides: Partial<BomCostingAgentEstimate> = {}): BomCostingAgentEstimate {
|
||||
return {
|
||||
structureSummary: '三块侧板 + 底板 + 背板的简易柜体',
|
||||
lines: [
|
||||
{
|
||||
lineKey: 'board-side',
|
||||
category: 'BOARD',
|
||||
partName: '侧板',
|
||||
material: '颗粒板 18mm',
|
||||
quantity: 2,
|
||||
unit: '块',
|
||||
unitPrice: 25,
|
||||
evidence: ['search_reference_prices 结果第 1 条'],
|
||||
},
|
||||
{
|
||||
lineKey: 'hardware-cam',
|
||||
category: 'ACCESSORY',
|
||||
partName: '三合一连接件',
|
||||
material: null,
|
||||
quantity: 12,
|
||||
unit: '个',
|
||||
unitPrice: 0.4,
|
||||
evidence: ['search_reference_prices 结果第 3 条'],
|
||||
},
|
||||
{
|
||||
lineKey: 'labor-tbd',
|
||||
category: 'LABOR',
|
||||
partName: '组装人工',
|
||||
material: null,
|
||||
quantity: 1,
|
||||
unit: '套',
|
||||
unitPrice: null,
|
||||
evidence: ['无价格依据,待补'],
|
||||
},
|
||||
],
|
||||
confidence: 0.55,
|
||||
estimateRange: { min: 55, max: 70 },
|
||||
openQuestions: ['板厚未能从图片确认'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseBomCostingAgentAction', () => {
|
||||
it('解析带 ```json 围栏的工具动作', () => {
|
||||
const { action, error } = parseBomCostingAgentAction(
|
||||
'说明文字\n```json\n{"type":"tool","tool":"search_reference_prices","arguments":{"category":"BOARD"}}\n```',
|
||||
)
|
||||
expect(error).toBeNull()
|
||||
expect(action).toEqual({
|
||||
type: 'tool',
|
||||
tool: 'search_reference_prices',
|
||||
arguments: { category: 'BOARD' },
|
||||
})
|
||||
})
|
||||
|
||||
it('解析 final 动作并校验 estimate 契约', () => {
|
||||
const { action } = parseBomCostingAgentAction(
|
||||
JSON.stringify({ type: 'final', estimate: validEstimate() }),
|
||||
)
|
||||
expect(action?.type).toBe('final')
|
||||
expect(action?.type === 'final' && action.estimate.lines).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('拒绝缺 confidence 或区间颠倒的 estimate', () => {
|
||||
expect(parseBomCostingAgentAction(
|
||||
JSON.stringify({ type: 'final', estimate: { ...validEstimate(), confidence: 1.5 } }),
|
||||
).action).toBeNull()
|
||||
expect(parseBomCostingAgentAction(
|
||||
JSON.stringify({ type: 'final', estimate: { ...validEstimate(), estimateRange: { min: 90, max: 20 } } }),
|
||||
).action).toBeNull()
|
||||
})
|
||||
|
||||
it('拒绝非 JSON 输出与未知 type', () => {
|
||||
expect(parseBomCostingAgentAction('我认为成本大概是 60 元').action).toBeNull()
|
||||
expect(parseBomCostingAgentAction('{"type":"thinking"}').action).toBeNull()
|
||||
})
|
||||
|
||||
it('多对象拼接时取第一个完整 JSON 对象', () => {
|
||||
const { action } = parseBomCostingAgentAction(
|
||||
'{"type":"tool","tool":"list_material_options","arguments":{}}{"type":"tool","tool":"list_archived_images","arguments":{}}',
|
||||
)
|
||||
expect(action).toEqual({
|
||||
type: 'tool',
|
||||
tool: 'list_material_options',
|
||||
arguments: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('字符串内的花括号不影响配平提取', () => {
|
||||
const { action } = parseBomCostingAgentAction(
|
||||
'前置说明 {"type":"tool","tool":"search_reference_prices","arguments":{"materialKeywords":["{特殊}字符"]}} 尾部',
|
||||
)
|
||||
expect(action?.type).toBe('tool')
|
||||
expect(action?.type === 'tool' && action.arguments.materialKeywords).toEqual(['{特殊}字符'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeBomCostingAgentEstimate', () => {
|
||||
it('按分类汇总有价行,缺价行只计入未解决数', () => {
|
||||
const totals = summarizeBomCostingAgentEstimate(validEstimate())
|
||||
expect(totals.BOARD).toBe(50)
|
||||
expect(totals.ACCESSORY).toBe(4.8)
|
||||
expect(totals.LABOR).toBeUndefined()
|
||||
expect(totals.unresolvedLineCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
function fakeModelCaller(responses: string[], calls: { userContent: unknown }[] = []): BomCostingAgentModelCaller {
|
||||
let index = 0
|
||||
return async (input) => {
|
||||
calls.push({ userContent: input.userContent })
|
||||
const content = responses[Math.min(index, responses.length - 1)]
|
||||
index += 1
|
||||
return { content: typeof content === 'string' ? content : JSON.stringify(content), durationMs: 5 }
|
||||
}
|
||||
}
|
||||
|
||||
const mission = () => ({
|
||||
mission: { userContent: [{ type: 'text' as const, text: '商品图片与文字资料…' }], toolContext: { caseId: 'case-1', skuId: 'sku-1' } },
|
||||
})
|
||||
|
||||
describe('runBomCostingAgentLoop', () => {
|
||||
it('工具调用后产出 final:完成态、工具收到解析后的参数与上下文、总额由代码汇总', async () => {
|
||||
const executed: Array<{ input: unknown; context: unknown }> = []
|
||||
const tools = createBomCostingAgentTools({
|
||||
listArchivedImages: async () => [],
|
||||
searchReferencePrices: async (query, context) => {
|
||||
executed.push({ input: query, context })
|
||||
return [{
|
||||
referenceSku: 'SKU-1',
|
||||
partName: '侧板',
|
||||
materialName: '颗粒板',
|
||||
unit: '块',
|
||||
unitPrice: 25,
|
||||
amount: 25,
|
||||
sourceRowId: 'row-1',
|
||||
}]
|
||||
},
|
||||
listMaterialOptions: async () => [],
|
||||
searchRealtimeMarket: async () => ({
|
||||
status: 'NOT_CONFIGURED' as const,
|
||||
procurementCost: null,
|
||||
estimateRange: null,
|
||||
comparableOfferCount: 0,
|
||||
}),
|
||||
})
|
||||
const result = await runBomCostingAgentLoop({
|
||||
...mission(),
|
||||
toolRuntimes: tools,
|
||||
callModel: fakeModelCaller([
|
||||
{ type: 'tool', tool: 'search_reference_prices', arguments: { category: 'BOARD' } },
|
||||
{ type: 'final', estimate: validEstimate() },
|
||||
]),
|
||||
})
|
||||
expect(result.status).toBe('COMPLETED')
|
||||
expect(executed).toHaveLength(1)
|
||||
expect(executed[0]!.input).toEqual({ category: 'BOARD', materialKeywords: undefined, limit: undefined })
|
||||
expect(executed[0]!.context).toEqual({ caseId: 'case-1', skuId: 'sku-1' })
|
||||
expect(result.categoryTotals?.BOARD).toBe(50)
|
||||
expect(result.usage.modelCalls).toBe(2)
|
||||
expect(result.trace.map((step) => step.actionType)).toEqual(['tool', 'final'])
|
||||
})
|
||||
|
||||
it('未知工具会以系统提示回填观察,下一轮可继续', async () => {
|
||||
const result = await runBomCostingAgentLoop({
|
||||
...mission(),
|
||||
toolRuntimes: [],
|
||||
callModel: fakeModelCaller([
|
||||
{ type: 'tool', tool: 'not_a_tool', arguments: {} },
|
||||
{ type: 'final', estimate: validEstimate() },
|
||||
]),
|
||||
})
|
||||
expect(result.status).toBe('COMPLETED')
|
||||
expect(result.trace[0]!.actionType).toBe('tool')
|
||||
expect(result.trace[0]!.tool).toBe('not_a_tool')
|
||||
})
|
||||
|
||||
it('连续两轮无法解析则失败', async () => {
|
||||
const result = await runBomCostingAgentLoop({
|
||||
...mission(),
|
||||
toolRuntimes: [],
|
||||
callModel: fakeModelCaller(['我觉得大概 60 元', '还是没法给出 JSON']),
|
||||
})
|
||||
expect(result.status).toBe('FAILED')
|
||||
expect(result.warnings[0]).toContain('无法解析为合法动作')
|
||||
})
|
||||
|
||||
it('预算耗尽返回 BUDGET_EXHAUSTED 并守住轮次上限', async () => {
|
||||
const result = await runBomCostingAgentLoop({
|
||||
mission: {
|
||||
...mission().mission,
|
||||
budget: { maxRounds: 3, maxModelCalls: 3, maxWallClockMs: 60_000 },
|
||||
},
|
||||
toolRuntimes: [],
|
||||
callModel: fakeModelCaller([
|
||||
{ type: 'tool', tool: 'unknown', arguments: {} },
|
||||
{ type: 'tool', tool: 'unknown', arguments: {} },
|
||||
{ type: 'tool', tool: 'unknown', arguments: {} },
|
||||
]),
|
||||
})
|
||||
expect(result.status).toBe('BUDGET_EXHAUSTED')
|
||||
expect(result.usage.rounds).toBe(3)
|
||||
expect(result.usage.modelCalls).toBe(3)
|
||||
})
|
||||
|
||||
it('final 无有效行视为部分结果', async () => {
|
||||
const result = await runBomCostingAgentLoop({
|
||||
...mission(),
|
||||
toolRuntimes: [],
|
||||
callModel: fakeModelCaller([
|
||||
{ type: 'final', estimate: validEstimate({ lines: [] }) },
|
||||
]),
|
||||
})
|
||||
expect(result.status).toBe('PARTIAL')
|
||||
expect(result.categoryTotals?.unresolvedLineCount).toBe(0)
|
||||
})
|
||||
|
||||
it('工具抛错会以错误观察继续而不是中断', async () => {
|
||||
const boom = createEstimateBoardSheetsTool()
|
||||
expect(boom.name).toBe('estimate_board_sheets')
|
||||
const failing = {
|
||||
name: 'failing_tool',
|
||||
description: 'always fails',
|
||||
execute: async () => {
|
||||
throw new Error('依赖不可用')
|
||||
},
|
||||
}
|
||||
const result = await runBomCostingAgentLoop({
|
||||
...mission(),
|
||||
toolRuntimes: [failing],
|
||||
callModel: fakeModelCaller([
|
||||
{ type: 'tool', tool: 'failing_tool', arguments: {} },
|
||||
{ type: 'final', estimate: validEstimate() },
|
||||
]),
|
||||
})
|
||||
expect(result.status).toBe('COMPLETED')
|
||||
expect(result.trace[0]!.toolResultSummary).toContain('依赖不可用')
|
||||
})
|
||||
})
|
||||
|
||||
describe('estimate_board_sheets 工具', () => {
|
||||
it('合法输入返回开料方案摘要', async () => {
|
||||
const tool = createEstimateBoardSheetsTool()
|
||||
const payload = await tool.execute({
|
||||
sheet: { substrateId: 'sub-1', materialName: '颗粒板 18mm', sheetLengthMm: 2440, sheetWidthMm: 1220, fullSheetPrice: 120 },
|
||||
panels: [
|
||||
{ lineId: 'p1', lengthMm: 800, widthMm: 400, quantity: 4 },
|
||||
{ lineId: 'p2', lengthMm: 600, widthMm: 300, quantity: 2 },
|
||||
],
|
||||
}, { caseId: 'c' }) as Record<string, unknown>
|
||||
expect(payload.error).toBeUndefined()
|
||||
expect(typeof payload.sheetCount).toBe('number')
|
||||
expect(typeof payload.perUnitSheetCost).toBe('number')
|
||||
expect(payload.utilization).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('非法输入返回结构化错误而不是抛出', async () => {
|
||||
const tool = createEstimateBoardSheetsTool()
|
||||
const payload = await tool.execute({ sheet: { sheetLengthMm: -1 } }, { caseId: 'c' }) as Record<string, unknown>
|
||||
expect(typeof payload.error).toBe('string')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,253 @@
|
||||
import type { BomCostingMultimodalUserContent } from '../bom-costing-multimodal-gateway'
|
||||
import {
|
||||
DEFAULT_BOM_COSTING_AGENT_BUDGET,
|
||||
parseBomCostingAgentAction,
|
||||
summarizeBomCostingAgentEstimate,
|
||||
type BomCostingAgentBudget,
|
||||
type BomCostingAgentRunResult,
|
||||
type BomCostingAgentStepTrace,
|
||||
} from './agent-contracts'
|
||||
import type { BomCostingAgentToolContext, BomCostingAgentToolRuntime } from './agent-tools'
|
||||
|
||||
export type BomCostingAgentModelCaller = (input: {
|
||||
systemPrompt: string
|
||||
userContent: BomCostingMultimodalUserContent
|
||||
timeoutMs?: number
|
||||
}) => Promise<{ content: string; durationMs: number }>
|
||||
|
||||
const MAX_CONSECUTIVE_PARSE_FAILURES = 2
|
||||
const MAX_MODEL_CALL_ATTEMPTS = 3
|
||||
const MODEL_CALL_RETRY_DELAY_MS = 3_000
|
||||
|
||||
export interface BomCostingAgentMission {
|
||||
userContent: BomCostingMultimodalUserContent
|
||||
toolContext?: BomCostingAgentToolContext
|
||||
budget?: Partial<BomCostingAgentBudget>
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
function renderTranscript(
|
||||
baseContent: BomCostingMultimodalUserContent,
|
||||
observations: string[],
|
||||
): BomCostingMultimodalUserContent {
|
||||
if (observations.length === 0) return baseContent
|
||||
if (typeof baseContent === 'string') {
|
||||
return [baseContent, ...observations].join('\n\n')
|
||||
}
|
||||
return [...baseContent, ...observations.map((text) => ({ type: 'text' as const, text }))]
|
||||
}
|
||||
|
||||
function observationText(tool: string, payload: unknown) {
|
||||
return `[工具结果 ${tool}] ${safeJsonStringify(payload)}`
|
||||
}
|
||||
|
||||
// 网关在流中检测到第一个完整 JSON 就会截断返回,但模型仍可能在一轮里
|
||||
// 拼接多个动作对象;这里检测"首个对象之后是否还有动作对象",用于回填协议提醒。
|
||||
function containsExtraActionObjects(content: string) {
|
||||
const firstEnd = content.indexOf('}')
|
||||
if (firstEnd === -1) return false
|
||||
return /"\s*type"\s*:\s*"(?:tool|final)"/.test(content.slice(firstEnd + 1))
|
||||
}
|
||||
|
||||
function safeJsonStringify(payload: unknown) {
|
||||
try {
|
||||
return JSON.stringify(payload)
|
||||
} catch {
|
||||
return '{"error":"工具结果无法序列化"}'
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBomCostingAgentSystemPrompt(input: {
|
||||
toolRuntimes: BomCostingAgentToolRuntime[]
|
||||
skillKnowledge?: string
|
||||
}) {
|
||||
const toolLines = input.toolRuntimes
|
||||
.map((tool) => `- ${tool.name}: ${tool.description}`)
|
||||
.join('\n')
|
||||
return [
|
||||
'你是家具竞品成本预估 agent。目标:基于已归档的商品图片/文字与工具结果,产出同材质复刻的 BOM 成本“预估”(带区间与置信度),不是精确核算。',
|
||||
'',
|
||||
'工作方式(严格遵守):',
|
||||
'- 每一轮回复只输出一个 JSON 对象,前后不允许有其他文字,也不允许输出多个 JSON 对象;',
|
||||
'- 调用工具:{"type":"tool","tool":"<工具名>","arguments":{...}},然后停下等待下一轮的 [工具结果];',
|
||||
'- 只有在需要的工具结果都已拿到之后,才输出最终结果:{"type":"final","estimate":{...}};禁止在未看到工具结果时就输出 final;',
|
||||
'- search_realtime_market 需要 imageUrl 参数:先用 list_archived_images 取得图片地址再调用它。',
|
||||
'',
|
||||
'estimate 契约(必须满足,否则会被拒绝):',
|
||||
'- structureSummary:字符串,结构拆解的一句话说明',
|
||||
'- lines:数组,每行 {lineKey, category(BOARD|STEEL|ACCESSORY|PACKAGING|PROCESSING|LABOR), partName, material|null, quantity(>0), unit, unitPrice(数字或 null), evidence[]};unitPrice 只能来自工具结果,没有依据就填 null,禁止编造价格',
|
||||
'- evidence:每行至少一条,说明价格或数量来自哪个工具结果/图片',
|
||||
'- confidence:0-1;estimateRange:{min,max} 且 min<=max',
|
||||
'- openQuestions:字符串数组,列出影响精度但无法从现有信息确认的问题',
|
||||
'',
|
||||
'可用工具:',
|
||||
toolLines,
|
||||
'',
|
||||
input.skillKnowledge
|
||||
? `品类知识与结构先验(优先按此推断缺失结构,但可见证据优先于先验):\n${input.skillKnowledge}`
|
||||
: '未注入品类先验:按通用板式家具结构常识推断,并在 openQuestions 中说明置信度折扣。',
|
||||
'',
|
||||
'纪律:先看图和文字,再查价格工具,最后才出 final;工具结果不满足时可以多轮检索;总额不需要你计算(系统按行汇总)。',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export async function runBomCostingAgentLoop(input: {
|
||||
mission: BomCostingAgentMission
|
||||
toolRuntimes: BomCostingAgentToolRuntime[]
|
||||
callModel: BomCostingAgentModelCaller
|
||||
}): Promise<BomCostingAgentRunResult> {
|
||||
const budget: BomCostingAgentBudget = {
|
||||
...DEFAULT_BOM_COSTING_AGENT_BUDGET,
|
||||
...input.mission.budget,
|
||||
}
|
||||
const startedAt = Date.now()
|
||||
const trace: BomCostingAgentStepTrace[] = []
|
||||
const warnings: string[] = []
|
||||
const observations: string[] = []
|
||||
const toolsByName = new Map(input.toolRuntimes.map((tool) => [tool.name, tool]))
|
||||
let rounds = 0
|
||||
let modelCalls = 0
|
||||
let consecutiveParseFailures = 0
|
||||
|
||||
while (true) {
|
||||
const elapsedMs = Date.now() - startedAt
|
||||
if (rounds >= budget.maxRounds || modelCalls >= budget.maxModelCalls || elapsedMs >= budget.maxWallClockMs) {
|
||||
warnings.push(
|
||||
`预算耗尽:rounds=${rounds}/${budget.maxRounds},modelCalls=${modelCalls}/${budget.maxModelCalls},` +
|
||||
`elapsedMs=${Math.round(elapsedMs)}/${budget.maxWallClockMs},未得到 final 结果`,
|
||||
)
|
||||
trace.push({ round: rounds + 1, actionType: 'budget_exhausted' })
|
||||
return {
|
||||
status: 'BUDGET_EXHAUSTED',
|
||||
estimate: null,
|
||||
categoryTotals: null,
|
||||
warnings,
|
||||
trace,
|
||||
usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt },
|
||||
}
|
||||
}
|
||||
if (input.mission.signal?.aborted) {
|
||||
warnings.push('任务被外部中止')
|
||||
return {
|
||||
status: 'FAILED',
|
||||
estimate: null,
|
||||
categoryTotals: null,
|
||||
warnings,
|
||||
trace,
|
||||
usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt },
|
||||
}
|
||||
}
|
||||
|
||||
rounds += 1
|
||||
const callStartedAt = Date.now()
|
||||
let content: string
|
||||
try {
|
||||
let attempt = 0
|
||||
while (true) {
|
||||
attempt += 1
|
||||
try {
|
||||
const result = await input.callModel({
|
||||
systemPrompt: buildBomCostingAgentSystemPrompt({ toolRuntimes: input.toolRuntimes }),
|
||||
userContent: renderTranscript(input.mission.userContent, observations),
|
||||
})
|
||||
content = result.content
|
||||
modelCalls += 1
|
||||
break
|
||||
} catch (error) {
|
||||
// 上游抓取图片超时等瞬时错误常见(服务商跨境拉存储对象),有界重试。
|
||||
if (attempt >= MAX_MODEL_CALL_ATTEMPTS) throw error
|
||||
await new Promise((resolve) => setTimeout(resolve, MODEL_CALL_RETRY_DELAY_MS))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
warnings.push(`模型调用失败:${error instanceof Error ? error.message : '未知错误'}`)
|
||||
return {
|
||||
status: 'FAILED',
|
||||
estimate: null,
|
||||
categoryTotals: null,
|
||||
warnings,
|
||||
trace,
|
||||
usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt },
|
||||
}
|
||||
}
|
||||
const modelCallMs = Date.now() - callStartedAt
|
||||
const { action, error } = parseBomCostingAgentAction(content)
|
||||
|
||||
if (!action) {
|
||||
consecutiveParseFailures += 1
|
||||
trace.push({
|
||||
round: rounds,
|
||||
actionType: 'parse_error',
|
||||
modelCallMs,
|
||||
error: `${error ?? '未知原因'};内容预览:${content.slice(0, 160)}`,
|
||||
})
|
||||
if (consecutiveParseFailures >= MAX_CONSECUTIVE_PARSE_FAILURES) {
|
||||
warnings.push(`连续 ${consecutiveParseFailures} 轮输出无法解析为合法动作,任务终止`)
|
||||
return {
|
||||
status: 'FAILED',
|
||||
estimate: null,
|
||||
categoryTotals: null,
|
||||
warnings,
|
||||
trace,
|
||||
usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt },
|
||||
}
|
||||
}
|
||||
observations.push(
|
||||
`[系统提示] 上一轮输出无法解析(${error ?? '未知原因'})。请只输出一个 JSON 对象:` +
|
||||
'{"type":"tool","tool":...,"arguments":{...}} 或 {"type":"final","estimate":{...}}',
|
||||
)
|
||||
continue
|
||||
}
|
||||
consecutiveParseFailures = 0
|
||||
if (containsExtraActionObjects(content)) {
|
||||
observations.push('[系统提示] 检测到本轮输出了多个 JSON 对象,已只执行第一个;后续每轮请严格只输出一个动作。')
|
||||
}
|
||||
|
||||
if (action.type === 'final') {
|
||||
trace.push({ round: rounds, actionType: 'final', modelCallMs })
|
||||
if (action.estimate.lines.length === 0) {
|
||||
warnings.push('final 结果没有任何有效行,视为部分结果')
|
||||
return {
|
||||
status: 'PARTIAL',
|
||||
estimate: action.estimate,
|
||||
categoryTotals: summarizeBomCostingAgentEstimate(action.estimate),
|
||||
warnings,
|
||||
trace,
|
||||
usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt },
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'COMPLETED',
|
||||
estimate: action.estimate,
|
||||
categoryTotals: summarizeBomCostingAgentEstimate(action.estimate),
|
||||
warnings,
|
||||
trace,
|
||||
usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt },
|
||||
}
|
||||
}
|
||||
|
||||
trace.push({
|
||||
round: rounds,
|
||||
actionType: 'tool',
|
||||
tool: action.tool,
|
||||
toolArguments: action.arguments,
|
||||
modelCallMs,
|
||||
})
|
||||
const tool = toolsByName.get(action.tool)
|
||||
if (!tool) {
|
||||
observations.push(
|
||||
`[系统提示] 未知工具 "${action.tool}"。可用工具:${[...toolsByName.keys()].join(', ')}`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = await tool.execute(action.arguments, input.mission.toolContext ?? { caseId: '' })
|
||||
} catch (error) {
|
||||
payload = { error: `工具执行失败:${error instanceof Error ? error.message : '未知错误'}` }
|
||||
}
|
||||
const summary = safeJsonStringify(payload)
|
||||
trace[trace.length - 1]!.toolResultSummary = summary.length > 400 ? `${summary.slice(0, 400)}…` : summary
|
||||
observations.push(observationText(action.tool, payload))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
import { bomCostingAgentRepository } from '@/server/repositories/bom-costing-agent.repository'
|
||||
import { resolveBomCostingImageSearchSourceUrl } from '../bom-costing-1688-image-search'
|
||||
import type { BomCostingMultimodalUserContent } from '../bom-costing-multimodal-gateway'
|
||||
import type { BomCostingAgentBudget, BomCostingAgentRunResult } from './agent-contracts'
|
||||
import { runBomCostingAgentLoop } from './bom-costing-agent-loop'
|
||||
import { createGatewayBomCostingAgentModelCaller } from './agent-model-caller'
|
||||
import { renderBomCostingAgentSkillKnowledgeByCode } from './agent-skill-knowledge'
|
||||
import { createBomCostingAgentTools } from './agent-tools'
|
||||
import { createDefaultBomCostingAgentToolDeps } from './agent-tool-providers'
|
||||
|
||||
// 旁路试验入口:只读案例数据、只调工具与模型,不写任何业务表、
|
||||
// 不进处理管线。对比与验收在工程侧脚本里做,替换锚定前必须先过实测。
|
||||
const DEFAULT_MAX_IMAGES = 8
|
||||
|
||||
export interface BomCostingAgentTrialImageAsset {
|
||||
assetId: string
|
||||
kind: 'MAIN' | 'SKU' | 'DETAIL'
|
||||
url: string
|
||||
externalSkuIds: unknown
|
||||
}
|
||||
|
||||
function assetSkuTags(asset: BomCostingAgentTrialImageAsset) {
|
||||
return Array.isArray(asset.externalSkuIds)
|
||||
? asset.externalSkuIds.filter((item): item is string => typeof item === 'string')
|
||||
: []
|
||||
}
|
||||
|
||||
// 图片优先级:本 SKU 专属图 > 主图 > 详情图;同优先级保持归档顺序。
|
||||
export function selectAgentTrialImages(
|
||||
assets: readonly BomCostingAgentTrialImageAsset[],
|
||||
externalSkuId: string,
|
||||
maxImages: number,
|
||||
) {
|
||||
const skuTagged = assets.filter((asset) => assetSkuTags(asset).includes(externalSkuId))
|
||||
const main = assets.filter((asset) => asset.kind === 'MAIN')
|
||||
const detail = assets.filter((asset) => asset.kind === 'DETAIL')
|
||||
const seen = new Set<string>()
|
||||
const selected: BomCostingAgentTrialImageAsset[] = []
|
||||
for (const asset of [...skuTagged, ...main, ...detail]) {
|
||||
if (selected.length >= Math.max(1, maxImages)) break
|
||||
if (seen.has(asset.assetId) || !asset.url) continue
|
||||
seen.add(asset.assetId)
|
||||
selected.push(asset)
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
function decimalText(value: unknown) {
|
||||
return value === null ? null : Number(value)
|
||||
}
|
||||
|
||||
// 归档图片是站内相对路径或私有桶地址,模型网关需要可抓取的签名绝对 URL;
|
||||
// 与生产 1688 图搜使用同一转换,失败时退回原始值由调用方报错。
|
||||
function toModelImageSrc(rawValue: string) {
|
||||
try {
|
||||
return resolveBomCostingImageSearchSourceUrl(rawValue).toString()
|
||||
} catch {
|
||||
return rawValue
|
||||
}
|
||||
}
|
||||
|
||||
export async function runBomCostingAgentTrialForSku(input: {
|
||||
caseId: string
|
||||
skuId: string
|
||||
maxImages?: number
|
||||
budget?: Partial<BomCostingAgentBudget>
|
||||
signal?: AbortSignal
|
||||
}): Promise<{
|
||||
caseId: string
|
||||
skuId: string
|
||||
externalSkuId: string
|
||||
skillCode: string | null
|
||||
selectedImageCount: number
|
||||
result: BomCostingAgentRunResult
|
||||
}> {
|
||||
const [sourceCase, sku] = await Promise.all([
|
||||
bomCostingAgentRepository.findCaseForAgentTrial(input.caseId),
|
||||
bomCostingAgentRepository.findSkuForAgentTrial(input.skuId),
|
||||
])
|
||||
if (!sourceCase) throw new Error(`案例不存在或已删除:${input.caseId}`)
|
||||
if (!sku || sku.caseId !== input.caseId) throw new Error(`SKU 不属于该案例:${input.skuId}`)
|
||||
|
||||
const assets = await bomCostingAgentRepository.listArchivedAssetsForCase(input.caseId)
|
||||
const images = selectAgentTrialImages(
|
||||
assets.map((asset) => ({
|
||||
assetId: asset.id,
|
||||
kind: asset.kind,
|
||||
url: toModelImageSrc(asset.storedUrl || asset.originalUrl),
|
||||
externalSkuIds: asset.externalSkuIds as unknown,
|
||||
})),
|
||||
sku.externalSkuId,
|
||||
input.maxImages ?? DEFAULT_MAX_IMAGES,
|
||||
)
|
||||
|
||||
const dimensionParts = [
|
||||
sku.lengthMm !== null ? `长 ${decimalText(sku.lengthMm)}mm` : null,
|
||||
sku.widthMm !== null ? `宽 ${decimalText(sku.widthMm)}mm` : null,
|
||||
sku.heightMm !== null ? `高 ${decimalText(sku.heightMm)}mm` : null,
|
||||
sku.weightKg !== null ? `毛重 ${decimalText(sku.weightKg)}kg` : null,
|
||||
].filter((part): part is string => part !== null)
|
||||
const userText = [
|
||||
`商品标题:${sourceCase.title || '(无标题)'}`,
|
||||
`来源渠道:${sourceCase.sourceHost}`,
|
||||
`目标 SKU:${sku.externalSkuId}${sku.name ? `(${sku.name})` : ''}`,
|
||||
dimensionParts.length > 0 ? `规格:${dimensionParts.join(',')}` : null,
|
||||
'',
|
||||
'任务:基于以上文字与随附图片,预估该 SKU 的同材质复刻 BOM 成本。'
|
||||
+ '先查看图片与材质目录,再用工具检索公司历史价格,最后给出 final。',
|
||||
].filter((part): part is string => part !== null).join('\n')
|
||||
|
||||
const userContent: BomCostingMultimodalUserContent = [
|
||||
{ type: 'text', text: userText },
|
||||
...images.map((image) => ({
|
||||
type: 'image_url' as const,
|
||||
image_url: { url: image.url, detail: 'auto' as const },
|
||||
})),
|
||||
]
|
||||
|
||||
const skillKnowledge = renderBomCostingAgentSkillKnowledgeByCode(sourceCase.skillCode)
|
||||
const result = await runBomCostingAgentLoop({
|
||||
mission: {
|
||||
userContent,
|
||||
toolContext: { caseId: input.caseId, skuId: input.skuId },
|
||||
budget: input.budget,
|
||||
signal: input.signal,
|
||||
},
|
||||
toolRuntimes: createBomCostingAgentTools(createDefaultBomCostingAgentToolDeps()),
|
||||
callModel: createGatewayBomCostingAgentModelCaller(),
|
||||
})
|
||||
return {
|
||||
caseId: input.caseId,
|
||||
skuId: input.skuId,
|
||||
externalSkuId: sku.externalSkuId,
|
||||
skillCode: sourceCase.skillCode,
|
||||
selectedImageCount: images.length,
|
||||
result,
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,8 @@ export type ImageBackgroundRemovalApiMode =
|
||||
export interface ProviderModelOption {
|
||||
value: string
|
||||
label: string
|
||||
/** 模型目录来源:gateway=通用图像网关目录;async-matting=上游异步媒体任务的专用抠图目录。 */
|
||||
source?: 'gateway' | 'async-matting'
|
||||
}
|
||||
|
||||
export interface ProviderModelCatalogResult {
|
||||
@ -43,6 +45,7 @@ const MODEL_ID_FIELDS = ['id', 'modelId', 'model', 'value', 'name'] as const
|
||||
const MODEL_LABEL_FIELDS = ['displayName', 'label', 'name', 'id', 'modelId', 'model', 'value'] as const
|
||||
const MODEL_COLLECTION_FIELDS = ['data', 'models', 'items', 'results', 'result'] as const
|
||||
const MAX_COLLECTION_DEPTH = 4
|
||||
const MATTING_CATALOG_PATH = '/api/v2/catalog/models'
|
||||
|
||||
function normalizeRequiredText(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
@ -176,17 +179,32 @@ function buildEndpointCandidates(
|
||||
serviceType: ProviderModelServiceType,
|
||||
apiMode?: ImageBackgroundRemovalApiMode,
|
||||
): Array<{ url: string; path: string }> {
|
||||
if (serviceType === 'image-background-removal' && apiMode === 'async-media-task') {
|
||||
return [{
|
||||
url: joinUrl(baseUrl, '/api/v2/catalog/models'),
|
||||
path: '/api/v2/catalog/models',
|
||||
if (serviceType === 'image-background-removal') {
|
||||
// 非 async-media-task 协议也要携带专用抠图目录候选:fetchCatalog 会合并
|
||||
// 两份目录,让默认协议下同样能看到 remove-background 等抠图模型。
|
||||
if (apiMode === 'async-media-task') {
|
||||
return [{ url: joinUrl(baseUrl, MATTING_CATALOG_PATH), path: MATTING_CATALOG_PATH }]
|
||||
}
|
||||
if (apiMode === 'openai-compatible-image-edit') {
|
||||
const openAiCandidates: Array<{ url: string; path: string }> = []
|
||||
const parsedUrl = new URL(baseUrl)
|
||||
const normalizedPath = parsedUrl.pathname.replace(/\/+$/, '')
|
||||
openAiCandidates.push({ url: joinUrl(baseUrl, '/models'), path: '/models' })
|
||||
if (!normalizedPath.endsWith('/v1')) {
|
||||
openAiCandidates.push({ url: joinUrl(baseUrl, '/v1/models'), path: '/v1/models' })
|
||||
}
|
||||
openAiCandidates.push({ url: joinUrl(baseUrl, MATTING_CATALOG_PATH), path: MATTING_CATALOG_PATH })
|
||||
return openAiCandidates
|
||||
}
|
||||
const candidates = [{
|
||||
url: joinUrl(baseUrl, '/api/external/image/models'),
|
||||
path: '/api/external/image/models',
|
||||
}]
|
||||
candidates.push({ url: joinUrl(baseUrl, MATTING_CATALOG_PATH), path: MATTING_CATALOG_PATH })
|
||||
return candidates
|
||||
}
|
||||
|
||||
if (
|
||||
serviceType === 'ai-render' ||
|
||||
(serviceType === 'image-background-removal' && apiMode !== 'openai-compatible-image-edit')
|
||||
) {
|
||||
if (serviceType === 'ai-render') {
|
||||
return [{
|
||||
url: joinUrl(baseUrl, '/api/external/image/models'),
|
||||
path: '/api/external/image/models',
|
||||
@ -248,7 +266,7 @@ async function requestCatalog(
|
||||
}
|
||||
|
||||
const models = response.ok
|
||||
? apiMode === 'async-media-task'
|
||||
? endpoint.path === MATTING_CATALOG_PATH
|
||||
? extractAsyncMediaTaskModelOptions(payload)
|
||||
: extractProviderModelOptions(payload, {
|
||||
preferExternalImageModelId:
|
||||
@ -286,6 +304,57 @@ export class ProviderModelCatalogService {
|
||||
let lastAttempt: CatalogAttempt | null = null
|
||||
let reachedProvider = false
|
||||
|
||||
// 商品抠图需要同时看到两份目录:通用图像网关模型(以文改图)和上游
|
||||
// 异步媒体任务的专用抠图模型(remove-background 等)。任一协议模式下
|
||||
// 都合并两份目录并标记来源,避免“默认协议看不到抠图模型”的死角。
|
||||
if (input.serviceType === 'image-background-removal') {
|
||||
const merged = new Map<string, ProviderModelOption>()
|
||||
let firstEndpointWithModels: string | null = null
|
||||
for (const candidate of candidates) {
|
||||
const attempt = await requestCatalog(candidate, apiKey, input.serviceType, input.apiMode)
|
||||
lastAttempt = attempt
|
||||
reachedProvider ||= attempt.status >= 200 && attempt.status < 500
|
||||
if (attempt.models.length > 0 && firstEndpointWithModels === null) {
|
||||
firstEndpointWithModels = attempt.endpointPath
|
||||
}
|
||||
const source: ProviderModelOption['source'] = candidate.path === MATTING_CATALOG_PATH
|
||||
? 'async-matting'
|
||||
: 'gateway'
|
||||
for (const model of attempt.models) {
|
||||
if (!merged.has(model.value)) merged.set(model.value, { ...model, source })
|
||||
}
|
||||
if ([401, 403].includes(attempt.status)) break
|
||||
}
|
||||
const models = Array.from(merged.values()).sort((left, right) => {
|
||||
if (left.source !== right.source) return left.source === 'async-matting' ? -1 : 1
|
||||
return 0
|
||||
})
|
||||
const status = lastAttempt?.status ?? 0
|
||||
if (models.length > 0) {
|
||||
return {
|
||||
ok: true,
|
||||
status: status === 0 ? 200 : status,
|
||||
latency: Date.now() - startedAt,
|
||||
models,
|
||||
endpointPath: firstEndpointWithModels,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
const error = [401, 403].includes(status)
|
||||
? 'API 密钥无权读取模型列表,请检查密钥权限'
|
||||
: reachedProvider
|
||||
? '服务可以连接,但没有返回可选择的模型。请确认服务地址包含正确的 API 前缀'
|
||||
: lastAttempt?.error ?? '无法连接模型服务'
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
latency: Date.now() - startedAt,
|
||||
models: [],
|
||||
endpointPath: lastAttempt?.endpointPath ?? null,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const attempt = await requestCatalog(candidate, apiKey, input.serviceType, input.apiMode)
|
||||
lastAttempt = attempt
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user