137 lines
13 KiB
TypeScript
137 lines
13 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
||
import sharp from 'sharp'
|
||
import { z } from 'zod'
|
||
import { AiModelError, assertAiModelAdmin } from './ai-model-access'
|
||
import { aiModelAudit, aiModelTransaction } from './ai-model-repository'
|
||
import { getAiModelAdminState, resolveAiModelVerification } from './ai-model-service'
|
||
import { AI_MODEL_ADAPTER_VERSION, callAiModelProtocol } from './ai-model-protocol'
|
||
import { AI_MODEL_STAGES, type AiModelStage } from './ai-model-types'
|
||
import { AI_MODEL_VERIFICATION_COLUMNS, type AiModelVerificationRow } from './ai-model-verification-state'
|
||
import { prisma } from './prisma'
|
||
import { detailAnalysisSchema } from './detail-generator/validation'
|
||
import { detailTransactionLock } from './detail-generator/repository'
|
||
import type { AiModelDatabase } from './ai-model-repository'
|
||
|
||
const requestSchema = z.object({ stage: z.enum(AI_MODEL_STAGES), revision: z.number().int().positive(), idempotencyKey: z.string().uuid() }).strict()
|
||
function safeId(value: unknown, secret: string): string | null { return typeof value === 'string' && /^[\w.-]{1,160}$/.test(value) && !value.includes(secret) ? value : null }
|
||
async function assertExecutionSlot(db: AiModelDatabase, actorId: string, resumeId: string | null = null): Promise<void> {
|
||
await detailTransactionLock(db, 'detail-generator-execution')
|
||
const rows = await db.$queryRawUnsafe<Array<{ count: number; own: number }>>(`SELECT COUNT(*)::int AS count,COUNT(*) FILTER (WHERE actor_id=$1)::int AS own FROM (
|
||
SELECT actor_id FROM ai_model_verifications WHERE ($2::text IS NULL OR id<>$2) AND (status='running' OR status='unknown' AND task_id IS NOT NULL)
|
||
UNION ALL SELECT owner_id AS actor_id FROM detail_generator_jobs WHERE status='running' AND lease_expires_at>NOW()
|
||
OR status='queued' AND checkpoint->>'dispatchPending'='true' AND COALESCE(checkpoint #>> '{upstreamTask,id}', '') ~ '^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,199}$'
|
||
) AS active_executions`, actorId, resumeId)
|
||
if (rows[0]?.count >= 2 || rows[0]?.own >= 1) throw new AiModelError('已有生成或模型测试正在执行,请等待完成', 409, 'verification_busy')
|
||
}
|
||
async function referenceImage(): Promise<Buffer> {
|
||
return sharp({ create: { width: 512, height: 512, channels: 3, background: '#ffffff' } }).composite([
|
||
{ input: await sharp({ create: { width: 180, height: 180, channels: 3, background: '#ff0000' } }).png().toBuffer(), left: 32, top: 32 },
|
||
{ input: await sharp({ create: { width: 180, height: 180, channels: 3, background: '#0000ff' } }).png().toBuffer(), left: 300, top: 300 },
|
||
]).png().toBuffer()
|
||
}
|
||
function parseJson(text: string | undefined): any {
|
||
if (!text) throw new AiModelError('模型未返回可验证的内容', 422, 'verification_output_invalid')
|
||
try { return JSON.parse(text.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '')) } catch { throw new AiModelError('模型未返回要求的 JSON 内容', 422, 'verification_output_invalid') }
|
||
}
|
||
export async function validateAiModelVerificationOutput(stage: AiModelStage, result: { text?: string; image?: Buffer }): Promise<Record<string, unknown>> {
|
||
if (stage === 'analysis') {
|
||
const value = parseJson(result.text)
|
||
if (value.topLeft !== 'red' || value.bottomRight !== 'blue') throw new AiModelError('图片内容识别未通过,不能开放资料分析', 422, 'verification_output_invalid')
|
||
return { imageInputVerified: true, colorPositionsMatched: true, textCharacters: result.text!.length }
|
||
}
|
||
if (stage === 'copy') {
|
||
const value = parseJson(result.text)
|
||
if (Array.isArray(value?.screens)) value.screens = value.screens.map((screen: any) => ({ ...screen, id: randomUUID() }))
|
||
if (!detailAnalysisSchema.safeParse(value).success || value.screens.length !== 1) throw new AiModelError('文案大纲结构未通过,不能开放卖点文案', 422, 'verification_output_invalid')
|
||
return { outlineSchemaVerified: true, screenCount: 1, textCharacters: result.text!.length }
|
||
}
|
||
if (!result.image) throw new AiModelError('模型未返回图片', 422, 'verification_output_invalid')
|
||
const image = sharp(result.image, { limitInputPixels: 40_000_000 }), metadata = await image.metadata(), stats = await image.stats()
|
||
if (!metadata.width || !metadata.height || metadata.width < 64 || metadata.height < 64 || !stats.channels.some(channel => channel.stdev > 1)) throw new AiModelError('模型返回的图片为空白或无效', 422, 'verification_output_invalid')
|
||
return { referenceImageAccepted: true, imageDecoded: true, width: metadata.width, height: metadata.height, bytes: result.image.length }
|
||
}
|
||
|
||
async function executeVerification(row: AiModelVerificationRow, userId: string, providerId: string, resume: boolean): Promise<void> {
|
||
let sent = false, apiKey = '', knownTaskId = row.taskId
|
||
const beforeRequest = async () => {
|
||
await assertAiModelAdmin(userId)
|
||
const current = await resolveAiModelVerification(providerId, row.modelKey, row.stage)
|
||
if (current.contextHash !== row.contextHash) throw new AiModelError('连接或路由已改变,测试已停止', 409, 'verification_context_changed')
|
||
sent = true
|
||
}
|
||
try {
|
||
const context = await resolveAiModelVerification(providerId, row.modelKey, row.stage)
|
||
apiKey = context.apiKey
|
||
if (context.contextHash !== row.contextHash) throw new AiModelError('验证对应的连接或路由已改变,不能继续此任务', 409, 'verification_context_changed')
|
||
const prompt = row.stage === 'analysis' ? '观察图片:左上方方块与右下方方块分别是什么颜色?只输出 JSON:{"topLeft":"英文小写颜色","bottomRight":"英文小写颜色"}。不要根据文字猜测,必须读取图片。'
|
||
: row.stage === 'copy' ? '真实产品资料:型号TEST,颜色蓝色。不要编造其他规格。写一屏中文详情页,严格只输出 JSON:{"sellingPoints":["卖点"],"warnings":[],"screens":[{"title":"标题","body":"正文","imagePrompt":"无文字的场景描述"}]}。'
|
||
: '依据参考图片生成一张简洁的产品展示图,保留两个方块的颜色与相对位置,白色背景,不要加入文字或标志。'
|
||
const result = await callAiModelProtocol({ baseUrl: context.provider.baseUrl, apiKey: context.apiKey, modelId: context.model.id, protocol: context.protocol,
|
||
prompt, ...(row.stage !== 'copy' ? { images: [{ buffer: await referenceImage(), mimeType: 'image/png' }] } : {}), maxOutputTokens: row.stage === 'copy' ? 1200 : 1024,
|
||
idempotencyKey: row.idempotencyKey, beforeRequest, ...(resume ? { resumeTaskId: row.taskId! } : {}),
|
||
onTaskSubmitted: async taskId => {
|
||
if (!safeId(taskId, context.apiKey)) throw new AiModelError('上游任务编号无效', 502, 'verification_task_invalid')
|
||
knownTaskId = taskId
|
||
const updated = await prisma.$executeRawUnsafe(`UPDATE ai_model_verifications SET task_id=$2 WHERE id=$1 AND status='running' AND (task_id IS NULL OR task_id=$2)`, row.id, taskId)
|
||
if (updated !== 1) throw new AiModelError('任务检查点未保存,已暂停后续请求', 409, 'verification_checkpoint_failed')
|
||
} })
|
||
const evidence = await validateAiModelVerificationOutput(row.stage, result)
|
||
await assertAiModelAdmin(userId)
|
||
await aiModelTransaction(async db => {
|
||
const current = await resolveAiModelVerification(providerId, row.modelKey, row.stage)
|
||
if (current.contextHash !== row.contextHash) throw new AiModelError('验证期间连接或路由已改变', 409, 'verification_context_changed')
|
||
await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status='passed',request_id=$2,message='实际接口测试通过',evidence=$3::jsonb,finished_at=NOW() WHERE id=$1 AND status='running'`, row.id, safeId(result.requestId, context.apiKey), JSON.stringify({ ...evidence, adapterVersion: AI_MODEL_ADAPTER_VERSION, protocol: context.protocol.id, scopeId: context.protocol.scopeId, routeId: context.protocol.routeId, operationId: context.protocol.operationId }))
|
||
await aiModelAudit(db, userId, 'model.verification-passed', providerId, { verificationId: row.id, modelKey: row.modelKey, stage: row.stage, evidence })
|
||
})
|
||
} catch (error) {
|
||
const outcome = error as { outcomeUnknown?: boolean; taskId?: string; requestId?: string }
|
||
const unknown = typeof outcome.outcomeUnknown === 'boolean' ? (sent || Boolean(knownTaskId)) && outcome.outcomeUnknown : Boolean(knownTaskId) || sent && !(error instanceof AiModelError)
|
||
const message = unknown ? '请求结果尚未确认,请核查上游记录;禁止重复提交' : error instanceof AiModelError ? error.message : '模型测试未通过'
|
||
await aiModelTransaction(async db => {
|
||
await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status=$2,request_id=$3,task_id=COALESCE(task_id,$4),message=$5,finished_at=NOW() WHERE id=$1 AND status='running'`, row.id, unknown ? 'unknown' : 'failed', safeId(outcome.requestId, apiKey), safeId(outcome.taskId || knownTaskId, apiKey), message)
|
||
await aiModelAudit(db, userId, unknown ? 'model.verification-unknown' : 'model.verification-failed', providerId, { verificationId: row.id, modelKey: row.modelKey, stage: row.stage })
|
||
})
|
||
}
|
||
}
|
||
|
||
export async function verifyAiModel(userId: string, providerId: string, modelKey: string, body: unknown) {
|
||
const parsed = requestSchema.safeParse(body)
|
||
if (!parsed.success) throw new AiModelError('测试参数无效')
|
||
await assertAiModelAdmin(userId)
|
||
const input = parsed.data, context = await resolveAiModelVerification(providerId, modelKey, input.stage, input.revision)
|
||
const created = await aiModelTransaction(async db => {
|
||
await assertAiModelAdmin(userId)
|
||
const prior = await db.$queryRawUnsafe<AiModelVerificationRow[]>(`SELECT ${AI_MODEL_VERIFICATION_COLUMNS} FROM ai_model_verifications WHERE model_key=$1 AND stage=$2 AND idempotency_key=$3`, modelKey, input.stage, input.idempotencyKey)
|
||
if (prior[0]) return null // A repeated HTTP request never repeats a model call.
|
||
await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status='unknown',message='进程或请求中断,结果需核查',finished_at=NOW() WHERE status='running' AND deadline_at<=NOW()`)
|
||
const active = await db.$queryRawUnsafe<Array<{ count: number }>>(`SELECT COUNT(*)::int AS count FROM ai_model_verifications WHERE model_key=$1 AND stage=$2 AND status IN ('running','unknown')`, modelKey, input.stage)
|
||
if (active[0]?.count) throw new AiModelError('该用途存在进行中或结果未知的请求,不能重复测试;请先核查原请求', 409, 'verification_unresolved')
|
||
await assertExecutionSlot(db, userId)
|
||
const rows = await db.$queryRawUnsafe<AiModelVerificationRow[]>(`INSERT INTO ai_model_verifications (id,model_key,stage,context_hash,idempotency_key,actor_id,status) VALUES ($1,$2,$3,$4,$5,$6,'running') RETURNING ${AI_MODEL_VERIFICATION_COLUMNS}`, randomUUID(), modelKey, input.stage, context.contextHash, input.idempotencyKey, userId)
|
||
await aiModelAudit(db, userId, 'model.verification-start', providerId, { verificationId: rows[0].id, modelKey, stage: input.stage })
|
||
return rows[0]
|
||
})
|
||
if (created) await executeVerification(created, userId, providerId, false)
|
||
return getAiModelAdminState()
|
||
}
|
||
|
||
/** Only fetch an already-created upstream task. This endpoint can never submit a new generation. */
|
||
export async function refreshAiModelVerification(userId: string, providerId: string, verificationId: string) {
|
||
if (!z.string().uuid().safeParse(verificationId).success) throw new AiModelError('测试编号无效')
|
||
await assertAiModelAdmin(userId)
|
||
const row = await aiModelTransaction(async db => {
|
||
const rows = await db.$queryRawUnsafe<AiModelVerificationRow[]>(`SELECT ${AI_MODEL_VERIFICATION_COLUMNS} FROM ai_model_verifications WHERE id=$1 FOR UPDATE`, verificationId)
|
||
const current = rows[0]
|
||
if (!current?.taskId || !['unknown','running'].includes(current.status)) throw new AiModelError('没有可以查询的上游任务', 409, 'verification_not_resumable')
|
||
if (current.status === 'running' && current.deadlineAt.getTime() > Date.now()) throw new AiModelError('测试仍在执行,请等待', 409, 'verification_busy')
|
||
const context = await resolveAiModelVerification(providerId, current.modelKey, current.stage)
|
||
if (context.contextHash !== current.contextHash) throw new AiModelError('原连接或路由已改变,请在上游核查原任务', 409, 'verification_context_changed')
|
||
await assertExecutionSlot(db, userId, current.id)
|
||
await db.$executeRawUnsafe(`UPDATE ai_model_verifications SET status='running',actor_id=$2,deadline_at=NOW()+INTERVAL '10 minutes',finished_at=NULL WHERE id=$1`, current.id, userId)
|
||
await aiModelAudit(db, userId, 'model.verification-query', providerId, { verificationId: current.id, modelKey: current.modelKey, stage: current.stage })
|
||
return current
|
||
})
|
||
await executeVerification(row, userId, providerId, true)
|
||
return getAiModelAdminState()
|
||
}
|