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:** ``, ``, `

`, `
`, `