296 lines
11 KiB
JavaScript
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 };
|
|
}
|