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:
+7
@@ -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()));
|
||||
|
||||
+201
-2
@@ -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
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+12
-1
@@ -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"
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user