feat(backend): employee name fallback labels and AI exam question generation

- 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
This commit is contained in:
2026-07-20 10:16:40 +08:00
parent 875a1cfd2a
commit 4e9ccfa8b5
4 changed files with 226 additions and 3 deletions
@@ -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<ExamAiGenerateResponse> generateSupervisorExamQuestions(@RequestBody ExamAiGenerateRequest request) {
return R.ok(examService.generateQuestions(request, supervisorScopeExtPartyId()));
}
@GetMapping("/exams/supervisor/{id}/results")
public R<List<ExamResultResponse>> supervisorExamResults(@PathVariable Long id) {
return R.ok(examService.results(id, supervisorScopeExtPartyId()));
@@ -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<List<String>> STRING_LIST = new TypeReference<>() { };
private static final Set<String> 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<ResolvedTarget> 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<String, ResolvedTarget> 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<String> 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<String> 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<String> response = modelSeedService.tryChat(systemPrompt, userPrompt.toString(), 0.4);
if (response.isEmpty()) {
throw new ServiceException("AI 出题暂不可用:未配置可用模型或调用失败");
}
List<ExamQuestionDraft> drafts = parseGeneratedQuestions(response.get());
if (drafts.isEmpty()) {
throw new ServiceException("AI 出题结果不可用,请调整提示后重试");
}
int total = drafts.size();
int base = 100 / total;
List<ExamQuestionDraft> 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<String> groundingFragments(String positionCode, String hints) {
String query = (positionCode + " " + hints).trim();
if (query.isEmpty()) {
return List.of();
}
try {
List<String> 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<ExamQuestionDraft> 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<ExamQuestionDraft> 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<String> options = jsonStringList(node.path("options")).stream()
.map(AihrExamService::clean).filter(value -> !value.isEmpty()).distinct().toList();
List<String> 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<String> jsonStringList(JsonNode node) {
List<String> values = new ArrayList<>();
if (node != null && node.isArray()) {
for (JsonNode item : node) {
values.add(item.isTextual() ? item.asText() : item.toString());
}
}
return values;
}
private List<String> supervisorProjects(String supervisorIdentity) {
return jdbcTemplate.query("""
SELECT DISTINCT project_code
@@ -115,6 +115,12 @@ public final class AihrLearningDto {
public record ExamPublishRequest(String requestId) {
}
public record ExamAiGenerateRequest(String positionCode, String hints, Integer count, List<String> formats) {
}
public record ExamAiGenerateResponse(List<ExamQuestionDraft> questions, int groundedFragments) {
}
public record ExamSummaryResponse(
Long id,
String title,
@@ -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"
);