Skip to content

Commit

Permalink
Modeling exercises: Inline AI feedback view (#9799)
Browse files Browse the repository at this point in the history
Co-authored-by: Maximilian Sölch <[email protected]>
  • Loading branch information
LeonWehrhahn and maximiliansoelch authored Dec 8, 2024
1 parent afa2ce3 commit 390d00c
Show file tree
Hide file tree
Showing 18 changed files with 1,080 additions and 200 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ default List<Result> findLatestAutomaticResultsWithEagerFeedbacksTestCasesForExe

Optional<Result> findFirstByParticipationIdOrderByCompletionDateDesc(long participationId);

Optional<Result> findFirstByParticipationIdAndAssessmentTypeOrderByCompletionDateDesc(long participationId, AssessmentType assessmentType);

@EntityGraph(type = LOAD, attributePaths = { "feedbacks", "feedbacks.testCase" })
Optional<Result> findResultWithFeedbacksAndTestCasesById(long resultId);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package de.tum.cit.aet.artemis.athena.dto;

import java.util.List;

import jakarta.validation.constraints.NotNull;

import com.fasterxml.jackson.annotation.JsonInclude;
Expand All @@ -13,7 +11,7 @@
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record ModelingFeedbackDTO(long id, long exerciseId, long submissionId, String title, String description, double credits, Long structuredGradingInstructionId,
List<String> elementIds) implements FeedbackBaseDTO {
String reference) implements FeedbackBaseDTO {

/**
* Creates a ModelingFeedbackDTO from a Feedback object
Expand All @@ -30,6 +28,6 @@ public static ModelingFeedbackDTO of(long exerciseId, long submissionId, @NotNul
}

return new ModelingFeedbackDTO(feedback.getId(), exerciseId, submissionId, feedback.getText(), feedback.getDetailText(), feedback.getCredits(), gradingInstructionId,
List.of(feedback.getReference()));
feedback.getReference());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,6 @@ public ModelingExerciseFeedbackService(Optional<AthenaFeedbackSuggestionsService
this.participationService = participationService;
}

private void checkRateLimitOrThrow(StudentParticipation participation) {

List<Result> athenaResults = participation.getResults().stream().filter(result -> result.getAssessmentType() == AssessmentType.AUTOMATIC_ATHENA).toList();

if (athenaResults.size() >= 10) {
throw new BadRequestAlertException("Maximum number of AI feedback requests reached.", "participation", "preconditions not met");
}
}

/**
* Handles the request for generating feedback for a modeling exercise.
* Unlike programming exercises a tutor is not notified if Athena is not available.
Expand All @@ -79,6 +70,7 @@ private void checkRateLimitOrThrow(StudentParticipation participation) {
public StudentParticipation handleNonGradedFeedbackRequest(StudentParticipation participation, ModelingExercise modelingExercise) {
if (this.athenaFeedbackSuggestionsService.isPresent()) {
this.checkRateLimitOrThrow(participation);
this.checkLatestSubmissionHasAthenaResultOrThrow(participation);
CompletableFuture.runAsync(() -> this.generateAutomaticNonGradedFeedback(participation, modelingExercise));
}
return participation;
Expand Down Expand Up @@ -125,6 +117,10 @@ public void generateAutomaticNonGradedFeedback(StudentParticipation participatio
}
catch (Exception e) {
log.error("Could not generate feedback for exercise ID: {} and participation ID: {}", modelingExercise.getId(), participation.getId(), e);
automaticResult.setSuccessful(false);
automaticResult.setCompletionDate(null);
participation.addResult(automaticResult);
this.resultWebsocketService.broadcastNewResult(participation, automaticResult);
throw new InternalServerErrorException("Something went wrong... AI Feedback could not be generated");
}
}
Expand Down Expand Up @@ -173,6 +169,7 @@ private Feedback convertToFeedback(ModelingFeedbackDTO feedbackItem) {
feedback.setHasLongFeedbackText(false);
feedback.setType(FeedbackType.AUTOMATIC);
feedback.setCredits(feedbackItem.credits());
feedback.setReference(feedbackItem.reference());
return feedback;
}

Expand All @@ -193,4 +190,45 @@ private double calculateTotalFeedbackScore(List<Feedback> feedbacks, ModelingExe

return (totalCredits / maxPoints) * 100;
}

/**
* Checks if the number of Athena results for the given participation exceeds
* the allowed threshold and throws an exception if the limit is reached.
*
* @param participation the student participation to check
* @throws BadRequestAlertException if the maximum number of Athena feedback requests is exceeded
*/
private void checkRateLimitOrThrow(StudentParticipation participation) {
List<Result> athenaResults = participation.getResults().stream().filter(result -> result.getAssessmentType() == AssessmentType.AUTOMATIC_ATHENA).toList();

if (athenaResults.size() >= 10) {
throw new BadRequestAlertException("Maximum number of AI feedback requests reached.", "participation", "maxAthenaResultsReached", true);
}
}

/**
* Ensures that the latest submission associated with the participation does not already
* have an Athena-generated result. Throws an exception if Athena result already exists.
*
* @param participation the student participation to validate
* @throws BadRequestAlertException if no legal submissions exist or if an Athena result is already present
*/
private void checkLatestSubmissionHasAthenaResultOrThrow(StudentParticipation participation) {
Optional<Submission> submissionOptional = participationService.findExerciseParticipationWithLatestSubmissionAndResultElseThrow(participation.getId())
.findLatestSubmission();

if (submissionOptional.isEmpty()) {
throw new BadRequestAlertException("No legal submissions found", "submission", "noSubmission");
}

Submission submission = submissionOptional.get();

Result latestResult = submission.getLatestResult();

if (latestResult != null && latestResult.getAssessmentType() == AssessmentType.AUTOMATIC_ATHENA) {
log.debug("Submission ID: {} already has an Athena result. Skipping feedback generation.", submission.getId());
throw new BadRequestAlertException("Submission already has an Athena result", "submission", "submissionAlreadyHasAthenaResult", true);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
Expand Down Expand Up @@ -31,6 +32,8 @@
import de.tum.cit.aet.artemis.assessment.domain.GradingCriterion;
import de.tum.cit.aet.artemis.assessment.domain.Result;
import de.tum.cit.aet.artemis.assessment.repository.GradingCriterionRepository;
import de.tum.cit.aet.artemis.assessment.repository.ResultRepository;
import de.tum.cit.aet.artemis.assessment.service.ResultService;
import de.tum.cit.aet.artemis.core.domain.User;
import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException;
import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException;
Expand Down Expand Up @@ -67,6 +70,8 @@ public class ModelingSubmissionResource extends AbstractSubmissionResource {

private static final String ENTITY_NAME = "modelingSubmission";

private final ResultRepository resultRepository;

@Value("${jhipster.clientApp.name}")
private String applicationName;

Expand All @@ -82,17 +87,21 @@ public class ModelingSubmissionResource extends AbstractSubmissionResource {

private final PlagiarismService plagiarismService;

private final ResultService resultService;

public ModelingSubmissionResource(SubmissionRepository submissionRepository, ModelingSubmissionService modelingSubmissionService,
ModelingExerciseRepository modelingExerciseRepository, AuthorizationCheckService authCheckService, UserRepository userRepository, ExerciseRepository exerciseRepository,
GradingCriterionRepository gradingCriterionRepository, ExamSubmissionService examSubmissionService, StudentParticipationRepository studentParticipationRepository,
ModelingSubmissionRepository modelingSubmissionRepository, PlagiarismService plagiarismService) {
ModelingSubmissionRepository modelingSubmissionRepository, PlagiarismService plagiarismService, ResultService resultService, ResultRepository resultRepository) {
super(submissionRepository, authCheckService, userRepository, exerciseRepository, modelingSubmissionService, studentParticipationRepository);
this.modelingSubmissionService = modelingSubmissionService;
this.modelingExerciseRepository = modelingExerciseRepository;
this.gradingCriterionRepository = gradingCriterionRepository;
this.examSubmissionService = examSubmissionService;
this.modelingSubmissionRepository = modelingSubmissionRepository;
this.plagiarismService = plagiarismService;
this.resultService = resultService;
this.resultRepository = resultRepository;
}

/**
Expand Down Expand Up @@ -367,4 +376,81 @@ public ResponseEntity<ModelingSubmission> getLatestSubmissionForModelingEditor(@

return ResponseEntity.ok(modelingSubmission);
}

/**
* GET /participations/{participationId}/submissions-with-results : get submissions with results for a particular student participation.
* When the assessment period is not over yet, only submissions with Athena results are returned.
* When the assessment period is over, both Athena and normal results are returned.
*
* @param participationId the id of the participation for which to get the submissions with results
* @return the ResponseEntity with status 200 (OK) and with body the list of submissions with results and feedbacks, or with status 404 (Not Found) if the participation could
* not be found
*/
@GetMapping("participations/{participationId}/submissions-with-results")
@EnforceAtLeastStudent
public ResponseEntity<List<Submission>> getSubmissionsWithResultsForParticipation(@PathVariable long participationId) {
log.debug("REST request to get submissions with results for participation: {}", participationId);

// Retrieve and check the participation
StudentParticipation participation = studentParticipationRepository.findByIdWithLegalSubmissionsResultsFeedbackElseThrow(participationId);
User user = userRepository.getUserWithGroupsAndAuthorities();

if (participation.getExercise() == null) {
return ResponseEntity.badRequest()
.headers(HeaderUtil.createFailureAlert(applicationName, true, "modelingExercise", "exerciseEmpty", "The exercise belonging to the participation is null."))
.body(null);
}

if (!(participation.getExercise() instanceof ModelingExercise modelingExercise)) {
return ResponseEntity.badRequest().headers(
HeaderUtil.createFailureAlert(applicationName, true, "modelingExercise", "wrongExerciseType", "The exercise of the participation is not a modeling exercise."))
.body(null);
}

// Students can only see their own models (to prevent cheating). TAs, instructors and admins can see all models.
boolean isAtLeastTutor = authCheckService.isAtLeastTeachingAssistantForExercise(modelingExercise, user);
if (!(authCheckService.isOwnerOfParticipation(participation) || isAtLeastTutor)) {
throw new AccessForbiddenException();
}

// Exam exercises cannot be seen by students between the endDate and the publishResultDate
if (!authCheckService.isAllowedToGetExamResult(modelingExercise, participation, user)) {
throw new AccessForbiddenException();
}

boolean isStudent = !isAtLeastTutor;

// Get the submissions associated with the participation
Set<Submission> submissions = participation.getSubmissions();

// Filter submissions to only include those with relevant results
List<Submission> submissionsWithResults = submissions.stream().filter(submission -> {

submission.setParticipation(participation);

// Filter results within each submission based on assessment type and period
List<Result> filteredResults = submission.getResults().stream().filter(result -> {
if (isStudent) {
if (ExerciseDateService.isAfterAssessmentDueDate(modelingExercise)) {
return true; // Include all results if the assessment period is over
}
else {
return result.getAssessmentType() == AssessmentType.AUTOMATIC_ATHENA; // Only include Athena results if the assessment period is not over
}
}
else {
return true; // Tutors and above can see all results
}
}).peek(Result::filterSensitiveInformation).sorted(Comparator.comparing(Result::getCompletionDate).reversed()).toList();

// Set filtered results back into the submission if any results remain after filtering
if (!filteredResults.isEmpty()) {
submission.setResults(filteredResults);
return true; // Include submission as it has relevant results
}
return false;
}).toList();

return ResponseEntity.ok().body(submissionsWithResults);
}
}
27 changes: 7 additions & 20 deletions src/main/webapp/app/assessment/athena.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { TextBlockRef } from 'app/entities/text/text-block-ref.model';
import { TextSubmission } from 'app/entities/text/text-submission.model';
import { PROFILE_ATHENA } from 'app/app.constants';
import { ModelingSubmission } from 'app/entities/modeling-submission.model';
import { UMLModel, findElement } from '@ls1intum/apollon';

@Injectable({ providedIn: 'root' })
export class AthenaService {
Expand Down Expand Up @@ -169,45 +168,33 @@ export class AthenaService {
public getModelingFeedbackSuggestions(exercise: Exercise, submission: ModelingSubmission): Observable<Feedback[]> {
return this.getFeedbackSuggestions<ModelingFeedbackSuggestion>(exercise, submission.id!).pipe(
map((suggestions) => {
const referencedElementIDs = new Set();

const model: UMLModel | undefined = submission.model ? JSON.parse(submission.model) : undefined;

return suggestions.map((suggestion, index) => {
const feedback = new Feedback();
feedback.id = index;
feedback.credits = suggestion.credits;
feedback.positive = suggestion.credits >= 1;

// Even though Athena can reference multiple elements for the same feedback item, Apollon can only
// attach feedback to one element, so we select the first element ID mentioned. To ensure that not
// more than one feedback item is attached to the same element, we additionally ensure that the
// same element is only referenced once.
const referenceId: string | undefined = suggestion.elementIds.filter((id) => !referencedElementIDs.has(id))[0];
// Extract reference details if present
const reference = suggestion.reference?.split(':');
const [referenceType, referenceId] = reference || [];

if (referenceId) {
feedback.type = FeedbackType.AUTOMATIC;
feedback.text = suggestion.description;

feedback.reference = suggestion.reference;
feedback.referenceId = referenceId;

referencedElementIDs.add(referenceId);

if (model && feedback.referenceId) {
const element = findElement(model, feedback.referenceId);
feedback.referenceType = element?.type;
feedback.reference = `${element?.type}:${referenceId}`;
}
feedback.referenceType = referenceType;
} else {
feedback.type = FeedbackType.MANUAL_UNREFERENCED;
feedback.text = `${FEEDBACK_SUGGESTION_IDENTIFIER}${suggestion.title}`;
feedback.detailText = suggestion.description;
}

// Load grading instruction from exercise, if available
// Attach grading instruction if available
if (suggestion.structuredGradingInstructionId) {
feedback.gradingInstruction = this.findGradingInstruction(exercise, suggestion.structuredGradingInstructionId);
}

return feedback;
});
}),
Expand Down
2 changes: 1 addition & 1 deletion src/main/webapp/app/entities/feedback-suggestion.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,6 @@ export class ModelingFeedbackSuggestion {
public description: string,
public credits: number,
public structuredGradingInstructionId: number | undefined,
public elementIds: string[],
public reference: string | undefined,
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ArtemisFullscreenModule } from 'app/shared/fullscreen/fullscreen.module
import { RatingModule } from 'app/exercises/shared/rating/rating.module';
import { ArtemisMarkdownModule } from 'app/shared/markdown.module';
import { ArtemisTeamParticipeModule } from 'app/exercises/shared/team/team-participate/team-participate.module';
import { RequestFeedbackButtonComponent } from 'app/overview/exercise-details/request-feedback-button/request-feedback-button.component';

@NgModule({
imports: [
Expand All @@ -29,6 +30,7 @@ import { ArtemisTeamParticipeModule } from 'app/exercises/shared/team/team-parti
RatingModule,
ArtemisMarkdownModule,
ArtemisTeamParticipeModule,
RequestFeedbackButtonComponent,
],
declarations: [ModelingSubmissionComponent],
exports: [ModelingSubmissionComponent],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ export const routes: Routes = [
canActivate: [UserRouteAccessService],
canDeactivate: [PendingChangesGuard],
},
{
path: 'participate/:participationId/submission/:submissionId',
component: ModelingSubmissionComponent,
data: {
authorities: [Authority.USER],
pageTitle: 'artemisApp.modelingExercise.home.title',
},
canActivate: [UserRouteAccessService],
canDeactivate: [PendingChangesGuard],
},
];

@NgModule({
Expand Down
Loading

0 comments on commit 390d00c

Please sign in to comment.