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

55 lines
4.9 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)) 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
}
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 canResume = Boolean(queryAvailable && row.taskId && row.contextHash === contextHash && effective === 'unknown')
return { status, id: row.id, checkedAt: row.finishedAt?.toISOString() || row.startedAt.toISOString(), requestId: row.requestId,
canResume, canReconcile: effective === 'unknown' && !canResume, 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.message }
}