feat(aihr): complete phase two pilot readiness
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT="$ROOT_DIR/scripts/demo-check.sh"
|
||||
|
||||
assert_contains() {
|
||||
local text="$1"
|
||||
grep -Fq "$text" "$SCRIPT" || {
|
||||
echo "FAIL: demo-check.sh missing: $text" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local text="$1"
|
||||
if grep -Fq "$text" "$SCRIPT"; then
|
||||
echo "FAIL: demo-check.sh still contains unsafe match: $text" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_contains 'AIHR_PILOT_START_DATE'
|
||||
assert_contains 'AIHR_PILOT_END_DATE'
|
||||
assert_contains 'AIHR_DEMO_CHECK_LIBRARY_ONLY'
|
||||
assert_contains 'safe_org AS'
|
||||
assert_contains 'HAVING SUM(s.session_count) >= 10'
|
||||
assert_contains 'HAVING COUNT(DISTINCT ext_party_id) = 1'
|
||||
assert_contains 'finished_time >='
|
||||
assert_contains 'reviewed_time >='
|
||||
assert_not_contains 'p.identity_key = o.ext_party_id OR p.identity_key = o.person_phone'
|
||||
assert_not_contains 's.ext_party_id = o.ext_party_id OR s.ext_party_id = o.person_phone'
|
||||
|
||||
if ! command -v docker >/dev/null || ! docker ps --format '{{.Names}}' | grep -qx 'wygj-mysql'; then
|
||||
echo "SKIP: behavioral pilot gate test requires wygj-mysql"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export AIHR_DEMO_CHECK_LIBRARY_ONLY=true
|
||||
# shellcheck source=../demo-check.sh
|
||||
source "$SCRIPT"
|
||||
|
||||
unset AIHR_PILOT_DB
|
||||
[[ "$(pilot_sql_scalar 'SELECT 1')" == "1" ]] || {
|
||||
echo "FAIL: default ry-vue pilot database is unavailable" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
TEST_DB="wygj_demo_check_test_$$"
|
||||
cleanup() {
|
||||
docker exec wygj-mysql mysql -uroot -proot -e "DROP DATABASE IF EXISTS \`$TEST_DB\`" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 <<SQL >/dev/null
|
||||
CREATE DATABASE \`$TEST_DB\` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
USE \`$TEST_DB\`;
|
||||
CREATE TABLE aihr_practice_scenario (tenant_id varchar(20), position varchar(80), enabled tinyint);
|
||||
CREATE TABLE aihr_practice_session (tenant_id varchar(20), session_id varchar(100), ext_party_id varchar(100), mode varchar(30), finished_time datetime);
|
||||
CREATE TABLE aihr_practice_assignment (tenant_id varchar(20), ext_party_id varchar(100), create_time datetime);
|
||||
CREATE TABLE aihr_org_snapshot (
|
||||
tenant_id varchar(20), employment_status varchar(20), ext_party_id varchar(100),
|
||||
project_code varchar(50), person_phone varchar(30)
|
||||
);
|
||||
CREATE TABLE aihr_practice_calibration (
|
||||
id bigint auto_increment primary key, tenant_id varchar(20), session_id varchar(100),
|
||||
original_score int, corrected_score int, create_time datetime
|
||||
);
|
||||
CREATE TABLE aihr_sop_answer_review (
|
||||
tenant_id varchar(20), status varchar(30), usable tinyint,
|
||||
reviewed_time datetime, create_time datetime
|
||||
);
|
||||
|
||||
INSERT INTO aihr_practice_scenario
|
||||
SELECT '000000','生活顾问',1 FROM (
|
||||
SELECT 1 n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6
|
||||
UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 UNION ALL SELECT 11 UNION ALL SELECT 12
|
||||
) n;
|
||||
INSERT INTO aihr_org_snapshot VALUES
|
||||
('000000','active','A','P1','13900000001'),
|
||||
('000000','active','B','P1','13900000001'),
|
||||
('000000','active','C','P2','D'),
|
||||
('000000','active','D','P2','13900000004'),
|
||||
('000000','active','E','P3','13900000005'),
|
||||
('000000','active','F','P3','13900000006');
|
||||
INSERT INTO aihr_practice_assignment VALUES
|
||||
('000000','D','2026-07-08 08:00:00'),
|
||||
('000000','13900000001','2026-07-08 08:00:00'),
|
||||
('000000','E','2026-07-08 08:00:00'),
|
||||
('000000','13900000005','2026-07-08 08:00:00'),
|
||||
('000000','F','2026-07-08 08:00:00');
|
||||
INSERT INTO aihr_practice_session
|
||||
SELECT '000000', CONCAT('e-', n), CASE WHEN n <= 5 THEN 'E' ELSE '13900000005' END, 'mobile', '2026-07-08 09:00:00'
|
||||
FROM (
|
||||
SELECT 1 n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
|
||||
UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9
|
||||
) n;
|
||||
INSERT INTO aihr_practice_session
|
||||
SELECT '000000', CONCAT('f-', n), 'F','mobile','2026-07-08 10:00:00' FROM (
|
||||
SELECT 1 n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
|
||||
UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10
|
||||
) n;
|
||||
INSERT INTO aihr_practice_session VALUES
|
||||
('000000','e-out','E','mobile','2026-07-06 10:00:00'),
|
||||
('000000','unsafe-inside','D','mobile','2026-07-08 10:00:00');
|
||||
INSERT INTO aihr_practice_calibration (tenant_id,session_id,original_score,corrected_score,create_time) VALUES
|
||||
('000000','f-1',80,82,'2026-07-08 11:00:00'),
|
||||
('000000','unsafe-inside',80,40,'2026-07-08 11:00:00'),
|
||||
('000000','e-out',80,40,'2026-07-06 11:00:00');
|
||||
INSERT INTO aihr_sop_answer_review VALUES
|
||||
('000000','已评审',1,'2026-07-08 12:00:00','2026-07-08 11:00:00'),
|
||||
('000000','已评审',0,'2026-07-06 12:00:00','2026-07-06 11:00:00'),
|
||||
('000000','待评审',NULL,NULL,'2026-07-08 13:00:00'),
|
||||
('000000','待评审',NULL,NULL,'2026-07-06 13:00:00');
|
||||
SQL
|
||||
|
||||
export AIHR_PILOT_DB="$TEST_DB"
|
||||
pilot_output="$(AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 check_pilot_samples)"
|
||||
[[ "$pilot_output" == *'ten_sessions=1/5'* ]] || { echo "FAIL: 9/10 boundary or raw window count: $pilot_output" >&2; exit 1; }
|
||||
[[ "$pilot_output" == *'org_login=2/6'* ]] || { echo "FAIL: unsafe org identities were not excluded: $pilot_output" >&2; exit 1; }
|
||||
[[ "$pilot_output" == *'org_pilot=2/20'* ]] || { echo "FAIL: collision or dual-key identity was miscounted: $pilot_output" >&2; exit 1; }
|
||||
[[ "$pilot_output" == *'projects=1/1-2'* ]] || { echo "FAIL: safe pilot project count: $pilot_output" >&2; exit 1; }
|
||||
[[ "$pilot_output" == *'org_ten_sessions=1/2'* ]] || { echo "FAIL: formal 9/10 boundary: $pilot_output" >&2; exit 1; }
|
||||
[[ "$pilot_output" == *'calibration=1/20 consistency=1/1'* ]] || { echo "FAIL: calibration window: $pilot_output" >&2; exit 1; }
|
||||
[[ "$pilot_output" == *'sop_usable=1/1 sop_pending=1'* ]] || { echo "FAIL: SOP window: $pilot_output" >&2; exit 1; }
|
||||
|
||||
pilot_sql_scalar "INSERT INTO aihr_practice_session VALUES ('000000','e-10','13900000005','mobile','2026-07-09 09:00:00')" >/dev/null
|
||||
pilot_output="$(AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 check_pilot_samples)"
|
||||
[[ "$pilot_output" == *'org_ten_sessions=2/2'* ]] || { echo "FAIL: formal 10/10 boundary: $pilot_output" >&2; exit 1; }
|
||||
|
||||
if (AIHR_PILOT_STRICT=true AIHR_PILOT_START_DATE= AIHR_PILOT_END_DATE= check_pilot_samples) >/tmp/wygj-pilot-strict-test.log 2>&1; then
|
||||
echo "FAIL: strict mode accepted an implicit date window" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq 'strict pilot check requires AIHR_PILOT_START_DATE and AIHR_PILOT_END_DATE' /tmp/wygj-pilot-strict-test.log || {
|
||||
echo "FAIL: strict mode missing-date error is unclear" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "PASS: demo pilot gates use a bounded window and unique formal identities"
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { parseCsv, verifyPilotExportContract } from '../lib/pilot-export-contract.mjs';
|
||||
|
||||
const startDate = '2026-07-07';
|
||||
const endDate = '2026-07-10';
|
||||
|
||||
function validCsv() {
|
||||
const metrics = {
|
||||
pilot_start_date: startDate,
|
||||
pilot_end_date: endDate,
|
||||
data_scope: 'active_org_unique_identity',
|
||||
scenario_count: '12',
|
||||
scenario_count_target: '12',
|
||||
formal_org_login_count: '20',
|
||||
formal_pilot_people_count: '20',
|
||||
formal_pilot_people_target: '20',
|
||||
formal_pilot_project_count: '1',
|
||||
formal_pilot_project_target: '1-2',
|
||||
formal_pilot_scope_ready: 'true',
|
||||
training_count: '1',
|
||||
completion_definition: 'at_least_10_sessions_per_person',
|
||||
completion_people_count: '1',
|
||||
completion_people_total: '20',
|
||||
completion_rate: '5.0%',
|
||||
completion_rate_target: '80.0%',
|
||||
completion_rate_ready: 'false',
|
||||
average_score: '86.0',
|
||||
alert_count: '0',
|
||||
review_rate: '100.0%',
|
||||
calibration_sample_count: '1',
|
||||
ai_human_band_matched_count: '1',
|
||||
calibration_sample_target: '20',
|
||||
calibration_sample_ready: 'false',
|
||||
ai_human_band_consistency_rate: '100.0%',
|
||||
ai_human_band_consistency_target: '70.0%',
|
||||
ai_human_band_consistency_ready: 'false',
|
||||
sop_review_sample_count: '2',
|
||||
sop_answer_usable_count: '2',
|
||||
sop_answer_usable_rate: '100.0%',
|
||||
sop_answer_usable_target: '80.0%',
|
||||
sop_answer_usable_ready: 'true',
|
||||
pilot_metric_ready: 'false'
|
||||
};
|
||||
const lines = ['metric,value', ...Object.entries(metrics).map(([key, value]) => `${key},${value}`)];
|
||||
lines.push('', 'formal_ext_party_id,project_codes,session_id,trainee,scene,score,status,finished_time,summary,review_advice,incentive_point');
|
||||
lines.push('FORMAL-1,P1,session-1,员工甲,投诉处理,86,已复盘,2026-07-08 10:00:00,正式记录,保持回访,3');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
test('accepts a complete formal export whose training summary matches its details', () => {
|
||||
const result = verifyPilotExportContract({
|
||||
csv: validCsv(),
|
||||
startDate,
|
||||
endDate,
|
||||
contentDisposition: 'attachment; filename=aihr-practice-pilot-2026-07-07-2026-07-10.csv'
|
||||
});
|
||||
|
||||
assert.equal(result.metrics.get('training_count'), '1');
|
||||
assert.equal(result.detailRows.length, 1);
|
||||
});
|
||||
|
||||
test('rejects a missing metric and a detail row outside the requested date window', () => {
|
||||
assert.throws(() => verifyPilotExportContract({
|
||||
csv: validCsv().replace('calibration_sample_count,1\n', ''),
|
||||
startDate,
|
||||
endDate,
|
||||
contentDisposition: 'attachment; filename=aihr-practice-pilot-2026-07-07-2026-07-10.csv'
|
||||
}), /missing metric calibration_sample_count/);
|
||||
|
||||
assert.throws(() => verifyPilotExportContract({
|
||||
csv: validCsv().replace('2026-07-08 10:00:00', '2026-07-11 10:00:00'),
|
||||
startDate,
|
||||
endDate,
|
||||
contentDisposition: 'attachment; filename=aihr-practice-pilot-2026-07-07-2026-07-10.csv'
|
||||
}), /outside requested window/);
|
||||
});
|
||||
|
||||
test('rejects contradictory pilot metrics and incomplete formal details', () => {
|
||||
assert.throws(() => verifyPilotExportContract({
|
||||
csv: validCsv().replace('completion_people_total,20', 'completion_people_total,19'),
|
||||
startDate,
|
||||
endDate,
|
||||
contentDisposition: 'attachment; filename=aihr-practice-pilot-2026-07-07-2026-07-10.csv'
|
||||
}), /completion_people_total/);
|
||||
|
||||
assert.throws(() => verifyPilotExportContract({
|
||||
csv: validCsv().replace('training_count,1', 'training_count,2'),
|
||||
startDate,
|
||||
endDate,
|
||||
contentDisposition: 'attachment; filename=aihr-practice-pilot-2026-07-07-2026-07-10.csv'
|
||||
}), /does not match detail rows/);
|
||||
|
||||
assert.throws(() => verifyPilotExportContract({
|
||||
csv: validCsv().replace('FORMAL-1,P1,session-1', ',P1,session-1'),
|
||||
startDate,
|
||||
endDate,
|
||||
contentDisposition: 'attachment; filename=wrong.csv'
|
||||
}), /missing formal identity/);
|
||||
|
||||
assert.throws(() => verifyPilotExportContract({
|
||||
csv: validCsv().replace('ai_human_band_consistency_rate,100.0%', 'ai_human_band_consistency_rate,0.0%'),
|
||||
startDate,
|
||||
endDate,
|
||||
contentDisposition: 'attachment; filename=aihr-practice-pilot-2026-07-07-2026-07-10.csv'
|
||||
}), /ai_human_band_consistency_rate/);
|
||||
});
|
||||
|
||||
test('parses valid quoted CSV fields and rejects malformed quoting', () => {
|
||||
assert.deepEqual(parseCsv('metric,value\nsummary,"逗号,转义""引号""\n第二行"\n'), [
|
||||
['metric', 'value'],
|
||||
['summary', '逗号,转义"引号"\n第二行']
|
||||
]);
|
||||
assert.deepEqual(parseCsv('""'), [['']]);
|
||||
|
||||
assert.throws(() => parseCsv('metric,value\nsummary,"未闭合'), /unclosed quoted field/);
|
||||
assert.throws(() => parseCsv('metric,value\nsummary,值"错误'), /quote must start a field/);
|
||||
assert.throws(() => parseCsv('metric,value\nsummary,"值"错误'), /unexpected character after closing quote/);
|
||||
assert.throws(() => verifyPilotExportContract({
|
||||
csv: validCsv().replace('metric,value', '"metric,value"'),
|
||||
startDate,
|
||||
endDate,
|
||||
contentDisposition: 'attachment; filename=aihr-practice-pilot-2026-07-07-2026-07-10.csv'
|
||||
}), /missing metric,value header/);
|
||||
});
|
||||
|
||||
test('uses raw numerators for threshold readiness instead of rounded percentages', () => {
|
||||
const csv = validCsv()
|
||||
.replace('calibration_sample_count,1', 'calibration_sample_count,203')
|
||||
.replace('ai_human_band_matched_count,1', 'ai_human_band_matched_count,142')
|
||||
.replace('ai_human_band_consistency_rate,100.0%', 'ai_human_band_consistency_rate,70.0%')
|
||||
.replace('calibration_sample_ready,false', 'calibration_sample_ready,true')
|
||||
.replace('sop_review_sample_count,2', 'sop_review_sample_count,2049')
|
||||
.replace('sop_answer_usable_count,2', 'sop_answer_usable_count,1639')
|
||||
.replace('sop_answer_usable_rate,100.0%', 'sop_answer_usable_rate,80.0%')
|
||||
.replace('sop_answer_usable_ready,true', 'sop_answer_usable_ready,false');
|
||||
|
||||
assert.doesNotThrow(() => verifyPilotExportContract({
|
||||
csv,
|
||||
startDate,
|
||||
endDate,
|
||||
contentDisposition: 'attachment; filename=aihr-practice-pilot-2026-07-07-2026-07-10.csv'
|
||||
}));
|
||||
});
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/lib/process-tree.sh"
|
||||
|
||||
sleep 60 &
|
||||
protected_pid="$!"
|
||||
signal_process_snapshot "${protected_pid}|not-the-real-start-time" KILL
|
||||
if ! kill -0 "$protected_pid" >/dev/null 2>&1; then
|
||||
echo "FAIL: identity mismatch killed an unrelated process" >&2
|
||||
exit 1
|
||||
fi
|
||||
kill "$protected_pid" >/dev/null 2>&1 || true
|
||||
wait "$protected_pid" 2>/dev/null || true
|
||||
|
||||
sh -c 'sleep 60 & wait' &
|
||||
parent_pid="$!"
|
||||
sleep 0.1
|
||||
child_pid="$(pgrep -P "$parent_pid" | head -n 1)"
|
||||
|
||||
if [[ -z "$child_pid" ]]; then
|
||||
echo "FAIL: dummy child process was not created" >&2
|
||||
stop_process_tree "$parent_pid" KILL
|
||||
exit 1
|
||||
fi
|
||||
|
||||
stop_process_tree "$parent_pid"
|
||||
wait "$parent_pid" 2>/dev/null || true
|
||||
sleep 0.1
|
||||
|
||||
child_state="$(ps -o stat= -p "$child_pid" 2>/dev/null | xargs || true)"
|
||||
if [[ -n "$child_state" && "$child_state" != Z* ]]; then
|
||||
echo "FAIL: child process $child_pid is still running ($child_state)" >&2
|
||||
stop_process_tree "$child_pid" KILL
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: process tree stopped"
|
||||
|
||||
PROCESS_TREE_TERM_WAIT_LOOPS=2
|
||||
export PROCESS_TREE_TERM_WAIT_LOOPS
|
||||
sh -c 'trap "" TERM; sh -c '\''trap "" TERM; sleep 60'\'' & wait' &
|
||||
stubborn_parent_pid="$!"
|
||||
sleep 0.1
|
||||
stubborn_child_pid="$(pgrep -P "$stubborn_parent_pid" | head -n 1)"
|
||||
|
||||
stop_process_tree "$stubborn_parent_pid"
|
||||
wait "$stubborn_parent_pid" 2>/dev/null || true
|
||||
sleep 0.1
|
||||
|
||||
stubborn_child_state="$(ps -o stat= -p "$stubborn_child_pid" 2>/dev/null | xargs || true)"
|
||||
if [[ -n "$stubborn_child_state" && "$stubborn_child_state" != Z* ]]; then
|
||||
echo "FAIL: TERM-resistant child process $stubborn_child_pid is still running ($stubborn_child_state)" >&2
|
||||
kill -KILL "$stubborn_child_pid" >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: TERM-resistant process tree escalated safely"
|
||||
|
||||
identity_marker="$(mktemp)"
|
||||
rm -f "$identity_marker"
|
||||
process_identity_token() {
|
||||
if [[ ! -f "$identity_marker" ]]; then
|
||||
touch "$identity_marker"
|
||||
echo "first identity"
|
||||
else
|
||||
echo "reused identity"
|
||||
fi
|
||||
}
|
||||
|
||||
reused_snapshot="$(snapshot_process_tree 999999)"
|
||||
rm -f "$identity_marker"
|
||||
if [[ -n "$reused_snapshot" ]]; then
|
||||
echo "FAIL: snapshot retained a process tree after root identity changed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: root identity change invalidates the snapshot"
|
||||
Reference in New Issue
Block a user