fix(aihr): bind SOP reviews to pilot identity

This commit is contained in:
2026-07-14 15:43:23 +08:00
parent 64f369eff0
commit 14d91f86a9
7 changed files with 113 additions and 27 deletions
@@ -31,6 +31,7 @@ import org.dromara.aihr.service.AihrUploadQueueService;
import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -82,7 +83,15 @@ public class AihrSopController {
@SaCheckLogin
@PostMapping("/search")
public R<SearchResponse> search(@RequestBody SearchRequest request) {
return R.ok(sopSeedService.search(restrictDemoVerificationSource(request)));
return R.ok(sopSeedService.search(restrictDemoVerificationSource(request), currentRequesterExtPartyId()));
}
private String currentRequesterExtPartyId() {
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
return "";
}
return loginUser.getUsername();
}
private SearchRequest restrictDemoVerificationSource(SearchRequest request) {
@@ -139,24 +139,29 @@ public class AihrSopSeedService {
}
public SearchResponse search(SearchRequest request) {
return search(request, "");
}
public SearchResponse search(SearchRequest request, String requesterExtPartyId) {
String requestedCategory = request == null ? "" : request.category();
SopSeed seed = seeds.getOrDefault(requestedCategory, seeds.get("投诉处理 SOP"));
String category = isBlank(requestedCategory) ? seed.category() : requestedCategory;
String position = firstNonBlank(request == null ? null : request.position(), "生活顾问");
String source = normalizeSearchSource(request == null ? null : request.source());
String requester = isBlank(requesterExtPartyId) ? "" : requesterExtPartyId.trim();
String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim();
if (isBlank(queryText)) {
return withReviewId(noEvidenceResponse("", category), source);
return withReviewId(noEvidenceResponse("", category), source, requester);
}
SearchResponse dbResponse = dbSearch(dbCategory(category), queryText, request == null ? null : request.limit());
if (dbResponse != null) {
if (isNoEvidenceAnswer(dbResponse.answer())) {
recordKnowledgeGap(queryText, category, position, source);
}
return withReviewId(dbResponse, source);
return withReviewId(dbResponse, source, requester);
}
recordKnowledgeGap(queryText, category, position, source);
return withReviewId(noEvidenceResponse(queryText, category), source);
return withReviewId(noEvidenceResponse(queryText, category), source, requester);
}
/**
@@ -1853,8 +1858,9 @@ public class AihrSopSeedService {
}
}
private SearchResponse withReviewId(SearchResponse response, String source) {
Long reviewId = shouldCreateSopReview(response.queryText(), source) ? createSopReview(response, source) : null;
private SearchResponse withReviewId(SearchResponse response, String source, String requesterExtPartyId) {
Long reviewId = shouldCreateSopReview(response.queryText(), source)
? createSopReview(response, source, requesterExtPartyId) : null;
return new SearchResponse(
response.queryText(),
response.category(),
@@ -1878,15 +1884,15 @@ public class AihrSopSeedService {
static boolean shouldCreateSopReview(String queryText, String source) {
return !isBlank(queryText) && !skipSopReview(source);
}
private Long createSopReview(SearchResponse response, String source) {
private Long createSopReview(SearchResponse response, String source, String requesterExtPartyId) {
try {
ensureSopReviewTable();
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement("""
INSERT INTO aihr_sop_answer_review
(tenant_id, query_text, category, answer_text, reference_text, snippet_count, source, prompt_version, status, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '待评审', ?, ?)
(tenant_id, query_text, category, answer_text, reference_text, snippet_count, source, requester_ext_party_id, prompt_version, status, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '待评审', ?, ?)
""", Statement.RETURN_GENERATED_KEYS);
ps.setString(1, tenantId());
ps.setString(2, truncate(AihrSensitiveText.forModel(response.queryText()), 1000));
@@ -1895,10 +1901,11 @@ public class AihrSopSeedService {
ps.setString(5, truncate(AihrSensitiveText.forModel(response.reference()), 1000));
ps.setInt(6, response.snippets() == null ? 0 : response.snippets().size());
ps.setString(7, normalizeSearchSource(source));
ps.setString(8, firstNonBlank(response.promptVersion(), "unknown"));
ps.setString(8, requesterExtPartyId.isBlank() ? null : truncate(requesterExtPartyId, 100));
ps.setString(9, firstNonBlank(response.promptVersion(), "unknown"));
Timestamp now = Timestamp.valueOf(java.time.LocalDateTime.now());
ps.setTimestamp(9, now);
ps.setTimestamp(10, now);
ps.setTimestamp(11, now);
return ps;
}, keyHolder);
Number key = keyHolder.getKey();
@@ -1930,10 +1937,18 @@ public class AihrSopSeedService {
public SopReviewStats sopReviewStats() {
ensureSopReviewTable();
return jdbcTemplate.queryForObject("""
SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) AS usable
FROM aihr_sop_answer_review
WHERE tenant_id = ? AND status = '已评审'
AND (LOWER(TRIM(COALESCE(category, ''))) = 'sop' OR category LIKE '%SOP%')
SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN r.usable = 1 THEN 1 ELSE 0 END), 0) AS usable
FROM aihr_sop_answer_review r
WHERE r.tenant_id = ? AND r.status = '已评审'
AND NULLIF(TRIM(r.requester_ext_party_id), '') IS NOT NULL
AND EXISTS (
SELECT 1
FROM aihr_org_snapshot o
WHERE o.tenant_id = r.tenant_id
AND o.employment_status = 'active'
AND (o.ext_party_id = r.requester_ext_party_id OR o.person_phone = r.requester_ext_party_id)
)
AND (LOWER(TRIM(COALESCE(r.category, ''))) = 'sop' OR r.category LIKE '%SOP%')
""", (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")), tenantId());
}
@@ -1944,11 +1959,19 @@ public class AihrSopSeedService {
public SopReviewStats sopReviewStats(LocalDateTime startTime, LocalDateTime endTime) {
ensureSopReviewTable();
return jdbcTemplate.queryForObject("""
SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) AS usable
FROM aihr_sop_answer_review
WHERE tenant_id = ? AND status = '已评审'
AND (LOWER(TRIM(COALESCE(category, ''))) = 'sop' OR category LIKE '%SOP%')
AND reviewed_time >= ? AND reviewed_time < ?
SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN r.usable = 1 THEN 1 ELSE 0 END), 0) AS usable
FROM aihr_sop_answer_review r
WHERE r.tenant_id = ? AND r.status = '已评审'
AND NULLIF(TRIM(r.requester_ext_party_id), '') IS NOT NULL
AND EXISTS (
SELECT 1
FROM aihr_org_snapshot o
WHERE o.tenant_id = r.tenant_id
AND o.employment_status = 'active'
AND (o.ext_party_id = r.requester_ext_party_id OR o.person_phone = r.requester_ext_party_id)
)
AND (LOWER(TRIM(COALESCE(r.category, ''))) = 'sop' OR r.category LIKE '%SOP%')
AND r.reviewed_time >= ? AND r.reviewed_time < ?
""", (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")),
tenantId(), Timestamp.valueOf(startTime), Timestamp.valueOf(endTime));
}
@@ -1971,6 +1994,7 @@ public class AihrSopSeedService {
`reference_text` varchar(1000) DEFAULT NULL COMMENT '引用',
`snippet_count` int DEFAULT 0 COMMENT '命中片段数',
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
`requester_ext_party_id` varchar(100) DEFAULT NULL COMMENT '提问员工外部主体ID',
`prompt_version` varchar(80) DEFAULT NULL COMMENT '答案生成提示词版本',
`usable` tinyint DEFAULT NULL COMMENT '人工评审是否可用',
`reviewer` varchar(100) DEFAULT NULL COMMENT '评审人',
@@ -1984,6 +2008,7 @@ public class AihrSopSeedService {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR SOP问答人工评审';
""");
ensureColumn("aihr_sop_answer_review", "source", "ALTER TABLE aihr_sop_answer_review ADD COLUMN `source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源' AFTER `snippet_count`");
ensureColumn("aihr_sop_answer_review", "requester_ext_party_id", "ALTER TABLE aihr_sop_answer_review ADD COLUMN `requester_ext_party_id` varchar(100) DEFAULT NULL COMMENT '提问员工外部主体ID' AFTER `source`");
ensureColumn("aihr_sop_answer_review", "prompt_version", "ALTER TABLE aihr_sop_answer_review ADD COLUMN `prompt_version` varchar(80) DEFAULT NULL COMMENT '答案生成提示词版本' AFTER `source`");
sopReviewTableReady = true;
}
@@ -62,8 +62,8 @@ public class AihrSopSeedServiceTest {
}
String code = Files.readString(source);
assertTrue(code.contains("LOWER(TRIM(COALESCE(category, ''))) = 'sop'"));
assertTrue(code.contains("category LIKE '%SOP%'"));
assertTrue(code.contains("LOWER(TRIM(COALESCE(r.category, ''))) = 'sop'"));
assertTrue(code.contains("r.category LIKE '%SOP%'"));
}
@Test
@@ -139,6 +139,28 @@ public class AihrSopSeedServiceTest {
assertTrue(code.contains("reviewSearch(id, request, operator)"));
}
@Test
@Tag("dev")
public void formalSopSamplesRequireAuthenticatedAppRequesterIdentity() throws Exception {
Path controller = Path.of("src/main/java/org/dromara/aihr/controller/AihrSopController.java");
if (!Files.exists(controller)) {
controller = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java");
}
Path service = Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java");
if (!Files.exists(service)) {
service = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java");
}
String controllerCode = Files.readString(controller);
String serviceCode = Files.readString(service);
assertTrue(controllerCode.contains("UserType.APP_USER.getUserType()"));
assertTrue(controllerCode.contains("currentRequesterExtPartyId()"));
assertTrue(serviceCode.contains("requester_ext_party_id"));
assertTrue(serviceCode.contains("NULLIF(TRIM(r.requester_ext_party_id), '') IS NOT NULL"));
assertTrue(serviceCode.contains("FROM aihr_org_snapshot o"));
assertTrue(serviceCode.contains("o.employment_status = 'active'"));
}
@Test
@Tag("dev")
public void demoVerificationSourceIsRestrictedToSuperAdmin() throws Exception {
@@ -237,6 +237,7 @@ CREATE TABLE IF NOT EXISTS `aihr_sop_answer_review` (
`reference_text` varchar(1000) DEFAULT NULL COMMENT '引用',
`snippet_count` int DEFAULT 0 COMMENT '命中片段数',
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
`requester_ext_party_id` varchar(100) DEFAULT NULL COMMENT '提问员工外部主体ID',
`prompt_version` varchar(80) DEFAULT NULL COMMENT '答案生成提示词版本',
`usable` tinyint DEFAULT NULL COMMENT '人工评审是否可用',
`reviewer` varchar(100) DEFAULT NULL COMMENT '评审人',
@@ -0,0 +1,24 @@
-- AIHR SOP 问答提问员工主体迁移(MySQL 8.x)
-- 仅补齐结构,不写入业务数据;可重复执行。
SET @aihr_sop_review_table_exists := (
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'aihr_sop_answer_review'
);
SET @aihr_sop_requester_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'aihr_sop_answer_review'
AND COLUMN_NAME = 'requester_ext_party_id'
);
SET @aihr_sop_requester_ddl := IF(
@aihr_sop_review_table_exists = 1 AND @aihr_sop_requester_exists = 0,
'ALTER TABLE aihr_sop_answer_review ADD COLUMN `requester_ext_party_id` varchar(100) DEFAULT NULL COMMENT ''提问员工外部主体ID'' AFTER `source`',
'SELECT 1'
);
PREPARE aihr_sop_requester_stmt FROM @aihr_sop_requester_ddl;
EXECUTE aihr_sop_requester_stmt;
DEALLOCATE PREPARE aihr_sop_requester_stmt;
+1
View File
@@ -298,3 +298,4 @@
- 2026-07-14 BRD 发布预检业务码修复:`release-preflight.sh` 此前只检查线上 HTTP 状态,HTTP 200 但业务 JSON 返回 `code=401/405` 仍可能被记录为通过;现对远端租户接口同时校验业务 `code=200`,并同步发布说明。线上根站和 `/h5/` 仍只读返回 200,本轮未发布或写入生产。
- 2026-07-14 发布预检业务码误匹配修复:业务码校验进一步收紧为 JSON 顶层首字段 `code=200`,不再接受嵌套对象中的同名 `code=200`;本地成功/嵌套失败样例与线上租户接口均验证通过。三端产物已重建且时间不早于当前提交,完整预检仍因两份用户未提交的 Figma 文档停止,本轮未发布生产。
- 2026-07-14 BRD 最新线上只读复核:生产根站与 `/h5/` 返回 `200`,线上仍加载管理端 `assets/index-CJZ3Ax3Z.js` 与 H5 `assets/index-D4-NrEpb.js`;当前本地提交 `94d483d3` 构建资源分别为 `assets/index-Cv2wI9UI.js`(SHA-256 `cd6e3eab4a684141ccd64617dcde4d99030c9c13f7da618c11e1709c04f244a0`)和 `assets/index-Dq7sfWZP.js`(SHA-256 `f8499e5f261cc06d02de275e285e4a7c946dd6ef54fd29b3a1c0bc7a9a689c3d`)。本轮只读核验,未执行生产静态资源同步、后端重启或业务数据写入;五维修复仍未被证明在线上生效。
- 2026-07-14 BRD 4.7/M5 SOP 试点评审主体留痕修复:SOP 搜索生成评审记录时,现仅从已认证 APP 用户上下文保存 `requester_ext_party_id`,不接受客户端传入主体;管理端/系统搜索保持空值。正式 SOP 评审统计和严格门禁只统计已评审、有提问员工主体且能命中当前租户在职组织快照的记录,避免管理员烟测、临时 APP 账号或无组织身份数据混入正式可用率;同步补 reset SQL、幂等迁移脚本、运行时补列和回归门禁。该修复只完善证据归属,仍需真实员工提问和人工评审样本,不能把有主体字段等同于 M5 已达标。
+9 -5
View File
@@ -81,7 +81,7 @@ check_pilot_samples() {
[[ "$(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 scenario_service_promotion_count scenario_daily_service_count rubric_expected_count rubric_five_dimension_count completed_people pilot_people org_active org_phone_linked org_hire_date_linked org_pilot_people org_pilot_projects org_completed_people audio_table_count prompt_version_schema_count hire_date_schema_count sop_document_count case_stored_count case_unmasked_count
local scenario_count scenario_service_promotion_count scenario_daily_service_count rubric_expected_count rubric_five_dimension_count completed_people pilot_people org_active org_phone_linked org_hire_date_linked org_pilot_people org_pilot_projects org_completed_people audio_table_count prompt_version_schema_count requester_identity_schema_count hire_date_schema_count sop_document_count case_stored_count case_unmasked_count
local calibration_stats calibration_count calibration_matched sop_total sop_usable sop_pending satisfaction_count satisfaction_average
local org_identity_cte="
WITH active_org AS (
@@ -141,6 +141,7 @@ check_pilot_samples() {
) ready_rubrics")"
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'")"
requester_identity_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 = 'requester_ext_party_id'")"
hire_date_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_org_snapshot' AND column_name = 'hire_date'")"
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 = '已入库'")"
@@ -270,9 +271,9 @@ check_pilot_samples() {
JOIN latest_calibration latest ON latest.id = c.id
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 = '$pilot_tenant' AND status = '已评审' AND (LOWER(TRIM(COALESCE(category, ''))) = 'sop' OR category LIKE '%SOP%') 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 (LOWER(TRIM(COALESCE(category, ''))) = 'sop' OR category LIKE '%SOP%') 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 (LOWER(TRIM(COALESCE(category, ''))) = 'sop' OR category LIKE '%SOP%') 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 r WHERE r.tenant_id = '$pilot_tenant' AND r.status = '已评审' AND NULLIF(TRIM(r.requester_ext_party_id), '') IS NOT NULL AND EXISTS (SELECT 1 FROM aihr_org_snapshot o WHERE o.tenant_id = r.tenant_id AND o.employment_status = 'active' AND (o.ext_party_id = r.requester_ext_party_id OR o.person_phone = r.requester_ext_party_id)) AND (LOWER(TRIM(COALESCE(r.category, ''))) = 'sop' OR r.category LIKE '%SOP%') AND r.reviewed_time >= '${pilot_start} 00:00:00' AND r.reviewed_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)")"
sop_usable="$(pilot_sql_scalar "SELECT COALESCE(SUM(CASE WHEN r.usable = 1 THEN 1 ELSE 0 END), 0) FROM aihr_sop_answer_review r WHERE r.tenant_id = '$pilot_tenant' AND r.status = '已评审' AND NULLIF(TRIM(r.requester_ext_party_id), '') IS NOT NULL AND EXISTS (SELECT 1 FROM aihr_org_snapshot o WHERE o.tenant_id = r.tenant_id AND o.employment_status = 'active' AND (o.ext_party_id = r.requester_ext_party_id OR o.person_phone = r.requester_ext_party_id)) AND (LOWER(TRIM(COALESCE(r.category, ''))) = 'sop' OR r.category LIKE '%SOP%') AND r.reviewed_time >= '${pilot_start} 00:00:00' AND r.reviewed_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)")"
sop_pending="$(pilot_sql_scalar "SELECT COUNT(*) FROM aihr_sop_answer_review r WHERE r.tenant_id = '$pilot_tenant' AND r.status <> '已评审' AND NULLIF(TRIM(r.requester_ext_party_id), '') IS NOT NULL AND EXISTS (SELECT 1 FROM aihr_org_snapshot o WHERE o.tenant_id = r.tenant_id AND o.employment_status = 'active' AND (o.ext_party_id = r.requester_ext_party_id OR o.person_phone = r.requester_ext_party_id)) AND (LOWER(TRIM(COALESCE(r.category, ''))) = 'sop' OR r.category LIKE '%SOP%') AND r.create_time >= '${pilot_start} 00:00:00' AND r.create_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)")"
satisfaction_count="$(pilot_sql_scalar "
$org_identity_cte,
formal_sessions AS (
@@ -295,7 +296,7 @@ check_pilot_samples() {
AND s.finished_time < DATE_ADD('${pilot_end}', INTERVAL 1 DAY)
)
SELECT COALESCE(ROUND(AVG(satisfaction_score), 1), 0) FROM formal_sessions WHERE satisfaction_score IS NOT NULL")"
echo "OK: pilot gates window=${pilot_start}..${pilot_end} scenarios=${scenario_count}/12 rubric_five_dimensions=${rubric_five_dimension_count}/${rubric_expected_count} core_flow=service_promotion:${scenario_service_promotion_count} daily_service:${scenario_daily_service_count} sop_docs=${sop_document_count}/5 stored_cases=${case_stored_count}/20 case_unmasked=${case_unmasked_count} ten_sessions=${completed_people}/${pilot_people} org_identity=${org_phone_linked}/${org_active} org_hire_date=${org_hire_date_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} satisfaction=${satisfaction_average}/5 responses=${satisfaction_count} audio_schema=${audio_table_count} prompt_version_schema=${prompt_version_schema_count} hire_date_schema=${hire_date_schema_count}"
echo "OK: pilot gates window=${pilot_start}..${pilot_end} scenarios=${scenario_count}/12 rubric_five_dimensions=${rubric_five_dimension_count}/${rubric_expected_count} core_flow=service_promotion:${scenario_service_promotion_count} daily_service:${scenario_daily_service_count} sop_docs=${sop_document_count}/5 stored_cases=${case_stored_count}/20 case_unmasked=${case_unmasked_count} ten_sessions=${completed_people}/${pilot_people} org_identity=${org_phone_linked}/${org_active} org_hire_date=${org_hire_date_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} satisfaction=${satisfaction_average}/5 responses=${satisfaction_count} audio_schema=${audio_table_count} prompt_version_schema=${prompt_version_schema_count} requester_identity_schema=${requester_identity_schema_count} hire_date_schema=${hire_date_schema_count}"
[[ "${AIHR_PILOT_STRICT:-false}" != "true" ]] || {
(( scenario_count >= 12 )) || fail "pilot enabled scenarios ${scenario_count}/12"
@@ -307,6 +308,7 @@ check_pilot_samples() {
(( case_unmasked_count == 0 )) || fail "pilot stored cases contain unmasked known PII (${case_unmasked_count})"
(( audio_table_count == 1 )) || fail "pilot audio schema unavailable (aihr_practice_audio missing)"
(( prompt_version_schema_count == 1 )) || fail "pilot RAG traceability schema unavailable (aihr_sop_answer_review.prompt_version missing)"
(( requester_identity_schema_count == 1 )) || fail "pilot SOP requester identity schema unavailable (aihr_sop_answer_review.requester_ext_party_id missing)"
(( hire_date_schema_count == 1 )) || fail "pilot org hire_date schema unavailable"
(( org_phone_linked >= 20 )) || fail "pilot mobile-login org members ${org_phone_linked}/20 (active org ${org_active})"
(( org_hire_date_linked >= 20 )) || fail "pilot org hire_date identities ${org_hire_date_linked}/20"
@@ -598,6 +600,8 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/control
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片文件"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "/embeddings"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "aihr_knowledge_gap"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "requester_ext_party_id"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java "currentRequesterExtPartyId"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "hasExplicitSopReviewDecision"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "validateFeedbackFragmentOwnership"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "normalizeFeedbackReviewId"