:deleteFrom
+ )
+ """)
+ int countTextBlockForRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+
/**
* Deletes {@link TextBlock} entries linked to non-rated {@link Result} that are not the latest non-rated result
* for a {@link Participation}, where the associated course's start and end dates
@@ -130,4 +194,36 @@ SELECT MAX(r2.id)
)
""")
int deleteTextBlockForNonRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
+
+ /**
+ * Counts {@link TextBlock} entries linked to non-rated {@link Result} that are not the latest non-rated result
+ * for a {@link Participation}, where the associated course's start and end dates
+ * are between the specified date range.
+ *
+ * @param deleteFrom the start date for selecting courses
+ * @param deleteTo the end date for selecting courses
+ * @return the number of entities that would be deleted
+ */
+ @Query("""
+ SELECT COUNT(tb)
+ FROM TextBlock tb
+ WHERE tb.feedback IN (
+ SELECT f
+ FROM Feedback f
+ LEFT JOIN f.result r
+ LEFT JOIN r.participation p
+ LEFT JOIN p.exercise e
+ LEFT JOIN e.course c
+ WHERE f.result.id NOT IN (
+ SELECT MAX(r2.id)
+ FROM Result r2
+ WHERE r2.participation.id = p.id
+ AND r2.rated = FALSE
+ )
+ AND r.rated = FALSE
+ AND c.endDate < :deleteTo
+ AND c.startDate > :deleteFrom
+ )
+ """)
+ int countTextBlockForNonRatedResultsWhereCourseDateBetween(@Param("deleteFrom") ZonedDateTime deleteFrom, @Param("deleteTo") ZonedDateTime deleteTo);
}
diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ParticipantScoreScheduleService.java b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ParticipantScoreScheduleService.java
index a3b7521668f4..f8095e57a686 100644
--- a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ParticipantScoreScheduleService.java
+++ b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ParticipantScoreScheduleService.java
@@ -55,7 +55,8 @@
*
* The approach is two-sided, to make the participant scores eventually consistent within seconds without overloading the database.
* Using a listener on the {@link Result} entity, changes are detected and forwarded (via the broker if not on the main instance) to this service.
- * This method is fast, but not 100% reliable. Therefore, a cron job regularly checks for invalid participant scores and updates them.
+ * This method is fast, but not 100% reliable. For example, network outages could cause this service to miss result updates.
+ * Therefore, a cron job regularly checks for invalid participant scores and updates them.
* In all cases, using asynchronous scheduled tasks speeds up all requests that modify results.
*
* @see ResultListener
@@ -70,11 +71,11 @@ public class ParticipantScoreScheduleService {
private final TaskScheduler scheduler;
- private final Map> scheduledTasks = new ConcurrentHashMap<>();
+ private final Map> scheduledTasks = new ConcurrentHashMap<>();
private Optional lastScheduledRun = Optional.empty();
- private final CompetencyProgressApi competencyProgressApi;
+ private final Optional competencyProgressApi;
private final ParticipantScoreRepository participantScoreRepository;
@@ -96,7 +97,7 @@ public class ParticipantScoreScheduleService {
*/
private final AtomicBoolean isRunning = new AtomicBoolean(false);
- public ParticipantScoreScheduleService(@Qualifier("taskScheduler") TaskScheduler scheduler, CompetencyProgressApi competencyProgressApi,
+ public ParticipantScoreScheduleService(@Qualifier("taskScheduler") TaskScheduler scheduler, Optional competencyProgressApi,
ParticipantScoreRepository participantScoreRepository, StudentScoreRepository studentScoreRepository, TeamScoreRepository teamScoreRepository,
ExerciseRepository exerciseRepository, ResultRepository resultRepository, UserRepository userRepository, TeamRepository teamRepository) {
this.scheduler = scheduler;
@@ -156,6 +157,7 @@ public void shutdown() {
/**
* Every minute, query for modified results and schedule a task to update the participant scores.
+ * This is used as a fallback in case Result updates are lost for any reason.
* We schedule all results that were created/updated since the last run of the cron job.
* Additionally, we schedule all participant scores that are outdated/invalid.
*/
@@ -223,17 +225,17 @@ public void scheduleTask(@NotNull Long exerciseId, @NotNull Long participantId,
* @param resultIdToBeDeleted the id of the result that is about to be deleted (or null, if result is created/updated)
*/
private void scheduleTask(Long exerciseId, Long participantId, Instant resultLastModified, Long resultIdToBeDeleted) {
- final int participantScoreHash = new ParticipantScoreId(exerciseId, participantId).hashCode();
- var task = scheduledTasks.get(participantScoreHash);
+ final var participantScoreId = new ParticipantScoreId(exerciseId, participantId);
+ var task = scheduledTasks.get(participantScoreId);
if (task != null) {
// If a task is already scheduled, cancel it and reschedule it with the latest result
task.cancel(true);
- scheduledTasks.remove(participantScoreHash);
+ scheduledTasks.remove(participantScoreId);
}
var schedulingTime = ZonedDateTime.now().plus(DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS, ChronoUnit.MILLIS);
var future = scheduler.schedule(() -> this.executeTask(exerciseId, participantId, resultLastModified, resultIdToBeDeleted), schedulingTime.toInstant());
- scheduledTasks.put(participantScoreHash, future);
+ scheduledTasks.put(participantScoreId, future);
log.debug("Scheduled task for exercise {} and participant {} at {}.", exerciseId, participantId, schedulingTime);
}
@@ -336,13 +338,14 @@ private void executeTask(Long exerciseId, Long participantId, Instant resultLast
if (scoreParticipant instanceof Team team && !Hibernate.isInitialized(team.getStudents())) {
scoreParticipant = teamRepository.findWithStudentsByIdElseThrow(team.getId());
}
- competencyProgressApi.updateProgressByLearningObjectSync(score.getExercise(), scoreParticipant.getParticipants());
+ Participant finalScoreParticipant = scoreParticipant;
+ competencyProgressApi.ifPresent(api -> api.updateProgressByLearningObjectSync(score.getExercise(), finalScoreParticipant.getParticipants()));
}
catch (Exception e) {
log.error("Exception while processing participant score for exercise {} and participant {} for participant scores:", exerciseId, participantId, e);
}
finally {
- scheduledTasks.remove(new ParticipantScoreId(exerciseId, participantId).hashCode());
+ scheduledTasks.remove(new ParticipantScoreId(exerciseId, participantId));
}
long end = System.currentTimeMillis();
log.debug("Updating the participant score for exercise {} and participant {} took {} ms.", exerciseId, participantId, end - start);
diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java
index 1f62ef78665b..17ccb2ca9fa6 100644
--- a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java
+++ b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java
@@ -4,7 +4,6 @@
import java.time.ZonedDateTime;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
@@ -25,7 +24,7 @@
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.data.domain.Page;
-import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import de.tum.cit.aet.artemis.assessment.domain.AssessmentType;
@@ -49,7 +48,7 @@
import de.tum.cit.aet.artemis.core.domain.Course;
import de.tum.cit.aet.artemis.core.domain.User;
import de.tum.cit.aet.artemis.core.dto.SearchResultPageDTO;
-import de.tum.cit.aet.artemis.core.dto.pageablesearch.PageableSearchDTO;
+import de.tum.cit.aet.artemis.core.dto.SortingOrder;
import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException;
import de.tum.cit.aet.artemis.core.repository.UserRepository;
import de.tum.cit.aet.artemis.core.security.Role;
@@ -64,19 +63,20 @@
import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository;
import de.tum.cit.aet.artemis.exercise.service.ExerciseDateService;
import de.tum.cit.aet.artemis.lti.service.LtiNewResultService;
+import de.tum.cit.aet.artemis.modeling.service.compass.strategy.NameSimilarity;
import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise;
import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation;
import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation;
+import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseTask;
import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseTestCase;
import de.tum.cit.aet.artemis.programming.domain.build.BuildPlanType;
-import de.tum.cit.aet.artemis.programming.domain.hestia.ProgrammingExerciseTask;
import de.tum.cit.aet.artemis.programming.repository.BuildJobRepository;
import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository;
import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseStudentParticipationRepository;
import de.tum.cit.aet.artemis.programming.repository.SolutionProgrammingExerciseParticipationRepository;
import de.tum.cit.aet.artemis.programming.repository.TemplateProgrammingExerciseParticipationRepository;
import de.tum.cit.aet.artemis.programming.service.BuildLogEntryService;
-import de.tum.cit.aet.artemis.programming.service.hestia.ProgrammingExerciseTaskService;
+import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseTaskService;
@Profile(PROFILE_CORE)
@Service
@@ -126,6 +126,10 @@ public class ResultService {
private final ProgrammingExerciseRepository programmingExerciseRepository;
+ private static final int MAX_FEEDBACK_IDS = 5;
+
+ private static final double SIMILARITY_THRESHOLD = 0.7;
+
public ResultService(UserRepository userRepository, ResultRepository resultRepository, Optional ltiNewResultService,
ResultWebsocketService resultWebsocketService, ComplaintResponseRepository complaintResponseRepository, RatingRepository ratingRepository,
FeedbackRepository feedbackRepository, LongFeedbackTextRepository longFeedbackTextRepository, ComplaintRepository complaintRepository,
@@ -194,8 +198,8 @@ public Result createNewManualResult(Result result, boolean ratedResult) {
return savedResult;
}
- public Result createNewRatedManualResult(Result result) {
- return createNewManualResult(result, true);
+ public void createNewRatedManualResult(Result result) {
+ createNewManualResult(result, true);
}
/**
@@ -518,23 +522,26 @@ private void handleFeedbackPersistence(Feedback feedback, Result result, Map taskNames = tasks.stream().map(ProgrammingExerciseTask::getTaskName).collect(Collectors.toSet());
// 5. Include unassigned tasks if specified by the filter; otherwise, only include specified tasks
- List includeUnassignedTasks = new ArrayList<>(taskNames);
+ List includeNotAssignedToTask = new ArrayList<>(taskNames);
if (!data.getFilterTasks().isEmpty()) {
- includeUnassignedTasks.removeAll(data.getFilterTasks());
+ includeNotAssignedToTask.removeAll(data.getFilterTasks());
}
else {
- includeUnassignedTasks.clear();
+ includeNotAssignedToTask.clear();
}
// 6. Define the occurrence range based on filter parameters
@@ -614,22 +623,116 @@ public FeedbackAnalysisResponseDTO getFeedbackDetailsOnPage(long exerciseId, Fee
List filterErrorCategories = data.getFilterErrorCategories();
// 8. Set up pagination and sorting based on input data
- final var pageable = PageUtil.createDefaultPageRequest(data, PageUtil.ColumnMapping.FEEDBACK_ANALYSIS);
+ final Pageable pageable = groupFeedback ? Pageable.unpaged() : PageUtil.createDefaultPageRequest(data, PageUtil.ColumnMapping.FEEDBACK_ANALYSIS);
- // 9. Query the database to retrieve paginated and filtered feedback
+ // 9. Query the database based on groupFeedback attribute to retrieve paginated and filtered feedback
final Page feedbackDetailPage = studentParticipationRepository.findFilteredFeedbackByExerciseId(exerciseId,
- StringUtils.isBlank(data.getSearchTerm()) ? "" : data.getSearchTerm().toLowerCase(), data.getFilterTestCases(), includeUnassignedTasks, minOccurrence,
+ StringUtils.isBlank(data.getSearchTerm()) ? "" : data.getSearchTerm().toLowerCase(), data.getFilterTestCases(), includeNotAssignedToTask, minOccurrence,
maxOccurrence, filterErrorCategories, pageable);
+ ;
+ List processedDetails;
+ int totalPages = 0;
+ long totalCount = 0;
+ long highestOccurrenceOfGroupedFeedback = 0;
+ if (!groupFeedback) {
+ // Process and map feedback details, calculating relative count and assigning task names
+ processedDetails = feedbackDetailPage.getContent().stream()
+ .map(detail -> new FeedbackDetailDTO(detail.feedbackIds().subList(0, Math.min(detail.feedbackIds().size(), MAX_FEEDBACK_IDS)), detail.count(),
+ (detail.count() * 100.00) / distinctResultCount, detail.detailTexts(), detail.testCaseName(), detail.taskName(), detail.errorCategory(),
+ detail.hasLongFeedbackText()))
+ .toList();
+ totalPages = feedbackDetailPage.getTotalPages();
+ totalCount = feedbackDetailPage.getTotalElements();
+ }
+ else {
+ // Fetch all feedback details
+ List allFeedbackDetails = feedbackDetailPage.getContent();
+
+ // Apply grouping and aggregation with a similarity threshold of 90%
+ List aggregatedFeedbackDetails = aggregateFeedback(allFeedbackDetails, SIMILARITY_THRESHOLD);
+
+ highestOccurrenceOfGroupedFeedback = aggregatedFeedbackDetails.stream().mapToLong(FeedbackDetailDTO::count).max().orElse(0);
+ // Apply manual sorting
+ Comparator comparator = getComparatorForFeedbackDetails(data);
+ List processedDetailsPreSort = new ArrayList<>(aggregatedFeedbackDetails);
+ processedDetailsPreSort.sort(comparator);
+ // Apply manual pagination
+ int page = data.getPage();
+ int pageSize = data.getPageSize();
+ int start = Math.max(0, (page - 1) * pageSize);
+ int end = Math.min(start + pageSize, processedDetailsPreSort.size());
+ processedDetails = processedDetailsPreSort.subList(start, end);
+ processedDetails = processedDetails.stream()
+ .map(detail -> new FeedbackDetailDTO(detail.feedbackIds().subList(0, Math.min(detail.feedbackIds().size(), 5)), detail.count(),
+ (detail.count() * 100.00) / distinctResultCount, detail.detailTexts(), detail.testCaseName(), detail.taskName(), detail.errorCategory(),
+ detail.hasLongFeedbackText()))
+ .toList();
+ totalPages = (int) Math.ceil((double) processedDetailsPreSort.size() / pageSize);
+ totalCount = aggregatedFeedbackDetails.size();
+ }
- // 10. Process and map feedback details, calculating relative count and assigning task names
- List processedDetails = feedbackDetailPage.getContent().stream().map(detail -> new FeedbackDetailDTO(detail.concatenatedFeedbackIds(), detail.count(),
- (detail.count() * 100.00) / distinctResultCount, detail.detailText(), detail.testCaseName(), detail.taskName(), detail.errorCategory())).toList();
- // 11. Predefined error categories available for filtering on the client side
+ // 10. Predefined error categories available for filtering on the client side
final List ERROR_CATEGORIES = List.of("Student Error", "Ares Error", "AST Error");
- // 12. Return response containing processed feedback details, task names, active test case names, and error categories
- return new FeedbackAnalysisResponseDTO(new SearchResultPageDTO<>(processedDetails, feedbackDetailPage.getTotalPages()), feedbackDetailPage.getTotalElements(), taskNames,
- activeTestCaseNames, ERROR_CATEGORIES);
+ // 11. Return response containing processed feedback details, task names, active test case names, and error categories
+ return new FeedbackAnalysisResponseDTO(new SearchResultPageDTO<>(processedDetails, totalPages), totalCount, taskNames, activeTestCaseNames, ERROR_CATEGORIES,
+ highestOccurrenceOfGroupedFeedback);
+ }
+
+ private Comparator getComparatorForFeedbackDetails(FeedbackPageableDTO search) {
+ Map> comparators = Map.of("count", Comparator.comparingLong(FeedbackDetailDTO::count), "detailTexts",
+ Comparator.comparing(detail -> detail.detailTexts().isEmpty() ? "" : detail.detailTexts().getFirst(), // Sort by the first element of the list
+ String.CASE_INSENSITIVE_ORDER),
+ "testCaseName", Comparator.comparing(FeedbackDetailDTO::testCaseName, String.CASE_INSENSITIVE_ORDER), "taskName",
+ Comparator.comparing(FeedbackDetailDTO::taskName, String.CASE_INSENSITIVE_ORDER));
+
+ Comparator comparator = comparators.getOrDefault(search.getSortedColumn(), (a, b) -> 0);
+ return search.getSortingOrder() == SortingOrder.ASCENDING ? comparator : comparator.reversed();
+ }
+
+ private List aggregateFeedback(List feedbackDetails, double similarityThreshold) {
+ List processedDetails = new ArrayList<>();
+
+ for (FeedbackDetailDTO base : feedbackDetails) {
+ boolean isMerged = false;
+
+ for (FeedbackDetailDTO processed : processedDetails) {
+ // Ensure feedbacks have the same testCaseName and taskName
+ if (base.testCaseName().equals(processed.testCaseName()) && base.taskName().equals(processed.taskName())) {
+ double similarity = NameSimilarity.levenshteinSimilarity(base.detailTexts().getFirst(), processed.detailTexts().getFirst());
+
+ if (similarity > similarityThreshold) {
+ // Merge the current base feedback into the processed feedback
+ List mergedFeedbackIds = new ArrayList<>(processed.feedbackIds());
+ if (processed.feedbackIds().size() < MAX_FEEDBACK_IDS) {
+ mergedFeedbackIds.addAll(base.feedbackIds());
+ }
+
+ List mergedTexts = new ArrayList<>(processed.detailTexts());
+ mergedTexts.add(base.detailTexts().getFirst());
+
+ long mergedCount = processed.count() + base.count();
+
+ // Replace the processed entry with the updated one
+ processedDetails.remove(processed);
+ FeedbackDetailDTO updatedProcessed = new FeedbackDetailDTO(mergedFeedbackIds, mergedCount, 0, mergedTexts, processed.testCaseName(), processed.taskName(),
+ processed.errorCategory(), processed.hasLongFeedbackText());
+ processedDetails.add(updatedProcessed); // Add the updated entry
+ isMerged = true;
+ break; // No need to check further
+ }
+ }
+ }
+
+ if (!isMerged) {
+ // If not merged, add it as a new entry in processedDetails
+ FeedbackDetailDTO newEntry = new FeedbackDetailDTO(base.feedbackIds(), base.count(), 0, List.of(base.detailTexts().getFirst()), base.testCaseName(),
+ base.taskName(), base.errorCategory(), base.hasLongFeedbackText());
+ processedDetails.add(newEntry);
+ }
+ }
+
+ return processedDetails;
}
/**
@@ -648,20 +751,15 @@ public long getMaxCountForExercise(long exerciseId) {
/**
* Retrieves a paginated list of students affected by specific feedback entries for a given exercise.
*