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
+183 -19
View File
@@ -47,7 +47,9 @@ check_mobile_h5_shell() {
}
pilot_sql_scalar() {
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 -N -B -D ry-vue -e "$1" 2>/dev/null
local database="${AIHR_PILOT_DB:-ry-vue}"
[[ "$database" =~ ^[A-Za-z0-9_-]+$ ]] || fail "invalid AIHR_PILOT_DB: $database"
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 -N -B -D "$database" -e "$1" 2>/dev/null
}
check_pilot_samples() {
@@ -57,46 +59,196 @@ check_pilot_samples() {
return
fi
local scenario_count completed_people pilot_people calibration_stats calibration_count calibration_matched sop_total sop_usable sop_pending
if ! pilot_sql_scalar "SELECT 1" >/dev/null; then
[[ "${AIHR_PILOT_STRICT:-false}" != "true" ]] || fail "pilot DB sample counts unavailable (database connection failed)"
echo "SKIP: pilot DB sample counts (database connection failed)"
return
fi
if [[ "${AIHR_PILOT_STRICT:-false}" == "true" ]] && {
[[ -z "${AIHR_PILOT_START_DATE:-}" ]] || [[ -z "${AIHR_PILOT_END_DATE:-}" ]]
}; then
fail "strict pilot check requires AIHR_PILOT_START_DATE and AIHR_PILOT_END_DATE"
fi
local pilot_start="${AIHR_PILOT_START_DATE:-$(date +%F)}"
local pilot_end="${AIHR_PILOT_END_DATE:-$pilot_start}"
[[ "$pilot_start" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || fail "invalid AIHR_PILOT_START_DATE: $pilot_start"
[[ "$pilot_end" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || fail "invalid AIHR_PILOT_END_DATE: $pilot_end"
[[ "$(pilot_sql_scalar "SELECT DATE('$pilot_start') IS NOT NULL")" == "1" ]] || fail "invalid AIHR_PILOT_START_DATE: $pilot_start"
[[ "$(pilot_sql_scalar "SELECT DATE('$pilot_end') IS NOT NULL")" == "1" ]] || fail "invalid AIHR_PILOT_END_DATE: $pilot_end"
[[ "$pilot_start" > "$pilot_end" ]] && fail "pilot start date must not be after end date"
local scenario_count completed_people pilot_people org_active org_phone_linked org_pilot_people org_pilot_projects org_completed_people
local calibration_stats calibration_count calibration_matched sop_total sop_usable sop_pending
local org_identity_cte="
WITH active_org AS (
SELECT DISTINCT ext_party_id, project_code, person_phone
FROM aihr_org_snapshot
WHERE tenant_id = '000000' AND employment_status = 'active'
AND person_phone IS NOT NULL AND person_phone <> ''
), all_org_keys AS (
SELECT ext_party_id AS identity_key, ext_party_id
FROM active_org
WHERE ext_party_id IS NOT NULL AND ext_party_id <> ''
UNION ALL
SELECT person_phone AS identity_key, ext_party_id
FROM active_org
), conflict_keys AS (
SELECT identity_key
FROM all_org_keys
GROUP BY identity_key
HAVING COUNT(DISTINCT ext_party_id) > 1
), unsafe_org AS (
SELECT DISTINCT k.ext_party_id
FROM all_org_keys k
JOIN conflict_keys c ON c.identity_key = k.identity_key
), safe_org AS (
SELECT o.ext_party_id, o.project_code, o.person_phone
FROM active_org o
LEFT JOIN unsafe_org u ON u.ext_party_id = o.ext_party_id
WHERE u.ext_party_id IS NULL
), org_key_candidates AS (
SELECT ext_party_id AS identity_key, ext_party_id
FROM safe_org
WHERE ext_party_id IS NOT NULL AND ext_party_id <> ''
UNION ALL
SELECT person_phone AS identity_key, ext_party_id
FROM safe_org
), org_identity AS (
SELECT identity_key, MIN(ext_party_id) AS ext_party_id
FROM org_key_candidates
GROUP BY identity_key
HAVING COUNT(DISTINCT ext_party_id) = 1
)"
scenario_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_practice_scenario WHERE tenant_id = '000000' AND position = '生活顾问' AND enabled = 1")"
completed_people="$(pilot_sql_scalar "SELECT COUNT(*) FROM (SELECT DISTINCT ext_party_id FROM aihr_practice_session WHERE tenant_id = '000000' AND mode = 'mobile') completed_people")"
completed_people="$(pilot_sql_scalar "
SELECT COUNT(*)
FROM (
SELECT ext_party_id
FROM aihr_practice_session
WHERE tenant_id = '000000' AND mode = 'mobile'
AND finished_time >= '${pilot_start} 00:00:00'
AND finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
GROUP BY ext_party_id
HAVING COUNT(*) >= 10
) completed_people")"
pilot_people="$(pilot_sql_scalar "
SELECT COUNT(*)
FROM (
SELECT ext_party_id
FROM aihr_practice_session
WHERE tenant_id = '000000' AND mode = 'mobile'
AND finished_time >= '${pilot_start} 00:00:00'
AND finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
UNION
SELECT ext_party_id
FROM aihr_practice_assignment
WHERE tenant_id = '000000'
AND status = '待训练'
AND (source <> 'daily' OR DATE(create_time) = CURRENT_DATE())
AND create_time >= '${pilot_start} 00:00:00'
AND create_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
) pilot_people")"
org_active="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_org_snapshot WHERE tenant_id = '000000' AND employment_status = 'active'")"
org_phone_linked="$(pilot_sql_scalar "$org_identity_cte
SELECT COUNT(DISTINCT ext_party_id)
FROM safe_org")"
org_pilot_people="$(pilot_sql_scalar "
$org_identity_cte,
pilot_identity AS (
SELECT ext_party_id identity_key
FROM aihr_practice_session
WHERE tenant_id = '000000' AND mode = 'mobile'
AND finished_time >= '${pilot_start} 00:00:00'
AND finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
UNION
SELECT ext_party_id identity_key
FROM aihr_practice_assignment
WHERE tenant_id = '000000'
AND create_time >= '${pilot_start} 00:00:00'
AND create_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
)
SELECT COUNT(DISTINCT o.ext_party_id)
FROM pilot_identity p
JOIN org_identity o ON o.identity_key = p.identity_key")"
org_pilot_projects="$(pilot_sql_scalar "
$org_identity_cte,
pilot_identity AS (
SELECT ext_party_id identity_key
FROM aihr_practice_session
WHERE tenant_id = '000000' AND mode = 'mobile'
AND finished_time >= '${pilot_start} 00:00:00'
AND finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
UNION
SELECT ext_party_id identity_key
FROM aihr_practice_assignment
WHERE tenant_id = '000000'
AND create_time >= '${pilot_start} 00:00:00'
AND create_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
), pilot_org AS (
SELECT DISTINCT o.ext_party_id
FROM pilot_identity p
JOIN org_identity o ON o.identity_key = p.identity_key
)
SELECT COUNT(DISTINCT o.project_code)
FROM pilot_org p
JOIN safe_org o ON o.ext_party_id = p.ext_party_id
WHERE o.project_code IS NOT NULL AND o.project_code <> ''")"
org_completed_people="$(pilot_sql_scalar "
$org_identity_cte,
session_identity AS (
SELECT ext_party_id AS identity_key, COUNT(*) AS session_count
FROM aihr_practice_session
WHERE tenant_id = '000000' AND mode = 'mobile'
AND finished_time >= '${pilot_start} 00:00:00'
AND finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
GROUP BY ext_party_id
), completed_org AS (
SELECT o.ext_party_id
FROM session_identity s
JOIN org_identity o ON o.identity_key = s.identity_key
GROUP BY o.ext_party_id
HAVING SUM(s.session_count) >= 10
)
SELECT COUNT(*)
FROM completed_org")"
calibration_stats="$(pilot_sql_scalar "
$org_identity_cte,
formal_sessions AS (
SELECT s.session_id
FROM aihr_practice_session s
JOIN org_identity o ON o.identity_key = s.ext_party_id
WHERE s.tenant_id = '000000' AND s.mode = 'mobile'
AND s.finished_time >= '${pilot_start} 00:00:00'
AND s.finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
), latest_calibration AS (
SELECT c.session_id, MAX(c.id) AS id
FROM aihr_practice_calibration c
JOIN formal_sessions s ON s.session_id = c.session_id
WHERE c.tenant_id = '000000' AND c.original_score IS NOT NULL AND c.corrected_score IS NOT NULL
AND c.create_time >= '${pilot_start} 00:00:00'
AND c.create_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
GROUP BY c.session_id
)
SELECT COUNT(*) AS total,
COALESCE(SUM(CASE
WHEN (CASE WHEN c.original_score < 60 THEN 0 WHEN c.original_score < 75 THEN 1 WHEN c.original_score < 90 THEN 2 ELSE 3 END)
= (CASE WHEN c.corrected_score < 60 THEN 0 WHEN c.corrected_score < 75 THEN 1 WHEN c.corrected_score < 90 THEN 2 ELSE 3 END)
THEN 1 ELSE 0 END), 0) AS matched
FROM aihr_practice_calibration c
JOIN (
SELECT session_id, MAX(id) AS id
FROM aihr_practice_calibration
WHERE tenant_id = '000000' AND original_score IS NOT NULL AND corrected_score IS NOT NULL
GROUP BY session_id
) latest ON latest.id = c.id
JOIN latest_calibration latest ON latest.id = c.id
WHERE c.tenant_id = '000000'")"
read -r calibration_count calibration_matched <<<"$calibration_stats"
sop_total="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status = '已评审'")"
sop_usable="$(pilot_sql_scalar "SELECT COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status = '已评审'")"
sop_pending="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status <> '已评审'")"
echo "OK: pilot gates scenarios=${scenario_count}/12 completion=${completed_people}/${pilot_people} calibration=${calibration_count}/20 consistency=${calibration_matched}/${calibration_count} sop_usable=${sop_usable}/${sop_total} sop_pending=${sop_pending}"
sop_total="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status = '已评审' AND reviewed_time >= '${pilot_start} 00:00:00' AND reviewed_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)")"
sop_usable="$(pilot_sql_scalar "SELECT COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status = '已评审' AND reviewed_time >= '${pilot_start} 00:00:00' AND reviewed_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)")"
sop_pending="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_sop_answer_review WHERE tenant_id = '000000' AND status <> '已评审' AND create_time >= '${pilot_start} 00:00:00' AND create_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)")"
echo "OK: pilot gates window=${pilot_start}..${pilot_end} scenarios=${scenario_count}/12 ten_sessions=${completed_people}/${pilot_people} org_login=${org_phone_linked}/${org_active} org_pilot=${org_pilot_people}/20 projects=${org_pilot_projects}/1-2 org_ten_sessions=${org_completed_people}/${org_pilot_people} calibration=${calibration_count}/20 consistency=${calibration_matched}/${calibration_count} sop_usable=${sop_usable}/${sop_total} sop_pending=${sop_pending}"
[[ "${AIHR_PILOT_STRICT:-false}" != "true" ]] || {
(( scenario_count >= 12 )) || fail "pilot enabled scenarios ${scenario_count}/12"
(( pilot_people > 0 )) || fail "pilot completion samples missing"
(( completed_people * 100 >= pilot_people * 80 )) || fail "pilot completion rate below 80% (${completed_people}/${pilot_people})"
(( org_phone_linked >= 20 )) || fail "pilot mobile-login org members ${org_phone_linked}/20 (active org ${org_active})"
(( org_pilot_people >= 20 )) || fail "pilot activity matched to active org ${org_pilot_people}/20"
(( org_pilot_projects >= 1 && org_pilot_projects <= 2 )) || fail "pilot project scope must be 1-2 projects (actual ${org_pilot_projects})"
(( org_completed_people * 100 >= org_pilot_people * 80 )) || fail "pilot org members with at least 10 completed sessions below 80% (${org_completed_people}/${org_pilot_people})"
(( calibration_count >= 20 )) || fail "pilot calibration samples ${calibration_count}/20"
(( calibration_matched * 100 >= calibration_count * 70 )) || fail "pilot AI/human band consistency below 70%"
(( sop_total > 0 )) || fail "pilot SOP review samples missing"
@@ -104,6 +256,10 @@ check_pilot_samples() {
}
}
if [[ "${AIHR_DEMO_CHECK_LIBRARY_ONLY:-false}" == "true" ]]; then
return 0 2>/dev/null || exit 0
fi
need curl
check_url "frontend" "$FRONTEND_URL/"
@@ -132,6 +288,10 @@ contains mobile-uni/src/pages.json "pages/user/today/index"
contains mobile-uni/src/pages.json "pages/user/learning/index"
contains mobile-uni/src/pages.json "pages/supervisor/index/index"
contains mobile-uni/src/pages.json "pages/candidate/index/index"
contains mobile-uni/src/pages.json "pages/candidate/interview/index"
contains mobile-uni/src/pages.json "pages/candidate/materials/index"
contains mobile-uni/src/pages.json "pages/candidate/progress/index"
contains mobile-uni/src/pages.json "pages/candidate/study/index"
contains mobile-uni/src/pages/auth/login/index.vue "/resource/sms/code"
contains mobile-uni/src/pages/auth/login/index.vue "/auth/mobile/sms-login"
contains mobile-uni/src/services/home.ts "/api/aihr/mobile/home/"
@@ -145,6 +305,10 @@ contains mobile-uni/src/services/knowledge.ts "/api/knowledge/summary-card"
contains mobile-uni/src/services/case.ts "/api/knowledge/case/upload"
contains mobile-uni/src/services/candidate.ts "/api/recruit/interview/start"
contains mobile-uni/src/services/candidate.ts "/api/aihr/mobile/candidate/materials"
contains mobile-uni/src/pages/candidate/interview/index.vue "startCandidateInterview"
contains mobile-uni/src/pages/candidate/materials/index.vue "uploadCandidateMaterial"
contains mobile-uni/src/pages/candidate/progress/index.vue "buildCandidateFlow"
contains mobile-uni/src/pages/candidate/study/index.vue "searchKnowledge"
contains mobile-uni/src/pages/user/learning/index.vue "岗位 SOP"
contains mobile-uni/src/pages/user/learning/index.vue "getPracticeAssignments"
contains mobile-uni/src/pages/user/learning/index.vue "searchKnowledge"
@@ -186,9 +350,9 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java "Duration.ofSeconds(5)"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java "待复盘对练"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews"
contains mobile-uni/src/pages/supervisor/index/index.vue "人工贡献分"
contains mobile-uni/src/pages/supervisor/review/index.vue "人工贡献分"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews/{id}/reviewed"
contains mobile-uni/src/pages/supervisor/index/index.vue "标记已复盘"
contains mobile-uni/src/pages/supervisor/review/index.vue "标记已复盘"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "source = 'retry'"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "maskSensitiveText"
contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/practice/assignments"
+5 -3
View File
@@ -2,6 +2,7 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/scripts/lib/process-tree.sh"
PIDS=()
usage() {
@@ -20,7 +21,7 @@ EOF
cleanup() {
trap - INT TERM EXIT
for pid in "${PIDS[@]}"; do
kill "$pid" >/dev/null 2>&1 || true
stop_process_tree "$pid"
done
}
@@ -94,14 +95,15 @@ stop_portless_route() {
fi
echo "Stopping portless route $name: $pids"
kill $pids >/dev/null 2>&1 || true
for pid in $pids; do
stop_process_tree "$pid"
done
for _ in {1..20}; do
if ! portless list 2>/dev/null | grep -q "https://${name}[.]localhost"; then
return
fi
sleep 0.2
done
kill -9 $pids >/dev/null 2>&1 || true
}
stop_launchctl_job() {
+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
}
@@ -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'
}));
});
+79
View File
@@ -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"
+92 -1
View File
@@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { verifyPilotExportContract } from './lib/pilot-export-contract.mjs';
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
const args = new Map(process.argv.slice(2).map((arg) => {
@@ -21,6 +22,10 @@ const file = path.resolve(root, args.get('file') || 'docs/20260708/发布会演
const prefixes = (args.get('prefix') || 'Q').split(',').map((item) => item.trim()).filter(Boolean);
const ids = new Set((args.get('ids') || '').split(',').map((item) => item.trim()).filter(Boolean));
const limit = Number(args.get('limit') || 0);
const orgDryRun = args.has('org-dry-run');
const pilotExport = args.has('pilot-export');
const pilotStartDate = args.get('start-date') || '';
const pilotEndDate = args.get('end-date') || '';
function readEnv(filePath) {
const env = {};
@@ -100,6 +105,26 @@ async function request(method, endpoint, body, token, encrypted = false) {
}
}
async function requestText(method, endpoint, token) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 70000);
try {
const response = await fetch(`${baseUrl}${endpoint}`, {
method,
headers: { clientid, 'Content-Language': 'zh_CN', Authorization: `Bearer ${token}` },
signal: controller.signal
});
return {
status: response.status,
contentType: response.headers.get('content-type') || '',
disposition: response.headers.get('content-disposition') || '',
text: await response.text()
};
} finally {
clearTimeout(timeout);
}
}
async function login() {
if (process.env.AIHR_VERIFY_TOKEN) return process.env.AIHR_VERIFY_TOKEN;
const codeResponse = await request('GET', '/auth/code');
@@ -160,13 +185,79 @@ function evidenceTitles(result) {
].filter(Boolean);
}
const token = await login();
if (pilotExport) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(pilotStartDate) || !/^\d{4}-\d{2}-\d{2}$/.test(pilotEndDate)) {
throw new Error('--pilot-export requires --start-date=YYYY-MM-DD and --end-date=YYYY-MM-DD');
}
const missingWindow = await requestText('GET', '/api/train/practice/export', token);
let missingWindowData;
try {
missingWindowData = JSON.parse(missingWindow.text);
} catch {
missingWindowData = null;
}
if (missingWindow.status < 400 && (missingWindow.contentType.includes('text/csv') || missingWindowData?.code === 200)) {
console.error('FAIL: pilot export accepted a request without an explicit date window');
process.exit(1);
}
const endpoint = `/api/train/practice/export?startDate=${encodeURIComponent(pilotStartDate)}&endDate=${encodeURIComponent(pilotEndDate)}`;
const response = await requestText('GET', endpoint, token);
if (response.status !== 200 || !response.contentType.includes('text/csv')) {
console.error(`FAIL: pilot export HTTP ${response.status}, content-type=${response.contentType}`);
if (verbose) console.error(response.text.slice(0, 1000));
process.exit(1);
}
let verifiedExport;
try {
verifiedExport = verifyPilotExportContract({
csv: response.text,
startDate: pilotStartDate,
endDate: pilotEndDate,
contentDisposition: response.disposition
});
} catch (error) {
console.error(`FAIL: ${error.message}`);
process.exit(1);
}
const { metrics } = verifiedExport;
console.log(
`PILOT_EXPORT window=${pilotStartDate}..${pilotEndDate}`
+ ` org_login=${metrics.get('formal_org_login_count')}`
+ ` people=${metrics.get('formal_pilot_people_count')}`
+ ` projects=${metrics.get('formal_pilot_project_count')}`
+ ` ten_sessions=${metrics.get('completion_people_count')}/${metrics.get('completion_people_total')}`
+ ` calibration=${metrics.get('calibration_sample_count')}`
+ ` sop=${metrics.get('sop_review_sample_count')}`
+ ` ready=${metrics.get('pilot_metric_ready')}`
);
process.exit(0);
}
if (orgDryRun) {
const response = await request('POST', '/api/aihr/org/sync', {
dryRun: true,
replaceExisting: true,
pageSize: 500,
maxPages: 200
}, token);
const data = response.data?.data;
if (response.status !== 200 || response.data?.code !== 200 || !data?.dryRun) {
console.error(`FAIL: org dry-run failed: ${response.data?.msg || response.status}`);
process.exit(1);
}
console.log(`ORG_DRY_RUN employees=${data.employeeCount} phone_linked=${data.phoneLinked} masked_phone=${data.maskedPhone} suspect_text=${data.suspectText} synced=${data.syncedCount}`);
for (const warning of data.warnings || []) {
console.log(`WARN ${warning}`);
}
process.exit(0);
}
const questions = parseQuestions(fs.readFileSync(file, 'utf8'));
if (!questions.length) {
console.error(`FAIL: no questions found in ${file} for prefix ${prefixes.join(',')}`);
process.exit(1);
}
const token = await login();
const vector = await request('GET', '/api/knowledge/doc/vector-index-status', undefined, token);
const vectorStatus = vector.data?.data;
if (!vectorStatus?.matched) {