gongxiangp2/lib/ai-model-verification-output.ts

92 lines
6.8 KiB
TypeScript

import { randomInt, randomUUID } from 'node:crypto'
import sharp from 'sharp'
import { AiModelError } from './ai-model-access'
import { detailAnalysisSchema } from './detail-generator/validation'
import type { AiModelStage } from './ai-model-types'
export const AI_MODEL_VERIFICATION_RULE_VERSION = 2
const colors = { red: '#ff0000', blue: '#0000ff', green: '#00ff00', yellow: '#ffff00' } as const
export type VerificationChallenge = { topLeft: keyof typeof colors; bottomRight: keyof typeof colors }
const names = Object.keys(colors) as Array<keyof typeof colors>
export function createVerificationChallenge(): VerificationChallenge {
const topLeft = names[randomInt(names.length)]
const others = names.filter(name => name !== topLeft)
return { topLeft, bottomRight: others[randomInt(others.length)] }
}
export function readVerificationChallenge(value: unknown): VerificationChallenge {
const entry = value as VerificationChallenge | null
// Older, already-submitted tests used these two colors. Resume their existing task only.
if (!entry) return { topLeft: 'red', bottomRight: 'blue' }
if (!names.includes(entry.topLeft) || !names.includes(entry.bottomRight) || entry.topLeft === entry.bottomRight) throw new AiModelError('测试参考图检查点无效', 409)
return { topLeft: entry.topLeft, bottomRight: entry.bottomRight }
}
export async function verificationReferenceImage(challenge: VerificationChallenge): 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: colors[challenge.topLeft] } }).png().toBuffer(), left: 32, top: 32 },
{ input: await sharp({ create: { width: 180, height: 180, channels: 3, background: colors[challenge.bottomRight] } }).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 referenceLayoutMatches(bytes: Buffer, expected: VerificationChallenge): Promise<boolean> {
const { data, info } = await sharp(bytes, { limitInputPixels: 40_000_000 }).flatten({ background: '#fff' }).removeAlpha().resize(96, 96, { fit: 'fill' }).raw().toBuffer({ resolveWithObject: true })
const hues = { red: 0, yellow: 60, green: 120, blue: 240 }
const found = { topLeft: { n: 0, x: 0, y: 0 }, bottomRight: { n: 0, x: 0, y: 0 } }
for (let y = 0; y < info.height; y++) for (let x = 0; x < info.width; x++) {
const offset = (y * info.width + x) * info.channels
const r = data[offset] / 255, g = data[offset + 1] / 255, b = data[offset + 2] / 255
const max = Math.max(r, g, b), min = Math.min(r, g, b), delta = max - min
if (max < .3 || delta / max < .45) continue
let hue = max === r ? 60 * ((g - b) / delta) : max === g ? 60 * ((b - r) / delta + 2) : 60 * ((r - g) / delta + 4)
hue = (hue + 360) % 360
for (const key of ['topLeft', 'bottomRight'] as const) {
const distance = Math.abs(hue - hues[expected[key]])
if (Math.min(distance, 360 - distance) > 28) continue
found[key].n++; found[key].x += (x + .5) / info.width; found[key].y += (y + .5) / info.height
}
}
const a = found.topLeft, b = found.bottomRight, minimum = info.width * info.height * .015
return a.n >= minimum && b.n >= minimum && a.x / a.n < .5 && a.y / a.n < .5 && b.x / b.n > .5 && b.y / b.n > .5
}
export async function validateAiModelVerificationOutput(stage: AiModelStage, result: { text?: string; image?: Buffer }, challenge: VerificationChallenge = { topLeft: 'red', bottomRight: 'blue' }): Promise<Record<string, unknown>> {
if (stage === 'analysis') {
const value = parseJson(result.text)
if (value?.topLeft !== challenge.topLeft || value?.bottomRight !== challenge.bottomRight) 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')
if (!(await referenceLayoutMatches(result.image, challenge))) throw new AiModelError('图片未保留参考图的颜色与相对位置,不能开放参考图生图', 422, 'verification_output_invalid')
return { referenceImageAccepted: true, referenceLayoutMatched: true, imageDecoded: true, width: metadata.width, height: metadata.height, bytes: result.image.length }
}
export type AiModelTextCheck = { status: 'passed' | 'failed'; message: string; evidence: Record<string, unknown> }
/** A malformed or failed section cannot approve or erase the other purpose. */
export async function validateAiModelCombinedVerificationOutput(result: { text?: string }, challenge: VerificationChallenge): Promise<Record<'analysis' | 'copy', AiModelTextCheck>> {
let value: any
try { value = parseJson(result.text) } catch (error) {
if (!(error instanceof AiModelError)) throw error
const failed: AiModelTextCheck = { status: 'failed', message: error.message, evidence: {} }
return { analysis: { ...failed }, copy: { ...failed } }
}
const check = async (stage: 'analysis' | 'copy'): Promise<AiModelTextCheck> => {
try {
const evidence = await validateAiModelVerificationOutput(stage, { text: JSON.stringify(value?.[stage] ?? null) }, challenge)
return { status: 'passed', message: '联合测试:该用途已通过', evidence }
} catch (error) {
if (!(error instanceof AiModelError)) throw error
return { status: 'failed', message: error.message, evidence: {} }
}
}
return { analysis: await check('analysis'), copy: await check('copy') }
}