Files
prop-ai-hr/scripts/verify-data-quality-calibration-local.mjs
T

241 lines
9.7 KiB
JavaScript

#!/usr/bin/env node
import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const baseUrl = process.env.AIHR_BASE_URL || 'https://wygj-api.localhost';
const env = readEnv(path.join(root, 'frontend/.env.development'));
const clientId = env.VITE_APP_CLIENT_ID;
const requestPublicKey = pem('PUBLIC KEY', env.VITE_APP_RSA_PUBLIC_KEY);
const responsePrivateKey = pem('PRIVATE KEY', env.VITE_APP_RSA_PRIVATE_KEY);
const marker = `CALIBRATION_E2E_${Date.now()}`;
if (new URL(baseUrl).hostname.endsWith('.localhost')) process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
const created = { ruleId: 0, datasetId: 0 };
let token = '';
try {
token = await login();
const before = governanceCounts();
const rule = await ok('create rule', 'POST', '/api/knowledge/quality/rules', {
ruleCode: marker,
stage: 'QUALITY',
riskClass: 'HIGH',
action: 'QUARANTINE',
implementationType: 'DETERMINISTIC',
scope: { sourceTypes: ['UPLOAD'] },
config: { reasonCode: 'SOURCE_UNKNOWN' }
});
created.ruleId = rule.id;
await ok('enter shadow', 'POST', `/api/knowledge/quality/rules/${rule.id}/status`, {
targetStatus: 'SHADOW',
reason: 'local calibration verification'
});
const dataset = await ok('create golden dataset', 'POST', '/api/knowledge/quality/golden-datasets', {
datasetCode: marker,
name: 'Local calibration verification',
description: 'Temporary human label used by the local verification script'
});
created.datasetId = dataset.id;
const sample = await ok('add golden sample', 'POST', `/api/knowledge/quality/golden-datasets/${dataset.id}/samples`, {
sampleKey: 'unknown-source-policy',
riskClass: 'HIGH',
expectedDecision: 'BLOCK',
reasonCodes: ['SOURCE_UNKNOWN'],
evidenceRef: 'local-verification:human-reviewed-source-policy'
});
const frozen = await ok('freeze golden dataset', 'POST', `/api/knowledge/quality/golden-datasets/${dataset.id}/freeze`);
assert(frozen.status === 'FROZEN' && /^[a-f0-9]{64}$/.test(frozen.contentHash), 'frozen dataset hash missing');
await rejected('frozen dataset mutation', 'POST', `/api/knowledge/quality/golden-datasets/${dataset.id}/samples`, {
sampleKey: 'late-mutation',
riskClass: 'LOW',
expectedDecision: 'PASS',
reasonCodes: [],
evidenceRef: 'must be rejected'
});
const evaluation = await ok('golden evaluation', 'POST', `/api/knowledge/quality/rules/${rule.id}/evaluations`, {
sampleKey: 'client-supplied-key-must-be-ignored',
expectedDecision: 'PASS',
actualDecision: 'PASS',
confidence: 0.99,
expectedSource: 'GOLDEN',
matchedReasonCodes: [],
runId: `${marker}:shadow`,
goldenSampleId: sample.id
});
assert(evaluation.expectedDecision === 'BLOCK', 'client overrode the frozen golden label');
assert(evaluation.falseAllow === true, 'server did not derive false-allow from the frozen label');
assert(evaluation.goldenSampleId === sample.id, 'golden evaluation lineage is missing');
await rejected('unsafe threshold floor', 'POST', `/api/knowledge/quality/rules/${rule.id}/acceptance-profiles`, {
minTotalSamples: 1,
minGoldenSamples: 1,
minReviewedSamples: 1,
minAgreementRate: 0.5,
maxFalseAllowRate: 0.5,
maxFalseBlockRate: 0.5,
minReviewCoverageRate: 0
});
const profile = await ok('create acceptance profile', 'POST', `/api/knowledge/quality/rules/${rule.id}/acceptance-profiles`, {
minTotalSamples: 100,
minGoldenSamples: 50,
minReviewedSamples: 30,
minAgreementRate: 0.99,
maxFalseAllowRate: 0,
maxFalseBlockRate: 0.02,
minReviewCoverageRate: 0.5
});
await ok('freeze acceptance profile', 'POST',
`/api/knowledge/quality/rules/${rule.id}/acceptance-profiles/${profile.id}/freeze`,
{ reason: 'local risk-threshold verification' });
const readiness = await ok('readiness', 'GET', `/api/knowledge/quality/rules/${rule.id}/readiness`);
assert(readiness.evidenceReady === false, 'insufficient calibration evidence was marked ready');
assert(readiness.enforcementEnabled === false, 'automatic enforcement was enabled');
assert(readiness.reasonCodes.includes('TOTAL_SAMPLE_INSUFFICIENT'), 'missing total-sample failure reason');
assert(readiness.reasonCodes.includes('FALSE_ALLOW_ABOVE_THRESHOLD'), 'missing false-allow failure reason');
await rejected('active transition', 'POST', `/api/knowledge/quality/rules/${rule.id}/status`, {
targetStatus: 'ACTIVE',
reason: 'must remain unavailable'
});
const after = governanceCounts();
assert(before.assets === after.assets, 'asset count changed during calibration verification');
assert(before.outbox === after.outbox, 'index outbox changed during calibration verification');
console.log(JSON.stringify({
passed: true,
frozenHash: true,
serverDerivedGoldenLabel: true,
unsafeThresholdRejected: true,
enforcementEnabled: readiness.enforcementEnabled,
readinessReasonCodes: readiness.reasonCodes,
assetMutationCount: after.assets - before.assets,
indexMutationCount: after.outbox - before.outbox
}, null, 2));
} finally {
cleanup();
}
function readEnv(filePath) {
const values = {};
for (const line of fs.readFileSync(filePath, 'utf8').split(/\r?\n/)) {
const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/);
if (match) values[match[1]] = match[2].replace(/^['"]|['"]$/g, '');
}
return values;
}
function pem(label, body) {
return `-----BEGIN ${label}-----\n${body.match(/.{1,64}/g).join('\n')}\n-----END ${label}-----`;
}
function encryptPayload(payload) {
const keyText = crypto.randomBytes(24).toString('base64').slice(0, 32);
const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(keyText, 'utf8'), null);
cipher.setAutoPadding(true);
const body = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]).toString('base64');
const encryptedKey = crypto.publicEncrypt(
{ key: requestPublicKey, padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(Buffer.from(keyText, 'utf8').toString('base64'), 'utf8')
).toString('base64');
return { body, encryptedKey };
}
function decryptResponse(text, encryptedKey) {
if (!encryptedKey) return JSON.parse(text);
const keyBase64 = crypto.privateDecrypt(
{ key: responsePrivateKey, padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(encryptedKey, 'base64')
).toString('utf8');
const decipher = crypto.createDecipheriv('aes-256-ecb', Buffer.from(keyBase64, 'base64'), null);
decipher.setAutoPadding(true);
return JSON.parse(Buffer.concat([decipher.update(Buffer.from(text, 'base64')), decipher.final()]).toString('utf8'));
}
async function login() {
const encrypted = encryptPayload({
tenantId: '000000',
username: process.env.AIHR_VERIFY_USER || 'admin',
password: process.env.AIHR_VERIFY_PASSWORD || 'admin123',
rememberMe: false,
clientId,
grantType: 'password'
});
const response = await fetch(`${baseUrl}/auth/login`, {
method: 'POST',
headers: { clientid: clientId, 'Content-Type': 'application/json;charset=utf-8', 'encrypt-key': encrypted.encryptedKey },
body: encrypted.body,
signal: AbortSignal.timeout(20_000)
});
const body = decryptResponse(await response.text(), response.headers.get('encrypt-key'));
if (!body.data?.access_token) throw new Error(`admin login failed: ${body.msg || body.code}`);
return body.data.access_token;
}
async function api(method, endpoint, body) {
const response = await fetch(`${baseUrl}${endpoint}`, {
method,
headers: {
clientid: clientId,
Authorization: `Bearer ${token}`,
'Content-Language': 'zh_CN',
...(body === undefined ? {} : { 'Content-Type': 'application/json;charset=utf-8' })
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(20_000)
});
return JSON.parse(await response.text());
}
async function ok(label, method, endpoint, body) {
const response = await api(method, endpoint, body);
if (Number(response.code) !== 200) throw new Error(`${label} failed: ${response.msg || response.code}`);
return response.data;
}
async function rejected(label, method, endpoint, body) {
const response = await api(method, endpoint, body);
if (Number(response.code) === 200) throw new Error(`${label} unexpectedly succeeded`);
}
function mysql(sql) {
return execFileSync('docker', [
'exec', 'wygj-mysql', 'mysql', '-uroot', '-proot', '--default-character-set=utf8mb4', 'ry-vue', '-Nse', sql
], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
}
function governanceCounts() {
const values = mysql("select (select count(*) from aihr_data_asset), (select count(*) from aihr_index_outbox)")
.split(/\s+/).map(Number);
return { assets: values[0], outbox: values[1] };
}
function cleanup() {
if (!created.ruleId && !created.datasetId) return;
const ruleId = Number(created.ruleId) || 0;
const datasetId = Number(created.datasetId) || 0;
mysql(`
delete link from aihr_rule_golden_evaluation link
join aihr_rule_evaluation evaluation on evaluation.id = link.evaluation_id
where evaluation.rule_id = ${ruleId};
delete from aihr_review_sample where rule_id = ${ruleId};
delete from aihr_rule_evaluation where rule_id = ${ruleId};
delete from aihr_rule_acceptance_profile where rule_id = ${ruleId};
delete from aihr_processing_rule_transition where rule_id = ${ruleId};
delete from aihr_processing_rule where id = ${ruleId};
delete from aihr_golden_sample where dataset_id = ${datasetId};
delete from aihr_golden_dataset where id = ${datasetId};
`);
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}