Files
prop-ai-hr/scripts/verify-demo-questions.mjs
T

304 lines
12 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { verifyPilotExportContract } from './lib/pilot-export-contract.mjs';
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
const args = new Map(process.argv.slice(2).map((arg) => {
const [key, ...rest] = arg.replace(/^--/, '').split('=');
return [key, rest.join('=') || 'true'];
}));
const baseUrl = args.get('base-url') || process.env.AIHR_BASE_URL || 'https://wygj-api.localhost';
const verbose = args.has('verbose');
const parsedBaseUrl = new URL(baseUrl);
if (parsedBaseUrl.protocol === 'https:' && parsedBaseUrl.hostname.endsWith('.localhost') && !process.env.NODE_EXTRA_CA_CERTS) {
// ponytail: local portless CA is not visible to Node fetch; only relax TLS for .localhost dev verification.
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
}
const file = path.resolve(root, args.get('file') || 'docs/20260708/发布会演示候选题单.md');
const prefixes = (args.get('prefix') || 'Q').split(',').map((item) => item.trim()).filter(Boolean);
const ids = new Set((args.get('ids') || '').split(',').map((item) => item.trim()).filter(Boolean));
const limit = Number(args.get('limit') || 0);
const orgDryRun = args.has('org-dry-run');
const pilotExport = args.has('pilot-export');
const pilotStartDate = args.get('start-date') || '';
const pilotEndDate = args.get('end-date') || '';
function readEnv(filePath) {
const env = {};
const text = fs.readFileSync(filePath, 'utf8');
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/);
if (!match) continue;
env[match[1]] = match[2].replace(/^['"]|['"]$/g, '');
}
return env;
}
function pem(label, body) {
return `-----BEGIN ${label}-----\n${body.match(/.{1,64}/g).join('\n')}\n-----END ${label}-----`;
}
const frontendEnv = readEnv(path.join(root, 'frontend/.env.development'));
const clientid = process.env.AIHR_CLIENT_ID || frontendEnv.VITE_APP_CLIENT_ID;
const requestPublicKey = pem('PUBLIC KEY', frontendEnv.VITE_APP_RSA_PUBLIC_KEY);
const responsePrivateKey = pem('PRIVATE KEY', frontendEnv.VITE_APP_RSA_PRIVATE_KEY);
function aesEncrypt(text, keyText) {
const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(keyText, 'utf8'), null);
cipher.setAutoPadding(true);
return Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]).toString('base64');
}
function aesDecrypt(base64Text, key) {
const decipher = crypto.createDecipheriv('aes-256-ecb', key, null);
decipher.setAutoPadding(true);
return Buffer.concat([decipher.update(Buffer.from(base64Text, 'base64')), decipher.final()]).toString('utf8');
}
function encryptPayload(payload) {
const keyText = crypto.randomBytes(24).toString('base64').slice(0, 32);
const encryptedBody = aesEncrypt(JSON.stringify(payload), keyText);
const keyBase64 = Buffer.from(keyText, 'utf8').toString('base64');
const encryptedKey = crypto.publicEncrypt(
{ key: requestPublicKey, padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(keyBase64, 'utf8')
).toString('base64');
return { encryptedBody, 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');
return JSON.parse(aesDecrypt(text, Buffer.from(keyBase64, 'base64')));
}
async function request(method, endpoint, body, token, encrypted = false) {
const headers = { clientid, 'Content-Language': 'zh_CN' };
let payload;
if (token) headers.Authorization = `Bearer ${token}`;
if (body !== undefined) {
headers['Content-Type'] = 'application/json;charset=utf-8';
if (encrypted) {
const encryptedPayload = encryptPayload(body);
payload = encryptedPayload.encryptedBody;
headers['encrypt-key'] = encryptedPayload.encryptedKey;
} else {
payload = JSON.stringify(body);
}
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 70000);
try {
const response = await fetch(`${baseUrl}${endpoint}`, { method, headers, body: payload, signal: controller.signal });
const text = await response.text();
const data = encrypted ? decryptResponse(text, response.headers.get('encrypt-key')) : JSON.parse(text);
return { status: response.status, data };
} finally {
clearTimeout(timeout);
}
}
async function requestText(method, endpoint, token) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 70000);
try {
const response = await fetch(`${baseUrl}${endpoint}`, {
method,
headers: { clientid, 'Content-Language': 'zh_CN', Authorization: `Bearer ${token}` },
signal: controller.signal
});
return {
status: response.status,
contentType: response.headers.get('content-type') || '',
disposition: response.headers.get('content-disposition') || '',
text: await response.text()
};
} finally {
clearTimeout(timeout);
}
}
async function login() {
if (process.env.AIHR_VERIFY_TOKEN) return process.env.AIHR_VERIFY_TOKEN;
const codeResponse = await request('GET', '/auth/code');
const uuid = codeResponse.data?.data?.uuid;
if (!uuid) throw new Error('captcha uuid missing');
const redisKey = `global:captcha_codes:${uuid}`;
const redisValue = execFileSync(
'docker',
['exec', 'wygj-redis', 'redis-cli', '-a', 'ruoyi123', '--raw', 'get', redisKey],
{ encoding: 'utf8' }
).trim().split('\n').pop().replace(/^"|"$/g, '');
const loginResponse = await request('POST', '/auth/login', {
tenantId: '000000',
username: process.env.AIHR_VERIFY_USER || 'admin',
password: process.env.AIHR_VERIFY_PASSWORD || 'admin123',
rememberMe: false,
code: redisValue,
uuid,
clientId: clientid,
grantType: 'password'
}, undefined, true);
const token = loginResponse.data?.data?.access_token;
if (!token) throw new Error(`login failed: ${loginResponse.data?.msg || loginResponse.status}`);
return token;
}
function parseQuestions(markdown) {
const rows = [];
for (const line of markdown.split(/\r?\n/)) {
if (!line.startsWith('|')) continue;
const cells = line.split('|').slice(1, -1).map((cell) => cell.trim());
if (cells.length < 5 || !/^[A-Z]\d+$/u.test(cells[0])) continue;
if (ids.size && !ids.has(cells[0])) continue;
if (!prefixes.some((prefix) => cells[0].startsWith(prefix))) continue;
rows.push({
id: cells[0],
scene: cells[1],
question: cells[2],
sources: [...cells[3].matchAll(/`([^`]+)`/g)].map((match) => match[1])
});
}
return limit > 0 ? rows.slice(0, limit) : rows;
}
function hasExpectedSource(result, expectedSources) {
if (!expectedSources.length) return true;
const haystack = [
...(result.docs || []).map((item) => item.title || item.docName || item.name || ''),
...(result.snippets || []).map((item) => item.title || item.docName || item.name || '')
].join('\n');
return expectedSources.some((source) => haystack.includes(source.replace(/\s+/g, ' ').trim()));
}
function evidenceTitles(result) {
return [
...(result.docs || []).map((item) => item.title || item.docName || item.name || ''),
...(result.snippets || []).map((item) => item.title || item.docName || item.name || '')
].filter(Boolean);
}
const token = await login();
if (pilotExport) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(pilotStartDate) || !/^\d{4}-\d{2}-\d{2}$/.test(pilotEndDate)) {
throw new Error('--pilot-export requires --start-date=YYYY-MM-DD and --end-date=YYYY-MM-DD');
}
const missingWindow = await requestText('GET', '/api/train/practice/export', token);
let missingWindowData;
try {
missingWindowData = JSON.parse(missingWindow.text);
} catch {
missingWindowData = null;
}
if (missingWindow.status < 400 && (missingWindow.contentType.includes('text/csv') || missingWindowData?.code === 200)) {
console.error('FAIL: pilot export accepted a request without an explicit date window');
process.exit(1);
}
const endpoint = `/api/train/practice/export?startDate=${encodeURIComponent(pilotStartDate)}&endDate=${encodeURIComponent(pilotEndDate)}`;
const response = await requestText('GET', endpoint, token);
if (response.status !== 200 || !response.contentType.includes('text/csv')) {
console.error(`FAIL: pilot export HTTP ${response.status}, content-type=${response.contentType}`);
if (verbose) console.error(response.text.slice(0, 1000));
process.exit(1);
}
let verifiedExport;
try {
verifiedExport = verifyPilotExportContract({
csv: response.text,
startDate: pilotStartDate,
endDate: pilotEndDate,
contentDisposition: response.disposition
});
} catch (error) {
console.error(`FAIL: ${error.message}`);
process.exit(1);
}
const { metrics } = verifiedExport;
console.log(
`PILOT_EXPORT window=${pilotStartDate}..${pilotEndDate}`
+ ` org_login=${metrics.get('formal_org_login_count')}`
+ ` people=${metrics.get('formal_pilot_people_count')}`
+ ` projects=${metrics.get('formal_pilot_project_count')}`
+ ` ten_sessions=${metrics.get('completion_people_count')}/${metrics.get('completion_people_total')}`
+ ` calibration=${metrics.get('calibration_sample_count')}`
+ ` sop=${metrics.get('sop_review_sample_count')}`
+ ` ready=${metrics.get('pilot_metric_ready')}`
);
process.exit(0);
}
if (orgDryRun) {
const response = await request('POST', '/api/aihr/org/sync', {
dryRun: true,
replaceExisting: true,
pageSize: 500,
maxPages: 200
}, token);
const data = response.data?.data;
if (response.status !== 200 || response.data?.code !== 200 || !data?.dryRun) {
console.error(`FAIL: org dry-run failed: ${response.data?.msg || response.status}`);
process.exit(1);
}
console.log(`ORG_DRY_RUN employees=${data.employeeCount} phone_linked=${data.phoneLinked} masked_phone=${data.maskedPhone} suspect_text=${data.suspectText} synced=${data.syncedCount}`);
for (const warning of data.warnings || []) {
console.log(`WARN ${warning}`);
}
process.exit(0);
}
const questions = parseQuestions(fs.readFileSync(file, 'utf8'));
if (!questions.length) {
console.error(`FAIL: no questions found in ${file} for prefix ${prefixes.join(',')}`);
process.exit(1);
}
const vector = await request('GET', '/api/knowledge/doc/vector-index-status', undefined, token);
const vectorStatus = vector.data?.data;
if (!vectorStatus?.matched) {
console.error(`FAIL: vector index mismatch: ${vectorStatus?.message || 'unknown'}`);
process.exit(1);
}
let failed = 0;
let warned = 0;
console.log(`VERIFY ${questions.length} question(s), prefix=${prefixes.join(',')}, base=${baseUrl}`);
for (const item of questions) {
const response = await request('POST', '/api/knowledge/search', {
queryText: item.question,
category: 'sop',
position: '生活顾问',
source: 'verify_demo_questions'
}, token);
const data = response.data?.data || {};
const docs = data.docs || [];
const snippets = data.snippets || [];
const ok = response.status === 200 && response.data?.code === 200 && Boolean((data.answer || '').trim()) && (docs.length || snippets.length);
const sourceOk = hasExpectedSource(data, item.sources);
if (!ok) {
failed += 1;
console.log(`FAIL ${item.id} ${item.scene}: ${item.question}`);
continue;
}
if (!sourceOk) {
warned += 1;
console.log(`WARN ${item.id} ${item.scene}: answered, but top evidence missed listed source`);
if (verbose) {
console.log(` evidence: ${evidenceTitles(data).join(' | ') || '-'}`);
}
} else {
console.log(`OK ${item.id} ${item.scene}`);
if (verbose) {
console.log(` evidence: ${evidenceTitles(data).join(' | ') || '-'}`);
}
}
}
console.log(`SUMMARY ok=${questions.length - failed}/${questions.length} warnings=${warned} vector=${vectorStatus.embeddedFragments}/${vectorStatus.fragments}`);
process.exit(failed ? 1 : 0);