From 4e9ccfa8b5e2b4ed6a68d03bfc22c4efcadbc895 Mon Sep 17 00:00:00 2001 From: let5sne Date: Mon, 20 Jul 2026 10:16:40 +0800 Subject: [PATCH] feat(backend): employee name fallback labels and AI exam question generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - practice team members / exam targets: when person_name is empty, show phone-masked label (员工·尾号XXXX) instead of a bare 员工 - POST /api/aihr/mobile/exams/supervisor/ai-generate: LLM-generated exam questions grounded on SOP knowledge fragments, strict JSON sanitize, scores rebalanced to 100; clear errors when model unavailable --- .../aihr/controller/AihrMobileController.java | 7 + .../aihr/learning/AihrExamService.java | 203 +++++++++++++++++- .../aihr/learning/AihrLearningDto.java | 6 + .../aihr/service/AihrPracticeSeedService.java | 13 +- 4 files changed, 226 insertions(+), 3 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java index f557183e..b4bb2fb5 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java @@ -34,6 +34,8 @@ import org.dromara.aihr.learning.AihrLearningDto.AssignmentFeedbackRequest; import org.dromara.aihr.learning.AihrLearningDto.AssignmentFeedbackResponse; import org.dromara.aihr.learning.AihrLearningDto.DifficultyAssignRequest; import org.dromara.aihr.learning.AihrLearningDto.DifficultyResponse; +import org.dromara.aihr.learning.AihrLearningDto.ExamAiGenerateRequest; +import org.dromara.aihr.learning.AihrLearningDto.ExamAiGenerateResponse; import org.dromara.aihr.learning.AihrLearningDto.ExamDetailResponse; import org.dromara.aihr.learning.AihrLearningDto.ExamPublishRequest; import org.dromara.aihr.learning.AihrLearningDto.ExamResultResponse; @@ -324,6 +326,11 @@ public class AihrMobileController { return R.ok(examService.publish(id, request, supervisorScopeExtPartyId())); } + @PostMapping("/exams/supervisor/ai-generate") + public R generateSupervisorExamQuestions(@RequestBody ExamAiGenerateRequest request) { + return R.ok(examService.generateQuestions(request, supervisorScopeExtPartyId())); + } + @GetMapping("/exams/supervisor/{id}/results") public R> supervisorExamResults(@PathVariable Long id) { return R.ok(examService.results(id, supervisorScopeExtPartyId())); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamService.java index 6ea6f242..5db0b039 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrExamService.java @@ -2,9 +2,12 @@ package org.dromara.aihr.learning; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; +import org.dromara.aihr.learning.AihrLearningDto.ExamAiGenerateRequest; +import org.dromara.aihr.learning.AihrLearningDto.ExamAiGenerateResponse; import org.dromara.aihr.learning.AihrLearningDto.ExamAnswerRequest; import org.dromara.aihr.learning.AihrLearningDto.ExamAnswerResult; import org.dromara.aihr.learning.AihrLearningDto.ExamDetailResponse; @@ -18,6 +21,7 @@ import org.dromara.aihr.learning.AihrLearningDto.ExamSummaryResponse; import org.dromara.aihr.learning.AihrLearningDto.ExamTargetDraft; import org.dromara.aihr.learning.AihrLearningDto.ExamTargetResponse; import org.dromara.aihr.learning.AihrLearningDto.ExamUpsertRequest; +import org.dromara.aihr.service.AihrModelSeedService; import org.dromara.common.core.exception.ServiceException; import org.dromara.common.tenant.helper.TenantHelper; import org.springframework.dao.DuplicateKeyException; @@ -40,6 +44,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; +import java.util.Set; /** Supervisor-authored onboarding exams and employee submission flow. */ @Service @@ -49,9 +55,11 @@ public class AihrExamService { private static final String DEFAULT_TENANT = "000000"; private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final TypeReference> STRING_LIST = new TypeReference<>() { }; + private static final Set AI_QUESTION_FORMATS = Set.of("single", "multiple", "judge", "short"); private final JdbcTemplate jdbcTemplate; private final ObjectMapper objectMapper; + private final AihrModelSeedService modelSeedService; private volatile boolean schemaReady; @@ -437,12 +445,12 @@ public class AihrExamService { args.addAll(projects); } List rows = jdbcTemplate.query(""" - SELECT ext_party_id, person_name, project_code, position_name + SELECT ext_party_id, person_name, person_phone, project_code, position_name FROM aihr_org_snapshot WHERE tenant_id = ? AND employment_status = 'active' AND ext_party_id IN (%s)%s """.formatted(placeholders(requestedById.size()), scopeSql), (rs, rowNum) -> new ResolvedTarget( - rs.getString("ext_party_id"), fallback(rs.getString("person_name"), "员工"), + rs.getString("ext_party_id"), traineeLabel(rs.getString("person_name"), rs.getString("person_phone")), clean(rs.getString("project_code")), clean(rs.getString("position_name")) ), args.toArray()); Map resolved = new LinkedHashMap<>(); @@ -454,6 +462,197 @@ public class AihrExamService { return requestedById.keySet().stream().map(resolved::get).toList(); } + private static String traineeLabel(String personName, String personPhone) { + String name = clean(personName); + if (!name.isEmpty()) { + return name; + } + String phone = clean(personPhone); + if (phone.isEmpty()) { + return "员工"; + } + return "员工·尾号" + (phone.length() <= 4 ? phone : phone.substring(phone.length() - 4)); + } + + public ExamAiGenerateResponse generateQuestions(ExamAiGenerateRequest request, String supervisorExtPartyId) { + String supervisor = clean(supervisorExtPartyId); + if (!supervisor.isEmpty() && supervisorProjects(supervisor).isEmpty()) { + throw new ServiceException("无主管权限"); + } + int count = request != null && request.count() != null ? request.count() : 5; + count = Math.max(3, Math.min(10, count)); + List formats = request != null && request.formats() != null + ? request.formats().stream() + .map(format -> clean(format).toLowerCase(Locale.ROOT)) + .filter(AI_QUESTION_FORMATS::contains) + .distinct() + .toList() + : List.of(); + if (formats.isEmpty()) { + formats = List.of("single", "multiple", "judge"); + } + String positionCode = clean(request == null ? null : request.positionCode()); + String hints = clean(request == null ? null : request.hints()); + List fragments = groundingFragments(positionCode, hints); + + String systemPrompt = """ + 你是物业企业培训出题专家。按岗位与考察提示出题,题干要贴近物业一线真实场景、口语化。 + 题型规则:single 单选题给 4 个选项,correctAnswers 恰好 1 个;multiple 多选题至少 4 个选项,correctAnswers 至少 2 个;judge 判断题 options 固定为 ["正确","错误"],correctAnswers 恰好 1 个且属于选项;short 简答题 options 为 [],correctAnswers 放 1 条参考要点。 + correctAnswers 必须与选项文字完全一致;explanation 用一句话引用 SOP 或管理制度依据。 + 只输出 JSON 数组,不要输出任何其他文字:[{"questionType":"single","stem":"","options":[""],"correctAnswers":[""],"explanation":""}] + """; + StringBuilder userPrompt = new StringBuilder(); + userPrompt.append("岗位:").append(positionCode.isEmpty() ? "物业一线岗位" : positionCode).append('\n'); + userPrompt.append("考察提示:").append(hints.isEmpty() ? "岗位基础服务规范" : hints).append('\n'); + userPrompt.append("请出 ").append(count).append(" 道题,题型从 ") + .append(String.join("、", formats)).append(" 中选取,可以混合。\n"); + if (!fragments.isEmpty()) { + userPrompt.append("参考 SOP 片段(题目与解析需与之一致):\n"); + for (int index = 0; index < fragments.size(); index++) { + userPrompt.append("【片段").append(index + 1).append("】").append(fragments.get(index)).append('\n'); + } + } + + Optional response = modelSeedService.tryChat(systemPrompt, userPrompt.toString(), 0.4); + if (response.isEmpty()) { + throw new ServiceException("AI 出题暂不可用:未配置可用模型或调用失败"); + } + List drafts = parseGeneratedQuestions(response.get()); + if (drafts.isEmpty()) { + throw new ServiceException("AI 出题结果不可用,请调整提示后重试"); + } + int total = drafts.size(); + int base = 100 / total; + List questions = new ArrayList<>(); + for (int index = 0; index < total; index++) { + ExamQuestionDraft draft = drafts.get(index); + int score = index == total - 1 ? 100 - base * (total - 1) : base; + questions.add(new ExamQuestionDraft(null, draft.questionType(), draft.stem(), draft.options(), + draft.correctAnswers(), score, draft.explanation(), index + 1)); + } + return new ExamAiGenerateResponse(questions, fragments.size()); + } + + private List groundingFragments(String positionCode, String hints) { + String query = (positionCode + " " + hints).trim(); + if (query.isEmpty()) { + return List.of(); + } + try { + List rows = jdbcTemplate.query(""" + SELECT content FROM aihr_knowledge_fragment + WHERE tenant_id = ? AND MATCH(content) AGAINST (? IN NATURAL LANGUAGE MODE) + LIMIT 5 + """, (rs, rowNum) -> truncate(rs.getString("content"), 300), tenantId(), query); + if (!rows.isEmpty()) { + return rows; + } + } catch (RuntimeException ignored) { + // Fulltext index or table unavailable: degrade to the LIKE lookup below. + } + String keyword = query.contains(" ") ? query.substring(0, query.indexOf(' ')) : query; + String likePattern = "%" + keyword.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"; + try { + return jdbcTemplate.query(""" + SELECT content FROM aihr_knowledge_fragment + WHERE tenant_id = ? AND content LIKE ? + LIMIT 3 + """, (rs, rowNum) -> truncate(rs.getString("content"), 300), tenantId(), likePattern); + } catch (RuntimeException ignored) { + return List.of(); + } + } + + private List parseGeneratedQuestions(String raw) { + String text = clean(raw); + if (text.startsWith("```")) { + text = text.replaceFirst("^```[a-zA-Z]*\\s*", "").replaceFirst("\\s*```\\s*$", ""); + } + int start = text.indexOf('['); + int end = text.lastIndexOf(']'); + if (start >= 0 && end > start) { + text = text.substring(start, end + 1); + } + JsonNode root; + try { + root = objectMapper.readTree(text); + } catch (JsonProcessingException error) { + return List.of(); + } + if (!root.isArray()) { + return List.of(); + } + List drafts = new ArrayList<>(); + for (JsonNode node : root) { + ExamQuestionDraft draft = sanitizeGeneratedQuestion(node); + if (draft != null) { + drafts.add(draft); + } + } + return drafts; + } + + private ExamQuestionDraft sanitizeGeneratedQuestion(JsonNode node) { + if (node == null || !node.isObject()) { + return null; + } + String stem = clean(node.path("stem").asText("")); + if (stem.isEmpty()) { + return null; + } + String type = clean(node.path("questionType").asText("")).toLowerCase(Locale.ROOT); + if (!AI_QUESTION_FORMATS.contains(type)) { + type = "single"; + } + List options = jsonStringList(node.path("options")).stream() + .map(AihrExamService::clean).filter(value -> !value.isEmpty()).distinct().toList(); + List answers = jsonStringList(node.path("correctAnswers")).stream() + .map(AihrExamService::clean).filter(value -> !value.isEmpty()).distinct().toList(); + switch (type) { + case "single" -> { + if (options.size() < 2) { + return null; + } + answers = answers.stream().filter(options::contains).toList(); + if (answers.size() != 1) { + return null; + } + } + case "multiple" -> { + answers = answers.stream().filter(options::contains).toList(); + if (answers.size() < 2) { + return null; + } + } + case "judge" -> { + options = List.of("正确", "错误"); + answers = answers.stream().filter(options::contains).toList(); + if (answers.size() != 1) { + return null; + } + } + default -> { + options = List.of(); + if (answers.isEmpty()) { + return null; + } + answers = List.of(answers.get(0)); + } + } + return new ExamQuestionDraft(null, type, stem, options, answers, null, + truncate(node.path("explanation").asText(""), 1000), null); + } + + private static List jsonStringList(JsonNode node) { + List values = new ArrayList<>(); + if (node != null && node.isArray()) { + for (JsonNode item : node) { + values.add(item.isTextual() ? item.asText() : item.toString()); + } + } + return values; + } + private List supervisorProjects(String supervisorIdentity) { return jdbcTemplate.query(""" SELECT DISTINCT project_code diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningDto.java index b8d1efd4..0eb5ee6d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningDto.java @@ -115,6 +115,12 @@ public final class AihrLearningDto { public record ExamPublishRequest(String requestId) { } + public record ExamAiGenerateRequest(String positionCode, String hints, Integer count, List formats) { + } + + public record ExamAiGenerateResponse(List questions, int groundedFragments) { + } + public record ExamSummaryResponse( Long id, String title, diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index c4458598..71153b78 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -1020,6 +1020,17 @@ public class AihrPracticeSeedService { } where.append(")"); } + String phoneLabel = hasPhone + ? "CASE WHEN NULLIF(person_phone,'') IS NOT NULL THEN CONCAT('员工·尾号', RIGHT(person_phone, 4)) ELSE NULL END" + : null; + String nameExpression; + if (storeDisplayFields) { + nameExpression = hasPhone + ? "COALESCE(NULLIF(person_name, ''), " + phoneLabel + ", ext_party_id)" + : "COALESCE(NULLIF(person_name, ''), ext_party_id)"; + } else { + nameExpression = hasPhone ? "COALESCE(" + phoneLabel + ", '员工')" : "'员工'"; + } String sql = """ SELECT ext_party_id, %s AS name @@ -1027,7 +1038,7 @@ public class AihrPracticeSeedService { %s ORDER BY position_level, %s, ext_party_id """.formatted( - storeDisplayFields ? "COALESCE(NULLIF(person_name, ''), ext_party_id)" : "'员工'", + nameExpression, where, storeDisplayFields ? "person_name" : "ext_party_id" );