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.AssignmentFeedbackResponse;
|
||||||
import org.dromara.aihr.learning.AihrLearningDto.DifficultyAssignRequest;
|
import org.dromara.aihr.learning.AihrLearningDto.DifficultyAssignRequest;
|
||||||
import org.dromara.aihr.learning.AihrLearningDto.DifficultyResponse;
|
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.ExamDetailResponse;
|
||||||
import org.dromara.aihr.learning.AihrLearningDto.ExamPublishRequest;
|
import org.dromara.aihr.learning.AihrLearningDto.ExamPublishRequest;
|
||||||
import org.dromara.aihr.learning.AihrLearningDto.ExamResultResponse;
|
import org.dromara.aihr.learning.AihrLearningDto.ExamResultResponse;
|
||||||
@@ -324,6 +326,11 @@ public class AihrMobileController {
|
|||||||
return R.ok(examService.publish(id, request, supervisorScopeExtPartyId()));
|
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")
|
@GetMapping("/exams/supervisor/{id}/results")
|
||||||
public R<List<ExamResultResponse>> supervisorExamResults(@PathVariable Long id) {
|
public R<List<ExamResultResponse>> supervisorExamResults(@PathVariable Long id) {
|
||||||
return R.ok(examService.results(id, supervisorScopeExtPartyId()));
|
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.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import lombok.RequiredArgsConstructor;
|
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.ExamAnswerRequest;
|
||||||
import org.dromara.aihr.learning.AihrLearningDto.ExamAnswerResult;
|
import org.dromara.aihr.learning.AihrLearningDto.ExamAnswerResult;
|
||||||
import org.dromara.aihr.learning.AihrLearningDto.ExamDetailResponse;
|
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.ExamTargetDraft;
|
||||||
import org.dromara.aihr.learning.AihrLearningDto.ExamTargetResponse;
|
import org.dromara.aihr.learning.AihrLearningDto.ExamTargetResponse;
|
||||||
import org.dromara.aihr.learning.AihrLearningDto.ExamUpsertRequest;
|
import org.dromara.aihr.learning.AihrLearningDto.ExamUpsertRequest;
|
||||||
|
import org.dromara.aihr.service.AihrModelSeedService;
|
||||||
import org.dromara.common.core.exception.ServiceException;
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
import org.dromara.common.tenant.helper.TenantHelper;
|
import org.dromara.common.tenant.helper.TenantHelper;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
@@ -40,6 +44,8 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/** Supervisor-authored onboarding exams and employee submission flow. */
|
/** Supervisor-authored onboarding exams and employee submission flow. */
|
||||||
@Service
|
@Service
|
||||||
@@ -49,9 +55,11 @@ public class AihrExamService {
|
|||||||
private static final String DEFAULT_TENANT = "000000";
|
private static final String DEFAULT_TENANT = "000000";
|
||||||
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
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 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 JdbcTemplate jdbcTemplate;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final AihrModelSeedService modelSeedService;
|
||||||
|
|
||||||
private volatile boolean schemaReady;
|
private volatile boolean schemaReady;
|
||||||
|
|
||||||
@@ -437,12 +445,12 @@ public class AihrExamService {
|
|||||||
args.addAll(projects);
|
args.addAll(projects);
|
||||||
}
|
}
|
||||||
List<ResolvedTarget> rows = jdbcTemplate.query("""
|
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
|
FROM aihr_org_snapshot
|
||||||
WHERE tenant_id = ? AND employment_status = 'active'
|
WHERE tenant_id = ? AND employment_status = 'active'
|
||||||
AND ext_party_id IN (%s)%s
|
AND ext_party_id IN (%s)%s
|
||||||
""".formatted(placeholders(requestedById.size()), scopeSql), (rs, rowNum) -> new ResolvedTarget(
|
""".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"))
|
clean(rs.getString("project_code")), clean(rs.getString("position_name"))
|
||||||
), args.toArray());
|
), args.toArray());
|
||||||
Map<String, ResolvedTarget> resolved = new LinkedHashMap<>();
|
Map<String, ResolvedTarget> resolved = new LinkedHashMap<>();
|
||||||
@@ -454,6 +462,197 @@ public class AihrExamService {
|
|||||||
return requestedById.keySet().stream().map(resolved::get).toList();
|
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) {
|
private List<String> supervisorProjects(String supervisorIdentity) {
|
||||||
return jdbcTemplate.query("""
|
return jdbcTemplate.query("""
|
||||||
SELECT DISTINCT project_code
|
SELECT DISTINCT project_code
|
||||||
|
|||||||
+6
@@ -115,6 +115,12 @@ public final class AihrLearningDto {
|
|||||||
public record ExamPublishRequest(String requestId) {
|
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(
|
public record ExamSummaryResponse(
|
||||||
Long id,
|
Long id,
|
||||||
String title,
|
String title,
|
||||||
|
|||||||
+12
-1
@@ -1020,6 +1020,17 @@ public class AihrPracticeSeedService {
|
|||||||
}
|
}
|
||||||
where.append(")");
|
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 = """
|
String sql = """
|
||||||
SELECT ext_party_id,
|
SELECT ext_party_id,
|
||||||
%s AS name
|
%s AS name
|
||||||
@@ -1027,7 +1038,7 @@ public class AihrPracticeSeedService {
|
|||||||
%s
|
%s
|
||||||
ORDER BY position_level, %s, ext_party_id
|
ORDER BY position_level, %s, ext_party_id
|
||||||
""".formatted(
|
""".formatted(
|
||||||
storeDisplayFields ? "COALESCE(NULLIF(person_name, ''), ext_party_id)" : "'员工'",
|
nameExpression,
|
||||||
where,
|
where,
|
||||||
storeDisplayFields ? "person_name" : "ext_party_id"
|
storeDisplayFields ? "person_name" : "ext_party_id"
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user