feat(aihr): complete phase two pilot readiness
This commit is contained in:
+183
-19
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user