Files
prop-ai-hr/scripts/lib/pilot-export-contract.mjs
T

296 lines
11 KiB
JavaScript

const REQUIRED_METRICS = [
'pilot_start_date',
'pilot_end_date',
'data_scope',
'scenario_count',
'scenario_count_target',
'formal_org_login_count',
'formal_pilot_people_count',
'formal_pilot_people_target',
'formal_pilot_project_count',
'formal_pilot_project_target',
'formal_pilot_scope_ready',
'training_count',
'completion_definition',
'completion_people_count',
'completion_people_total',
'completion_rate',
'completion_rate_target',
'completion_rate_ready',
'average_score',
'alert_count',
'review_rate',
'calibration_sample_count',
'ai_human_band_matched_count',
'calibration_sample_target',
'calibration_sample_ready',
'ai_human_band_consistency_rate',
'ai_human_band_consistency_target',
'ai_human_band_consistency_ready',
'sop_review_sample_count',
'sop_answer_usable_count',
'sop_answer_usable_rate',
'sop_answer_usable_target',
'sop_answer_usable_ready',
'pilot_metric_ready'
];
const INTEGER_METRICS = [
'scenario_count',
'scenario_count_target',
'formal_org_login_count',
'formal_pilot_people_count',
'formal_pilot_people_target',
'formal_pilot_project_count',
'training_count',
'completion_people_count',
'completion_people_total',
'alert_count',
'calibration_sample_count',
'ai_human_band_matched_count',
'calibration_sample_target',
'sop_review_sample_count',
'sop_answer_usable_count'
];
const PERCENTAGE_METRICS = [
'completion_rate',
'completion_rate_target',
'review_rate',
'ai_human_band_consistency_rate',
'ai_human_band_consistency_target',
'sop_answer_usable_rate',
'sop_answer_usable_target'
];
const BOOLEAN_METRICS = [
'formal_pilot_scope_ready',
'completion_rate_ready',
'calibration_sample_ready',
'ai_human_band_consistency_ready',
'sop_answer_usable_ready',
'pilot_metric_ready'
];
const DETAIL_HEADERS = [
'formal_ext_party_id',
'project_codes',
'session_id',
'trainee',
'scene',
'score',
'status',
'finished_time',
'summary',
'review_advice',
'incentive_point'
];
function fail(message) {
throw new Error(`pilot export contract: ${message}`);
}
export function parseCsv(csv) {
const rows = [];
let row = [];
let cell = '';
let quoted = false;
let quoteClosed = false;
let fieldStart = true;
const text = String(csv || '').replace(/^\uFEFF/, '');
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (quoted) {
if (char === '"' && text[index + 1] === '"') {
cell += '"';
index += 1;
} else if (char === '"') {
quoted = false;
quoteClosed = true;
} else {
cell += char;
}
continue;
}
if (quoteClosed) {
if (char !== ',' && char !== '\n' && char !== '\r') {
fail('unexpected character after closing quote');
}
quoteClosed = false;
}
if (char === '"') {
if (!fieldStart) fail('quote must start a field');
quoted = true;
fieldStart = false;
continue;
}
if (char === ',') {
row.push(cell);
cell = '';
fieldStart = true;
continue;
}
if (char === '\n' || char === '\r') {
if (char === '\r' && text[index + 1] === '\n') index += 1;
row.push(cell);
rows.push(row);
row = [];
cell = '';
fieldStart = true;
continue;
}
cell += char;
fieldStart = false;
}
if (quoted) fail('unclosed quoted field');
if (cell || row.length || !fieldStart) {
row.push(cell);
rows.push(row);
}
return rows;
}
function isInteger(value) {
return /^\d+$/.test(value);
}
function isPercentage(value) {
return /^\d+(?:\.\d+)?%$/.test(value);
}
function integerMetric(metrics, key) {
return Number(metrics.get(key));
}
function percentageMetric(metrics, key) {
return Number(metrics.get(key).slice(0, -1));
}
function expectedPercentage(part, total) {
return total <= 0 ? '0.0%' : `${(Math.round((part * 1000) / total) / 10).toFixed(1)}%`;
}
function expectedAverage(scores) {
if (!scores.length) return '0.0';
const total = scores.reduce((sum, score) => sum + score, 0);
return (Math.round((total * 10) / scores.length) / 10).toFixed(1);
}
function expectMetric(metrics, key, expected) {
if (metrics.get(key) !== expected) fail(`metric ${key}=${metrics.get(key)}, expected ${expected}`);
}
export function verifyPilotExportContract({ csv, startDate, endDate, contentDisposition }) {
const rows = parseCsv(csv);
const blankRow = rows.findIndex((row) => row.length === 1 && row[0] === '');
if (blankRow < 0) fail('missing summary/detail separator');
const summaryRows = rows.slice(0, blankRow);
const detailRows = rows.slice(blankRow + 1).filter((row) => row.some((cell) => cell !== ''));
if (summaryRows.length < 2 || summaryRows[0].length !== 2
|| summaryRows[0][0] !== 'metric' || summaryRows[0][1] !== 'value') {
fail('missing metric,value header');
}
const metrics = new Map();
for (const row of summaryRows.slice(1)) {
if (row.length !== 2 || !row[0]) fail('invalid metric row');
if (metrics.has(row[0])) fail(`duplicate metric ${row[0]}`);
metrics.set(row[0], row[1]);
}
for (const key of REQUIRED_METRICS) {
if (!metrics.has(key)) fail(`missing metric ${key}`);
}
const exactValues = new Map([
['pilot_start_date', startDate],
['pilot_end_date', endDate],
['data_scope', 'active_org_unique_identity'],
['scenario_count_target', '12'],
['formal_pilot_people_target', '20'],
['formal_pilot_project_target', '1-2'],
['completion_definition', 'at_least_10_sessions_per_person'],
['completion_rate_target', '80.0%'],
['calibration_sample_target', '20'],
['ai_human_band_consistency_target', '70.0%'],
['sop_answer_usable_target', '80.0%']
]);
for (const [key, expected] of exactValues) {
if (metrics.get(key) !== expected) fail(`metric ${key}=${metrics.get(key)}, expected ${expected}`);
}
for (const key of INTEGER_METRICS) {
if (!isInteger(metrics.get(key))) fail(`metric ${key} must be a non-negative integer`);
}
if (!/^\d+(?:\.\d+)?$/.test(metrics.get('average_score'))) {
fail('metric average_score must be numeric');
}
for (const key of PERCENTAGE_METRICS) {
if (!isPercentage(metrics.get(key))) fail(`metric ${key} must be a percentage`);
}
for (const key of BOOLEAN_METRICS) {
if (!['true', 'false'].includes(metrics.get(key))) fail(`metric ${key} must be true or false`);
}
for (const key of PERCENTAGE_METRICS) {
const percentage = percentageMetric(metrics, key);
if (percentage < 0 || percentage > 100) fail(`metric ${key} must be between 0% and 100%`);
}
const formalPeople = integerMetric(metrics, 'formal_pilot_people_count');
const completionTotal = integerMetric(metrics, 'completion_people_total');
const completedPeople = integerMetric(metrics, 'completion_people_count');
const projects = integerMetric(metrics, 'formal_pilot_project_count');
const orgLogin = integerMetric(metrics, 'formal_org_login_count');
if (completionTotal !== formalPeople) {
fail(`completion_people_total ${completionTotal} does not match formal_pilot_people_count ${formalPeople}`);
}
if (completedPeople > completionTotal) fail('completion_people_count exceeds completion_people_total');
const scopeReady = orgLogin >= 20 && formalPeople >= 20 && projects >= 1 && projects <= 2;
expectMetric(metrics, 'formal_pilot_scope_ready', String(scopeReady));
expectMetric(metrics, 'completion_rate', expectedPercentage(completedPeople, completionTotal));
const completionReady = scopeReady && completedPeople * 100 >= completionTotal * 80;
expectMetric(metrics, 'completion_rate_ready', String(completionReady));
const calibrationSamples = integerMetric(metrics, 'calibration_sample_count');
const calibrationMatched = integerMetric(metrics, 'ai_human_band_matched_count');
if (calibrationMatched > calibrationSamples) fail('ai_human_band_matched_count exceeds calibration_sample_count');
expectMetric(metrics, 'ai_human_band_consistency_rate', expectedPercentage(calibrationMatched, calibrationSamples));
const calibrationReady = calibrationSamples >= 20 && calibrationMatched * 100 >= calibrationSamples * 70;
expectMetric(metrics, 'calibration_sample_ready', String(calibrationSamples >= 20));
expectMetric(metrics, 'ai_human_band_consistency_ready', String(calibrationReady));
const sopSamples = integerMetric(metrics, 'sop_review_sample_count');
const sopUsable = integerMetric(metrics, 'sop_answer_usable_count');
if (sopUsable > sopSamples) fail('sop_answer_usable_count exceeds sop_review_sample_count');
expectMetric(metrics, 'sop_answer_usable_rate', expectedPercentage(sopUsable, sopSamples));
const sopReady = sopSamples > 0 && sopUsable * 100 >= sopSamples * 80;
expectMetric(metrics, 'sop_answer_usable_ready', String(sopReady));
const pilotReady = integerMetric(metrics, 'scenario_count') >= 12 && completionReady && calibrationReady && sopReady;
expectMetric(metrics, 'pilot_metric_ready', String(pilotReady));
if (!detailRows.length || detailRows[0].length !== DETAIL_HEADERS.length
|| detailRows[0].some((header, index) => header !== DETAIL_HEADERS[index])) {
fail('missing formal detail header');
}
const detailDataRows = detailRows.slice(1);
if (Number(metrics.get('training_count')) !== detailDataRows.length) {
fail(`training_count ${metrics.get('training_count')} does not match detail rows ${detailDataRows.length}`);
}
const scores = [];
let reviewed = 0;
let alerts = 0;
for (const row of detailDataRows) {
if (row.length !== DETAIL_HEADERS.length) fail(`detail row has ${row.length} columns`);
if (!row[0] || !row[1] || !row[2]) fail('detail row is missing formal identity, project, or session id');
const finishedDate = row[7]?.slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(finishedDate) || finishedDate < startDate || finishedDate > endDate) {
fail(`detail finished_time ${row[7] || '<missing>'} is outside requested window`);
}
const score = Number(row[5]);
if (!Number.isFinite(score) || score < 0 || score > 100) fail(`detail score ${row[5] || '<missing>'} is invalid`);
scores.push(score);
if (row[6] === '已复盘') reviewed += 1;
if (score < 80 || row[6] === '待复盘') alerts += 1;
}
expectMetric(metrics, 'average_score', expectedAverage(scores));
expectMetric(metrics, 'alert_count', String(alerts));
expectMetric(metrics, 'review_rate', expectedPercentage(reviewed, detailDataRows.length));
const expectedFilename = `filename=aihr-practice-pilot-${startDate}-${endDate}.csv`;
if (!String(contentDisposition || '').toLowerCase().includes(expectedFilename)) {
fail(`Content-Disposition is missing ${expectedFilename}`);
}
return { metrics, detailRows: detailDataRows };
}