fix(aihr): scope pilot gates by tenant

This commit is contained in:
2026-07-14 11:03:23 +08:00
parent 00cb4d48e9
commit a34aeacc09
5 changed files with 31 additions and 24 deletions
+1
View File
@@ -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`,默认仍为本地开发租户,试点命令和文档已同步。
+1 -1
View File
@@ -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`。开发数据和非正式会话校准不能替代正式试点人员;补齐上游手机号并重新同步前,不得宣称一期业务验收完成。
+1 -1
View File
@@ -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`。
+24 -22
View File
@@ -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)
)
@@ -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; }