92 lines
6.8 KiB
TypeScript
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') }
|
|
}
|