diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index e8855059..fbdfa2ca 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -183,3 +183,4 @@ - 2026-07-14 BRD 知识反馈归属复核:答案反馈原先信任客户端传入的片段 ID,未校验知识片段是否属于当前租户;现入库前按当前租户校验片段归属并限制单次最多 20 个片段。 - 2026-07-14 BRD 知识反馈关联复核:答案反馈原先直接接受客户端传入的 `reviewId`;现仅保留当前租户存在的 SOP 评审关联,非法或跨租户 ID 归一为空。 - 2026-07-14 BRD 候选资料审核留痕:候选资料审核此前只有状态三态,没有审核人和审核时间;现由服务端从当前 HR 登录态写入 `reviewer/reviewed_time`,管理端审核表同步展示。 +- 2026-07-14 BRD 试点门禁租户参数化:严格试点预检此前将 M5 查询固定到 `tenant_id='000000'`,正式试点切换租户时会统计错误;现支持经过字符白名单校验的 `AIHR_PILOT_TENANT_ID`,默认仍为本地开发租户,试点命令和文档已同步。 diff --git a/docs/DEMO_ACCEPTANCE.md b/docs/DEMO_ACCEPTANCE.md index e3cd747b..cbfbefdf 100644 --- a/docs/DEMO_ACCEPTANCE.md +++ b/docs/DEMO_ACCEPTANCE.md @@ -32,7 +32,7 @@ | 4 | 管理端 `/train/practice` | 选择训练记录 → 人工校准评分 → 选择试点起止日期 → 导出试点 CSV | 导出只统计批次内能唯一匹配在职组织的正式数据,包含训练次数、每人 10 次完训率、平均分、预警数、主管复盘率、AI/人工分档一致率、SOP 可用率及正式身份明细 | | 5 | 模型成本闸门 | 设置 `AIHR_AI_RUNTIME_ENABLED=false` 后重启后端 → 调 `/api/aihr/model/chat` | 返回 `cost-guard-fallback`,chat/asr/tts 走现有文本兜底,不影响训练闭环 | -试点正式验收前,用 `AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 AIHR_PILOT_STRICT=true ./scripts/demo-check.sh` 检查 M5 样本闸门,日期替换为本批试点的真实起止日。闸门只统计该时间窗内的数据;正式试点人员必须通过唯一手机号映射到在职组织快照,达到 10 次已完成对练的人数需覆盖试点人员的 80% 以上。校准样本、AI/人工分档一致率和 SOP 可用率未达标时同样失败,不用静态演示 marker 或历史 seed 数据代替真实试点数据。 +试点正式验收前,用 `AIHR_PILOT_TENANT_ID=000000 AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 AIHR_PILOT_STRICT=true ./scripts/demo-check.sh` 检查 M5 样本闸门,租户和日期替换为本批试点的真实值。闸门只统计目标租户、该时间窗内的数据;正式试点人员必须通过唯一手机号映射到在职组织快照,达到 10 次已完成对练的人数需覆盖试点人员的 80% 以上。校准样本、AI/人工分档一致率和 SOP 可用率未达标时同样失败,不用静态演示 marker 或历史 seed 数据代替真实试点数据。 2026-07-10 本地快照(时间窗 `2026-07-07..2026-07-10`):场景 `12/12`、开发活动身份 `50` 人,但达到 10 次对练者 `0` 人;窗口内能关联正式试点会话的校准样本为 `0/20`,因此分档一致率暂不可验收;已评审 SOP `2/2` 可用,另有 `119` 条待评审。严格预检当前**失败**于正式组织人员门槛:3001 名本地在职人员只有 1 名具备唯一可用手机号,活动数据也只有 1 人能唯一匹配正式组织,且该人员达到 10 次对练的数量为 `0/1`。开发数据和非正式会话校准不能替代正式试点人员;补齐上游手机号并重新同步前,不得宣称一期业务验收完成。 diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 2e85fae9..8de1b935 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -52,7 +52,7 @@ Qdrant 默认本地无需配置;远端或自定义 collection 可用 `AIHR_QDR 组织同步写入前先运行只读预检:`node scripts/verify-demo-questions.mjs --org-dry-run`。dry-run 不检查或变更本地快照表结构,也不写数据库;输出只包含人数、手机号覆盖、脱敏数、疑似乱码数和警告,不输出员工姓名。只有 `phone_linked` 能覆盖至少 20 名正式试点人员且上游修复疑似乱码后,才切换管理端组织同步为写入模式。 -正式试点预检必须指定当前批次时间窗,例如:`AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 AIHR_PILOT_STRICT=true ./scripts/demo-check.sh`。脚本只接受 `YYYY-MM-DD`,只统计窗口内完成的训练、校准和 SOP 评审;人员先按唯一手机号映射到在职组织快照,完训口径为每人至少 10 次已完成对练。 +正式试点预检必须指定当前批次租户和时间窗,例如:`AIHR_PILOT_TENANT_ID=000000 AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 AIHR_PILOT_STRICT=true ./scripts/demo-check.sh`。脚本只接受安全租户编号和 `YYYY-MM-DD` 日期,只统计目标租户窗口内完成的训练、校准和 SOP 评审;人员先按唯一手机号映射到在职组织快照,完训口径为每人至少 10 次已完成对练。 管理端正式试点 CSV 也必须显式选择同一批次日期。可用真实 API 验证其日期拒绝、CSV 契约和正式身份范围:`node scripts/verify-demo-questions.mjs --pilot-export --start-date=2026-07-07 --end-date=2026-07-10`。 diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index c286a0e4..68bf3f5b 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -73,6 +73,8 @@ check_pilot_samples() { local pilot_start="${AIHR_PILOT_START_DATE:-$(date +%F)}" local pilot_end="${AIHR_PILOT_END_DATE:-$pilot_start}" + local pilot_tenant="${AIHR_PILOT_TENANT_ID:-000000}" + [[ "$pilot_tenant" =~ ^[A-Za-z0-9_-]+$ ]] || fail "invalid AIHR_PILOT_TENANT_ID: $pilot_tenant" [[ "$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" @@ -85,7 +87,7 @@ check_pilot_samples() { 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' + WHERE tenant_id = '$pilot_tenant' 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 @@ -121,18 +123,18 @@ check_pilot_samples() { 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")" + scenario_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_practice_scenario WHERE tenant_id = '$pilot_tenant' AND position = '生活顾问' AND enabled = 1")" audio_table_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_audio'")" prompt_version_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_sop_answer_review' AND column_name = 'prompt_version'")" - sop_document_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_knowledge_attach WHERE tenant_id = '000000' AND type = 'sop' AND status = 2")" - case_stored_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_case_record WHERE tenant_id = '000000' AND status = '已入库'")" - case_unmasked_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_case_record WHERE tenant_id = '000000' AND status = '已入库' AND CONCAT_WS(' ', title, transcript, summary) REGEXP '1[3-9][0-9]{9}|[[:alnum:]._%+-]+@[[:alnum:].-]+\\.[[:alpha:]]{2,}|[0-9]{1,2}(栋|幢|号楼|单元)[0-9]{3,4}|[0-9]{1,2}[--][0-9]{3,4}'")" + sop_document_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_knowledge_attach WHERE tenant_id = '$pilot_tenant' AND type = 'sop' AND status = 2")" + case_stored_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_case_record WHERE tenant_id = '$pilot_tenant' AND status = '已入库'")" + case_unmasked_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_case_record WHERE tenant_id = '$pilot_tenant' AND status = '已入库' AND CONCAT_WS(' ', title, transcript, summary) REGEXP '1[3-9][0-9]{9}|[[:alnum:]._%+-]+@[[:alnum:].-]+\\.[[:alpha:]]{2,}|[0-9]{1,2}(栋|幢|号楼|单元)[0-9]{3,4}|[0-9]{1,2}[--][0-9]{3,4}'")" completed_people="$(pilot_sql_scalar " SELECT COUNT(*) FROM ( SELECT ext_party_id FROM aihr_practice_session - WHERE tenant_id = '000000' AND mode = 'mobile' + WHERE tenant_id = '$pilot_tenant' 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 @@ -143,17 +145,17 @@ check_pilot_samples() { FROM ( SELECT ext_party_id FROM aihr_practice_session - WHERE tenant_id = '000000' AND mode = 'mobile' + WHERE tenant_id = '$pilot_tenant' 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' + WHERE tenant_id = '$pilot_tenant' 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_active="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_org_snapshot WHERE tenant_id = '$pilot_tenant' AND employment_status = 'active'")" org_phone_linked="$(pilot_sql_scalar "$org_identity_cte SELECT COUNT(DISTINCT ext_party_id) FROM safe_org")" @@ -162,13 +164,13 @@ check_pilot_samples() { pilot_identity AS ( SELECT ext_party_id identity_key FROM aihr_practice_session - WHERE tenant_id = '000000' AND mode = 'mobile' + WHERE tenant_id = '$pilot_tenant' 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' + WHERE tenant_id = '$pilot_tenant' AND create_time >= '${pilot_start} 00:00:00' AND create_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY) ) @@ -180,13 +182,13 @@ check_pilot_samples() { pilot_identity AS ( SELECT ext_party_id identity_key FROM aihr_practice_session - WHERE tenant_id = '000000' AND mode = 'mobile' + WHERE tenant_id = '$pilot_tenant' 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' + WHERE tenant_id = '$pilot_tenant' AND create_time >= '${pilot_start} 00:00:00' AND create_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY) ), pilot_org AS ( @@ -203,7 +205,7 @@ check_pilot_samples() { 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' + WHERE tenant_id = '$pilot_tenant' 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 @@ -222,14 +224,14 @@ check_pilot_samples() { 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' + WHERE s.tenant_id = '$pilot_tenant' 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 + WHERE c.tenant_id = '$pilot_tenant' 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 @@ -241,18 +243,18 @@ check_pilot_samples() { THEN 1 ELSE 0 END), 0) AS matched FROM aihr_practice_calibration c JOIN latest_calibration latest ON latest.id = c.id - WHERE c.tenant_id = '000000'")" + WHERE c.tenant_id = '$pilot_tenant'")" 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 = '已评审' 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)")" + sop_total="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_sop_answer_review WHERE tenant_id = '$pilot_tenant' 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 = '$pilot_tenant' 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 = '$pilot_tenant' AND status <> '已评审' AND create_time >= '${pilot_start} 00:00:00' AND create_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)")" satisfaction_count="$(pilot_sql_scalar " $org_identity_cte, formal_sessions AS ( SELECT s.satisfaction_score 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' + WHERE s.tenant_id = '$pilot_tenant' AND s.mode = 'mobile' AND s.finished_time >= '${pilot_start} 00:00:00' AND s.finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY) ) @@ -263,7 +265,7 @@ check_pilot_samples() { SELECT s.satisfaction_score 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' + WHERE s.tenant_id = '$pilot_tenant' AND s.mode = 'mobile' AND s.finished_time >= '${pilot_start} 00:00:00' AND s.finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY) ) diff --git a/scripts/tests/demo-check-pilot-gates.test.sh b/scripts/tests/demo-check-pilot-gates.test.sh index 83ea253d..dd59f0f0 100644 --- a/scripts/tests/demo-check-pilot-gates.test.sh +++ b/scripts/tests/demo-check-pilot-gates.test.sh @@ -22,6 +22,7 @@ assert_not_contains() { assert_contains 'AIHR_PILOT_START_DATE' assert_contains 'AIHR_PILOT_END_DATE' +assert_contains 'AIHR_PILOT_TENANT_ID' assert_contains 'AIHR_DEMO_CHECK_LIBRARY_ONLY' assert_contains 'safe_org AS' assert_contains 'HAVING SUM(s.session_count) >= 10' @@ -151,6 +152,9 @@ pilot_output="$(AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 [[ "$pilot_output" == *'sop_usable=1/1 sop_pending=1'* ]] || { echo "FAIL: SOP window: $pilot_output" >&2; exit 1; } [[ "$pilot_output" == *'satisfaction=5.0/5 responses=19'* ]] || { echo "FAIL: safe satisfaction identity scope: $pilot_output" >&2; exit 1; } +override_output="$(AIHR_PILOT_TENANT_ID=other AIHR_PILOT_START_DATE=2026-07-07 AIHR_PILOT_END_DATE=2026-07-10 check_pilot_samples)" +[[ "$override_output" == *'scenarios=0/12'* ]] || { echo "FAIL: pilot tenant override was ignored: $override_output" >&2; exit 1; } + pilot_sql_scalar "INSERT INTO aihr_practice_session (tenant_id, session_id, ext_party_id, mode, finished_time, satisfaction_score) VALUES ('000000','e-10','13900000005','mobile','2026-07-09 09:00:00',5)" >/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; }