diff --git a/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java b/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java index 4c103dde0732..ef1d75fde4de 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/Exercise.java @@ -49,6 +49,7 @@ import com.fasterxml.jackson.annotation.JsonView; import de.tum.in.www1.artemis.domain.competency.CourseCompetency; +import de.tum.in.www1.artemis.domain.enumeration.AssessmentType; import de.tum.in.www1.artemis.domain.enumeration.ExerciseType; import de.tum.in.www1.artemis.domain.enumeration.IncludedInOverallScore; import de.tum.in.www1.artemis.domain.enumeration.InitializationState; @@ -603,6 +604,10 @@ else if (resultDate1.isAfter(resultDate2)) { public Set findResultsFilteredForStudents(Participation participation) { boolean isAssessmentOver = getAssessmentDueDate() == null || getAssessmentDueDate().isBefore(ZonedDateTime.now()); if (!isAssessmentOver) { + // This allows the showing of preliminary feedback in case the assessment due date is set before its over. + if (this instanceof TextExercise) { + return participation.getResults().stream().filter(result -> result.getAssessmentType() == AssessmentType.AUTOMATIC_ATHENA).collect(Collectors.toSet()); + } return Set.of(); } return participation.getResults().stream().filter(result -> result.getCompletionDate() != null).collect(Collectors.toSet()); diff --git a/src/main/java/de/tum/in/www1/artemis/domain/Submission.java b/src/main/java/de/tum/in/www1/artemis/domain/Submission.java index 30f4275cc14f..b6d819ca32e3 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/Submission.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/Submission.java @@ -213,6 +213,16 @@ public List getManualResults() { return results.stream().filter(result -> result != null && !result.isAutomatic() && !result.isAthenaAutomatic()).collect(Collectors.toCollection(ArrayList::new)); } + /** + * This method is necessary to ignore Athena results in the assessment view + * + * @return non athena automatic results including null results + */ + @JsonIgnore + public List getNonAthenaResults() { + return results.stream().filter(result -> result == null || !result.isAthenaAutomatic()).collect(Collectors.toCollection(ArrayList::new)); + } + /** * Get the manual result by id of the submission * diff --git a/src/main/java/de/tum/in/www1/artemis/service/ParticipationService.java b/src/main/java/de/tum/in/www1/artemis/service/ParticipationService.java index d579f5765083..23f8b350ca6b 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/ParticipationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/ParticipationService.java @@ -31,6 +31,7 @@ import de.tum.in.www1.artemis.domain.enumeration.InitializationState; import de.tum.in.www1.artemis.domain.enumeration.SubmissionType; import de.tum.in.www1.artemis.domain.participation.Participant; +import de.tum.in.www1.artemis.domain.participation.Participation; import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseStudentParticipation; import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.domain.quiz.QuizExercise; @@ -664,6 +665,21 @@ public Optional findOneByExerciseAndStudentLoginWithEagerS return studentParticipationRepository.findWithEagerLegalSubmissionsByExerciseIdAndStudentLogin(exercise.getId(), username); } + /** + * Get one participation (in any state) by its student and exercise with eager submissions else throw exception. + * + * @param exercise the exercise for which to find a participation + * @param username the username of the student + * @return the participation of the given student and exercise with eager submissions in any state + */ + public StudentParticipation findOneByExerciseAndStudentLoginWithEagerSubmissionsAnyStateElseThrow(Exercise exercise, String username) { + Optional optionalParticipation = findOneByExerciseAndStudentLoginWithEagerSubmissionsAnyState(exercise, username); + if (optionalParticipation.isEmpty()) { + throw new EntityNotFoundException("No participation found in exercise with id " + exercise.getId() + " for user " + username); + } + return optionalParticipation.get(); + } + /** * Get all exercise participations belonging to exercise and student. * @@ -693,6 +709,21 @@ public List findByExerciseAndStudentIdWithEagerSubmissions return studentParticipationRepository.findByExerciseIdAndStudentIdWithEagerLegalSubmissions(exercise.getId(), studentId); } + /** + * Get the text exercise participation with the Latest Submissions and its results + * + * @param participationId the id of the participation + * @return the participation with latest submission and result + * @throws EntityNotFoundException + */ + public StudentParticipation findTextExerciseParticipationWithLatestSubmissionAndResultElseThrow(Long participationId) throws EntityNotFoundException { + Optional participation = participationRepository.findByIdWithLatestSubmissionAndResult(participationId); + if (participation.isEmpty() || !(participation.get() instanceof StudentParticipation studentParticipation)) { + throw new EntityNotFoundException("No text exercise participation found with id " + participationId); + } + return studentParticipation; + } + /** * Get all programming exercise participations belonging to exercise and student with eager results and submissions. * diff --git a/src/main/java/de/tum/in/www1/artemis/service/TextExerciseFeedbackService.java b/src/main/java/de/tum/in/www1/artemis/service/TextExerciseFeedbackService.java new file mode 100644 index 000000000000..844f3cc20016 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/TextExerciseFeedbackService.java @@ -0,0 +1,149 @@ +package de.tum.in.www1.artemis.service; + +import static de.tum.in.www1.artemis.config.Constants.PROFILE_CORE; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.in.www1.artemis.domain.Feedback; +import de.tum.in.www1.artemis.domain.Result; +import de.tum.in.www1.artemis.domain.TextExercise; +import de.tum.in.www1.artemis.domain.TextSubmission; +import de.tum.in.www1.artemis.domain.enumeration.AssessmentType; +import de.tum.in.www1.artemis.domain.enumeration.FeedbackType; +import de.tum.in.www1.artemis.domain.participation.Participation; +import de.tum.in.www1.artemis.domain.participation.StudentParticipation; +import de.tum.in.www1.artemis.repository.ResultRepository; +import de.tum.in.www1.artemis.service.connectors.athena.AthenaFeedbackSuggestionsService; +import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; +import de.tum.in.www1.artemis.web.rest.errors.InternalServerErrorException; +import de.tum.in.www1.artemis.web.websocket.ResultWebsocketService; + +@Profile(PROFILE_CORE) +@Service +public class TextExerciseFeedbackService { + + private static final Logger log = LoggerFactory.getLogger(TextExerciseFeedbackService.class); + + public static final String NON_GRADED_FEEDBACK_SUGGESTION = "NonGradedFeedbackSuggestion:"; + + private final Optional athenaFeedbackSuggestionsService; + + private final ResultWebsocketService resultWebsocketService; + + private final SubmissionService submissionService; + + private final ParticipationService participationService; + + private final ResultService resultService; + + private final ResultRepository resultRepository; + + public TextExerciseFeedbackService(Optional athenaFeedbackSuggestionsService, SubmissionService submissionService, + ResultService resultService, ResultRepository resultRepository, ResultWebsocketService resultWebsocketService, ParticipationService participationService) { + this.athenaFeedbackSuggestionsService = athenaFeedbackSuggestionsService; + this.submissionService = submissionService; + this.resultService = resultService; + this.resultRepository = resultRepository; + this.resultWebsocketService = resultWebsocketService; + this.participationService = participationService; + } + + private void checkRateLimitOrThrow(StudentParticipation participation) { + + List athenaResults = participation.getResults().stream().filter(result -> result.getAssessmentType() == AssessmentType.AUTOMATIC_ATHENA).toList(); + + long countOfAthenaResults = athenaResults.size(); + + if (countOfAthenaResults >= 10) { + throw new BadRequestAlertException("Maximum number of AI feedback requests reached.", "participation", "preconditions not met"); + } + } + + /** + * Handles the request for generating feedback for a text exercise. + * Unlike programming exercises a tutor is not notified if Athena is not available. + * + * @param exerciseId the id of the text exercise. + * @param participation the student participation associated with the exercise. + * @param textExercise the text exercise object. + * @return StudentParticipation updated text exercise for an AI assessment + */ + public StudentParticipation handleNonGradedFeedbackRequest(Long exerciseId, StudentParticipation participation, TextExercise textExercise) { + if (this.athenaFeedbackSuggestionsService.isPresent()) { + this.checkRateLimitOrThrow(participation); + CompletableFuture.runAsync(() -> this.generateAutomaticNonGradedFeedback(participation, textExercise)); + } + return participation; + } + + /** + * Generates automatic non-graded feedback for a text exercise submission. + * This method leverages the Athena service to generate feedback based on the latest submission. + * + * @param participation the student participation associated with the exercise. + * @param textExercise the text exercise object. + */ + public void generateAutomaticNonGradedFeedback(StudentParticipation participation, TextExercise textExercise) { + log.debug("Using athena to generate (text exercise) feedback request: {}", textExercise.getId()); + + // athena takes over the control here + var submissionOptional = participationService.findTextExerciseParticipationWithLatestSubmissionAndResultElseThrow(participation.getId()).findLatestSubmission(); + + if (submissionOptional.isEmpty()) { + throw new BadRequestAlertException("No legal submissions found", "submission", "noSubmission"); + } + var submission = submissionOptional.get(); + + Result automaticResult = new Result(); + automaticResult.setAssessmentType(AssessmentType.AUTOMATIC_ATHENA); + automaticResult.setRated(true); + automaticResult.setScore(0.0); + automaticResult.setSuccessful(null); + automaticResult.setSubmission(submission); + automaticResult.setParticipation(participation); + try { + this.resultWebsocketService.broadcastNewResult((Participation) participation, automaticResult); + + log.debug("Submission id: {}", submission.getId()); + + var athenaResponse = this.athenaFeedbackSuggestionsService.orElseThrow().getTextFeedbackSuggestions(textExercise, (TextSubmission) submission, false); + + List feedbacks = athenaResponse.stream().filter(individualFeedbackItem -> individualFeedbackItem.description() != null).map(individualFeedbackItem -> { + var feedback = new Feedback(); + feedback.setText(individualFeedbackItem.title()); + feedback.setDetailText(individualFeedbackItem.description()); + feedback.setHasLongFeedbackText(false); + feedback.setType(FeedbackType.AUTOMATIC); + feedback.setCredits(individualFeedbackItem.credits()); + return feedback; + }).toList(); + + double totalFeedbacksScore = 0.0; + for (Feedback feedback : feedbacks) { + totalFeedbacksScore += feedback.getCredits(); + } + totalFeedbacksScore = totalFeedbacksScore / textExercise.getMaxPoints() * 100; + automaticResult.setSuccessful(true); + automaticResult.setCompletionDate(ZonedDateTime.now()); + + automaticResult.setScore(Math.clamp(totalFeedbacksScore, 0, 100)); + + automaticResult = this.resultRepository.save(automaticResult); + resultService.storeFeedbackInResult(automaticResult, feedbacks, true); + submissionService.saveNewResult(submission, automaticResult); + this.resultWebsocketService.broadcastNewResult((Participation) participation, automaticResult); + } + catch (Exception e) { + log.error("Could not generate feedback", e); + throw new InternalServerErrorException("Something went wrong... AI Feedback could not be generated"); + } + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/TextSubmissionService.java b/src/main/java/de/tum/in/www1/artemis/service/TextSubmissionService.java index 11110487e211..65e28f3db003 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/TextSubmissionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/TextSubmissionService.java @@ -81,6 +81,13 @@ public TextSubmission handleTextSubmission(TextSubmission textSubmission, TextEx if (exercise.isExamExercise() || exerciseDateService.isBeforeDueDate(participation)) { textSubmission.setSubmitted(true); } + + // if athena results are present than create new submission on submit + if (!textSubmission.getResults().isEmpty()) { + log.debug("Creating a new submission due to Athena results for user: {}", user.getLogin()); + textSubmission.setId(null); + } + textSubmission = save(textSubmission, participation, exercise, user); return textSubmission; } @@ -104,7 +111,6 @@ private TextSubmission save(TextSubmission textSubmission, StudentParticipation participation.setInitializationState(InitializationState.FINISHED); studentParticipationRepository.save(participation); } - // remove result from submission (in the unlikely case it is passed here), so that students cannot inject a result textSubmission.setResults(new ArrayList<>()); textSubmission = textSubmissionRepository.save(textSubmission); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/AbstractSubmissionResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/AbstractSubmissionResource.java index cfd5595c9794..21d2324ee4bc 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/AbstractSubmissionResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/AbstractSubmissionResource.java @@ -81,6 +81,10 @@ protected ResponseEntity> getAllSubmissions(Long exerciseId, bo if (submission.getParticipation() != null && submission.getParticipation().getExercise() != null) { submission.getParticipation().setExercise(null); } + // Important for exercises with Athena results + if (assessedByTutor) { + submission.setResults(submission.getNonAthenaResults()); + } }); return ResponseEntity.ok().body(submissions); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ParticipationResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ParticipationResource.java index c12a2a4b56b8..8e5654e315d7 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ParticipationResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ParticipationResource.java @@ -20,6 +20,7 @@ import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotNull; +import org.apache.velocity.exception.ResourceNotFoundException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -50,6 +51,7 @@ import de.tum.in.www1.artemis.domain.Submission; import de.tum.in.www1.artemis.domain.TextExercise; import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.enumeration.AssessmentType; import de.tum.in.www1.artemis.domain.enumeration.ExerciseType; import de.tum.in.www1.artemis.domain.enumeration.InitializationState; import de.tum.in.www1.artemis.domain.enumeration.SubmissionType; @@ -83,6 +85,7 @@ import de.tum.in.www1.artemis.service.GradingScaleService; import de.tum.in.www1.artemis.service.ParticipationAuthorizationCheckService; import de.tum.in.www1.artemis.service.ParticipationService; +import de.tum.in.www1.artemis.service.TextExerciseFeedbackService; import de.tum.in.www1.artemis.service.connectors.ci.ContinuousIntegrationService; import de.tum.in.www1.artemis.service.feature.Feature; import de.tum.in.www1.artemis.service.feature.FeatureToggle; @@ -162,6 +165,8 @@ public class ParticipationResource { private final ProgrammingExerciseCodeReviewFeedbackService programmingExerciseCodeReviewFeedbackService; + private final TextExerciseFeedbackService textExerciseFeedbackService; + public ParticipationResource(ParticipationService participationService, ProgrammingExerciseParticipationService programmingExerciseParticipationService, CourseRepository courseRepository, QuizExerciseRepository quizExerciseRepository, ExerciseRepository exerciseRepository, ProgrammingExerciseRepository programmingExerciseRepository, AuthorizationCheckService authCheckService, @@ -171,7 +176,7 @@ public ParticipationResource(ParticipationService participationService, Programm ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, SubmissionRepository submissionRepository, ResultRepository resultRepository, ExerciseDateService exerciseDateService, InstanceMessageSendService instanceMessageSendService, QuizBatchService quizBatchService, SubmittedAnswerRepository submittedAnswerRepository, QuizSubmissionService quizSubmissionService, GradingScaleService gradingScaleService, - ProgrammingExerciseCodeReviewFeedbackService programmingExerciseCodeReviewFeedbackService) { + ProgrammingExerciseCodeReviewFeedbackService programmingExerciseCodeReviewFeedbackService, TextExerciseFeedbackService textExerciseFeedbackService) { this.participationService = participationService; this.programmingExerciseParticipationService = programmingExerciseParticipationService; this.quizExerciseRepository = quizExerciseRepository; @@ -197,6 +202,7 @@ public ParticipationResource(ParticipationService participationService, Programm this.quizSubmissionService = quizSubmissionService; this.gradingScaleService = gradingScaleService; this.programmingExerciseCodeReviewFeedbackService = programmingExerciseCodeReviewFeedbackService; + this.textExerciseFeedbackService = textExerciseFeedbackService; } /** @@ -352,39 +358,70 @@ public ResponseEntity resumeParticipati @PutMapping("exercises/{exerciseId}/request-feedback") @EnforceAtLeastStudent @FeatureToggle(Feature.ProgrammingExercises) - public ResponseEntity requestFeedback(@PathVariable Long exerciseId, Principal principal) { + public ResponseEntity requestFeedback(@PathVariable Long exerciseId, Principal principal) { log.debug("REST request for feedback request: {}", exerciseId); - var programmingExercise = programmingExerciseRepository.findByIdWithTemplateAndSolutionParticipationElseThrow(exerciseId); - if (programmingExercise.isExamExercise()) { - throw new BadRequestAlertException("Not intended for the use in exams", "participation", "preconditions not met"); + Exercise exercise = exerciseRepository.findByIdElseThrow(exerciseId); + + if (!(exercise instanceof TextExercise) && !(exercise instanceof ProgrammingExercise)) { + throw new BadRequestAlertException("Unsupported exercise type", "participation", "unsupported type"); } - if (programmingExercise.getDueDate() != null && now().isAfter(programmingExercise.getDueDate())) { + return handleExerciseFeedbackRequest(exercise, principal); + } + + private ResponseEntity handleExerciseFeedbackRequest(Exercise exercise, Principal principal) { + // Validate exercise and timing + if (exercise.isExamExercise()) { + throw new BadRequestAlertException("Not intended for the use in exams", "participation", "preconditions not met"); + } + if (exercise.getDueDate() != null && now().isAfter(exercise.getDueDate())) { throw new BadRequestAlertException("The due date is over", "participation", "preconditions not met"); } + if (exercise instanceof ProgrammingExercise) { + ((ProgrammingExercise) exercise).validateSettingsForFeedbackRequest(); + } - var participation = programmingExerciseParticipationService.findStudentParticipationByExerciseAndStudentId(programmingExercise, principal.getName()); + // Get and validate participation User user = userRepository.getUserWithGroupsAndAuthorities(); + StudentParticipation participation = (exercise instanceof ProgrammingExercise) + ? programmingExerciseParticipationService.findStudentParticipationByExerciseAndStudentId(exercise, principal.getName()) + : studentParticipationRepository.findByExerciseIdAndStudentLogin(exercise.getId(), principal.getName()) + .orElseThrow(() -> new ResourceNotFoundException("Participation not found")); checkAccessPermissionOwner(participation, user); - programmingExercise.validateSettingsForFeedbackRequest(); + participation = studentParticipationRepository.findByIdWithResultsElseThrow(participation.getId()); - var studentParticipation = (ProgrammingExerciseStudentParticipation) studentParticipationRepository.findByIdWithResultsElseThrow(participation.getId()); - var result = studentParticipation.findLatestLegalResult(); - if (result == null) { - throw new BadRequestAlertException("User has not reached the conditions to submit a feedback request", "participation", "preconditions not met"); + // Check submission requirements + if (exercise instanceof TextExercise) { + if (submissionRepository.findAllByParticipationId(participation.getId()).isEmpty()) { + throw new BadRequestAlertException("You need to submit at least once", "participation", "preconditions not met"); + } + } + else if (exercise instanceof ProgrammingExercise) { + if (participation.findLatestLegalResult() == null) { + throw new BadRequestAlertException("User has not reached the conditions to submit a feedback request", "participation", "preconditions not met"); + } } + // Check if feedback has already been requested var currentDate = now(); var participationIndividualDueDate = participation.getIndividualDueDate(); if (participationIndividualDueDate != null && currentDate.isAfter(participationIndividualDueDate)) { throw new BadRequestAlertException("Request has already been sent", "participation", "already sent"); } - participation = this.programmingExerciseCodeReviewFeedbackService.handleNonGradedFeedbackRequest(exerciseId, studentParticipation, programmingExercise); + // Process feedback request + StudentParticipation updatedParticipation; + if (exercise instanceof TextExercise) { + updatedParticipation = textExerciseFeedbackService.handleNonGradedFeedbackRequest(exercise.getId(), participation, (TextExercise) exercise); + } + else { + updatedParticipation = programmingExerciseCodeReviewFeedbackService.handleNonGradedFeedbackRequest(exercise.getId(), + (ProgrammingExerciseStudentParticipation) participation, (ProgrammingExercise) exercise); + } - return ResponseEntity.ok().body(participation); + return ResponseEntity.ok().body(updatedParticipation); } /** @@ -580,7 +617,8 @@ public ResponseEntity> getAllParticipationsForExercise participations = findParticipationWithLatestResults(exercise); participations.forEach(participation -> { participation.setSubmissionCount(participation.getSubmissions().size()); - if (participation.getResults() != null && !participation.getResults().isEmpty()) { + if (participation.getResults() != null && !participation.getResults().isEmpty() + && !(participation.getResults().stream().allMatch(result -> AssessmentType.AUTOMATIC_ATHENA.equals(result.getAssessmentType())))) { participation.setSubmissions(null); } else if (participation.getSubmissions() != null && !participation.getSubmissions().isEmpty()) { diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/TextExerciseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/TextExerciseResource.java index 853e39e8c861..cb528fc8e38d 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/TextExerciseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/TextExerciseResource.java @@ -2,12 +2,10 @@ import static de.tum.in.www1.artemis.config.Constants.PROFILE_CORE; import static de.tum.in.www1.artemis.web.rest.plagiarism.PlagiarismResultResponseBuilder.buildPlagiarismResultResponse; -import static java.util.Collections.emptySet; import java.io.File; import java.net.URI; import java.net.URISyntaxException; -import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Objects; @@ -41,6 +39,7 @@ import de.tum.in.www1.artemis.domain.TextExercise; import de.tum.in.www1.artemis.domain.TextSubmission; import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.enumeration.AssessmentType; import de.tum.in.www1.artemis.domain.metis.conversation.Channel; import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.domain.plagiarism.text.TextPlagiarismResult; @@ -424,8 +423,11 @@ public ResponseEntity getDataForTextEditor(@PathVariable L textSubmission.setParticipation(null); if (!ExerciseDateService.isAfterAssessmentDueDate(textExercise)) { - textSubmission.setResults(Collections.emptyList()); - participation.setResults(emptySet()); + // We want to have the preliminary feedback before the assessment due date too + List athenaResults = participation.getResults().stream().filter(result -> result.getAssessmentType() == AssessmentType.AUTOMATIC_ATHENA).toList(); + textSubmission.setResults(athenaResults); + Set athenaResultsSet = new HashSet(athenaResults); + participation.setResults(athenaResultsSet); } Result result = textSubmission.getLatestResult(); diff --git a/src/main/java/de/tum/in/www1/artemis/web/websocket/ResultWebsocketService.java b/src/main/java/de/tum/in/www1/artemis/web/websocket/ResultWebsocketService.java index 05e5a645b23b..ab27fb9e76b9 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/websocket/ResultWebsocketService.java +++ b/src/main/java/de/tum/in/www1/artemis/web/websocket/ResultWebsocketService.java @@ -84,8 +84,8 @@ private void broadcastNewResultToParticipants(StudentParticipation studentPartic // Don't send students results after the exam ended boolean isAfterExamEnd = isWorkingPeriodOver && exercise.isExamExercise() && !exercise.getExam().isTestExam(); // If the assessment due date is not over yet, do not send manual feedback to students! - boolean isAutomaticAssessmentOrDueDateOver = AssessmentType.AUTOMATIC == result.getAssessmentType() || exercise.getAssessmentDueDate() == null - || ZonedDateTime.now().isAfter(exercise.getAssessmentDueDate()); + boolean isAutomaticAssessmentOrDueDateOver = AssessmentType.AUTOMATIC == result.getAssessmentType() || AssessmentType.AUTOMATIC_ATHENA == result.getAssessmentType() + || exercise.getAssessmentDueDate() == null || ZonedDateTime.now().isAfter(exercise.getAssessmentDueDate()); if (isAutomaticAssessmentOrDueDateOver && !isAfterExamEnd) { var students = studentParticipation.getStudents(); diff --git a/src/main/webapp/app/entities/submission.model.ts b/src/main/webapp/app/entities/submission.model.ts index ced9e0276d88..3dd3c6039acd 100644 --- a/src/main/webapp/app/entities/submission.model.ts +++ b/src/main/webapp/app/entities/submission.model.ts @@ -2,6 +2,7 @@ import { BaseEntity } from 'app/shared/model/base-entity'; import { Participation } from 'app/entities/participation/participation.model'; import { Result } from 'app/entities/result.model'; import dayjs from 'dayjs/esm'; +import { AssessmentType } from 'app/entities/assessment-type.model'; export const enum SubmissionType { MANUAL = 'MANUAL', @@ -63,14 +64,14 @@ export function getLatestSubmissionResult(submission: Submission | undefined): R /** * Used to access a submissions result for a specific correctionRound - * + * Athena Results need to be excluded to avoid an assessment being locked by null * @param submission * @param correctionRound * @returns the results or undefined if submission or the result for the requested correctionRound is undefined */ export function getSubmissionResultByCorrectionRound(submission: Submission | undefined, correctionRound: number): Result | undefined { - if (submission?.results && submission?.results.length >= correctionRound) { - return submission.results[correctionRound]; + if (submission?.results && submission?.results.filter((result) => result.assessmentType !== AssessmentType.AUTOMATIC_ATHENA).length >= correctionRound) { + return submission.results.filter((result) => result.assessmentType !== AssessmentType.AUTOMATIC_ATHENA)[correctionRound]; } return undefined; } diff --git a/src/main/webapp/app/exercises/shared/exercise-scores/exercise-scores.component.ts b/src/main/webapp/app/exercises/shared/exercise-scores/exercise-scores.component.ts index b518bd05bb5a..c2642528bc00 100644 --- a/src/main/webapp/app/exercises/shared/exercise-scores/exercise-scores.component.ts +++ b/src/main/webapp/app/exercises/shared/exercise-scores/exercise-scores.component.ts @@ -171,8 +171,16 @@ export class ExerciseScoresComponent implements OnInit, OnDestroy { // the result of the first correction round will be at index 0, // the result of a complaints or the second correction at index 1. participation.results?.sort((result1, result2) => (result1.id ?? 0) - (result2.id ?? 0)); - if (participation.results?.[0].submission) { - participation.submissions = [participation.results?.[0].submission]; + + const resultsWithoutAthena = participation.results?.filter((result) => result.assessmentType !== AssessmentType.AUTOMATIC_ATHENA); + if (resultsWithoutAthena?.length != 0) { + if (resultsWithoutAthena?.[0].submission) { + participation.submissions = [resultsWithoutAthena?.[0].submission]; + } else if (participation.results?.[0].submission) { + participation.submissions = [participation.results?.[0].submission]; + } + } else { + participation.results = undefined; } }); this.filteredParticipations = this.filterByScoreRange(this.participations); diff --git a/src/main/webapp/app/exercises/shared/exercise-scores/manage-assessment-buttons.component.html b/src/main/webapp/app/exercises/shared/exercise-scores/manage-assessment-buttons.component.html index 4417caebec48..614a4d3b4ff3 100644 --- a/src/main/webapp/app/exercises/shared/exercise-scores/manage-assessment-buttons.component.html +++ b/src/main/webapp/app/exercises/shared/exercise-scores/manage-assessment-buttons.component.html @@ -3,7 +3,9 @@ @if ( (correctionRound === 0 || participation.results?.[correctionRound - 1]?.completionDate) && (newManualResultAllowed || - (participation.results?.[correctionRound]?.assessmentType && participation.results?.[correctionRound]?.assessmentType !== AssessmentType.AUTOMATIC)) + (participation.results?.[correctionRound]?.assessmentType && + participation.results?.[correctionRound]?.assessmentType !== AssessmentType.AUTOMATIC && + participation.results?.[correctionRound]?.assessmentType !== AssessmentType.AUTOMATIC_ATHENA)) ) { + @if (this.exercise.type === ExerciseType.TEXT) { +
+ + + +
+ } @if (!!this.exercise.feedbackSuggestionModule) {
diff --git a/src/main/webapp/app/exercises/shared/feedback-suggestion/exercise-feedback-suggestion-options.component.ts b/src/main/webapp/app/exercises/shared/feedback-suggestion/exercise-feedback-suggestion-options.component.ts index 40ee934e6dc8..993b4154c449 100644 --- a/src/main/webapp/app/exercises/shared/feedback-suggestion/exercise-feedback-suggestion-options.component.ts +++ b/src/main/webapp/app/exercises/shared/feedback-suggestion/exercise-feedback-suggestion-options.component.ts @@ -15,6 +15,8 @@ export class ExerciseFeedbackSuggestionOptionsComponent implements OnInit, OnCha @Input() dueDate?: dayjs.Dayjs; @Input() readOnly: boolean = false; + protected readonly ExerciseType = ExerciseType; + protected readonly AssessmentType = AssessmentType; readonly assessmentType: AssessmentType; @@ -72,10 +74,20 @@ export class ExerciseFeedbackSuggestionOptionsComponent implements OnInit, OnCha if (event.target.checked) { this.exercise.feedbackSuggestionModule = this.availableAthenaModules.first(); } else { + this.exercise.allowFeedbackRequests = false; this.exercise.feedbackSuggestionModule = undefined; } } + toggleFeedbackRequests(event: any) { + if (event.target.checked) { + this.exercise.feedbackSuggestionModule = this.availableAthenaModules.first(); + this.exercise.allowFeedbackRequests = true; + } else { + this.exercise.allowFeedbackRequests = false; + } + } + private hasDueDatePassed() { return dayjs(this.exercise.dueDate).isBefore(dayjs()); } diff --git a/src/main/webapp/app/exercises/shared/feedback/feedback.component.html b/src/main/webapp/app/exercises/shared/feedback/feedback.component.html index 5d41e4bfe798..a0de0676f7dc 100644 --- a/src/main/webapp/app/exercises/shared/feedback/feedback.component.html +++ b/src/main/webapp/app/exercises/shared/feedback/feedback.component.html @@ -84,7 +84,7 @@

} - @if (result.assessmentType !== AssessmentType.AUTOMATIC_ATHENA && showScoreChart && result.participation?.exercise) { + @if (showScoreChart && result.participation?.exercise) {
{{ resultString }} diff --git a/src/main/webapp/app/exercises/shared/result/result.component.ts b/src/main/webapp/app/exercises/shared/result/result.component.ts index b323a640b21e..3415021e11c7 100644 --- a/src/main/webapp/app/exercises/shared/result/result.component.ts +++ b/src/main/webapp/app/exercises/shared/result/result.component.ts @@ -43,6 +43,7 @@ export class ResultComponent implements OnInit, OnChanges, OnDestroy { readonly ExerciseType = ExerciseType; readonly roundScoreSpecifiedByCourseSettings = roundValueSpecifiedByCourseSettings; readonly getCourseFromExercise = getCourseFromExercise; + protected readonly AssessmentType = AssessmentType; @Input() participation: Participation; @Input() isBuilding: boolean; diff --git a/src/main/webapp/app/exercises/shared/result/result.service.ts b/src/main/webapp/app/exercises/shared/result/result.service.ts index 800fe1b4aecd..2fd62aa574b7 100644 --- a/src/main/webapp/app/exercises/shared/result/result.service.ts +++ b/src/main/webapp/app/exercises/shared/result/result.service.ts @@ -94,6 +94,9 @@ export class ResultService implements IResultService { const relativeScore = roundValueSpecifiedByCourseSettings(result.score!, getCourseFromExercise(exercise)); const points = roundValueSpecifiedByCourseSettings((result.score! * exercise.maxPoints!) / 100, getCourseFromExercise(exercise)); if (exercise.type !== ExerciseType.PROGRAMMING) { + if (Result.isAthenaAIResult(result)) { + return this.getResultStringNonProgrammingExerciseWithAIFeedback(result, relativeScore, points, short); + } return this.getResultStringNonProgrammingExercise(relativeScore, points, short); } else { return this.getResultStringProgrammingExercise(result, exercise as ProgrammingExercise, relativeScore, points, short); @@ -101,7 +104,23 @@ export class ResultService implements IResultService { } /** - * Generates the result string for a programming exercise. Contains the score and points + * Generates the result string for a text exercise. Contains the score and points + * @param result the result object + * @param relativeScore the achieved score in percent + * @param points the amount of achieved points + * @param short flag that indicates if the resultString should use the short format + */ + private getResultStringNonProgrammingExerciseWithAIFeedback(result: Result, relativeScore: number, points: number, short: boolean | undefined): string { + let aiFeedbackMessage: string = ''; + if (result && Result.isAthenaAIResult(result) && result.successful === undefined) { + return this.translateService.instant('artemisApp.result.resultString.automaticAIFeedbackInProgress'); + } + aiFeedbackMessage = this.getResultStringNonProgrammingExercise(relativeScore, points, short); + return `${aiFeedbackMessage} (${this.translateService.instant('artemisApp.result.preliminary')})`; + } + + /** + * Generates the result string for a non programming exercise. Contains the score and points * @param relativeScore the achieved score in percent * @param points the amount of achieved points * @param short flag that indicates if the resultString should use the short format diff --git a/src/main/webapp/app/exercises/shared/result/result.utils.ts b/src/main/webapp/app/exercises/shared/result/result.utils.ts index b8b43ed63c80..e8ebbf03e898 100644 --- a/src/main/webapp/app/exercises/shared/result/result.utils.ts +++ b/src/main/webapp/app/exercises/shared/result/result.utils.ts @@ -10,6 +10,7 @@ import { ProgrammingSubmission } from 'app/entities/programming/programming-subm import { AssessmentType } from 'app/entities/assessment-type.model'; import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { faCheckCircle, faQuestionCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons'; +import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'; import { isModelingOrTextOrFileUpload, isParticipationInDueTime, isProgrammingOrQuiz } from 'app/exercises/shared/participation/participation.utils'; import { getExerciseDueDate } from 'app/exercises/shared/exercise/exercise.utils'; import { Exercise, ExerciseType } from 'app/entities/exercise.model'; @@ -165,8 +166,11 @@ export const evaluateTemplateStatus = ( if (inDueTime && initializedResultWithScore(result)) { // Submission is in due time of exercise and has a result with score - if (!assessmentDueDate || assessmentDueDate.isBefore(dayjs())) { - // the assessment due date has passed (or there was none) + if (!assessmentDueDate || assessmentDueDate.isBefore(dayjs()) || !isManualResult(result)) { + // the assessment due date has passed (or there was none) (or it is not manual feedback) + if (result?.assessmentType === AssessmentType.AUTOMATIC_ATHENA && result?.successful === undefined) { + return ResultTemplateStatus.IS_GENERATING_FEEDBACK; + } return ResultTemplateStatus.HAS_RESULT; } else { // the assessment period is still active @@ -177,7 +181,7 @@ export const evaluateTemplateStatus = ( if (!dueDate || dueDate.isSameOrAfter(dayjs())) { // the due date is in the future (or there is none) => the exercise is still ongoing return ResultTemplateStatus.SUBMITTED; - } else if (!assessmentDueDate || assessmentDueDate.isSameOrAfter(dayjs())) { + } else if (!assessmentDueDate || assessmentDueDate.isSameOrAfter(dayjs()) || isManualResult(result)) { // the due date is over, further submissions are no longer possible, waiting for grading return ResultTemplateStatus.SUBMITTED_WAITING_FOR_GRADING; } else { @@ -185,7 +189,7 @@ export const evaluateTemplateStatus = ( // TODO why is this distinct from the case above? The submission can still be graded and often is. return ResultTemplateStatus.NO_RESULT; } - } else if (initializedResultWithScore(result) && (!assessmentDueDate || assessmentDueDate.isBefore(dayjs()))) { + } else if (initializedResultWithScore(result) && (!assessmentDueDate || assessmentDueDate.isBefore(dayjs()) || !isManualResult(result))) { // Submission is not in due time of exercise, has a result with score and there is no assessmentDueDate for the exercise or it lies in the past. // TODO handle external submissions with new status "External" return ResultTemplateStatus.LATE; @@ -243,6 +247,13 @@ export const getTextColorClass = (result: Result | undefined, templateStatus: Re return 'text-secondary'; } + if (result.assessmentType === AssessmentType.AUTOMATIC_ATHENA) { + if (result.successful == undefined) { + return 'text-primary'; + } + return 'text-secondary'; + } + if (templateStatus === ResultTemplateStatus.LATE) { return 'result-late'; } @@ -283,6 +294,13 @@ export const getResultIconClass = (result: Result | undefined, templateStatus: R return faQuestionCircle; } + if (result.assessmentType === AssessmentType.AUTOMATIC_ATHENA) { + if (result.successful === undefined) { + return faCircleNotch; + } + return faQuestionCircle; + } + if (isBuildFailedAndResultIsAutomatic(result) || isAIResultAndFailed(result)) { return faTimesCircle; } @@ -309,9 +327,14 @@ export const getResultIconClass = (result: Result | undefined, templateStatus: R * @param result the result. It must include a participation and exercise. */ export const resultIsPreliminary = (result: Result) => { - return ( - result.participation && isProgrammingExerciseStudentParticipation(result.participation) && isResultPreliminary(result, result.participation.exercise as ProgrammingExercise) - ); + if (result.participation && result.participation.exercise && result.participation.exercise.type === ExerciseType.TEXT) { + return result.assessmentType === AssessmentType.AUTOMATIC_ATHENA; + } else + return ( + result.participation && + isProgrammingExerciseStudentParticipation(result.participation) && + isResultPreliminary(result, result.participation.exercise as ProgrammingExercise) + ); }; /** @@ -346,7 +369,7 @@ export const isBuildFailed = (submission?: Submission) => { * @param result the result. */ export const isManualResult = (result?: Result) => { - return result?.assessmentType !== AssessmentType.AUTOMATIC; + return result?.assessmentType !== AssessmentType.AUTOMATIC && result?.assessmentType !== AssessmentType.AUTOMATIC_ATHENA; }; /** diff --git a/src/main/webapp/app/exercises/text/assess/text-assessment.service.ts b/src/main/webapp/app/exercises/text/assess/text-assessment.service.ts index d996953e4cf6..492d620afe9c 100644 --- a/src/main/webapp/app/exercises/text/assess/text-assessment.service.ts +++ b/src/main/webapp/app/exercises/text/assess/text-assessment.service.ts @@ -152,7 +152,7 @@ export class TextAssessmentService { if (participation.exercise) { this.accountService.setAccessRightsForExercise(participation.exercise); } - const submission = participation.submissions![0]; + const submission = participation.submissions!.last()!; let result; if (resultId) { result = getSubmissionResultById(submission, resultId); diff --git a/src/main/webapp/app/exercises/text/assess/text-submission-assessment.component.ts b/src/main/webapp/app/exercises/text/assess/text-submission-assessment.component.ts index 875555a1492c..ed71e52c21d4 100644 --- a/src/main/webapp/app/exercises/text/assess/text-submission-assessment.component.ts +++ b/src/main/webapp/app/exercises/text/assess/text-submission-assessment.component.ts @@ -187,7 +187,7 @@ export class TextSubmissionAssessmentComponent extends TextAssessmentBaseCompone } this.participation = studentParticipation; - this.submission = this.participation!.submissions![0] as TextSubmission; + this.submission = this.participation?.submissions?.last() as TextSubmission; this.exercise = this.participation?.exercise as TextExercise; this.course = getCourseFromExercise(this.exercise); setLatestSubmissionResult(this.submission, getLatestSubmissionResult(this.submission)); diff --git a/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-detail.component.ts b/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-detail.component.ts index 4199461314b2..e758013e44ee 100644 --- a/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-detail.component.ts +++ b/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-detail.component.ts @@ -106,6 +106,7 @@ export class TextExerciseDetailComponent implements OnInit, OnDestroy { details: [ ...defaultGradingDetails, { type: DetailType.Boolean, title: 'artemisApp.exercise.feedbackSuggestionsEnabled', data: { boolean: !!exercise.feedbackSuggestionModule } }, + { type: DetailType.Boolean, title: 'artemisApp.programmingExercise.timeline.manualFeedbackRequests', data: { boolean: exercise.allowFeedbackRequests } }, ...gradingInstructionsCriteriaDetails, ], }, diff --git a/src/main/webapp/app/exercises/text/participate/text-editor.component.html b/src/main/webapp/app/exercises/text/participate/text-editor.component.html index 280323741cda..6a0d7d189269 100644 --- a/src/main/webapp/app/exercises/text/participate/text-editor.component.html +++ b/src/main/webapp/app/exercises/text/participate/text-editor.component.html @@ -49,7 +49,7 @@ >
- @if (!result) { + @if (!result || isAutomaticResult) {