feat(aihr): complete phase two pilot readiness

This commit is contained in:
2026-07-10 19:13:31 +08:00
parent 0219de6fa8
commit 13230e6b9a
48 changed files with 7040 additions and 1647 deletions
+295
View File
@@ -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 };
}
+107
View File
@@ -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
}