From 6e2a9a9cf693532b82706f871f277f4ae8690450 Mon Sep 17 00:00:00 2001 From: Can Date: Mon, 5 Jan 2026 17:09:07 +0100 Subject: [PATCH 01/33] Add UML exercise entities/repositories --- .../entity/umlExercise/UmlExerciseEntity.java | 44 +++++++++++++ .../entity/umlExercise/UmlFeedbackEntity.java | 29 +++++++++ .../umlExercise/UmlStudentSolutionEntity.java | 33 ++++++++++ .../UmlStudentSubmissionEntity.java | 31 +++++++++ .../repository/UmlExerciseRepository.java | 25 ++++++++ .../UmlStudentSolutionRepository.java | 35 ++++++++++ .../UmlStudentSubmissionRepository.java | 21 ++++++ .../resources/graphql/service/uml.graphqls | 64 +++++++++++++++++++ 8 files changed, 282 insertions(+) create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlExerciseEntity.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlFeedbackEntity.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSolutionEntity.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSubmissionEntity.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlExerciseRepository.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlStudentSolutionRepository.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlStudentSubmissionRepository.java create mode 100644 src/main/resources/graphql/service/uml.graphqls diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlExerciseEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlExerciseEntity.java new file mode 100644 index 0000000..bceebba --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlExerciseEntity.java @@ -0,0 +1,44 @@ +package de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise; + +import jakarta.persistence.*; +import lombok.*; +import de.unistuttgart.iste.meitrex.common.persistence.IWithId; + +import java.util.List; +import java.util.UUID; + +@Entity(name = "UmlExercise") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UmlExerciseEntity implements IWithId { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, unique = true) + private UUID assessmentId; + + @Column(nullable = false) + private UUID courseId; + + @Column(nullable = false, columnDefinition = "TEXT") + private String description; + + @Column(nullable = false) + private boolean showSolution; + + @Column(columnDefinition = "TEXT") + private String tutorSolution; + + @Column(nullable = false) + private int totalPoints; + + @Column(nullable = false) + private double requiredPercentage; + + @OneToMany(mappedBy = "exercise", cascade = CascadeType.ALL, orphanRemoval = true) + private List studentSubmissions; +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlFeedbackEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlFeedbackEntity.java new file mode 100644 index 0000000..7be18ac --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlFeedbackEntity.java @@ -0,0 +1,29 @@ +package de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise; + +import jakarta.persistence.*; +import lombok.*; +import de.unistuttgart.iste.meitrex.common.persistence.IWithId; + +import java.util.UUID; + +@Entity(name = "UmlFeedback") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UmlFeedbackEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @OneToOne + @JoinColumn(name = "solution_id") + private UmlStudentSolutionEntity solution; + + @Column(nullable = false, columnDefinition = "TEXT") + private String comment; + + @Column(nullable = false) + private int points; +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSolutionEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSolutionEntity.java new file mode 100644 index 0000000..28f46cf --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSolutionEntity.java @@ -0,0 +1,33 @@ +package de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise; + +import jakarta.persistence.*; +import lombok.*; +import de.unistuttgart.iste.meitrex.common.persistence.IWithId; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity(name = "UmlStudentSolution") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UmlStudentSolutionEntity implements IWithId { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "submission_id") + private UmlStudentSubmissionEntity submission; + + @Column(nullable = false) + private OffsetDateTime submittedAt; + + @Column(nullable = false, columnDefinition = "TEXT") + private String diagram; + + @OneToOne(mappedBy = "solution", cascade = CascadeType.ALL) + private UmlFeedbackEntity feedback; +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSubmissionEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSubmissionEntity.java new file mode 100644 index 0000000..6cdf102 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSubmissionEntity.java @@ -0,0 +1,31 @@ +package de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise; + +import jakarta.persistence.*; +import lombok.*; +import de.unistuttgart.iste.meitrex.common.persistence.IWithId; + +import java.util.List; +import java.util.UUID; + +@Entity(name = "UmlStudentSubmission") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UmlStudentSubmissionEntity implements IWithId { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + private UUID studentId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "exercise_id") + private UmlExerciseEntity exercise; + + @OneToMany(mappedBy = "submission", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("submittedAt DESC") + private List solutions; +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlExerciseRepository.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlExerciseRepository.java new file mode 100644 index 0000000..a4e6cce --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlExerciseRepository.java @@ -0,0 +1,25 @@ +package de.unistuttgart.iste.meitrex.assignment_service.persistence.repository; + +import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlExerciseEntity; +import de.unistuttgart.iste.meitrex.common.persistence.MeitrexRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface UmlExerciseRepository extends MeitrexRepository { + + /** + * Finds the UML exercise by its assessmentId and fetches the submissions + * to avoid lazy loading issues in the gateway/controller. + */ + @Query("SELECT e FROM UmlExercise e " + + "LEFT JOIN FETCH e.studentSubmissions s " + + "WHERE e.assessmentId = :assessmentId") + Optional findByAssessmentIdWithSubmissions(@Param("assessmentId") UUID assessmentId); + + boolean existsByAssessmentId(UUID assessmentId); +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlStudentSolutionRepository.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlStudentSolutionRepository.java new file mode 100644 index 0000000..2981339 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlStudentSolutionRepository.java @@ -0,0 +1,35 @@ +package de.unistuttgart.iste.meitrex.assignment_service.persistence.repository; + +import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlStudentSolutionEntity; +import de.unistuttgart.iste.meitrex.common.persistence.MeitrexRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface UmlStudentSolutionRepository extends MeitrexRepository { + + /** + * Finds all solutions for a specific student and exercise, + * ordered by submission date (latest first). + */ + @Query("SELECT s FROM UmlStudentSolution s " + + "WHERE s.submission.studentId = :studentId " + + "AND s.submission.exercise.assessmentId = :assessmentId " + + "ORDER BY s.submittedAt DESC") + List findAllByStudentIdAndAssessmentId( + @Param("studentId") UUID studentId, + @Param("assessmentId") UUID assessmentId); + + /** + * Fetches a solution along with its feedback to avoid N+1 queries + * when displaying history in the frontend. + */ + @Query("SELECT s FROM UmlStudentSolution s " + + "LEFT JOIN FETCH s.feedback " + + "WHERE s.id = :solutionId") + UmlStudentSolutionEntity findByIdWithFeedback(@Param("solutionId") UUID solutionId); +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlStudentSubmissionRepository.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlStudentSubmissionRepository.java new file mode 100644 index 0000000..73c6ad5 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlStudentSubmissionRepository.java @@ -0,0 +1,21 @@ +package de.unistuttgart.iste.meitrex.assignment_service.persistence.repository; + +import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlStudentSubmissionEntity; +import de.unistuttgart.iste.meitrex.common.persistence.MeitrexRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface UmlStudentSubmissionRepository extends MeitrexRepository { + + @Query("SELECT s FROM UmlStudentSubmission s " + + "LEFT JOIN FETCH s.solutions " + + "WHERE s.studentId = :studentId AND s.exercise.assessmentId = :assessmentId") + Optional findByStudentAndAssessmentWithSolutions( + @Param("studentId") UUID studentId, + @Param("assessmentId") UUID assessmentId); +} \ No newline at end of file diff --git a/src/main/resources/graphql/service/uml.graphqls b/src/main/resources/graphql/service/uml.graphqls new file mode 100644 index 0000000..e34c545 --- /dev/null +++ b/src/main/resources/graphql/service/uml.graphqls @@ -0,0 +1,64 @@ +type UmlExercise { + id: UUID! + """ + Identifier of the assignment, same as the identifier of the assessment. + """ + assessmentId: UUID! + """ + Id of the course this assignment belongs to. + """ + courseId: UUID! + """ + Description of the task of the UML assignment + """ + description: String! + """ + Whether the solution can be shown to the student if they submitted at least one solution + """ + showSolution: Boolean! + """ + HyLiMo code for the 'optimal' solution, created by the tutor. + This gets used to evaluate against the submission of a student to create feedback + """ + tutorSolution: String! + """ + The total amount of points that can be achieved + """ + totalPoints: Int! + """ + The required percentage to pass the assignment. A value between 0 and 1. Defaults to 0.5 + """ + requiredPercentage: Float + """ + Student submissions + """ + studentSubmissions: [UmlStudentSubmission!]! +} + +type UmlStudentSubmission { + id: UUID! + studentId: UUID! + solutions: [UmlStudentSolution!]! +} + +type UmlStudentSolution { + id: UUID! + submittedAt: DateTime! + diagram: String! + feedback: UmlFeedback +} + +type UmlFeedback { + id: UUID! + comment: String! + points: Int! +} + +input CreateUmlExerciseInput { + description: String! + showSolution: Boolean! = false + totalPoints: Int! @Positive + requiredPercentage: Float! = 0.5 @Range(min : 0, max : 1) + courseId: UUID! + tutorSolution: String +} \ No newline at end of file From 71887dcc46b422fc48955926e4502d4d0dd6892c Mon Sep 17 00:00:00 2001 From: Can Date: Mon, 5 Jan 2026 17:09:24 +0100 Subject: [PATCH 02/33] Add mutations/queries for uml exercise --- .../AssignmentServiceApplication.java | 2 + .../controller/UmlExerciseController.java | 50 ++++++++ .../persistence/mapper/UmlExerciseMapper.java | 58 +++++++++ .../service/UmlEvaluationService.java | 50 ++++++++ .../service/UmlExerciseService.java | 116 ++++++++++++++++++ .../graphql/service/mutation.graphqls | 28 +++++ .../resources/graphql/service/query.graphqls | 5 + 7 files changed, 309 insertions(+) create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/mapper/UmlExerciseMapper.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlEvaluationService.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/AssignmentServiceApplication.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/AssignmentServiceApplication.java index e989f2c..b45bab6 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/AssignmentServiceApplication.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/AssignmentServiceApplication.java @@ -3,6 +3,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; import java.util.Arrays; @@ -12,6 +13,7 @@ */ @SpringBootApplication @Slf4j +@EnableAsync public class AssignmentServiceApplication { public static void main(String[] args) { diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java new file mode 100644 index 0000000..e1015b0 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java @@ -0,0 +1,50 @@ +package de.unistuttgart.iste.meitrex.assignment_service.controller; + +import de.unistuttgart.iste.meitrex.assignment_service.service.UmlExerciseService; +import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; +import de.unistuttgart.iste.meitrex.generated.dto.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.graphql.data.method.annotation.*; +import org.springframework.stereotype.Controller; + +import java.util.UUID; + +@Slf4j +@Controller +@RequiredArgsConstructor +public class UmlExerciseController { + + private final UmlExerciseService umlExerciseService; + + @MutationMapping(name = "_internal_noauth_createUmlExercise") + public UmlExercise createUmlExercise(@Argument final UUID courseId, + @Argument final UUID assessmentId, + @Argument final CreateUmlExerciseInput input) { + return umlExerciseService.createExercise(courseId, assessmentId, input); + } + + @MutationMapping + public UmlExerciseMutation mutateUmlExercise(@Argument final UUID assessmentId, + @ContextValue final LoggedInUser currentUser) { + return umlExerciseService.mutateUmlExercise(assessmentId, currentUser); + } + + @SchemaMapping(typeName = "UmlExerciseMutation") + public UmlExercise updateTutorSolution(final UmlExerciseMutation mutation, + @Argument final String tutorSolution) { + return umlExerciseService.updateTutorSolution(mutation.getAssessmentId(), tutorSolution); + } + + @SchemaMapping(typeName = "UmlExerciseMutation") + public UmlStudentSolution submitStudentSolution(final UmlExerciseMutation mutation, + @Argument final UUID studentId, + @Argument final String diagram) { + return umlExerciseService.submitSolution(mutation.getAssessmentId(), studentId, diagram); + } + + @QueryMapping + public UmlExercise getUmlExerciseByAssessmentId(@Argument UUID assessmentId) { + return umlExerciseService.getExerciseByAssessmentId(assessmentId); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/mapper/UmlExerciseMapper.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/mapper/UmlExerciseMapper.java new file mode 100644 index 0000000..1e496ad --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/mapper/UmlExerciseMapper.java @@ -0,0 +1,58 @@ +package de.unistuttgart.iste.meitrex.assignment_service.persistence.mapper; + +import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlExerciseEntity; +import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlFeedbackEntity; +import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlStudentSolutionEntity; +import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlStudentSubmissionEntity; +import de.unistuttgart.iste.meitrex.generated.dto.*; +import lombok.RequiredArgsConstructor; +import org.modelmapper.ModelMapper; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class UmlExerciseMapper { + + private final ModelMapper modelMapper; + + public UmlExercise entityToDto(UmlExerciseEntity entity) { + if (entity == null) { + return null; + } + UmlExercise dto = modelMapper.map(entity, UmlExercise.class); + + // Ensure nested lists are mapped correctly + if (entity.getStudentSubmissions() != null) { + dto.setStudentSubmissions(entity.getStudentSubmissions().stream() + .map(this::submissionEntityToDto) + .toList()); + } + return dto; + } + + public UmlStudentSubmission submissionEntityToDto(UmlStudentSubmissionEntity entity) { + UmlStudentSubmission dto = modelMapper.map(entity, UmlStudentSubmission.class); + if (entity.getSolutions() != null) { + dto.setSolutions(entity.getSolutions().stream() + .map(this::solutionEntityToDto) + .toList()); + } + return dto; + } + + public UmlStudentSolution solutionEntityToDto(UmlStudentSolutionEntity entity) { + UmlStudentSolution dto = modelMapper.map(entity, UmlStudentSolution.class); + if (entity.getFeedback() != null) { + dto.setFeedback(feedbackEntityToDto(entity.getFeedback())); + } + return dto; + } + + public UmlFeedback feedbackEntityToDto(UmlFeedbackEntity entity) { + return modelMapper.map(entity, UmlFeedback.class); + } + + public UmlExerciseEntity createInputToEntity(CreateUmlExerciseInput input) { + return modelMapper.map(input, UmlExerciseEntity.class); + } +} \ No newline at end of file diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlEvaluationService.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlEvaluationService.java new file mode 100644 index 0000000..cfd69fd --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlEvaluationService.java @@ -0,0 +1,50 @@ +package de.unistuttgart.iste.meitrex.assignment_service.service; + +import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlFeedbackEntity; +import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlStudentSolutionEntity; +import de.unistuttgart.iste.meitrex.assignment_service.persistence.repository.UmlStudentSolutionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Service +@Slf4j +@RequiredArgsConstructor +public class UmlEvaluationService { + + private final UmlStudentSolutionRepository solutionRepository; + + @Async + @Transactional + public void generateFeedbackAsync(final UUID solutionId, final String diagram) { + log.info("Starting background feedback generation for solution {}", solutionId); + + try { + Thread.sleep(20000); + String feedbackText = "Test feedback text"; + + UmlStudentSolutionEntity solution = solutionRepository.findById(solutionId) + .orElseThrow(); + + UmlFeedbackEntity feedback = UmlFeedbackEntity.builder() + .solution(solution) + .comment(feedbackText) + .points(8) + .build(); + + solution.setFeedback(feedback); + + log.info("Feedback successfully saved for solution {}", solutionId); + + } catch (InterruptedException e) { + log.error("Async feedback generation interrupted", e); + Thread.currentThread().interrupt(); + } catch (Exception e) { + log.error("Error during async feedback generation", e); + } + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java new file mode 100644 index 0000000..cb210ed --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java @@ -0,0 +1,116 @@ +package de.unistuttgart.iste.meitrex.assignment_service.service; + +import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlExerciseEntity; +import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlStudentSolutionEntity; +import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlStudentSubmissionEntity; +import de.unistuttgart.iste.meitrex.assignment_service.persistence.mapper.UmlExerciseMapper; +import de.unistuttgart.iste.meitrex.assignment_service.persistence.repository.UmlExerciseRepository; +import de.unistuttgart.iste.meitrex.assignment_service.persistence.repository.UmlStudentSolutionRepository; +import de.unistuttgart.iste.meitrex.assignment_service.persistence.repository.UmlStudentSubmissionRepository; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; +import static de.unistuttgart.iste.meitrex.common.user_handling.UserCourseAccessValidator.validateUserHasAccessToCourse; +import de.unistuttgart.iste.meitrex.generated.dto.*; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UmlExerciseService { + + private final UmlExerciseRepository exerciseRepository; + private final UmlStudentSubmissionRepository submissionRepository; + private final UmlStudentSolutionRepository solutionRepository; + private final UmlExerciseMapper umlMapper; + + + private final UmlEvaluationService evaluationService; + + /** + * Fetches the full UML exercise details by its assessment ID. + */ + public UmlExercise getExerciseByAssessmentId(UUID assessmentId) { + return exerciseRepository.findByAssessmentIdWithSubmissions(assessmentId) + .map(umlMapper::entityToDto) + .orElseThrow(() -> new EntityNotFoundException("UmlExercise not found for assessmentId: " + assessmentId)); + } + + /** + * Creates a new UML exercise after the assignment was created + */ + public UmlExercise createExercise(final UUID courseId, UUID assessmentId, final CreateUmlExerciseInput input) { + UmlExerciseEntity entity = UmlExerciseEntity.builder() + .assessmentId(assessmentId) + .courseId(courseId) + .description(input.getDescription()) + .showSolution(input.getShowSolution()) + .totalPoints(input.getTotalPoints()) + .requiredPercentage(input.getRequiredPercentage()) + .studentSubmissions(new ArrayList<>()) + .build(); + + UmlExerciseEntity savedEntity = exerciseRepository.save(entity); + return umlMapper.entityToDto(savedEntity); + } + + /** + * Initializes a mutation object for a UML exercise and checks permissions. + */ + public UmlExerciseMutation mutateUmlExercise(final UUID assessmentId, final LoggedInUser currentUser) { + UmlExerciseEntity entity = exerciseRepository.findByAssessmentIdWithSubmissions(assessmentId) + .orElseThrow(() -> new IllegalArgumentException("Exercise not found")); + + validateUserHasAccessToCourse(currentUser, LoggedInUser.UserRoleInCourse.ADMINISTRATOR, entity.getCourseId()); + + return new UmlExerciseMutation(assessmentId); + } + + /** + * Updates the reference solution for a task. + */ + public UmlExercise updateTutorSolution(final UUID assessmentId, final String tutorSolution) { + UmlExerciseEntity entity = exerciseRepository.findByAssessmentIdWithSubmissions(assessmentId) + .orElseThrow(() -> new IllegalArgumentException("Exercise not found")); + + entity.setTutorSolution(tutorSolution); + UmlExerciseEntity savedEntity = exerciseRepository.save(entity); + return umlMapper.entityToDto(savedEntity); + } + + /** + * Submits a new diagram as a student solution attempt. + */ + public UmlStudentSolution submitSolution(final UUID assessmentId, final UUID studentId, final String diagram) { + UmlExerciseEntity exercise = exerciseRepository.findByAssessmentIdWithSubmissions(assessmentId) + .orElseThrow(() -> new IllegalArgumentException("Exercise not found")); + + UmlStudentSubmissionEntity submission = submissionRepository + .findByStudentAndAssessmentWithSolutions(studentId, exercise.getId()) + .orElseGet(() -> submissionRepository.save(UmlStudentSubmissionEntity.builder() + .studentId(studentId) + .exercise(exercise) + .solutions(new ArrayList<>()) + .build())); + + UmlStudentSolutionEntity solutionEntity = UmlStudentSolutionEntity.builder() + .submission(submission) + .diagram(diagram) + .submittedAt(OffsetDateTime.now()) + .build(); + + submission.getSolutions().add(solutionEntity); + UmlStudentSolutionEntity savedEntity = solutionRepository.save(solutionEntity); + + evaluationService.generateFeedbackAsync(savedEntity.getId(), diagram); + + log.info("Solution {} submitted. Feedback generation started in background.", savedEntity.getId()); + + return umlMapper.solutionEntityToDto(savedEntity); + } +} diff --git a/src/main/resources/graphql/service/mutation.graphqls b/src/main/resources/graphql/service/mutation.graphqls index 489450c..fd463fc 100644 --- a/src/main/resources/graphql/service/mutation.graphqls +++ b/src/main/resources/graphql/service/mutation.graphqls @@ -41,6 +41,20 @@ type Mutation { Fetches assignment info from external code assessment provider for the given course """ syncAssignmentsForCourse(courseId: UUID!): Boolean! + + """ + Creates a new UML Exercise. Mutation is only accessible internally within the system by other + services and the gateway. + ⚠️ This mutation is only accessible internally in the system and allows the caller to create assignments without + any permissions check and should not be called without any validation of the caller's permissions. ⚠️ + """ + _internal_noauth_createUmlExercise(courseId: UUID!, assessmentId: UUID!, input: CreateUmlExerciseInput!): UmlExercise! + + """ + Modify an UML exercise. + 🔒 The user must be an admin in the course the assignment is in to perform this action. + """ + mutateUmlExercise(assessmentId: UUID!): UmlExerciseMutation! } type AssignmentMutation { @@ -80,6 +94,20 @@ type AssignmentMutation { deleteSubexercise(itemId: UUID!): UUID! } +type UmlExerciseMutation { + """ + ID of the UmlExercise that is being modified. + """ + assessmentId: UUID! + + """ + Creates a new UML exercise. Throws an error if the assignment does not exist. + """ + updateTutorSolution(tutorSolution: String!): UmlExercise! + + submitStudentSolution(studentId: UUID!, diagram: String!): UmlStudentSolution +} + input CreateAssignmentInput { """ Number of total credits in the assignment. Optional for CODE_ASSIGNMENT. diff --git a/src/main/resources/graphql/service/query.graphqls b/src/main/resources/graphql/service/query.graphqls index c91902c..49f92aa 100644 --- a/src/main/resources/graphql/service/query.graphqls +++ b/src/main/resources/graphql/service/query.graphqls @@ -41,6 +41,11 @@ type Query { 🔒 The user must be an admin in the course. Otherwise null is returned. """ getManualMappingInstances(courseId: UUID!): [ManualMappingInstance]! + + """ + Returns the UML exercise for the given assessment ID. + """ + getUmlExerciseByAssessmentId(assessmentId: UUID!): UmlExercise! } type ExternalCourse { From 61d709b6dae5a5ab338e86582e067f9865790dd2 Mon Sep 17 00:00:00 2001 From: Can Date: Sun, 25 Jan 2026 17:50:50 +0100 Subject: [PATCH 03/33] Add initial manual feedback generation --- .../service/UmlEvaluationService.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlEvaluationService.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlEvaluationService.java index cfd69fd..9f33d96 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlEvaluationService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlEvaluationService.java @@ -47,4 +47,17 @@ public void generateFeedbackAsync(final UUID solutionId, final String diagram) { log.error("Error during async feedback generation", e); } } + + @Transactional + public void generateFeedback(final UmlStudentSolutionEntity solution, final String semanticModel) { + String feedbackText = "Manual Test feedback text"; + + UmlFeedbackEntity feedback = UmlFeedbackEntity.builder() + .solution(solution) + .comment(feedbackText) + .points(8) + .build(); + + solution.setFeedback(feedback); + } } From 6d5902546060aa3f6f7e8a50195e856b39264d7b Mon Sep 17 00:00:00 2001 From: Can Date: Sun, 25 Jan 2026 17:50:58 +0100 Subject: [PATCH 04/33] Add new mutations/queries --- src/main/resources/graphql/service/mutation.graphqls | 5 +++++ src/main/resources/graphql/service/uml.graphqls | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/main/resources/graphql/service/mutation.graphqls b/src/main/resources/graphql/service/mutation.graphqls index fd463fc..36e7ef3 100644 --- a/src/main/resources/graphql/service/mutation.graphqls +++ b/src/main/resources/graphql/service/mutation.graphqls @@ -106,6 +106,11 @@ type UmlExerciseMutation { updateTutorSolution(tutorSolution: String!): UmlExercise! submitStudentSolution(studentId: UUID!, diagram: String!): UmlStudentSolution + + """ + Manually triggers the evaluation for the latest submitted solution of a student for a specific exercise. + """ + evaluateLatestSolution(assessmentId: UUID!, studentId: UUID!): UmlStudentSolution! } input CreateAssignmentInput { diff --git a/src/main/resources/graphql/service/uml.graphqls b/src/main/resources/graphql/service/uml.graphqls index e34c545..5869884 100644 --- a/src/main/resources/graphql/service/uml.graphqls +++ b/src/main/resources/graphql/service/uml.graphqls @@ -33,6 +33,16 @@ type UmlExercise { Student submissions """ studentSubmissions: [UmlStudentSubmission!]! + + """ + Returns the latest solution attempt for a specific student. + """ + latestSolution(studentId: UUID!): UmlStudentSolution + + """ + Returns all solution attempts for a specific student, sorted by submission date. + """ + solutionsByStudent(studentId: UUID!): [UmlStudentSolution!]! } type UmlStudentSubmission { From d2d7330e90056274c607383477d2dfe3ef28c7fb Mon Sep 17 00:00:00 2001 From: Can Date: Sun, 25 Jan 2026 17:51:09 +0100 Subject: [PATCH 05/33] Implement new mutations/queries --- .../controller/UmlExerciseController.java | 21 ++++ .../service/UmlExerciseService.java | 102 ++++++++++++++++-- 2 files changed, 114 insertions(+), 9 deletions(-) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java index e1015b0..c20c5bc 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java @@ -8,6 +8,7 @@ import org.springframework.graphql.data.method.annotation.*; import org.springframework.stereotype.Controller; +import java.util.List; import java.util.UUID; @Slf4j @@ -47,4 +48,24 @@ public UmlStudentSolution submitStudentSolution(final UmlExerciseMutation mutati public UmlExercise getUmlExerciseByAssessmentId(@Argument UUID assessmentId) { return umlExerciseService.getExerciseByAssessmentId(assessmentId); } + + @SchemaMapping(typeName = "UmlExercise") + public List solutionsByStudent(UmlExercise exercise, @Argument UUID studentId) { + return umlExerciseService.getSolutionsByStudent(exercise, studentId); + } + + @SchemaMapping(typeName = "UmlExercise") + public UmlStudentSolution latestSolution(UmlExercise exercise, @Argument UUID studentId) { + return umlExerciseService.getSolutionsByStudent(exercise, studentId).stream() + .findFirst() + .orElse(null); + } + + @MutationMapping + public UmlStudentSolution evaluateLatestSolution( + @Argument UUID assessmentId, + @Argument UUID studentId, + @Argument String semanticModel) { + return umlExerciseService.evaluateLatestSolution(assessmentId, studentId, semanticModel); + } } diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java index cb210ed..5bd214c 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java @@ -16,8 +16,7 @@ import de.unistuttgart.iste.meitrex.generated.dto.*; import java.time.OffsetDateTime; -import java.util.ArrayList; -import java.util.UUID; +import java.util.*; @Slf4j @Service @@ -52,9 +51,14 @@ public UmlExercise createExercise(final UUID courseId, UUID assessmentId, final .showSolution(input.getShowSolution()) .totalPoints(input.getTotalPoints()) .requiredPercentage(input.getRequiredPercentage()) + .tutorSolution(input.getTutorSolution()) .studentSubmissions(new ArrayList<>()) .build(); + if (entity.getTutorSolution() == null) { + entity.setTutorSolution(""); + } + UmlExerciseEntity savedEntity = exerciseRepository.save(entity); return umlMapper.entityToDto(savedEntity); } @@ -87,16 +91,35 @@ public UmlExercise updateTutorSolution(final UUID assessmentId, final String tut * Submits a new diagram as a student solution attempt. */ public UmlStudentSolution submitSolution(final UUID assessmentId, final UUID studentId, final String diagram) { + log.info("SubmitSolution triggered: assessmentId={}, studentId={}", assessmentId, studentId); + UmlExerciseEntity exercise = exerciseRepository.findByAssessmentIdWithSubmissions(assessmentId) - .orElseThrow(() -> new IllegalArgumentException("Exercise not found")); + .orElseThrow(() -> new IllegalArgumentException("Exercise not found for assessmentId: " + assessmentId)); + + log.info("Found Exercise. Internal ID: {}, Assessment ID: {}", exercise.getId(), exercise.getAssessmentId()); + + // Check for existing submission + Optional submissionOptional = submissionRepository + .findByStudentAndAssessmentWithSolutions(studentId, exercise.getId()); + + UmlStudentSubmissionEntity submission; - UmlStudentSubmissionEntity submission = submissionRepository - .findByStudentAndAssessmentWithSolutions(studentId, exercise.getId()) - .orElseGet(() -> submissionRepository.save(UmlStudentSubmissionEntity.builder() + if (submissionOptional.isPresent()) { + submission = submissionOptional.get(); + log.info("REUSING existing submission: id={}, studentId={}, solutionsCount={}", + submission.getId(), submission.getStudentId(), submission.getSolutions().size()); + } else { + log.info("NOT FOUND: No submission for studentId={} and exerciseId={}. Creating new entity.", + studentId, exercise.getId()); + + submission = submissionRepository.save(UmlStudentSubmissionEntity.builder() .studentId(studentId) .exercise(exercise) .solutions(new ArrayList<>()) - .build())); + .build()); + + log.info("CREATED new submission: id={}", submission.getId()); + } UmlStudentSolutionEntity solutionEntity = UmlStudentSolutionEntity.builder() .submission(submission) @@ -107,10 +130,71 @@ public UmlStudentSolution submitSolution(final UUID assessmentId, final UUID stu submission.getSolutions().add(solutionEntity); UmlStudentSolutionEntity savedEntity = solutionRepository.save(solutionEntity); - evaluationService.generateFeedbackAsync(savedEntity.getId(), diagram); + log.info("SAVED new solution: id={}, attached to submission: {}", savedEntity.getId(), submission.getId()); - log.info("Solution {} submitted. Feedback generation started in background.", savedEntity.getId()); + evaluationService.generateFeedbackAsync(savedEntity.getId(), diagram); return umlMapper.solutionEntityToDto(savedEntity); } + + /** + * Retrieves all solution attempts for a specific student associated with a given exercise. + *

+ * The method fetches the exercise entity, filters the student submissions to find the container + * belonging to the specified student, and returns their solutions sorted by submission date + * in descending order (newest first). + * + * @param exerciseDto The exercise DTO containing the identifier used to fetch the entity. + * @param studentId The UUID of the student whose solutions are being requested. + * @return A list of {@link UmlStudentSolution} DTOs, or an empty list if the student + * has no submissions for this exercise. + * @throws NoSuchElementException If no exercise is found for the given identifier. + */ + public List getSolutionsByStudent(final UmlExercise exerciseDto, final UUID studentId) { + UmlExerciseEntity entity = exerciseRepository.findByAssessmentIdWithSubmissions(exerciseDto.getId()) + .orElseThrow(() -> new NoSuchElementException("Exercise not found")); + + return entity.getStudentSubmissions().stream() + .filter(sub -> sub.getStudentId().equals(studentId)) + .findFirst() + .map(sub -> sub.getSolutions().stream() + .sorted(Comparator.comparing(UmlStudentSolutionEntity::getSubmittedAt).reversed()) + .map(umlMapper::solutionEntityToDto) + .toList()) + .orElse(Collections.emptyList()); + } + + /** + * Triggers a manual evaluation and feedback generation for a student's most recent solution attempt. + *

+ * This method identifies the latest solution by finding the student's submission container and + * selecting the solution with the most recent 'submittedAt' timestamp. It then invokes the + * evaluation service to attach feedback directly to that solution entity. + * + * @param assessmentId The external UUID of the assessment/exercise. + * @param studentId The UUID of the student whose work is being evaluated. + * @param semanticModel Semantic model data used for evaluation to compare against tutor solution. + * @return The updated {@link UmlStudentSolution} DTO containing the newly generated feedback. + * @throws NoSuchElementException If the exercise is not found. + * @throws IllegalStateException If no submission or no solutions are found for the student, + * meaning there is nothing to evaluate. + */ + public UmlStudentSolution evaluateLatestSolution( + final UUID assessmentId, final UUID studentId, final String semanticModel) { + UmlExerciseEntity exercise = exerciseRepository.findByAssessmentIdWithSubmissions(assessmentId) + .orElseThrow(() -> new NoSuchElementException("Exercise not found")); + + UmlStudentSubmissionEntity submission = exercise.getStudentSubmissions().stream() + .filter(sub -> sub.getStudentId().equals(studentId)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No submission found for this student.")); + + UmlStudentSolutionEntity latestSolution = submission.getSolutions().stream() + .max(Comparator.comparing(UmlStudentSolutionEntity::getSubmittedAt)) + .orElseThrow(() -> new IllegalStateException("Submission exists but contains no solutions.")); + + evaluationService.generateFeedback(latestSolution, semanticModel); + + return umlMapper.solutionEntityToDto(latestSolution); + } } From 40e446e8da37a3712cf26cc7b46b3c11072bc5fb Mon Sep 17 00:00:00 2001 From: Can Date: Sun, 25 Jan 2026 17:51:28 +0100 Subject: [PATCH 06/33] Fix SQL query mistake --- .../repository/UmlStudentSubmissionRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlStudentSubmissionRepository.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlStudentSubmissionRepository.java index 73c6ad5..fd443c2 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlStudentSubmissionRepository.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlStudentSubmissionRepository.java @@ -14,8 +14,8 @@ public interface UmlStudentSubmissionRepository extends MeitrexRepository findByStudentAndAssessmentWithSolutions( @Param("studentId") UUID studentId, - @Param("assessmentId") UUID assessmentId); + @Param("exerciseId") UUID exerciseId); } \ No newline at end of file From 213a6236d0c962306deb725e30ce4f04153c9ab0 Mon Sep 17 00:00:00 2001 From: Can Date: Wed, 28 Jan 2026 22:43:16 +0100 Subject: [PATCH 07/33] Add save and creation functions for a students solution --- .../controller/UmlExerciseController.java | 17 ++- .../service/UmlExerciseService.java | 138 ++++++++++++++---- .../graphql/service/mutation.graphqls | 10 +- .../resources/graphql/service/uml.graphqls | 2 +- 4 files changed, 131 insertions(+), 36 deletions(-) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java index c20c5bc..b00fb12 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java @@ -38,10 +38,21 @@ public UmlExercise updateTutorSolution(final UmlExerciseMutation mutation, } @SchemaMapping(typeName = "UmlExerciseMutation") - public UmlStudentSolution submitStudentSolution(final UmlExerciseMutation mutation, + public UmlStudentSolution createUmlSolution(@Argument UUID assessmentId, + @Argument UUID studentId, + @Argument boolean createFromPrevious) { + log.info("Mutation: createUmlSolution for assessmentId={}, studentId={}", assessmentId, studentId); + return umlExerciseService.createNewSolution(assessmentId, studentId, createFromPrevious); + } + + @SchemaMapping(typeName = "UmlExerciseMutation") + public UmlStudentSolution saveStudentSolution(final UmlExerciseMutation mutation, @Argument final UUID studentId, - @Argument final String diagram) { - return umlExerciseService.submitSolution(mutation.getAssessmentId(), studentId, diagram); + @Argument final String diagram, + @Argument final UUID solutionId, + @Argument final boolean submitted) { + return umlExerciseService.saveStudentSolution( + mutation.getAssessmentId(), studentId, diagram, solutionId, submitted); } @QueryMapping diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java index 5bd214c..5ea7854 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java @@ -14,7 +14,9 @@ import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; import static de.unistuttgart.iste.meitrex.common.user_handling.UserCourseAccessValidator.validateUserHasAccessToCourse; import de.unistuttgart.iste.meitrex.generated.dto.*; +import org.springframework.transaction.annotation.Transactional; +import javax.annotation.Nullable; import java.time.OffsetDateTime; import java.util.*; @@ -30,6 +32,15 @@ public class UmlExerciseService { private final UmlEvaluationService evaluationService; + private static final String DEFAULT_START_DIAGRAM = """ + classDiagram { + class("HelloWorld") { + public { + hello : string + } + } + } + """; /** * Fetches the full UML exercise details by its assessment ID. @@ -40,6 +51,19 @@ public UmlExercise getExerciseByAssessmentId(UUID assessmentId) { .orElseThrow(() -> new EntityNotFoundException("UmlExercise not found for assessmentId: " + assessmentId)); } + /** + * Helper to find or initialize the submission container for a student. + */ + private UmlStudentSubmissionEntity getOrCreateSubmission(UmlExerciseEntity exercise, UUID studentId) { + return submissionRepository + .findByStudentAndAssessmentWithSolutions(studentId, exercise.getId()) + .orElseGet(() -> submissionRepository.save(UmlStudentSubmissionEntity.builder() + .studentId(studentId) + .exercise(exercise) + .solutions(new ArrayList<>()) + .build())); +} + /** * Creates a new UML exercise after the assignment was created */ @@ -88,51 +112,103 @@ public UmlExercise updateTutorSolution(final UUID assessmentId, final String tut } /** - * Submits a new diagram as a student solution attempt. + * Creates a new unsubmitted solution for a student. + * + * @param assessmentId The ID of the exercise. + * @param studentId The ID of the student. + * @param createFromPrevious If true, copies the diagram from the most recent submission. + * @return The newly created solution DTO. */ - public UmlStudentSolution submitSolution(final UUID assessmentId, final UUID studentId, final String diagram) { - log.info("SubmitSolution triggered: assessmentId={}, studentId={}", assessmentId, studentId); + @Transactional + public UmlStudentSolution createNewSolution(UUID assessmentId, UUID studentId, boolean createFromPrevious) { + UmlExerciseEntity exercise = exerciseRepository.findByAssessmentIdWithSubmissions(assessmentId) + .orElseThrow(() -> new NoSuchElementException("Exercise not found")); + + UmlStudentSubmissionEntity submission = getOrCreateSubmission(exercise, studentId); + + String diagram; + if (createFromPrevious) { + // Find the most recently submitted solution + diagram = submission.getSolutions().stream() + .filter(s -> s.getSubmittedAt() != null) + .max(Comparator.comparing(UmlStudentSolutionEntity::getSubmittedAt)) + .map(UmlStudentSolutionEntity::getDiagram) + .orElseThrow(() -> new IllegalStateException( + "Cannot create from previous: No submitted solutions found for this student.")); + } else { + diagram = DEFAULT_START_DIAGRAM; + } + + UmlStudentSolutionEntity newSolution = UmlStudentSolutionEntity.builder() + .submission(submission) + .diagram(diagram) + .submittedAt(null) + .build(); + + UmlStudentSolutionEntity saved = solutionRepository.save(newSolution); + submission.getSolutions().add(saved); + + return umlMapper.solutionEntityToDto(saved); + } + + /** + * Saves or submits a student's solution attempt. + * Updates an existing draft if a solutionId is provided or an unsubmitted solution exists. + * Creates a new solution record if no unsubmitted draft is found. + */ + public UmlStudentSolution saveStudentSolution(final UUID assessmentId, + final UUID studentId, + final String diagram, + @Nullable final UUID solutionId, + final boolean submit) { + log.info("saveStudentSolution triggered: assessmentId={}, studentId={}, submit={}", + assessmentId, studentId, submit); UmlExerciseEntity exercise = exerciseRepository.findByAssessmentIdWithSubmissions(assessmentId) .orElseThrow(() -> new IllegalArgumentException("Exercise not found for assessmentId: " + assessmentId)); - log.info("Found Exercise. Internal ID: {}, Assessment ID: {}", exercise.getId(), exercise.getAssessmentId()); + UmlStudentSubmissionEntity submission = getOrCreateSubmission(exercise, studentId); - // Check for existing submission - Optional submissionOptional = submissionRepository - .findByStudentAndAssessmentWithSolutions(studentId, exercise.getId()); + UmlStudentSolutionEntity solutionEntity; - UmlStudentSubmissionEntity submission; + if (solutionId != null) { + // If a specific solutionId is requested, verify it exists and is still a draft + solutionEntity = solutionRepository.findById(solutionId) + .orElseThrow(() -> new IllegalArgumentException("Solution not found for id: " + solutionId)); - if (submissionOptional.isPresent()) { - submission = submissionOptional.get(); - log.info("REUSING existing submission: id={}, studentId={}, solutionsCount={}", - submission.getId(), submission.getStudentId(), submission.getSolutions().size()); + if (solutionEntity.getSubmittedAt() != null) { + throw new IllegalStateException("Cannot modify a solution that has already been submitted."); + } } else { - log.info("NOT FOUND: No submission for studentId={} and exerciseId={}. Creating new entity.", - studentId, exercise.getId()); + // Otherwise, look for an existing unsubmitted draft in the container + solutionEntity = submission.getSolutions().stream() + .filter(s -> s.getSubmittedAt() == null) + .findFirst() + .orElseGet(() -> { + // No current draft exists; create a new solution record + UmlStudentSolutionEntity newSolution = UmlStudentSolutionEntity.builder() + .submission(submission) + .diagram(diagram) + .build(); + submission.getSolutions().add(newSolution); + return newSolution; + }); + } - submission = submissionRepository.save(UmlStudentSubmissionEntity.builder() - .studentId(studentId) - .exercise(exercise) - .solutions(new ArrayList<>()) - .build()); + solutionEntity.setDiagram(diagram); - log.info("CREATED new submission: id={}", submission.getId()); + if (submit) { + solutionEntity.setSubmittedAt(OffsetDateTime.now()); } - UmlStudentSolutionEntity solutionEntity = UmlStudentSolutionEntity.builder() - .submission(submission) - .diagram(diagram) - .submittedAt(OffsetDateTime.now()) - .build(); - - submission.getSolutions().add(solutionEntity); UmlStudentSolutionEntity savedEntity = solutionRepository.save(solutionEntity); - log.info("SAVED new solution: id={}, attached to submission: {}", savedEntity.getId(), submission.getId()); - - evaluationService.generateFeedbackAsync(savedEntity.getId(), diagram); + if (submit) { + evaluationService.generateFeedbackAsync(savedEntity.getId(), diagram); + log.info("Solution submitted and evaluation triggered for id: {}", savedEntity.getId()); + } else { + log.info("Draft saved for solution id: {}", savedEntity.getId()); + } return umlMapper.solutionEntityToDto(savedEntity); } @@ -151,7 +227,7 @@ public UmlStudentSolution submitSolution(final UUID assessmentId, final UUID stu * @throws NoSuchElementException If no exercise is found for the given identifier. */ public List getSolutionsByStudent(final UmlExercise exerciseDto, final UUID studentId) { - UmlExerciseEntity entity = exerciseRepository.findByAssessmentIdWithSubmissions(exerciseDto.getId()) + UmlExerciseEntity entity = exerciseRepository.findByAssessmentIdWithSubmissions(exerciseDto.getAssessmentId()) .orElseThrow(() -> new NoSuchElementException("Exercise not found")); return entity.getStudentSubmissions().stream() diff --git a/src/main/resources/graphql/service/mutation.graphqls b/src/main/resources/graphql/service/mutation.graphqls index 36e7ef3..a44176f 100644 --- a/src/main/resources/graphql/service/mutation.graphqls +++ b/src/main/resources/graphql/service/mutation.graphqls @@ -105,7 +105,15 @@ type UmlExerciseMutation { """ updateTutorSolution(tutorSolution: String!): UmlExercise! - submitStudentSolution(studentId: UUID!, diagram: String!): UmlStudentSolution + """ + Creates a new solution attempt for a student. + """ + createUmlSolution(assessmentId: UUID!, studentId: UUID!, createFromPrevious: Boolean!): UmlStudentSolution! + + """ + Saves progress or finalizes a student's solution depending on the submit flag. + """ + saveStudentSolution(studentId: UUID!, diagram: String!, solutionId: UUID, submit: Boolean!): UmlStudentSolution """ Manually triggers the evaluation for the latest submitted solution of a student for a specific exercise. diff --git a/src/main/resources/graphql/service/uml.graphqls b/src/main/resources/graphql/service/uml.graphqls index 5869884..5354bf3 100644 --- a/src/main/resources/graphql/service/uml.graphqls +++ b/src/main/resources/graphql/service/uml.graphqls @@ -53,7 +53,7 @@ type UmlStudentSubmission { type UmlStudentSolution { id: UUID! - submittedAt: DateTime! + submittedAt: DateTime diagram: String! feedback: UmlFeedback } From 3d2db8497e8dad966b9d8e4c507a3f9b2089eb24 Mon Sep 17 00:00:00 2001 From: Can Date: Wed, 28 Jan 2026 22:47:54 +0100 Subject: [PATCH 08/33] Remove unnecessary assignmentId --- .../controller/UmlExerciseController.java | 6 +++--- src/main/resources/graphql/service/mutation.graphqls | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java index b00fb12..b559cb5 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java @@ -38,11 +38,11 @@ public UmlExercise updateTutorSolution(final UmlExerciseMutation mutation, } @SchemaMapping(typeName = "UmlExerciseMutation") - public UmlStudentSolution createUmlSolution(@Argument UUID assessmentId, + public UmlStudentSolution createUmlSolution(final UmlExerciseMutation mutation, @Argument UUID studentId, @Argument boolean createFromPrevious) { - log.info("Mutation: createUmlSolution for assessmentId={}, studentId={}", assessmentId, studentId); - return umlExerciseService.createNewSolution(assessmentId, studentId, createFromPrevious); + log.info("Mutation: createUmlSolution for assessmentId={}, studentId={}", mutation.getAssessmentId(), studentId); + return umlExerciseService.createNewSolution(mutation.getAssessmentId(), studentId, createFromPrevious); } @SchemaMapping(typeName = "UmlExerciseMutation") diff --git a/src/main/resources/graphql/service/mutation.graphqls b/src/main/resources/graphql/service/mutation.graphqls index a44176f..663eb25 100644 --- a/src/main/resources/graphql/service/mutation.graphqls +++ b/src/main/resources/graphql/service/mutation.graphqls @@ -108,7 +108,7 @@ type UmlExerciseMutation { """ Creates a new solution attempt for a student. """ - createUmlSolution(assessmentId: UUID!, studentId: UUID!, createFromPrevious: Boolean!): UmlStudentSolution! + createUmlSolution(studentId: UUID!, createFromPrevious: Boolean!): UmlStudentSolution! """ Saves progress or finalizes a student's solution depending on the submit flag. From ece34c0657b316f38ae48205999f0b598ca28b35 Mon Sep 17 00:00:00 2001 From: Can Date: Sat, 31 Jan 2026 13:13:57 +0100 Subject: [PATCH 09/33] Change minimum required role in course to student for uml mutation --- .../assignment_service/service/UmlExerciseService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java index 5ea7854..4f4db96 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java @@ -90,11 +90,11 @@ public UmlExercise createExercise(final UUID courseId, UUID assessmentId, final /** * Initializes a mutation object for a UML exercise and checks permissions. */ - public UmlExerciseMutation mutateUmlExercise(final UUID assessmentId, final LoggedInUser currentUser) { + public UmlExerciseMutation mutateUmlExercise(final UUID assessmentId, final LoggedInUser currentUser) { UmlExerciseEntity entity = exerciseRepository.findByAssessmentIdWithSubmissions(assessmentId) .orElseThrow(() -> new IllegalArgumentException("Exercise not found")); - validateUserHasAccessToCourse(currentUser, LoggedInUser.UserRoleInCourse.ADMINISTRATOR, entity.getCourseId()); + validateUserHasAccessToCourse(currentUser, LoggedInUser.UserRoleInCourse.STUDENT, entity.getCourseId()); return new UmlExerciseMutation(assessmentId); } From cfa18461692b951d0df1b1dfca0dce4b2635673f Mon Sep 17 00:00:00 2001 From: Can Date: Sat, 31 Jan 2026 13:17:23 +0100 Subject: [PATCH 10/33] Change argument name to submit to match schema --- .../assignment_service/controller/UmlExerciseController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java index b559cb5..372fe34 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java @@ -50,9 +50,9 @@ public UmlStudentSolution saveStudentSolution(final UmlExerciseMutation mutation @Argument final UUID studentId, @Argument final String diagram, @Argument final UUID solutionId, - @Argument final boolean submitted) { + @Argument final boolean submit) { return umlExerciseService.saveStudentSolution( - mutation.getAssessmentId(), studentId, diagram, solutionId, submitted); + mutation.getAssessmentId(), studentId, diagram, solutionId, submit); } @QueryMapping From 2a2b6f1992710f1fe5dc69432990d311f3777de8 Mon Sep 17 00:00:00 2001 From: Can Date: Sat, 31 Jan 2026 13:18:06 +0100 Subject: [PATCH 11/33] Change submit to type Boolean --- .../assignment_service/controller/UmlExerciseController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java index 372fe34..e416104 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java @@ -50,7 +50,7 @@ public UmlStudentSolution saveStudentSolution(final UmlExerciseMutation mutation @Argument final UUID studentId, @Argument final String diagram, @Argument final UUID solutionId, - @Argument final boolean submit) { + @Argument final Boolean submit) { return umlExerciseService.saveStudentSolution( mutation.getAssessmentId(), studentId, diagram, solutionId, submit); } From 96f15dcf341dae67fdf3d71677ccd920111ca427 Mon Sep 17 00:00:00 2001 From: Can Date: Sat, 31 Jan 2026 19:21:40 +0100 Subject: [PATCH 12/33] Change submit_at to be nullable --- .../entity/umlExercise/UmlStudentSolutionEntity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSolutionEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSolutionEntity.java index 28f46cf..0ec7a8d 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSolutionEntity.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSolutionEntity.java @@ -22,7 +22,7 @@ public class UmlStudentSolutionEntity implements IWithId { @JoinColumn(name = "submission_id") private UmlStudentSubmissionEntity submission; - @Column(nullable = false) + @Column(nullable = true) private OffsetDateTime submittedAt; @Column(nullable = false, columnDefinition = "TEXT") From 36be1d315e4da6c4ee6801eb55648cbab71eeebb Mon Sep 17 00:00:00 2001 From: Can Date: Mon, 2 Feb 2026 15:23:56 +0100 Subject: [PATCH 13/33] Fix evaluation mutation --- .../controller/UmlExerciseController.java | 6 +++--- .../entity/umlExercise/UmlStudentSolutionEntity.java | 2 +- src/main/resources/graphql/service/mutation.graphqls | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java index e416104..3c3666c 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java @@ -72,11 +72,11 @@ public UmlStudentSolution latestSolution(UmlExercise exercise, @Argument UUID st .orElse(null); } - @MutationMapping + @SchemaMapping(typeName = "UmlExerciseMutation") public UmlStudentSolution evaluateLatestSolution( - @Argument UUID assessmentId, + final UmlExerciseMutation mutation, @Argument UUID studentId, @Argument String semanticModel) { - return umlExerciseService.evaluateLatestSolution(assessmentId, studentId, semanticModel); + return umlExerciseService.evaluateLatestSolution(mutation.getAssessmentId(), studentId, semanticModel); } } diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSolutionEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSolutionEntity.java index 0ec7a8d..24ea07b 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSolutionEntity.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSolutionEntity.java @@ -22,7 +22,7 @@ public class UmlStudentSolutionEntity implements IWithId { @JoinColumn(name = "submission_id") private UmlStudentSubmissionEntity submission; - @Column(nullable = true) + @Column() private OffsetDateTime submittedAt; @Column(nullable = false, columnDefinition = "TEXT") diff --git a/src/main/resources/graphql/service/mutation.graphqls b/src/main/resources/graphql/service/mutation.graphqls index 663eb25..e6345a1 100644 --- a/src/main/resources/graphql/service/mutation.graphqls +++ b/src/main/resources/graphql/service/mutation.graphqls @@ -118,7 +118,7 @@ type UmlExerciseMutation { """ Manually triggers the evaluation for the latest submitted solution of a student for a specific exercise. """ - evaluateLatestSolution(assessmentId: UUID!, studentId: UUID!): UmlStudentSolution! + evaluateLatestSolution(studentId: UUID!, semanticModel: String!): UmlStudentSolution! } input CreateAssignmentInput { From 695b2fe97a98483c0bb70a9cbe4e8685303037fc Mon Sep 17 00:00:00 2001 From: Can Date: Mon, 2 Feb 2026 15:24:15 +0100 Subject: [PATCH 14/33] Add check if creation of new solution is possible --- .../service/UmlExerciseService.java | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java index 4f4db96..d9d066d 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java @@ -126,6 +126,14 @@ public UmlStudentSolution createNewSolution(UUID assessmentId, UUID studentId, b UmlStudentSubmissionEntity submission = getOrCreateSubmission(exercise, studentId); + boolean hasUnsubmitted = submission.getSolutions().stream() + .anyMatch(sol -> sol.getSubmittedAt() == null); + + if (hasUnsubmitted) { + throw new IllegalStateException( + "Cannot create a new attempt: An unsubmitted draft already exists for this student."); + } + String diagram; if (createFromPrevious) { // Find the most recently submitted solution @@ -204,7 +212,7 @@ public UmlStudentSolution saveStudentSolution(final UUID assessmentId, UmlStudentSolutionEntity savedEntity = solutionRepository.save(solutionEntity); if (submit) { - evaluationService.generateFeedbackAsync(savedEntity.getId(), diagram); + //evaluationService.generateFeedbackAsync(savedEntity.getId(), diagram); log.info("Solution submitted and evaluation triggered for id: {}", savedEntity.getId()); } else { log.info("Draft saved for solution id: {}", savedEntity.getId()); @@ -226,19 +234,22 @@ public UmlStudentSolution saveStudentSolution(final UUID assessmentId, * has no submissions for this exercise. * @throws NoSuchElementException If no exercise is found for the given identifier. */ - public List getSolutionsByStudent(final UmlExercise exerciseDto, final UUID studentId) { - UmlExerciseEntity entity = exerciseRepository.findByAssessmentIdWithSubmissions(exerciseDto.getAssessmentId()) - .orElseThrow(() -> new NoSuchElementException("Exercise not found")); - - return entity.getStudentSubmissions().stream() - .filter(sub -> sub.getStudentId().equals(studentId)) - .findFirst() - .map(sub -> sub.getSolutions().stream() - .sorted(Comparator.comparing(UmlStudentSolutionEntity::getSubmittedAt).reversed()) - .map(umlMapper::solutionEntityToDto) - .toList()) - .orElse(Collections.emptyList()); - } + public List getSolutionsByStudent(final UmlExercise exerciseDto, final UUID studentId) { + UmlExerciseEntity entity = exerciseRepository.findByAssessmentIdWithSubmissions(exerciseDto.getAssessmentId()) + .orElseThrow(() -> new NoSuchElementException("Exercise not found")); + + return entity.getStudentSubmissions().stream() + .filter(sub -> sub.getStudentId().equals(studentId)) + .findFirst() + .map(sub -> sub.getSolutions().stream() + .sorted(Comparator.comparing( + UmlStudentSolutionEntity::getSubmittedAt, + Comparator.nullsLast(Comparator.naturalOrder()) + )) + .map(umlMapper::solutionEntityToDto) + .toList()) + .orElse(Collections.emptyList()); + } /** * Triggers a manual evaluation and feedback generation for a student's most recent solution attempt. @@ -265,11 +276,18 @@ public UmlStudentSolution evaluateLatestSolution( .findFirst() .orElseThrow(() -> new IllegalStateException("No submission found for this student.")); + boolean hasUnsubmitted = submission.getSolutions().stream() + .anyMatch(sol -> sol.getSubmittedAt() == null); + + if (hasUnsubmitted) { + throw new IllegalStateException("Cannot evaluate: One or more solutions are not submitted."); + } + UmlStudentSolutionEntity latestSolution = submission.getSolutions().stream() .max(Comparator.comparing(UmlStudentSolutionEntity::getSubmittedAt)) .orElseThrow(() -> new IllegalStateException("Submission exists but contains no solutions.")); - evaluationService.generateFeedback(latestSolution, semanticModel); + evaluationService.generateFeedback(latestSolution, semanticModel, exercise.getTotalPoints()); return umlMapper.solutionEntityToDto(latestSolution); } From aca5f566df0f9d6a3c45081fdc45b7aa2dd34255 Mon Sep 17 00:00:00 2001 From: Can Date: Mon, 2 Feb 2026 15:24:37 +0100 Subject: [PATCH 15/33] Make amount of points randomized for testing purpose --- .../assignment_service/service/UmlEvaluationService.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlEvaluationService.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlEvaluationService.java index 9f33d96..003a0dc 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlEvaluationService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlEvaluationService.java @@ -10,6 +10,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; @Service @Slf4j @@ -49,13 +50,16 @@ public void generateFeedbackAsync(final UUID solutionId, final String diagram) { } @Transactional - public void generateFeedback(final UmlStudentSolutionEntity solution, final String semanticModel) { + public void generateFeedback( + final UmlStudentSolutionEntity solution, final String semanticModel, final int totalPoints) { String feedbackText = "Manual Test feedback text"; + int randomPoints = ThreadLocalRandom.current().nextInt(totalPoints + 1); + UmlFeedbackEntity feedback = UmlFeedbackEntity.builder() .solution(solution) .comment(feedbackText) - .points(8) + .points(randomPoints) .build(); solution.setFeedback(feedback); From 20c7496dc0680282a264995a9ed4db361206c60b Mon Sep 17 00:00:00 2001 From: Can Date: Mon, 16 Feb 2026 16:48:48 +0100 Subject: [PATCH 16/33] Add UML Diagram class --- .../controller/UmlExerciseController.java | 19 +- .../entity/umlExercise/UmlDiagram.java | 26 +++ .../entity/umlExercise/UmlExerciseEntity.java | 4 +- .../umlExercise/UmlStudentSolutionEntity.java | 4 +- .../persistence/mapper/UmlExerciseMapper.java | 41 +++- .../UmlExerciseService.java | 199 ++++++++---------- .../graphql/service/mutation.graphqls | 6 +- .../resources/graphql/service/uml.graphqls | 16 +- 8 files changed, 183 insertions(+), 132 deletions(-) create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlDiagram.java rename src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/{ => uml_assignment}/UmlExerciseService.java (57%) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java index 3c3666c..14dfe63 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java @@ -1,6 +1,6 @@ package de.unistuttgart.iste.meitrex.assignment_service.controller; -import de.unistuttgart.iste.meitrex.assignment_service.service.UmlExerciseService; +import de.unistuttgart.iste.meitrex.assignment_service.service.uml_assignment.UmlExerciseService; import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; import de.unistuttgart.iste.meitrex.generated.dto.*; import lombok.RequiredArgsConstructor; @@ -33,7 +33,7 @@ public UmlExerciseMutation mutateUmlExercise(@Argument final UUID assessmentId, @SchemaMapping(typeName = "UmlExerciseMutation") public UmlExercise updateTutorSolution(final UmlExerciseMutation mutation, - @Argument final String tutorSolution) { + @Argument final UmlDiagramInput tutorSolution) { return umlExerciseService.updateTutorSolution(mutation.getAssessmentId(), tutorSolution); } @@ -47,12 +47,12 @@ public UmlStudentSolution createUmlSolution(final UmlExerciseMutation mutation, @SchemaMapping(typeName = "UmlExerciseMutation") public UmlStudentSolution saveStudentSolution(final UmlExerciseMutation mutation, - @Argument final UUID studentId, - @Argument final String diagram, - @Argument final UUID solutionId, - @Argument final Boolean submit) { + @Argument final UUID studentId, + @Argument final UmlDiagramInput diagram, + @Argument final UUID solutionId, + @Argument final Boolean submit) { return umlExerciseService.saveStudentSolution( - mutation.getAssessmentId(), studentId, diagram, solutionId, submit); + mutation.getAssessmentId(), studentId, diagram, solutionId, submit != null && submit); } @QueryMapping @@ -75,8 +75,7 @@ public UmlStudentSolution latestSolution(UmlExercise exercise, @Argument UUID st @SchemaMapping(typeName = "UmlExerciseMutation") public UmlStudentSolution evaluateLatestSolution( final UmlExerciseMutation mutation, - @Argument UUID studentId, - @Argument String semanticModel) { - return umlExerciseService.evaluateLatestSolution(mutation.getAssessmentId(), studentId, semanticModel); + @Argument UUID studentId) { + return umlExerciseService.evaluateLatestSolution(mutation.getAssessmentId(), studentId); } } diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlDiagram.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlDiagram.java new file mode 100644 index 0000000..30741bb --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlDiagram.java @@ -0,0 +1,26 @@ +package de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.*; + +@Embeddable +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UmlDiagram { + + @Column(name = "diagram", columnDefinition = "TEXT") + private String diagramCode; + + @Column(columnDefinition = "TEXT") + private String semanticModel; + + /** + * Helper to check if the diagram is actually "empty" + */ + public boolean isEmpty() { + return (diagramCode == null || diagramCode.isBlank()) && (semanticModel == null || semanticModel.isBlank()); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlExerciseEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlExerciseEntity.java index bceebba..c0ad407 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlExerciseEntity.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlExerciseEntity.java @@ -30,8 +30,8 @@ public class UmlExerciseEntity implements IWithId { @Column(nullable = false) private boolean showSolution; - @Column(columnDefinition = "TEXT") - private String tutorSolution; + @Embedded + private UmlDiagram tutorSolution; @Column(nullable = false) private int totalPoints; diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSolutionEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSolutionEntity.java index 24ea07b..c495100 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSolutionEntity.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlStudentSolutionEntity.java @@ -25,8 +25,8 @@ public class UmlStudentSolutionEntity implements IWithId { @Column() private OffsetDateTime submittedAt; - @Column(nullable = false, columnDefinition = "TEXT") - private String diagram; + @Embedded + private UmlDiagram diagram; @OneToOne(mappedBy = "solution", cascade = CascadeType.ALL) private UmlFeedbackEntity feedback; diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/mapper/UmlExerciseMapper.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/mapper/UmlExerciseMapper.java index 1e496ad..db9a880 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/mapper/UmlExerciseMapper.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/mapper/UmlExerciseMapper.java @@ -15,13 +15,34 @@ public class UmlExerciseMapper { private final ModelMapper modelMapper; + /** + * Maps the JPA UmlDiagram entity to the GraphQL DTO. + */ + public de.unistuttgart.iste.meitrex.generated.dto.UmlDiagram diagramToDto( + de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlDiagram entity) { + if (entity == null) return null; + return modelMapper.map(entity, de.unistuttgart.iste.meitrex.generated.dto.UmlDiagram.class); + } + + /** + * Maps the GraphQL UmlDiagramInput to the JPA entity. + */ + public de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlDiagram inputToEntity( + UmlDiagramInput input) { + if (input == null) return null; + return modelMapper.map(input, + de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlDiagram.class); + } + public UmlExercise entityToDto(UmlExerciseEntity entity) { - if (entity == null) { - return null; - } + if (entity == null) return null; + UmlExercise dto = modelMapper.map(entity, UmlExercise.class); - // Ensure nested lists are mapped correctly + if (entity.getTutorSolution() != null) { + dto.setTutorSolution(diagramToDto(entity.getTutorSolution())); + } + if (entity.getStudentSubmissions() != null) { dto.setStudentSubmissions(entity.getStudentSubmissions().stream() .map(this::submissionEntityToDto) @@ -42,6 +63,12 @@ public UmlStudentSubmission submissionEntityToDto(UmlStudentSubmissionEntity ent public UmlStudentSolution solutionEntityToDto(UmlStudentSolutionEntity entity) { UmlStudentSolution dto = modelMapper.map(entity, UmlStudentSolution.class); + + // Explicitly map the embedded student diagram + if (entity.getDiagram() != null) { + dto.setDiagram(diagramToDto(entity.getDiagram())); + } + if (entity.getFeedback() != null) { dto.setFeedback(feedbackEntityToDto(entity.getFeedback())); } @@ -53,6 +80,10 @@ public UmlFeedback feedbackEntityToDto(UmlFeedbackEntity entity) { } public UmlExerciseEntity createInputToEntity(CreateUmlExerciseInput input) { - return modelMapper.map(input, UmlExerciseEntity.class); + UmlExerciseEntity entity = modelMapper.map(input, UmlExerciseEntity.class); + if (input.getTutorSolution() != null) { + entity.setTutorSolution(inputToEntity(input.getTutorSolution())); + } + return entity; } } \ No newline at end of file diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlExerciseService.java similarity index 57% rename from src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java rename to src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlExerciseService.java index d9d066d..752c364 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlExerciseService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlExerciseService.java @@ -1,5 +1,6 @@ -package de.unistuttgart.iste.meitrex.assignment_service.service; +package de.unistuttgart.iste.meitrex.assignment_service.service.uml_assignment; +import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlDiagram; import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlExerciseEntity; import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlStudentSolutionEntity; import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlStudentSubmissionEntity; @@ -68,23 +69,22 @@ private UmlStudentSubmissionEntity getOrCreateSubmission(UmlExerciseEntity exerc * Creates a new UML exercise after the assignment was created */ public UmlExercise createExercise(final UUID courseId, UUID assessmentId, final CreateUmlExerciseInput input) { - UmlExerciseEntity entity = UmlExerciseEntity.builder() - .assessmentId(assessmentId) - .courseId(courseId) - .description(input.getDescription()) - .showSolution(input.getShowSolution()) - .totalPoints(input.getTotalPoints()) - .requiredPercentage(input.getRequiredPercentage()) - .tutorSolution(input.getTutorSolution()) - .studentSubmissions(new ArrayList<>()) - .build(); - - if (entity.getTutorSolution() == null) { - entity.setTutorSolution(""); - } + UmlDiagram tutorSolution = input.getTutorSolution() != null + ? umlMapper.inputToEntity(input.getTutorSolution()) + : UmlDiagram.builder().diagramCode("").semanticModel("").build(); - UmlExerciseEntity savedEntity = exerciseRepository.save(entity); - return umlMapper.entityToDto(savedEntity); + UmlExerciseEntity entity = UmlExerciseEntity.builder() + .assessmentId(assessmentId) + .courseId(courseId) + .description(input.getDescription()) + .showSolution(input.getShowSolution()) + .totalPoints(input.getTotalPoints()) + .requiredPercentage(input.getRequiredPercentage()) + .tutorSolution(tutorSolution) + .studentSubmissions(new ArrayList<>()) + .build(); + + return umlMapper.entityToDto(exerciseRepository.save(entity)); } /** @@ -102,13 +102,12 @@ public UmlExerciseMutation mutateUmlExercise(final UUID assessmentId, final Log /** * Updates the reference solution for a task. */ - public UmlExercise updateTutorSolution(final UUID assessmentId, final String tutorSolution) { + public UmlExercise updateTutorSolution(final UUID assessmentId, final UmlDiagramInput tutorSolution) { UmlExerciseEntity entity = exerciseRepository.findByAssessmentIdWithSubmissions(assessmentId) - .orElseThrow(() -> new IllegalArgumentException("Exercise not found")); + .orElseThrow(() -> new IllegalArgumentException("Exercise not found")); - entity.setTutorSolution(tutorSolution); - UmlExerciseEntity savedEntity = exerciseRepository.save(entity); - return umlMapper.entityToDto(savedEntity); + entity.setTutorSolution(umlMapper.inputToEntity(tutorSolution)); + return umlMapper.entityToDto(exerciseRepository.save(entity)); } /** @@ -127,36 +126,30 @@ public UmlStudentSolution createNewSolution(UUID assessmentId, UUID studentId, b UmlStudentSubmissionEntity submission = getOrCreateSubmission(exercise, studentId); boolean hasUnsubmitted = submission.getSolutions().stream() - .anyMatch(sol -> sol.getSubmittedAt() == null); + .anyMatch(sol -> sol.getSubmittedAt() == null); if (hasUnsubmitted) { - throw new IllegalStateException( - "Cannot create a new attempt: An unsubmitted draft already exists for this student."); + throw new IllegalStateException("An unsubmitted draft already exists."); } - String diagram; + UmlDiagram diagram; if (createFromPrevious) { // Find the most recently submitted solution diagram = submission.getSolutions().stream() .filter(s -> s.getSubmittedAt() != null) .max(Comparator.comparing(UmlStudentSolutionEntity::getSubmittedAt)) .map(UmlStudentSolutionEntity::getDiagram) - .orElseThrow(() -> new IllegalStateException( - "Cannot create from previous: No submitted solutions found for this student.")); + .orElseThrow(() -> new IllegalStateException("No previous submission found.")); } else { - diagram = DEFAULT_START_DIAGRAM; + diagram = UmlDiagram.builder().diagramCode(DEFAULT_START_DIAGRAM).semanticModel("").build(); } UmlStudentSolutionEntity newSolution = UmlStudentSolutionEntity.builder() .submission(submission) .diagram(diagram) - .submittedAt(null) .build(); - UmlStudentSolutionEntity saved = solutionRepository.save(newSolution); - submission.getSolutions().add(saved); - - return umlMapper.solutionEntityToDto(saved); + return umlMapper.solutionEntityToDto(solutionRepository.save(newSolution)); } /** @@ -164,60 +157,49 @@ public UmlStudentSolution createNewSolution(UUID assessmentId, UUID studentId, b * Updates an existing draft if a solutionId is provided or an unsubmitted solution exists. * Creates a new solution record if no unsubmitted draft is found. */ - public UmlStudentSolution saveStudentSolution(final UUID assessmentId, - final UUID studentId, - final String diagram, - @Nullable final UUID solutionId, - final boolean submit) { - log.info("saveStudentSolution triggered: assessmentId={}, studentId={}, submit={}", - assessmentId, studentId, submit); - + @Transactional + public UmlStudentSolution saveStudentSolution( + final UUID assessmentId, + final UUID studentId, + final UmlDiagramInput diagramInput, + @Nullable final UUID solutionId, + final boolean submit + ) { UmlExerciseEntity exercise = exerciseRepository.findByAssessmentIdWithSubmissions(assessmentId) - .orElseThrow(() -> new IllegalArgumentException("Exercise not found for assessmentId: " + assessmentId)); + .orElseThrow(() -> new IllegalArgumentException("Exercise not found.")); UmlStudentSubmissionEntity submission = getOrCreateSubmission(exercise, studentId); - UmlStudentSolutionEntity solutionEntity; if (solutionId != null) { - // If a specific solutionId is requested, verify it exists and is still a draft solutionEntity = solutionRepository.findById(solutionId) - .orElseThrow(() -> new IllegalArgumentException("Solution not found for id: " + solutionId)); + .orElseThrow(() -> new IllegalArgumentException("Solution not found.")); if (solutionEntity.getSubmittedAt() != null) { - throw new IllegalStateException("Cannot modify a solution that has already been submitted."); + throw new IllegalStateException("Solution already submitted."); } } else { - // Otherwise, look for an existing unsubmitted draft in the container solutionEntity = submission.getSolutions().stream() .filter(s -> s.getSubmittedAt() == null) .findFirst() .orElseGet(() -> { - // No current draft exists; create a new solution record UmlStudentSolutionEntity newSolution = UmlStudentSolutionEntity.builder() .submission(submission) - .diagram(diagram) + // Initialize with provided diagram + .diagram(umlMapper.inputToEntity(diagramInput)) .build(); submission.getSolutions().add(newSolution); return newSolution; }); } - solutionEntity.setDiagram(diagram); + solutionEntity.setDiagram(umlMapper.inputToEntity(diagramInput)); if (submit) { solutionEntity.setSubmittedAt(OffsetDateTime.now()); } UmlStudentSolutionEntity savedEntity = solutionRepository.save(solutionEntity); - - if (submit) { - //evaluationService.generateFeedbackAsync(savedEntity.getId(), diagram); - log.info("Solution submitted and evaluation triggered for id: {}", savedEntity.getId()); - } else { - log.info("Draft saved for solution id: {}", savedEntity.getId()); - } - return umlMapper.solutionEntityToDto(savedEntity); } @@ -236,59 +218,62 @@ public UmlStudentSolution saveStudentSolution(final UUID assessmentId, */ public List getSolutionsByStudent(final UmlExercise exerciseDto, final UUID studentId) { UmlExerciseEntity entity = exerciseRepository.findByAssessmentIdWithSubmissions(exerciseDto.getAssessmentId()) - .orElseThrow(() -> new NoSuchElementException("Exercise not found")); + .orElseThrow(() -> new NoSuchElementException("Exercise not found")); return entity.getStudentSubmissions().stream() - .filter(sub -> sub.getStudentId().equals(studentId)) - .findFirst() - .map(sub -> sub.getSolutions().stream() - .sorted(Comparator.comparing( - UmlStudentSolutionEntity::getSubmittedAt, - Comparator.nullsLast(Comparator.naturalOrder()) - )) - .map(umlMapper::solutionEntityToDto) - .toList()) - .orElse(Collections.emptyList()); + .filter(sub -> sub.getStudentId().equals(studentId)) + .findFirst() + .map(sub -> sub.getSolutions().stream() + .sorted(Comparator.comparing( + UmlStudentSolutionEntity::getSubmittedAt, + Comparator.nullsLast(Comparator.naturalOrder()) + )) + .map(umlMapper::solutionEntityToDto) + .toList()) + .orElse(Collections.emptyList()); } - /** - * Triggers a manual evaluation and feedback generation for a student's most recent solution attempt. + /** + * Triggers an automated evaluation and feedback generation for a student's most recent submitted solution. *

- * This method identifies the latest solution by finding the student's submission container and - * selecting the solution with the most recent 'submittedAt' timestamp. It then invokes the - * evaluation service to attach feedback directly to that solution entity. + * This method retrieves the student's latest submitted solution and compares its stored semantic model + * against the tutor's reference model using a two-step LLM evaluation process. The resulting + * feedback and points are then persisted directly to the solution entity. * - * @param assessmentId The external UUID of the assessment/exercise. - * @param studentId The UUID of the student whose work is being evaluated. - * @param semanticModel Semantic model data used for evaluation to compare against tutor solution. - * @return The updated {@link UmlStudentSolution} DTO containing the newly generated feedback. - * @throws NoSuchElementException If the exercise is not found. - * @throws IllegalStateException If no submission or no solutions are found for the student, - * meaning there is nothing to evaluate. + * @param assessmentId The external UUID of the UML assessment/exercise. + * @param studentId The UUID of the student whose submission is being evaluated. + * @return The updated {@link UmlStudentSolution} DTO, now containing the generated feedback and points. + * @throws NoSuchElementException If the exercise or submission container cannot be found. + * @throws IllegalStateException If no submitted solutions exist, or if the tutor has not + * yet provided a reference semantic model for comparison. */ - public UmlStudentSolution evaluateLatestSolution( - final UUID assessmentId, final UUID studentId, final String semanticModel) { - UmlExerciseEntity exercise = exerciseRepository.findByAssessmentIdWithSubmissions(assessmentId) - .orElseThrow(() -> new NoSuchElementException("Exercise not found")); - - UmlStudentSubmissionEntity submission = exercise.getStudentSubmissions().stream() - .filter(sub -> sub.getStudentId().equals(studentId)) - .findFirst() - .orElseThrow(() -> new IllegalStateException("No submission found for this student.")); - - boolean hasUnsubmitted = submission.getSolutions().stream() - .anyMatch(sol -> sol.getSubmittedAt() == null); - - if (hasUnsubmitted) { - throw new IllegalStateException("Cannot evaluate: One or more solutions are not submitted."); - } - - UmlStudentSolutionEntity latestSolution = submission.getSolutions().stream() - .max(Comparator.comparing(UmlStudentSolutionEntity::getSubmittedAt)) - .orElseThrow(() -> new IllegalStateException("Submission exists but contains no solutions.")); - - evaluationService.generateFeedback(latestSolution, semanticModel, exercise.getTotalPoints()); - - return umlMapper.solutionEntityToDto(latestSolution); - } + @Transactional + public UmlStudentSolution evaluateLatestSolution(final UUID assessmentId, final UUID studentId) { + UmlExerciseEntity exercise = exerciseRepository.findByAssessmentIdWithSubmissions(assessmentId) + .orElseThrow(() -> new NoSuchElementException("Exercise not found")); + + UmlStudentSubmissionEntity submission = exercise.getStudentSubmissions().stream() + .filter(sub -> sub.getStudentId().equals(studentId)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No submission found.")); + + UmlStudentSolutionEntity latestSolution = submission.getSolutions().stream() + .filter(sol -> sol.getSubmittedAt() != null) + .max(Comparator.comparing(UmlStudentSolutionEntity::getSubmittedAt)) + .orElseThrow(() -> new IllegalStateException("No submitted solutions found.")); + + // Check if tutor solution exists + if (exercise.getTutorSolution() == null || exercise.getTutorSolution().getSemanticModel() == null) { + throw new IllegalStateException("Tutor solution is missing semantic model for evaluation."); + } + + evaluationService.generateFeedback( + latestSolution, + exercise.getTutorSolution().getSemanticModel(), + "", // TODO: Add grading rules + exercise.getTotalPoints() + ); + + return umlMapper.solutionEntityToDto(latestSolution); + } } diff --git a/src/main/resources/graphql/service/mutation.graphqls b/src/main/resources/graphql/service/mutation.graphqls index e6345a1..7ad33a8 100644 --- a/src/main/resources/graphql/service/mutation.graphqls +++ b/src/main/resources/graphql/service/mutation.graphqls @@ -103,7 +103,7 @@ type UmlExerciseMutation { """ Creates a new UML exercise. Throws an error if the assignment does not exist. """ - updateTutorSolution(tutorSolution: String!): UmlExercise! + updateTutorSolution(tutorSolution: UmlDiagramInput!): UmlExercise! """ Creates a new solution attempt for a student. @@ -113,12 +113,12 @@ type UmlExerciseMutation { """ Saves progress or finalizes a student's solution depending on the submit flag. """ - saveStudentSolution(studentId: UUID!, diagram: String!, solutionId: UUID, submit: Boolean!): UmlStudentSolution + saveStudentSolution(studentId: UUID!, diagram: UmlDiagramInput!, solutionId: UUID, submit: Boolean!): UmlStudentSolution """ Manually triggers the evaluation for the latest submitted solution of a student for a specific exercise. """ - evaluateLatestSolution(studentId: UUID!, semanticModel: String!): UmlStudentSolution! + evaluateLatestSolution(studentId: UUID!): UmlStudentSolution! } input CreateAssignmentInput { diff --git a/src/main/resources/graphql/service/uml.graphqls b/src/main/resources/graphql/service/uml.graphqls index 5354bf3..dda29d2 100644 --- a/src/main/resources/graphql/service/uml.graphqls +++ b/src/main/resources/graphql/service/uml.graphqls @@ -20,7 +20,7 @@ type UmlExercise { HyLiMo code for the 'optimal' solution, created by the tutor. This gets used to evaluate against the submission of a student to create feedback """ - tutorSolution: String! + tutorSolution: UmlDiagram """ The total amount of points that can be achieved """ @@ -45,6 +45,11 @@ type UmlExercise { solutionsByStudent(studentId: UUID!): [UmlStudentSolution!]! } +type UmlDiagram { + diagramCode: String! + semanticModel: String +} + type UmlStudentSubmission { id: UUID! studentId: UUID! @@ -54,10 +59,15 @@ type UmlStudentSubmission { type UmlStudentSolution { id: UUID! submittedAt: DateTime - diagram: String! + diagram: UmlDiagram! feedback: UmlFeedback } +input UmlDiagramInput { + diagramCode: String! + semanticModel: String +} + type UmlFeedback { id: UUID! comment: String! @@ -70,5 +80,5 @@ input CreateUmlExerciseInput { totalPoints: Int! @Positive requiredPercentage: Float! = 0.5 @Range(min : 0, max : 1) courseId: UUID! - tutorSolution: String + tutorSolution: UmlDiagramInput } \ No newline at end of file From 1a846a8626df70c4d74a94887bf12f6c0a8c3ece Mon Sep 17 00:00:00 2001 From: Can Date: Mon, 16 Feb 2026 16:49:55 +0100 Subject: [PATCH 17/33] Create evaluation with LLM via the OllamaClient in meitrex-common --- build.gradle | 4 +- .../AssignmentServiceApplication.java | 2 - .../config/OllamaClientConfiguration.java | 40 +++++++ .../service/UmlEvaluationService.java | 67 ----------- .../uml_assignment/UmlAnalysisResponse.java | 20 ++++ .../uml_assignment/UmlEvaluationService.java | 111 ++++++++++++++++++ .../uml_assignment/UmlFeedbackResponse.java | 6 + src/main/resources/application-dev.properties | 7 +- .../resources/application-prod.properties | 5 + src/main/resources/application.properties | 5 + .../prompt_templates/uml_analysis.md | 45 +++++++ .../resources/prompt_templates/uml_grading.md | 35 ++++++ 12 files changed, 275 insertions(+), 72 deletions(-) create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/assignment_service/config/OllamaClientConfiguration.java delete mode 100644 src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlEvaluationService.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlAnalysisResponse.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlEvaluationService.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlFeedbackResponse.java create mode 100644 src/main/resources/prompt_templates/uml_analysis.md create mode 100644 src/main/resources/prompt_templates/uml_grading.md diff --git a/build.gradle b/build.gradle index a95e764..5d5477c 100644 --- a/build.gradle +++ b/build.gradle @@ -111,7 +111,7 @@ repositories { } dependencies { - implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.12' + implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.5.3' implementation 'de.unistuttgart.iste.meitrex:content_service:1.5.0rc7' implementation 'de.unistuttgart.iste.meitrex:course_service:1.1.0rc2' implementation 'de.unistuttgart.iste.meitrex:user_service:1.0.0rc1' @@ -134,7 +134,7 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' annotationProcessor 'org.projectlombok:lombok' - testImplementation 'de.unistuttgart.iste.meitrex:meitrex-common-test:1.4.12' + testImplementation 'de.unistuttgart.iste.meitrex:meitrex-common-test:1.5.0' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework:spring-webflux' testImplementation 'org.springframework.graphql:spring-graphql-test' diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/AssignmentServiceApplication.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/AssignmentServiceApplication.java index b45bab6..e989f2c 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/AssignmentServiceApplication.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/AssignmentServiceApplication.java @@ -3,7 +3,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.scheduling.annotation.EnableAsync; import java.util.Arrays; @@ -13,7 +12,6 @@ */ @SpringBootApplication @Slf4j -@EnableAsync public class AssignmentServiceApplication { public static void main(String[] args) { diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/config/OllamaClientConfiguration.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/config/OllamaClientConfiguration.java new file mode 100644 index 0000000..852fd2c --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/config/OllamaClientConfiguration.java @@ -0,0 +1,40 @@ +package de.unistuttgart.iste.meitrex.assignment_service.config; + +import de.unistuttgart.iste.meitrex.common.ollama.OllamaClient; +import de.unistuttgart.iste.meitrex.common.config.OllamaConfig; +import de.unistuttgart.iste.meitrex.common.service.JsonSchemaGeneratorService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.net.http.HttpClient; +import java.time.Duration; + +@Configuration +public class OllamaClientConfiguration { + + @Bean + public OllamaConfig ollamaConfig() { + return new OllamaConfig(); + } + + @Bean + public HttpClient ollamaHttpClient() { + return HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(30)) + .build(); + } + + @Bean + public JsonSchemaGeneratorService jsonSchemaGeneratorService() { + return new JsonSchemaGeneratorService(); + } + + @Bean + public OllamaClient ollamaClient(OllamaConfig config, + JsonSchemaGeneratorService schemaService, + ObjectMapper objectMapper, + HttpClient ollamaHttpClient) { + return new OllamaClient(config, schemaService, objectMapper, ollamaHttpClient); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlEvaluationService.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlEvaluationService.java deleted file mode 100644 index 003a0dc..0000000 --- a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/UmlEvaluationService.java +++ /dev/null @@ -1,67 +0,0 @@ -package de.unistuttgart.iste.meitrex.assignment_service.service; - -import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlFeedbackEntity; -import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlStudentSolutionEntity; -import de.unistuttgart.iste.meitrex.assignment_service.persistence.repository.UmlStudentSolutionRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; -import java.util.concurrent.ThreadLocalRandom; - -@Service -@Slf4j -@RequiredArgsConstructor -public class UmlEvaluationService { - - private final UmlStudentSolutionRepository solutionRepository; - - @Async - @Transactional - public void generateFeedbackAsync(final UUID solutionId, final String diagram) { - log.info("Starting background feedback generation for solution {}", solutionId); - - try { - Thread.sleep(20000); - String feedbackText = "Test feedback text"; - - UmlStudentSolutionEntity solution = solutionRepository.findById(solutionId) - .orElseThrow(); - - UmlFeedbackEntity feedback = UmlFeedbackEntity.builder() - .solution(solution) - .comment(feedbackText) - .points(8) - .build(); - - solution.setFeedback(feedback); - - log.info("Feedback successfully saved for solution {}", solutionId); - - } catch (InterruptedException e) { - log.error("Async feedback generation interrupted", e); - Thread.currentThread().interrupt(); - } catch (Exception e) { - log.error("Error during async feedback generation", e); - } - } - - @Transactional - public void generateFeedback( - final UmlStudentSolutionEntity solution, final String semanticModel, final int totalPoints) { - String feedbackText = "Manual Test feedback text"; - - int randomPoints = ThreadLocalRandom.current().nextInt(totalPoints + 1); - - UmlFeedbackEntity feedback = UmlFeedbackEntity.builder() - .solution(solution) - .comment(feedbackText) - .points(randomPoints) - .build(); - - solution.setFeedback(feedback); - } -} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlAnalysisResponse.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlAnalysisResponse.java new file mode 100644 index 0000000..8174bf1 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlAnalysisResponse.java @@ -0,0 +1,20 @@ +package de.unistuttgart.iste.meitrex.assignment_service.service.uml_assignment; + +import java.util.List; + +/** + * Result of the first LLM step: purely analytical comparison. + */ +public record UmlAnalysisResponse( + List semanticErrors, + List missingElements, + List correctElements, + boolean isSemanticallyValid, + String analysisSummary +) { + public UmlAnalysisResponse { + correctElements = correctElements == null ? List.of() : correctElements; + semanticErrors = semanticErrors == null ? List.of() : semanticErrors; + missingElements = missingElements == null ? List.of() : missingElements; + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlEvaluationService.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlEvaluationService.java new file mode 100644 index 0000000..9909154 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlEvaluationService.java @@ -0,0 +1,111 @@ +package de.unistuttgart.iste.meitrex.assignment_service.service.uml_assignment; + +import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlFeedbackEntity; +import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlStudentSolutionEntity; +import de.unistuttgart.iste.meitrex.assignment_service.persistence.repository.UmlStudentSolutionRepository; +import de.unistuttgart.iste.meitrex.common.ollama.OllamaClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@Service +@Slf4j +@RequiredArgsConstructor +public class UmlEvaluationService { + + private final OllamaClient ollamaClient; + + private static final String TEMPLATE_ANALYSIS = "uml_analysis.md"; + private static final String TEMPLATE_GRADING = "uml_grading.md"; + + + @Transactional + public void generateFeedback( + final UmlStudentSolutionEntity solution, + final String tutorModel, + final String gradingRules, + final int totalPoints + ) { + log.info("Starting automated feedback generation for solution ID: {}", solution.getId()); + final String studentModel = solution.getDiagram().getSemanticModel(); + UmlAnalysisResponse analysis = performAnalysis(studentModel, tutorModel); + + log.info("Analysis completed. Valid: {}, Summary: {}", + analysis.isSemanticallyValid(), analysis.analysisSummary()); + + UmlFeedbackResponse grading = performGrading(analysis, gradingRules, totalPoints); + + UmlFeedbackEntity feedbackEntity = UmlFeedbackEntity.builder() + .solution(solution) + .comment(grading.feedbackText()) + .points(grading.points()) + .build(); + + solution.setFeedback(feedbackEntity); + } + + private UmlAnalysisResponse performAnalysis(String studentModel, String tutorModel) { + Map args = Map.of( + "studentModel", studentModel, + "tutorModel", tutorModel + ); + + UmlAnalysisResponse fallback = new UmlAnalysisResponse( + Collections.emptyList(), // correctElements + Collections.emptyList(), // semanticErrors + Collections.emptyList(), // missingElements + false, + "Analysis failed." + ); + + UmlAnalysisResponse response = ollamaClient.startQuery( + UmlAnalysisResponse.class, TEMPLATE_ANALYSIS, args, fallback, null); + + log.info("Detailed Analysis Findings:\n - Correct: {}\n - Errors: {}\n - Missing: {}", + response.correctElements(), response.semanticErrors(), response.missingElements()); + + return response; + } + + private UmlFeedbackResponse performGrading(UmlAnalysisResponse analysis, String rules, int maxPoints) { + String effectiveRules = (rules != null && !rules.isBlank()) ? rules : "Standard UML grading."; + + Map args = Map.of( + "maxPoints", String.valueOf(maxPoints), + "gradingRules", effectiveRules, + "isValid", String.valueOf(analysis.isSemanticallyValid()), + "correctElements", formatListForPrompt(analysis.correctElements()), + "semanticErrors", formatListForPrompt(analysis.semanticErrors()), + "missingElements", formatListForPrompt(analysis.missingElements()) + ); + + UmlFeedbackResponse fallback = new UmlFeedbackResponse( + "Grading unavailable. Please review manually.", + 0 + ); + + return ollamaClient.startQuery(UmlFeedbackResponse.class, TEMPLATE_GRADING, args, fallback, null); + } + + /** + * Safely formats a list of strings into a single semicolon-separated string. + *

+ * If the list is null or empty, it returns a default "None identified." string + * to ensure the LLM receives explicit context rather than an empty field. + *

+ * + * @param list The list of strings to format. + * @return A semicolon-separated string of the list items, or "None identified.". + */ + private String formatListForPrompt(List list) { + if (list == null || list.isEmpty()) { + return "None identified."; + } + return String.join("; ", list); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlFeedbackResponse.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlFeedbackResponse.java new file mode 100644 index 0000000..a4e814a --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlFeedbackResponse.java @@ -0,0 +1,6 @@ +package de.unistuttgart.iste.meitrex.assignment_service.service.uml_assignment; + +public record UmlFeedbackResponse( + String feedbackText, + int points +) {} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 72f6c7f..3fca183 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -11,4 +11,9 @@ spring.jpa.hibernate.ddl-auto=create # URLs for course, content and user service course_service.url=http://localhost:2001/graphql content_service.url=http://localhost:4001/graphql -user_service.url=http://localhost:5001/graphql \ No newline at end of file +user_service.url=http://localhost:5001/graphql + +ollama.url=http://localhost:11434 +ollama.model=llama3:8b-instruct-q4_0 +ollama.endpoint=api/generate +ollama.promptFolder=prompt_templates \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 10fdb11..ee4ea2b 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -11,3 +11,8 @@ spring.jpa.hibernate.ddl-auto=update course_service.url=${COURSE_SERVICE_URL:http://app-course:2001/graphql} content_service.url=${CONTENT_SERVICE_URL:http://app-content:4001/graphql} user_service.url=${USER_SERVICE_URL:http://app-user:5001/graphql} + +ollama.url=${OLLAMA_URL:http://129.69.217.248:11434} +ollama.endpoint=${OLLAMA_ENDPOINT:api/generate} +ollama.model=${OLLAMA_MODEL:llama3:8b-instruct-q4_0} +ollama.promptFolder=${OLLAMA_PROMPT_FOLDER:prompt_templates} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index cbe9d36..6b9075c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -17,6 +17,11 @@ server.port=1101 dapr.appId=assignment_service dapr.port=1100 +ollama.url=http://localhost:11434 +ollama.model=llama3:8b-instruct-q4_0 +ollama.endpoint=api/generate +ollama.promptFolder=prompt_templates + # URL base path for external system like TMS external_system.url=http://localhost:1234/ external_system.authToken="" \ No newline at end of file diff --git a/src/main/resources/prompt_templates/uml_analysis.md b/src/main/resources/prompt_templates/uml_analysis.md new file mode 100644 index 0000000..cf18450 --- /dev/null +++ b/src/main/resources/prompt_templates/uml_analysis.md @@ -0,0 +1,45 @@ +### ROLE +You are a precision-focused Software Architect. Compare the **Student Model** to the **Reference Solution**. + +### INSPECTION CHECKLIST +1. **Classifiers:** Classes, Interfaces, Abstract Classes, and Enums. +2. **Features:** - Attributes (Type, Visibility, Static/Instance). + - Methods (Parameters, Return Type, Visibility). + - **Enum Literals:** Every single value within an Enum must match. +3. **Relationships (The Logic):** + - Associations, Aggregations, Compositions. + - Generalization (Inheritance) and Realization (Interface implementation). + - **Multiplicity:** (e.g., 1..*, 0..1). + - Role Names and Navigability. +4. **Constraints:** Notes, Stereotypes (e.g., <>), and access modifiers. + +### CATEGORIZATION RULES +- **missingElements:** Use for anything in the Reference not found in the Student + (e.g., "Missing literal 'PENDING' in Enum 'OrderState'", "Missing multiplicity '1..*' on Library->Book association"). +- **semanticErrors:** Use for items that exist but are structurally wrong +- (e.g., "Used Association instead of Composition", "Incorrect return type for getID()" or wrong relationship types). + +### DATA TO ANALYZE +- **Reference Solution:** +--- +{{tutorModel}} +--- +- **Student Submission:** +--- +{{studentModel}} +--- + +### OUTPUT REQUIREMENTS +- Every error mentioned in the 'analysisSummary' MUST be present in the arrays. +- If an Enum literal is missing, specify which one. +- Output ONLY valid JSON. + +**Output Format (JSON):** +{ +"correctElements": ["List of things done well (e.g., 'Correct inheritance hierarchy', 'Valid alternative for User class')"], +"semanticErrors": ["List of logic errors"], +"missingElements": ["List every specific missing class, attribute, or enum value here"], +"isSemanticallyValid": , +"analysisSummary": "Brief summary of the submission quality." +} + diff --git a/src/main/resources/prompt_templates/uml_grading.md b/src/main/resources/prompt_templates/uml_grading.md new file mode 100644 index 0000000..1fc5001 --- /dev/null +++ b/src/main/resources/prompt_templates/uml_grading.md @@ -0,0 +1,35 @@ +### ROLE +You are a supportive and expert Software Architecture Tutor. Your goal is to transform technical analysis into encouraging, constructive, and fair feedback for a student. + +### PEDAGOGICAL GUIDELINES +1. **The "Sandwich" Feedback Method:** - Start with specific praise (from 'Things Done Well'). + - Address the technical gaps (from 'Logic Errors' and 'Missing Items') as "opportunities for improvement." + - End with a motivating closing statement. +2. **Contextual Impact:** Explain *why* an error matters. + *Example:* "Without the Composition between 'Order' and 'OrderItem', deleting an Order might leave orphaned items in the database." +3. **Tone:** Academic yet empathetic. Avoid being overly critical. + +### GRADING LOGIC +- **Total Possible:** {{maxPoints}} points. +- **Reference Rubric:** {{gradingRules}} +- **Semantic Constraint:** If `isValid` is "true", the student HAS met the core requirements. Score them at 80% or higher unless missing items are critical. +- **Deduction Policy:** A missing class is a "Major" error; a missing attribute/literal is a "Minor" error. Be consistent with deductions. + +### DATA FOR REPORT +- **Student Status:** (Semantically Valid: {{isValid}}) +- **Things Done Well:** {{correctElements}} +- **Logic Errors:** {{semanticErrors}} +- **Missing Items:** {{missingElements}} + +### TECHNICAL CONSTRAINTS +- **Output Format:** STRICT HTML only. +- **Allowed Tags:** ``, ``, `

`, `
`, `

    `, `
  • `. +- **Prohibited:** No Markdown (no #, *, or `), no `