From 8f9c244aeb1bceb95575055ca6b13341ecf17686 Mon Sep 17 00:00:00 2001 From: let5sne Date: Wed, 5 Aug 2026 02:45:57 +0800 Subject: [PATCH] feat(aihr): add experience library and community best answers as ask sources - server-side employee position resolution (org snapshot) with hard position gate for EXPERIENCE/CASE sources; formal sources unaffected; unresolvable position fails closed to general-scope only - merge published experiences (aihr_case_record, CASE_EXP) and moderated best answers (COMMUNITY, no submitter identity) into the citation pool as REFERENCE tier; auxiliary sources never alter formal answers and fail without breaking the main chain; grading re-grades ROUTE into GUIDANCE when only auxiliary evidence exists - case recall endpoints enforce the same position gate - moderator endpoint to promote a best answer into a CANDIDATE case record (idempotent via source_ref unique key, never auto-published) - migration aihr_20260822_case_source_ref_and_best_answer_index_mysql8.sql - COMMUNITY citations carry community-question:{id} in detailRef for direct navigation --- AGENTS.md | 2 +- .../aihr/agent/AihrAgentOrchestrator.java | 20 +- .../aihr/community/AihrCommunityDto.java | 15 + .../aihr/community/AihrCommunityService.java | 203 +++++++++++- .../AihrQuestionAdminController.java | 10 + .../aihr/controller/AihrCaseController.java | 22 +- .../service/AihrKnowledgeQueryService.java | 272 ++++++++++++++-- .../dromara/aihr/service/AihrCaseService.java | 99 +++++- .../service/AihrEmployeePositionResolver.java | 66 ++++ .../aihr/service/AihrPositionGate.java | 83 +++++ .../aihr/agent/AihrAgentMediaTest.java | 1 + .../aihr/agent/AihrAgentOrchestratorTest.java | 32 +- .../aihr/agent/AihrAgentWebResearchTest.java | 1 + .../AihrCommunityBestAnswerSourceTest.java | 225 ++++++++++++++ .../AihrKnowledgeAuxiliarySourceTest.java | 293 ++++++++++++++++++ .../AihrKnowledgeQueryServiceTest.java | 8 + .../aihr/service/AihrCaseServiceTest.java | 58 ++++ .../AihrEmployeePositionResolverTest.java | 79 +++++ .../aihr/service/AihrPositionGateTest.java | 61 ++++ ...ource_ref_and_best_answer_index_mysql8.sql | 74 +++++ docs/API_INTEGRATION.md | 8 +- docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md | 3 + 22 files changed, 1578 insertions(+), 57 deletions(-) create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrEmployeePositionResolver.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPositionGate.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/community/AihrCommunityBestAnswerSourceTest.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAuxiliarySourceTest.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrEmployeePositionResolverTest.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPositionGateTest.java create mode 100644 backend/script/sql/update/aihr_20260822_case_source_ref_and_best_answer_index_mysql8.sql diff --git a/AGENTS.md b/AGENTS.md index 3c80781e..7afd8cc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ - 不要把三端概括成全模态:员工对练/每日题、问师傅和直通车反馈支持文字/语音,问师傅图片/视频只用于本次分析,案例传音频;候选面试支持文字/语音,资料可传文档/图片但图片不参与评分;主管写文字复盘/指派并回听录音。员工对练图像/视频情境、服务录像回放和方言正式验收仍未完成;语音继续复用 `services/speech.ts` 与 `/api/ai/asr`。 - 问题榜奖励只记积分/学习学分,不得擅自增加现金、提现、预算或税务语义;选最佳答案和考试提交必须保持请求幂等。 - 问师傅的未解决答案只有在本人先提交 `down` 反馈并再次确认后,才能由服务端重读本人查询审计和反馈创建 `PENDING` 问题榜条目;客户端不得提交改写答案、引用、身份或奖励,重复请求必须返回同一问题。正式学习任务与每日三题分开:材料只允许当前租户 `READY` 附件,任务保存材料版本、周期、起止时间和完成规则;`EXAM_PASS` 只能由关联考试通过完成,随机组卷只从已审核启用题库抽取并在发布时冻结来源和答案。 -- 案例与训练高分只进入经验候选;业务审核不等于正式发布。正式经验必须另行确认来源授权、脱敏、适用岗位、负责人、版本和生效期后进入 `PUBLISHED`,员工只可召回当前生效的正式经验;下线后立即停止员工召回。案例、成果投稿和知识空间不得自动互写。 +- 案例与训练高分只进入经验候选;业务审核不等于正式发布。正式经验必须另行确认来源授权、脱敏、适用岗位、负责人、版本和生效期后进入 `PUBLISHED`,员工只可召回当前生效的正式经验,且经验/案例类证据按服务端解析的在职岗位硬过滤(空适用范围通用,岗位解析不到仅通用可见,形式制度不受影响);下线后立即停止员工召回。问题榜最佳答案可由 moderator 经 `POST /api/aihr/question-admin/answers/{answerId}/to-case-candidate` 幂等转为 `CANDIDATE` 经验候选(`source_type=COMMUNITY_QA`、`source_ref` 存回答 ID、`source_authorized/deidentified` 保持 0),正式发布仍走同一人工确认链。案例、成果投稿和知识空间不得自动互写。 - 全网 AI 与企业问师傅必须分入口、分来源和分免责声明;提供方仅允许公网 HTTPS,修改地址/密钥后必须连接测试成功才能启用,未配置时明确不可用且不生成假答案。 - 多租户知识平台的有效范围是“租户 + 调用应用绑定 + 主体授权”的交集;不信任客户端传入身份,API_TOKEN 不得进入浏览器或小程序包。生产排序规则以 `aihr_knowledge_info.tenant_id` 为基准,新表最后执行 `aihr_20260718_release_collation_compat_mysql8.sql`。 - 移动端首页接口:`GET /api/aihr/mobile/home/{role}`,当前 `@SaIgnore` 公开只读 seed,用于 H5 首屏 API 优先 + 本地 fallback;后续确定小程序登录后再接移动端 token,不复用管理后台登录态。 diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java index 5db26b15..9421157f 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java @@ -19,6 +19,7 @@ import org.dromara.aihr.knowledge.service.AihrEvidenceGradingService; import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService.MediaMode; import org.dromara.aihr.agent.shadow.AihrAgentShadowService; +import org.dromara.aihr.service.AihrEmployeePositionResolver; import org.springframework.beans.factory.annotation.Autowired; import org.dromara.aihr.webai.AihrWebAiDto; import org.dromara.aihr.webai.AihrWebAiService; @@ -46,6 +47,7 @@ public class AihrAgentOrchestrator { private final AihrWebAiService webAiService; private final AihrAgentAuditService auditService; private final AihrAgentShadowService shadowService; + private final AihrEmployeePositionResolver positionResolver; private AihrAgentActiveService activeService; @Autowired @@ -80,9 +82,10 @@ public class AihrAgentOrchestrator { false, false, GROUNDED); } principal = principalResolver.current(); + String position = positionResolver == null ? null : positionResolver.resolvePosition(principal); response = media - ? executeMedia(plan, request, file, principal) - : executeText(plan, request, principal); + ? executeMedia(plan, request, file, principal, position) + : executeText(plan, request, principal, position); return response; } catch (ServiceException ex) { errorCode = ex.getCode() == null ? "REJECTED" : "HTTP_" + ex.getCode(); @@ -108,7 +111,8 @@ public class AihrAgentOrchestrator { } } - private AgentResponse executeText(AgentPlan plan, AgentRequest request, AihrKnowledgePrincipal principal) { + private AgentResponse executeText(AgentPlan plan, AgentRequest request, AihrKnowledgePrincipal principal, + String position) { if (plan.requiresExternalConsent() && !request.externalConsent()) { return simple(plan, AgentStatus.NEEDS_INPUT, "全网查询会将脱敏后的问题发送给外部公开检索服务。", @@ -146,32 +150,32 @@ public class AihrAgentOrchestrator { new Clarification("请补充具体目标", List.of("具体问题"))); case WEB_RESEARCH -> fromWeb(plan, request, webAiService.query(new AihrWebAiDto.QueryRequest(plan.rewrittenRequest(), true))); - default -> fromQuery(plan, request); + default -> fromQuery(plan, request, position); }; } private AgentResponse executeMedia(AgentPlan plan, AgentRequest request, MultipartFile file, - AihrKnowledgePrincipal principal) { + AihrKnowledgePrincipal principal, String position) { policy.authorize(principal, plan, request.externalConsent()); MediaMode mode = needsKnowledge(request.question()) ? MediaMode.MEDIA_WITH_KNOWLEDGE : MediaMode.MEDIA_ONLY; String source = agentKnowledgeSource(request.question()); QueryResponse response = queryService.queryInternalMedia(new QueryRequest( - request.question(), List.of(), "sop", null, source, 5, null, + request.question(), List.of(), "sop", position, source, 5, null, request.conversationId(), request.contextVersion(), request.projectCode(), request.broadcastMessageId() ), file, mode); return fromResponse(plan, response, "MEDIA"); } - private AgentResponse fromQuery(AgentPlan plan, AgentRequest request) { + private AgentResponse fromQuery(AgentPlan plan, AgentRequest request, String position) { String toolCode = switch (plan.tool()) { case MY_CURRENT_TASKS, MY_PRACTICE_SUMMARY, TEAM_PRACTICE_SUMMARY -> plan.tool().name(); default -> null; }; String source = agentKnowledgeSource(request.question()); QueryResponse response = queryService.queryInternal(new QueryRequest( - plan.rewrittenRequest(), List.of(), "sop", null, source, 5, toolCode, + plan.rewrittenRequest(), List.of(), "sop", position, source, 5, toolCode, request.conversationId(), request.contextVersion(), request.projectCode(), request.broadcastMessageId() )); return fromResponse(plan, response, toolCode == null ? "ENTERPRISE_KNOWLEDGE" : "LIVE_DATA"); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityDto.java index 103232ef..1f274dea 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityDto.java @@ -87,4 +87,19 @@ public final class AihrCommunityDto { int studyCreditBalance, List rules) { } + + /** + * Read-only best-answer match for the knowledge auxiliary source. Deliberately carries no + * submitter identity fields; the employee-facing citation only quotes the question title and + * the accepted answer content. + */ + public record BestAnswerMatch(Long questionId, + Long answerId, + String questionTitle, + String answerContent) { + } + + public record CaseCandidateResponse(String caseId, + boolean created) { + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityService.java index c04e8cdc..8e5beeac 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrCommunityService.java @@ -3,7 +3,9 @@ package org.dromara.aihr.community; import lombok.RequiredArgsConstructor; import org.dromara.aihr.community.AihrCommunityDto.AnswerResponse; import org.dromara.aihr.community.AihrCommunityDto.AnswerFeedbackQuestionRequest; +import org.dromara.aihr.community.AihrCommunityDto.BestAnswerMatch; import org.dromara.aihr.community.AihrCommunityDto.BestAnswerResponse; +import org.dromara.aihr.community.AihrCommunityDto.CaseCandidateResponse; import org.dromara.aihr.community.AihrCommunityDto.CreateAnswerRequest; import org.dromara.aihr.community.AihrCommunityDto.CreateQuestionRequest; import org.dromara.aihr.community.AihrCommunityDto.PointsEntryResponse; @@ -12,7 +14,9 @@ import org.dromara.aihr.community.AihrCommunityDto.QuestionResponse; import org.dromara.aihr.community.AihrCommunityDto.ReviewRequest; import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.aihr.knowledge.service.AihrRagQueryPlanner; import org.dromara.aihr.service.AihrSchemaMigrationGuard; +import org.dromara.aihr.service.AihrSensitiveText; import org.dromara.common.core.exception.ServiceException; import org.springframework.beans.factory.annotation.Value; import org.springframework.jdbc.core.JdbcTemplate; @@ -32,6 +36,7 @@ import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -343,6 +348,184 @@ public class AihrCommunityService { return principalResolver.current(); } + /** + * Read-only best-answer retrieval for the knowledge auxiliary source. Only accepted answers + * of visible questions in the same tenant are matched against the question title/content; + * no submitter identity column is ever selected. + */ + public List searchBestAnswers(String tenantId, String queryText, int limit) { + String tenant = clean(tenantId); + List terms = AihrRagQueryPlanner.sparseTerms(queryText).stream().limit(5).toList(); + if (tenant.isBlank() || terms.isEmpty()) { + return List.of(); + } + int safeLimit = limit <= 0 ? 3 : Math.min(limit, 5); + String matchExpr = "CONCAT_WS(' ', COALESCE(q.title, ''), COALESCE(q.content, ''))"; + String hits = terms.stream() + .map(term -> "(CASE WHEN " + matchExpr + " LIKE ? ESCAPE '\\\\' THEN 1 ELSE 0 END)") + .collect(java.util.stream.Collectors.joining(" + ")); + String termWhere = terms.stream() + .map(term -> matchExpr + " LIKE ? ESCAPE '\\\\'") + .collect(java.util.stream.Collectors.joining(" OR ")); + List args = new ArrayList<>(); + args.add(tenant); + terms.forEach(term -> args.add(likePattern(term))); + terms.forEach(term -> args.add(likePattern(term))); + args.add(safeLimit); + return jdbcTemplate.query(""" + SELECT a.id AS answer_id, q.id AS question_id, q.title, a.content + FROM aihr_community_answer a + JOIN aihr_community_question q + ON q.tenant_id = a.tenant_id AND q.id = a.question_id + WHERE a.tenant_id = ? + AND a.answer_status = 'PUBLISHED' + AND a.is_best = 1 + AND q.question_status IN ('PUBLISHED', 'CLOSED') + AND (%s) + ORDER BY (%s) DESC, a.id DESC + LIMIT ? + """.formatted(termWhere, hits), + (rs, rowNum) -> new BestAnswerMatch( + rs.getLong("question_id"), rs.getLong("answer_id"), + rs.getString("title"), rs.getString("content")), + args.toArray()); + } + + /** + * Moderator-triggered conversion of an accepted best answer into an experience candidate. + * The candidate always enters the manual review chain: knowledge_status stays CANDIDATE and + * source_authorized/deidentified stay 0, so it can never become employee-visible without the + * formal publish confirmation. Idempotent per answer: the same answerId maps to one case. + */ + @Transactional(rollbackFor = Exception.class) + public CaseCandidateResponse toCaseCandidate(Long answerId) { + AihrKnowledgePrincipal principal = requireModerator(); + ensureTables(); + ensureCaseSourceRef(); + if (answerId == null || answerId < 1) { + throw new IllegalArgumentException("回答不存在"); + } + List rows = jdbcTemplate.query(""" + select a.id, a.question_id, a.content, a.answer_status, a.is_best, + q.title, q.category + from aihr_community_answer a + join aihr_community_question q + on q.tenant_id = a.tenant_id and q.id = a.question_id + where a.tenant_id = ? and a.id = ? + """, (rs, rowNum) -> new BestAnswerSource( + rs.getLong("id"), rs.getLong("question_id"), rs.getString("content"), + rs.getString("answer_status"), rs.getBoolean("is_best"), + rs.getString("title"), rs.getString("category")), + principal.tenantId(), answerId); + if (rows.isEmpty()) { + throw new IllegalArgumentException("回答不存在"); + } + BestAnswerSource answer = rows.get(0); + if (!answer.best() || !"PUBLISHED".equals(answer.status())) { + throw new IllegalArgumentException("仅已发布且被评为最佳的回答可转为经验候选"); + } + String sourceMarker = "community-answer:" + answerId; + List existing = jdbcTemplate.query(""" + select case_id from aihr_case_record + where tenant_id = ? and file_name = ? + limit 1 + """, (rs, rowNum) -> rs.getString("case_id"), principal.tenantId(), sourceMarker); + if (!existing.isEmpty()) { + return new CaseCandidateResponse(existing.get(0), false); + } + String caseId = "case-" + UUID.randomUUID(); + try { + jdbcTemplate.update(""" + insert into aihr_case_record + (tenant_id, case_id, file_name, project_ext_org_id, title, type, status, knowledge_status, + source_type, source_ref, transcript, summary, applicable_positions, + source_authorized, deidentified, owner, create_time, update_time) + values (?, ?, ?, '', ?, '问题榜最佳答案', '待审核', 'CANDIDATE', 'COMMUNITY_QA', ?, ?, ?, ?, 0, 0, + '问题榜', now(), now()) + """, principal.tenantId(), caseId, sourceMarker, + truncate(clean(answer.questionTitle()), 120), + String.valueOf(answerId), + truncate(AihrSensitiveText.forModel(answer.content()), 4000), + truncate(AihrSensitiveText.forModel(answer.questionTitle()), 1000), + positionForCategory(answer.category())); + } catch (DuplicateKeyException ignored) { + List replay = jdbcTemplate.query(""" + select case_id from aihr_case_record + where tenant_id = ? and file_name = ? + limit 1 + """, (rs, rowNum) -> rs.getString("case_id"), principal.tenantId(), sourceMarker); + if (replay.isEmpty()) { + throw new IllegalStateException("经验候选创建冲突,请稍后重试"); + } + return new CaseCandidateResponse(replay.get(0), false); + } + return new CaseCandidateResponse(caseId, true); + } + + /** + * Best-effort category-to-position hint for community-sourced candidates. When no catalog + * position can be determined the scope stays empty (universal); the formal publish flow + * requires a human to confirm the applicable positions anyway. + */ + static String positionForCategory(String category) { + String value = clean(category); + if (value.isBlank()) { + return ""; + } + if (value.contains("保安") || value.contains("秩序") || value.contains("安防")) { + return "保安"; + } + if (value.contains("保洁") || value.contains("环境") || value.contains("绿化")) { + return "保洁"; + } + if (value.contains("保修") || value.contains("维修") || value.contains("工程") || value.contains("报修")) { + return "保修"; + } + if (value.contains("客服") || value.contains("管家")) { + return "客服"; + } + if (value.contains("生活顾问") || value.contains("顾问")) { + return "生活顾问"; + } + return ""; + } + + private void ensureCaseSourceRef() { + if (!runtimeSchemaBootstrap) { + AihrSchemaMigrationGuard.requireTables(jdbcTemplate, "aihr_case_record"); + AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_case_record", "source_ref"); + AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_case_record", + "uk_aihr_case_record_source", true, "tenant_id", "source_type", "source_ref"); + return; + } + Integer columnCount = jdbcTemplate.queryForObject(""" + select count(*) from information_schema.columns + where table_schema = database() and table_name = 'aihr_case_record' and column_name = 'source_ref' + """, Integer.class); + if (columnCount == null || columnCount == 0) { + jdbcTemplate.execute(""" + ALTER TABLE `aihr_case_record` + ADD COLUMN `source_ref` varchar(80) DEFAULT NULL COMMENT '经验来源引用(如问题榜回答ID)' AFTER `source_type` + """); + } + Integer indexCount = jdbcTemplate.queryForObject(""" + select count(*) from information_schema.statistics + where table_schema = database() and table_name = 'aihr_case_record' + and index_name = 'uk_aihr_case_record_source' + """, Integer.class); + if (indexCount == null || indexCount == 0) { + jdbcTemplate.execute(""" + ALTER TABLE `aihr_case_record` + ADD UNIQUE KEY `uk_aihr_case_record_source` (`tenant_id`, `source_type`, `source_ref`) + """); + } + } + + private static String likePattern(String term) { + String escaped = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + return "%" + escaped + "%"; + } + private QuestionResponse response(AihrKnowledgePrincipal principal, QuestionRow row, boolean includeModeration) { List answers = answers(principal, row.id(), includeModeration); return new QuestionResponse(row.id(), row.type(), row.category(), row.title(), row.content(), row.authorName(), @@ -567,6 +750,8 @@ public class AihrCommunityService { "uk_aihr_community_question_submit_key", true, "tenant_id", "author_user_id", "submit_idempotency_key"); AihrSchemaMigrationGuard.requireColumns(jdbcTemplate, "aihr_points_ledger", "account_type"); + AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_community_answer", + "idx_aihr_community_answer_best", false, "tenant_id", "is_best", "answer_status"); tablesReady = true; return; } @@ -587,6 +772,17 @@ public class AihrCommunityService { """); } ensureQuestionBridgeSchema(); + Integer bestIndexCount = jdbcTemplate.queryForObject(""" + select count(*) from information_schema.statistics + where table_schema = database() and table_name = 'aihr_community_answer' + and index_name = 'idx_aihr_community_answer_best' + """, Integer.class); + if (bestIndexCount == null || bestIndexCount == 0) { + jdbcTemplate.execute(""" + ALTER TABLE `aihr_community_answer` + ADD KEY `idx_aihr_community_answer_best` (`tenant_id`, `is_best`, `answer_status`) + """); + } tablesReady = true; } } @@ -623,7 +819,8 @@ public class AihrCommunityService { `moderation_note` varchar(300) DEFAULT NULL, `moderated_time` datetime DEFAULT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), KEY `idx_aihr_community_answer_question` (`tenant_id`, `question_id`, `answer_status`, `create_time`) + PRIMARY KEY (`id`), KEY `idx_aihr_community_answer_question` (`tenant_id`, `question_id`, `answer_status`, `create_time`), + KEY `idx_aihr_community_answer_best` (`tenant_id`, `is_best`, `answer_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci """); ddls.add(""" @@ -777,4 +974,8 @@ public class AihrCommunityService { private record LinkedQuestion(Long questionId, String sourceRequestId) { } + + private record BestAnswerSource(Long id, Long questionId, String content, String status, boolean best, + String questionTitle, String category) { + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrQuestionAdminController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrQuestionAdminController.java index 2c97270f..f7f5817b 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrQuestionAdminController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/community/AihrQuestionAdminController.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import org.dromara.aihr.community.AihrCommunityDto.AnswerResponse; import org.dromara.aihr.community.AihrCommunityDto.BestAnswerRequest; import org.dromara.aihr.community.AihrCommunityDto.BestAnswerResponse; +import org.dromara.aihr.community.AihrCommunityDto.CaseCandidateResponse; import org.dromara.aihr.community.AihrCommunityDto.CreateQuestionRequest; import org.dromara.aihr.community.AihrCommunityDto.QuestionResponse; import org.dromara.aihr.community.AihrCommunityDto.ReviewRequest; @@ -54,4 +55,13 @@ public class AihrQuestionAdminController { } return R.ok(communityService.chooseBest(id, request.answerId(), true)); } + + /** + * 最佳答案转经验候选:只创建 CANDIDATE 候选(source_authorized/deidentified 保持 0), + * 正式发布仍走案例库人工确认链;同一 answerId 幂等返回同一 caseId。 + */ + @PostMapping("/answers/{id}/to-case-candidate") + public R toCaseCandidate(@PathVariable Long id) { + return R.ok(communityService.toCaseCandidate(id)); + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java index 30b78558..b96793c8 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java @@ -81,7 +81,7 @@ public class AihrCaseController { if (!canViewCases()) { return R.fail("无权查看案例素材"); } - return R.ok(caseService.records(limit, currentProjectScopes())); + return R.ok(caseService.records(limit, currentProjectScopes(), currentAppPositions())); } @GetMapping("/records/{caseId}") @@ -89,7 +89,7 @@ public class AihrCaseController { if (!canViewCases()) { return R.fail("无权查看案例素材"); } - DetailResponse detail = caseService.detail(caseId, currentProjectScopes()); + DetailResponse detail = caseService.detail(caseId, currentProjectScopes(), currentAppPositions()); return detail == null ? R.fail("案例不存在或无权访问") : R.ok(detail); } @@ -99,7 +99,7 @@ public class AihrCaseController { response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权查看案例音频"); return; } - Long mediaOssId = caseService.authorizedMediaOssId(caseId, currentProjectScopes()); + Long mediaOssId = caseService.authorizedMediaOssId(caseId, currentProjectScopes(), currentAppPositions()); if (mediaOssId == null) { response.sendError(HttpServletResponse.SC_NOT_FOUND, "案例音频不存在或无权访问"); return; @@ -150,6 +150,22 @@ public class AihrCaseController { return caseService.projectScopesForAppUser(currentAppMobilePhone(loginUser)); } + /** + * Server-side position scope for the experience position gate. Management-side viewers keep + * the null (ungated) semantics; APP employees are resolved from the org snapshot and an + * unresolved position only matches universal (empty-scope) experience. + */ + private List currentAppPositions() { + LoginUser loginUser = LoginHelper.getLoginUser(); + if (loginUser != null && UserType.SYS_USER.getUserType().equals(loginUser.getUserType())) { + return null; + } + if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) { + return List.of(); + } + return caseService.positionsForAppUser(currentAppMobilePhone(loginUser)); + } + private boolean canManageCases() { LoginUser loginUser = LoginHelper.getLoginUser(); if (loginUser != null && UserType.SYS_USER.getUserType().equals(loginUser.getUserType())) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java index bb52f919..888e4789 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java @@ -20,17 +20,23 @@ import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.Conve import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.PendingClarification; import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.ToolResult; import org.dromara.aihr.knowledge.quality.AihrKnowledgeGlossaryService; +import org.dromara.aihr.community.AihrCommunityDto.BestAnswerMatch; +import org.dromara.aihr.community.AihrCommunityService; import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse; import org.dromara.aihr.memory.AihrMemoryDto.SourceSnapshot; import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecall; import org.dromara.aihr.memory.AihrMemoryService; +import org.dromara.aihr.service.AihrEmployeePositionResolver; import org.dromara.aihr.service.AihrModelSeedService; +import org.dromara.aihr.service.AihrPositionGate; +import org.dromara.aihr.service.AihrSensitiveText; import org.dromara.aihr.service.AihrSopSeedService; import org.dromara.aihr.service.AihrSopSeedService.SearchExecution; import org.dromara.common.core.constant.HttpStatus; import org.dromara.common.core.enums.UserType; import org.dromara.common.core.exception.ServiceException; import org.dromara.common.tenant.helper.TenantHelper; +import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -68,6 +74,8 @@ public class AihrKnowledgeQueryService { private final AihrModelSeedService modelService; private final AihrKnowledgeCitationDetailService citationDetailService; private final AihrKnowledgeGlossaryService glossaryService; + private final AihrEmployeePositionResolver positionResolver; + private final AihrCommunityService communityService; @Autowired public AihrKnowledgeQueryService(AihrKnowledgePrincipalResolver principalResolver, @@ -82,7 +90,9 @@ public class AihrKnowledgeQueryService { AihrBroadcastService broadcastService, AihrModelSeedService modelService, AihrKnowledgeCitationDetailService citationDetailService, - AihrKnowledgeGlossaryService glossaryService) { + AihrKnowledgeGlossaryService glossaryService, + AihrEmployeePositionResolver positionResolver, + AihrCommunityService communityService) { this.principalResolver = principalResolver; this.appService = appService; this.accessService = accessService; @@ -96,6 +106,26 @@ public class AihrKnowledgeQueryService { this.modelService = modelService; this.citationDetailService = citationDetailService; this.glossaryService = glossaryService; + this.positionResolver = positionResolver; + this.communityService = communityService; + } + + public AihrKnowledgeQueryService(AihrKnowledgePrincipalResolver principalResolver, + AihrKnowledgeAppService appService, + AihrKnowledgeAccessService accessService, + AihrSopSeedService sopService, + AihrKnowledgeQueryAuditService auditService, + JdbcTemplate jdbcTemplate, + AihrKnowledgeDataToolService dataToolService, + AihrKnowledgeConversationService conversationService, + AihrMemoryService memoryService, + AihrBroadcastService broadcastService, + AihrModelSeedService modelService, + AihrKnowledgeCitationDetailService citationDetailService, + AihrKnowledgeGlossaryService glossaryService) { + this(principalResolver, appService, accessService, sopService, auditService, jdbcTemplate, + dataToolService, conversationService, memoryService, broadcastService, modelService, + citationDetailService, glossaryService, null, null); } public AihrKnowledgeQueryService(AihrKnowledgePrincipalResolver principalResolver, @@ -138,6 +168,7 @@ public class AihrKnowledgeQueryService { QueryRequest request = validate(rawRequest); AihrKnowledgePrincipal principal = projectPrincipal(principalResolver.current(), request.projectCode()); AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey()); + List positions = resolvePositions(principal); ConversationContext context = conversationContext(principal, app, request); QueryRequest routed = routedRequest(request, context); Set spaceIds = Set.of(); @@ -164,7 +195,7 @@ public class AihrKnowledgeQueryService { request.queryText(), spaceIds.isEmpty() ? emptyProjectResponse(request.queryText()) - : queryDocuments(principal, app, spaceIds, routed, request.queryText()) + : queryDocuments(principal, app, spaceIds, routed, request.queryText(), positions) ); // Revalidate immediately before the response can be persisted into the short // conversation. A withdrawal (or an employee becoming ineligible) during the @@ -201,13 +232,19 @@ public class AihrKnowledgeQueryService { if (response == null) { response = candidateIntent ? emptyProjectResponse(request.queryText()) - : queryDocuments(principal, app, spaceIds, routed, request.queryText()); + : queryDocuments(principal, app, spaceIds, routed, request.queryText(), positions); finalizeAudit = candidateIntent; } if (!memoryRecall.items().isEmpty()) { response = mergeServiceMemories(memoryRecall, response); finalizeAudit = true; } + List auxiliary = auxiliaryEvidenceCitations( + app.tenantId(), principal, positions, routed, context, candidateIntent); + if (!auxiliary.isEmpty()) { + response = mergeAuxiliaryEvidence(response, auxiliary); + finalizeAudit = true; + } } MemoryCandidateResponse candidate = context.broadcastMessageId() != null || !"QA".equals(context.intent()) || hasText(routed.toolCode()) @@ -289,14 +326,15 @@ public class AihrKnowledgeQueryService { List snippets = evidenceResponse == null ? List.of() : evidenceResponse.snippets(); long citationGateStarted = System.nanoTime(); List citations = withCitationIds(requestId, citations( - app.tenantId(), spaceIds, snippets, formalPolicyOnly, request.queryText())); + app.tenantId(), spaceIds, snippets, formalPolicyOnly, request.queryText(), + resolvePositions(principal))); Map timings = new LinkedHashMap<>(execution == null ? Map.of() : execution.timings()); timings.put("CITATION_GATE", elapsedMillis(citationGateStarted)); List candidates = execution == null ? List.of() : execution.candidates(); String status = citations.isEmpty() ? "NO_EVIDENCE" : "EVIDENCE_READY"; long auditStarted = System.nanoTime(); auditService.record(requestId, principal, app, request.queryText(), scopeCodes, - citations.isEmpty() ? List.of() : List.of("DOCUMENT"), status, + citationSourceTypes(citations), status, elapsedMillis(started), "evidence-only-v1"); auditService.recordEvidence(requestId, app.tenantId(), snippets, citations.stream() .map(Citation::fragmentId).filter(Objects::nonNull).toList()); @@ -352,6 +390,7 @@ public class AihrKnowledgeQueryService { } AihrKnowledgePrincipal principal = projectPrincipal(principalResolver.current(), request.projectCode()); AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey()); + List positions = resolvePositions(principal); Set spaceIds = mode == MediaMode.MEDIA_ONLY ? Set.of() : accessService.resolveInternalSpaceIds(principal, app, request.spaceCodes(), "READ"); @@ -380,7 +419,7 @@ public class AihrKnowledgeQueryService { combinedQuery, request.spaceCodes(), request.category(), request.position(), request.source(), request.limit(), null, request.conversationId(), request.contextVersion(), request.projectCode() - ), request.queryText()); + ), request.queryText(), positions); response = withMediaContextWhenNoEvidence(response, extractedContext); } QueryResponse completed = complete(principal, app, spaceIds, request.queryText(), context, response); @@ -417,7 +456,7 @@ public class AihrKnowledgeQueryService { } return TenantHelper.dynamic(app.tenantId(), () -> { Set spaceIds = accessService.resolveExternalSpaceIds(app, request.spaceCodes()); - return queryDocuments(null, app, spaceIds, request, request.queryText()); + return queryDocuments(null, app, spaceIds, request, request.queryText(), List.of()); }); } @@ -474,7 +513,8 @@ public class AihrKnowledgeQueryService { } private QueryResponse queryDocuments(AihrKnowledgePrincipal principal, AuthenticatedApp app, - Set spaceIds, QueryRequest request, String displayQuery) { + Set spaceIds, QueryRequest request, String displayQuery, + List positions) { String requestId = UUID.randomUUID().toString().replace("-", ""); long started = System.nanoTime(); List scopeCodes = spaceCodes(app.tenantId(), spaceIds); @@ -502,7 +542,7 @@ public class AihrKnowledgeQueryService { } SearchResponse legacy = execution.response(); List citations = citations( - app.tenantId(), spaceIds, legacy.snippets(), formalPolicyOnly, request.queryText()); + app.tenantId(), spaceIds, legacy.snippets(), formalPolicyOnly, request.queryText(), positions); QueryPlan plan = execution.queryPlan() != null ? execution.queryPlan() : AihrRagQueryPlanner.plan(request.queryText()); // 降级阶梯仲裁单点:noEvidence、答案文案和证据分级全部来自 grading 结果。 @@ -516,7 +556,7 @@ public class AihrKnowledgeQueryService { String status = noEvidence ? "NO_EVIDENCE" : needsClarification ? "NEEDS_CLARIFICATION" : "SUCCESS"; long latency = elapsedMillis(started); auditService.record(requestId, principal, app, displayQuery, scopeCodes, - citations.isEmpty() ? List.of() : List.of("DOCUMENT"), status, latency, gradedLegacy.promptVersion()); + citationSourceTypes(citations), status, latency, gradedLegacy.promptVersion()); auditService.recordEvidence(requestId, app.tenantId(), gradedLegacy.snippets(), citations.stream() .map(Citation::fragmentId).filter(Objects::nonNull).toList()); auditService.recordCandidates(requestId, app.tenantId(), execution.candidates()); @@ -822,6 +862,166 @@ public class AihrKnowledgeQueryService { ); } + private List resolvePositions(AihrKnowledgePrincipal principal) { + if (positionResolver == null || principal == null) { + return List.of(); + } + return positionResolver.resolvePositions(principal); + } + + /** + * Auxiliary read-only answer sources for QA questions: published experience-library cases + * (position-gated) and community best answers. Both are REFERENCE-tier evidence; any failure + * in an auxiliary source must never break the main retrieval chain. + */ + private List auxiliaryEvidenceCitations(String tenantId, AihrKnowledgePrincipal principal, + List positions, QueryRequest routed, + ConversationContext context, boolean candidateIntent) { + if (principal == null || candidateIntent || !"QA".equals(context.intent())) { + return List.of(); + } + List auxiliary = new ArrayList<>(); + auxiliary.addAll(publishedExperienceCitations(tenantId, positions, routed.queryText())); + auxiliary.addAll(communityBestAnswerCitations(tenantId, routed.queryText())); + return List.copyOf(auxiliary); + } + + private List publishedExperienceCitations(String tenantId, List positions, String queryText) { + List terms = AihrRagQueryPlanner.sparseTerms(queryText).stream().limit(5).toList(); + if (terms.isEmpty()) { + return List.of(); + } + try { + String matchExpr = "CONCAT_WS(' ', COALESCE(title, ''), COALESCE(summary, ''), COALESCE(transcript, ''))"; + String hits = terms.stream() + .map(term -> "(CASE WHEN " + matchExpr + " LIKE ? ESCAPE '\\\\' THEN 1 ELSE 0 END)") + .collect(java.util.stream.Collectors.joining(" + ")); + String termWhere = terms.stream() + .map(term -> matchExpr + " LIKE ? ESCAPE '\\\\'") + .collect(java.util.stream.Collectors.joining(" OR ")); + List args = new ArrayList<>(); + args.add(tenantId); + String positionGate = AihrPositionGate.applicableScopeSql("applicable_positions", positions); + args.addAll(AihrPositionGate.normalizedPositions(positions)); + terms.forEach(term -> args.add(likePattern(term))); + terms.forEach(term -> args.add(likePattern(term))); + return jdbcTemplate.query(""" + SELECT case_id, title, summary, content_version, effective_from + FROM aihr_case_record + WHERE tenant_id = ? + AND status = '已入库' + AND knowledge_status = 'PUBLISHED' + AND (effective_from IS NULL OR effective_from <= CURRENT_DATE()) + AND (effective_to IS NULL OR effective_to >= CURRENT_DATE()) + AND %s + AND (%s) + ORDER BY (%s) DESC, update_time DESC, id DESC + LIMIT 3 + """.formatted(positionGate, termWhere, hits), + (rs, rowNum) -> new Citation( + null, CASE_EXP_SOURCE_TYPE, "case:" + rs.getString("case_id"), + hasText(rs.getString("title")) ? rs.getString("title") : "现场经验", + truncate(masked(firstNonBlank(rs.getString("summary"), "")), 500), + null, CASE_EXP_SOURCE_TYPE, "PUBLISHED", null, null, + null, null, null, "EXPERIENCE_LIBRARY", "CASE", null, + rs.getString("content_version"), + rs.getDate("effective_from") == null ? null : rs.getDate("effective_from").toString(), + null, AihrEvidenceGradingService.TIER_REFERENCE + ), args.toArray()); + } catch (DataAccessException ex) { + // 经验库是增强来源:表结构未迁移或查询失败时不得影响主检索链。 + return List.of(); + } + } + + private List communityBestAnswerCitations(String tenantId, String queryText) { + if (communityService == null) { + return List.of(); + } + List matches; + try { + matches = communityService.searchBestAnswers(tenantId, queryText, 3); + } catch (RuntimeException ex) { + // 问题榜是增强来源:任何失败都不得影响主检索链。 + return List.of(); + } + if (matches == null || matches.isEmpty()) { + return List.of(); + } + return matches.stream() + .map(match -> new Citation( + null, COMMUNITY_SOURCE_TYPE, "community-answer:" + match.answerId(), + hasText(match.questionTitle()) ? match.questionTitle() : "问题榜最佳答案", + truncate(masked(match.answerContent()), 500), + null, COMMUNITY_SOURCE_TYPE, "PUBLISHED", null, null, + null, match.questionId() == null ? null : "community-question:" + match.questionId(), null, "COMMUNITY_BEST_ANSWER", "EXPERIENCE", null, + null, null, null, AihrEvidenceGradingService.TIER_REFERENCE + )) + .toList(); + } + + /** + * Appends auxiliary REFERENCE citations after the main retrieval. The grading single point + * decides the visible answer shape: with existing evidence the answer is untouched; with no + * document evidence the auxiliary citations are re-graded into GUIDANCE instead of ROUTE. + */ + private QueryResponse mergeAuxiliaryEvidence(QueryResponse response, List auxiliary) { + List citations = new ArrayList<>(response.citations()); + auxiliary.stream() + .filter(citation -> citations.stream() + .noneMatch(existing -> Objects.equals(existing.docId(), citation.docId()))) + .forEach(citations::add); + if (citations.size() == response.citations().size()) { + return response; + } + if (!response.noEvidence()) { + return new QueryResponse( + response.requestId(), response.queryText(), response.answer(), List.copyOf(citations), + response.usedSpaceCodes(), response.noEvidence(), response.promptVersion(), response.legacy(), + response.data(), response.conversationId(), response.contextVersion(), response.intent(), + response.rewrittenQuery(), response.resources(), response.memoryCandidate(), + response.broadcastContext(), response.queryPlan(), response.retrievalCandidates(), + response.evidenceLevel(), response.uncoveredPoints(), response.nextActions() + ); + } + String domain = response.queryPlan() == null ? null : response.queryPlan().domain(); + AihrEvidenceGradingService.GradedAnswer graded = AihrEvidenceGradingService.grade( + domain, citations, response.legacy()); + SearchResponse legacy = response.legacy() == null ? null + : response.legacy().withGradedAnswer(graded.answer(), graded.keyPoints(), graded.cautions()); + return new QueryResponse( + response.requestId(), response.queryText(), graded.answer(), List.copyOf(citations), + response.usedSpaceCodes(), graded.noEvidence(), response.promptVersion(), legacy, + response.data(), response.conversationId(), response.contextVersion(), response.intent(), + response.rewrittenQuery(), response.resources(), response.memoryCandidate(), + response.broadcastContext(), response.queryPlan(), response.retrievalCandidates(), + graded.evidenceLevel(), graded.uncoveredPoints(), graded.nextActions() + ); + } + + private static List citationSourceTypes(List citations) { + if (citations == null || citations.isEmpty()) { + return List.of(); + } + LinkedHashSet sourceTypes = new LinkedHashSet<>(); + citations.stream().map(Citation::sourceType).filter(AihrKnowledgeQueryService::hasText) + .forEach(sourceTypes::add); + return List.copyOf(sourceTypes); + } + + private static String likePattern(String term) { + String escaped = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + return "%" + escaped + "%"; + } + + private static String masked(String value) { + return AihrSensitiveText.forModel(value); + } + + private static String firstNonBlank(String value, String fallback) { + return hasText(value) ? value : fallback; + } + private static QueryResponse emptyProjectResponse(String queryText) { return new QueryResponse( UUID.randomUUID().toString().replace("-", ""), queryText, "", List.of(), List.of(), @@ -832,14 +1032,9 @@ public class AihrKnowledgeQueryService { private static List finalSourceTypes(QueryResponse response, ServiceMemoryRecall recall, MemoryCandidateResponse candidate) { LinkedHashSet sourceTypes = new LinkedHashSet<>(); - boolean hasDocument = response.citations().stream() - .anyMatch(citation -> "DOCUMENT".equals(citation.sourceType())); - if (hasDocument) { - sourceTypes.add("DOCUMENT"); - } - if (response.citations().stream().anyMatch(citation -> "BCAST".equals(citation.sourceType()))) { - sourceTypes.add("BCAST"); - } + response.citations().stream().map(Citation::sourceType) + .filter(AihrKnowledgeQueryService::hasText) + .forEach(sourceTypes::add); if (recall != null && !recall.items().isEmpty()) { recall.items().stream().map(item -> hasText(item.domain()) ? item.domain() : PROJECT_SERVICE) .forEach(sourceTypes::add); @@ -914,7 +1109,7 @@ public class AihrKnowledgeQueryService { } private List citations(String tenantId, Set allowedSpaceIds, List snippets, - boolean formalPolicyOnly, String queryText) { + boolean formalPolicyOnly, String queryText, List positions) { if (snippets == null || snippets.isEmpty()) { return List.of(); } @@ -928,7 +1123,7 @@ public class AihrKnowledgeQueryService { args.addAll(allowedSpaceIds); args.addAll(fragmentIds); String formalSourceClause = ""; - List rows = jdbcTemplate.query(""" + List rows = jdbcTemplate.query(""" select k.code as space_code, f.doc_id, coalesce(( select governed_version.redacted_source_name @@ -949,7 +1144,8 @@ public class AihrKnowledgeQueryService { governed_asset.source_version, governed_asset.effective_from, concat_ws('|', nullif(governed_asset.applicable_region, ''), nullif(governed_asset.applicable_project, ''), nullif(governed_asset.applicable_role, '')) - as applicability + as applicability, + governed_asset.applicable_role from aihr_knowledge_fragment f join aihr_knowledge_info k on k.id = f.knowledge_id and k.tenant_id = f.tenant_id left join aihr_knowledge_attach a @@ -975,22 +1171,30 @@ public class AihrKnowledgeQueryService { %s """.formatted(placeholders(allowedSpaceIds.size()), placeholders(fragmentIds.size()), formalSourceClause), - (rs, rowNum) -> new Citation( - rs.getString("space_code"), "DOCUMENT", rs.getString("doc_id"), rs.getString("title"), - rs.getString("content"), rs.getLong("fragment_id"), "ENTERPRISE", null, null, null, - null, null, null, rs.getString("source_authority"), - AihrRagQueryPlanner.sourceKind(rs.getString("source_authority"), rs.getString("source_type"), - rs.getString("source_name")), null, rs.getString("source_version"), - rs.getDate("effective_from") == null ? null : rs.getDate("effective_from").toString(), - rs.getString("applicability") + (rs, rowNum) -> new GovernedCitation( + new Citation( + rs.getString("space_code"), "DOCUMENT", rs.getString("doc_id"), rs.getString("title"), + rs.getString("content"), rs.getLong("fragment_id"), "ENTERPRISE", null, null, null, + null, null, null, rs.getString("source_authority"), + AihrRagQueryPlanner.sourceKind(rs.getString("source_authority"), rs.getString("source_type"), + rs.getString("source_name")), null, rs.getString("source_version"), + rs.getDate("effective_from") == null ? null : rs.getDate("effective_from").toString(), + rs.getString("applicability") + ), + rs.getString("applicable_role") ), args.toArray()); - Map byId = new LinkedHashMap<>(); - rows.forEach(row -> byId.put(row.fragmentId(), row)); + Map byId = new LinkedHashMap<>(); + rows.forEach(row -> byId.put(row.citation().fragmentId(), row)); // 授权/生效门保持不变;formalPolicyOnly 不再按来源级别过滤,改为标记 tier: // FORMAL 级证据仍需通过相关性判定,REFERENCE 级证据保留并降级为参考经验。 + // 岗位门:EXPERIENCE/CASE 类证据额外要求 applicable_role 为空(通用)或包含当前 + // 员工岗位(服务端解析);解析不到岗位时仅空适用范围可通过。其他来源行为不变。 return fragmentIds.stream() .map(byId::get) .filter(java.util.Objects::nonNull) + .filter(row -> !POSITION_GATED_SOURCE_KINDS.contains(row.citation().sourceKind()) + || AihrPositionGate.matches(row.applicableRole(), positions)) + .map(GovernedCitation::citation) .map(row -> new Citation( row.spaceCode(), row.sourceType(), row.docId(), row.title(), row.snippet(), row.fragmentId(), row.domain(), row.status(), row.occurredAt(), @@ -1118,6 +1322,9 @@ public class AihrKnowledgeQueryService { } private static final String PROJECT_SERVICE = "PROJECT_SERVICE"; + private static final String CASE_EXP_SOURCE_TYPE = "CASE_EXP"; + private static final String COMMUNITY_SOURCE_TYPE = "COMMUNITY"; + private static final Set POSITION_GATED_SOURCE_KINDS = Set.of("EXPERIENCE", "CASE"); private static final String BROADCAST_PROMPT_VERSION = "broadcast-context-v1"; private static final int BROADCAST_MODEL_ANSWER_MAX = 1200; private static final int BROADCAST_ECHO_FRAGMENT_MIN = 24; @@ -1134,6 +1341,9 @@ public class AihrKnowledgeQueryService { private record AttachmentResource(Long attachmentId, String title) { } + private record GovernedCitation(Citation citation, String applicableRole) { + } + private record CitationLocation(Long attachmentId, String sourceKind, org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.LocatorSummary summary) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java index da5a7bb8..3f38d1ff 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java @@ -238,6 +238,10 @@ public class AihrCaseService { } public List records(Integer limit, List projectScopes) { + return records(limit, projectScopes, null); + } + + public List records(Integer limit, List projectScopes, List positions) { ensureCaseTable(); if (projectScopes != null && projectScopes.isEmpty()) { return List.of(); @@ -249,9 +253,14 @@ public class AihrCaseService { projectWhere = " AND project_ext_org_id IN (" + placeholders(projectScopes.size()) + ")"; args.addAll(projectScopes); } - String publishedWhere = projectScopes == null ? "" : " AND status = '已入库' AND knowledge_status = 'PUBLISHED'" - + " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())" - + " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())"; + String publishedWhere = ""; + if (projectScopes != null) { + publishedWhere = " AND status = '已入库' AND knowledge_status = 'PUBLISHED'" + + " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())" + + " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())" + + " AND " + AihrPositionGate.applicableScopeSql("applicable_positions", positions); + args.addAll(AihrPositionGate.normalizedPositions(positions)); + } args.add(safeLimit); String sql = """ SELECT case_id, title, type, status, knowledge_status, source_type, @@ -279,6 +288,10 @@ public class AihrCaseService { } public DetailResponse detail(String caseId, List projectScopes) { + return detail(caseId, projectScopes, null); + } + + public DetailResponse detail(String caseId, List projectScopes, List positions) { String normalizedCaseId = clean(caseId); if (normalizedCaseId.isBlank() || (projectScopes != null && projectScopes.isEmpty())) { return null; @@ -291,9 +304,14 @@ public class AihrCaseService { projectWhere = " AND project_ext_org_id IN (" + placeholders(projectScopes.size()) + ")"; args.addAll(projectScopes); } - String publishedWhere = projectScopes == null ? "" : " AND status = '已入库' AND knowledge_status = 'PUBLISHED'" - + " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())" - + " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())"; + String publishedWhere = ""; + if (projectScopes != null) { + publishedWhere = " AND status = '已入库' AND knowledge_status = 'PUBLISHED'" + + " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())" + + " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())" + + " AND " + AihrPositionGate.applicableScopeSql("applicable_positions", positions); + args.addAll(AihrPositionGate.normalizedPositions(positions)); + } String sql = """ SELECT case_id, file_name, title, type, status, knowledge_status, source_type, applicable_positions, content_version, effective_from, effective_to, @@ -337,6 +355,10 @@ public class AihrCaseService { * The controller streams the object through the authenticated API instead of exposing the raw OSS URL. */ public Long authorizedMediaOssId(String caseId, List projectScopes) { + return authorizedMediaOssId(caseId, projectScopes, null); + } + + public Long authorizedMediaOssId(String caseId, List projectScopes, List positions) { String normalizedCaseId = clean(caseId); if (normalizedCaseId.isBlank() || (projectScopes != null && projectScopes.isEmpty())) { return null; @@ -349,9 +371,14 @@ public class AihrCaseService { projectWhere = " AND project_ext_org_id IN (" + placeholders(projectScopes.size()) + ")"; args.addAll(projectScopes); } - String publishedWhere = projectScopes == null ? "" : " AND status = '已入库' AND knowledge_status = 'PUBLISHED'" - + " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())" - + " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())"; + String publishedWhere = ""; + if (projectScopes != null) { + publishedWhere = " AND status = '已入库' AND knowledge_status = 'PUBLISHED'" + + " AND (effective_from IS NULL OR effective_from <= CURRENT_DATE())" + + " AND (effective_to IS NULL OR effective_to >= CURRENT_DATE())" + + " AND " + AihrPositionGate.applicableScopeSql("applicable_positions", positions); + args.addAll(AihrPositionGate.normalizedPositions(positions)); + } String sql = """ SELECT media_oss_id FROM aihr_case_record @@ -393,6 +420,37 @@ public class AihrCaseService { } } + /** + * Active positions of an APP employee, resolved by exact mobile phone with the same + * identity discipline as {@link #projectScopesForAppUser}. An empty result is fail-closed + * at the position gate (only universal experience remains visible). + */ + public List positionsForAppUser(String mobilePhone) { + String phone = clean(mobilePhone); + if (phone.isBlank()) { + return List.of(); + } + try { + return jdbcTemplate.query(""" + SELECT DISTINCT position_name + FROM aihr_org_snapshot + WHERE tenant_id = ? + AND employment_status = 'active' + AND person_phone = ? + AND position_name IS NOT NULL + AND position_name <> '' + ORDER BY position_name + """, (rs, rowNum) -> rs.getString("position_name"), tenantId(), phone).stream() + .map(AihrCaseService::clean) + .filter(value -> !value.isBlank()) + .distinct() + .toList(); + } catch (DataAccessException e) { + log.warn("resolve app user positions failed(处理错误已隐藏)"); + return List.of(); + } + } + public boolean isCaseContributor(String mobilePhone) { String phone = clean(mobilePhone); if (phone.isBlank()) { @@ -497,9 +555,11 @@ public class AihrCaseService { "media_oss_id", "media_url", "learning_points", "supervisor_comment", "synthetic_content", "generator_type", "model_name", "prompt_version", "grounding_oss_id", "ai_summary_hash", "reviewer_id", "reviewed_time", - "knowledge_status", "source_type", "applicable_positions", "content_version", + "knowledge_status", "source_type", "source_ref", "applicable_positions", "content_version", "source_authorized", "deidentified", "effective_from", "effective_to", "release_owner", "release_note", "released_by", "released_time", "retire_reason"); + AihrSchemaMigrationGuard.requireIndexDefinition(jdbcTemplate, "aihr_case_record", + "uk_aihr_case_record_source", true, "tenant_id", "source_type", "source_ref"); tableReady = true; return; } @@ -522,6 +582,7 @@ public class AihrCaseService { `owner` varchar(80) DEFAULT '培训组' COMMENT '负责人', `knowledge_status` varchar(20) NOT NULL DEFAULT 'CANDIDATE' COMMENT 'CANDIDATE/PUBLISHED/OFFLINE', `source_type` varchar(40) DEFAULT NULL COMMENT '经验来源类型', + `source_ref` varchar(80) DEFAULT NULL COMMENT '经验来源引用(如问题榜回答ID)', `applicable_positions` varchar(500) DEFAULT NULL COMMENT '适用岗位', `content_version` varchar(50) DEFAULT NULL COMMENT '正式经验版本', `source_authorized` tinyint(1) NOT NULL DEFAULT 0 COMMENT '来源是否已授权', @@ -545,6 +606,7 @@ public class AihrCaseService { `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_aihr_case_record_case` (`tenant_id`, `case_id`), + UNIQUE KEY `uk_aihr_case_record_source` (`tenant_id`, `source_type`, `source_ref`), KEY `idx_aihr_case_record_status` (`tenant_id`, `status`, `update_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 案例沉淀记录'; """); @@ -562,6 +624,9 @@ public class AihrCaseService { addCaseColumn("reviewed_time", "ALTER TABLE aihr_case_record ADD COLUMN reviewed_time datetime DEFAULT NULL COMMENT '人工审核时间'"); addCaseColumn("knowledge_status", "ALTER TABLE aihr_case_record ADD COLUMN knowledge_status varchar(20) NOT NULL DEFAULT 'CANDIDATE' COMMENT 'CANDIDATE/PUBLISHED/OFFLINE'"); addCaseColumn("source_type", "ALTER TABLE aihr_case_record ADD COLUMN source_type varchar(40) DEFAULT NULL COMMENT '经验来源类型'"); + addCaseColumn("source_ref", "ALTER TABLE aihr_case_record ADD COLUMN source_ref varchar(80) DEFAULT NULL COMMENT '经验来源引用(如问题榜回答ID)'"); + addCaseIndex("uk_aihr_case_record_source", + "ALTER TABLE aihr_case_record ADD UNIQUE KEY uk_aihr_case_record_source (tenant_id, source_type, source_ref)"); addCaseColumn("applicable_positions", "ALTER TABLE aihr_case_record ADD COLUMN applicable_positions varchar(500) DEFAULT NULL COMMENT '适用岗位'"); addCaseColumn("content_version", "ALTER TABLE aihr_case_record ADD COLUMN content_version varchar(50) DEFAULT NULL COMMENT '正式经验版本'"); addCaseColumn("source_authorized", "ALTER TABLE aihr_case_record ADD COLUMN source_authorized tinyint(1) NOT NULL DEFAULT 0 COMMENT '来源是否已授权'"); @@ -754,6 +819,20 @@ public class AihrCaseService { } } + private void addCaseIndex(String index, String sql) { + try { + Integer count = jdbcTemplate.queryForObject(""" + select count(*) from information_schema.statistics + where table_schema = database() and table_name = 'aihr_case_record' and index_name = ? + """, Integer.class, index); + if (count == null || count == 0) { + jdbcTemplate.execute(sql); + } + } catch (RuntimeException e) { + log.debug("case table index already exists or cannot be created: {}", index); + } + } + private static List tagsFromText(String text) { String source = clean(text); List tags = new ArrayList<>(); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrEmployeePositionResolver.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrEmployeePositionResolver.java new file mode 100644 index 00000000..fb9a4db4 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrEmployeePositionResolver.java @@ -0,0 +1,66 @@ +package org.dromara.aihr.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.common.core.enums.UserType; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Objects; + +/** + * Server-side resolution of the current employee's active positions from the organization + * snapshot. Clients never submit a position: APP employees are looked up by their authenticated + * organization identity ({@code ext_party_id}); anything that cannot be resolved (including + * management-side previews without an organization identity) yields an empty result, which the + * position gate treats as "only universal (empty-scope) experience applies". + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class AihrEmployeePositionResolver { + + private final JdbcTemplate jdbcTemplate; + + public List resolvePositions(AihrKnowledgePrincipal principal) { + if (principal == null || !UserType.APP_USER.getUserType().equals(principal.userType()) + || !hasText(principal.extPartyId()) || jdbcTemplate == null) { + return List.of(); + } + try { + return jdbcTemplate.query(""" + SELECT DISTINCT position_name + FROM aihr_org_snapshot + WHERE tenant_id = ? + AND employment_status = 'active' + AND ext_party_id = ? + AND position_name IS NOT NULL AND position_name <> '' + ORDER BY position_name + """, (rs, rowNum) -> rs.getString("position_name"), + principal.tenantId(), principal.extPartyId()).stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .distinct() + .toList(); + } catch (DataAccessException ex) { + // Position resolution must never break the main chain; an unresolved position is + // fail-closed at the gate (only universal experience remains visible). + log.warn("resolve employee positions failed, falling back to universal scope only"); + return List.of(); + } + } + + /** The first active position in deterministic order, or null when none can be resolved. */ + public String resolvePosition(AihrKnowledgePrincipal principal) { + List positions = resolvePositions(principal); + return positions.isEmpty() ? null : positions.get(0); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPositionGate.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPositionGate.java new file mode 100644 index 00000000..75e95f04 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPositionGate.java @@ -0,0 +1,83 @@ +package org.dromara.aihr.service; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; + +/** + * Position gate for experience/case-type evidence. An empty applicable scope is universal and + * always passes; a non-empty scope requires the current employee position (server-resolved, + * never client-supplied) to appear in the scope. When no position can be resolved the gate is + * fail-closed: only universal (empty-scope) entries pass. + * + *

The applicable scope is stored as free text separated by ASCII/Chinese commas, Chinese + * enumeration marks or semicolons (for example "生活顾问,客服管家" or "生活顾问、保安"). Both + * sides are normalized the same way before comparison: separators become commas and all + * whitespace is removed.

+ */ +public final class AihrPositionGate { + + private static final String SCOPE_SPLIT = "[,,、;;/]"; + + private AihrPositionGate() { + } + + /** Trims entries, removes whitespace, drops blanks and dedupes, keeping order. */ + public static List normalizedPositions(List positions) { + if (positions == null || positions.isEmpty()) { + return List.of(); + } + LinkedHashSet normalized = new LinkedHashSet<>(); + for (String position : positions) { + String value = normalizeToken(position); + if (!value.isEmpty()) { + normalized.add(value); + } + } + return List.copyOf(normalized); + } + + /** + * Java-side hard gate. Blank scope is universal; otherwise any normalized employee position + * must appear in the scope. Empty employee positions only match the universal scope. + */ + public static boolean matches(String applicableScope, List positions) { + String scope = applicableScope == null ? "" : applicableScope.trim(); + if (scope.isEmpty()) { + return true; + } + List normalized = normalizedPositions(positions); + if (normalized.isEmpty()) { + return false; + } + for (String token : scope.split(SCOPE_SPLIT)) { + if (normalized.contains(normalizeToken(token))) { + return true; + } + } + return false; + } + + /** + * SQL predicate equivalent of {@link #matches} for a stored scope column. Emits one {@code ?} + * placeholder per normalized position; bind {@link #normalizedPositions} in the same order. + */ + public static String applicableScopeSql(String column, List positions) { + List normalized = normalizedPositions(positions); + String universal = "(" + column + " IS NULL OR TRIM(" + column + ") = '')"; + if (normalized.isEmpty()) { + return universal; + } + String normalizedColumn = "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(" + column + "," + + " ',', ','), '、', ','), ';', ','), ';', ','), ' ', '')"; + List clauses = new ArrayList<>(); + for (int index = 0; index < normalized.size(); index++) { + clauses.add("FIND_IN_SET(?, " + normalizedColumn + ") > 0"); + } + return "(" + universal + " OR " + String.join(" OR ", clauses) + ")"; + } + + private static String normalizeToken(String value) { + return value == null ? "" : value.replaceAll("\\s+", "").trim(); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentMediaTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentMediaTest.java index e487b9d7..d705027d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentMediaTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentMediaTest.java @@ -57,6 +57,7 @@ class AihrAgentMediaTest { null, null, null, + null, null ); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java index fda37d1e..77795e97 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java @@ -19,6 +19,7 @@ import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.CurrentTa import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse; import org.dromara.aihr.memory.AihrMemoryDto.MemoryDraft; import org.dromara.aihr.agent.shadow.AihrAgentShadowService; +import org.dromara.aihr.service.AihrEmployeePositionResolver; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -39,6 +40,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @Tag("dev") class AihrAgentOrchestratorTest { @@ -341,6 +343,13 @@ class AihrAgentOrchestratorTest { private static AihrAgentOrchestrator orchestrator(CapturingQueryService query, AihrAgentActionService actionService, AihrAgentShadowService shadowService) { + return orchestrator(query, actionService, shadowService, null); + } + + private static AihrAgentOrchestrator orchestrator(CapturingQueryService query, + AihrAgentActionService actionService, + AihrAgentShadowService shadowService, + AihrEmployeePositionResolver positionResolver) { return new AihrAgentOrchestrator( new AihrAgentPlanner(null, new ObjectMapper()), new AihrAgentPolicy(), @@ -355,7 +364,8 @@ class AihrAgentOrchestratorTest { actionService, null, null, - shadowService + shadowService, + positionResolver ); } @@ -374,6 +384,26 @@ class AihrAgentOrchestratorTest { broadcastContext); } + @Test + void knowledgeQuestionCarriesServerResolvedPositionIntoQuery() { + var query = new CapturingQueryService(response("按制度处理。", null, false)); + var positionResolver = mock(AihrEmployeePositionResolver.class); + when(positionResolver.resolvePosition(any())).thenReturn("生活顾问"); + + orchestrator(query, null, null, positionResolver).handle(request("电梯困人怎么处理")); + + assertEquals("生活顾问", query.lastRequest.position()); + } + + @Test + void knowledgeQuestionWithoutPositionResolutionKeepsNullPosition() { + var query = new CapturingQueryService(response("按制度处理。", null, false)); + + orchestrator(query).handle(request("电梯困人怎么处理")); + + assertNull(query.lastRequest.position()); + } + private static final class CapturingQueryService extends AihrKnowledgeQueryService { private final QueryResponse response; private QueryRequest lastRequest; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentWebResearchTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentWebResearchTest.java index 5fe60ce5..26b7b152 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentWebResearchTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentWebResearchTest.java @@ -74,6 +74,7 @@ class AihrAgentWebResearchTest { null, web, null, + null, null ); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/community/AihrCommunityBestAnswerSourceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/community/AihrCommunityBestAnswerSourceTest.java new file mode 100644 index 00000000..8b97ada1 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/community/AihrCommunityBestAnswerSourceTest.java @@ -0,0 +1,225 @@ +package org.dromara.aihr.community; + +import org.dromara.aihr.community.AihrCommunityDto.BestAnswerMatch; +import org.dromara.aihr.community.AihrCommunityDto.CaseCandidateResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrCommunityBestAnswerSourceTest { + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void searchBestAnswersOnlyReadsAcceptedAnswersOfVisibleQuestionsWithoutSubmitterIdentity() { + var jdbc = mock(JdbcTemplate.class); + var service = new AihrCommunityService(jdbc, + mock(AihrKnowledgePrincipalResolver.class), new AihrCommunityProperties(), mock(AihrIncentiveService.class)); + var sqlCaptor = ArgumentCaptor.forClass(String.class); + var argsCaptor = ArgumentCaptor.forClass(Object[].class); + when(jdbc.query(sqlCaptor.capture(), any(RowMapper.class), argsCaptor.capture())).thenAnswer(invocation -> { + RowMapper mapper = invocation.getArgument(1); + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("question_id")).thenReturn(7L); + when(rs.getLong("answer_id")).thenReturn(55L); + when(rs.getString("title")).thenReturn("电梯困人怎么安抚住户"); + when(rs.getString("content")).thenReturn("先安抚情绪,再按流程报修并留痕。"); + return List.of(mapper.mapRow(rs, 0)); + }); + + List matches = service.searchBestAnswers("000000", "电梯困人怎么处理", 3); + + assertEquals(1, matches.size()); + BestAnswerMatch match = matches.get(0); + assertEquals(7L, match.questionId()); + assertEquals(55L, match.answerId()); + assertEquals("电梯困人怎么安抚住户", match.questionTitle()); + String sql = sqlCaptor.getValue(); + assertTrue(sql.contains("a.answer_status = 'PUBLISHED'")); + assertTrue(sql.contains("a.is_best = 1")); + assertTrue(sql.contains("q.question_status IN ('PUBLISHED', 'CLOSED')")); + assertTrue(sql.contains("a.tenant_id = ?")); + assertFalse(sql.contains("author"), "best-answer retrieval must never select submitter identity columns"); + assertEquals("000000", argsCaptor.getValue()[0]); + assertEquals(3, argsCaptor.getValue()[argsCaptor.getValue().length - 1]); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void searchBestAnswersSkipsBlankQueryAndOtherTenantsStayOutOfTheQuery() { + var jdbc = mock(JdbcTemplate.class); + var service = new AihrCommunityService(jdbc, + mock(AihrKnowledgePrincipalResolver.class), new AihrCommunityProperties(), mock(AihrIncentiveService.class)); + + assertTrue(service.searchBestAnswers("000000", " ", 3).isEmpty()); + assertTrue(service.searchBestAnswers("", "电梯困人", 3).isEmpty()); + verify(jdbc, never()).query(anyString(), any(RowMapper.class), any(Object[].class)); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void toCaseCandidateCreatesPendingCandidateWithoutAuthorizationFlags() { + var jdbc = mock(JdbcTemplate.class); + stubSchemaBootstrap(jdbc); + var principal = new AihrKnowledgePrincipal("000000", 9L, "sys_user", "", + Set.of("admin"), Set.of(), "pc"); + var resolver = mock(AihrKnowledgePrincipalResolver.class); + when(resolver.current()).thenReturn(principal); + var service = new AihrCommunityService(jdbc, resolver, new AihrCommunityProperties(), + mock(AihrIncentiveService.class)); + var selectSql = ArgumentCaptor.forClass(String.class); + when(jdbc.query(selectSql.capture(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + if (sql.contains("from aihr_case_record")) { + return List.of(); + } + RowMapper mapper = invocation.getArgument(1); + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(55L); + when(rs.getLong("question_id")).thenReturn(7L); + when(rs.getString("content")).thenReturn("先安抚情绪,再按流程报修。"); + when(rs.getString("answer_status")).thenReturn("PUBLISHED"); + when(rs.getBoolean("is_best")).thenReturn(true); + when(rs.getString("title")).thenReturn("电梯困人怎么安抚住户"); + when(rs.getString("category")).thenReturn("工程维修"); + return List.of(mapper.mapRow(rs, 0)); + }); + var updateSql = ArgumentCaptor.forClass(String.class); + var updateArgs = ArgumentCaptor.forClass(Object[].class); + when(jdbc.update(updateSql.capture(), updateArgs.capture())).thenReturn(1); + + CaseCandidateResponse response = service.toCaseCandidate(55L); + + assertTrue(response.created()); + assertTrue(response.caseId().startsWith("case-")); + String insert = updateSql.getValue(); + assertTrue(insert.contains("insert into aihr_case_record")); + assertTrue(insert.contains("'待审核', 'CANDIDATE', 'COMMUNITY_QA'")); + assertTrue(insert.contains("source_authorized, deidentified")); + assertTrue(insert.contains(", 0, 0,"), "candidate must keep source_authorized=0 and deidentified=0"); + Object[] args = updateArgs.getValue(); + assertEquals("000000", args[0]); + assertEquals("community-answer:55", args[2]); + assertEquals("55", args[4]); + assertEquals("保修", args[args.length - 1], "category 工程维修 maps to the 保修 position"); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void toCaseCandidateIsIdempotentPerAnswer() { + var jdbc = mock(JdbcTemplate.class); + stubSchemaBootstrap(jdbc); + var principal = new AihrKnowledgePrincipal("000000", 9L, "sys_user", "", + Set.of("admin"), Set.of(), "pc"); + var resolver = mock(AihrKnowledgePrincipalResolver.class); + when(resolver.current()).thenReturn(principal); + var service = new AihrCommunityService(jdbc, resolver, new AihrCommunityProperties(), + mock(AihrIncentiveService.class)); + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + if (sql.contains("from aihr_case_record")) { + RowMapper mapper = invocation.getArgument(1); + ResultSet rs = mock(ResultSet.class); + when(rs.getString("case_id")).thenReturn("case-existing"); + return List.of(mapper.mapRow(rs, 0)); + } + RowMapper mapper = invocation.getArgument(1); + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("id")).thenReturn(55L); + when(rs.getLong("question_id")).thenReturn(7L); + when(rs.getString("answer_status")).thenReturn("PUBLISHED"); + when(rs.getBoolean("is_best")).thenReturn(true); + return List.of(mapper.mapRow(rs, 0)); + }); + + CaseCandidateResponse response = service.toCaseCandidate(55L); + + assertFalse(response.created()); + assertEquals("case-existing", response.caseId()); + verify(jdbc, never()).update(anyString(), any(Object[].class)); + } + + @Test + void toCaseCandidateRequiresModeratorRole() { + var jdbc = mock(JdbcTemplate.class); + var principal = new AihrKnowledgePrincipal("000000", 5L, "app_user", "employee-5", + Set.of("employee"), Set.of("P1"), "app"); + var resolver = mock(AihrKnowledgePrincipalResolver.class); + when(resolver.current()).thenReturn(principal); + var service = new AihrCommunityService(jdbc, resolver, new AihrCommunityProperties(), + mock(AihrIncentiveService.class)); + + ServiceException error = assertThrows(ServiceException.class, () -> service.toCaseCandidate(55L)); + + assertEquals(403, error.getCode()); + verify(jdbc, never()).update(anyString(), any(Object[].class)); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void toCaseCandidateRejectsNonBestOrMissingAnswers() { + var jdbc = mock(JdbcTemplate.class); + stubSchemaBootstrap(jdbc); + var principal = new AihrKnowledgePrincipal("000000", 9L, "sys_user", "", + Set.of("admin"), Set.of(), "pc"); + var resolver = mock(AihrKnowledgePrincipalResolver.class); + when(resolver.current()).thenReturn(principal); + var service = new AihrCommunityService(jdbc, resolver, new AihrCommunityProperties(), + mock(AihrIncentiveService.class)); + AtomicInteger call = new AtomicInteger(); + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + if (sql.contains("aihr_community_answer") && call.getAndIncrement() == 0) { + RowMapper mapper = invocation.getArgument(1); + ResultSet rs = mock(ResultSet.class); + when(rs.getBoolean("is_best")).thenReturn(false); + when(rs.getString("answer_status")).thenReturn("PUBLISHED"); + return List.of(mapper.mapRow(rs, 0)); + } + return List.of(); + }); + + assertThrows(IllegalArgumentException.class, () -> service.toCaseCandidate(55L)); + assertThrows(IllegalArgumentException.class, () -> service.toCaseCandidate(99L)); + verify(jdbc, never()).update(anyString(), any(Object[].class)); + } + + @Test + void categoryPositionMappingFallsBackToUniversalWhenUndecidable() { + assertEquals("保安", AihrCommunityService.positionForCategory("秩序维护")); + assertEquals("保洁", AihrCommunityService.positionForCategory("环境绿化")); + assertEquals("保修", AihrCommunityService.positionForCategory("工程维修")); + assertEquals("客服", AihrCommunityService.positionForCategory("客服管家")); + assertEquals("生活顾问", AihrCommunityService.positionForCategory("生活顾问")); + assertEquals("", AihrCommunityService.positionForCategory("工作求助")); + assertEquals("", AihrCommunityService.positionForCategory(null)); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static void stubSchemaBootstrap(JdbcTemplate jdbc) { + // runtimeSchemaBootstrap defaults to true in plain unit tests; schema guard reads stay lenient. + lenient().when(jdbc.queryForObject(anyString(), any(Class.class))).thenReturn(null); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAuxiliarySourceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAuxiliarySourceTest.java new file mode 100644 index 00000000..6927e057 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeAuxiliarySourceTest.java @@ -0,0 +1,293 @@ +package org.dromara.aihr.knowledge; + +import org.dromara.aihr.community.AihrCommunityService; +import org.dromara.aihr.community.AihrCommunityDto.BestAnswerMatch; +import org.dromara.aihr.domain.AihrSopDto.SearchResponse; +import org.dromara.aihr.domain.AihrSopDto.SnippetResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Citation; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse; +import org.dromara.aihr.knowledge.service.AihrEvidenceGradingService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeAccessService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService; +import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; +import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryAuditService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; +import org.dromara.aihr.memory.AihrMemoryService; +import org.dromara.aihr.service.AihrEmployeePositionResolver; +import org.dromara.aihr.service.AihrSopSeedService; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrKnowledgeAuxiliarySourceTest { + + @Test + void positionGateHardFiltersExperienceCitationsWithoutTouchingFormalSources() { + var fixture = fixture(List.of("生活顾问")); + fixture.citationAuthority = "EMPLOYEE_SUBMISSION"; + fixture.citationApplicableRole = "保安"; + stubSearch(fixture.sop(), new SnippetResponse("经验萃取", "保安岗位的经验", 99L)); + + QueryResponse mismatched = fixture.service().queryInternal(question("电梯困人怎么处理")); + assertTrue(mismatched.citations().isEmpty(), "其他岗位的经验类证据必须被硬过滤"); + + fixture.citationApplicableRole = "生活顾问"; + QueryResponse matched = fixture.service().queryInternal(question("电梯困人怎么处理")); + assertEquals(1, matched.citations().size()); + assertEquals("EXPERIENCE", matched.citations().get(0).sourceKind()); + + fixture.citationAuthority = "FORMAL_POLICY"; + fixture.citationApplicableRole = "保安"; + QueryResponse formal = fixture.service().queryInternal(question("电梯困人怎么处理")); + assertEquals(1, formal.citations().size(), "岗位门不影响正式制度来源"); + assertEquals("FORMAL_POLICY", formal.citations().get(0).sourceKind()); + + var noPosition = fixture(List.of()); + noPosition.citationAuthority = "EMPLOYEE_SUBMISSION"; + noPosition.citationApplicableRole = "生活顾问"; + stubSearch(noPosition.sop(), new SnippetResponse("经验萃取", "经验", 99L)); + QueryResponse unresolved = noPosition.service().queryInternal(question("电梯困人怎么处理")); + assertTrue(unresolved.citations().isEmpty(), "岗位解析不到时仅空适用范围可通过"); + + noPosition.citationApplicableRole = null; + QueryResponse universal = noPosition.service().queryInternal(question("电梯困人怎么处理")); + assertEquals(1, universal.citations().size(), "空适用范围对任何岗位通用"); + } + + @Test + void publishedExperiencesMergeAsReferenceTierCitations() { + var fixture = fixture(List.of("生活顾问")); + fixture.experienceRow = true; + stubSearch(fixture.sop()); + + QueryResponse result = fixture.service().queryInternal(question("电梯困人怎么处理")); + + assertFalse(result.noEvidence()); + assertEquals(AihrEvidenceGradingService.LEVEL_GUIDANCE, result.evidenceLevel()); + assertTrue(result.answer().contains(AihrEvidenceGradingService.GUIDANCE_MARKER)); + assertEquals(1, result.citations().size()); + Citation citation = result.citations().get(0); + assertEquals("CASE_EXP", citation.sourceType()); + assertEquals("case:case-1", citation.docId()); + assertNull(citation.fragmentId()); + assertEquals("CASE_EXP", citation.domain()); + assertEquals("CASE", citation.sourceKind()); + assertEquals(AihrEvidenceGradingService.TIER_REFERENCE, citation.evidenceTier()); + assertEquals("v1.0", citation.sourceVersion()); + assertTrue(fixture.sqlOf("aihr_case_record").contains("knowledge_status = 'PUBLISHED'")); + assertTrue(fixture.sqlOf("aihr_case_record").contains("FIND_IN_SET(?")); + assertTrue(fixture.sqlOf("aihr_case_record").contains("LIMIT 3")); + + var sourceTypes = ArgumentCaptor.forClass(List.class); + verify(fixture.audit()).recordFinal(anyString(), any(), any(), anyString(), any(), + sourceTypes.capture(), anyString(), anyLong(), any()); + assertTrue(sourceTypes.getValue().contains("CASE_EXP")); + } + + @Test + void communityBestAnswersMergeAsReferenceTierCitations() { + var community = mock(AihrCommunityService.class); + when(community.searchBestAnswers(anyString(), anyString(), eq(3))).thenReturn(List.of( + new BestAnswerMatch(7L, 55L, "电梯困人怎么安抚住户", "先安抚情绪,再按流程报修。"))); + var fixture = fixture(List.of("生活顾问"), community); + stubSearch(fixture.sop()); + + QueryResponse result = fixture.service().queryInternal(question("电梯困人怎么处理")); + + assertFalse(result.noEvidence()); + assertEquals(AihrEvidenceGradingService.LEVEL_GUIDANCE, result.evidenceLevel()); + assertEquals(1, result.citations().size()); + Citation citation = result.citations().get(0); + assertEquals("COMMUNITY", citation.sourceType()); + assertEquals("community-answer:55", citation.docId()); + assertNull(citation.fragmentId()); + assertEquals("COMMUNITY", citation.domain()); + assertEquals(AihrEvidenceGradingService.TIER_REFERENCE, citation.evidenceTier()); + verify(community).searchBestAnswers(eq("000000"), eq("电梯困人怎么处理"), eq(3)); + } + + @Test + void zeroAuxiliaryHitsKeepTheMainChainUntouched() { + var fixture = fixture(List.of("生活顾问")); + stubSearch(fixture.sop()); + + QueryResponse result = fixture.service().queryInternal(question("电梯困人怎么处理")); + + assertTrue(result.noEvidence()); + assertTrue(result.citations().isEmpty()); + assertEquals(AihrEvidenceGradingService.LEVEL_ROUTE, result.evidenceLevel()); + assertEquals(AihrEvidenceGradingService.GENERIC_ROUTE_ANSWER, result.answer()); + } + + @Test + void auxiliarySourceFailureNeverBreaksTheMainChain() { + var community = mock(AihrCommunityService.class); + when(community.searchBestAnswers(anyString(), anyString(), eq(3))) + .thenThrow(new IllegalStateException("community store unavailable")); + var fixture = fixture(List.of("生活顾问"), community); + stubSearch(fixture.sop()); + + QueryResponse result = fixture.service().queryInternal(question("电梯困人怎么处理")); + + assertTrue(result.noEvidence()); + assertEquals(AihrEvidenceGradingService.LEVEL_ROUTE, result.evidenceLevel()); + } + + @Test + void formalDocumentEvidenceKeepsItsAnswerWhenAuxiliaryReferencesAreAppended() { + var fixture = fixture(List.of("生活顾问")); + fixture.citationAuthority = "FORMAL_POLICY"; + fixture.experienceRow = true; + stubSearch(fixture.sop(), new SnippetResponse("电梯制度", "按制度流程处置", 99L)); + + QueryResponse result = fixture.service().queryInternal(question("电梯困人怎么处理")); + + assertFalse(result.noEvidence()); + assertEquals(AihrEvidenceGradingService.LEVEL_FULL, result.evidenceLevel()); + assertEquals("按制度提交报销单并保留发票。", result.answer()); + assertEquals(2, result.citations().size()); + assertEquals(AihrEvidenceGradingService.TIER_FORMAL, result.citations().get(0).evidenceTier()); + assertEquals("CASE_EXP", result.citations().get(1).sourceType()); + } + + private static QueryRequest question(String text) { + return new QueryRequest(text, List.of(), "sop", null, "mobile_uni_agent", 5, null); + } + + private static Fixture fixture(List positions) { + return fixture(positions, null); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static Fixture fixture(List positions, AihrCommunityService community) { + var resolver = mock(AihrKnowledgePrincipalResolver.class); + var appService = mock(AihrKnowledgeAppService.class); + var access = mock(AihrKnowledgeAccessService.class); + var sop = mock(AihrSopSeedService.class); + var jdbc = mock(JdbcTemplate.class); + var audit = mock(AihrKnowledgeQueryAuditService.class); + var memory = mock(AihrMemoryService.class); + var positionResolver = mock(AihrEmployeePositionResolver.class); + var principal = new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1", + Set.of("employee"), Set.of(), "app"); + var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null); + when(resolver.current()).thenReturn(principal); + when(appService.requireSessionApp("000000", "app")).thenReturn(app); + when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L)); + when(positionResolver.resolvePositions(principal)).thenReturn(positions); + var fixture = new Fixture(null, sop, jdbc, audit, new ArrayList<>()); + lenient().when(jdbc.queryForList(anyString(), eq(String.class), any(Object[].class))) + .thenReturn(List.of("sop")); + lenient().when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) + .thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + fixture.queriedSql().add(sql); + RowMapper mapper = invocation.getArgument(1); + if (sql.contains("aihr_knowledge_fragment") && fixture.citationAuthority != null) { + return List.of(mapper.mapRow(fixture.citationResultSet(), 0)); + } + if (sql.contains("aihr_case_record") && fixture.experienceRow) { + return List.of(mapper.mapRow(fixture.experienceResultSet(), 0)); + } + return List.of(); + }); + var service = new AihrKnowledgeQueryService(resolver, appService, access, sop, audit, jdbc, + mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class), + memory, null, null, null, null, positionResolver, community); + fixture.service = service; + return fixture; + } + + private static void stubSearch(AihrSopSeedService sop, SnippetResponse... snippets) { + when(sop.searchAuthorized(any(), any(), any())).thenAnswer(invocation -> + new SearchResponse("电梯困人怎么处理", "sop", "按制度提交报销单并保留发票。", "", List.of(), + List.of(snippets), List.of(), List.of(), List.of(), List.of(), "test-v1", null)); + } + + private static final class Fixture { + private AihrKnowledgeQueryService service; + private final AihrSopSeedService sop; + private final JdbcTemplate jdbc; + private final AihrKnowledgeQueryAuditService audit; + private final List queriedSql; + private String citationAuthority; + private String citationApplicableRole; + private boolean experienceRow; + + private Fixture(AihrKnowledgeQueryService service, AihrSopSeedService sop, JdbcTemplate jdbc, + AihrKnowledgeQueryAuditService audit, List queriedSql) { + this.service = service; + this.sop = sop; + this.jdbc = jdbc; + this.audit = audit; + this.queriedSql = queriedSql; + } + + private AihrKnowledgeQueryService service() { + return service; + } + + private AihrSopSeedService sop() { + return sop; + } + + private AihrKnowledgeQueryAuditService audit() { + return audit; + } + + private List queriedSql() { + return queriedSql; + } + + private String sqlOf(String tableMarker) { + return queriedSql.stream().filter(sql -> sql.contains(tableMarker)).findFirst().orElse(""); + } + + private ResultSet citationResultSet() throws java.sql.SQLException { + ResultSet rs = mock(ResultSet.class); + lenient().when(rs.getString("space_code")).thenReturn("sop"); + lenient().when(rs.getString("doc_id")).thenReturn("doc-1"); + lenient().when(rs.getString("title")).thenReturn("电梯处置依据"); + lenient().when(rs.getString("content")).thenReturn("按制度流程处置"); + lenient().when(rs.getLong("fragment_id")).thenReturn(99L); + lenient().when(rs.getString("source_authority")).thenReturn(citationAuthority); + lenient().when(rs.getString("applicable_role")).thenReturn(citationApplicableRole); + return rs; + } + + private ResultSet experienceResultSet() throws java.sql.SQLException { + ResultSet rs = mock(ResultSet.class); + lenient().when(rs.getString("case_id")).thenReturn("case-1"); + lenient().when(rs.getString("title")).thenReturn("电梯困人安抚经验"); + lenient().when(rs.getString("summary")).thenReturn("先安抚情绪,再按流程报修。"); + lenient().when(rs.getString("content_version")).thenReturn("v1.0"); + return rs; + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java index 0893df82..119ef10c 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java @@ -283,6 +283,10 @@ class AihrKnowledgeQueryServiceTest { when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class))) .thenReturn(List.of("sop")); when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + if (!sql.contains("aihr_knowledge_fragment")) { + return List.of(); + } RowMapper mapper = invocation.getArgument(1); ResultSet rs = mock(ResultSet.class); when(rs.getString("space_code")).thenReturn("sop"); @@ -648,6 +652,10 @@ class AihrKnowledgeQueryServiceTest { List.of(new SnippetResponse("养老咨询 SOP", "记录需求并安排回访", 88L)), List.of(), List.of(), List.of(), List.of(), "test-v1", null)); when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + if (!sql.contains("aihr_knowledge_fragment")) { + return List.of(); + } RowMapper mapper = invocation.getArgument(1); ResultSet rs = mock(ResultSet.class); when(rs.getString("space_code")).thenReturn("sop"); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java index 46fd0385..4a5bb6df 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java @@ -311,6 +311,64 @@ class AihrCaseServiceTest { assertEquals("培训组", AihrCaseService.caseOwner(" ")); } + @Test + @Tag("dev") + void appCaseRecallAppliesPositionGateWithBoundPositions() { + PositionGateCaseJdbcTemplate jdbcTemplate = new PositionGateCaseJdbcTemplate(); + AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate, null); + + service.records(10, List.of("P1"), List.of("生活顾问")); + + assertTrue(jdbcTemplate.lastSql.contains("FIND_IN_SET(?")); + assertTrue(jdbcTemplate.lastSql.contains("applicable_positions IS NULL")); + assertEquals("生活顾问", jdbcTemplate.lastArgs[2]); + + service.detail("case-1", List.of("P1"), List.of("保安")); + assertTrue(jdbcTemplate.lastSql.contains("FIND_IN_SET(?")); + assertEquals("保安", jdbcTemplate.lastArgs[3]); + + service.authorizedMediaOssId("case-1", List.of("P1"), List.of("保安")); + assertTrue(jdbcTemplate.lastSql.contains("SELECT media_oss_id")); + assertTrue(jdbcTemplate.lastSql.contains("FIND_IN_SET(?")); + } + + @Test + @Tag("dev") + void appCaseRecallWithoutPositionOnlyMatchesUniversalScope() { + PositionGateCaseJdbcTemplate jdbcTemplate = new PositionGateCaseJdbcTemplate(); + AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate, null); + + service.records(10, List.of("P1")); + + assertTrue(jdbcTemplate.lastSql.contains("(applicable_positions IS NULL OR TRIM(applicable_positions) = '')")); + assertFalse(jdbcTemplate.lastSql.contains("FIND_IN_SET")); + } + + @Test + @Tag("dev") + void adminCaseRecallSkipsPublishedAndPositionGates() { + PositionGateCaseJdbcTemplate jdbcTemplate = new PositionGateCaseJdbcTemplate(); + AihrCaseService service = new AihrCaseService(null, null, new ObjectMapper(), jdbcTemplate, null); + + service.records(10, null); + + assertFalse(jdbcTemplate.lastSql.contains("knowledge_status = 'PUBLISHED'")); + assertFalse(jdbcTemplate.lastSql.contains("FIND_IN_SET")); + assertFalse(jdbcTemplate.lastSql.contains("(applicable_positions IS NULL")); + } + + private static final class PositionGateCaseJdbcTemplate extends CaseJdbcTemplate { + private String lastSql = ""; + private Object[] lastArgs = new Object[0]; + + @Override + public List query(String sql, RowMapper rowMapper, Object... args) { + this.lastSql = sql; + this.lastArgs = args; + return List.of(); + } + } + private static class CaseJdbcTemplate extends JdbcTemplate { private Object[] updateArgs = new Object[0]; private String lastUpdateSql = ""; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrEmployeePositionResolverTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrEmployeePositionResolverTest.java new file mode 100644 index 00000000..57464246 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrEmployeePositionResolverTest.java @@ -0,0 +1,79 @@ +package org.dromara.aihr.service; + +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrEmployeePositionResolverTest { + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void appEmployeePositionsComeFromActiveOrgSnapshotByExtPartyId() { + var jdbc = mock(JdbcTemplate.class); + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) + .thenReturn(List.of("生活顾问", "客服")); + var resolver = new AihrEmployeePositionResolver(jdbc); + var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7", + Set.of("employee"), Set.of("P1"), "app"); + + assertEquals(List.of("生活顾问", "客服"), resolver.resolvePositions(principal)); + assertEquals("生活顾问", resolver.resolvePosition(principal)); + + var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + var argsCaptor = org.mockito.ArgumentCaptor.forClass(Object[].class); + verify(jdbc, org.mockito.Mockito.times(2)) + .query(sqlCaptor.capture(), any(RowMapper.class), argsCaptor.capture()); + assertTrue(sqlCaptor.getValue().contains("aihr_org_snapshot")); + assertTrue(sqlCaptor.getValue().contains("employment_status = 'active'")); + assertTrue(sqlCaptor.getValue().contains("ext_party_id = ?")); + assertEquals(List.of("000000", "employee-7"), List.of(argsCaptor.getValue())); + } + + @Test + void managementSideAndMissingIdentityResolveToNoPosition() { + var jdbc = mock(JdbcTemplate.class); + var resolver = new AihrEmployeePositionResolver(jdbc); + var sysUser = new AihrKnowledgePrincipal("000000", 1L, "sys_user", "", + Set.of("admin"), Set.of(), "pc"); + var noIdentity = new AihrKnowledgePrincipal("000000", 2L, "app_user", "", + Set.of("employee"), Set.of(), "app"); + + assertTrue(resolver.resolvePositions(sysUser).isEmpty()); + assertTrue(resolver.resolvePositions(noIdentity).isEmpty()); + assertNull(resolver.resolvePosition(sysUser)); + assertTrue(resolver.resolvePositions(null).isEmpty()); + verify(jdbc, never()).query(anyString(), any(RowMapper.class), any(Object[].class)); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void snapshotFailureFailsClosedToUniversalScopeOnly() { + var jdbc = mock(JdbcTemplate.class); + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) + .thenThrow(new DataAccessException("boom") { + }); + var resolver = new AihrEmployeePositionResolver(jdbc); + var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7", + Set.of("employee"), Set.of("P1"), "app"); + + assertTrue(resolver.resolvePositions(principal).isEmpty()); + assertNull(resolver.resolvePosition(principal)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPositionGateTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPositionGateTest.java new file mode 100644 index 00000000..b0980815 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPositionGateTest.java @@ -0,0 +1,61 @@ +package org.dromara.aihr.service; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("dev") +class AihrPositionGateTest { + + @Test + void emptyScopeIsUniversalForAnyPosition() { + assertTrue(AihrPositionGate.matches(null, List.of("生活顾问"))); + assertTrue(AihrPositionGate.matches("", List.of("生活顾问"))); + assertTrue(AihrPositionGate.matches(" ", List.of())); + } + + @Test + void scopedExperienceRequiresTheEmployeePosition() { + assertTrue(AihrPositionGate.matches("生活顾问", List.of("生活顾问"))); + assertTrue(AihrPositionGate.matches("生活顾问,客服", List.of("客服"))); + assertTrue(AihrPositionGate.matches("生活顾问、保安", List.of("保安"))); + assertTrue(AihrPositionGate.matches("生活顾问,保安", List.of("生活顾问"))); + assertFalse(AihrPositionGate.matches("保安", List.of("生活顾问"))); + assertFalse(AihrPositionGate.matches("保安主管", List.of("保安"))); + } + + @Test + void unresolvedPositionOnlyMatchesUniversalScope() { + assertTrue(AihrPositionGate.matches(null, List.of())); + assertTrue(AihrPositionGate.matches("", null)); + assertFalse(AihrPositionGate.matches("生活顾问", List.of())); + assertFalse(AihrPositionGate.matches("生活顾问", null)); + } + + @Test + void whitespaceAndSeparatorVariantsNormalizeTheSameWay() { + assertTrue(AihrPositionGate.matches("生活顾问 , 客服", List.of(" 客服 "))); + assertTrue(AihrPositionGate.matches("生活顾问;保安", List.of("保安"))); + assertEquals(List.of("生活顾问", "客服"), AihrPositionGate.normalizedPositions( + List.of(" 生活顾问 ", "客服", "客服", " "))); + } + + @Test + void sqlPredicateMirrorsTheJavaGate() { + String universalOnly = AihrPositionGate.applicableScopeSql("applicable_positions", List.of()); + assertTrue(universalOnly.contains("applicable_positions IS NULL")); + assertTrue(universalOnly.contains("TRIM(applicable_positions) = ''")); + assertFalse(universalOnly.contains("FIND_IN_SET")); + + String gated = AihrPositionGate.applicableScopeSql("applicable_positions", List.of("生活顾问", "客服")); + assertTrue(gated.contains("applicable_positions IS NULL")); + assertEquals(2, gated.split("FIND_IN_SET\\(\\?", -1).length - 1); + assertTrue(gated.contains("REPLACE(")); + assertFalse(gated.contains("生活顾问"), "positions must be bound parameters, never inlined"); + } +} diff --git a/backend/script/sql/update/aihr_20260822_case_source_ref_and_best_answer_index_mysql8.sql b/backend/script/sql/update/aihr_20260822_case_source_ref_and_best_answer_index_mysql8.sql new file mode 100644 index 00000000..6dba1cf1 --- /dev/null +++ b/backend/script/sql/update/aihr_20260822_case_source_ref_and_best_answer_index_mysql8.sql @@ -0,0 +1,74 @@ +-- Community best-answer to experience-candidate traceability and auxiliary retrieval indexes. +-- Additive and idempotent on MySQL 8. The new source_ref column keeps the originating +-- community answer id so the moderator-triggered conversion stays idempotent; the unique key +-- only protects (tenant, source_type, source_ref) triples and allows multiple NULL refs. +-- No existing row is approved, published or modified by this migration. + +SET @aihr_case_source_ref_table_exists := ( + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'aihr_case_record' +); + +SET @aihr_case_source_ref_column_exists := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'aihr_case_record' + AND column_name = 'source_ref' +); +SET @aihr_case_source_ref_ddl := IF( + @aihr_case_source_ref_table_exists = 1 AND @aihr_case_source_ref_column_exists = 0, + 'ALTER TABLE `aihr_case_record` ADD COLUMN `source_ref` varchar(80) DEFAULT NULL COMMENT ''经验来源引用(如问题榜回答ID)'' AFTER `source_type`', + 'SELECT 1' +); +PREPARE aihr_case_source_ref_stmt FROM @aihr_case_source_ref_ddl; +EXECUTE aihr_case_source_ref_stmt; +DEALLOCATE PREPARE aihr_case_source_ref_stmt; + +SET @aihr_case_source_ref_index_exists := ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'aihr_case_record' + AND index_name = 'uk_aihr_case_record_source' +); +SET @aihr_case_source_ref_ddl := IF( + @aihr_case_source_ref_table_exists = 1 AND @aihr_case_source_ref_index_exists = 0, + 'ALTER TABLE `aihr_case_record` ADD UNIQUE KEY `uk_aihr_case_record_source` (`tenant_id`, `source_type`, `source_ref`)', + 'SELECT 1' +); +PREPARE aihr_case_source_ref_stmt FROM @aihr_case_source_ref_ddl; +EXECUTE aihr_case_source_ref_stmt; +DEALLOCATE PREPARE aihr_case_source_ref_stmt; + +SET @aihr_community_best_table_exists := ( + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'aihr_community_answer' +); +SET @aihr_community_best_index_exists := ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'aihr_community_answer' + AND index_name = 'idx_aihr_community_answer_best' +); +SET @aihr_community_best_ddl := IF( + @aihr_community_best_table_exists = 1 AND @aihr_community_best_index_exists = 0, + 'ALTER TABLE `aihr_community_answer` ADD KEY `idx_aihr_community_answer_best` (`tenant_id`, `is_best`, `answer_status`)', + 'SELECT 1' +); +PREPARE aihr_community_best_stmt FROM @aihr_community_best_ddl; +EXECUTE aihr_community_best_stmt; +DEALLOCATE PREPARE aihr_community_best_stmt; + +SET @aihr_case_source_ref_table_exists := NULL; +SET @aihr_case_source_ref_column_exists := NULL; +SET @aihr_case_source_ref_index_exists := NULL; +SET @aihr_case_source_ref_ddl := NULL; +SET @aihr_community_best_table_exists := NULL; +SET @aihr_community_best_index_exists := NULL; +SET @aihr_community_best_ddl := NULL; diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 42f8f3e2..5bd0c4a9 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -16,7 +16,7 @@ | 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 | | 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice?, voiceProfile?:{role,voice?,speed?,emotion?,dialect?}, practiceContext?:{sessionId,turnIndex,role:'customer'}}`,`dialect` 仅接受 `mandarin/cantonese/sichuanese`,非法值返回业务码 `400`;旧 `voice` 兼容;成功返回 `ossId`,客户端播放地址为受控的内联 `data:audio/*`,不返回原始 OSS URL)、`GET /api/aihr/mobile/oss/{ossId}` | ASR 按供应商分流:SiliconFlow 等 OpenAI audio 兼容服务继续走 `/audio/transcriptions` multipart;阿里云百炼 `dashscope/qianwen + qwen3-asr-flash` 走工作空间 `/compatible-mode/v1/chat/completions`,将短音频编码为 Base64 `input_audio` 并读取 `choices[0].message.content`,请求上限仍由本接口收窄为 5MB。生产 TTS 优先阿里 `qwen-audio-3.0-tts-flash`,旧 SiliconFlow CosyVoice2 保留兼容;按角色映射老师傅/业主/面试官音色,业主对练再按已有情绪分切换平静/严肃/强烈语气和语速;dialect 作为语气提示传给支持表现力提示词的 TTS,不含克隆,也不等同于方言正式验收。`practiceContext` 仅允许已认证 APP 员工把本次服务端生成的业主 TTS 绑定到本人当前或刚完成会话的指定原话术回合;服务端重新校验租户、员工身份、回合和文本,不接受员工端传 OSS ID 或任意角色。设备语音降级按方言选择语言:粤语使用 `zh-HK`,四川话无法由设备语音可靠模拟时明确提示用户转用服务端语音或文字,不伪装成普通话。模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级设备语音或文本;训练/每日题录音只通过 `audioOssId` 走受保护下载,历史客户端传入的 HTTP `audioUrl` 不再回显;TTS 成功音频仍写入 `sys_oss` 留痕,同时用内联 data URL 保持旧客户端可播放;移动端 OSS 下载只允许经本人或主管项目范围验证的 APP 身份,后台账号若需回放必须使用未来独立、受审计的管理端契约。 | | 实时对练 Beta(WebRTC) | `POST /api/train/practice/realtime/sdp`、`GET /realtime/personas`、`POST /realtime/session`、`POST /realtime/tools/invoke` | H5 浏览器及 App-Plus renderjs WebView 内的 WebRTC 直连阿里云(音频不过服务端),后端仅代理 SDP 交换(3 次/分/账号,sessionId 仅作日志关联且限字符)。人设为服务端 `AihrRealtimePersonaRegistry` 白名单:`owner-calm` 业主·常规(默认)、`owner-impatient` 业主·急躁、`digital-mentor` 数字师傅;人设绑定默认音色,客户端只选不编。`session.update`(instructions/音色/turn_detection/tools 声明)由 `/realtime/session` 组装下发,客户端原样转发;personaId 空白回落默认人设;会话记录写 Redis 30min(成功工具调用滑动续期),创建限流 6 次/分/账号。`digital-mentor` 声明 `search_knowledge` 工具:模型 function calling 命中(`response.function_call_arguments.done`)时客户端回传 `/realtime/tools/invoke`,服务端按登录态 + Redis 会话归属 + 人设工具白名单三重校验后走 `searchAuthorized` 浅层检索(不生成 LLM 答案、不落 `aihr_agent_run`;`source=realtime` 跳过 SOP 评审与知识缺口写入),output 拼接 ≤3 条片段并截断 1200 字;工具按账号限流 20 次/分,无授权/无结果/超时一律返回软着陆文案让模型如实作答,不中断语音会话。实时 Beta 不计分、不持久化训练数据;H5 与 App-Plus 均支持,App-Plus 在 renderjs 视图层执行媒体/WebRTC 并通过逻辑层复用受认证 SDP 与工具接口;需 APP 员工登录态,不启用模型 `enable_search`。配置 `AIHR_QWEN_REALTIME_ENDPOINT` / `AIHR_QWEN_REALTIME_API_KEY` / `AIHR_QWEN_REALTIME_MODEL`(模型白名单 `qwen3.5-omni-(flash|plus)-realtime`),密钥只放 `.env.local` 或外部环境变量。 | -| 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`POST /records/{caseId}/publish`、`POST /records/{caseId}/retire`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;`/curate` 只进入经验候选池,`/review` 只完成业务审核,`/publish` 才能在来源授权、脱敏、适用岗位、负责人、版本和生效期齐全时进入正式经验库,`/retire` 要求填写下线原因并立即停止员工召回;APP 用户必须先以认证手机号精确校验在职 `person_phone`,再由可信主体解析 `aihr_org_snapshot` 项目范围;案例 SQL 不得把 `person_phone` 与 `ext_party_id` 作为替代查询键。上传、整理、候选、审核、发布、下线、列表和详情均按该项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `knowledge_status=PUBLISHED` 且处于生效期的 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | +| 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`POST /records/{caseId}/publish`、`POST /records/{caseId}/retire`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;`/curate` 只进入经验候选池,`/review` 只完成业务审核,`/publish` 才能在来源授权、脱敏、适用岗位、负责人、版本和生效期齐全时进入正式经验库,`/retire` 要求填写下线原因并立即停止员工召回;APP 用户必须先以认证手机号精确校验在职 `person_phone`,再由可信主体解析 `aihr_org_snapshot` 项目范围;案例 SQL 不得把 `person_phone` 与 `ext_party_id` 作为替代查询键。上传、整理、候选、审核、发布、下线、列表和详情均按该项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情/音频只返回 `knowledge_status=PUBLISHED` 且处于生效期的 `已入库` 案例,并叠加岗位门:`applicable_positions` 为空(通用)或包含服务端按认证手机号从 `aihr_org_snapshot` 解析的在职岗位,岗位解析不到时仅通用经验可见;管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | | 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL | | 问·数字师傅工作 Agent `/pages/user/sop/index` | `POST /api/aihr/agent/messages`、`POST /api/aihr/agent/messages/media`、`POST /api/aihr/agent/actions/{draftId}/confirm`、`POST /api/aihr/agent/actions/{draftId}/dismiss` | 移动端文字、ASR 转写和图片/视频统一进入 Agent。服务端根据消息规划 `KNOWLEDGE_QA/RESOURCE_DELIVERY/LIVE_MY_WORK/LIVE_TEAM_WORK/PRACTICE_COACHING/CAPTURE_FACT/MEDIA_UNDERSTANDING/WEB_RESEARCH/CLARIFY/SOCIAL`,再由固定策略校验当前登录身份和工具权限;客户端不提交 `toolCode`,模型也不能自由指定身份、SQL、URL 或任意工具。知识、训练概况、当前待办、确认式记忆、媒体分析和全网查询均复用现有领域服务;普通图片问答只做本次视觉分析,不要求先具备知识空间,用户明确问现场制度/流程时才校验授权空间并补充 SOP。全网查询先返回 `NEEDS_INPUT`,只有用户明确同意后才调用外部服务。统一响应使用结构化 `status/sourceSummary/citations/resources/actionDraft/clarification`;按钮不从答案文本推断。写入动作只接受 30 分钟有效的不透明 `draftId`,最终确认继续复用既有版本、幂等与可见性规则。每次运行只审计租户、主体、意图、工具、状态、来源类型、耗时和错误码,不保存原始问题、答案或附件。 | | SOP 知识底层服务 `/knowledge/sop` 与旧客户端兼容 | `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/knowledge/citations/{detailRef}`、`POST /api/knowledge/citations/{detailRef}/media-link`、`GET /api/knowledge/resources/{attachmentId}/content`、`POST /api/knowledge/resources/{attachmentId}/download-link`、`GET /api/knowledge/resources/{attachmentId}/download?ticket=`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | Agent 按规划结果调用该层;管理端和旧客户端接口继续兼容。文字/现场附件查询走统一知识空间授权和 MySQL Fulltext + Qdrant 混合召回;内部登录端可选传 `conversationId/contextVersion`,服务端保留 30 分钟不活跃过期、最新 6 轮脱敏截断上下文,用现有 chat 模型或保守规则输出 `QA/FILE/VIDEO/DATA_TOOL` 和 `rewrittenQuery`,旧版本冲突返回 `409`。员工从银城大喇叭详情追问时只传 `broadcastMessageId`:服务端每轮按当前 APP 在职员工、当前租户和 `PUBLISHED` 状态重新取消息,响应仅回显 `{messageId,title,publishedAt}`,消息正文不写入客户端存储、会话 JSON 或查询审计;同一会话的消息来源不可改绑,撤回、跨租户或非在职员工均拒绝。该上下文只说明公司消息,不可据此捏造个人任务;上下文模式拒绝现场媒体和数据工具,外部无状态 API 不接受该参数。岗位 SOP 的项目范围复用当前 APP 已验证主体的 `projectCodes`,不得以管理端组织快照的模糊/别名查询作为后备。原文件、图片和视频只从当前命中且仍有授权的附件返回;回答只携带不透明 `detailRef` 和最小定位摘要,详情及媒体票据接口每次重新计算租户、应用、主体和来源状态。文本详情返回受控分段和命中标记,图片/视频通过短期单附件票据以内联 MIME 响应读取,视频支持 HTTP Range;不返回原始 OSS 地址或登录令牌。无资源时明确返回空列表,不生成假链接。搜索继续返回 `reviewId/promptVersion`,员工反馈保存评审批次;员工学习页按当前 APP 身份读取正式岗前/入职任务,资格证据无正式数据时明确返回 `NOT_CONFIGURED`。详细边界见 [专项设计](20260718/问师傅多轮会话与原始资料交付设计.md)和[引用详情实施计划](superpowers/plans/2026-07-31-mobile-citation-source-viewer.md)。 | @@ -36,7 +36,7 @@ | 每日题反馈与常见难题 | `POST /api/aihr/mobile/practice/assignments/{id}/feedback`、`GET /api/aihr/mobile/practice/difficulties`、`POST /api/aihr/mobile/practice/difficulties/assign` | 反馈区分题目有用性与答案正确性;仅认证 APP 主管可按其项目范围聚合岗位、时间、次数、人数和均分,并基于难题创建专项。管理端不得调用移动主管接口;后台人工任务只走受角色保护的 `/api/train/practice/assignments/manage/**`。 | | 岗位考试 | 员工 `GET /api/aihr/mobile/exams`、`GET /exams/{id}`、`POST /exams/{id}/submit`;主管 `GET/POST /exams/supervisor`、`GET/PUT /exams/supervisor/{id}`、`POST /exams/supervisor/{id}/publish`、`GET /exams/supervisor/{id}/results`、`POST /exams/supervisor/question-bank/draw` | 主管按项目范围选择真实员工、组卷、发布和查成绩;员工只读本人目标考试。题库只有 `APPROVED + enabled + exam_enabled` 的题目可抽取,抽题请求 ID 会冻结岗位/题型/题数与题目快照,配置变更复用同一 ID 会拒绝;保存试卷时服务端重新加载快照并保存来源题目 ID、版本和 hash,后续题库编辑不改写已发布试卷。发布和提交接受请求 ID 并保持幂等,不得重复生成成绩或奖励。 | | 正式学习中心与任务 | 管理端 `/api/train/practice/onboard-tasks/manage`、`/materials`、`/exams`、`/batch`;员工 `GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete` | 管理端按租户、项目、规范岗位和在职外部 ID 派发正式学习任务;材料只允许当前租户 `READY` 附件并保存标题/版本快照,任务支持一次/每日/每周/月度周期、起效时间、截止时间、进度和到期提醒。`MANUAL_CONFIRM` 才能人工确认,`EXAM_PASS` 只能由关联考试通过完成;重复请求 ID 返回逐人 `DUPLICATE`,配置不一致拒绝。员工只读本人已生效任务,原文继续经受保护资源接口鉴权;“每日三题”仍留在练习入口,不混入学习中心主任务列表。 | -| 开放问题榜与积分 | 移动端 `/api/aihr/community/questions`、`questions/{id}`、`questions/{id}/answers`、`questions/{id}/best-answer`、`points/me`;后台 `/api/aihr/question-admin/**`、`/api/aihr/incentive-admin/rules` | 问题、答案、最佳答案和奖励全程按租户/身份授权;重复选优不重复入账。奖励只有积分与学习学分,不代表现金或提现 | +| 开放问题榜与积分 | 移动端 `/api/aihr/community/questions`、`questions/{id}`、`questions/{id}/answers`、`questions/{id}/best-answer`、`points/me`;后台 `/api/aihr/question-admin/**`、`/api/aihr/incentive-admin/rules` | 问题、答案、最佳答案和奖励全程按租户/身份授权;重复选优不重复入账。奖励只有积分与学习学分,不代表现金或提现。已发布的最佳答案作为问模块只读 `REFERENCE` 级来源(不返回提交人身份); moderator 可用 `POST /api/aihr/question-admin/answers/{answerId}/to-case-candidate` 把最佳答案转为经验候选:仅接受本租户 `is_best=1 AND answer_status=PUBLISHED` 的回答,同一 `answerId` 幂等返回同一 `{caseId, created}`;候选固定 `status=待审核 + knowledge_status=CANDIDATE + source_type=COMMUNITY_QA + source_authorized=0 + deidentified=0`,`source_ref` 存回答 ID,适用岗位按问题分类尽力映射(不可判定置空=通用),正式发布必须再走案例库人工确认链 | | 未解决反馈转人工 | `POST /api/aihr/community/questions/from-answer-feedback`,仅提交 `requestId`、`idempotencyKey` | 服务端重读本人 `down` 反馈和查询审计,问题固定进入 `PENDING`;重复请求返回同一问题,不自动公开或奖励,客户端不得提交重构答案、引用或身份 | | 全网 AI | 员工 `GET /api/aihr/web-ai/capabilities`、`POST /api/aihr/web-ai/query`;提供方管理 `/api/aihr/web-search/providers/**` | 与企业知识问答分入口和来源。仅接受公网 HTTPS 提供方,地址或密钥变更后必须重新连接测试;未配置/未启用时返回明确不可用,不生成假答案 | @@ -62,6 +62,10 @@ curl -fsS "$API_BASE/api/aihr/agent/messages/media" \ 文字问题去空白后必须为 1–1000 字;媒体接口的 `question` 为空时使用“请分析附件内容”。请求不接受身份、角色、`toolCode`、知识空间 ID、SQL 或任意 URL。响应继续使用若依信封,`data.status` 只会是 `COMPLETED/NEEDS_INPUT/NEEDS_CONFIRMATION/NO_EVIDENCE/FORBIDDEN/UNAVAILABLE/FAILED`,按钮分别读取 `actionDraft/clarification/resources/nextActions`,不得解析 `answer` 猜状态。知识问答答案由服务端统一仲裁证据分级:`data.evidenceLevel` 为 `FULL/PARTIAL/GUIDANCE/ROUTE`(旧路径可为空),`ROUTE` 对应 `NO_EVIDENCE`,其余对应 `COMPLETED`;`data.uncoveredPoints` 列出没有正式依据的点;`data.nextActions` 形状为 `[{type, channel, label}]`,`type ∈ HUMAN_HELP/DIRECT_CHANNEL/WEB_RESEARCH`,`channel` 仅 `DIRECT_CHANNEL` 时有值(如 `direct_finance`)。`GUIDANCE` 答案显式标注“以下为参考经验,非正式制度依据”。澄清与 `NO_EVIDENCE` 互斥;服务端把待澄清事项挂起在 30 分钟短会话上,下一轮回复按“已回答/放弃/新话题”闭环,同一澄清最多问一轮。 +除正式文档证据外,问答引用池还并入两个只读增强来源(均为 `REFERENCE` 级,不得包装为制度):`CASE_EXP` 来自生活顾问经验库 `aihr_case_record`(仅 `已入库 + knowledge_status=PUBLISHED` 且处于生效期的记录,按问题关键词命中标题/摘要/转写,最多 3 条,引用 `docId=case:{caseId}`、`fragmentId=null`);`COMMUNITY` 来自有奖征答最佳答案(仅本租户 `PUBLISHED/CLOSED` 问题下 `answer_status=PUBLISHED AND is_best=1` 的回答,按问题标题/内容关键词命中,最多 3 条,引用 `docId=community-answer:{answerId}`,绝不返回提交人身份字段)。两类来源仅在 `QA` 意图下追加到既有文档引用之后:已有正式证据时答案与分级不变;无文档证据时由同一仲裁单点降级为 `GUIDANCE` 而不是 `ROUTE`;任一增强来源失败都不影响主检索链。审计 `sourceTypes` 如实记录实际使用的来源类型。 + +岗位门:客户端永远不传岗位。服务端按当前认证 APP 主体从 `aihr_org_snapshot` 精确解析在职岗位(`ext_party_id`,多岗位取全部去重值);管理端预览等无法解析的场景一律视为无岗位。经验/案例类证据(`sourceKind=EXPERIENCE/CASE`,含 `aihr_case_record` 与治理资产的 `applicable_role`)必须满足适用范围为空(通用)或包含当前员工岗位,否则硬过滤不出现;无岗位时仅空适用范围可通过(fail-closed)。`FORMAL_POLICY/OPERATING_MANUAL/APPROVED_SOP` 及其他来源不受岗位门影响。员工端案例列表/详情/音频召回与问答引用装配使用同一谓词;适用范围按逗号/顿号/分号分隔并去空白后精确比对。 + 政策边界在检索前由服务端确定性执行:明确存在版本/规则冲突时返回 `NEEDS_INPUT`,不得自行裁决或拼接;明确无依据时返回 `NO_EVIDENCE`,不得用经验补全政策事实;跨项目、他人敏感信息、无权附件或未发布制度返回 `FORBIDDEN`。移动端 Agent 对涉及制度、权限、费用、安全职责或必留记录的问题,服务端会在规划改写前固定为正式政策模式,客户端不能选择或绕过;MySQL 模糊/全文召回、Qdrant 命中回表和最终引用装配均只接受 `aihr_knowledge_source_governance` 中当前有效的 `FORMAL_POLICY + APPROVED` 附件,并校验审批时 `docId` 快照一致、版本和原文件 SHA-256 登记完整。已覆盖的试点政策领域还会在进入模型前和最终引用装配时执行问题领域锚点相关性复核,只有来源合规且与问题领域匹配的片段才能参与回答;返回客户端的引用摘要围绕同一命中锚点截取,不能只展示无关的文档开头。正式问题不混入个人/项目记忆;治理表缺失、未审批、已失效、版本或哈希为空、领域不相关,以及最终引用重校验失败时统一返回 `NO_EVIDENCE`。普通“经验话术怎么说”等建议问题仍可检索经授权经验材料,但不得包装为制度。访谈、经验萃取、seed 或 AI 输出不得登记为正式政策来源。 全网意图在 `externalConsent=false` 时以业务成功响应返回 `NEEDS_INPUT`,用户同意后原请求携带 `externalConsent=true` 重试。确认式记忆先取得 `actionDraft.draftId`,再提交卡片中可编辑的完整 `draft`: diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index 7012e498..ecafd6d8 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -185,6 +185,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260819_knowledge_retention_deletion_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260820_knowledge_migration_orchestration_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260821_knowledge_shadow_gate_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260822_case_source_ref_and_best_answer_index_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql ``` @@ -194,6 +195,8 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_perso 生命周期兼容发布继续按 `20260815 -> 20260816 -> 20260817 -> 20260818 -> 20260819 -> 20260820 -> 20260821` 执行。`20260815` 是七阶段共同依赖的阶段 0,不是平行的第八项:先建立按空间的 `LEGACY/SHADOW/ENFORCED` 兼容范围和只读基线,再增加候选/生效双指针、审核辅助、不可变 generation 与回滚证据、保留和异人删除、历史迁移清单、影子指标与启用门禁。七个脚本均为增量结构迁移;不得在 SQL 阶段自动发布、撤回、删除或切换现有空间。迁移完成后所有空间仍应为 LEGACY 或保持迁移前显式模式,历史资料继续可见。生产发布必须先只读统计资产、版本、生产成员、fragment、OSS 和 Qdrant 数量并备份;先以单空间 dry-run 和 SHADOW 验证,达到门禁后由人工启用。正式迁移任务必须通过 `verified_dry_run_id` 绑定同知识空间、同风险范围、同批大小且无失败的已完成演练;创建正式任务不自动推进批次。任何补链失败都停留在迁移 dead letter,不得切断旧读路径。系统负责生成范围清单、差异、风险、影响、候选 generation 和门禁证据,人工只保留业务事实裁决、异人审批、抽样复核和最终切换。 +`aihr_20260822_case_source_ref_and_best_answer_index_mysql8.sql` 位于全部知识生命周期迁移之后:为 `aihr_case_record` 增加 `source_ref` 来源引用列和 `(tenant_id, source_type, source_ref)` 唯一键,支撑“最佳答案转经验候选”的幂等追溯;为 `aihr_community_answer` 增加 `(tenant_id, is_best, answer_status)` 索引以服务问模块最佳答案只读检索。脚本只加列和索引,不批准、不发布、不改写任何既有案例或回答。 + 生产物理删除不是部署步骤,也不得与 schema/JAR 发布同窗口批量执行。日常纠错只做 `withdraw`;`purge` 必须有业务/法务确认的保留分类、影响快照、异人批准和已到期 `execute_after`。活动 generation 仍包含该资产时,先构建并启用排除该资产的新 generation,系统不会允许直接清理;清理成功后,受影响的非活动代次会被自动重算并标记失效,不能再作为回滚目标。发布后先运行 reconciliation 并处理不一致,再考虑清理。ENFORCED 启用后旧 generation 保留七天;窗口内指标恶化且旧代次未失效时可从管理端回退,超过窗口或旧代次已失效时必须重新构建候选 generation 和重新通过门禁。 8 月 1 日受控内部试点的正式来源注册是独立的业务数据变更,不随通用 schema 自动执行。先逐一核对附件 `id + doc_id`、原文件 SHA-256、发文号/版本、生效日期和适用范围,再由已授权负责人执行 `backend/script/sql/ops/aihr_20260730_aug1_formal_source_registry_mysql8.sql`。执行后必须只读确认恰好 10 条 `FORMAL_POLICY + APPROVED` 记录;任何缺失都保持无正式依据,不得把访谈、经验萃取、AI 生成内容或草稿补进白名单。