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

70 lines
6.4 KiB
TypeScript

import { createHash } from 'node:crypto'
import { prisma } from './prisma'
import type { AiModelDatabase } from './ai-model-repository'
import type { AiModelStage, AiModelVerificationSummary } from './ai-model-types'
import { aiModelCapabilityFingerprint } from './ai-model-capabilities'
import { AI_MODEL_ADAPTER_VERSION, type AiModelProtocol } from './ai-model-protocol'
import { AI_MODEL_VERIFICATION_RULE_VERSION } from './ai-model-verification-output'
import { AiModelError } from './ai-model-access'
export interface AiModelVerificationRow {
id: string; modelKey: string; stage: AiModelStage; contextHash: string; idempotencyKey: string; actorId: string;
status: 'running' | 'passed' | 'failed' | 'unknown'; requestId: string | null; taskId: string | null;
message: string | null; evidence: Record<string, unknown>; startedAt: Date; deadlineAt: Date; finishedAt: Date | null;
}
export const AI_MODEL_VERIFICATION_COLUMNS = `id,model_key AS "modelKey",stage,context_hash AS "contextHash",idempotency_key AS "idempotencyKey",actor_id AS "actorId",status,request_id AS "requestId",task_id AS "taskId",message,evidence,started_at AS "startedAt",deadline_at AS "deadlineAt",finished_at AS "finishedAt"`
export function verificationExecutionId(row: AiModelVerificationRow): string {
return row.evidence?.purpose === 'analysis-copy' && typeof row.evidence.executionGroupId === 'string' ? row.evidence.executionGroupId : row.id
}
/** Lock both purpose records together; never recover only half of one upstream request. */
export async function lockAiModelVerificationExecution(db: AiModelDatabase, row: AiModelVerificationRow): Promise<AiModelVerificationRow[]> {
if (row.evidence?.purpose !== 'analysis-copy') return [row]
const executionId = verificationExecutionId(row)
if (!/^[a-f0-9-]{36}$/i.test(executionId) || executionId === row.id) throw new AiModelError('联合测试检查点无效,请核查记录', 409, 'verification_checkpoint_invalid')
const rows = await db.$queryRawUnsafe<AiModelVerificationRow[]>(`SELECT ${AI_MODEL_VERIFICATION_COLUMNS} FROM ai_model_verifications WHERE model_key=$1 AND idempotency_key=$2 AND evidence->>'executionGroupId'=$3 ORDER BY stage FOR UPDATE`, row.modelKey, row.idempotencyKey, executionId)
if (rows.length !== 2 || !rows.some(value => value.id === row.id) || rows[0].stage !== 'analysis' || rows[1].stage !== 'copy' || rows.some(value => value.evidence?.purpose !== 'analysis-copy' || value.taskId !== row.taskId || value.requestId !== row.requestId)) throw new AiModelError('联合测试记录不完整或原请求编号不一致,请核查原请求', 409, 'verification_checkpoint_invalid')
return rows
}
export async function listAiModelVerifications(db: AiModelDatabase = prisma): Promise<AiModelVerificationRow[]> {
return db.$queryRawUnsafe<AiModelVerificationRow[]>(`SELECT DISTINCT ON (model_key,stage) ${AI_MODEL_VERIFICATION_COLUMNS} FROM ai_model_verifications ORDER BY model_key,stage,started_at DESC,id DESC`)
}
/** Private, never returned through DTOs. A key rotation invalidates evidence without depending on UI revisions. */
export function aiModelVerificationContext(baseUrl: string, modelId: string, metadata: unknown, apiKey: string, protocol: AiModelProtocol): string {
return createHash('sha256').update(JSON.stringify({ baseUrl, modelId, capability: aiModelCapabilityFingerprint(metadata),
credential: createHash('sha256').update(apiKey).digest('hex'), adapter: AI_MODEL_ADAPTER_VERSION, protocol })).digest('hex')
}
export function verificationEffectiveStatus(row: AiModelVerificationRow): AiModelVerificationRow['status'] {
return row.status === 'running' && new Date(row.deadlineAt).getTime() <= Date.now() ? 'unknown' : row.status
}
/** A single-image pass cannot authorize an untested multi-image binding. */
export function verifiedReferenceImageCount(row: AiModelVerificationRow | undefined): number | null {
if (!row || row.stage !== 'image' || verificationEffectiveStatus(row) !== 'passed') return null
const count = row.evidence?.referenceImageCount ?? 1
if (!Number.isInteger(count) || (count as number) < 1 || (count as number) > 8) return null
if ((count as number) > 1 && (row.evidence?.multiReferenceRuleVersion !== 1 || row.evidence?.allReferenceImagesMatched !== true ||
!Array.isArray(row.evidence.referenceMatches) || row.evidence.referenceMatches.length !== count || row.evidence.referenceMatches.some(value => value !== true))) return null
return count as number
}
export function aiModelVerificationSummary(rows: AiModelVerificationRow[], modelKey: string, stage: AiModelStage, contextHash: string | null, queryAvailable = true): AiModelVerificationSummary {
const row = rows.find(value => value.modelKey === modelKey && value.stage === stage)
if (!row) return { status: 'untested', id: null, checkedAt: null, requestId: null, message: null }
const effective = verificationEffectiveStatus(row)
const status = effective === 'unknown' ? 'unknown' : row.contextHash !== contextHash || effective === 'passed' && row.evidence?.ruleVersion !== AI_MODEL_VERIFICATION_RULE_VERSION ? 'stale' : effective
const retainedRequest = typeof row.requestId === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(row.requestId)
const canResume = Boolean(queryAvailable && (row.taskId || retainedRequest) && row.contextHash === contextHash && effective === 'unknown')
const reconciliation = row.evidence?.reconciliation as { outcome?: unknown; note?: unknown } | undefined
const message = status === 'failed' && reconciliation?.outcome === 'failed' && typeof reconciliation.note === 'string'
? `已核查上游执行失败:${reconciliation.note}` : row.message
return { status, id: row.id, checkedAt: row.finishedAt?.toISOString() || row.startedAt.toISOString(), requestId: row.requestId,
...(status === 'passed' && stage === 'image' && verifiedReferenceImageCount(row) ? { verifiedReferenceImageCount: verifiedReferenceImageCount(row)! } : {}),
canResume, canReconcile: effective === 'unknown' && (!canResume || !row.taskId), reconciled: Boolean(row.evidence?.reconciliation), taskId: row.taskId, submissionKey: row.idempotencyKey, purpose: row.evidence?.purpose === 'analysis-copy' ? 'analysis-copy' : stage,
message: status === 'stale' ? '连接、路由或测试规则已改变,请重新测试' : status === 'unknown' && (row.status !== 'unknown' || !row.message) ? '请求结果尚未确认,已暂停重复测试;请核查上游请求记录' : message }
}