feat(aihr): complete phase two pilot readiness
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
process_identity_token() {
|
||||
local pid="$1"
|
||||
ps -o lstart= -p "$pid" 2>/dev/null | xargs || true
|
||||
}
|
||||
|
||||
same_process_identity() {
|
||||
local pid="$1"
|
||||
local expected_token="$2"
|
||||
local current_token
|
||||
|
||||
current_token="$(process_identity_token "$pid")"
|
||||
[[ -n "$current_token" && "$current_token" == "$expected_token" ]]
|
||||
}
|
||||
|
||||
snapshot_process_tree() {
|
||||
local pid="$1"
|
||||
local token
|
||||
local child
|
||||
local child_snapshot
|
||||
local descendants=""
|
||||
|
||||
[[ "$pid" =~ ^[0-9]+$ ]] || return 0
|
||||
token="$(process_identity_token "$pid")"
|
||||
[[ -n "$token" ]] || return 0
|
||||
|
||||
while IFS= read -r child; do
|
||||
if [[ -n "$child" ]]; then
|
||||
child_snapshot="$(snapshot_process_tree "$child")"
|
||||
if [[ -n "$child_snapshot" ]]; then
|
||||
descendants="${descendants}${child_snapshot}"$'\n'
|
||||
fi
|
||||
fi
|
||||
done < <(pgrep -P "$pid" 2>/dev/null || true)
|
||||
|
||||
same_process_identity "$pid" "$token" || return 0
|
||||
printf '%s' "$descendants"
|
||||
printf '%s|%s\n' "$pid" "$token"
|
||||
}
|
||||
|
||||
signal_process_snapshot() {
|
||||
local snapshot="$1"
|
||||
local signal="${2:-TERM}"
|
||||
local pid
|
||||
local token
|
||||
|
||||
[[ -n "$snapshot" ]] || return 0
|
||||
while IFS='|' read -r pid token; do
|
||||
if [[ -n "$pid" ]] && same_process_identity "$pid" "$token"; then
|
||||
kill "-$signal" "$pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done <<<"$snapshot"
|
||||
}
|
||||
|
||||
process_snapshot_alive() {
|
||||
local snapshot="$1"
|
||||
local pid
|
||||
local token
|
||||
|
||||
[[ -n "$snapshot" ]] || return 1
|
||||
while IFS='|' read -r pid token; do
|
||||
if [[ -n "$pid" ]] && same_process_identity "$pid" "$token"; then
|
||||
return 0
|
||||
fi
|
||||
done <<<"$snapshot"
|
||||
return 1
|
||||
}
|
||||
|
||||
stop_process_tree() {
|
||||
local root_pid="$1"
|
||||
local wait_loops="${PROCESS_TREE_TERM_WAIT_LOOPS:-20}"
|
||||
local snapshot
|
||||
local descendants
|
||||
local current
|
||||
local all_snapshots
|
||||
|
||||
[[ "$root_pid" =~ ^[0-9]+$ ]] || return 0
|
||||
snapshot="$(snapshot_process_tree "$root_pid")"
|
||||
[[ -n "$snapshot" ]] || return 0
|
||||
all_snapshots="$snapshot"
|
||||
|
||||
# Stop the first child snapshot before the wrapper so it cannot orphan the
|
||||
# already-known Java/Vite process when the wrapper exits.
|
||||
descendants="$(printf '%s\n' "$snapshot" | sed '$d')"
|
||||
signal_process_snapshot "$descendants" TERM
|
||||
|
||||
current="$(snapshot_process_tree "$root_pid")"
|
||||
if [[ -n "$current" ]]; then
|
||||
all_snapshots="${all_snapshots}"$'\n'"${current}"
|
||||
signal_process_snapshot "$current" TERM
|
||||
fi
|
||||
|
||||
for _ in $(seq 1 "$wait_loops"); do
|
||||
current="$(snapshot_process_tree "$root_pid")"
|
||||
if [[ -n "$current" ]]; then
|
||||
all_snapshots="${all_snapshots}"$'\n'"${current}"
|
||||
signal_process_snapshot "$current" TERM
|
||||
fi
|
||||
if ! process_snapshot_alive "$all_snapshots"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
signal_process_snapshot "$all_snapshots" KILL
|
||||
}
|
||||
Reference in New Issue
Block a user