feat: govern knowledge assets and source citations
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
#!/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);
|
||||
}
|
||||
Reference in New Issue
Block a user