diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..3775d3c
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,5 @@
+# Copy this file to .env and fill in real values.
+# Never commit .env to git.
+
+# OLLAMA API key used by Spring property: ollama.apiKey
+OLLAMA_API_KEY=
diff --git a/.gitignore b/.gitignore
index 891e458..ba73287 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,8 @@ out/
.vscode/
node_modules/
+
+# Local environment secrets
+.env
+.env.local
+.env.*.local
diff --git a/README.md b/README.md
index aa7072d..dc7cf78 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@ It handles assignment creation, grading, and publishing grading results. Externa
| spring.datasource.url | PostgreSQL database URL | jdbc:postgresql://localhost:1132/assignment_service | jdbc:postgresql://assignment-service-db-postgresql:5432/assignment-service |
| spring.datasource.username | Database usernam | root | gits |
| spring.datasource.password | Database password | root | *secret* |
+| OLLAMA_API_KEY | API key for LLM requests (ollama.apiKey) | set via local .env file | set via deployment secret/env var |
| DAPR_HTTP_PORT | Dapr HTTP Port* | 1100 | 3500 |
| server.port | Port on which the application runs | 1101 | 1101 |
| course_service.url | URL for course service GraphQL | http://localhost:2001/graphql | http://localhost:3500/v1.0/invoke/course-service/method/graphql |
@@ -36,6 +37,21 @@ It handles assignment creation, grading, and publishing grading results. Externa
| logging.level.root | Logging level for root logger | DEBUG | - |
| DAPR_GRPC_PORT | Dapr gRPC Port | - | 50001 |
+## Local secrets with .env
+
+For local development, keep secrets in a root-level `.env` file. The application imports this file via Spring configuration.
+
+1. Create `.env` in the repository root.
+2. Add your key:
+
+```properties
+OLLAMA_API_KEY=your_real_key_here
+```
+
+3. Do not commit `.env` (already ignored via `.gitignore`).
+
+Use `.env.example` as the template for required keys.
+
## API description
The GraphQL API is described in the [api.md file](api.md).
diff --git a/build.gradle b/build.gradle
index a95e764..a5cc1d6 100644
--- a/build.gradle
+++ b/build.gradle
@@ -111,8 +111,8 @@ repositories {
}
dependencies {
- implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.12'
- implementation 'de.unistuttgart.iste.meitrex:content_service:1.5.0rc7'
+ implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.6.0'
+ implementation 'de.unistuttgart.iste.meitrex:content_service:1.6.1'
implementation 'de.unistuttgart.iste.meitrex:course_service:1.1.0rc2'
implementation 'de.unistuttgart.iste.meitrex:user_service:1.0.0rc1'
implementation 'com.google.code.gson:gson:2.13.1'
@@ -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.6.0'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework:spring-webflux'
testImplementation 'org.springframework.graphql:spring-graphql-test'
diff --git a/docker-compose.yml b/docker-compose.yml
index 69df564..5a04b57 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -27,19 +27,26 @@ services:
- "1101:1101"
depends_on:
- database
+ env_file:
+ - ./../assignment_service/.env
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://database:5432/assignment_service
SPRING_DATASOURCE_USERNAME: root
SPRING_DATASOURCE_PASSWORD: root
dapr-assignment:
image: "daprio/daprd"
- command: [
- "./daprd",
- "--app-id", "assignment_service",
- "--app-port", "1101",
- "--dapr-http-port", "1100",
- "--resources-path", "./components"
- ]
+ command:
+ [
+ "./daprd",
+ "--app-id",
+ "assignment_service",
+ "--app-port",
+ "1101",
+ "--dapr-http-port",
+ "1100",
+ "--resources-path",
+ "./components",
+ ]
volumes:
- "./../assignment_service/components/:/components" # Mount our components folder for the runtime to use. The mounted location must match the --resources-path argument.
depends_on:
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..dde379c 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,8 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.EnableAsync;
import java.util.Arrays;
@@ -11,6 +13,8 @@
*
*/
@SpringBootApplication
+@EnableScheduling
+@EnableAsync
@Slf4j
public class AssignmentServiceApplication {
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/controller/UmlExerciseController.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java
new file mode 100644
index 0000000..7779746
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/controller/UmlExerciseController.java
@@ -0,0 +1,98 @@
+package de.unistuttgart.iste.meitrex.assignment_service.controller;
+
+import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlEvaluationJobStatus;
+import de.unistuttgart.iste.meitrex.assignment_service.service.uml_assignment.UmlExerciseService;
+import de.unistuttgart.iste.meitrex.assignment_service.service.uml_assignment.UmlEvaluationQueueService;
+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.List;
+import java.util.UUID;
+
+@Slf4j
+@Controller
+@RequiredArgsConstructor
+public class UmlExerciseController {
+
+ private final UmlExerciseService umlExerciseService;
+ private final UmlEvaluationQueueService umlEvaluationQueueService;
+
+ @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 UmlDiagramInput tutorSolution) {
+ return umlExerciseService.updateTutorSolution(mutation.getAssessmentId(), tutorSolution);
+ }
+
+ @SchemaMapping(typeName = "UmlExerciseMutation")
+ public UmlExercise updateUmlExercise(final UmlExerciseMutation mutation,
+ @Argument final UpdateUmlExerciseInput input) {
+ return umlExerciseService.updateUmlExercise(mutation.getAssessmentId(), input);
+ }
+
+ @SchemaMapping(typeName = "UmlExerciseMutation")
+ public UmlStudentSolution createUmlSolution(final UmlExerciseMutation mutation,
+ @Argument UUID studentId,
+ @Argument boolean createFromPrevious) {
+ log.info("Mutation: createUmlSolution for assessmentId={}, studentId={}", mutation.getAssessmentId(), studentId);
+ return umlExerciseService.createNewSolution(mutation.getAssessmentId(), studentId, createFromPrevious);
+ }
+
+ @SchemaMapping(typeName = "UmlExerciseMutation")
+ public UmlStudentSolution saveStudentSolution(final UmlExerciseMutation mutation,
+ @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 != null && submit);
+ }
+
+ @QueryMapping
+ 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);
+ }
+
+ @SchemaMapping(typeName = "UmlExerciseMutation")
+ public UmlStudentSolution evaluateLatestSolution(
+ final UmlExerciseMutation mutation,
+ @Argument UUID studentId) {
+ return umlExerciseService.enqueueLatestSolutionForEvaluation(mutation.getAssessmentId(), studentId);
+ }
+
+ @SchemaMapping(typeName = "UmlStudentSolution")
+ public UmlEvaluationJobStatus evaluationStatus(UmlStudentSolution solution) {
+ if (solution == null || solution.getId() == null) {
+ return null;
+ }
+ return umlEvaluationQueueService.getJobStatus(solution.getId());
+ }
+}
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/exception/AiEvaluationException.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/exception/AiEvaluationException.java
new file mode 100644
index 0000000..3c0d6d6
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/exception/AiEvaluationException.java
@@ -0,0 +1,7 @@
+package de.unistuttgart.iste.meitrex.assignment_service.exception;
+
+public class AiEvaluationException extends RuntimeException {
+ public AiEvaluationException(String message) {
+ super(message);
+ }
+}
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/UmlEvaluationJobEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlEvaluationJobEntity.java
new file mode 100644
index 0000000..d7da052
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlEvaluationJobEntity.java
@@ -0,0 +1,43 @@
+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 = "UmlEvaluationJob")
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class UmlEvaluationJobEntity implements IWithId {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private UUID id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "solution_id", nullable = false)
+ private UmlStudentSolutionEntity solution;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private UmlEvaluationJobStatus status;
+
+ @Column(columnDefinition = "TEXT")
+ private String errorMessage;
+
+ @Column
+ private OffsetDateTime createdAt;
+
+ @Column
+ private OffsetDateTime startedAt;
+
+ @Column
+ private OffsetDateTime completedAt;
+
+ @Version
+ private Long version;
+}
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlEvaluationJobStatus.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlEvaluationJobStatus.java
new file mode 100644
index 0000000..5091ef8
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlEvaluationJobStatus.java
@@ -0,0 +1,8 @@
+package de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise;
+
+public enum UmlEvaluationJobStatus {
+ ENQUEUED,
+ PROCESSING,
+ DONE,
+ FAILED
+}
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..f37aaa8
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/entity/umlExercise/UmlExerciseEntity.java
@@ -0,0 +1,47 @@
+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;
+
+ @Embedded
+ private UmlDiagram tutorSolution;
+
+ @Column(columnDefinition = "TEXT")
+ private String gradingRules;
+
+ @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..c495100
--- /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()
+ private OffsetDateTime submittedAt;
+
+ @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/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/mapper/UmlExerciseMapper.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/mapper/UmlExerciseMapper.java
new file mode 100644
index 0000000..db9a880
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/mapper/UmlExerciseMapper.java
@@ -0,0 +1,89 @@
+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;
+
+ /**
+ * 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;
+
+ UmlExercise dto = modelMapper.map(entity, UmlExercise.class);
+
+ if (entity.getTutorSolution() != null) {
+ dto.setTutorSolution(diagramToDto(entity.getTutorSolution()));
+ }
+
+ 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);
+
+ // Explicitly map the embedded student diagram
+ if (entity.getDiagram() != null) {
+ dto.setDiagram(diagramToDto(entity.getDiagram()));
+ }
+
+ 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) {
+ 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/persistence/repository/UmlEvaluationJobRepository.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlEvaluationJobRepository.java
new file mode 100644
index 0000000..ea12281
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/persistence/repository/UmlEvaluationJobRepository.java
@@ -0,0 +1,31 @@
+package de.unistuttgart.iste.meitrex.assignment_service.persistence.repository;
+
+import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlEvaluationJobEntity;
+import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlEvaluationJobStatus;
+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.Optional;
+import java.util.UUID;
+
+@Repository
+public interface UmlEvaluationJobRepository extends MeitrexRepository {
+
+ /**
+ * Finds the first job with the given status, ordered by creation date.
+ */
+ Optional findFirstByStatusOrderByCreatedAt(UmlEvaluationJobStatus status);
+
+ /**
+ * Finds all jobs with a specific status.
+ */
+ List findAllByStatus(UmlEvaluationJobStatus status);
+
+ /**
+ * Finds a job by solution ID.
+ */
+ Optional findBySolutionId(UUID solutionId);
+}
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..fd443c2
--- /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.id = :exerciseId")
+ Optional findByStudentAndAssessmentWithSolutions(
+ @Param("studentId") UUID studentId,
+ @Param("exerciseId") UUID exerciseId);
+}
\ No newline at end of file
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..c4750d0
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlAnalysisResponse.java
@@ -0,0 +1,22 @@
+package de.unistuttgart.iste.meitrex.assignment_service.service.uml_assignment;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+/**
+ * Result of the first LLM step: purely analytical comparison.
+ */
+public record UmlAnalysisResponse(
+ @JsonProperty("semanticErrors") List semanticErrors,
+ @JsonProperty("missingElements") List missingElements,
+ @JsonProperty("correctElements") List correctElements,
+ @JsonProperty("isSemanticallyValid") boolean isSemanticallyValid,
+ @JsonProperty("analysisSummary") 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/UmlEvaluationQueueService.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlEvaluationQueueService.java
new file mode 100644
index 0000000..453ba9f
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlEvaluationQueueService.java
@@ -0,0 +1,196 @@
+package de.unistuttgart.iste.meitrex.assignment_service.service.uml_assignment;
+
+import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlEvaluationJobEntity;
+import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlEvaluationJobStatus;
+import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlStudentSolutionEntity;
+import de.unistuttgart.iste.meitrex.assignment_service.persistence.repository.UmlEvaluationJobRepository;
+import de.unistuttgart.iste.meitrex.assignment_service.persistence.repository.UmlStudentSolutionRepository;
+import de.unistuttgart.iste.meitrex.common.dapr.TopicPublisher;
+import de.unistuttgart.iste.meitrex.common.event.ServerSource;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.OffsetDateTime;
+import java.util.Optional;
+import java.util.UUID;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class UmlEvaluationQueueService {
+
+ private final UmlEvaluationJobRepository jobRepository;
+ private final UmlStudentSolutionRepository solutionRepository;
+ private final UmlEvaluationService evaluationService;
+ private final TopicPublisher topicPublisher;
+
+ /**
+ * Polls the database every 500ms for ENQUEUED jobs and processes them.
+ * Also performs crash recovery: if any job is in PROCESSING state on startup,
+ * it gets reset to ENQUEUED to retry.
+ */
+ @Scheduled(fixedDelay = 500)
+ @Transactional
+ public void pollAndProcessJobs() {
+ // Crash recovery: if any job is PROCESSING, reset it to ENQUEUED
+ // This handles the case where the service crashed while processing
+ try {
+ long processingCount = jobRepository.findAllByStatus(UmlEvaluationJobStatus.PROCESSING).stream()
+ .filter(job -> {
+ // Only reset if it's been processing for more than 10 minutes (likely crashed)
+ if (job.getStartedAt() != null) {
+ OffsetDateTime tenMinutesAgo = OffsetDateTime.now().minusMinutes(10);
+ return job.getStartedAt().isBefore(tenMinutesAgo);
+ }
+ return false;
+ })
+ .peek(job -> {
+ log.warn("Resetting stale job {} to ENQUEUED (was PROCESSING for > 10 minutes)", job.getId());
+ job.setStatus(UmlEvaluationJobStatus.ENQUEUED);
+ job.setStartedAt(null);
+ jobRepository.save(job);
+ })
+ .count();
+
+ if (processingCount > 0) {
+ log.info("Reset {} stale PROCESSING jobs to ENQUEUED", processingCount);
+ }
+ } catch (Exception e) {
+ log.error("Error during crash recovery", e);
+ }
+
+ // Process one ENQUEUED job
+ Optional job = jobRepository.findFirstByStatusOrderByCreatedAt(UmlEvaluationJobStatus.ENQUEUED);
+
+ if (job.isPresent()) {
+ processJob(job.get());
+ }
+ }
+
+ /**
+ * Processes a single evaluation job asynchronously.
+ * Transitions: ENQUEUED → PROCESSING → DONE/FAILED
+ */
+ private void processJob(UmlEvaluationJobEntity job) {
+ try {
+ // Mark as PROCESSING
+ job.setStatus(UmlEvaluationJobStatus.PROCESSING);
+ job.setStartedAt(OffsetDateTime.now());
+ jobRepository.save(job);
+
+ // Process asynchronously so polling can continue
+ executeEvaluationAsync(job.getId());
+ } catch (Exception e) {
+ log.error("Error starting async evaluation for job {}", job.getId(), e);
+ job.setStatus(UmlEvaluationJobStatus.FAILED);
+ job.setErrorMessage("Failed to start async evaluation: " + e.getMessage());
+ job.setCompletedAt(OffsetDateTime.now());
+ jobRepository.save(job);
+ }
+ }
+
+ /**
+ * Executes the evaluation asynchronously.
+ * This runs in a separate thread pool so polling can continue.
+ */
+ @Async
+ @Transactional
+ public void executeEvaluationAsync(UUID jobId) {
+ Optional jobOpt = jobRepository.findById(jobId);
+
+ if (jobOpt.isEmpty()) {
+ log.error("Job not found: {}", jobId);
+ return;
+ }
+
+ UmlEvaluationJobEntity job = jobOpt.get();
+ UmlStudentSolutionEntity solution = job.getSolution();
+
+ try {
+ // Refresh solution entity to get exercise details
+ Optional solutionOpt = solutionRepository.findById(solution.getId());
+ if (solutionOpt.isEmpty()) {
+ throw new IllegalStateException("Solution not found: " + solution.getId());
+ }
+
+ solution = solutionOpt.get();
+
+ // Get the exercise through submission
+ if (solution.getSubmission() == null) {
+ throw new IllegalStateException("Solution submission is missing");
+ }
+
+ // Call evaluation service - this performs the actual LLM evaluation
+ evaluationService.generateFeedbackForJob(solution, job);
+
+ // Mark job as DONE
+ job.setStatus(UmlEvaluationJobStatus.DONE);
+ job.setCompletedAt(OffsetDateTime.now());
+ jobRepository.save(job);
+
+ log.info("Evaluation completed successfully for job {}", jobId);
+
+ } catch (Exception e) {
+ log.error("Error evaluating solution in job {}", jobId, e);
+ job.setStatus(UmlEvaluationJobStatus.FAILED);
+ job.setErrorMessage(e.getMessage() != null ? e.getMessage() : "Unknown error");
+ job.setCompletedAt(OffsetDateTime.now());
+ jobRepository.save(job);
+
+ // Send failure notification to the student
+ try {
+ if (solution.getSubmission() != null && solution.getSubmission().getExercise() != null) {
+ UUID studentUserId = solution.getSubmission().getStudentId();
+ UUID courseId = solution.getSubmission().getExercise().getCourseId();
+ UUID assessmentId = solution.getSubmission().getExercise().getAssessmentId();
+ String link = "/courses/" + courseId + "/uml/" + assessmentId;
+ topicPublisher.notificationEvent(
+ courseId,
+ java.util.List.of(studentUserId),
+ ServerSource.COURSE,
+ link,
+ "Your UML submission evaluation failed",
+ "Automated evaluation failed. Please try again or contact your instructor."
+ );
+ }
+ } catch (Exception ne) {
+ log.error("Failed to send failure notification for job {}", jobId, ne);
+ }
+ }
+ }
+
+ /**
+ * Creates an evaluation job for the given solution.
+ * This is called when a student submits a solution.
+ */
+ @Transactional
+ public UmlEvaluationJobEntity createJob(UmlStudentSolutionEntity solution) {
+ UmlEvaluationJobEntity job = UmlEvaluationJobEntity.builder()
+ .solution(solution)
+ .status(UmlEvaluationJobStatus.ENQUEUED)
+ .createdAt(OffsetDateTime.now())
+ .build();
+
+ return jobRepository.save(job);
+ }
+
+ /**
+ * Gets the current status of an evaluation for a solution.
+ */
+ public UmlEvaluationJobStatus getJobStatus(UUID solutionId) {
+ Optional job = jobRepository.findBySolutionId(solutionId);
+ return job.map(UmlEvaluationJobEntity::getStatus).orElse(null);
+ }
+
+ /**
+ * Gets the error message for a failed evaluation.
+ */
+ public String getJobErrorMessage(UUID solutionId) {
+ Optional job = jobRepository.findBySolutionId(solutionId);
+ return job.map(UmlEvaluationJobEntity::getErrorMessage).orElse(null);
+ }
+}
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..54820f2
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlEvaluationService.java
@@ -0,0 +1,179 @@
+package de.unistuttgart.iste.meitrex.assignment_service.service.uml_assignment;
+
+import de.unistuttgart.iste.meitrex.assignment_service.exception.AiEvaluationException;
+import de.unistuttgart.iste.meitrex.assignment_service.persistence.entity.umlExercise.UmlEvaluationJobEntity;
+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.common.dapr.TopicPublisher;
+import de.unistuttgart.iste.meitrex.common.event.ServerSource;
+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.*;
+
+@Service
+@Slf4j
+@RequiredArgsConstructor
+public class UmlEvaluationService {
+
+ private final OllamaClient ollamaClient;
+ private final TopicPublisher topicPublisher;
+
+ 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,
+ final double requiredPercentage,
+ final boolean showSolution
+ ) {
+ 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, requiredPercentage, showSolution);
+
+ UmlFeedbackEntity feedbackEntity = UmlFeedbackEntity.builder()
+ .solution(solution)
+ .comment(grading.feedbackText())
+ .points(grading.points())
+ .build();
+
+ solution.setFeedback(feedbackEntity);
+
+ UUID studentUserId = solution.getSubmission().getStudentId();
+ UUID courseId = solution.getSubmission().getExercise().getCourseId();
+ UUID assessmentId = solution.getSubmission().getExercise().getAssessmentId();
+ String link = "/courses/" + courseId + "/uml/" + assessmentId;
+ topicPublisher.notificationEvent(
+ courseId,
+ List.of(studentUserId),
+ ServerSource.COURSE,
+ link,
+ "Your UML submission was evaluated",
+ "Feedback and points are now available."
+ );
+ }
+
+ /**
+ * Alternative version of generateFeedback for use with the evaluation queue.
+ * Called from UmlEvaluationQueueService when processing jobs.
+ * This method fetches the exercise details from the solution and performs the evaluation.
+ */
+ @Transactional
+ public void generateFeedbackForJob(
+ final UmlStudentSolutionEntity solution,
+ final UmlEvaluationJobEntity job
+ ) {
+ // Get exercise from the submission
+ if (solution.getSubmission() == null || solution.getSubmission().getExercise() == null) {
+ throw new IllegalStateException("Solution is missing exercise information");
+ }
+
+ final var exercise = solution.getSubmission().getExercise();
+
+ // Call the standard generateFeedback method with exercise details
+ generateFeedback(
+ solution,
+ exercise.getTutorSolution().getSemanticModel(),
+ exercise.getGradingRules(),
+ exercise.getTotalPoints(),
+ exercise.getRequiredPercentage(),
+ exercise.isShowSolution()
+ );
+ }
+
+ private UmlAnalysisResponse performAnalysis(String studentModel, String tutorModel) throws AiEvaluationException {
+ 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);
+
+ if ("Analysis failed.".equals(response.analysisSummary())) {
+ log.error("Ollama client returned the fallback response. Aborting evaluation.");
+ throw new AiEvaluationException("The AI model failed to analyze the UML diagram.");
+ }
+
+ log.info("Detailed Analysis Findings:\n - Correct: {}\n - Errors: {}\n - Missing: {}",
+ response.correctElements(), response.semanticErrors(), response.missingElements());
+
+ return response;
+ }
+
+ private UmlFeedbackResponse performGrading(
+ final UmlAnalysisResponse analysis,
+ final String rules,
+ final int maxPoints,
+ final double requiredPercentage,
+ final boolean showSolution
+ ) throws AiEvaluationException {
+ String effectiveRules = (rules != null && !rules.isBlank()) ? rules : "Standard UML grading.";
+
+ Map args = Map.of(
+ "maxPoints", String.valueOf(maxPoints),
+ "passingThreshold", String.valueOf(requiredPercentage * maxPoints),
+ "showSolution", String.valueOf(showSolution),
+ "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
+ );
+
+ UmlFeedbackResponse response = ollamaClient.startQuery(
+ UmlFeedbackResponse.class, TEMPLATE_GRADING, args, fallback, null);
+
+ // Intercept the fallback to prevent saving a 0-point grade
+ if ("Grading unavailable. Please review manually.".equals(response.feedbackText())) {
+ log.error("Ollama client returned the fallback response for grading. Aborting evaluation.");
+ throw new AiEvaluationException("The AI model successfully analyzed the diagram but failed to generate a final grade. Please try again.");
+ }
+
+ return response;
+ }
+
+ /**
+ * 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/UmlExerciseService.java b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlExerciseService.java
new file mode 100644
index 0000000..c7c95e4
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/assignment_service/service/uml_assignment/UmlExerciseService.java
@@ -0,0 +1,392 @@
+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;
+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 de.unistuttgart.iste.meitrex.content_service.client.ContentServiceClient;
+import de.unistuttgart.iste.meitrex.content_service.exception.ContentServiceConnectionException;
+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 org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Nullable;
+import java.time.OffsetDateTime;
+import java.util.*;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class UmlExerciseService {
+
+ private final UmlExerciseRepository exerciseRepository;
+ private final UmlStudentSubmissionRepository submissionRepository;
+ private final UmlStudentSolutionRepository solutionRepository;
+ private final UmlExerciseMapper umlMapper;
+ private final ContentServiceClient contentServiceClient;
+ private final UmlEvaluationService evaluationService;
+ private final UmlEvaluationQueueService queueService;
+ private static final String DEFAULT_START_DIAGRAM = """
+ classDiagram {
+ class("HelloWorld") {
+ public {
+ hello : string
+ }
+ }
+ }
+ """;
+
+ /**
+ * 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));
+ }
+
+ /**
+ * 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
+ */
+ public UmlExercise createExercise(final UUID courseId, UUID assessmentId, final CreateUmlExerciseInput input) {
+ UmlDiagram tutorSolution = input.getTutorSolution() != null
+ ? umlMapper.inputToEntity(input.getTutorSolution())
+ : UmlDiagram.builder().diagramCode("").semanticModel("").build();
+
+ String gradingRules = input.getGradingRules() != null ? input.getGradingRules() : "";
+
+ UmlExerciseEntity entity = UmlExerciseEntity.builder()
+ .assessmentId(assessmentId)
+ .courseId(courseId)
+ .description(input.getDescription())
+ .showSolution(input.getShowSolution())
+ .totalPoints(input.getTotalPoints())
+ .requiredPercentage(input.getRequiredPercentage())
+ .gradingRules(gradingRules)
+ .tutorSolution(tutorSolution)
+ .studentSubmissions(new ArrayList<>())
+ .build();
+
+ return umlMapper.entityToDto(exerciseRepository.save(entity));
+ }
+
+ /**
+ * 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.STUDENT, entity.getCourseId());
+
+ return new UmlExerciseMutation(assessmentId);
+ }
+
+ /**
+ * Updates the reference solution for a task.
+ */
+ public UmlExercise updateTutorSolution(final UUID assessmentId, final UmlDiagramInput tutorSolution) {
+ UmlExerciseEntity entity = exerciseRepository.findByAssessmentIdWithSubmissions(assessmentId)
+ .orElseThrow(() -> new IllegalArgumentException("Exercise not found"));
+
+ entity.setTutorSolution(umlMapper.inputToEntity(tutorSolution));
+ return umlMapper.entityToDto(exerciseRepository.save(entity));
+ }
+
+ /**
+ * Updates fields of a UML exercise.
+ */
+ public UmlExercise updateUmlExercise(final UUID assessmentId, final UpdateUmlExerciseInput input) {
+ UmlExerciseEntity entity = exerciseRepository.findByAssessmentIdWithSubmissions(assessmentId)
+ .orElseThrow(() -> new IllegalArgumentException("Exercise not found"));
+
+ if (input.getDescription() != null) {
+ entity.setDescription(input.getDescription());
+ }
+
+ if (input.getRequiredPercentage() != null) {
+ entity.setRequiredPercentage(input.getRequiredPercentage());
+ }
+
+ if (input.getShowSolution() != null) {
+ entity.setShowSolution(input.getShowSolution());
+ }
+
+ if (input.getTutorSolution() != null) {
+ entity.setTutorSolution(umlMapper.inputToEntity(input.getTutorSolution()));
+ }
+
+ if (input.getTotalPoints() != null) {
+ entity.setTotalPoints(input.getTotalPoints());
+ }
+
+ UmlExerciseEntity savedEntity = exerciseRepository.save(entity);
+ return umlMapper.entityToDto(savedEntity);
+ }
+
+ /**
+ * 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.
+ */
+ @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);
+
+ boolean hasUnsubmitted = submission.getSolutions().stream()
+ .anyMatch(sol -> sol.getSubmittedAt() == null);
+
+ if (hasUnsubmitted) {
+ throw new IllegalStateException("An unsubmitted draft already exists.");
+ }
+
+ ensureNewSolutionAllowed(exercise, submission, studentId);
+
+ 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("No previous submission found."));
+ } else {
+ diagram = UmlDiagram.builder().diagramCode(DEFAULT_START_DIAGRAM).semanticModel("").build();
+ }
+
+ UmlStudentSolutionEntity newSolution = UmlStudentSolutionEntity.builder()
+ .submission(submission)
+ .diagram(diagram)
+ .build();
+
+ return umlMapper.solutionEntityToDto(solutionRepository.save(newSolution));
+ }
+
+ /**
+ * 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.
+ */
+ @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."));
+
+ UmlStudentSubmissionEntity submission = getOrCreateSubmission(exercise, studentId);
+ UmlStudentSolutionEntity solutionEntity;
+
+ if (solutionId != null) {
+ solutionEntity = solutionRepository.findById(solutionId)
+ .orElseThrow(() -> new IllegalArgumentException("Solution not found."));
+
+ if (solutionEntity.getSubmittedAt() != null) {
+ throw new IllegalStateException("Solution already submitted.");
+ }
+ } else {
+ Optional draftSolution = submission.getSolutions().stream()
+ .filter(s -> s.getSubmittedAt() == null)
+ .findFirst();
+
+ if (draftSolution.isPresent()) {
+ solutionEntity = draftSolution.get();
+ } else {
+ ensureNewSolutionAllowed(exercise, submission, studentId);
+ solutionEntity = UmlStudentSolutionEntity.builder()
+ .submission(submission)
+ // Initialize with provided diagram
+ .diagram(umlMapper.inputToEntity(diagramInput))
+ .build();
+ submission.getSolutions().add(solutionEntity);
+ }
+ }
+
+ solutionEntity.setDiagram(umlMapper.inputToEntity(diagramInput));
+
+ if (submit) {
+ solutionEntity.setSubmittedAt(OffsetDateTime.now());
+ }
+
+ UmlStudentSolutionEntity savedEntity = solutionRepository.save(solutionEntity);
+
+ // If this is a submission, enqueue it for evaluation instead of evaluating synchronously
+ if (submit) {
+ queueService.createJob(savedEntity);
+ log.info("Evaluation job created for solution {}", savedEntity.getId());
+ }
+
+ 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.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 an automated evaluation and feedback generation for a student's most recent submitted solution.
+ *
+ * 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 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.
+ */
+ @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(),
+ exercise.getGradingRules(),
+ exercise.getTotalPoints(),
+ exercise.getRequiredPercentage(),
+ exercise.isShowSolution()
+ );
+
+ return umlMapper.solutionEntityToDto(latestSolution);
+ }
+
+ /**
+ * Enqueues the student's latest submitted solution for evaluation.
+ * Returns the solution DTO immediately.
+ */
+ @Transactional
+ public UmlStudentSolution enqueueLatestSolutionForEvaluation(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."));
+
+ // Ensure tutor solution exists to prevent queueing invalid jobs
+ if (exercise.getTutorSolution() == null || exercise.getTutorSolution().getSemanticModel() == null) {
+ throw new IllegalStateException("Tutor solution is missing semantic model for evaluation.");
+ }
+
+ queueService.createJob(latestSolution);
+ log.info("Manual evaluation job created for solution {}", latestSolution.getId());
+
+ return umlMapper.solutionEntityToDto(latestSolution);
+ }
+
+ private void ensureNewSolutionAllowed(final UmlExerciseEntity exercise,
+ final UmlStudentSubmissionEntity submission,
+ final UUID studentId) {
+ boolean hasSubmittedSolution = submission.getSolutions().stream()
+ .anyMatch(solution -> solution.getSubmittedAt() != null);
+
+ if (!hasSubmittedSolution) {
+ return;
+ }
+
+ if (!isAssessmentRepeatable(exercise.getAssessmentId(), studentId)) {
+ throw new IllegalStateException("This UML exercise is not repeatable. You cannot create another solution after submission.");
+ }
+ }
+
+ private boolean isAssessmentRepeatable(final UUID assessmentId, final UUID studentId) {
+ try {
+ Content content = contentServiceClient.queryContentsByIds(studentId, List.of(assessmentId)).stream()
+ .filter(candidate -> assessmentId.equals(candidate.getId()))
+ .findFirst()
+ .orElseThrow(() -> new EntityNotFoundException("Content with assessmentId %s not found".formatted(assessmentId)));
+
+ if (!(content instanceof Assessment assessmentContent)) {
+ throw new IllegalStateException("Content with assessmentId %s is not an assessment".formatted(assessmentId));
+ }
+
+ AssessmentMetadata metadata = assessmentContent.getAssessmentMetadata();
+ return metadata != null && metadata.getInitialLearningInterval() != null;
+ } catch (ContentServiceConnectionException e) {
+ throw new IllegalStateException("Could not determine repeatability for UML exercise.", e);
+ }
+ }
+}
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..34e22be 100644
--- a/src/main/resources/application-dev.properties
+++ b/src/main/resources/application-dev.properties
@@ -11,4 +11,10 @@ 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://129.69.217.245:4000
+ollama.model=qwen3-coder-80B-A10B
+ollama.endpoint=v1/chat/completions
+ollama.promptFolder=prompt_templates
+ollama.apiKey=${OLLAMA_API_KEY:}
\ No newline at end of file
diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties
index 10fdb11..9f8f351 100644
--- a/src/main/resources/application-prod.properties
+++ b/src/main/resources/application-prod.properties
@@ -11,3 +11,9 @@ 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.245:4000}
+ollama.endpoint=${OLLAMA_ENDPOINT:v1/chat/completions}
+ollama.model=${OLLAMA_MODEL:qwen3-coder-80B-A10B}
+ollama.promptFolder=${OLLAMA_PROMPT_FOLDER:prompt_templates}
+ollama.apiKey=${OLLAMA_API_KEY:}
\ No newline at end of file
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index cbe9d36..5a5b95a 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -1,5 +1,6 @@
# for deployment or when using docker compose = prod, for local development = dev
spring.profiles.active=prod
+spring.config.import=optional:file:.env[.properties]
# enable graphiql (graphiql is a web interface for exploring GraphQL)
spring.graphql.graphiql.enabled=true
spring.graphql.graphiql.path=/graphiql
@@ -17,6 +18,11 @@ server.port=1101
dapr.appId=assignment_service
dapr.port=1100
+ollama.url=http://129.69.217.245:4000
+ollama.model=qwen3-coder-80B-A10B
+ollama.endpoint=v1/chat/completions
+ollama.promptFolder=prompt_templates
+ollama.apiKey=${OLLAMA_API_KEY:}
# 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/graphql/service/mutation.graphqls b/src/main/resources/graphql/service/mutation.graphqls
index 489450c..6e6abeb 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,65 @@ 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: UmlDiagramInput!): UmlExercise!
+
+ """
+ Updates fields of a UML exercise.
+ """
+ updateUmlExercise(input: UpdateUmlExerciseInput!): UmlExercise!
+
+ """
+ Creates a new solution attempt for a student.
+ """
+ createUmlSolution(studentId: UUID!, createFromPrevious: Boolean!): UmlStudentSolution!
+
+ """
+ Saves progress or finalizes a student's solution depending on the submit flag.
+ """
+ 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!): UmlStudentSolution!
+}
+
+input UpdateUmlExerciseInput {
+ """
+ Description of the UML exercise.
+ """
+ description: String
+
+ """
+ Whether the tutor solution can be shown to students.
+ """
+ showSolution: Boolean
+
+ """
+ Tutor solution diagram.
+ """
+ tutorSolution: UmlDiagramInput
+
+ """
+ Maximum points achievable in this UML exercise.
+ """
+ totalPoints: Int @Positive
+
+ """
+ Required percentage to pass. Must be between 0 and 1.
+ """
+ requiredPercentage: Float @Range(min : 0, max : 1)
+}
+
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 {
diff --git a/src/main/resources/graphql/service/uml.graphqls b/src/main/resources/graphql/service/uml.graphqls
new file mode 100644
index 0000000..7dd69ee
--- /dev/null
+++ b/src/main/resources/graphql/service/uml.graphqls
@@ -0,0 +1,97 @@
+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: UmlDiagram
+ """
+ Optional text of rules for grading the diagram. If not provided LLM will analyze with its own knowledge base
+ """
+ gradingRules: 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!]!
+
+ """
+ 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 UmlDiagram {
+ diagramCode: String!
+ semanticModel: String
+}
+
+type UmlStudentSubmission {
+ id: UUID!
+ studentId: UUID!
+ solutions: [UmlStudentSolution!]!
+}
+
+type UmlStudentSolution {
+ id: UUID!
+ submittedAt: DateTime
+ diagram: UmlDiagram!
+ evaluationStatus: UmlEvaluationStatus
+ feedback: UmlFeedback
+}
+
+enum UmlEvaluationStatus {
+ ENQUEUED
+ PROCESSING
+ DONE
+ FAILED
+}
+
+input UmlDiagramInput {
+ diagramCode: String!
+ semanticModel: String
+}
+
+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: UmlDiagramInput
+ gradingRules: String
+}
\ No newline at end of file
diff --git a/src/main/resources/prompt_templates/analysis_task.md b/src/main/resources/prompt_templates/analysis_task.md
new file mode 100644
index 0000000..c2f05c9
--- /dev/null
+++ b/src/main/resources/prompt_templates/analysis_task.md
@@ -0,0 +1,41 @@
+### ROLE
+You are a Requirements-Driven Software Architecture Evaluator.
+You are evaluating a student's UML class diagram based *strictly on the original assignment task*. A Tutor Reference Solution is provided, but it is only *one possible valid solution*, not the absolute truth.
+
+### DATA TO ANALYZE
+- **Original Assignment Task:**
+---
+{{taskDescription}}
+---
+- **Tutor Reference Solution (Use as a complexity hint only):**
+---
+{{tutorModel}}
+---
+- **Student Submission:**
+---
+{{studentModel}}
+---
+
+### EVALUATION STRATEGY
+1. Read the **Assignment Task** to understand the required entities, attributes, and relationships.
+2. Analyze the **Student Submission**. Does it fulfill the core requirements of the task?
+3. If the student diverges from the Tutor Reference but still logically satisfies the Assignment Task (e.g., using a List of Enum values instead of a dedicated Rating class), mark it as **CORRECT**.
+4. Do NOT penalize for minor syntax variations (`int` vs `Integer`) or slightly different but logical association names.
+
+### CATEGORIZATION STRICTNESS
+- **correctElements:** List elements that successfully fulfill a requirement from the Assignment Task.
+- **missingElements:** List elements explicitly requested by the Assignment Task that the student failed to include.
+- **semanticErrors:** List elements that violate UML logic or explicitly contradict the Assignment Task.
+
+### CRITICAL OUTPUT RULES
+- Ignore visual layout coordinates.
+- Output ONLY valid JSON matching the exact schema below.
+
+**Output Format (JSON):**
+{
+"correctElements": ["Elements fulfilling the task requirements."],
+"semanticErrors": ["Logical violations or contradictions of the task."],
+"missingElements": ["Elements required by the task that are missing."],
+"isSemanticallyValid": ,
+"analysisSummary": "A concise summary of how well the student met the task requirements."
+}
\ 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..e7e8055
--- /dev/null
+++ b/src/main/resources/prompt_templates/uml_analysis.md
@@ -0,0 +1,40 @@
+### ROLE
+You are an Experienced Software Engineering Professor and UML Evaluator.
+Your task is to compare the **Reference Solution** against the **Student Submission**, but you must prioritize *theoretical correctness* and *semantic meaning* over strict syntactic matching.
+
+### EQUIVALENCE RULES (CRITICAL - DO NOT PENALIZE FOR THESE)
+1. **Data Types:** Treat semantically similar types as identical (e.g., `int` == `Integer`, `String` == `string`, `Long` == `long`, `boolean` == `Boolean`).
+2. **Association Labels:** Focus on the *meaning* of the relationship, not the exact string. (e.g., "owns", "has", "contains", and "is part of" are effectively equivalent if the multiplicity and direction are correct).
+3. **Architectural Variations:** Students may solve domain problems slightly differently.
+ - Example 1: Using an `Enum` for a property vs. a dedicated Class with constraints.
+ - Example 2: Using an `abstract class` instead of an `interface`.
+ - If the student's alternative logically fulfills the same domain requirement as the reference, accept it as CORRECT.
+
+### DATA TO ANALYZE
+- **Reference Solution:**
+---
+{{tutorModel}}
+---
+- **Student Submission:**
+---
+{{studentModel}}
+---
+
+### CATEGORIZATION STRICTNESS
+- **correctElements:** List all correctly implemented details. If a student used an acceptable equivalent (e.g., `int` instead of `Integer`), list it here as correct.
+- **missingElements:** List items that are COMPLETELY absent and have no logical equivalent in the student's code.
+- **semanticErrors:** List items that are actively WRONG (e.g., a composition used where an inheritance was clearly required, or fundamentally backward multiplicities).
+
+### CRITICAL OUTPUT RULES
+- Do NOT deduct points or list errors for layout attributes (e.g., `pos`, `vdist`, `layout`).
+- Output ONLY valid JSON.
+- Every element analyzed MUST be present in exactly one of the three arrays.
+
+**Output Format (JSON):**
+{
+"correctElements": ["List specific correct elements and accepted equivalents."],
+"semanticErrors": ["List actively INCORRECT structural logic."],
+"missingElements": ["List completely ABSENT elements."],
+"isSemanticallyValid": ,
+"analysisSummary": "A concise summary derived strictly from the lists above."
+}
\ No newline at end of file
diff --git a/src/main/resources/prompt_templates/uml_analysis_pure_task.md b/src/main/resources/prompt_templates/uml_analysis_pure_task.md
new file mode 100644
index 0000000..94e7bb4
--- /dev/null
+++ b/src/main/resources/prompt_templates/uml_analysis_pure_task.md
@@ -0,0 +1,37 @@
+### ROLE
+You are an Expert Requirements Engineer and Software Architecture Evaluator.
+Your task is to evaluate a student's UML class diagram based STRICTLY and ONLY on the original assignment task. You do not have a reference solution. You must deduce the correctness entirely from domain logic and the provided requirements.
+
+### DATA TO ANALYZE
+- **Original Assignment Task:**
+---
+{{taskDescription}}
+---
+- **Student Submission:**
+---
+{{studentModel}}
+---
+
+### EVALUATION STRATEGY
+1. Read the **Assignment Task** and extract every explicit requirement (entities, attributes, and relationships).
+2. Analyze the **Student Submission**. Check if every requirement from the task is fulfilled.
+3. Because there is no reference solution, you must be tolerant of different architectural choices. If a student uses an Enum, a Class, or an Interface in a way that logically solves the domain problem described in the task, mark it as CORRECT.
+4. Only penalize elements if they directly contradict the task description or violate standard UML logic.
+
+### CATEGORIZATION STRICTNESS
+- **correctElements:** List elements that successfully fulfill a requirement from the Assignment Task.
+- **missingElements:** List elements explicitly requested by the Assignment Task that the student failed to include.
+- **semanticErrors:** List elements that violate UML logic or explicitly contradict the Assignment Task.
+
+### CRITICAL OUTPUT RULES
+- Ignore visual layout coordinates (pos, vdist, layout, etc.).
+- Output ONLY valid JSON matching the exact schema below.
+
+**Output Format (JSON):**
+{
+"correctElements": ["Elements fulfilling the task requirements."],
+"semanticErrors": ["Logical violations or contradictions of the task."],
+"missingElements": ["Elements required by the task that are missing."],
+"isSemanticallyValid": ,
+"analysisSummary": "A concise summary of how well the student met the task requirements based ONLY on the text prompt."
+}
\ No newline at end of file
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..5e02875
--- /dev/null
+++ b/src/main/resources/prompt_templates/uml_grading.md
@@ -0,0 +1,39 @@
+### ROLE
+You are a supportive Software Architecture Tutor. Your goal is to transform technical analysis into constructive HTML feedback and calculate a final grade based strictly on the provided rubric.
+
+### GRADING LOGIC (FOLLOW STRICTLY)
+- **Total Possible:** {{maxPoints}} points.
+- **Passing Threshold:** {{passingThreshold}} points.
+- **Reference Rubric:** {{gradingRules}}
+
+**Calculation Hierarchy:**
+1. Start at {{maxPoints}} points.
+2. Look at the items in `semanticErrors` and `missingElements`.
+3. For EACH error, find the corresponding penalty in the **Reference Rubric**.
+4. Subtract the penalty from the current score. (e.g., If the rubric says "-0.25P per missing class" and there are 2 missing classes, subtract 0.5P).
+5. DO NOT deduct points for anything not explicitly listed in the errors arrays.
+6. If the final score drops below 0, set it to 0.
+
+### PEDAGOGICAL GUIDELINES
+1. **The "Sandwich" Method:** Start with specific praise (correctElements), address gaps (semanticErrors/missingElements), and end with motivation.
+2. **Spoiler Policy:** The 'Show Solution' flag is: {{showSolution}}
+ - **If FALSE:** Provide Socratic hints (e.g., "Check the relationship multiplicity."). DO NOT give the exact answer.
+ - **If TRUE:** You may explicitly state the correct answer.
+3. **Mandatory Sign-off:** You MUST conclude the HTML string exactly with: `
Best Regards,
Your AI Tutor`
+
+### DATA FOR REPORT
+- **Valid Graph:** {{isValid}}
+- **Things Done Well:** {{correctElements}}
+- **Logic Errors:** {{semanticErrors}}
+- **Missing Items:** {{missingElements}}
+
+### TECHNICAL CONSTRAINTS
+- **Output Format:** STRICT JSON containing an HTML string and an integer/float.
+- **Allowed HTML:** ``, ``, ``, `
`, `