55 lines
4.9 KiB
TypeScript
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 }
|
|
}
|