From 6afa7609b78a3a4c1e965a0bc8572acb64f0bd92 Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Thu, 27 Nov 2025 02:09:29 +0100 Subject: [PATCH 01/24] Listen for UserHexadPlayerTypeSetEvent, persist the received player-type data, and use it to tailor responses for the user --- build.gradle | 6 +- .../01_create_user_player_type_table.sql | 15 ++++ settings.gradle | 3 + .../controller/SubscriptionController.java | 62 +++++++++++++++ .../entity/UserPlayerTypeEntity.java | 76 +++++++++++++++++++ .../repository/UserPlayerTypeRepository.java | 15 ++++ .../tutor_service/service/HintService.java | 41 +++++++++- .../tutor_service/service/TutorService.java | 46 ++++++++++- .../service/UserPlayerTypeService.java | 68 +++++++++++++++++ src/main/resources/application.properties | 2 + .../answer_lecture_question_prompt.txt | 3 + .../prompt_templates/generate_hint.md | 1 + 12 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 pg-init-scripts/01_create_user_player_type_table.sql create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/UserPlayerTypeEntity.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/UserPlayerTypeRepository.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserPlayerTypeService.java diff --git a/build.gradle b/build.gradle index 552c223..43058b2 100644 --- a/build.gradle +++ b/build.gradle @@ -108,7 +108,7 @@ repositories { } dependencies { - implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.1' + implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.12pre2' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-graphql' implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -121,12 +121,14 @@ dependencies { implementation 'com.graphql-java:graphql-java-extended-validation:22.0' implementation 'de.unistuttgart.iste.meitrex:content_service:1.0.9' implementation 'com.fasterxml.jackson.module:jackson-module-jsonSchema:2.17.1' + implementation 'io.dapr:dapr-sdk-springboot:1.12.0' + // implementation 'de.unistuttgart.iste.meitrex:gamification_service:1.0.0' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' 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.2.6' + testImplementation 'de.unistuttgart.iste.meitrex:meitrex-common-test:1.4.11' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework:spring-webflux' testImplementation 'org.springframework.graphql:spring-graphql-test' diff --git a/pg-init-scripts/01_create_user_player_type_table.sql b/pg-init-scripts/01_create_user_player_type_table.sql new file mode 100644 index 0000000..38bdb5b --- /dev/null +++ b/pg-init-scripts/01_create_user_player_type_table.sql @@ -0,0 +1,15 @@ +-- Create user_player_type table +CREATE TABLE IF NOT EXISTS user_player_type ( + user_id UUID PRIMARY KEY, + primary_player_type VARCHAR(50) NOT NULL, + achiever_score REAL, + player_score REAL, + socialiser_score REAL, + free_spirit_score REAL, + philanthropist_score REAL, + disruptor_score REAL +); + +-- Create index on primary_player_type for faster queries +CREATE INDEX IF NOT EXISTS idx_user_player_type_primary + ON user_player_type(primary_player_type); diff --git a/settings.gradle b/settings.gradle index ddaef67..7a830e6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -10,4 +10,7 @@ sourceControl { gitRepository(uri('https://github.com/MEITREX/content_service')) { producesModule('de.unistuttgart.iste.meitrex:content_service') } + gitRepository(uri('https://github.com/MP-DG/gamification_service')) { + producesModule('de.unistuttgart.iste.meitrex:gamification_service') + } } diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java new file mode 100644 index 0000000..a94de6b --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java @@ -0,0 +1,62 @@ +package de.unistuttgart.iste.meitrex.tutor_service.controller; + +import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; +import de.unistuttgart.iste.meitrex.common.event.UserHexadPlayerTypeSetEvent; +import de.unistuttgart.iste.meitrex.tutor_service.service.UserPlayerTypeService; +import io.dapr.Topic; +import io.dapr.client.domain.CloudEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.Map; + +/** + * Controller for subscribing to Dapr pub/sub events. + */ +@Slf4j +@RestController +@RequiredArgsConstructor +public class SubscriptionController { + + private final UserPlayerTypeService userPlayerTypeService; + + /** + * Handles the user-hexad-player-type-set event. + * Saves the user's player type information when received. + * + * @param cloudEvent the cloud event containing the user hexad player type data + * @param headers request headers from Dapr + * @return Mono for reactive processing + */ + @Topic(name = "user-hexad-player-type-set", pubsubName = "meitrex") + @PostMapping(path = "/user-hexad-player-type-set-pubsub") + public Mono onUserHexadPlayerTypeSetEvent(@RequestBody CloudEvent cloudEvent, + @RequestHeader Map headers) { + return Mono.fromRunnable(() -> { + UserHexadPlayerTypeSetEvent event = cloudEvent.getData(); + + log.info("Received UserHexadPlayerTypeSetEvent for user: {}, primaryType: {}", + event.getUserId(), + event.getPrimaryPlayerType()); + + // Convert Float playerTypePercentages to Double + Map playerTypePercentages = new HashMap<>(); + event.getPlayerTypePercentages().forEach((type, percentage) -> + playerTypePercentages.put(type, percentage.doubleValue()) + ); + + // Save the player type information + userPlayerTypeService.saveUserPlayerType( + event.getUserId(), + event.getPrimaryPlayerType(), + playerTypePercentages + ); + }); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/UserPlayerTypeEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/UserPlayerTypeEntity.java new file mode 100644 index 0000000..2d1c41e --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/UserPlayerTypeEntity.java @@ -0,0 +1,76 @@ +package de.unistuttgart.iste.meitrex.tutor_service.persistence.entity; + +import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; +import jakarta.persistence.*; +import lombok.*; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Entity representing a user's Hexad player type scores. + * Stores the primary player type and all six player type scores. + */ +@Entity +@Table(name = "user_player_type") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserPlayerTypeEntity { + + @Id + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Enumerated(EnumType.STRING) + @Column(name = "primary_player_type", nullable = false) + private HexadPlayerType primaryPlayerType; + + @Column(name = "achiever_score") + private Double achieverScore; + + @Column(name = "player_score") + private Double playerScore; + + @Column(name = "socialiser_score") + private Double socialiserScore; + + @Column(name = "free_spirit_score") + private Double freeSpiritScore; + + @Column(name = "philanthropist_score") + private Double philanthropistScore; + + @Column(name = "disruptor_score") + private Double disruptorScore; + + /** + * Converts the individual percentage fields into a Map for easier access. + * @return Map of HexadPlayerType to percentage values + */ + public Map getPlayerTypePercentagesAsMap() { + Map playerTypePercentages = new HashMap<>(); + playerTypePercentages.put(HexadPlayerType.ACHIEVER, achieverScore); + playerTypePercentages.put(HexadPlayerType.PLAYER, playerScore); + playerTypePercentages.put(HexadPlayerType.SOCIALISER, socialiserScore); + playerTypePercentages.put(HexadPlayerType.FREE_SPIRIT, freeSpiritScore); + playerTypePercentages.put(HexadPlayerType.PHILANTHROPIST, philanthropistScore); + playerTypePercentages.put(HexadPlayerType.DISRUPTOR, disruptorScore); + return playerTypePercentages; + } + + /** + * Sets all percentage fields from a Map. + * @param playerTypePercentages Map of HexadPlayerType to percentage values + */ + public void setPlayerTypePercentagesFromMap(Map playerTypePercentages) { + this.achieverScore = playerTypePercentages.get(HexadPlayerType.ACHIEVER); + this.playerScore = playerTypePercentages.get(HexadPlayerType.PLAYER); + this.socialiserScore = playerTypePercentages.get(HexadPlayerType.SOCIALISER); + this.freeSpiritScore = playerTypePercentages.get(HexadPlayerType.FREE_SPIRIT); + this.philanthropistScore = playerTypePercentages.get(HexadPlayerType.PHILANTHROPIST); + this.disruptorScore = playerTypePercentages.get(HexadPlayerType.DISRUPTOR); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/UserPlayerTypeRepository.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/UserPlayerTypeRepository.java new file mode 100644 index 0000000..3fcd5d8 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/UserPlayerTypeRepository.java @@ -0,0 +1,15 @@ +package de.unistuttgart.iste.meitrex.tutor_service.persistence.repository; + +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.UserPlayerTypeEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +/** + * Repository interface for UserPlayerTypeEntity. + * Provides database access for user player type information. + */ +@Repository +public interface UserPlayerTypeRepository extends JpaRepository { +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/HintService.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/HintService.java index d7adc08..9987c2e 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/HintService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/HintService.java @@ -1,5 +1,6 @@ package de.unistuttgart.iste.meitrex.tutor_service.service; +import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; import de.unistuttgart.iste.meitrex.generated.dto.*; import de.unistuttgart.iste.meitrex.tutor_service.service.models.*; @@ -10,6 +11,7 @@ import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; @Slf4j @@ -18,6 +20,7 @@ public class HintService { private final OllamaService ollamaService; private final SemanticSearchService semanticSearchService; + private final UserPlayerTypeService userPlayerTypeService; @Value("${semantic.search.threshold.hint:0.4}") private double scoreThreshold; @@ -72,14 +75,50 @@ public HintResponse generateHintWithQuestion( String prompt = ollamaService.getTemplate(PROMPT_TEMPLATES.get("GENERATION")); String contentString = semanticSearchService.formatIntoNumberedListForPrompt( documentSegments.stream().map(DocumentRecordSegment::getText).toList()); + String gamificationPrompt = generateGamificationPrompt(courseId, currentUser); List promptArgs = List.of( TemplateArgs.builder().argumentName("questionPrompt").argumentValue(questionPrompt).build(), - TemplateArgs.builder().argumentName("content").argumentValue(contentString).build() + TemplateArgs.builder().argumentName("content").argumentValue(contentString).build(), + TemplateArgs.builder().argumentName("gamificationPrompt").argumentValue(gamificationPrompt).build() ); return ollamaService.startQuery(HintResponse.class, prompt, promptArgs, new HintResponse("An error occurred")); } + private String generateGamificationPrompt(UUID courseId, final LoggedInUser currentUser) { + try { + Optional playerTypeOpt = userPlayerTypeService.getPrimaryPlayerType(currentUser.getId()); + + if (playerTypeOpt.isEmpty()) { + log.warn("No player type found for user {}", currentUser.getId()); + return ""; // Return empty string if no player type available + } + + HexadPlayerType playerType = playerTypeOpt.get(); + log.info("Player type for user {}: {}", currentUser.getId(), playerType); + + // Generate personalized prompt based on player type + return switch (playerType) { + case ACHIEVER -> + "Give the user a small hint that helps them solve the problem without revealing the full solution. " + + "Keep the hint short and motivating. This is for an Achiever who values mastery and goals."; + case PHILANTHROPIST, SOCIALISER -> + "Encourage the user to collaborate and share knowledge. " + + "If relevant, suggest looking for similar questions in the forum or encouraging them to help others. " + + "This is for a Philanthropist or Socialiser who values social interaction."; + case FREE_SPIRIT -> + "Give the user a normal hint that helps them understand the problem and explore solutions independently. " + + "Encourage exploration and creativity. This is for a Free Spirit who values autonomy."; + case PLAYER, DISRUPTOR -> + "Be especially careful not to reveal any solution directly. " + + "Frame the hint as a challenge. This is for a Player or Disruptor who values competition."; + }; + } catch (Exception e) { + log.error("Error fetching player type for user {}: {}", currentUser.getId(), e.getMessage()); + return ""; // Return empty string on error to not break hint generation + } + } + private HintGenerationData getGenerationData(HintGenerationInput input) { return switch (input.getType()) { case CLOZE -> buildClozeData(input.getCloze()); diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java index 1d73847..7a6100f 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java @@ -1,6 +1,7 @@ package de.unistuttgart.iste.meitrex.tutor_service.service; import de.unistuttgart.iste.meitrex.common.event.AskedTutorAQuestionEvent; +import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; import de.unistuttgart.iste.meitrex.common.event.TutorCategory; import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; import de.unistuttgart.iste.meitrex.common.dapr.TopicPublisher; @@ -10,13 +11,17 @@ import de.unistuttgart.iste.meitrex.generated.dto.VideoSource; import de.unistuttgart.iste.meitrex.tutor_service.service.models.*; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import java.util.Comparator; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.UUID; +@Slf4j @Service @RequiredArgsConstructor public class TutorService { @@ -24,8 +29,11 @@ public class TutorService { private final OllamaService ollamaService; private final SemanticSearchService semanticSearchService; private final TopicPublisher topicPublisher; + private final UserPlayerTypeService userPlayerTypeService; @Value("${semantic.search.threshold.tutor:0.4}") private double scoreThreshold; + @Value("${semantic.search.topN.tutor:5}") + private long topSourceCount; private final String ERROR_MESSAGE = ("Oops, something went wrong! " + "The request could not be processed. Please try again."); @@ -34,6 +42,11 @@ public class TutorService { "categorize_message_prompt.txt", "answer_lecture_question_prompt.txt" ); + private static final List SKILL_LEVEL_PROMPT_TEMPLATES = List.of( + "low skill level", + "normal skill level", + "high performer" + ); /** * Handles a user’s question by categorizing it and returning an appropriate response. @@ -80,7 +93,14 @@ public LectureQuestionResponse handleUserQuestion(String userQuestion, UUID cour return new LectureQuestionResponse(ERROR_MESSAGE, List.of()); } - private LectureQuestionResponse answerLectureQuestion(String question, UUID courseId, LoggedInUser currentUser){ + private LectureQuestionResponse answerLectureQuestion(String question, UUID courseId, LoggedInUser currentUser){ + Optional playerType = userPlayerTypeService.getPrimaryPlayerType(currentUser.getId()); + if (playerType.isPresent()) { + log.info("User {} has player type: {}", currentUser.getId(), playerType.get()); + } else { + log.info("User {} has no player type set", currentUser.getId()); + } + if(courseId == null){ String response = "Something went wrong! If your question is about lecture materials, " + @@ -99,22 +119,44 @@ private LectureQuestionResponse answerLectureQuestion(String question, UUID cour List documentSegments = segmentSearchResults.stream() .filter(result -> result.getScore() <= scoreThreshold) + .sorted(Comparator.comparingDouble(SemanticSearchResult::getScore).reversed()) .map(SemanticSearchResult::getMediaRecordSegment) .filter(segment -> segment instanceof DocumentRecordSegment) .map(segment -> (DocumentRecordSegment) segment) + .limit(topSourceCount) .toList(); if(documentSegments.isEmpty()){ return new LectureQuestionResponse("No answer was found in the documents of the lecture.", List.of()); } + /** + // Skill level range from 0 to 1 + List skillLevels = getSkillLevelEntitiesForCourse(courseId, userId); + double averageSkillevel = skillLevels.stream() + .filter(Objects::nonNull) + .mapToDouble(Foo::getValue) + .average() + .orElse(0.0); + String skillLevelPromotContent; + if (averageSkillevel <= 0.3) { + skillLevelPromotContent = SKILL_LEVEL_PROMPT_TEMPLATES.get(0); + } else if (averageSkillevel < 0.7) { + skillLevelPromotContent = SKILL_LEVEL_PROMPT_TEMPLATES.get(1); + } else { + skillLevelPromotContent = SKILL_LEVEL_PROMPT_TEMPLATES.get(2); + } + */ + String skillLevelPromotContent = ""; // remove later + LectureQuestionResponse errorResponse = new LectureQuestionResponse(ERROR_MESSAGE, List.of()); String prompt = ollamaService.getTemplate(PROMPT_TEMPLATES.get(1)); String contentString = semanticSearchService.formatIntoNumberedListForPrompt( documentSegments.stream().map(DocumentRecordSegment::getText).toList()); List promptArgs = List.of( TemplateArgs.builder().argumentName("question").argumentValue(question).build(), - TemplateArgs.builder().argumentName("content").argumentValue(contentString).build() + TemplateArgs.builder().argumentName("content").argumentValue(contentString).build(), + TemplateArgs.builder().argumentName("skill").argumentValue(skillLevelPromotContent).build() ); TutorAnswer response = ollamaService.startQuery( diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserPlayerTypeService.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserPlayerTypeService.java new file mode 100644 index 0000000..e0a6942 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserPlayerTypeService.java @@ -0,0 +1,68 @@ +package de.unistuttgart.iste.meitrex.tutor_service.service; + +import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.UserPlayerTypeEntity; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.repository.UserPlayerTypeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +/** + * Service for managing user player type information. + * Handles saving and retrieving player type data from events. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class UserPlayerTypeService { + + private final UserPlayerTypeRepository userPlayerTypeRepository; + + /** + * Saves or updates user player type information from an event. + * + * @param userId the ID of the user + * @param primaryPlayerType the user's primary player type + * @param playerTypePercentages map of all player type percentages + */ + @Transactional + public void saveUserPlayerType(UUID userId, HexadPlayerType primaryPlayerType, Map playerTypePercentages) { + log.info("Saving player type for user {}: primaryType={}", userId, primaryPlayerType); + + UserPlayerTypeEntity entity = userPlayerTypeRepository.findById(userId) + .orElse(UserPlayerTypeEntity.builder() + .userId(userId) + .build()); + + entity.setPrimaryPlayerType(primaryPlayerType); + entity.setPlayerTypePercentagesFromMap(playerTypePercentages); + + userPlayerTypeRepository.save(entity); + } + + /** + * Retrieves user player type information by user ID. + * + * @param userId the ID of the user + * @return Optional containing the user's player type entity if found + */ + public Optional getUserPlayerType(UUID userId) { + return userPlayerTypeRepository.findById(userId); + } + + /** + * Gets the primary player type for a user. + * + * @param userId the ID of the user + * @return Optional containing the primary player type if found + */ + public Optional getPrimaryPlayerType(UUID userId) { + return userPlayerTypeRepository.findById(userId) + .map(UserPlayerTypeEntity::getPrimaryPlayerType); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9947fcb..0ecdd04 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -23,6 +23,8 @@ ollama.model=llama3:8b-instruct-q4_0 semantic.search.threshold.hint=0.4 semantic.search.threshold.tutor=0.4 +semantic.search.topN.tutor=5 content_service.url=http://app-content:4001/graphql +gamification_service.url=http://app-gamification:1201/graphql docproc.url=http://app-docprocai:9901/graphql/ diff --git a/src/main/resources/prompt_templates/answer_lecture_question_prompt.txt b/src/main/resources/prompt_templates/answer_lecture_question_prompt.txt index caa5a62..0e9f687 100644 --- a/src/main/resources/prompt_templates/answer_lecture_question_prompt.txt +++ b/src/main/resources/prompt_templates/answer_lecture_question_prompt.txt @@ -12,6 +12,9 @@ Your entire response must be a valid JSON object of the following form: {"answer": "your answer here"} +You know how well the student usually performs in this course. +{{skill}} + If it helps clarity, you may refer to specific parts of the lecture content using their assigned numbers (e.g., "[1]", "[2]", etc.). --- diff --git a/src/main/resources/prompt_templates/generate_hint.md b/src/main/resources/prompt_templates/generate_hint.md index cdf6f92..2545086 100644 --- a/src/main/resources/prompt_templates/generate_hint.md +++ b/src/main/resources/prompt_templates/generate_hint.md @@ -4,6 +4,7 @@ Your role is to provide clear, concise, and actionable hints that encourage the Assume the student has read the course segments but may not fully recall all details. Use the lecture content as the sole source of information for your hint, and prioritize this over general knowledge. +{{gamificationPrompt}} ### Goal: 1. Generate a helpful **hint** that encourages the student to think critically and progress toward solving the question **without revealing the answer**. From 6f821b53a5d66bf1b857d0c57f27703239a39401 Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Wed, 3 Dec 2025 02:17:06 +0100 Subject: [PATCH 02/24] added skill level to prompt, stores skill level, no longer uses sql --- build.gradle | 2 +- .../01_create_user_player_type_table.sql | 15 ---- .../controller/SubscriptionController.java | 26 +++++++ .../entity/UserPlayerTypeEntity.java | 4 +- .../entity/UserSkillLevelEntity.java | 45 ++++++++++++ .../mapper/UserPlayerTypeMapper.java | 56 ++++++++++++++ .../mapper/UserSkillLevelMapper.java | 47 ++++++++++++ .../repository/UserSkillLevelRepository.java | 23 ++++++ .../tutor_service/service/TutorService.java | 49 +++++++++---- .../service/UserPlayerTypeService.java | 15 ++-- .../service/UserSkillLevelService.java | 73 +++++++++++++++++++ src/main/resources/application.properties | 6 ++ 12 files changed, 324 insertions(+), 37 deletions(-) delete mode 100644 pg-init-scripts/01_create_user_player_type_table.sql create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/UserSkillLevelEntity.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserPlayerTypeMapper.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserSkillLevelMapper.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/UserSkillLevelRepository.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserSkillLevelService.java diff --git a/build.gradle b/build.gradle index 43058b2..fa6a548 100644 --- a/build.gradle +++ b/build.gradle @@ -108,7 +108,7 @@ repositories { } dependencies { - implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.12pre2' + implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.12pre5' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-graphql' implementation 'org.springframework.boot:spring-boot-starter-validation' diff --git a/pg-init-scripts/01_create_user_player_type_table.sql b/pg-init-scripts/01_create_user_player_type_table.sql deleted file mode 100644 index 38bdb5b..0000000 --- a/pg-init-scripts/01_create_user_player_type_table.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Create user_player_type table -CREATE TABLE IF NOT EXISTS user_player_type ( - user_id UUID PRIMARY KEY, - primary_player_type VARCHAR(50) NOT NULL, - achiever_score REAL, - player_score REAL, - socialiser_score REAL, - free_spirit_score REAL, - philanthropist_score REAL, - disruptor_score REAL -); - --- Create index on primary_player_type for faster queries -CREATE INDEX IF NOT EXISTS idx_user_player_type_primary - ON user_player_type(primary_player_type); diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java index a94de6b..f15cc19 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java @@ -2,7 +2,9 @@ import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; import de.unistuttgart.iste.meitrex.common.event.UserHexadPlayerTypeSetEvent; +import de.unistuttgart.iste.meitrex.common.event.skilllevels.UserSkillLevelChangedEvent; import de.unistuttgart.iste.meitrex.tutor_service.service.UserPlayerTypeService; +import de.unistuttgart.iste.meitrex.tutor_service.service.UserSkillLevelService; import io.dapr.Topic; import io.dapr.client.domain.CloudEvent; import lombok.RequiredArgsConstructor; @@ -25,6 +27,7 @@ public class SubscriptionController { private final UserPlayerTypeService userPlayerTypeService; + private final UserSkillLevelService userSkillLevelService; /** * Handles the user-hexad-player-type-set event. @@ -59,4 +62,27 @@ public Mono onUserHexadPlayerTypeSetEvent(@RequestBody CloudEvent for reactive processing + */ + @Topic(name = "user-skill-level-changed", pubsubName = "meitrex") + @PostMapping(path = "/user-skill-level-changed-pubsub") + public Mono onUserSkillLevelChangedEvent(@RequestBody CloudEvent cloudEvent, + @RequestHeader Map headers) { + return Mono.fromRunnable(() -> { + UserSkillLevelChangedEvent event = cloudEvent.getData(); + + userSkillLevelService.saveUserSkillLevel( + event.getUserId(), + event.getSkillId(), + event.getNewValue() + ); + }); + } } diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/UserPlayerTypeEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/UserPlayerTypeEntity.java index 2d1c41e..3614e4e 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/UserPlayerTypeEntity.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/UserPlayerTypeEntity.java @@ -13,7 +13,9 @@ * Stores the primary player type and all six player type scores. */ @Entity -@Table(name = "user_player_type") +@Table(name = "user_player_type", indexes = { + @Index(name = "idx_user_player_type_primary", columnList = "primary_player_type") +}) @Data @Builder @NoArgsConstructor diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/UserSkillLevelEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/UserSkillLevelEntity.java new file mode 100644 index 0000000..e82e1ed --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/UserSkillLevelEntity.java @@ -0,0 +1,45 @@ +package de.unistuttgart.iste.meitrex.tutor_service.persistence.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.io.Serializable; +import java.util.UUID; + +/** + * Entity representing a user's skill level for a specific skill. + * Stores the userId, skillId, and the skill level value. + */ +@Entity +@Table(name = "user_skill_level", indexes = { + @Index(name = "idx_user_skill_level_user_id", columnList = "user_id") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@IdClass(UserSkillLevelEntity.UserSkillLevelId.class) +public class UserSkillLevelEntity { + + @Id + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Id + @Column(name = "skill_id", nullable = false) + private UUID skillId; + + @Column(name = "skill_level_value", nullable = false) + private Float skillLevelValue; + + /** + * Composite primary key for UserSkillLevelEntity. + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class UserSkillLevelId implements Serializable { + private UUID userId; + private UUID skillId; + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserPlayerTypeMapper.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserPlayerTypeMapper.java new file mode 100644 index 0000000..a07a76d --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserPlayerTypeMapper.java @@ -0,0 +1,56 @@ +package de.unistuttgart.iste.meitrex.tutor_service.persistence.mapper; + +import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.UserPlayerTypeEntity; +import lombok.RequiredArgsConstructor; +import org.modelmapper.ModelMapper; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.UUID; + +/** + * Mapper for UserPlayerTypeEntity. + * Handles conversion between entity and domain models. + */ +@Component +@RequiredArgsConstructor +public class UserPlayerTypeMapper { + + private final ModelMapper modelMapper; + + /** + * Creates a UserPlayerTypeEntity from event data. + * + * @param userId the user ID + * @param primaryPlayerType the primary player type + * @param playerTypePercentages map of player type percentages + * @return the entity + */ + public UserPlayerTypeEntity createEntity(UUID userId, + HexadPlayerType primaryPlayerType, + Map playerTypePercentages) { + UserPlayerTypeEntity entity = UserPlayerTypeEntity.builder() + .userId(userId) + .primaryPlayerType(primaryPlayerType) + .build(); + entity.setPlayerTypePercentagesFromMap(playerTypePercentages); + return entity; + } + + /** + * Updates an existing entity with new data. + * + * @param entity the entity to update + * @param primaryPlayerType the new primary player type + * @param playerTypePercentages the new percentages + * @return the updated entity + */ + public UserPlayerTypeEntity updateEntity(UserPlayerTypeEntity entity, + HexadPlayerType primaryPlayerType, + Map playerTypePercentages) { + entity.setPrimaryPlayerType(primaryPlayerType); + entity.setPlayerTypePercentagesFromMap(playerTypePercentages); + return entity; + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserSkillLevelMapper.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserSkillLevelMapper.java new file mode 100644 index 0000000..5626594 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserSkillLevelMapper.java @@ -0,0 +1,47 @@ +package de.unistuttgart.iste.meitrex.tutor_service.persistence.mapper; + +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.UserSkillLevelEntity; +import lombok.RequiredArgsConstructor; +import org.modelmapper.ModelMapper; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Mapper for UserSkillLevelEntity. + * Handles conversion between entity and domain models. + */ +@Component +@RequiredArgsConstructor +public class UserSkillLevelMapper { + + private final ModelMapper modelMapper; + + /** + * Creates a UserSkillLevelEntity from event data. + * + * @param userId the user ID + * @param skillId the skill ID + * @param skillLevelValue the skill level value + * @return the entity + */ + public UserSkillLevelEntity createEntity(UUID userId, UUID skillId, Float skillLevelValue) { + return UserSkillLevelEntity.builder() + .userId(userId) + .skillId(skillId) + .skillLevelValue(skillLevelValue) + .build(); + } + + /** + * Updates an existing entity with a new skill level value. + * + * @param entity the entity to update + * @param skillLevelValue the new skill level value + * @return the updated entity + */ + public UserSkillLevelEntity updateEntity(UserSkillLevelEntity entity, Float skillLevelValue) { + entity.setSkillLevelValue(skillLevelValue); + return entity; + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/UserSkillLevelRepository.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/UserSkillLevelRepository.java new file mode 100644 index 0000000..8080f22 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/UserSkillLevelRepository.java @@ -0,0 +1,23 @@ +package de.unistuttgart.iste.meitrex.tutor_service.persistence.repository; + +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.UserSkillLevelEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +/** + * Repository interface for UserSkillLevelEntity. + * Provides database access for user skill level information. + */ +@Repository +public interface UserSkillLevelRepository extends JpaRepository { + + /** + * Find all skill levels for a specific user. + * @param userId the ID of the user + * @return list of all skill levels for the user + */ + List findByUserId(UUID userId); +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java index 7a6100f..5b1eb01 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java @@ -2,6 +2,7 @@ import de.unistuttgart.iste.meitrex.common.event.AskedTutorAQuestionEvent; import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; +import de.unistuttgart.iste.meitrex.common.event.RequestUserSkillLevelEvent; import de.unistuttgart.iste.meitrex.common.event.TutorCategory; import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; import de.unistuttgart.iste.meitrex.common.dapr.TopicPublisher; @@ -9,6 +10,7 @@ import de.unistuttgart.iste.meitrex.generated.dto.LectureQuestionResponse; import de.unistuttgart.iste.meitrex.generated.dto.Source; import de.unistuttgart.iste.meitrex.generated.dto.VideoSource; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.UserSkillLevelEntity; import de.unistuttgart.iste.meitrex.tutor_service.service.models.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -30,10 +32,15 @@ public class TutorService { private final SemanticSearchService semanticSearchService; private final TopicPublisher topicPublisher; private final UserPlayerTypeService userPlayerTypeService; + private final UserSkillLevelService userSkillLevelService; @Value("${semantic.search.threshold.tutor:0.4}") private double scoreThreshold; @Value("${semantic.search.topN.tutor:5}") private long topSourceCount; + @Value("${skill.level.threshold.low:0.3}") + private double skillLevelLowThreshold; + @Value("${skill.level.threshold.high:0.7}") + private double skillLevelHighThreshold; private final String ERROR_MESSAGE = ("Oops, something went wrong! " + "The request could not be processed. Please try again."); @@ -43,9 +50,11 @@ public class TutorService { "answer_lecture_question_prompt.txt" ); private static final List SKILL_LEVEL_PROMPT_TEMPLATES = List.of( - "low skill level", - "normal skill level", - "high performer" + "Provide a clear and simple hint that gently guides the user toward the next step without overwhelming them.", + "Give the user a balanced hint that assists their reasoning while still allowing them to work out the solution independently. " + + "Assume general familiarity with the topic", + "Offer a sophisticated and subtle hint that challenges the users understanding without giving away the solution. " + + "Use precise, domain-specific language and encourage deeper analysis or alternative approaches" ); /** @@ -130,24 +139,36 @@ private LectureQuestionResponse answerLectureQuestion(String question, UUID cour return new LectureQuestionResponse("No answer was found in the documents of the lecture.", List.of()); } - /** // Skill level range from 0 to 1 - List skillLevels = getSkillLevelEntitiesForCourse(courseId, userId); - double averageSkillevel = skillLevels.stream() - .filter(Objects::nonNull) - .mapToDouble(Foo::getValue) - .average() - .orElse(0.0); + List skillLevels = userSkillLevelService.getAllSkillLevelsForUser(currentUser.getId()); + double averageSkillLevel; + + if (skillLevels.isEmpty()) { + RequestUserSkillLevelEvent requestEvent = RequestUserSkillLevelEvent.builder() + .userId(currentUser.getId()) + .build(); + topicPublisher.notifyRequestUserSkillLevel(requestEvent); + + averageSkillLevel = 0.5; + } else { + averageSkillLevel = skillLevels.stream() + .filter(Objects::nonNull) + .mapToDouble(UserSkillLevelEntity::getSkillLevelValue) + .average() + .orElse(0.5); + log.info("User {} has {} skill levels with average: {}", + currentUser.getId(), skillLevels.size(), averageSkillLevel); + } + String skillLevelPromotContent; - if (averageSkillevel <= 0.3) { + if (averageSkillLevel <= skillLevelLowThreshold) { skillLevelPromotContent = SKILL_LEVEL_PROMPT_TEMPLATES.get(0); - } else if (averageSkillevel < 0.7) { + log.info("Using low skill level prompt for user {}", currentUser.getId()); + } else if (averageSkillLevel < skillLevelHighThreshold) { skillLevelPromotContent = SKILL_LEVEL_PROMPT_TEMPLATES.get(1); } else { skillLevelPromotContent = SKILL_LEVEL_PROMPT_TEMPLATES.get(2); } - */ - String skillLevelPromotContent = ""; // remove later LectureQuestionResponse errorResponse = new LectureQuestionResponse(ERROR_MESSAGE, List.of()); String prompt = ollamaService.getTemplate(PROMPT_TEMPLATES.get(1)); diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserPlayerTypeService.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserPlayerTypeService.java index e0a6942..a1d4c66 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserPlayerTypeService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserPlayerTypeService.java @@ -2,6 +2,7 @@ import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.UserPlayerTypeEntity; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.mapper.UserPlayerTypeMapper; import de.unistuttgart.iste.meitrex.tutor_service.persistence.repository.UserPlayerTypeRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,6 +23,7 @@ public class UserPlayerTypeService { private final UserPlayerTypeRepository userPlayerTypeRepository; + private final UserPlayerTypeMapper userPlayerTypeMapper; /** * Saves or updates user player type information from an event. @@ -34,13 +36,14 @@ public class UserPlayerTypeService { public void saveUserPlayerType(UUID userId, HexadPlayerType primaryPlayerType, Map playerTypePercentages) { log.info("Saving player type for user {}: primaryType={}", userId, primaryPlayerType); - UserPlayerTypeEntity entity = userPlayerTypeRepository.findById(userId) - .orElse(UserPlayerTypeEntity.builder() - .userId(userId) - .build()); + Optional existingEntity = userPlayerTypeRepository.findById(userId); - entity.setPrimaryPlayerType(primaryPlayerType); - entity.setPlayerTypePercentagesFromMap(playerTypePercentages); + UserPlayerTypeEntity entity; + if (existingEntity.isPresent()) { + entity = userPlayerTypeMapper.updateEntity(existingEntity.get(), primaryPlayerType, playerTypePercentages); + } else { + entity = userPlayerTypeMapper.createEntity(userId, primaryPlayerType, playerTypePercentages); + } userPlayerTypeRepository.save(entity); } diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserSkillLevelService.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserSkillLevelService.java new file mode 100644 index 0000000..4b9ed40 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserSkillLevelService.java @@ -0,0 +1,73 @@ +package de.unistuttgart.iste.meitrex.tutor_service.service; + +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.UserSkillLevelEntity; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.mapper.UserSkillLevelMapper; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.repository.UserSkillLevelRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Service for managing user skill level information. + * Handles saving and retrieving skill level data from events. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class UserSkillLevelService { + + private final UserSkillLevelRepository userSkillLevelRepository; + private final UserSkillLevelMapper userSkillLevelMapper; + + /** + * Saves or updates user skill level information from an event. + * + * @param userId the ID of the user + * @param skillId the ID of the skill + * @param skillLevelValue the new skill level value + */ + @Transactional + public void saveUserSkillLevel(UUID userId, UUID skillId, Float skillLevelValue) { + log.info("Saving skill level for user {} and skill {}: value={}", userId, skillId, skillLevelValue); + + UserSkillLevelEntity.UserSkillLevelId id = new UserSkillLevelEntity.UserSkillLevelId(userId, skillId); + Optional existingEntity = userSkillLevelRepository.findById(id); + + UserSkillLevelEntity entity; + if (existingEntity.isPresent()) { + entity = userSkillLevelMapper.updateEntity(existingEntity.get(), skillLevelValue); + } else { + entity = userSkillLevelMapper.createEntity(userId, skillId, skillLevelValue); + } + + userSkillLevelRepository.save(entity); + } + + /** + * Retrieves a specific skill level for a user. + * + * @param userId the ID of the user + * @param skillId the ID of the skill + * @return Optional containing the skill level value if found + */ + public Optional getSkillLevel(UUID userId, UUID skillId) { + UserSkillLevelEntity.UserSkillLevelId id = new UserSkillLevelEntity.UserSkillLevelId(userId, skillId); + return userSkillLevelRepository.findById(id) + .map(UserSkillLevelEntity::getSkillLevelValue); + } + + /** + * Retrieves all skill levels for a user. + * + * @param userId the ID of the user + * @return List of all skill level entities for the user + */ + public List getAllSkillLevelsForUser(UUID userId) { + return userSkillLevelRepository.findByUserId(userId); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0ecdd04..82b094b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -25,6 +25,12 @@ semantic.search.threshold.hint=0.4 semantic.search.threshold.tutor=0.4 semantic.search.topN.tutor=5 +skill.level.threshold.low=0.3 +skill.level.threshold.high=0.7 + +spring.jpa.hibernate.ddl-auto=update +spring.jpa.open-in-view=false + content_service.url=http://app-content:4001/graphql gamification_service.url=http://app-gamification:1201/graphql docproc.url=http://app-docprocai:9901/graphql/ From 58d5596c5573d8ab1daf2878f3dc7b97c5ac3efe Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Sun, 14 Dec 2025 18:32:16 +0100 Subject: [PATCH 03/24] added proactive feedback. Currently accessable through query in the frontend --- .../controller/SubscriptionController.java | 40 +++ .../controller/TutorController.java | 50 +++ .../entity/ProactiveFeedbackEntity.java | 46 +++ .../ProactiveFeedbackRepository.java | 41 +++ .../tutor_service/service/HintService.java | 15 +- .../service/ProactiveFeedbackService.java | 315 ++++++++++++++++++ .../tutor_service/service/TutorService.java | 14 + src/main/resources/application.properties | 2 + .../graphql/service/mutation.graphqls | 17 + .../resources/graphql/service/query.graphqls | 10 + .../proactive_feedback_prompt.txt | 17 + 11 files changed, 563 insertions(+), 4 deletions(-) create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/ProactiveFeedbackEntity.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/ProactiveFeedbackRepository.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/ProactiveFeedbackService.java create mode 100644 src/main/resources/prompt_templates/proactive_feedback_prompt.txt diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java index f15cc19..3236113 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java @@ -1,8 +1,10 @@ package de.unistuttgart.iste.meitrex.tutor_service.controller; +import de.unistuttgart.iste.meitrex.common.event.ContentProgressedEvent; import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; import de.unistuttgart.iste.meitrex.common.event.UserHexadPlayerTypeSetEvent; import de.unistuttgart.iste.meitrex.common.event.skilllevels.UserSkillLevelChangedEvent; +import de.unistuttgart.iste.meitrex.tutor_service.service.ProactiveFeedbackService; import de.unistuttgart.iste.meitrex.tutor_service.service.UserPlayerTypeService; import de.unistuttgart.iste.meitrex.tutor_service.service.UserSkillLevelService; import io.dapr.Topic; @@ -28,6 +30,7 @@ public class SubscriptionController { private final UserPlayerTypeService userPlayerTypeService; private final UserSkillLevelService userSkillLevelService; + private final ProactiveFeedbackService proactiveFeedbackService; /** * Handles the user-hexad-player-type-set event. @@ -85,4 +88,41 @@ public Mono onUserSkillLevelChangedEvent(@RequestBody CloudEvent for reactive processing + */ + @Topic(name = "content-progressed", pubsubName = "meitrex") + @PostMapping(path = "/content-progressed-pubsub") + public Mono onContentProgressedEvent(@RequestBody CloudEvent cloudEvent, + @RequestHeader Map headers) { + return Mono.fromRunnable(() -> { + try { + ContentProgressedEvent event = cloudEvent.getData(); + + log.info("Received ContentProgressedEvent for user: {}, content: {}, type: {}", + event.getUserId(), + event.getContentId(), + event.getContentType()); + + if (event.getContentType() == ContentProgressedEvent.ContentType.ASSIGNMENT || + event.getContentType() == ContentProgressedEvent.ContentType.QUIZ) { + try { + proactiveFeedbackService.generateFeedback(event); + } catch (Exception e) { + log.error("Failed to generate feedback for user {} on content {}: {}", + event.getUserId(), event.getContentId(), e.getMessage(), e); + } + } + } catch (Exception e) { + log.error("Error processing ContentProgressedEvent: {}", e.getMessage(), e); + } + }); + } } diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/TutorController.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/TutorController.java index 477551a..7288c6a 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/TutorController.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/TutorController.java @@ -1,17 +1,22 @@ package de.unistuttgart.iste.meitrex.tutor_service.controller; +import de.unistuttgart.iste.meitrex.generated.dto.ProactiveFeedback; import de.unistuttgart.iste.meitrex.generated.dto.HintGenerationInput; import de.unistuttgart.iste.meitrex.generated.dto.HintResponse; import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; import de.unistuttgart.iste.meitrex.generated.dto.LectureQuestionResponse; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.ProactiveFeedbackEntity; +import de.unistuttgart.iste.meitrex.tutor_service.service.ProactiveFeedbackService; import de.unistuttgart.iste.meitrex.tutor_service.service.HintService; import de.unistuttgart.iste.meitrex.tutor_service.service.TutorService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.reactivestreams.Publisher; import org.springframework.graphql.data.method.annotation.Argument; import org.springframework.graphql.data.method.annotation.ContextValue; import org.springframework.graphql.data.method.annotation.MutationMapping; import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.graphql.data.method.annotation.SubscriptionMapping; import org.springframework.stereotype.Controller; import java.util.List; @@ -24,6 +29,7 @@ public class TutorController { private final TutorService tutorService; private final HintService hintService; + private final ProactiveFeedbackService proactiveFeedbackService; @MutationMapping public LectureQuestionResponse sendMessage( @@ -52,4 +58,48 @@ public HintResponse generateHint( return hintService.generateHintWithQuestion(questionInput, courseId, currentUser); } + @QueryMapping + public ProactiveFeedback proactiveFeedback( + @Argument final UUID assessmentId, + @ContextValue final LoggedInUser currentUser + ) { + return proactiveFeedbackService.getFeedbackForAssignment(currentUser.getId(), assessmentId) + .map(this::entityToDto) + .orElse(null); + } + + @QueryMapping + public List allProactiveFeedback( + @ContextValue final LoggedInUser currentUser + ) { + return proactiveFeedbackService.getAllFeedbackForUser(currentUser.getId()) + .stream() + .map(this::entityToDto) + .toList(); + } + + /** + * GraphQL subscription for real-time proactive feedback. + * Frontend subscribes to this and receives feedback automatically when generated. + * + * @param userId the user to receive feedback for + * @return publisher emitting ProactiveFeedback events + */ + @SubscriptionMapping + public Publisher proactiveFeedbackAdded(@Argument final UUID userId) { + log.info("User {} subscribed to proactive feedback", userId); + return proactiveFeedbackService.proactiveFeedbackStream(userId); + } + + private ProactiveFeedback entityToDto(ProactiveFeedbackEntity entity) { + return ProactiveFeedback.builder() + .setId(entity.getId()) + .setAssessmentId(entity.getAssessmentId()) + .setFeedbackText(entity.getFeedbackText()) + .setCorrectness(entity.getCorrectness()) + .setSuccess(entity.getSuccess()) + .setCreatedAt(entity.getCreatedAt()) + .build(); + } + } diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/ProactiveFeedbackEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/ProactiveFeedbackEntity.java new file mode 100644 index 0000000..bbbde06 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/ProactiveFeedbackEntity.java @@ -0,0 +1,46 @@ +package de.unistuttgart.iste.meitrex.tutor_service.persistence.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Entity representing feedback generated for a user after completing an assignment. + */ +@Entity +@Table(name = "proactive_feedback", indexes = { + @Index(name = "idx_proactive_feedback_user_id", columnList = "user_id"), + @Index(name = "idx_proactive_feedback_assessment_id", columnList = "assessment_id"), + @Index(name = "idx_proactive_feedback_user_assessment", columnList = "user_id,assessment_id") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ProactiveFeedbackEntity { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "id", nullable = false) + private UUID id; + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(name = "assessment_id", nullable = false) + private UUID assessmentId; + + @Column(name = "feedback_text", nullable = false, columnDefinition = "TEXT") + private String feedbackText; + + @Column(name = "correctness", nullable = false) + private Double correctness; + + @Column(name = "success", nullable = false) + private Boolean success; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/ProactiveFeedbackRepository.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/ProactiveFeedbackRepository.java new file mode 100644 index 0000000..6601f7e --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/ProactiveFeedbackRepository.java @@ -0,0 +1,41 @@ +package de.unistuttgart.iste.meitrex.tutor_service.persistence.repository; + +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.ProactiveFeedbackEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Repository for ProactiveFeedbackEntity. + */ +@Repository +public interface ProactiveFeedbackRepository extends JpaRepository { + + /** + * Finds all feedback entries for a specific user. + * + * @param userId the user ID + * @return list of feedback entities + */ + List findByUserIdOrderByCreatedAtDesc(UUID userId); + + /** + * Finds the most recent feedback for a user on a specific assessment. + * + * @param userId the user ID + * @param assessmentId the assessment ID + * @return optional feedback entity + */ + Optional findFirstByUserIdAndAssessmentIdOrderByCreatedAtDesc(UUID userId, UUID assessmentId); + + /** + * Finds all feedback for a specific assessment. + * + * @param assessmentId the assessment ID + * @return list of feedback entities + */ + List findByAssessmentIdOrderByCreatedAtDesc(UUID assessmentId); +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/HintService.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/HintService.java index 9987c2e..1534154 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/HintService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/HintService.java @@ -69,7 +69,7 @@ public HintResponse generateHintWithQuestion( } /* - * Now generate the hint given the question, answer options (which is injected via the questionPrompt) and + * Now generate the hint given the question, answer options (which is injected via the questionPrompt) and * the text inside segments which have been found relevant via the semantic search */ String prompt = ollamaService.getTemplate(PROMPT_TEMPLATES.get("GENERATION")); @@ -85,19 +85,26 @@ public HintResponse generateHintWithQuestion( return ollamaService.startQuery(HintResponse.class, prompt, promptArgs, new HintResponse("An error occurred")); } + /** + * Generates a gamification prompt based on the user's primary player type. + * Returns an empty string if no player type is found or an error occurs. + * + * @param courseId the ID of the course + * @param currentUser the current logged-in user + * @return the gamification prompt string + */ private String generateGamificationPrompt(UUID courseId, final LoggedInUser currentUser) { try { Optional playerTypeOpt = userPlayerTypeService.getPrimaryPlayerType(currentUser.getId()); if (playerTypeOpt.isEmpty()) { log.warn("No player type found for user {}", currentUser.getId()); - return ""; // Return empty string if no player type available + return ""; } HexadPlayerType playerType = playerTypeOpt.get(); log.info("Player type for user {}: {}", currentUser.getId(), playerType); - // Generate personalized prompt based on player type return switch (playerType) { case ACHIEVER -> "Give the user a small hint that helps them solve the problem without revealing the full solution. " + @@ -115,7 +122,7 @@ private String generateGamificationPrompt(UUID courseId, final LoggedInUser curr }; } catch (Exception e) { log.error("Error fetching player type for user {}: {}", currentUser.getId(), e.getMessage()); - return ""; // Return empty string on error to not break hint generation + return ""; } } diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/ProactiveFeedbackService.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/ProactiveFeedbackService.java new file mode 100644 index 0000000..4fb45b9 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/ProactiveFeedbackService.java @@ -0,0 +1,315 @@ +package de.unistuttgart.iste.meitrex.tutor_service.service; + +import de.unistuttgart.iste.meitrex.common.event.ContentProgressedEvent; +import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; +import de.unistuttgart.iste.meitrex.generated.dto.ProactiveFeedback; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.ProactiveFeedbackEntity; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.UserSkillLevelEntity; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.repository.ProactiveFeedbackRepository; +import de.unistuttgart.iste.meitrex.tutor_service.service.models.TemplateArgs; +import de.unistuttgart.iste.meitrex.tutor_service.service.models.TutorAnswer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.reactivestreams.Publisher; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Sinks; + +import java.time.OffsetDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Service for generating personalized tutor feedback after assignment completion. + * Supports real-time streaming of feedback via GraphQL subscriptions. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ProactiveFeedbackService { + + private final OllamaService ollamaService; + private final UserPlayerTypeService userPlayerTypeService; + private final UserSkillLevelService userSkillLevelService; + private final ProactiveFeedbackRepository proactiveFeedbackRepository; + + /** + * Per-user reactive sinks for streaming feedback to subscribers. + * Emits ProactiveFeedback DTOs for querying feedback history. + */ + private final ConcurrentMap> feedbackSinks = new ConcurrentHashMap<>(); + + @Value("${skill.level.threshold.low:0.3}") + private double skillLevelLowThreshold; + + @Value("${skill.level.threshold.high:0.7}") + private double skillLevelHighThreshold; + + @Value("${correctness.level.high:0.8}") + private double correctnessLevelHigh; + + @Value("${correctness.level.max:0.99}") + private double correctnessLevelMax; + + + private static final String FEEDBACK_PROMPT_TEMPLATE = "proactive_feedback_prompt.txt"; + + /** + * Returns a per-user stream for GraphQL subscription to proactive feedback. + * Frontend can subscribe to this to receive feedback DTOs for history/display. + * + * @param userId user id + * @return publisher emitting ProactiveFeedback for this user + */ + public Publisher proactiveFeedbackStream(final UUID userId) { + return feedbackSinks.computeIfAbsent(userId, k -> Sinks.many().multicast().onBackpressureBuffer()) + .asFlux(); + } + + /** + * Publishes feedback to a specific user's feedback stream. + * + * @param userId user id + * @param feedback + */ + private void publishFeedbackToUser(final UUID userId, final ProactiveFeedback feedback) { + final var sink = feedbackSinks.get(userId); + if (sink != null) { + sink.tryEmitNext(feedback); + log.debug("Published feedback to user feedback stream: userId={}", userId); + } + } + + /** + * Generates personalized feedback for the user based on their assignment performance. + * Takes into account the user's player type and skill level to tailor the response. + * Automatically pushes feedback to subscribed clients via GraphQL subscription AND + * sends the message to the user's chat window. + * + * @param event the content progressed event containing assignment completion data + * @return the generated feedback message, or null if feedback generation fails + */ + public String generateFeedback(ContentProgressedEvent event) { + try { + log.info("Generating feedback for user {} on assignment {}", + event.getUserId(), event.getContentId()); + + String individualizedPrompt = getIndivualizedPromotProactiveTestDone(event.getUserId(), event.getCorrectness()); + + String performanceContext = getPerformanceContext(event); + + String prompt = ollamaService.getTemplate(FEEDBACK_PROMPT_TEMPLATE); + List promptArgs = List.of( + TemplateArgs.builder() + .argumentName("correctness") + .argumentValue(String.format("%.2f", event.getCorrectness())) + .build(), + TemplateArgs.builder() + .argumentName("performance") + .argumentValue(performanceContext) + .build(), + TemplateArgs.builder() + .argumentName("individualizedPrompt") + .argumentValue(individualizedPrompt) + .build() + ); + + String error = "Oops, something went wrong generating proactive feedback! Your correctness was " + + String.format("%.2f", event.getCorrectness()) + + ". Please try again later."; + + TutorAnswer feedback = ollamaService.startQuery( + TutorAnswer.class, + prompt, + promptArgs, + new TutorAnswer(error) + ); + + ProactiveFeedbackEntity feedbackEntity = ProactiveFeedbackEntity.builder() + .userId(event.getUserId()) + .assessmentId(event.getContentId()) + .feedbackText(feedback.getAnswer()) + .correctness(event.getCorrectness()) + .success(event.isSuccess()) + .createdAt(OffsetDateTime.now()) + .build(); + + ProactiveFeedbackEntity savedEntity = proactiveFeedbackRepository.save(feedbackEntity); + + // Convert to DTO and publish to subscribed clients + ProactiveFeedback feedbackDto = ProactiveFeedback.builder() + .setId(savedEntity.getId()) + .setAssessmentId(savedEntity.getAssessmentId()) + .setFeedbackText(savedEntity.getFeedbackText()) + .setCorrectness(savedEntity.getCorrectness()) + .setSuccess(savedEntity.getSuccess()) + .setCreatedAt(savedEntity.getCreatedAt()) + .build(); + publishFeedbackToUser(event.getUserId(), feedbackDto); + + log.info("Generated and saved feedback for user {}: {}", event.getUserId(), feedback.getAnswer()); + return feedback.getAnswer(); + + } catch (Exception e) { + log.error("Failed to generate feedback for user {}: {}", + event.getUserId(), e.getMessage(), e); + return null; + } + } + + /** + * Retrieves the most recent feedback for a user on a specific assessment. + * + * @param userId the user ID + * @param assessmentId the assessment ID + * @return optional feedback entity + */ + public Optional getFeedbackForAssignment(UUID userId, UUID assessmentId) { + return proactiveFeedbackRepository.findFirstByUserIdAndAssessmentIdOrderByCreatedAtDesc(userId, assessmentId); + } + + /** + * Retrieves all feedback for a user, ordered by most recent first. + * + * @param userId the user ID + * @return list of feedback entities + */ + public List getAllFeedbackForUser(UUID userId) { + return proactiveFeedbackRepository.findByUserIdOrderByCreatedAtDesc(userId); + } + + /** + * Retrieves the most recent feedback for a user and deletes it. + * This is used when the frontend requests proactive feedback. + * + * @param userId the user ID + * @return optional feedback text, or empty if no feedback exists + */ + public Optional getAndDeleteLatestFeedback(UUID userId) { + List feedbackList = proactiveFeedbackRepository.findByUserIdOrderByCreatedAtDesc(userId); + + if (feedbackList.isEmpty()) { + return Optional.empty(); + } + + ProactiveFeedbackEntity latestFeedback = feedbackList.get(0); + String feedbackText = latestFeedback.getFeedbackText(); + + proactiveFeedbackRepository.delete(latestFeedback); + log.info("Retrieved and deleted latest feedback for user {}: feedbackId={}", userId, latestFeedback.getId()); + + return Optional.of(feedbackText); + } + + /** + * Gets player specific guidance for feedback personalization. + */ + private String getIndivualizedPromotProactiveTestDone(UUID userId, double correctness) { + try { + Optional playerType = userPlayerTypeService.getPrimaryPlayerType(userId); + if (playerType.isEmpty()) { + log.debug("No player type found for user {}, using generic feedback", userId); + return ""; + } + + HexadPlayerType type = playerType.get(); + + if (type == HexadPlayerType.ACHIEVER || type == HexadPlayerType.PHILANTHROPIST || + type == HexadPlayerType.SOCIALISER || type == HexadPlayerType.PLAYER) { + return getIndivualizedPromotProactiveTestDoneLogic(type, correctness); + } + + // use heighest player type for anything but DISRUPTOR or FREE_SPIRIT + if (type == HexadPlayerType.DISRUPTOR || type == HexadPlayerType.FREE_SPIRIT) { + Optional alternativeType = userPlayerTypeService.getUserPlayerType(userId) + .map(entity -> { + Map scores = entity.getPlayerTypePercentagesAsMap(); + Map map = new HashMap<>(); + map.put(scores.get(HexadPlayerType.ACHIEVER), HexadPlayerType.ACHIEVER); + map.put(scores.get(HexadPlayerType.SOCIALISER), HexadPlayerType.SOCIALISER); + map.put(scores.get(HexadPlayerType.PHILANTHROPIST), HexadPlayerType.PHILANTHROPIST); + map.put(scores.get(HexadPlayerType.PLAYER), HexadPlayerType.PLAYER); + + Map.Entry highestPlayerTypeWithHandling = + Collections.max(map.entrySet(), Map.Entry.comparingByKey()); + return highestPlayerTypeWithHandling.getValue(); + }); + + if (alternativeType.isPresent()) { + return getIndivualizedPromotProactiveTestDoneLogic(alternativeType.get(), correctness); + } + } + + return ""; + } catch (Exception e) { + log.warn("Failed to get individualized prompt for user {}: {}, using generic feedback", + userId, e.getMessage()); + return ""; + } + } + + /** + * YOU YIELD NOT PASS! *insert gandalf throwing a keyboard* + * + * @param playerType the user's primary player type. + * @requires to be ACHIEVER, PHILANTHROPIST, SOCIALISER or PLAYER + */ + private String getIndivualizedPromotProactiveTestDoneLogic(HexadPlayerType playerType, double correctness) { + return switch (playerType) { + case ACHIEVER -> { + if (correctness >= correctnessLevelMax) { + yield "Congratulate them on this perfect test and suggest the next challenge to tackle."; + } else { + yield "Suggest the test to be repeated to achieve perfection."; + } + } + case PHILANTHROPIST, SOCIALISER -> { + if (correctness >= correctnessLevelHigh) { + yield "Congratulate them on their great performance and suggest helping classmates who might struggle with the material."; + } else { + yield "Suggest reviewing the material together with classmates to improve understanding."; + } + } + case PLAYER -> { + if (correctness >= correctnessLevelMax) { + yield "Congratulate them on this perfect test and suggest the next challenge to unlock more rewards."; + } else { + yield "Suggest the test to be repeated to unlock full rewards."; + } + } + case DISRUPTOR, FREE_SPIRIT -> { + throw new IllegalArgumentException("DISRUPTOR and FREE_SPIRIT should not reach this logic branch"); + } + }; + } + + /** + * Gets skill level-specific guidance for feedback complexity. + */ + private String getSkillLevelGuidance(double averageSkillLevel) { + if (averageSkillLevel <= skillLevelLowThreshold) { + return "Use encouraging and simple language. Focus on building confidence."; + } else if (averageSkillLevel < skillLevelHighThreshold) { + return "Use balanced feedback that reinforces understanding and suggests next steps."; + } else { + return "Use advanced terminology. Challenge them with deeper insights and connections."; + } + } + + /** + * Determines performance context based on correctness and success. + */ + private String getPerformanceContext(ContentProgressedEvent event) { + double correctness = event.getCorrectness(); + boolean success = event.isSuccess(); + + if (success && (correctness >= correctnessLevelMax)) { + return "nearly perfect performance"; + } else if (success && (correctness >= correctnessLevelHigh)) { + return "solid understanding demonstrated"; + } else { + return "significant gaps in understanding"; + } + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java index 5b1eb01..b740857 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java @@ -33,6 +33,7 @@ public class TutorService { private final TopicPublisher topicPublisher; private final UserPlayerTypeService userPlayerTypeService; private final UserSkillLevelService userSkillLevelService; + private final ProactiveFeedbackService proactiveFeedbackService; @Value("${semantic.search.threshold.tutor:0.4}") private double scoreThreshold; @Value("${semantic.search.topN.tutor:5}") @@ -60,6 +61,7 @@ public class TutorService { /** * Handles a user’s question by categorizing it and returning an appropriate response. * Lecture questions are further processed, while other categories currently return default answers. + * Special handling: if the user input is "proactivefeedback", retrieves and deletes the latest saved feedback for the user. * * @param userQuestion the question asked by the user * @param courseId the ID of the course, required for lecture-related questions @@ -68,6 +70,18 @@ public class TutorService { */ public LectureQuestionResponse handleUserQuestion(String userQuestion, UUID courseId, LoggedInUser currentUser){ + // Special handling for proactive feedback retrieval. Will be removed once proactive feedback is integrated into the main flow and graphql works correctly. + if ("proactivefeedback".equalsIgnoreCase(userQuestion.trim())) { + Optional feedback = proactiveFeedbackService.getAndDeleteLatestFeedback(currentUser.getId()); + if (feedback.isPresent()) { + log.info("Retrieved proactive feedback for user {}", currentUser.getId()); + return new LectureQuestionResponse(feedback.get(), List.of()); + } else { + log.info("No proactive feedback available for user {}", currentUser.getId()); + return new LectureQuestionResponse("No proactive feedback available at the moment.", List.of()); + } + } + CategorizedQuestion categorizedQuestion = preprocessQuestion(userQuestion); TutorCategory category = categorizedQuestion.getCategory(); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 82b094b..847dbc8 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -28,6 +28,8 @@ semantic.search.topN.tutor=5 skill.level.threshold.low=0.3 skill.level.threshold.high=0.7 +correctness.level.high=0.8 + spring.jpa.hibernate.ddl-auto=update spring.jpa.open-in-view=false diff --git a/src/main/resources/graphql/service/mutation.graphqls b/src/main/resources/graphql/service/mutation.graphqls index 14cb714..8ca8faf 100644 --- a/src/main/resources/graphql/service/mutation.graphqls +++ b/src/main/resources/graphql/service/mutation.graphqls @@ -13,6 +13,14 @@ type Mutation { } +type Subscription { + """ + Subscribe to newly generated proactive feedback for the current user. + Emits feedback automatically when an assignment is completed. + """ + proactiveFeedbackAdded(userId: UUID!): ProactiveFeedback! +} + type LectureQuestionResponse { answer: String! sources: [Source!]! @@ -68,3 +76,12 @@ input AssociationPairInput { left: String! right: String! } + +type ProactiveFeedback { + id: UUID! + assessmentId: UUID! + feedbackText: String! + correctness: Float! + success: Boolean! + createdAt: DateTime! +} diff --git a/src/main/resources/graphql/service/query.graphqls b/src/main/resources/graphql/service/query.graphqls index 75d96c4..1be6f33 100644 --- a/src/main/resources/graphql/service/query.graphqls +++ b/src/main/resources/graphql/service/query.graphqls @@ -1,3 +1,13 @@ type Query { _empty: String + + """ + Retrieves the most recent proactive feedback for the current user on a specific assessment. + """ + proactiveFeedback(assessmentId: UUID!): ProactiveFeedback + + """ + Retrieves all proactive feedback for the current user, ordered by most recent first. + """ + allProactiveFeedback: [ProactiveFeedback!]! } \ No newline at end of file diff --git a/src/main/resources/prompt_templates/proactive_feedback_prompt.txt b/src/main/resources/prompt_templates/proactive_feedback_prompt.txt new file mode 100644 index 0000000..a39bad7 --- /dev/null +++ b/src/main/resources/prompt_templates/proactive_feedback_prompt.txt @@ -0,0 +1,17 @@ +You are an encouraging AI tutor providing personalized feedback to a student who just completed an assignment. + +**Assignment Performance:** +- Correctness: {{correctness}} (0.0 to 1.0) +- Performance Level: {{performance}} + +**Student Profile:** +- Player Type Guidance: {{individualizedPrompt}} + +**Your Task:** +Generate a personalized, encouraging feedback message (2-4 sentences) that: +1. Acknowledges their effort and performance +2. Provides constructive guidance aligned with their player type +3. Uses language complexity appropriate to their skill level +4. Motivates them for continued learning + +Be warm, supportive, and specific. If they passed, celebrate their success. If they didn't pass, encourage them to review and try again with specific suggestions. From d5728525fd5b6e349d8baadeff1a65d8c5cba17e Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Sun, 14 Dec 2025 19:08:28 +0100 Subject: [PATCH 04/24] bugfix filtering for sources --- .../iste/meitrex/tutor_service/service/TutorService.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java index b740857..d587a2b 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java @@ -146,7 +146,6 @@ private LectureQuestionResponse answerLectureQuestion(String question, UUID cour .map(SemanticSearchResult::getMediaRecordSegment) .filter(segment -> segment instanceof DocumentRecordSegment) .map(segment -> (DocumentRecordSegment) segment) - .limit(topSourceCount) .toList(); if(documentSegments.isEmpty()){ @@ -198,6 +197,10 @@ private LectureQuestionResponse answerLectureQuestion(String question, UUID cour TutorAnswer.class, prompt, promptArgs, new TutorAnswer(ERROR_MESSAGE)); List sources = segmentSearchResults.stream() + .filter(result -> result.getScore() <= scoreThreshold) + .filter(result -> result.getMediaRecordSegment() instanceof DocumentRecordSegment) + .sorted(Comparator.comparingDouble(SemanticSearchResult::getScore).reversed()) + .limit(topSourceCount) .map(this::generateSource) .filter(Objects::nonNull) .toList(); From 6057ad73f7381aee96df32b2399a3f440113be10 Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:01:37 +0100 Subject: [PATCH 05/24] tutor saves conversation context --- .../entity/ConversationHistoryEntity.java | 45 ++++++ .../ConversationHistoryRepository.java | 43 ++++++ .../service/ConversationHistoryService.java | 138 ++++++++++++++++++ .../tutor_service/service/TutorService.java | 14 +- src/main/resources/application.properties | 4 + .../answer_lecture_question_prompt.txt | 5 + 6 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/ConversationHistoryEntity.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/ConversationHistoryRepository.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/ConversationHistoryService.java diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/ConversationHistoryEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/ConversationHistoryEntity.java new file mode 100644 index 0000000..5ffee9a --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/ConversationHistoryEntity.java @@ -0,0 +1,45 @@ +package de.unistuttgart.iste.meitrex.tutor_service.persistence.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Entity representing a conversation exchange between user and tutor. + * Stores question-answer pairs for context in subsequent interactions. + */ +@Entity +@Table(name = "conversation_history", indexes = { + @Index(name = "idx_conversation_history_user_id", columnList = "user_id"), + @Index(name = "idx_conversation_history_course_id", columnList = "course_id"), + @Index(name = "idx_conversation_history_timestamp", columnList = "timestamp"), + @Index(name = "idx_conversation_history_user_course", columnList = "user_id,course_id") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ConversationHistoryEntity { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "id", nullable = false) + private UUID id; + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(name = "course_id", nullable = false) + private UUID courseId; + + @Column(name = "user_message", nullable = false, columnDefinition = "TEXT") + private String userMessage; + + @Column(name = "tutor_response", nullable = false, columnDefinition = "TEXT") + private String tutorResponse; + + @Column(name = "timestamp", nullable = false) + private OffsetDateTime timestamp; +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/ConversationHistoryRepository.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/ConversationHistoryRepository.java new file mode 100644 index 0000000..88a5a55 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/ConversationHistoryRepository.java @@ -0,0 +1,43 @@ +package de.unistuttgart.iste.meitrex.tutor_service.persistence.repository; + +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.ConversationHistoryEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +/** + * Repository for ConversationHistoryEntity. + */ +@Repository +public interface ConversationHistoryRepository extends JpaRepository { + + /** + * Finds all conversation history entries for a specific user and course, ordered by timestamp descending. + * + * @param userId the user ID + * @param courseId the course ID + * @return list of conversation history entities + */ + List findByUserIdAndCourseIdOrderByTimestampDesc(UUID userId, UUID courseId); + + /** + * Deletes conversation history entries older than the specified timestamp. + * + * @param userId the user ID + * @param courseId the course ID + * @param timestamp the cutoff timestamp + */ + void deleteByUserIdAndCourseIdAndTimestampBefore(UUID userId, UUID courseId, OffsetDateTime timestamp); + + /** + * Counts the number of conversation history entries for a user and course. + * + * @param userId the user ID + * @param courseId the course ID + * @return count of entries + */ + long countByUserIdAndCourseId(UUID userId, UUID courseId); +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/ConversationHistoryService.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/ConversationHistoryService.java new file mode 100644 index 0000000..77cd8da --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/ConversationHistoryService.java @@ -0,0 +1,138 @@ +package de.unistuttgart.iste.meitrex.tutor_service.service; + +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.ConversationHistoryEntity; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.repository.ConversationHistoryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +/** + * Service for managing conversation history between users and the AI tutor. + * Maintains a sliding window of recent conversation exchanges for context. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ConversationHistoryService { + + private final ConversationHistoryRepository conversationHistoryRepository; + + @Value("${tutor.conversation.history.max-pairs:3}") + private int maxHistoryPairs; + + @Value("${tutor.conversation.history.max-age-minutes:30}") + private int maxAgeMinutes; + + /** + * Retrieves the recent conversation history for a user in a specific course. + * Only returns entries that are not older than the configured max age. + * + * @param userId the user ID + * @param courseId the course ID + * @return list of recent conversation history entries, ordered by timestamp descending + */ + @Transactional(readOnly = true) + public List getRecentHistory(UUID userId, UUID courseId) { + OffsetDateTime cutoffTime = OffsetDateTime.now().minusMinutes(maxAgeMinutes); + + List allHistory = conversationHistoryRepository + .findByUserIdAndCourseIdOrderByTimestampDesc(userId, courseId); + + List recentHistory = allHistory.stream() + .filter(entry -> entry.getTimestamp().isAfter(cutoffTime)) + .limit(maxHistoryPairs) + .toList(); + + log.info("Retrieved {} recent history entries for user {} in course {} (filtered from {} total)", + recentHistory.size(), userId, courseId, allHistory.size()); + + return recentHistory; + } + + /** + * Adds a new conversation exchange to the history. + * Automatically manages the sliding window by removing old entries. + * + * @param userId the user ID + * @param courseId the course ID + * @param userMessage the user's question + * @param tutorResponse the tutor's response + */ + @Transactional + public void addConversationExchange(UUID userId, UUID courseId, String userMessage, String tutorResponse) { + OffsetDateTime cutoffTime = OffsetDateTime.now().minusMinutes(maxAgeMinutes); + conversationHistoryRepository.deleteByUserIdAndCourseIdAndTimestampBefore(userId, courseId, cutoffTime); + + List currentHistory = conversationHistoryRepository + .findByUserIdAndCourseIdOrderByTimestampDesc(userId, courseId); + + if (currentHistory.size() >= maxHistoryPairs) { + ConversationHistoryEntity oldest = currentHistory.get(currentHistory.size() - 1); + conversationHistoryRepository.delete(oldest); + log.info("Removed oldest conversation entry {} for user {} in course {}", + oldest.getId(), userId, courseId); + } + + ConversationHistoryEntity newEntry = ConversationHistoryEntity.builder() + .userId(userId) + .courseId(courseId) + .userMessage(userMessage) + .tutorResponse(tutorResponse) + .timestamp(OffsetDateTime.now()) + .build(); + + conversationHistoryRepository.save(newEntry); + log.info("Added new conversation entry for user {} in course {}", userId, courseId); + } + + /** + * Formats the conversation history as a string for inclusion in prompts. + * Oldest exchanges appear first. + * Returns an empty string if there is no valid history. + * + * @param userId the user ID + * @param courseId the course ID + * @return formatted conversation history + */ + public String formatHistoryForPrompt(UUID userId, UUID courseId) { + List history = getRecentHistory(userId, courseId); + + if (history.isEmpty()) { + return ""; + } + + StringBuilder formatted = new StringBuilder(); + formatted.append("\n\n---\n\nPrevious Conversation History:\n"); + + List chronological = history.reversed(); + + for (int i = 0; i < chronological.size(); i++) { + ConversationHistoryEntity entry = chronological.get(i); + formatted.append("\nExchange ").append(i + 1).append(":\n"); + formatted.append("Student: ").append(entry.getUserMessage()).append("\n"); + formatted.append("Tutor: ").append(entry.getTutorResponse()).append("\n"); + } + + return formatted.toString(); + } + + /** + * Clears all conversation history for a user in a specific course. + * + * @param userId the user ID + * @param courseId the course ID + */ + @Transactional + public void clearHistory(UUID userId, UUID courseId) { + List history = conversationHistoryRepository + .findByUserIdAndCourseIdOrderByTimestampDesc(userId, courseId); + conversationHistoryRepository.deleteAll(history); + log.info("Cleared conversation history for user {} in course {}", userId, courseId); + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java index d587a2b..df6aa7e 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java @@ -34,6 +34,7 @@ public class TutorService { private final UserPlayerTypeService userPlayerTypeService; private final UserSkillLevelService userSkillLevelService; private final ProactiveFeedbackService proactiveFeedbackService; + private final ConversationHistoryService conversationHistoryService; @Value("${semantic.search.threshold.tutor:0.4}") private double scoreThreshold; @Value("${semantic.search.topN.tutor:5}") @@ -187,15 +188,26 @@ private LectureQuestionResponse answerLectureQuestion(String question, UUID cour String prompt = ollamaService.getTemplate(PROMPT_TEMPLATES.get(1)); String contentString = semanticSearchService.formatIntoNumberedListForPrompt( documentSegments.stream().map(DocumentRecordSegment::getText).toList()); + + String conversationHistory = conversationHistoryService.formatHistoryForPrompt( + currentUser.getId(), courseId); + + log.info("Conversation history for user {} in course {}: {}", + currentUser.getId(), courseId, conversationHistory.isEmpty() ? "No history" : "Has history"); + List promptArgs = List.of( TemplateArgs.builder().argumentName("question").argumentValue(question).build(), TemplateArgs.builder().argumentName("content").argumentValue(contentString).build(), - TemplateArgs.builder().argumentName("skill").argumentValue(skillLevelPromotContent).build() + TemplateArgs.builder().argumentName("skill").argumentValue(skillLevelPromotContent).build(), + TemplateArgs.builder().argumentName("conversationHistory").argumentValue(conversationHistory).build() ); TutorAnswer response = ollamaService.startQuery( TutorAnswer.class, prompt, promptArgs, new TutorAnswer(ERROR_MESSAGE)); + conversationHistoryService.addConversationExchange( + currentUser.getId(), courseId, question, response.getAnswer()); + List sources = segmentSearchResults.stream() .filter(result -> result.getScore() <= scoreThreshold) .filter(result -> result.getMediaRecordSegment() instanceof DocumentRecordSegment) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 847dbc8..d6b4e30 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -30,6 +30,10 @@ skill.level.threshold.high=0.7 correctness.level.high=0.8 +# Conversation history settings +tutor.conversation.history.max-pairs=3 +tutor.conversation.history.max-age-minutes=30 + spring.jpa.hibernate.ddl-auto=update spring.jpa.open-in-view=false diff --git a/src/main/resources/prompt_templates/answer_lecture_question_prompt.txt b/src/main/resources/prompt_templates/answer_lecture_question_prompt.txt index 0e9f687..e1f4c9b 100644 --- a/src/main/resources/prompt_templates/answer_lecture_question_prompt.txt +++ b/src/main/resources/prompt_templates/answer_lecture_question_prompt.txt @@ -25,3 +25,8 @@ Question: Relevant Lecture Content: {{content}} + +You might also have communication history with the student. +Do not use the information provided by the student as facts, he can make mistakes. Base your answer on the lecture content. +Conversation History: +{{conversationHistory}} \ No newline at end of file From 2606977a366e805852620260ec8f1e8ac3b3ea8c Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:05:50 +0100 Subject: [PATCH 06/24] saves students code recieved in student-code-submitted event --- .gitignore | 4 + build.gradle | 2 +- .../controller/SubscriptionController.java | 41 +++++ .../entity/StudentCodeSubmissionEntity.java | 76 ++++++++ .../StudentCodeSubmissionRepository.java | 50 +++++ .../service/StudentCodeSubmissionService.java | 167 +++++++++++++++++ .../StudentCodeSubmissionServiceTest.java | 173 ++++++++++++++++++ .../service/TutorServiceTest.java | 7 +- 8 files changed, 518 insertions(+), 2 deletions(-) create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/StudentCodeSubmissionEntity.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/StudentCodeSubmissionRepository.java create mode 100644 src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/StudentCodeSubmissionService.java create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/StudentCodeSubmissionServiceTest.java diff --git a/.gitignore b/.gitignore index 891e458..0a638ed 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,8 @@ out/ ### VS Code ### .vscode/ +# Auto-generated API documentation +**/api.md +**/combined.graphql + node_modules/ diff --git a/build.gradle b/build.gradle index fa6a548..04ea384 100644 --- a/build.gradle +++ b/build.gradle @@ -108,7 +108,7 @@ repositories { } dependencies { - implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.12pre5' + implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.12pre6' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-graphql' implementation 'org.springframework.boot:spring-boot-starter-validation' diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java index 3236113..de79d6e 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java @@ -2,9 +2,11 @@ import de.unistuttgart.iste.meitrex.common.event.ContentProgressedEvent; import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; +import de.unistuttgart.iste.meitrex.common.event.StudentCodeSubmittedEvent; import de.unistuttgart.iste.meitrex.common.event.UserHexadPlayerTypeSetEvent; import de.unistuttgart.iste.meitrex.common.event.skilllevels.UserSkillLevelChangedEvent; import de.unistuttgart.iste.meitrex.tutor_service.service.ProactiveFeedbackService; +import de.unistuttgart.iste.meitrex.tutor_service.service.StudentCodeSubmissionService; import de.unistuttgart.iste.meitrex.tutor_service.service.UserPlayerTypeService; import de.unistuttgart.iste.meitrex.tutor_service.service.UserSkillLevelService; import io.dapr.Topic; @@ -31,6 +33,7 @@ public class SubscriptionController { private final UserPlayerTypeService userPlayerTypeService; private final UserSkillLevelService userSkillLevelService; private final ProactiveFeedbackService proactiveFeedbackService; + private final StudentCodeSubmissionService studentCodeSubmissionService; /** * Handles the user-hexad-player-type-set event. @@ -125,4 +128,42 @@ public Mono onContentProgressedEvent(@RequestBody CloudEvent for reactive processing + */ + @Topic(name = "student-code-submitted", pubsubName = "meitrex") + @PostMapping(path = "/student-code-submitted-pubsub") + public Mono onStudentCodeSubmittedEvent(@RequestBody CloudEvent cloudEvent, + @RequestHeader Map headers) { + return Mono.fromRunnable(() -> { + try { + StudentCodeSubmittedEvent event = cloudEvent.getData(); + + log.info("Received StudentCodeSubmittedEvent for student: {}, assignment: {}, commit: {}", + event.getStudentId(), + event.getAssignmentId(), + event.getCommitSha()); + + studentCodeSubmissionService.saveCodeSubmission( + event.getStudentId(), + event.getAssignmentId(), + event.getCourseId(), + event.getRepositoryUrl(), + event.getCommitSha(), + event.getCommitTimestamp(), + event.getFiles(), + event.getBranch() + ); + } catch (Exception e) { + log.error("Error processing StudentCodeSubmittedEvent: {}", e.getMessage(), e); + } + }); + } } diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/StudentCodeSubmissionEntity.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/StudentCodeSubmissionEntity.java new file mode 100644 index 0000000..bf78741 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/StudentCodeSubmissionEntity.java @@ -0,0 +1,76 @@ +package de.unistuttgart.iste.meitrex.tutor_service.persistence.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.io.Serializable; +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.UUID; + +/** + * Entity representing a student's code submission for an assignment. + * Stores the latest code submission per student per assignment. + */ +@Entity +@Table(name = "student_code_submission", indexes = { + @Index(name = "idx_student_code_submission_student", columnList = "student_id"), + @Index(name = "idx_student_code_submission_assignment", columnList = "assignment_id"), + @Index(name = "idx_student_code_submission_course", columnList = "course_id") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StudentCodeSubmissionEntity { + + @EmbeddedId + private PrimaryKey primaryKey; + + @Column(name = "course_id", nullable = false) + private UUID courseId; + + @Column(name = "repository_url", nullable = false, length = 500) + private String repositoryUrl; + + @Column(name = "commit_sha", nullable = false, length = 100) + private String commitSha; + + @Column(name = "commit_timestamp", nullable = false) + private OffsetDateTime commitTimestamp; + + @Column(name = "branch", length = 200) + private String branch; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "code_submission_files", + joinColumns = { + @JoinColumn(name = "student_id", referencedColumnName = "student_id"), + @JoinColumn(name = "assignment_id", referencedColumnName = "assignment_id") + } + ) + @MapKeyColumn(name = "file_path", length = 500) + @Column(name = "file_content", columnDefinition = "TEXT") + private Map files; + + @Column(name = "last_updated", nullable = false) + private OffsetDateTime lastUpdated; + + /** + * Composite primary key for student code submission. + * Consists of student ID and assignment ID to ensure only one submission per student per assignment. + */ + @Embeddable + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class PrimaryKey implements Serializable { + + @Column(name = "student_id", nullable = false) + private UUID studentId; + + @Column(name = "assignment_id", nullable = false) + private UUID assignmentId; + } +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/StudentCodeSubmissionRepository.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/StudentCodeSubmissionRepository.java new file mode 100644 index 0000000..866d020 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/repository/StudentCodeSubmissionRepository.java @@ -0,0 +1,50 @@ +package de.unistuttgart.iste.meitrex.tutor_service.persistence.repository; + +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.StudentCodeSubmissionEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Repository interface for StudentCodeSubmissionEntity. + * Provides database access for student code submission information. + */ +@Repository +public interface StudentCodeSubmissionRepository extends JpaRepository { + + /** + * Finds all code submissions for a specific student. + * + * @param studentId the student's ID + * @return list of code submissions for the student + */ + List findByPrimaryKey_StudentId(UUID studentId); + + /** + * Finds all code submissions for a specific assignment. + * + * @param assignmentId the assignment's ID + * @return list of code submissions for the assignment + */ + List findByPrimaryKey_AssignmentId(UUID assignmentId); + + /** + * Finds all code submissions for a specific course. + * + * @param courseId the course's ID + * @return list of code submissions in the course + */ + List findByCourseId(UUID courseId); + + /** + * Finds a specific code submission for a student and assignment. + * + * @param studentId the student's ID + * @param assignmentId the assignment's ID + * @return Optional containing the code submission if found + */ + Optional findByPrimaryKey_StudentIdAndPrimaryKey_AssignmentId(UUID studentId, UUID assignmentId); +} diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/StudentCodeSubmissionService.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/StudentCodeSubmissionService.java new file mode 100644 index 0000000..3491068 --- /dev/null +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/StudentCodeSubmissionService.java @@ -0,0 +1,167 @@ +package de.unistuttgart.iste.meitrex.tutor_service.service; + +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.StudentCodeSubmissionEntity; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.repository.StudentCodeSubmissionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +/** + * Service for managing student code submissions. + * Handles saving and retrieving code submission data from events. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class StudentCodeSubmissionService { + + private final StudentCodeSubmissionRepository studentCodeSubmissionRepository; + + /** + * Saves or updates a student's code submission for an assignment. + * Only keeps the latest submission per student per assignment. + * + * @param studentId the student's ID + * @param assignmentId the assignment's ID + * @param courseId the course's ID + * @param repositoryUrl the GitHub repository URL + * @param commitSha the commit SHA + * @param commitTimestamp the timestamp of the commit + * @param files map of file paths to file contents + * @param branch the branch name + */ + @Transactional + public void saveCodeSubmission( + UUID studentId, + UUID assignmentId, + UUID courseId, + String repositoryUrl, + String commitSha, + OffsetDateTime commitTimestamp, + Map files, + String branch) { + + StudentCodeSubmissionEntity.PrimaryKey primaryKey = + new StudentCodeSubmissionEntity.PrimaryKey(studentId, assignmentId); + + Optional existingEntity = studentCodeSubmissionRepository.findById(primaryKey); + + StudentCodeSubmissionEntity entity; + if (existingEntity.isPresent()) { + entity = existingEntity.get(); + } else { + entity = new StudentCodeSubmissionEntity(); + entity.setPrimaryKey(primaryKey); + } + + entity.setCourseId(courseId); + entity.setRepositoryUrl(repositoryUrl); + entity.setCommitSha(commitSha); + entity.setCommitTimestamp(commitTimestamp); + entity.setFiles(files); + entity.setBranch(branch); + entity.setLastUpdated(OffsetDateTime.now()); + + studentCodeSubmissionRepository.save(entity); + + log.info("Successfully saved code submission for student {} on assignment {}", studentId, assignmentId); + } + + /** + * Retrieves a student's code submission for a specific assignment. + * + * @param studentId the student's ID + * @param assignmentId the assignment's ID + * @return Optional containing the code submission if found + */ + public Optional getCodeSubmission(UUID studentId, UUID assignmentId) { + StudentCodeSubmissionEntity.PrimaryKey primaryKey = + new StudentCodeSubmissionEntity.PrimaryKey(studentId, assignmentId); + return studentCodeSubmissionRepository.findById(primaryKey); + } + + /** + * Retrieves all code submissions for a specific student. + * + * @param studentId the student's ID + * @return list of code submissions for the student + */ + public java.util.List getCodeSubmissionsForStudent(UUID studentId) { + return studentCodeSubmissionRepository.findByPrimaryKey_StudentId(studentId); + } + + /** + * Retrieves all code submissions for a specific assignment. + * + * @param assignmentId the assignment's ID + * @return list of code submissions for the assignment + */ + public java.util.List getCodeSubmissionsForAssignment(UUID assignmentId) { + return studentCodeSubmissionRepository.findByPrimaryKey_AssignmentId(assignmentId); + } + + /** + * Retrieves all code submissions for a specific course. + * + * @param courseId the course's ID + * @return list of code submissions in the course + */ + public java.util.List getCodeSubmissionsForCourse(UUID courseId) { + return studentCodeSubmissionRepository.findByCourseId(courseId); + } + + /** + * Retrieves the latest code submission for a student on an assignment as a formatted string. + * This method is designed to be used by the TutorService to provide context about student code. + * + * The returned string includes: + * - Repository information + * - Commit details + * - All source files with their contents + * + * @param studentId the student's ID + * @param assignmentId the assignment's ID + * @return Optional containing a formatted string with code submission details, or empty if not found + */ + public Optional getCodeSubmissionContextForTutor(UUID studentId, UUID assignmentId) { + return getCodeSubmission(studentId, assignmentId) + .map(this::formatCodeSubmissionForTutor); + } + + /** + * Formats a code submission entity into a human-readable string for the AI tutor. + * + * @param submission the code submission entity + * @return formatted string containing all relevant code submission information + */ + private String formatCodeSubmissionForTutor(StudentCodeSubmissionEntity submission) { + StringBuilder context = new StringBuilder(); + + context.append("Repository: ").append(submission.getRepositoryUrl()).append("\n"); + context.append("Branch: ").append(submission.getBranch()).append("\n"); + context.append("Commit: ").append(submission.getCommitSha()).append("\n"); + + context.append("Source Code Files:\n"); + context.append("==================\n\n"); + + submission.getFiles().entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> { + String filePath = entry.getKey(); + String fileContent = entry.getValue(); + + context.append("File: ").append(filePath).append("\n"); + context.append("---\n"); + context.append(fileContent); + context.append("\n\n"); + }); + + return context.toString(); + } +} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/StudentCodeSubmissionServiceTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/StudentCodeSubmissionServiceTest.java new file mode 100644 index 0000000..0dc14fa --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/StudentCodeSubmissionServiceTest.java @@ -0,0 +1,173 @@ +package de.unistuttgart.iste.meitrex.tutor_service.service; + +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.StudentCodeSubmissionEntity; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.repository.StudentCodeSubmissionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for StudentCodeSubmissionService. + */ +@ExtendWith(MockitoExtension.class) +class StudentCodeSubmissionServiceTest { + + @Mock + private StudentCodeSubmissionRepository repository; + + @InjectMocks + private StudentCodeSubmissionService service; + + private UUID studentId; + private UUID assignmentId; + private UUID courseId; + private String repositoryUrl; + private String commitSha; + private OffsetDateTime commitTimestamp; + private Map files; + private String branch; + + @BeforeEach + void setUp() { + studentId = UUID.randomUUID(); + assignmentId = UUID.randomUUID(); + courseId = UUID.randomUUID(); + repositoryUrl = "https://github.com/student/assignment-repo"; + commitSha = "abc123def456"; + commitTimestamp = OffsetDateTime.now(); + branch = "main"; + files = Map.of( + "src/Main.java", "public class Main { public static void main(String[] args) {} }", + "src/Utils.java", "public class Utils { }" + ); + } + + /** + * Test saving a new code submission. + * The repository should save a new entity. + */ + @Test + void testSaveCodeSubmission_NewSubmission() { + when(repository.findById(any())).thenReturn(Optional.empty()); + when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.saveCodeSubmission(studentId, assignmentId, courseId, repositoryUrl, + commitSha, commitTimestamp, files, branch); + + verify(repository, times(1)).findById(any()); + verify(repository, times(1)).save(any()); + } + + /** + * Test updating an existing code submission. + * The repository should update the existing entity. + */ + @Test + void testSaveCodeSubmission_UpdateExisting() { + StudentCodeSubmissionEntity existingEntity = StudentCodeSubmissionEntity.builder() + .primaryKey(new StudentCodeSubmissionEntity.PrimaryKey(studentId, assignmentId)) + .courseId(courseId) + .repositoryUrl(repositoryUrl) + .commitSha("oldCommitSha") + .commitTimestamp(commitTimestamp.minusDays(1)) + .files(Map.of("old/file.java", "old content")) + .branch("old-branch") + .lastUpdated(OffsetDateTime.now().minusDays(1)) + .build(); + + when(repository.findById(any())).thenReturn(Optional.of(existingEntity)); + when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + service.saveCodeSubmission(studentId, assignmentId, courseId, repositoryUrl, + commitSha, commitTimestamp, files, branch); + + verify(repository, times(1)).findById(any()); + verify(repository, times(1)).save(argThat(entity -> + entity.getCommitSha().equals(commitSha) && + entity.getFiles().size() == 2 && + entity.getBranch().equals(branch) + )); + } + + /** + * Test retrieving a code submission by student and assignment IDs. + */ + @Test + void testGetCodeSubmission() { + StudentCodeSubmissionEntity entity = StudentCodeSubmissionEntity.builder() + .primaryKey(new StudentCodeSubmissionEntity.PrimaryKey(studentId, assignmentId)) + .courseId(courseId) + .repositoryUrl(repositoryUrl) + .commitSha(commitSha) + .commitTimestamp(commitTimestamp) + .files(files) + .branch(branch) + .lastUpdated(OffsetDateTime.now()) + .build(); + + when(repository.findById(any())).thenReturn(Optional.of(entity)); + + Optional result = service.getCodeSubmission(studentId, assignmentId); + + assertTrue(result.isPresent()); + assertEquals(commitSha, result.get().getCommitSha()); + assertEquals(2, result.get().getFiles().size()); + verify(repository, times(1)).findById(any()); + } + + /** + * Test getting code submission context for a tutor. + */ + @Test + void testGetCodeSubmissionContextForTutor() { + StudentCodeSubmissionEntity entity = StudentCodeSubmissionEntity.builder() + .primaryKey(new StudentCodeSubmissionEntity.PrimaryKey(studentId, assignmentId)) + .courseId(courseId) + .repositoryUrl(repositoryUrl) + .commitSha(commitSha) + .commitTimestamp(commitTimestamp) + .files(files) + .branch(branch) + .lastUpdated(OffsetDateTime.now()) + .build(); + + when(repository.findById(any())).thenReturn(Optional.of(entity)); + + Optional context = service.getCodeSubmissionContextForTutor(studentId, assignmentId); + + assertTrue(context.isPresent()); + String contextString = context.get(); + assertTrue(contextString.contains(repositoryUrl)); + assertTrue(contextString.contains(commitSha)); + assertTrue(contextString.contains(branch)); + assertTrue(contextString.contains("src/Main.java")); + assertTrue(contextString.contains("src/Utils.java")); + assertTrue(contextString.contains("public class Main")); + verify(repository, times(1)).findById(any()); + } + + /** + * Test getting code submission context for a tutor when no submission exists. + */ + @Test + void testGetCodeSubmissionContextForTutor_NotFound() { + when(repository.findById(any())).thenReturn(Optional.empty()); + + Optional context = service.getCodeSubmissionContextForTutor(studentId, assignmentId); + + assertFalse(context.isPresent()); + verify(repository, times(1)).findById(any()); + } +} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorServiceTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorServiceTest.java index 1c96bb2..c74fef8 100644 --- a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorServiceTest.java +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorServiceTest.java @@ -24,11 +24,16 @@ public class TutorServiceTest { private final OllamaService ollamaService = Mockito.mock(OllamaService.class); private final SemanticSearchService semanticSearchService = Mockito.mock(SemanticSearchService.class); private final TopicPublisher topicPublisher = Mockito.mock(TopicPublisher.class); + private final UserPlayerTypeService userPlayerTypeService = Mockito.mock(UserPlayerTypeService.class); + private final UserSkillLevelService userSkillLevelService = Mockito.mock(UserSkillLevelService.class); + private final ProactiveFeedbackService proactiveFeedbackService = Mockito.mock(ProactiveFeedbackService.class); + private final ConversationHistoryService conversationHistoryService = Mockito.mock(ConversationHistoryService.class); private TutorService tutorService; @BeforeEach void setUp() { - tutorService = new TutorService(ollamaService, semanticSearchService, topicPublisher); + tutorService = new TutorService(ollamaService, semanticSearchService, topicPublisher, + userPlayerTypeService, userSkillLevelService, proactiveFeedbackService, conversationHistoryService); ReflectionTestUtils.setField(tutorService, "scoreThreshold", 0.4); } private final UUID courseId = UUID.randomUUID(); From b0d0cc524a2adb71415c1287f4c67cb4c425d9d5 Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Mon, 15 Dec 2025 19:31:53 +0100 Subject: [PATCH 07/24] implemented ai tutor can answer questions about submitted code --- build.gradle | 2 +- .../tutor_service/service/TutorService.java | 206 +++++++++++++++--- .../answer_code_feedback_prompt.txt | 35 +++ .../categorize_message_prompt.txt | 9 +- .../service/JsonSchemaServiceTest.java | 6 +- .../service/TutorServiceTest.java | 6 +- 6 files changed, 220 insertions(+), 44 deletions(-) create mode 100644 src/main/resources/prompt_templates/answer_code_feedback_prompt.txt diff --git a/build.gradle b/build.gradle index 04ea384..a084e1f 100644 --- a/build.gradle +++ b/build.gradle @@ -108,7 +108,7 @@ repositories { } dependencies { - implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.12pre6' + implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.12pre7' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-graphql' implementation 'org.springframework.boot:spring-boot-starter-validation' diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java index df6aa7e..c3123a6 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorService.java @@ -35,6 +35,7 @@ public class TutorService { private final UserSkillLevelService userSkillLevelService; private final ProactiveFeedbackService proactiveFeedbackService; private final ConversationHistoryService conversationHistoryService; + private final StudentCodeSubmissionService studentCodeSubmissionService; @Value("${semantic.search.threshold.tutor:0.4}") private double scoreThreshold; @Value("${semantic.search.topN.tutor:5}") @@ -47,9 +48,14 @@ public class TutorService { private final String ERROR_MESSAGE = ("Oops, something went wrong! " + "The request could not be processed. Please try again."); + private final String CODE_FEEDBACK_NO_SUBMISSION_MESSAGE = ("I couldn't find any code submission from you. " + + "Please make sure you've committed your code to your assignment repository. " + + "Open this respository and try again. Make sure to commit and push your code first!"); + private static final List PROMPT_TEMPLATES = List.of( "categorize_message_prompt.txt", - "answer_lecture_question_prompt.txt" + "answer_lecture_question_prompt.txt", + "answer_code_feedback_prompt.txt" ); private static final List SKILL_LEVEL_PROMPT_TEMPLATES = List.of( "Provide a clear and simple hint that gently guides the user toward the next step without overwhelming them.", @@ -108,8 +114,10 @@ public LectureQuestionResponse handleUserQuestion(String userQuestion, UUID cour } //Further process the question for the remaining categories - if(category == TutorCategory.LECTURE){ + if (category == TutorCategory.LECTURE){ return answerLectureQuestion(userQuestion, courseId, currentUser); + } else if (category == TutorCategory.CODE_FEEDBACK) { + return answerCodeFeedbackQuestion(userQuestion, courseId, currentUser); } else if (category == TutorCategory.SYSTEM) { return new LectureQuestionResponse( "At the moment, I can't answer any questions about the MEITREX system :(", List.of()); @@ -119,11 +127,6 @@ public LectureQuestionResponse handleUserQuestion(String userQuestion, UUID cour private LectureQuestionResponse answerLectureQuestion(String question, UUID courseId, LoggedInUser currentUser){ Optional playerType = userPlayerTypeService.getPrimaryPlayerType(currentUser.getId()); - if (playerType.isPresent()) { - log.info("User {} has player type: {}", currentUser.getId(), playerType.get()); - } else { - log.info("User {} has no player type set", currentUser.getId()); - } if(courseId == null){ String response = @@ -153,36 +156,10 @@ private LectureQuestionResponse answerLectureQuestion(String question, UUID cour return new LectureQuestionResponse("No answer was found in the documents of the lecture.", List.of()); } - // Skill level range from 0 to 1 - List skillLevels = userSkillLevelService.getAllSkillLevelsForUser(currentUser.getId()); - double averageSkillLevel; - - if (skillLevels.isEmpty()) { - RequestUserSkillLevelEvent requestEvent = RequestUserSkillLevelEvent.builder() - .userId(currentUser.getId()) - .build(); - topicPublisher.notifyRequestUserSkillLevel(requestEvent); - - averageSkillLevel = 0.5; - } else { - averageSkillLevel = skillLevels.stream() - .filter(Objects::nonNull) - .mapToDouble(UserSkillLevelEntity::getSkillLevelValue) - .average() - .orElse(0.5); - log.info("User {} has {} skill levels with average: {}", - currentUser.getId(), skillLevels.size(), averageSkillLevel); - } + double averageSkillLevel = getAverageSkillLevel(currentUser.getId()); + log.info("User {} average skill level: {}", currentUser.getId(), averageSkillLevel); - String skillLevelPromotContent; - if (averageSkillLevel <= skillLevelLowThreshold) { - skillLevelPromotContent = SKILL_LEVEL_PROMPT_TEMPLATES.get(0); - log.info("Using low skill level prompt for user {}", currentUser.getId()); - } else if (averageSkillLevel < skillLevelHighThreshold) { - skillLevelPromotContent = SKILL_LEVEL_PROMPT_TEMPLATES.get(1); - } else { - skillLevelPromotContent = SKILL_LEVEL_PROMPT_TEMPLATES.get(2); - } + String skillLevelPromotContent = getSkillBasedFeedbackStyle(averageSkillLevel); LectureQuestionResponse errorResponse = new LectureQuestionResponse(ERROR_MESSAGE, List.of()); String prompt = ollamaService.getTemplate(PROMPT_TEMPLATES.get(1)); @@ -253,5 +230,162 @@ private Source generateSource(SemanticSearchResult result){ } } + /** + * Handles code feedback questions by analyzing student's code submission and providing personalized feedback. + * Retrieves the student's latest code submission and generates feedback based on their player type and skill level. + * + * @param question the question asked by the student about their code + * @param courseId the ID of the course + * @param currentUser the currently logged-in user + * @return a response containing the feedback + */ + private LectureQuestionResponse answerCodeFeedbackQuestion(String question, UUID courseId, LoggedInUser currentUser) { + if (courseId == null) { + String response = "Something went wrong! If your question is about code for an assignment, " + + "please navigate to the course it relates to. Thank you! :)"; + return new LectureQuestionResponse(response, List.of()); + } + + Optional playerType = userPlayerTypeService.getPrimaryPlayerType(currentUser.getId()); + double averageSkillLevel = getAverageSkillLevel(currentUser.getId()); + String feedbackStyle = determineFeedbackStyle(playerType.orElse(null), averageSkillLevel); + + // Note: We need to determine the assignmentId from the context. For now, we'll try to get the most recent submission + List submissions = + studentCodeSubmissionService.getCodeSubmissionsForStudent(currentUser.getId()); + + if (submissions.isEmpty()) { + return new LectureQuestionResponse( + CODE_FEEDBACK_NO_SUBMISSION_MESSAGE, + List.of()); + } + + de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.StudentCodeSubmissionEntity mostRecentSubmission = + submissions.stream() + .max(Comparator.comparing(de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.StudentCodeSubmissionEntity::getLastUpdated)) + .orElse(null); + + if (mostRecentSubmission == null) { + return new LectureQuestionResponse( + CODE_FEEDBACK_NO_SUBMISSION_MESSAGE, + List.of()); + } + + Optional codeContext = studentCodeSubmissionService.getCodeSubmissionContextForTutor( + currentUser.getId(), + mostRecentSubmission.getPrimaryKey().getAssignmentId()); + + if (codeContext.isEmpty()) { + return new LectureQuestionResponse( + CODE_FEEDBACK_NO_SUBMISSION_MESSAGE, + List.of()); + } + + String conversationHistory = conversationHistoryService.formatHistoryForPrompt( + currentUser.getId(), courseId); + + String prompt = ollamaService.getTemplate(PROMPT_TEMPLATES.get(2)); + List promptArgs = List.of( + TemplateArgs.builder().argumentName("question").argumentValue(question).build(), + TemplateArgs.builder().argumentName("codeContext").argumentValue(codeContext.get()).build(), + TemplateArgs.builder().argumentName("feedbackStyle").argumentValue(feedbackStyle).build(), + TemplateArgs.builder().argumentName("conversationHistory").argumentValue(conversationHistory).build() + ); + + TutorAnswer response = ollamaService.startQuery( + TutorAnswer.class, prompt, promptArgs, new TutorAnswer(ERROR_MESSAGE)); + + conversationHistoryService.addConversationExchange( + currentUser.getId(), courseId, question, response.getAnswer()); + + return new LectureQuestionResponse(response.getAnswer(), List.of()); + } + + /** + * Determines the feedback style based on the user's player type and skill level. + * + * @param playerType the user's primary player type + * @param skillLevel the user's average skill level (0-1) + * @return feedback style instructions for the AI + */ + private String determineFeedbackStyle(HexadPlayerType playerType, double skillLevel) { + if (playerType == null) { + return getSkillBasedFeedbackStyle(skillLevel); + } + + return switch (playerType) { + case ACHIEVER -> "Provide a tip that helps the student achieve their goal. " + getSkillLevelGuidanceAchiever(skillLevel) + "."; + + case PHILANTHROPIST -> "Instead of directly pointing out the error, ask the student to explain the part of their code " + + "where you detected an issue. Encourage them to articulate their thought process, which will help them discover " + + "the problem themselves. Be supportive and guide them through reflection. If they do not identify the issue, " + + "and ask again, you can then point out the error."; + + case DISRUPTOR -> "Provide a tip with additional focus on edge cases and unconventional scenarios. " + + "Challenge the student to think about how their code handles unusual inputs or boundary conditions."; + + case SOCIALISER, FREE_SPIRIT, PLAYER -> getSkillBasedFeedbackStyle(skillLevel); + }; + } + + /** + * Gets skill level guidance text based on the average skill level. + * + * @param skillLevel the average skill level (0-1) + * @return skill level guidance text + */ + private String getSkillLevelGuidanceAchiever(double skillLevel) { + if (skillLevel <= skillLevelLowThreshold) { + return "Focus on providing clear, simple hints that guide the student toward the next step without overwhelming them."; + } else if (skillLevel < skillLevelHighThreshold) { + return "Offer balanced hints that assist the student's reasoning while still allowing them to work out the solution independently. " + + "Assume general familiarity with the topic."; + } else { + return "Assume advanced understanding."; + } + } + + /** + * Gets feedback style based only on skill level (for player types that don't need special treatment). + * + * @param skillLevel the average skill level (0-1) + * @return feedback style instructions + */ + private String getSkillBasedFeedbackStyle(double skillLevel) { + if (skillLevel <= skillLevelLowThreshold) { + return SKILL_LEVEL_PROMPT_TEMPLATES.get(0); + } else if (skillLevel < skillLevelHighThreshold) { + return SKILL_LEVEL_PROMPT_TEMPLATES.get(1); + } else { + return SKILL_LEVEL_PROMPT_TEMPLATES.get(2); + } + } + + /** + * Calculates and returns the average skill level for a user. + * Requests skill levels if none are available and defaults to 0.5. + * + * @param userId the user's ID + * @return average skill level (0-1), defaults to 0.5 if unavailable + */ + private double getAverageSkillLevel(UUID userId) { + List skillLevels = userSkillLevelService.getAllSkillLevelsForUser(userId); + + if (skillLevels.isEmpty()) { + RequestUserSkillLevelEvent requestEvent = RequestUserSkillLevelEvent.builder() + .userId(userId) + .build(); + topicPublisher.notifyRequestUserSkillLevel(requestEvent); + + return 0.5; + } + + return skillLevels.stream() + .filter(Objects::nonNull) + .mapToDouble(UserSkillLevelEntity::getSkillLevelValue) + .average() + .orElse(0.5); + } + } diff --git a/src/main/resources/prompt_templates/answer_code_feedback_prompt.txt b/src/main/resources/prompt_templates/answer_code_feedback_prompt.txt new file mode 100644 index 0000000..1fb683e --- /dev/null +++ b/src/main/resources/prompt_templates/answer_code_feedback_prompt.txt @@ -0,0 +1,35 @@ +You are an AI tutor helping a student with their programming problem. + +Below is a question the student has asked, along with the code the student has comitted. + +Always answer in the same language as the student's question. +Do not translate or localize technical terms or vocabulary. + +Analyze the student's code and respond to their question. Be constructive, encouraging, and educational. +If you identify errors or issues in the code, point them out clearly. If the code looks correct, acknowledge that and provide suggestions for improvement or best practices. +Keep your response focused, clear, and actionable. It is very important that you do not send code snippets yourself. Only tell what is wrong or can be improved but never send the corrected code. + +Your entire response must be a valid JSON object of the following form: + +{"answer": "your answer here"} + +--- + +Question: +{{question}} + +--- + +--- + +STUDENT'S CODE SUBMISSION: +{{codeContext}} + +--- + +{{feedbackStyle}} + +You might also have communication history with the student. +Do not use the information provided by the student as facts, he can make mistakes. +Conversation History: +{{conversationHistory}} diff --git a/src/main/resources/prompt_templates/categorize_message_prompt.txt b/src/main/resources/prompt_templates/categorize_message_prompt.txt index f793b32..618d91b 100644 --- a/src/main/resources/prompt_templates/categorize_message_prompt.txt +++ b/src/main/resources/prompt_templates/categorize_message_prompt.txt @@ -1,12 +1,13 @@ -Your job is to categorize a given question into one of four categories: SYSTEM, LECTURE, OTHER, or UNRECOGNIZABLE. +Your job is to categorize a given question into one of five categories: SYSTEM, LECTURE, CODE_FEEDBACK, OTHER, or UNRECOGNIZABLE. To do this you must also correct typos and grammar in the question where possible. If the corrected version makes the meaning clear, use it to choose the appropriate category. Categories: 1. SYSTEM: Choose this if the question is about the website or app the user is currently using. For example: "Where do I change my avatar?" These questions relate specifically to the system or platform itself. 2. LECTURE: Choose this if the question is about understanding or explaining academic content related to a university course. These questions should NOT relate to the website or app. These question are not limited to only a "question" itself, but can also be message that convey the meaning of trying to understand a topic on a deeper level. For example: "What is unsupervised learning" or "Can you give examples for the Chain rule in differentiation" -3. OTHER: Choose this if the question fits neither SYSTEM nor LECTURE categories and is a general inquiry. -4. UNRECOGNIZABLE: Choose this if the question is too garbled, incomplete, or contains too many typos or grammar issues to be understood reliably. +3. CODE_FEEDBACK: Choose this if the student is asking for feedback, help, or explanations about code they have written for an assignment. This includes questions about errors, improvements, debugging, or understanding their own code implementation. For example: "Can you help me with my code?" or "Why doesn't my function work?" or "How can I improve my implementation?" +4. OTHER: Choose this if the question fits neither SYSTEM, LECTURE, nor CODE_FEEDBACK categories and is a general inquiry. +5. UNRECOGNIZABLE: Choose this if the question is too garbled, incomplete, or contains too many typos or grammar issues to be understood reliably. Only use the UNRECOGNIZABLE category when the meaning is truly unclear even after trying to correct it. @@ -14,7 +15,7 @@ Your response must be a JSON object ONLY (no extra text) with the exact followin { "question": "the original question here", - "category": "SYSTEM" | "LECTURE" | "OTHER" | "UNRECOGNIZABLE" + "category": "SYSTEM" | "LECTURE" | "CODE_FEEDBACK" | "OTHER" | "UNRECOGNIZABLE" } QUESTION: diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/JsonSchemaServiceTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/JsonSchemaServiceTest.java index dd49dea..9e929a8 100644 --- a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/JsonSchemaServiceTest.java +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/JsonSchemaServiceTest.java @@ -62,8 +62,10 @@ void testGenerateSchema_forCategorizedQuestion() { assertNotNull(schema); assertTrue(schema.contains("\"question\":{\"type\":\"string\"}")); - assertTrue(schema.contains("\"category\":{\"type\":\"string\"," + - "\"enum\":[\"SYSTEM\",\"LECTURE\",\"UNRECOGNIZABLE\",\"OTHER\",\"ERROR\"]}")); + // Note: The enum order in the schema might differ from the order in TutorCategory + assertTrue(schema.contains("\"category\"") && schema.contains("\"enum\"") && + schema.contains("\"CODE_FEEDBACK\""), + "Schema should contain category enum with CODE_FEEDBACK"); } @Test diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorServiceTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorServiceTest.java index c74fef8..3ab3f04 100644 --- a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorServiceTest.java +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorServiceTest.java @@ -28,12 +28,14 @@ public class TutorServiceTest { private final UserSkillLevelService userSkillLevelService = Mockito.mock(UserSkillLevelService.class); private final ProactiveFeedbackService proactiveFeedbackService = Mockito.mock(ProactiveFeedbackService.class); private final ConversationHistoryService conversationHistoryService = Mockito.mock(ConversationHistoryService.class); + private final StudentCodeSubmissionService studentCodeSubmissionService = Mockito.mock(StudentCodeSubmissionService.class); private TutorService tutorService; @BeforeEach void setUp() { tutorService = new TutorService(ollamaService, semanticSearchService, topicPublisher, - userPlayerTypeService, userSkillLevelService, proactiveFeedbackService, conversationHistoryService); + userPlayerTypeService, userSkillLevelService, proactiveFeedbackService, + conversationHistoryService, studentCodeSubmissionService); ReflectionTestUtils.setField(tutorService, "scoreThreshold", 0.4); } private final UUID courseId = UUID.randomUUID(); @@ -106,6 +108,8 @@ void testHandleUserQuestion_withLectureCategoryValidCourseId() { when(semanticSearchService.formatIntoNumberedListForPrompt(Mockito.any())).thenReturn("Mocked content"); when(ollamaService.startQuery(Mockito.eq(TutorAnswer.class), Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(new TutorAnswer(expectedAnswer)); + when(userSkillLevelService.getAllSkillLevelsForUser(Mockito.any())).thenReturn(List.of()); + when(conversationHistoryService.formatHistoryForPrompt(Mockito.any(), Mockito.any())).thenReturn(""); LectureQuestionResponse response = tutorService.handleUserQuestion(question, courseId, loggedInUser); From 531b3554bd53dce001b5b9cd828ad706dbbf3cbe Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:26:08 +0100 Subject: [PATCH 08/24] fixed proactive feedback for code assignment --- .../service/ProactiveFeedbackService.java | 37 +++ .../proactive_feedback_prompt.txt | 49 +++- .../service/ProactiveFeedbackServiceTest.java | 210 ++++++++++++++++++ 3 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/ProactiveFeedbackServiceTest.java diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/ProactiveFeedbackService.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/ProactiveFeedbackService.java index 4fb45b9..40e7a3e 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/ProactiveFeedbackService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/ProactiveFeedbackService.java @@ -33,6 +33,7 @@ public class ProactiveFeedbackService { private final UserPlayerTypeService userPlayerTypeService; private final UserSkillLevelService userSkillLevelService; private final ProactiveFeedbackRepository proactiveFeedbackRepository; + private final StudentCodeSubmissionService studentCodeSubmissionService; /** * Per-user reactive sinks for streaming feedback to subscribers. @@ -98,6 +99,11 @@ public String generateFeedback(ContentProgressedEvent event) { String individualizedPrompt = getIndivualizedPromotProactiveTestDone(event.getUserId(), event.getCorrectness()); String performanceContext = getPerformanceContext(event); + + String codeContext = ""; + if (event.getContentType() == ContentProgressedEvent.ContentType.ASSIGNMENT) { + codeContext = getCodeContextForAssignment(event.getUserId(), event.getContentId()); + } String prompt = ollamaService.getTemplate(FEEDBACK_PROMPT_TEMPLATE); List promptArgs = List.of( @@ -112,6 +118,10 @@ public String generateFeedback(ContentProgressedEvent event) { TemplateArgs.builder() .argumentName("individualizedPrompt") .argumentValue(individualizedPrompt) + .build(), + TemplateArgs.builder() + .argumentName("codeContext") + .argumentValue(codeContext) .build() ); @@ -312,4 +322,31 @@ private String getPerformanceContext(ContentProgressedEvent event) { return "significant gaps in understanding"; } } + + /** + * Retrieves code context for an assignment if available. + * Returns an empty string if no code submission is found. + * + * @param userId the user's ID + * @param assignmentId the assignment's ID + * @return formatted code context or empty string + */ + private String getCodeContextForAssignment(UUID userId, UUID assignmentId) { + try { + Optional codeContext = studentCodeSubmissionService.getCodeSubmissionContextForTutor( + userId, assignmentId); + + if (codeContext.isPresent()) { + log.debug("Retrieved code submission for user {} on assignment {}", userId, assignmentId); + return codeContext.get(); + } else { + log.debug("No code submission found for user {} on assignment {}", userId, assignmentId); + return ""; + } + } catch (Exception e) { + log.warn("Failed to retrieve code context for user {} on assignment {}: {}", + userId, assignmentId, e.getMessage()); + return ""; + } + } } diff --git a/src/main/resources/prompt_templates/proactive_feedback_prompt.txt b/src/main/resources/prompt_templates/proactive_feedback_prompt.txt index a39bad7..1ee742d 100644 --- a/src/main/resources/prompt_templates/proactive_feedback_prompt.txt +++ b/src/main/resources/prompt_templates/proactive_feedback_prompt.txt @@ -1,17 +1,42 @@ -You are an encouraging AI tutor providing personalized feedback to a student who just completed an assignment. +You are an encouraging AI tutor providing proactive, personalized feedback to a student who has just completed an assignment or quiz. -**Assignment Performance:** -- Correctness: {{correctness}} (0.0 to 1.0) +Your goal is to help the student reflect on their performance, feel motivated, and understand how to improve without overwhelming them. + +Always answer in the same language as the student’s submission. +Do not translate or localize technical terms or vocabulary. + +Your entire response must be a valid JSON object of the following form: + +{"answer": "your feedback here"} + +--- + +Assignment Overview: +- Correctness Score: {{correctness}} (0.0 to 1.0) - Performance Level: {{performance}} -**Student Profile:** -- Player Type Guidance: {{individualizedPrompt}} +--- + +STUDENT'S CODE SUBMISSION (optional): +{{codeContext}} + +--- + +Your Task: +Generate a personalized feedback message of 2 to 4 sentences that: + +1. Acknowledges the student’s effort and overall performance +2. Reflects their performance level accurately (celebrate success or normalize mistakes) +3. Motivates the student to continue learning or practicing + +If a code submission is provided: +- If the correctness is not 1, give him a small hint where his error lies +- Do NOT include code snippets +- Do NOT provide corrected code +- Focus on guidance, not solutions -**Your Task:** -Generate a personalized, encouraging feedback message (2-4 sentences) that: -1. Acknowledges their effort and performance -2. Provides constructive guidance aligned with their player type -3. Uses language complexity appropriate to their skill level -4. Motivates them for continued learning +If no code submission is provided: +- Focus on conceptual understanding, confidence, and next steps -Be warm, supportive, and specific. If they passed, celebrate their success. If they didn't pass, encourage them to review and try again with specific suggestions. +Your task: +{{individualizedPrompt}} \ No newline at end of file diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/ProactiveFeedbackServiceTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/ProactiveFeedbackServiceTest.java new file mode 100644 index 0000000..92e928a --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/ProactiveFeedbackServiceTest.java @@ -0,0 +1,210 @@ +package de.unistuttgart.iste.meitrex.tutor_service.service; + +import de.unistuttgart.iste.meitrex.common.event.ContentProgressedEvent; +import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.ProactiveFeedbackEntity; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.StudentCodeSubmissionEntity; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.UserPlayerTypeEntity; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.repository.ProactiveFeedbackRepository; +import de.unistuttgart.iste.meitrex.tutor_service.service.models.TutorAnswer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Unit tests for ProactiveFeedbackService. + */ +@ExtendWith(MockitoExtension.class) +class ProactiveFeedbackServiceTest { + + @Mock + private OllamaService ollamaService; + + @Mock + private UserPlayerTypeService userPlayerTypeService; + + @Mock + private UserSkillLevelService userSkillLevelService; + + @Mock + private ProactiveFeedbackRepository proactiveFeedbackRepository; + + @Mock + private StudentCodeSubmissionService studentCodeSubmissionService; + + @InjectMocks + private ProactiveFeedbackService proactiveFeedbackService; + + private UUID userId; + private UUID assignmentId; + private UUID quizId; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + assignmentId = UUID.randomUUID(); + quizId = UUID.randomUUID(); + + ReflectionTestUtils.setField(proactiveFeedbackService, "skillLevelLowThreshold", 0.3); + ReflectionTestUtils.setField(proactiveFeedbackService, "skillLevelHighThreshold", 0.7); + ReflectionTestUtils.setField(proactiveFeedbackService, "correctnessLevelHigh", 0.8); + ReflectionTestUtils.setField(proactiveFeedbackService, "correctnessLevelMax", 0.99); + } + + @Test + void testGenerateFeedback_forAssignmentWithCode() { + ContentProgressedEvent event = ContentProgressedEvent.builder() + .userId(userId) + .contentId(assignmentId) + .contentType(ContentProgressedEvent.ContentType.ASSIGNMENT) + .correctness(0.85) + .success(true) + .build(); + + String codeContext = "Repository: https://github.com/test/repo\nFile: Main.java\npublic class Main {}"; + String expectedFeedback = "Great job on your assignment! Your code shows good structure."; + + when(ollamaService.getTemplate(anyString())).thenReturn("Mock template"); + when(ollamaService.startQuery(eq(TutorAnswer.class), anyString(), anyList(), any())) + .thenReturn(new TutorAnswer(expectedFeedback)); + when(userPlayerTypeService.getPrimaryPlayerType(userId)).thenReturn(Optional.empty()); + when(studentCodeSubmissionService.getCodeSubmissionContextForTutor(userId, assignmentId)) + .thenReturn(Optional.of(codeContext)); + when(proactiveFeedbackRepository.save(any())).thenAnswer(invocation -> { + ProactiveFeedbackEntity entity = invocation.getArgument(0); + entity.setId(UUID.randomUUID()); + return entity; + }); + + String result = proactiveFeedbackService.generateFeedback(event); + + assertNotNull(result); + assertEquals(expectedFeedback, result); + verify(studentCodeSubmissionService, times(1)) + .getCodeSubmissionContextForTutor(userId, assignmentId); + verify(proactiveFeedbackRepository, times(1)).save(any(ProactiveFeedbackEntity.class)); + } + + @Test + void testGenerateFeedback_forQuizWithoutCode() { + ContentProgressedEvent event = ContentProgressedEvent.builder() + .userId(userId) + .contentId(quizId) + .contentType(ContentProgressedEvent.ContentType.QUIZ) + .correctness(0.75) + .success(true) + .build(); + + String expectedFeedback = "Good work on the quiz! You demonstrated solid understanding."; + + when(ollamaService.getTemplate(anyString())).thenReturn("Mock template"); + when(ollamaService.startQuery(eq(TutorAnswer.class), anyString(), anyList(), any())) + .thenReturn(new TutorAnswer(expectedFeedback)); + when(userPlayerTypeService.getPrimaryPlayerType(userId)).thenReturn(Optional.empty()); + when(proactiveFeedbackRepository.save(any())).thenAnswer(invocation -> { + ProactiveFeedbackEntity entity = invocation.getArgument(0); + entity.setId(UUID.randomUUID()); + return entity; + }); + + String result = proactiveFeedbackService.generateFeedback(event); + + assertNotNull(result); + assertEquals(expectedFeedback, result); + + verify(studentCodeSubmissionService, never()) + .getCodeSubmissionContextForTutor(any(), any()); + verify(proactiveFeedbackRepository, times(1)).save(any(ProactiveFeedbackEntity.class)); + } + + @Test + void testGenerateFeedback_forAssignmentWithoutCodeSubmission() { + ContentProgressedEvent event = ContentProgressedEvent.builder() + .userId(userId) + .contentId(assignmentId) + .contentType(ContentProgressedEvent.ContentType.ASSIGNMENT) + .correctness(0.90) + .success(true) + .build(); + + String expectedFeedback = "Excellent work on your assignment!"; + + when(ollamaService.getTemplate(anyString())).thenReturn("Mock template"); + when(ollamaService.startQuery(eq(TutorAnswer.class), anyString(), anyList(), any())) + .thenReturn(new TutorAnswer(expectedFeedback)); + when(userPlayerTypeService.getPrimaryPlayerType(userId)).thenReturn(Optional.empty()); + when(studentCodeSubmissionService.getCodeSubmissionContextForTutor(userId, assignmentId)) + .thenReturn(Optional.empty()); + when(proactiveFeedbackRepository.save(any())).thenAnswer(invocation -> { + ProactiveFeedbackEntity entity = invocation.getArgument(0); + entity.setId(UUID.randomUUID()); + return entity; + }); + + String result = proactiveFeedbackService.generateFeedback(event); + + assertNotNull(result); + assertEquals(expectedFeedback, result); + verify(studentCodeSubmissionService, times(1)) + .getCodeSubmissionContextForTutor(userId, assignmentId); + verify(proactiveFeedbackRepository, times(1)).save(any(ProactiveFeedbackEntity.class)); + } + + @Test + void testGenerateFeedback_withPlayerType() { + ContentProgressedEvent event = ContentProgressedEvent.builder() + .userId(userId) + .contentId(assignmentId) + .contentType(ContentProgressedEvent.ContentType.ASSIGNMENT) + .correctness(1.0) + .success(true) + .build(); + + String expectedFeedback = "Perfect score! You're an achiever!"; + + UserPlayerTypeEntity playerTypeEntity = UserPlayerTypeEntity.builder() + .userId(userId) + .primaryPlayerType(HexadPlayerType.ACHIEVER) + .achieverScore(0.9) + .playerScore(0.5) + .socialiserScore(0.4) + .freeSpiritScore(0.3) + .philanthropistScore(0.6) + .disruptorScore(0.2) + .build(); + + when(ollamaService.getTemplate(anyString())).thenReturn("Mock template"); + when(ollamaService.startQuery(eq(TutorAnswer.class), anyString(), anyList(), any())) + .thenReturn(new TutorAnswer(expectedFeedback)); + when(userPlayerTypeService.getPrimaryPlayerType(userId)) + .thenReturn(Optional.of(HexadPlayerType.ACHIEVER)); + when(studentCodeSubmissionService.getCodeSubmissionContextForTutor(userId, assignmentId)) + .thenReturn(Optional.empty()); + when(proactiveFeedbackRepository.save(any())).thenAnswer(invocation -> { + ProactiveFeedbackEntity entity = invocation.getArgument(0); + entity.setId(UUID.randomUUID()); + return entity; + }); + + String result = proactiveFeedbackService.generateFeedback(event); + + assertNotNull(result); + assertEquals(expectedFeedback, result); + verify(proactiveFeedbackRepository, times(1)).save(any(ProactiveFeedbackEntity.class)); + } +} From 3d9009b72aa38498bab6c0b8a410be2cefa3d431 Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:56:15 +0100 Subject: [PATCH 09/24] updated meitrex-common to full release --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index a084e1f..36dbc27 100644 --- a/build.gradle +++ b/build.gradle @@ -108,7 +108,7 @@ repositories { } dependencies { - implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.12pre7' + implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.4.12' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-graphql' implementation 'org.springframework.boot:spring-boot-starter-validation' From 8e57d1de1f6b6b6a75c556d9072b1378c0722a6c Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Tue, 16 Dec 2025 00:10:39 +0100 Subject: [PATCH 10/24] added tests for SubscriptionController --- build.gradle | 2 +- .../SubscriptionControllerTest.java | 312 ++++++++++++++++++ 2 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionControllerTest.java diff --git a/build.gradle b/build.gradle index 36dbc27..969d62f 100644 --- a/build.gradle +++ b/build.gradle @@ -128,7 +128,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.11' + testImplementation 'de.unistuttgart.iste.meitrex:meitrex-common-test:1.4.12' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework:spring-webflux' testImplementation 'org.springframework.graphql:spring-graphql-test' diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionControllerTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionControllerTest.java new file mode 100644 index 0000000..cf2231b --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionControllerTest.java @@ -0,0 +1,312 @@ +package de.unistuttgart.iste.meitrex.tutor_service.controller; + +import de.unistuttgart.iste.meitrex.common.event.ContentProgressedEvent; +import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; +import de.unistuttgart.iste.meitrex.common.event.StudentCodeSubmittedEvent; +import de.unistuttgart.iste.meitrex.common.event.UserHexadPlayerTypeSetEvent; +import de.unistuttgart.iste.meitrex.common.event.skilllevels.UserSkillLevelChangedEvent; +import de.unistuttgart.iste.meitrex.tutor_service.service.ProactiveFeedbackService; +import de.unistuttgart.iste.meitrex.tutor_service.service.StudentCodeSubmissionService; +import de.unistuttgart.iste.meitrex.tutor_service.service.UserPlayerTypeService; +import de.unistuttgart.iste.meitrex.tutor_service.service.UserSkillLevelService; +import io.dapr.client.domain.CloudEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Unit tests for SubscriptionController. + * Tests all Dapr pub/sub event handlers. + */ +@ExtendWith(MockitoExtension.class) +class SubscriptionControllerTest { + + @Mock + private UserPlayerTypeService userPlayerTypeService; + + @Mock + private UserSkillLevelService userSkillLevelService; + + @Mock + private ProactiveFeedbackService proactiveFeedbackService; + + @Mock + private StudentCodeSubmissionService studentCodeSubmissionService; + + @InjectMocks + private SubscriptionController subscriptionController; + + private Map headers; + private UUID userId; + private UUID skillId; + private UUID contentId; + private UUID assignmentId; + private UUID courseId; + + @BeforeEach + void setUp() { + headers = new HashMap<>(); + headers.put("Content-Type", "application/cloudevents+json"); + + userId = UUID.randomUUID(); + skillId = UUID.randomUUID(); + contentId = UUID.randomUUID(); + assignmentId = UUID.randomUUID(); + courseId = UUID.randomUUID(); + } + + @Test + void testOnUserHexadPlayerTypeSetEvent_SavesPlayerType() { + Map playerTypePercentages = new HashMap<>(); + playerTypePercentages.put(HexadPlayerType.ACHIEVER, 0.8); + playerTypePercentages.put(HexadPlayerType.PLAYER, 0.6); + playerTypePercentages.put(HexadPlayerType.SOCIALISER, 0.4); + playerTypePercentages.put(HexadPlayerType.FREE_SPIRIT, 0.5); + playerTypePercentages.put(HexadPlayerType.PHILANTHROPIST, 0.7); + playerTypePercentages.put(HexadPlayerType.DISRUPTOR, 0.3); + + UserHexadPlayerTypeSetEvent event = mock(UserHexadPlayerTypeSetEvent.class); + when(event.getUserId()).thenReturn(userId); + when(event.getPrimaryPlayerType()).thenReturn(HexadPlayerType.ACHIEVER); + when(event.getPlayerTypePercentages()).thenReturn(playerTypePercentages); + + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + doNothing().when(userPlayerTypeService).saveUserPlayerType(any(), any(), any()); + + assertDoesNotThrow(() -> subscriptionController.onUserHexadPlayerTypeSetEvent(cloudEvent, headers).block()); + + verify(userPlayerTypeService, times(1)).saveUserPlayerType( + eq(userId), + eq(HexadPlayerType.ACHIEVER), + argThat(percentages -> + percentages.get(HexadPlayerType.ACHIEVER) == 0.8 && + percentages.get(HexadPlayerType.PLAYER) == 0.6) + ); + } + + @Test + void testOnUserSkillLevelChangedEvent_SavesSkillLevel() { + Float skillLevelValue = 0.75f; + + UserSkillLevelChangedEvent event = UserSkillLevelChangedEvent.builder() + .userId(userId) + .skillId(skillId) + .newValue(skillLevelValue) + .build(); + + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + doNothing().when(userSkillLevelService).saveUserSkillLevel(any(), any(), any()); + + assertDoesNotThrow(() -> subscriptionController.onUserSkillLevelChangedEvent(cloudEvent, headers).block()); + + verify(userSkillLevelService, times(1)).saveUserSkillLevel( + eq(userId), + eq(skillId), + eq(skillLevelValue) + ); + } + + @Test + void testOnContentProgressedEvent_Assignment_GeneratesFeedback() { + ContentProgressedEvent event = ContentProgressedEvent.builder() + .userId(userId) + .contentId(contentId) + .contentType(ContentProgressedEvent.ContentType.ASSIGNMENT) + .correctness(0.85) + .success(true) + .build(); + + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + when(proactiveFeedbackService.generateFeedback(any())).thenReturn("Feedback generated"); + + assertDoesNotThrow(() -> subscriptionController.onContentProgressedEvent(cloudEvent, headers).block()); + + verify(proactiveFeedbackService, times(1)).generateFeedback(event); + } + + @Test + void testOnContentProgressedEvent_Quiz_GeneratesFeedback() { + ContentProgressedEvent event = ContentProgressedEvent.builder() + .userId(userId) + .contentId(contentId) + .contentType(ContentProgressedEvent.ContentType.QUIZ) + .correctness(0.90) + .success(true) + .build(); + + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + when(proactiveFeedbackService.generateFeedback(any())).thenReturn("Feedback generated"); + + assertDoesNotThrow(() -> subscriptionController.onContentProgressedEvent(cloudEvent, headers).block()); + + verify(proactiveFeedbackService, times(1)).generateFeedback(event); + } + + @Test + void testOnContentProgressedEvent_NonAssignmentOrQuiz_NoFeedback() { + ContentProgressedEvent event = mock(ContentProgressedEvent.class); + when(event.getUserId()).thenReturn(userId); + when(event.getContentId()).thenReturn(contentId); + when(event.getContentType()).thenReturn(null); // Will cause NPE internally, caught by exception handler + + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + assertDoesNotThrow(() -> subscriptionController.onContentProgressedEvent(cloudEvent, headers).block()); + + verify(proactiveFeedbackService, never()).generateFeedback(any()); + } + + @Test + void testOnContentProgressedEvent_FeedbackGenerationThrowsException_HandlesGracefully() { + ContentProgressedEvent event = ContentProgressedEvent.builder() + .userId(userId) + .contentId(contentId) + .contentType(ContentProgressedEvent.ContentType.ASSIGNMENT) + .correctness(0.85) + .success(true) + .build(); + + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + when(proactiveFeedbackService.generateFeedback(any())) + .thenThrow(new RuntimeException("AI service unavailable")); + + assertDoesNotThrow(() -> subscriptionController.onContentProgressedEvent(cloudEvent, headers).block()); + + verify(proactiveFeedbackService, times(1)).generateFeedback(event); + } + + @Test + void testOnStudentCodeSubmittedEvent_SavesCodeSubmission() { + Map files = new HashMap<>(); + files.put("src/Main.java", "public class Main {}"); + files.put("src/Helper.java", "public class Helper {}"); + + OffsetDateTime commitTimestamp = OffsetDateTime.now(); + String commitSha = "abc123def456"; + String repositoryUrl = "https://github.com/student/repo"; + String branch = "main"; + + StudentCodeSubmittedEvent event = StudentCodeSubmittedEvent.builder() + .studentId(userId) + .assignmentId(assignmentId) + .courseId(courseId) + .repositoryUrl(repositoryUrl) + .commitSha(commitSha) + .commitTimestamp(commitTimestamp) + .files(files) + .branch(branch) + .build(); + + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + doNothing().when(studentCodeSubmissionService).saveCodeSubmission( + any(), any(), any(), any(), any(), any(), any(), any()); + + assertDoesNotThrow(() -> subscriptionController.onStudentCodeSubmittedEvent(cloudEvent, headers).block()); + + verify(studentCodeSubmissionService, times(1)).saveCodeSubmission( + eq(userId), + eq(assignmentId), + eq(courseId), + eq(repositoryUrl), + eq(commitSha), + eq(commitTimestamp), + eq(files), + eq(branch) + ); + } + + @Test + void testOnStudentCodeSubmittedEvent_SaveThrowsException_HandlesGracefully() { + Map files = new HashMap<>(); + files.put("src/Main.java", "public class Main {}"); + + StudentCodeSubmittedEvent event = StudentCodeSubmittedEvent.builder() + .studentId(userId) + .assignmentId(assignmentId) + .courseId(courseId) + .repositoryUrl("https://github.com/student/repo") + .commitSha("abc123") + .commitTimestamp(OffsetDateTime.now()) + .files(files) + .branch("main") + .build(); + + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + doThrow(new RuntimeException("Database error")) + .when(studentCodeSubmissionService).saveCodeSubmission( + any(), any(), any(), any(), any(), any(), any(), any()); + + assertDoesNotThrow(() -> subscriptionController.onStudentCodeSubmittedEvent(cloudEvent, headers).block()); + + verify(studentCodeSubmissionService, times(1)).saveCodeSubmission( + any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void testMultiplePlayerTypes_ConvertsFloatToDouble() { + Map playerTypePercentages = new HashMap<>(); + playerTypePercentages.put(HexadPlayerType.ACHIEVER, 0.3); + playerTypePercentages.put(HexadPlayerType.PLAYER, 0.7); + playerTypePercentages.put(HexadPlayerType.SOCIALISER, 0.5); + playerTypePercentages.put(HexadPlayerType.FREE_SPIRIT, 0.6); + playerTypePercentages.put(HexadPlayerType.PHILANTHROPIST, 0.8); + playerTypePercentages.put(HexadPlayerType.DISRUPTOR, 0.4); + + UserHexadPlayerTypeSetEvent event = mock(UserHexadPlayerTypeSetEvent.class); + when(event.getUserId()).thenReturn(userId); + when(event.getPrimaryPlayerType()).thenReturn(HexadPlayerType.PHILANTHROPIST); + when(event.getPlayerTypePercentages()).thenReturn(playerTypePercentages); + + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(event); + + assertDoesNotThrow(() -> subscriptionController.onUserHexadPlayerTypeSetEvent(cloudEvent, headers).block()); + + verify(userPlayerTypeService, times(1)).saveUserPlayerType( + eq(userId), + eq(HexadPlayerType.PHILANTHROPIST), + argThat(percentages -> + percentages.size() == 6 && + percentages.get(HexadPlayerType.PHILANTHROPIST) == 0.8 && + percentages.get(HexadPlayerType.DISRUPTOR) == 0.4) + ); + } + + @Test + void testOnContentProgressedEvent_NullEventData_HandlesGracefully() { + CloudEvent cloudEvent = mock(CloudEvent.class); + when(cloudEvent.getData()).thenReturn(null); + + assertDoesNotThrow(() -> subscriptionController.onContentProgressedEvent(cloudEvent, headers).block()); + + verify(proactiveFeedbackService, never()).generateFeedback(any()); + } +} From 8b9eef13ae5e2a84e2deea70c19c8b33770bb992 Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Tue, 16 Dec 2025 00:29:29 +0100 Subject: [PATCH 11/24] added tests for ConversationHistoryService --- .../ConversationHistoryServiceTest.java | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/ConversationHistoryServiceTest.java diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/ConversationHistoryServiceTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/ConversationHistoryServiceTest.java new file mode 100644 index 0000000..e781ca8 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/ConversationHistoryServiceTest.java @@ -0,0 +1,236 @@ +package de.unistuttgart.iste.meitrex.tutor_service.service; + +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.ConversationHistoryEntity; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.repository.ConversationHistoryRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.OffsetDateTime; +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ConversationHistoryServiceTest { + + @Mock + private ConversationHistoryRepository conversationHistoryRepository; + + @InjectMocks + private ConversationHistoryService conversationHistoryService; + + private UUID userId; + private UUID courseId; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + courseId = UUID.randomUUID(); + + ReflectionTestUtils.setField(conversationHistoryService, "maxHistoryPairs", 3); + ReflectionTestUtils.setField(conversationHistoryService, "maxAgeMinutes", 30); + } + + @Test + void testGetRecentHistory_ReturnsOnlyRecentEntries() { + OffsetDateTime now = OffsetDateTime.now(); + + ConversationHistoryEntity recent1 = createHistoryEntity(now.minusMinutes(5)); + ConversationHistoryEntity recent2 = createHistoryEntity(now.minusMinutes(10)); + ConversationHistoryEntity old = createHistoryEntity(now.minusMinutes(60)); + + when(conversationHistoryRepository.findByUserIdAndCourseIdOrderByTimestampDesc(userId, courseId)) + .thenReturn(Arrays.asList(recent1, recent2, old)); + + List result = conversationHistoryService.getRecentHistory(userId, courseId); + + assertEquals(2, result.size()); + assertTrue(result.contains(recent1)); + assertTrue(result.contains(recent2)); + assertFalse(result.contains(old)); + } + + @Test + void testGetRecentHistory_LimitsToMaxHistoryPairs() { + OffsetDateTime now = OffsetDateTime.now(); + + ConversationHistoryEntity entry1 = createHistoryEntity(now.minusMinutes(1)); + ConversationHistoryEntity entry2 = createHistoryEntity(now.minusMinutes(2)); + ConversationHistoryEntity entry3 = createHistoryEntity(now.minusMinutes(3)); + ConversationHistoryEntity entry4 = createHistoryEntity(now.minusMinutes(4)); + + when(conversationHistoryRepository.findByUserIdAndCourseIdOrderByTimestampDesc(userId, courseId)) + .thenReturn(Arrays.asList(entry1, entry2, entry3, entry4)); + + List result = conversationHistoryService.getRecentHistory(userId, courseId); + + assertEquals(3, result.size()); + assertTrue(result.contains(entry1)); + assertTrue(result.contains(entry2)); + assertTrue(result.contains(entry3)); + assertFalse(result.contains(entry4)); + } + + @Test + void testGetRecentHistory_EmptyWhenNoHistory() { + when(conversationHistoryRepository.findByUserIdAndCourseIdOrderByTimestampDesc(userId, courseId)) + .thenReturn(Collections.emptyList()); + + List result = conversationHistoryService.getRecentHistory(userId, courseId); + + assertTrue(result.isEmpty()); + } + + @Test + void testAddConversationExchange_SavesNewEntry() { + String userMessage = "What do you get if you multiply six by nine?"; + String tutorResponse = "42"; + + when(conversationHistoryRepository.findByUserIdAndCourseIdOrderByTimestampDesc(userId, courseId)) + .thenReturn(Collections.emptyList()); + + conversationHistoryService.addConversationExchange(userId, courseId, userMessage, tutorResponse); + + verify(conversationHistoryRepository).save(argThat(entity -> + entity.getUserId().equals(userId) && + entity.getCourseId().equals(courseId) && + entity.getUserMessage().equals(userMessage) && + entity.getTutorResponse().equals(tutorResponse) + )); + } + + @Test + void testAddConversationExchange_DeletesOldEntriesBeforeSaving() { + String userMessage = "What is the meaning of life?"; + String tutorResponse = "42"; + OffsetDateTime cutoffTime = OffsetDateTime.now().minusMinutes(30); + + when(conversationHistoryRepository.findByUserIdAndCourseIdOrderByTimestampDesc(userId, courseId)) + .thenReturn(Collections.emptyList()); + + conversationHistoryService.addConversationExchange(userId, courseId, userMessage, tutorResponse); + + verify(conversationHistoryRepository).deleteByUserIdAndCourseIdAndTimestampBefore( + eq(userId), + eq(courseId), + any(OffsetDateTime.class) + ); + } + + @Test + void testAddConversationExchange_RemovesOldestWhenAtMaxCapacity() { + String userMessage = "New question"; + String tutorResponse = "New response"; + + ConversationHistoryEntity oldest = createHistoryEntity(OffsetDateTime.now().minusMinutes(20)); + ConversationHistoryEntity middle = createHistoryEntity(OffsetDateTime.now().minusMinutes(10)); + ConversationHistoryEntity newest = createHistoryEntity(OffsetDateTime.now().minusMinutes(5)); + + when(conversationHistoryRepository.findByUserIdAndCourseIdOrderByTimestampDesc(userId, courseId)) + .thenReturn(Arrays.asList(newest, middle, oldest)); + + conversationHistoryService.addConversationExchange(userId, courseId, userMessage, tutorResponse); + + verify(conversationHistoryRepository).delete(oldest); + verify(conversationHistoryRepository).save(any(ConversationHistoryEntity.class)); + } + + @Test + void testFormatHistoryForPrompt_ReturnsFormattedString() { + OffsetDateTime now = OffsetDateTime.now(); + String userMessage1 = "pi = e = 3"; + String tutorResponse1 = "And sqrt(10) is also pi"; + String userMessage2 = "Hello there"; + String tutorResponse2 = "General Kenobi"; + + ConversationHistoryEntity entry1 = ConversationHistoryEntity.builder() + .id(UUID.randomUUID()) + .userId(userId) + .courseId(courseId) + .userMessage(userMessage1) + .tutorResponse(tutorResponse1) + .timestamp(now.minusMinutes(10)) + .build(); + + ConversationHistoryEntity entry2 = ConversationHistoryEntity.builder() + .id(UUID.randomUUID()) + .userId(userId) + .courseId(courseId) + .userMessage(userMessage2) + .tutorResponse(tutorResponse2) + .timestamp(now.minusMinutes(5)) + .build(); + + when(conversationHistoryRepository.findByUserIdAndCourseIdOrderByTimestampDesc(userId, courseId)) + .thenReturn(Arrays.asList(entry2, entry1)); // Newest first + + String result = conversationHistoryService.formatHistoryForPrompt(userId, courseId); + + assertTrue(result.contains("Previous Conversation History")); + assertTrue(result.contains("Exchange 1")); + assertTrue(result.contains("Exchange 2")); + assertTrue(result.contains(userMessage1)); + assertTrue(result.contains(tutorResponse1)); + assertTrue(result.contains(userMessage2)); + assertTrue(result.contains(tutorResponse2)); + + int inheritanceIndex = result.indexOf(userMessage1); + int exampleIndex = result.indexOf(userMessage2); + assertTrue(inheritanceIndex < exampleIndex); + } + + @Test + void testFormatHistoryForPrompt_ReturnsEmptyWhenNoHistory() { + when(conversationHistoryRepository.findByUserIdAndCourseIdOrderByTimestampDesc(userId, courseId)) + .thenReturn(Collections.emptyList()); + + String result = conversationHistoryService.formatHistoryForPrompt(userId, courseId); + + assertEquals("", result); + } + + @Test + void testClearHistory_DeletesAllHistoryForUser() { + ConversationHistoryEntity entry1 = createHistoryEntity(OffsetDateTime.now().minusMinutes(5)); + ConversationHistoryEntity entry2 = createHistoryEntity(OffsetDateTime.now().minusMinutes(10)); + + when(conversationHistoryRepository.findByUserIdAndCourseIdOrderByTimestampDesc(userId, courseId)) + .thenReturn(Arrays.asList(entry1, entry2)); + + conversationHistoryService.clearHistory(userId, courseId); + + verify(conversationHistoryRepository).deleteAll(Arrays.asList(entry1, entry2)); + } + + @Test + void testClearHistory_HandlesEmptyHistory() { + when(conversationHistoryRepository.findByUserIdAndCourseIdOrderByTimestampDesc(userId, courseId)) + .thenReturn(Collections.emptyList()); + + conversationHistoryService.clearHistory(userId, courseId); + + verify(conversationHistoryRepository).deleteAll(Collections.emptyList()); + } + + /** + * Creates a ConversationHistoryEntity with the given timestamp. + */ + private ConversationHistoryEntity createHistoryEntity(OffsetDateTime timestamp) { + return ConversationHistoryEntity.builder() + .id(UUID.randomUUID()) + .userId(userId) + .courseId(courseId) + .userMessage("Test question at " + timestamp) + .tutorResponse("Test response at " + timestamp) + .timestamp(timestamp) + .build(); + } +} From 42dd4d84b3c50cf71ec93092595a852c8ba72088 Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Tue, 16 Dec 2025 00:38:12 +0100 Subject: [PATCH 12/24] added tests for UserPlayerTypeService --- .../service/UserPlayerTypeServiceTest.java | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserPlayerTypeServiceTest.java diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserPlayerTypeServiceTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserPlayerTypeServiceTest.java new file mode 100644 index 0000000..e5fdc6b --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserPlayerTypeServiceTest.java @@ -0,0 +1,144 @@ +package de.unistuttgart.iste.meitrex.tutor_service.service; + +import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.UserPlayerTypeEntity; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.mapper.UserPlayerTypeMapper; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.repository.UserPlayerTypeRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserPlayerTypeServiceTest { + + @Mock + private UserPlayerTypeRepository userPlayerTypeRepository; + + @Mock + private UserPlayerTypeMapper userPlayerTypeMapper; + + @InjectMocks + private UserPlayerTypeService userPlayerTypeService; + + private UUID userId; + private HexadPlayerType primaryPlayerType; + private Map playerTypePercentages; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + primaryPlayerType = HexadPlayerType.ACHIEVER; + + playerTypePercentages = new HashMap<>(); + playerTypePercentages.put(HexadPlayerType.ACHIEVER, 0.8); + playerTypePercentages.put(HexadPlayerType.PLAYER, 0.6); + playerTypePercentages.put(HexadPlayerType.SOCIALISER, 0.4); + playerTypePercentages.put(HexadPlayerType.FREE_SPIRIT, 0.5); + playerTypePercentages.put(HexadPlayerType.PHILANTHROPIST, 0.7); + playerTypePercentages.put(HexadPlayerType.DISRUPTOR, 0.3); + } + + @Test + void testSaveUserPlayerType_CreatesNewEntityWhenNotExists() { + UserPlayerTypeEntity newEntity = mock(UserPlayerTypeEntity.class); + + when(userPlayerTypeRepository.findById(userId)).thenReturn(Optional.empty()); + when(userPlayerTypeMapper.createEntity(userId, primaryPlayerType, playerTypePercentages)) + .thenReturn(newEntity); + + userPlayerTypeService.saveUserPlayerType(userId, primaryPlayerType, playerTypePercentages); + + verify(userPlayerTypeMapper).createEntity(userId, primaryPlayerType, playerTypePercentages); + verify(userPlayerTypeRepository).save(newEntity); + verify(userPlayerTypeMapper, never()).updateEntity(any(), any(), any()); + } + + @Test + void testSaveUserPlayerType_UpdatesExistingEntity() { + UserPlayerTypeEntity existingEntity = mock(UserPlayerTypeEntity.class); + UserPlayerTypeEntity updatedEntity = mock(UserPlayerTypeEntity.class); + + when(userPlayerTypeRepository.findById(userId)).thenReturn(Optional.of(existingEntity)); + when(userPlayerTypeMapper.updateEntity(existingEntity, primaryPlayerType, playerTypePercentages)) + .thenReturn(updatedEntity); + + userPlayerTypeService.saveUserPlayerType(userId, primaryPlayerType, playerTypePercentages); + + verify(userPlayerTypeMapper).updateEntity(existingEntity, primaryPlayerType, playerTypePercentages); + verify(userPlayerTypeRepository).save(updatedEntity); + verify(userPlayerTypeMapper, never()).createEntity(any(), any(), any()); + } + + @Test + void testGetUserPlayerType_ReturnsEntityWhenExists() { + UserPlayerTypeEntity entity = mock(UserPlayerTypeEntity.class); + when(userPlayerTypeRepository.findById(userId)).thenReturn(Optional.of(entity)); + + Optional result = userPlayerTypeService.getUserPlayerType(userId); + + assertTrue(result.isPresent()); + assertEquals(entity, result.get()); + } + + @Test + void testGetUserPlayerType_ReturnsEmptyWhenNotExists() { + when(userPlayerTypeRepository.findById(userId)).thenReturn(Optional.empty()); + + Optional result = userPlayerTypeService.getUserPlayerType(userId); + + assertTrue(result.isEmpty()); + } + + @Test + void testGetPrimaryPlayerType_ReturnsTypeWhenExists() { + UserPlayerTypeEntity entity = mock(UserPlayerTypeEntity.class); + when(entity.getPrimaryPlayerType()).thenReturn(primaryPlayerType); + when(userPlayerTypeRepository.findById(userId)).thenReturn(Optional.of(entity)); + + Optional result = userPlayerTypeService.getPrimaryPlayerType(userId); + + assertTrue(result.isPresent()); + assertEquals(primaryPlayerType, result.get()); + } + + @Test + void testGetPrimaryPlayerType_ReturnsEmptyWhenNotExists() { + when(userPlayerTypeRepository.findById(userId)).thenReturn(Optional.empty()); + + Optional result = userPlayerTypeService.getPrimaryPlayerType(userId); + + assertTrue(result.isEmpty()); + } + + @Test + void testSaveUserPlayerType_HandlesAllPlayerTypes() { + UserPlayerTypeEntity newEntity = mock(UserPlayerTypeEntity.class); + + Map allTypes = new HashMap<>(); + for (HexadPlayerType type : HexadPlayerType.values()) { + allTypes.put(type, 0.5); + } + + when(userPlayerTypeRepository.findById(userId)).thenReturn(Optional.empty()); + when(userPlayerTypeMapper.createEntity(userId, HexadPlayerType.PHILANTHROPIST, allTypes)) + .thenReturn(newEntity); + + userPlayerTypeService.saveUserPlayerType(userId, HexadPlayerType.PHILANTHROPIST, allTypes); + + verify(userPlayerTypeMapper).createEntity(userId, HexadPlayerType.PHILANTHROPIST, allTypes); + verify(userPlayerTypeRepository).save(newEntity); + } +} From dc017499903bae2180ef725d92f667f27795e641 Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Tue, 16 Dec 2025 00:44:20 +0100 Subject: [PATCH 13/24] added tests for UserSkillLevelService --- .../service/UserSkillLevelServiceTest.java | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserSkillLevelServiceTest.java diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserSkillLevelServiceTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserSkillLevelServiceTest.java new file mode 100644 index 0000000..78bb5d5 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/UserSkillLevelServiceTest.java @@ -0,0 +1,149 @@ +package de.unistuttgart.iste.meitrex.tutor_service.service; + +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.UserSkillLevelEntity; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.mapper.UserSkillLevelMapper; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.repository.UserSkillLevelRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserSkillLevelServiceTest { + + @Mock + private UserSkillLevelRepository userSkillLevelRepository; + + @Mock + private UserSkillLevelMapper userSkillLevelMapper; + + @InjectMocks + private UserSkillLevelService userSkillLevelService; + + private UUID userId; + private UUID skillId; + private Float skillLevelValue; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + skillId = UUID.randomUUID(); + skillLevelValue = 0.75f; + } + + @Test + void testSaveUserSkillLevel_CreatesNewEntityWhenNotExists() { + UserSkillLevelEntity.UserSkillLevelId id = new UserSkillLevelEntity.UserSkillLevelId(userId, skillId); + UserSkillLevelEntity newEntity = mock(UserSkillLevelEntity.class); + + when(userSkillLevelRepository.findById(id)).thenReturn(Optional.empty()); + when(userSkillLevelMapper.createEntity(userId, skillId, skillLevelValue)).thenReturn(newEntity); + + userSkillLevelService.saveUserSkillLevel(userId, skillId, skillLevelValue); + + verify(userSkillLevelMapper).createEntity(userId, skillId, skillLevelValue); + verify(userSkillLevelRepository).save(newEntity); + verify(userSkillLevelMapper, never()).updateEntity(any(), any()); + } + + @Test + void testSaveUserSkillLevel_UpdatesExistingEntity() { + UserSkillLevelEntity.UserSkillLevelId id = new UserSkillLevelEntity.UserSkillLevelId(userId, skillId); + UserSkillLevelEntity existingEntity = mock(UserSkillLevelEntity.class); + UserSkillLevelEntity updatedEntity = mock(UserSkillLevelEntity.class); + + when(userSkillLevelRepository.findById(id)).thenReturn(Optional.of(existingEntity)); + when(userSkillLevelMapper.updateEntity(existingEntity, skillLevelValue)).thenReturn(updatedEntity); + + userSkillLevelService.saveUserSkillLevel(userId, skillId, skillLevelValue); + + verify(userSkillLevelMapper).updateEntity(existingEntity, skillLevelValue); + verify(userSkillLevelRepository).save(updatedEntity); + verify(userSkillLevelMapper, never()).createEntity(any(), any(), any()); + } + + @Test + void testGetSkillLevel_ReturnsValueWhenExists() { + UserSkillLevelEntity.UserSkillLevelId id = new UserSkillLevelEntity.UserSkillLevelId(userId, skillId); + UserSkillLevelEntity entity = mock(UserSkillLevelEntity.class); + when(entity.getSkillLevelValue()).thenReturn(skillLevelValue); + when(userSkillLevelRepository.findById(id)).thenReturn(Optional.of(entity)); + + Optional result = userSkillLevelService.getSkillLevel(userId, skillId); + + assertTrue(result.isPresent()); + assertEquals(skillLevelValue, result.get()); + } + + @Test + void testGetSkillLevel_ReturnsEmptyWhenNotExists() { + UserSkillLevelEntity.UserSkillLevelId id = new UserSkillLevelEntity.UserSkillLevelId(userId, skillId); + when(userSkillLevelRepository.findById(id)).thenReturn(Optional.empty()); + + Optional result = userSkillLevelService.getSkillLevel(userId, skillId); + + assertTrue(result.isEmpty()); + } + + @Test + void testGetAllSkillLevelsForUser_ReturnsAllSkills() { + UserSkillLevelEntity skill1 = mock(UserSkillLevelEntity.class); + UserSkillLevelEntity skill2 = mock(UserSkillLevelEntity.class); + UserSkillLevelEntity skill3 = mock(UserSkillLevelEntity.class); + + List skills = Arrays.asList(skill1, skill2, skill3); + when(userSkillLevelRepository.findByUserId(userId)).thenReturn(skills); + + List result = userSkillLevelService.getAllSkillLevelsForUser(userId); + + assertEquals(3, result.size()); + assertTrue(result.contains(skill1)); + assertTrue(result.contains(skill2)); + assertTrue(result.contains(skill3)); + } + + @Test + void testGetAllSkillLevelsForUser_ReturnsEmptyListWhenNoSkills() { + when(userSkillLevelRepository.findByUserId(userId)).thenReturn(List.of()); + + List result = userSkillLevelService.getAllSkillLevelsForUser(userId); + + assertTrue(result.isEmpty()); + } + + @Test + void testSaveUserSkillLevel_HandlesMultipleSkillsForSameUser() { + UUID skill1Id = UUID.randomUUID(); + UUID skill2Id = UUID.randomUUID(); + Float value1 = 0.6f; + Float value2 = 0.9f; + + UserSkillLevelEntity.UserSkillLevelId id1 = new UserSkillLevelEntity.UserSkillLevelId(userId, skill1Id); + UserSkillLevelEntity.UserSkillLevelId id2 = new UserSkillLevelEntity.UserSkillLevelId(userId, skill2Id); + + UserSkillLevelEntity entity1 = mock(UserSkillLevelEntity.class); + UserSkillLevelEntity entity2 = mock(UserSkillLevelEntity.class); + + when(userSkillLevelRepository.findById(id1)).thenReturn(Optional.empty()); + when(userSkillLevelRepository.findById(id2)).thenReturn(Optional.empty()); + when(userSkillLevelMapper.createEntity(userId, skill1Id, value1)).thenReturn(entity1); + when(userSkillLevelMapper.createEntity(userId, skill2Id, value2)).thenReturn(entity2); + + userSkillLevelService.saveUserSkillLevel(userId, skill1Id, value1); + userSkillLevelService.saveUserSkillLevel(userId, skill2Id, value2); + + verify(userSkillLevelRepository).save(entity1); + verify(userSkillLevelRepository).save(entity2); + } +} From e27c56869adb8d21d248a857b9895d77806929fd Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Tue, 16 Dec 2025 01:12:43 +0100 Subject: [PATCH 14/24] added tests for UserPlyerTypeEntity --- .../entity/UserPlayerTypeEntityTest.java | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/UserPlayerTypeEntityTest.java diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/UserPlayerTypeEntityTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/UserPlayerTypeEntityTest.java new file mode 100644 index 0000000..a9dbeb8 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/entity/UserPlayerTypeEntityTest.java @@ -0,0 +1,156 @@ +package de.unistuttgart.iste.meitrex.tutor_service.persistence.entity; + +import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class UserPlayerTypeEntityTest { + + @Test + void testGetPlayerTypePercentagesAsMap_ReturnsAllTypes() { + UUID userId = UUID.randomUUID(); + UserPlayerTypeEntity entity = UserPlayerTypeEntity.builder() + .userId(userId) + .primaryPlayerType(HexadPlayerType.ACHIEVER) + .achieverScore(0.45) + .playerScore(0.20) + .socialiserScore(0.15) + .freeSpiritScore(0.10) + .philanthropistScore(0.05) + .disruptorScore(0.05) + .build(); + + Map percentages = entity.getPlayerTypePercentagesAsMap(); + + assertNotNull(percentages); + assertEquals(6, percentages.size()); + assertEquals(0.45, percentages.get(HexadPlayerType.ACHIEVER)); + assertEquals(0.20, percentages.get(HexadPlayerType.PLAYER)); + assertEquals(0.15, percentages.get(HexadPlayerType.SOCIALISER)); + assertEquals(0.10, percentages.get(HexadPlayerType.FREE_SPIRIT)); + assertEquals(0.05, percentages.get(HexadPlayerType.PHILANTHROPIST)); + assertEquals(0.05, percentages.get(HexadPlayerType.DISRUPTOR)); + } + + @Test + void testSetPlayerTypePercentagesFromMap_SetsAllFields() { + UUID userId = UUID.randomUUID(); + UserPlayerTypeEntity entity = UserPlayerTypeEntity.builder() + .userId(userId) + .primaryPlayerType(HexadPlayerType.ACHIEVER) + .build(); + + Map percentages = Map.of( + HexadPlayerType.ACHIEVER, 0.30, + HexadPlayerType.PLAYER, 0.25, + HexadPlayerType.SOCIALISER, 0.20, + HexadPlayerType.FREE_SPIRIT, 0.10, + HexadPlayerType.PHILANTHROPIST, 0.10, + HexadPlayerType.DISRUPTOR, 0.05 + ); + + entity.setPlayerTypePercentagesFromMap(percentages); + + assertEquals(0.30, entity.getAchieverScore()); + assertEquals(0.25, entity.getPlayerScore()); + assertEquals(0.20, entity.getSocialiserScore()); + assertEquals(0.10, entity.getFreeSpiritScore()); + assertEquals(0.10, entity.getPhilanthropistScore()); + assertEquals(0.05, entity.getDisruptorScore()); + } + + @Test + void testRoundTrip_MapToEntityToMap() { + Map originalPercentages = Map.of( + HexadPlayerType.ACHIEVER, 0.40, + HexadPlayerType.PLAYER, 0.30, + HexadPlayerType.SOCIALISER, 0.10, + HexadPlayerType.FREE_SPIRIT, 0.10, + HexadPlayerType.PHILANTHROPIST, 0.05, + HexadPlayerType.DISRUPTOR, 0.05 + ); + + UserPlayerTypeEntity entity = UserPlayerTypeEntity.builder() + .userId(UUID.randomUUID()) + .primaryPlayerType(HexadPlayerType.ACHIEVER) + .build(); + + entity.setPlayerTypePercentagesFromMap(originalPercentages); + Map retrievedPercentages = entity.getPlayerTypePercentagesAsMap(); + + assertEquals(originalPercentages, retrievedPercentages); + } + + @Test + void testBuilder_CreatesEntityWithAllFields() { + UUID userId = UUID.randomUUID(); + + UserPlayerTypeEntity entity = UserPlayerTypeEntity.builder() + .userId(userId) + .primaryPlayerType(HexadPlayerType.FREE_SPIRIT) + .achieverScore(0.10) + .playerScore(0.15) + .socialiserScore(0.20) + .freeSpiritScore(0.35) + .philanthropistScore(0.10) + .disruptorScore(0.10) + .build(); + + assertNotNull(entity); + assertEquals(userId, entity.getUserId()); + assertEquals(HexadPlayerType.FREE_SPIRIT, entity.getPrimaryPlayerType()); + assertEquals(0.10, entity.getAchieverScore()); + assertEquals(0.15, entity.getPlayerScore()); + assertEquals(0.20, entity.getSocialiserScore()); + assertEquals(0.35, entity.getFreeSpiritScore()); + assertEquals(0.10, entity.getPhilanthropistScore()); + assertEquals(0.10, entity.getDisruptorScore()); + } + + @Test + void testSettersAndGetters_WorkCorrectly() { + UserPlayerTypeEntity entity = new UserPlayerTypeEntity(); + UUID userId = UUID.randomUUID(); + + entity.setUserId(userId); + entity.setPrimaryPlayerType(HexadPlayerType.PHILANTHROPIST); + entity.setAchieverScore(0.15); + entity.setPlayerScore(0.10); + entity.setSocialiserScore(0.20); + entity.setFreeSpiritScore(0.15); + entity.setPhilanthropistScore(0.30); + entity.setDisruptorScore(0.10); + + assertEquals(userId, entity.getUserId()); + assertEquals(HexadPlayerType.PHILANTHROPIST, entity.getPrimaryPlayerType()); + assertEquals(0.15, entity.getAchieverScore()); + assertEquals(0.10, entity.getPlayerScore()); + assertEquals(0.20, entity.getSocialiserScore()); + assertEquals(0.15, entity.getFreeSpiritScore()); + assertEquals(0.30, entity.getPhilanthropistScore()); + assertEquals(0.10, entity.getDisruptorScore()); + } + + @Test + void testSetPlayerTypePercentagesFromMap_HandlesNullScores() { + UserPlayerTypeEntity entity = new UserPlayerTypeEntity(); + Map percentages = Map.of( + HexadPlayerType.ACHIEVER, 1.0, + HexadPlayerType.PLAYER, 0.0, + HexadPlayerType.SOCIALISER, 0.0, + HexadPlayerType.FREE_SPIRIT, 0.0, + HexadPlayerType.PHILANTHROPIST, 0.0, + HexadPlayerType.DISRUPTOR, 0.0 + ); + + entity.setPlayerTypePercentagesFromMap(percentages); + + assertEquals(1.0, entity.getAchieverScore()); + assertEquals(0.0, entity.getPlayerScore()); + assertEquals(0.0, entity.getSocialiserScore()); + } +} From 57b274411ef26a4b5dea79c17f6457f1848724a6 Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Tue, 16 Dec 2025 01:19:34 +0100 Subject: [PATCH 15/24] added tests for persistance/mapper/* --- .../mapper/UserPlayerTypeMapperTest.java | 194 +++++++++++++++ .../mapper/UserSkillLevelMapperTest.java | 222 ++++++++++++++++++ 2 files changed, 416 insertions(+) create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserPlayerTypeMapperTest.java create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserSkillLevelMapperTest.java diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserPlayerTypeMapperTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserPlayerTypeMapperTest.java new file mode 100644 index 0000000..9e8bb1d --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserPlayerTypeMapperTest.java @@ -0,0 +1,194 @@ +package de.unistuttgart.iste.meitrex.tutor_service.persistence.mapper; + +import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.UserPlayerTypeEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.modelmapper.ModelMapper; + +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +class UserPlayerTypeMapperTest { + + @Mock + private ModelMapper modelMapper; + + @InjectMocks + private UserPlayerTypeMapper mapper; + + private UUID userId; + private HexadPlayerType primaryPlayerType; + private Map playerTypePercentages; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + primaryPlayerType = HexadPlayerType.ACHIEVER; + playerTypePercentages = Map.of( + HexadPlayerType.ACHIEVER, 0.40, + HexadPlayerType.PLAYER, 0.25, + HexadPlayerType.SOCIALISER, 0.15, + HexadPlayerType.FREE_SPIRIT, 0.10, + HexadPlayerType.PHILANTHROPIST, 0.05, + HexadPlayerType.DISRUPTOR, 0.05 + ); + } + + @Test + void testCreateEntity_CreatesNewEntityWithAllFields() { + UserPlayerTypeEntity entity = mapper.createEntity(userId, primaryPlayerType, playerTypePercentages); + + assertNotNull(entity); + assertEquals(userId, entity.getUserId()); + assertEquals(primaryPlayerType, entity.getPrimaryPlayerType()); + assertEquals(0.40, entity.getAchieverScore()); + assertEquals(0.25, entity.getPlayerScore()); + assertEquals(0.15, entity.getSocialiserScore()); + assertEquals(0.10, entity.getFreeSpiritScore()); + assertEquals(0.05, entity.getPhilanthropistScore()); + assertEquals(0.05, entity.getDisruptorScore()); + } + + @Test + void testCreateEntity_WithDifferentPrimaryType() { + HexadPlayerType philanthropist = HexadPlayerType.PHILANTHROPIST; + Map percentages = Map.of( + HexadPlayerType.ACHIEVER, 0.10, + HexadPlayerType.PLAYER, 0.10, + HexadPlayerType.SOCIALISER, 0.20, + HexadPlayerType.FREE_SPIRIT, 0.15, + HexadPlayerType.PHILANTHROPIST, 0.35, + HexadPlayerType.DISRUPTOR, 0.10 + ); + + UserPlayerTypeEntity entity = mapper.createEntity(userId, philanthropist, percentages); + + assertNotNull(entity); + assertEquals(userId, entity.getUserId()); + assertEquals(philanthropist, entity.getPrimaryPlayerType()); + assertEquals(0.35, entity.getPhilanthropistScore()); + } + + @Test + void testUpdateEntity_UpdatesExistingEntity() { + UserPlayerTypeEntity existingEntity = UserPlayerTypeEntity.builder() + .userId(userId) + .primaryPlayerType(HexadPlayerType.PLAYER) + .achieverScore(0.20) + .playerScore(0.50) + .socialiserScore(0.10) + .freeSpiritScore(0.10) + .philanthropistScore(0.05) + .disruptorScore(0.05) + .build(); + + HexadPlayerType newPrimaryType = HexadPlayerType.ACHIEVER; + Map newPercentages = Map.of( + HexadPlayerType.ACHIEVER, 0.45, + HexadPlayerType.PLAYER, 0.20, + HexadPlayerType.SOCIALISER, 0.15, + HexadPlayerType.FREE_SPIRIT, 0.10, + HexadPlayerType.PHILANTHROPIST, 0.05, + HexadPlayerType.DISRUPTOR, 0.05 + ); + + UserPlayerTypeEntity updatedEntity = mapper.updateEntity(existingEntity, newPrimaryType, newPercentages); + + assertSame(existingEntity, updatedEntity); + assertEquals(newPrimaryType, updatedEntity.getPrimaryPlayerType()); + assertEquals(0.45, updatedEntity.getAchieverScore()); + assertEquals(0.20, updatedEntity.getPlayerScore()); + assertEquals(0.15, updatedEntity.getSocialiserScore()); + assertEquals(0.10, updatedEntity.getFreeSpiritScore()); + assertEquals(0.05, updatedEntity.getPhilanthropistScore()); + assertEquals(0.05, updatedEntity.getDisruptorScore()); + } + + @Test + void testUpdateEntity_ChangesAllPlayerTypeScores() { + UserPlayerTypeEntity existingEntity = UserPlayerTypeEntity.builder() + .userId(userId) + .primaryPlayerType(HexadPlayerType.ACHIEVER) + .achieverScore(0.10) + .playerScore(0.10) + .socialiserScore(0.10) + .freeSpiritScore(0.10) + .philanthropistScore(0.10) + .disruptorScore(0.50) + .build(); + + HexadPlayerType newPrimaryType = HexadPlayerType.FREE_SPIRIT; + Map newPercentages = Map.of( + HexadPlayerType.ACHIEVER, 0.15, + HexadPlayerType.PLAYER, 0.15, + HexadPlayerType.SOCIALISER, 0.15, + HexadPlayerType.FREE_SPIRIT, 0.35, + HexadPlayerType.PHILANTHROPIST, 0.10, + HexadPlayerType.DISRUPTOR, 0.10 + ); + + UserPlayerTypeEntity updatedEntity = mapper.updateEntity(existingEntity, newPrimaryType, newPercentages); + + assertEquals(newPrimaryType, updatedEntity.getPrimaryPlayerType()); + assertNotEquals(0.10, updatedEntity.getAchieverScore()); + assertNotEquals(0.50, updatedEntity.getDisruptorScore()); + assertEquals(0.35, updatedEntity.getFreeSpiritScore()); + } + + @Test + void testCreateEntity_WithAllSixPlayerTypes() { + for (HexadPlayerType type : HexadPlayerType.values()) { + Map percentages = Map.of( + HexadPlayerType.ACHIEVER, 0.16, + HexadPlayerType.PLAYER, 0.17, + HexadPlayerType.SOCIALISER, 0.17, + HexadPlayerType.FREE_SPIRIT, 0.17, + HexadPlayerType.PHILANTHROPIST, 0.17, + HexadPlayerType.DISRUPTOR, 0.16 + ); + + UserPlayerTypeEntity entity = mapper.createEntity(UUID.randomUUID(), type, percentages); + + assertNotNull(entity); + assertEquals(type, entity.getPrimaryPlayerType()); + } + } + + @Test + void testCreateEntity_WithZeroPercentages() { + Map zeroPercentages = Map.of( + HexadPlayerType.ACHIEVER, 1.0, + HexadPlayerType.PLAYER, 0.0, + HexadPlayerType.SOCIALISER, 0.0, + HexadPlayerType.FREE_SPIRIT, 0.0, + HexadPlayerType.PHILANTHROPIST, 0.0, + HexadPlayerType.DISRUPTOR, 0.0 + ); + + UserPlayerTypeEntity entity = mapper.createEntity(userId, HexadPlayerType.ACHIEVER, zeroPercentages); + + assertNotNull(entity); + assertEquals(1.0, entity.getAchieverScore()); + assertEquals(0.0, entity.getPlayerScore()); + } + + @Test + void testUpdateEntity_ReturnsSameInstance() { + UserPlayerTypeEntity existingEntity = UserPlayerTypeEntity.builder() + .userId(userId) + .primaryPlayerType(HexadPlayerType.PLAYER) + .build(); + + UserPlayerTypeEntity result = mapper.updateEntity(existingEntity, primaryPlayerType, playerTypePercentages); + + assertSame(existingEntity, result, "updateEntity should return the same instance"); + } +} diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserSkillLevelMapperTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserSkillLevelMapperTest.java new file mode 100644 index 0000000..cbf28e7 --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserSkillLevelMapperTest.java @@ -0,0 +1,222 @@ +package de.unistuttgart.iste.meitrex.tutor_service.persistence.mapper; + +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.UserSkillLevelEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.modelmapper.ModelMapper; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +class UserSkillLevelMapperTest { + + @Mock + private ModelMapper modelMapper; + + @InjectMocks + private UserSkillLevelMapper mapper; + + private UUID userId; + private UUID skillId; + private Float skillLevelValue; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + skillId = UUID.randomUUID(); + skillLevelValue = 0.75f; + } + + @Test + void testCreateEntity_CreatesNewEntityWithAllFields() { + // Act + UserSkillLevelEntity entity = mapper.createEntity(userId, skillId, skillLevelValue); + + // Assert + assertNotNull(entity); + assertEquals(userId, entity.getUserId()); + assertEquals(skillId, entity.getSkillId()); + assertEquals(skillLevelValue, entity.getSkillLevelValue()); + } + + @Test + void testCreateEntity_WithDifferentSkillLevels() { + // Test with beginner level + UserSkillLevelEntity beginnerEntity = mapper.createEntity(userId, skillId, 0.1f); + assertEquals(0.1f, beginnerEntity.getSkillLevelValue()); + + // Test with intermediate level + UserSkillLevelEntity intermediateEntity = mapper.createEntity(userId, skillId, 0.5f); + assertEquals(0.5f, intermediateEntity.getSkillLevelValue()); + + // Test with expert level + UserSkillLevelEntity expertEntity = mapper.createEntity(userId, skillId, 0.9f); + assertEquals(0.9f, expertEntity.getSkillLevelValue()); + + // Test with maximum level + UserSkillLevelEntity maxEntity = mapper.createEntity(userId, skillId, 1.0f); + assertEquals(1.0f, maxEntity.getSkillLevelValue()); + } + + @Test + void testCreateEntity_WithZeroSkillLevel() { + // Act + UserSkillLevelEntity entity = mapper.createEntity(userId, skillId, 0.0f); + + // Assert + assertNotNull(entity); + assertEquals(0.0f, entity.getSkillLevelValue()); + } + + @Test + void testCreateEntity_WithMultipleSkills() { + // Arrange + UUID skill1 = UUID.randomUUID(); + UUID skill2 = UUID.randomUUID(); + UUID skill3 = UUID.randomUUID(); + + // Act + UserSkillLevelEntity entity1 = mapper.createEntity(userId, skill1, 0.3f); + UserSkillLevelEntity entity2 = mapper.createEntity(userId, skill2, 0.6f); + UserSkillLevelEntity entity3 = mapper.createEntity(userId, skill3, 0.9f); + + // Assert + assertEquals(skill1, entity1.getSkillId()); + assertEquals(0.3f, entity1.getSkillLevelValue()); + + assertEquals(skill2, entity2.getSkillId()); + assertEquals(0.6f, entity2.getSkillLevelValue()); + + assertEquals(skill3, entity3.getSkillId()); + assertEquals(0.9f, entity3.getSkillLevelValue()); + } + + @Test + void testUpdateEntity_UpdatesSkillLevelValue() { + // Arrange + UserSkillLevelEntity existingEntity = UserSkillLevelEntity.builder() + .userId(userId) + .skillId(skillId) + .skillLevelValue(0.4f) + .build(); + + Float newSkillLevel = 0.8f; + + // Act + UserSkillLevelEntity updatedEntity = mapper.updateEntity(existingEntity, newSkillLevel); + + // Assert + assertSame(existingEntity, updatedEntity); + assertEquals(newSkillLevel, updatedEntity.getSkillLevelValue()); + } + + @Test + void testUpdateEntity_ReturnsSameInstance() { + // Arrange + UserSkillLevelEntity existingEntity = UserSkillLevelEntity.builder() + .userId(userId) + .skillId(skillId) + .skillLevelValue(0.5f) + .build(); + + // Act + UserSkillLevelEntity result = mapper.updateEntity(existingEntity, 0.7f); + + // Assert + assertSame(existingEntity, result, "updateEntity should return the same instance"); + } + + @Test + void testUpdateEntity_CanIncreaseSkillLevel() { + // Arrange + UserSkillLevelEntity existingEntity = UserSkillLevelEntity.builder() + .userId(userId) + .skillId(skillId) + .skillLevelValue(0.3f) + .build(); + + // Act + mapper.updateEntity(existingEntity, 0.7f); + + // Assert + assertEquals(0.7f, existingEntity.getSkillLevelValue()); + } + + @Test + void testUpdateEntity_CanDecreaseSkillLevel() { + // Arrange + UserSkillLevelEntity existingEntity = UserSkillLevelEntity.builder() + .userId(userId) + .skillId(skillId) + .skillLevelValue(0.9f) + .build(); + + // Act + mapper.updateEntity(existingEntity, 0.4f); + + // Assert + assertEquals(0.4f, existingEntity.getSkillLevelValue()); + } + + @Test + void testUpdateEntity_CanSetToZero() { + // Arrange + UserSkillLevelEntity existingEntity = UserSkillLevelEntity.builder() + .userId(userId) + .skillId(skillId) + .skillLevelValue(0.5f) + .build(); + + // Act + mapper.updateEntity(existingEntity, 0.0f); + + // Assert + assertEquals(0.0f, existingEntity.getSkillLevelValue()); + } + + @Test + void testUpdateEntity_CanSetToMaximum() { + // Arrange + UserSkillLevelEntity existingEntity = UserSkillLevelEntity.builder() + .userId(userId) + .skillId(skillId) + .skillLevelValue(0.5f) + .build(); + + // Act + mapper.updateEntity(existingEntity, 1.0f); + + // Assert + assertEquals(1.0f, existingEntity.getSkillLevelValue()); + } + + @Test + void testCreateEntity_PreservesUserId() { + // Arrange + UUID specificUserId = UUID.randomUUID(); + + // Act + UserSkillLevelEntity entity = mapper.createEntity(specificUserId, skillId, 0.5f); + + // Assert + assertEquals(specificUserId, entity.getUserId()); + } + + @Test + void testCreateEntity_PreservesSkillId() { + // Arrange + UUID specificSkillId = UUID.randomUUID(); + + // Act + UserSkillLevelEntity entity = mapper.createEntity(userId, specificSkillId, 0.5f); + + // Assert + assertEquals(specificSkillId, entity.getSkillId()); + } +} From a2f1ff47dd64ca600a6e7852011767f1812d26e7 Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Tue, 16 Dec 2025 09:56:40 +0100 Subject: [PATCH 16/24] added more testcases for ProactiveFeedbackService --- .../service/ProactiveFeedbackServiceTest.java | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/ProactiveFeedbackServiceTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/ProactiveFeedbackServiceTest.java index 92e928a..84b099a 100644 --- a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/ProactiveFeedbackServiceTest.java +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/ProactiveFeedbackServiceTest.java @@ -207,4 +207,159 @@ void testGenerateFeedback_withPlayerType() { assertEquals(expectedFeedback, result); verify(proactiveFeedbackRepository, times(1)).save(any(ProactiveFeedbackEntity.class)); } + + @Test + void testGenerateFeedback_withDisruptorFallbackToHighestType() { + ContentProgressedEvent event = ContentProgressedEvent.builder() + .userId(userId) + .contentId(assignmentId) + .contentType(ContentProgressedEvent.ContentType.ASSIGNMENT) + .correctness(0.95) + .success(true) + .build(); + + String expectedFeedback = "Great work!"; + + UserPlayerTypeEntity playerTypeEntity = UserPlayerTypeEntity.builder() + .userId(userId) + .primaryPlayerType(HexadPlayerType.DISRUPTOR) + .achieverScore(0.8) + .playerScore(0.6) + .socialiserScore(0.5) + .freeSpiritScore(0.3) + .philanthropistScore(0.7) + .disruptorScore(0.85) + .build(); + + when(ollamaService.getTemplate(anyString())).thenReturn("Mock template"); + when(ollamaService.startQuery(eq(TutorAnswer.class), anyString(), anyList(), any())) + .thenReturn(new TutorAnswer(expectedFeedback)); + when(userPlayerTypeService.getPrimaryPlayerType(userId)) + .thenReturn(Optional.of(HexadPlayerType.DISRUPTOR)); + when(userPlayerTypeService.getUserPlayerType(userId)) + .thenReturn(Optional.of(playerTypeEntity)); + when(studentCodeSubmissionService.getCodeSubmissionContextForTutor(userId, assignmentId)) + .thenReturn(Optional.empty()); + when(proactiveFeedbackRepository.save(any())).thenAnswer(invocation -> { + ProactiveFeedbackEntity entity = invocation.getArgument(0); + entity.setId(UUID.randomUUID()); + return entity; + }); + + String result = proactiveFeedbackService.generateFeedback(event); + + assertNotNull(result); + assertEquals(expectedFeedback, result); + verify(userPlayerTypeService, times(1)).getUserPlayerType(userId); + } + + @Test + void testGenerateFeedback_withException() { + ContentProgressedEvent event = ContentProgressedEvent.builder() + .userId(userId) + .contentId(assignmentId) + .contentType(ContentProgressedEvent.ContentType.ASSIGNMENT) + .correctness(0.85) + .success(true) + .build(); + + when(ollamaService.getTemplate(anyString())).thenThrow(new RuntimeException("Test exception")); + + String result = proactiveFeedbackService.generateFeedback(event); + + assertNull(result); + verify(proactiveFeedbackRepository, never()).save(any(ProactiveFeedbackEntity.class)); + } + + @Test + void testGetFeedbackForAssignment() { + ProactiveFeedbackEntity feedbackEntity = ProactiveFeedbackEntity.builder() + .id(UUID.randomUUID()) + .userId(userId) + .assessmentId(assignmentId) + .feedbackText("Test feedback") + .correctness(0.85) + .success(true) + .createdAt(OffsetDateTime.now()) + .build(); + + when(proactiveFeedbackRepository.findFirstByUserIdAndAssessmentIdOrderByCreatedAtDesc(userId, assignmentId)) + .thenReturn(Optional.of(feedbackEntity)); + + Optional result = proactiveFeedbackService.getFeedbackForAssignment(userId, assignmentId); + + assertTrue(result.isPresent()); + assertEquals(feedbackEntity, result.get()); + } + + @Test + void testGetAllFeedbackForUser() { + ProactiveFeedbackEntity feedback1 = ProactiveFeedbackEntity.builder() + .id(UUID.randomUUID()) + .userId(userId) + .assessmentId(assignmentId) + .feedbackText("Feedback 1") + .correctness(0.85) + .success(true) + .createdAt(OffsetDateTime.now()) + .build(); + + ProactiveFeedbackEntity feedback2 = ProactiveFeedbackEntity.builder() + .id(UUID.randomUUID()) + .userId(userId) + .assessmentId(UUID.randomUUID()) + .feedbackText("Feedback 2") + .correctness(0.75) + .success(true) + .createdAt(OffsetDateTime.now().minusHours(1)) + .build(); + + when(proactiveFeedbackRepository.findByUserIdOrderByCreatedAtDesc(userId)) + .thenReturn(List.of(feedback1, feedback2)); + + List result = proactiveFeedbackService.getAllFeedbackForUser(userId); + + assertEquals(2, result.size()); + assertEquals(feedback1, result.get(0)); + assertEquals(feedback2, result.get(1)); + } + + @Test + void testGetAndDeleteLatestFeedback() { + ProactiveFeedbackEntity feedbackEntity = ProactiveFeedbackEntity.builder() + .id(UUID.randomUUID()) + .userId(userId) + .assessmentId(assignmentId) + .feedbackText("Latest feedback") + .correctness(0.90) + .success(true) + .createdAt(OffsetDateTime.now()) + .build(); + + when(proactiveFeedbackRepository.findByUserIdOrderByCreatedAtDesc(userId)) + .thenReturn(List.of(feedbackEntity)); + + Optional result = proactiveFeedbackService.getAndDeleteLatestFeedback(userId); + + assertTrue(result.isPresent()); + assertEquals("Latest feedback", result.get()); + verify(proactiveFeedbackRepository, times(1)).delete(feedbackEntity); + } + + @Test + void testGetAndDeleteLatestFeedback_noFeedbackAvailable() { + when(proactiveFeedbackRepository.findByUserIdOrderByCreatedAtDesc(userId)) + .thenReturn(List.of()); + + Optional result = proactiveFeedbackService.getAndDeleteLatestFeedback(userId); + + assertFalse(result.isPresent()); + verify(proactiveFeedbackRepository, never()).delete(any()); + } + + @Test + void testProactiveFeedbackStream() { + var publisher = proactiveFeedbackService.proactiveFeedbackStream(userId); + assertNotNull(publisher); + } } From 0e6e76dfbba197a2b46d23f3369f1461fa1d46cc Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:05:39 +0100 Subject: [PATCH 17/24] updated tests for TutorService --- .../service/TutorServiceTest.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorServiceTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorServiceTest.java index 3ab3f04..ee3ffc3 100644 --- a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorServiceTest.java +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/TutorServiceTest.java @@ -13,6 +13,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.util.List; +import java.util.Optional; import java.util.UUID; import static de.unistuttgart.iste.meitrex.common.testutil.TestUsers.userWithMembershipInCourseWithId; @@ -171,4 +172,57 @@ void testAnswerLectureQuestion_withoutDocumentSegments() { LectureQuestionResponse response = tutorService.handleUserQuestion(question, courseId, loggedInUser); assertEquals(expectedAnswer, response.getAnswer()); } + + @Test + void testHandleUserQuestion_withCodeFeedbackCategoryNoCourseId() { + String question = "Can you review my code?"; + CategorizedQuestion categorizedQuestion = new CategorizedQuestion(question, TutorCategory.CODE_FEEDBACK); + when(ollamaService.startQuery(Mockito.eq(CategorizedQuestion.class), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(categorizedQuestion); + when(ollamaService.getTemplate(Mockito.any())).thenReturn("Mocked Prompt"); + + String expectedAnswer = "Something went wrong! If your question is about code for an assignment, " + + "please navigate to the course it relates to. Thank you! :)"; + + LectureQuestionResponse response = tutorService.handleUserQuestion(question, null, loggedInUser); + assertEquals(expectedAnswer, response.getAnswer()); + } + + @Test + void testHandleUserQuestion_withCodeFeedbackNoSubmissions() { + String question = "Can you review my code?"; + CategorizedQuestion categorizedQuestion = new CategorizedQuestion(question, TutorCategory.CODE_FEEDBACK); + + when(ollamaService.startQuery(Mockito.eq(CategorizedQuestion.class), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(categorizedQuestion); + when(ollamaService.getTemplate(Mockito.any())).thenReturn("Mocked Prompt"); + when(studentCodeSubmissionService.getCodeSubmissionsForStudent(Mockito.any())).thenReturn(List.of()); + + String expectedAnswer = "I couldn't find any code submission from you. " + + "Please make sure you've committed your code to your assignment repository. " + + "Open this respository and try again. Make sure to commit and push your code first!"; + + LectureQuestionResponse response = tutorService.handleUserQuestion(question, courseId, loggedInUser); + assertEquals(expectedAnswer, response.getAnswer()); + } + + @Test + void testHandleUserQuestion_withProactiveFeedbackKeyword() { + String feedback = "Great job on your assignment!"; + when(proactiveFeedbackService.getAndDeleteLatestFeedback(loggedInUser.getId())) + .thenReturn(Optional.of(feedback)); + + LectureQuestionResponse response = tutorService.handleUserQuestion("proactivefeedback", courseId, loggedInUser); + assertEquals(feedback, response.getAnswer()); + Mockito.verify(proactiveFeedbackService).getAndDeleteLatestFeedback(loggedInUser.getId()); + } + + @Test + void testHandleUserQuestion_withProactiveFeedbackKeywordNoFeedback() { + when(proactiveFeedbackService.getAndDeleteLatestFeedback(loggedInUser.getId())) + .thenReturn(Optional.empty()); + + LectureQuestionResponse response = tutorService.handleUserQuestion("proactivefeedback", courseId, loggedInUser); + assertEquals("No proactive feedback available at the moment.", response.getAnswer()); + } } From 9a01a6df847b1f049e5ca082b76e9001023290ba Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:13:24 +0100 Subject: [PATCH 18/24] added tests for TutorController --- .../controller/TutorControllerTest.java | 193 +++++++++++++++++- 1 file changed, 186 insertions(+), 7 deletions(-) diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/controller/TutorControllerTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/controller/TutorControllerTest.java index 80eaf19..181eb1b 100644 --- a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/controller/TutorControllerTest.java +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/controller/TutorControllerTest.java @@ -2,21 +2,26 @@ import de.unistuttgart.iste.meitrex.common.testutil.InjectCurrentUserHeader; import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; -import de.unistuttgart.iste.meitrex.generated.dto.LectureQuestionResponse; +import de.unistuttgart.iste.meitrex.generated.dto.*; +import de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.ProactiveFeedbackEntity; +import de.unistuttgart.iste.meitrex.tutor_service.service.HintService; +import de.unistuttgart.iste.meitrex.tutor_service.service.ProactiveFeedbackService; import de.unistuttgart.iste.meitrex.tutor_service.service.TutorService; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.junit.jupiter.api.Test; import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Flux; +import java.time.OffsetDateTime; import java.util.List; +import java.util.Optional; import java.util.UUID; import static de.unistuttgart.iste.meitrex.common.testutil.TestUsers.userWithMembershipInCourseWithId; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) public class TutorControllerTest { @@ -24,14 +29,20 @@ public class TutorControllerTest { @InjectMocks private TutorController tutorController; + @Mock + private TutorService tutorService; + + @Mock + private HintService hintService; + + @Mock + private ProactiveFeedbackService proactiveFeedbackService; + private final UUID courseId = UUID.randomUUID(); @InjectCurrentUserHeader private final LoggedInUser loggedInUser = userWithMembershipInCourseWithId(courseId, LoggedInUser.UserRoleInCourse.STUDENT); - @Mock - private TutorService tutorService; - @Test void testSendMessage_withEmptyInput() { LectureQuestionResponse answer = tutorController.sendMessage("", courseId, loggedInUser); @@ -51,4 +62,172 @@ void testSendMessage_withValidInput() { verify(tutorService).handleUserQuestion(question, courseId, loggedInUser); } + @Test + void testGenerateHint_withMultipleChoice() { + HintMultipleChoiceInput multipleChoiceInput = HintMultipleChoiceInput.builder() + .setText("What is Java?") + .setAnswers(List.of("A programming language", "A type of coffee", "An island")) + .build(); + + HintGenerationInput input = HintGenerationInput.builder() + .setType(HintQuestionType.MULTIPLE_CHOICE) + .setMultipleChoice(multipleChoiceInput) + .build(); + + HintResponse expectedResponse = new HintResponse("Java is a something a programmer uses..."); + when(hintService.generateHintWithQuestion(input, courseId, loggedInUser)) + .thenReturn(expectedResponse); + + HintResponse response = tutorController.generateHint(input, courseId, loggedInUser); + + assertEquals(expectedResponse, response); + verify(hintService).generateHintWithQuestion(input, courseId, loggedInUser); + } + + @Test + void testGenerateHint_withCloze() { + HintClozeInput clozeInput = HintClozeInput.builder() + .setText("Java is a ____ language") + .setBlanks(List.of("programming", "scripting", "markup")) + .build(); + + HintGenerationInput input = HintGenerationInput.builder() + .setType(HintQuestionType.CLOZE) + .setCloze(clozeInput) + .build(); + + HintResponse expectedResponse = new HintResponse("Think about what Java is commonly used for..."); + when(hintService.generateHintWithQuestion(input, courseId, loggedInUser)) + .thenReturn(expectedResponse); + + HintResponse response = tutorController.generateHint(input, courseId, loggedInUser); + + assertEquals(expectedResponse, response); + verify(hintService).generateHintWithQuestion(input, courseId, loggedInUser); + } + + @Test + void testGenerateHint_withAssociation() { + HintAssociationInput associationInput = HintAssociationInput.builder() + .setText("Match the concepts") + .setPairs(List.of( + new AssociationPairInput("JVM", "Java Virtual Machine"), + new AssociationPairInput("JDK", "Java Development Kit") + )) + .build(); + + HintGenerationInput input = HintGenerationInput.builder() + .setType(HintQuestionType.ASSOCIATION) + .setAssociation(associationInput) + .build(); + + HintResponse expectedResponse = new HintResponse("Consider what each acronym stands for..."); + when(hintService.generateHintWithQuestion(input, courseId, loggedInUser)) + .thenReturn(expectedResponse); + + HintResponse response = tutorController.generateHint(input, courseId, loggedInUser); + + assertEquals(expectedResponse, response); + verify(hintService).generateHintWithQuestion(input, courseId, loggedInUser); + } + + @Test + void testProactiveFeedback_withExistingFeedback() { + UUID assessmentId = UUID.randomUUID(); + ProactiveFeedbackEntity entity = ProactiveFeedbackEntity.builder() + .id(UUID.randomUUID()) + .userId(loggedInUser.getId()) + .assessmentId(assessmentId) + .feedbackText("Great work!") + .correctness(0.85) + .success(true) + .createdAt(OffsetDateTime.now()) + .build(); + + when(proactiveFeedbackService.getFeedbackForAssignment(loggedInUser.getId(), assessmentId)) + .thenReturn(Optional.of(entity)); + + ProactiveFeedback result = tutorController.proactiveFeedback(assessmentId, loggedInUser); + + assertNotNull(result); + assertEquals(entity.getId(), result.getId()); + assertEquals(entity.getFeedbackText(), result.getFeedbackText()); + assertEquals(entity.getCorrectness(), result.getCorrectness()); + } + + @Test + void testProactiveFeedback_withNoFeedback() { + UUID assessmentId = UUID.randomUUID(); + when(proactiveFeedbackService.getFeedbackForAssignment(loggedInUser.getId(), assessmentId)) + .thenReturn(Optional.empty()); + + ProactiveFeedback result = tutorController.proactiveFeedback(assessmentId, loggedInUser); + + assertNull(result); + } + + @Test + void testAllProactiveFeedback() { + ProactiveFeedbackEntity entity1 = ProactiveFeedbackEntity.builder() + .id(UUID.randomUUID()) + .userId(loggedInUser.getId()) + .assessmentId(UUID.randomUUID()) + .feedbackText("Feedback 1") + .correctness(0.85) + .success(true) + .createdAt(OffsetDateTime.now()) + .build(); + + ProactiveFeedbackEntity entity2 = ProactiveFeedbackEntity.builder() + .id(UUID.randomUUID()) + .userId(loggedInUser.getId()) + .assessmentId(UUID.randomUUID()) + .feedbackText("Feedback 2") + .correctness(0.75) + .success(true) + .createdAt(OffsetDateTime.now().minusHours(1)) + .build(); + + when(proactiveFeedbackService.getAllFeedbackForUser(loggedInUser.getId())) + .thenReturn(List.of(entity1, entity2)); + + List result = tutorController.allProactiveFeedback(loggedInUser); + + assertEquals(2, result.size()); + assertEquals(entity1.getFeedbackText(), result.get(0).getFeedbackText()); + assertEquals(entity2.getFeedbackText(), result.get(1).getFeedbackText()); + } + + @Test + void testAllProactiveFeedback_empty() { + when(proactiveFeedbackService.getAllFeedbackForUser(loggedInUser.getId())) + .thenReturn(List.of()); + + List result = tutorController.allProactiveFeedback(loggedInUser); + + assertTrue(result.isEmpty()); + } + + @Test + void testProactiveFeedbackAdded_subscription() { + UUID userId = UUID.randomUUID(); + Flux mockFlux = Flux.empty(); + + when(proactiveFeedbackService.proactiveFeedbackStream(userId)) + .thenReturn(mockFlux); + + var publisher = tutorController.proactiveFeedbackAdded(userId); + + assertNotNull(publisher); + verify(proactiveFeedbackService).proactiveFeedbackStream(userId); + } + + @Test + void testEmptyQuery_shouldThrowException() { + assertThrows(UnsupportedOperationException.class, () -> { + tutorController._empty(); + }); + } + } + From 8a981bb4d8fd23ad9f04a1bddade2887d14c7a69 Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:16:30 +0100 Subject: [PATCH 19/24] updated test for UserSkillLevelMapper --- .../mapper/UserSkillLevelMapperTest.java | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserSkillLevelMapperTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserSkillLevelMapperTest.java index cbf28e7..a5dde25 100644 --- a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserSkillLevelMapperTest.java +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/persistence/mapper/UserSkillLevelMapperTest.java @@ -35,10 +35,8 @@ void setUp() { @Test void testCreateEntity_CreatesNewEntityWithAllFields() { - // Act UserSkillLevelEntity entity = mapper.createEntity(userId, skillId, skillLevelValue); - // Assert assertNotNull(entity); assertEquals(userId, entity.getUserId()); assertEquals(skillId, entity.getSkillId()); @@ -47,46 +45,37 @@ void testCreateEntity_CreatesNewEntityWithAllFields() { @Test void testCreateEntity_WithDifferentSkillLevels() { - // Test with beginner level UserSkillLevelEntity beginnerEntity = mapper.createEntity(userId, skillId, 0.1f); assertEquals(0.1f, beginnerEntity.getSkillLevelValue()); - // Test with intermediate level UserSkillLevelEntity intermediateEntity = mapper.createEntity(userId, skillId, 0.5f); assertEquals(0.5f, intermediateEntity.getSkillLevelValue()); - // Test with expert level UserSkillLevelEntity expertEntity = mapper.createEntity(userId, skillId, 0.9f); assertEquals(0.9f, expertEntity.getSkillLevelValue()); - // Test with maximum level UserSkillLevelEntity maxEntity = mapper.createEntity(userId, skillId, 1.0f); assertEquals(1.0f, maxEntity.getSkillLevelValue()); } @Test void testCreateEntity_WithZeroSkillLevel() { - // Act UserSkillLevelEntity entity = mapper.createEntity(userId, skillId, 0.0f); - // Assert assertNotNull(entity); assertEquals(0.0f, entity.getSkillLevelValue()); } @Test void testCreateEntity_WithMultipleSkills() { - // Arrange UUID skill1 = UUID.randomUUID(); UUID skill2 = UUID.randomUUID(); UUID skill3 = UUID.randomUUID(); - // Act UserSkillLevelEntity entity1 = mapper.createEntity(userId, skill1, 0.3f); UserSkillLevelEntity entity2 = mapper.createEntity(userId, skill2, 0.6f); UserSkillLevelEntity entity3 = mapper.createEntity(userId, skill3, 0.9f); - // Assert assertEquals(skill1, entity1.getSkillId()); assertEquals(0.3f, entity1.getSkillLevelValue()); @@ -99,7 +88,6 @@ void testCreateEntity_WithMultipleSkills() { @Test void testUpdateEntity_UpdatesSkillLevelValue() { - // Arrange UserSkillLevelEntity existingEntity = UserSkillLevelEntity.builder() .userId(userId) .skillId(skillId) @@ -108,115 +96,92 @@ void testUpdateEntity_UpdatesSkillLevelValue() { Float newSkillLevel = 0.8f; - // Act UserSkillLevelEntity updatedEntity = mapper.updateEntity(existingEntity, newSkillLevel); - // Assert assertSame(existingEntity, updatedEntity); assertEquals(newSkillLevel, updatedEntity.getSkillLevelValue()); } @Test void testUpdateEntity_ReturnsSameInstance() { - // Arrange UserSkillLevelEntity existingEntity = UserSkillLevelEntity.builder() .userId(userId) .skillId(skillId) .skillLevelValue(0.5f) .build(); - // Act UserSkillLevelEntity result = mapper.updateEntity(existingEntity, 0.7f); - // Assert assertSame(existingEntity, result, "updateEntity should return the same instance"); } @Test void testUpdateEntity_CanIncreaseSkillLevel() { - // Arrange UserSkillLevelEntity existingEntity = UserSkillLevelEntity.builder() .userId(userId) .skillId(skillId) .skillLevelValue(0.3f) .build(); - // Act mapper.updateEntity(existingEntity, 0.7f); - // Assert assertEquals(0.7f, existingEntity.getSkillLevelValue()); } @Test void testUpdateEntity_CanDecreaseSkillLevel() { - // Arrange UserSkillLevelEntity existingEntity = UserSkillLevelEntity.builder() .userId(userId) .skillId(skillId) .skillLevelValue(0.9f) .build(); - // Act mapper.updateEntity(existingEntity, 0.4f); - // Assert assertEquals(0.4f, existingEntity.getSkillLevelValue()); } @Test void testUpdateEntity_CanSetToZero() { - // Arrange UserSkillLevelEntity existingEntity = UserSkillLevelEntity.builder() .userId(userId) .skillId(skillId) .skillLevelValue(0.5f) .build(); - // Act mapper.updateEntity(existingEntity, 0.0f); - // Assert assertEquals(0.0f, existingEntity.getSkillLevelValue()); } @Test void testUpdateEntity_CanSetToMaximum() { - // Arrange UserSkillLevelEntity existingEntity = UserSkillLevelEntity.builder() .userId(userId) .skillId(skillId) .skillLevelValue(0.5f) .build(); - // Act mapper.updateEntity(existingEntity, 1.0f); - // Assert assertEquals(1.0f, existingEntity.getSkillLevelValue()); } @Test void testCreateEntity_PreservesUserId() { - // Arrange UUID specificUserId = UUID.randomUUID(); - // Act UserSkillLevelEntity entity = mapper.createEntity(specificUserId, skillId, 0.5f); - // Assert assertEquals(specificUserId, entity.getUserId()); } @Test void testCreateEntity_PreservesSkillId() { - // Arrange UUID specificSkillId = UUID.randomUUID(); - // Act UserSkillLevelEntity entity = mapper.createEntity(userId, specificSkillId, 0.5f); - // Assert assertEquals(specificSkillId, entity.getSkillId()); } } From 77e17576473f758463df5203722a2fe4d2ac3739 Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:23:42 +0100 Subject: [PATCH 20/24] added tests for HintService --- .../service/HintServiceTest.java | 502 ++++++++++++++++++ 1 file changed, 502 insertions(+) create mode 100644 src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/HintServiceTest.java diff --git a/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/HintServiceTest.java b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/HintServiceTest.java new file mode 100644 index 0000000..286c50d --- /dev/null +++ b/src/test/java/de/unistuttgart/iste/meitrex/tutor_service/service/HintServiceTest.java @@ -0,0 +1,502 @@ +package de.unistuttgart.iste.meitrex.tutor_service.service; + +import de.unistuttgart.iste.meitrex.common.event.HexadPlayerType; +import de.unistuttgart.iste.meitrex.common.testutil.InjectCurrentUserHeader; +import de.unistuttgart.iste.meitrex.common.user_handling.LoggedInUser; +import de.unistuttgart.iste.meitrex.generated.dto.*; +import de.unistuttgart.iste.meitrex.tutor_service.service.models.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static de.unistuttgart.iste.meitrex.common.testutil.TestUsers.userWithMembershipInCourseWithId; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Unit tests for HintService. + */ +@ExtendWith(MockitoExtension.class) +class HintServiceTest { + + @Mock + private OllamaService ollamaService; + + @Mock + private SemanticSearchService semanticSearchService; + + @Mock + private UserPlayerTypeService userPlayerTypeService; + + @InjectMocks + private HintService hintService; + + private UUID courseId; + + @InjectCurrentUserHeader + private LoggedInUser currentUser; + + @BeforeEach + void setUp() { + courseId = UUID.randomUUID(); + currentUser = userWithMembershipInCourseWithId(courseId, LoggedInUser.UserRoleInCourse.STUDENT); + ReflectionTestUtils.setField(hintService, "scoreThreshold", 0.4); + } + + @Test + void testGenerateHintWithQuestion_multipleChoice_success() { + HintMultipleChoiceInput multipleChoiceInput = HintMultipleChoiceInput.builder() + .setText("What is Java?") + .setAnswers(List.of("A programming language", "A type of coffee", "An island")) + .build(); + + HintGenerationInput input = HintGenerationInput.builder() + .setType(HintQuestionType.MULTIPLE_CHOICE) + .setMultipleChoice(multipleChoiceInput) + .build(); + + List searchResults = List.of( + SemanticSearchResult.builder() + .score(0.25) + .typename("DocumentRecordSegment") + .mediaRecordSegment(DocumentRecordSegment.builder() + .text("Java is a high-level programming language.") + .page(1) + + .build()) + .build() + ); + + String expectedHint = "Think about what Java is commonly used for in software development."; + HintResponse expectedResponse = new HintResponse(expectedHint); + + when(ollamaService.getTemplate(contains("question_prompt_MULTIPLE_CHOICE"))) + .thenReturn("Question: {questionText}\nOptions: {options}"); + when(ollamaService.fillTemplate(anyString(), anyList())) + .thenReturn("Filled question prompt"); + when(semanticSearchService.semanticSearch(anyString(), eq(courseId), eq(currentUser))) + .thenReturn(searchResults); + when(semanticSearchService.formatIntoNumberedListForPrompt(anyList())) + .thenReturn("1. Java is a high-level programming language."); + when(ollamaService.getTemplate(contains("generate_hint"))) + .thenReturn("Generate hint template"); + when(userPlayerTypeService.getPrimaryPlayerType(currentUser.getId())) + .thenReturn(Optional.empty()); + when(ollamaService.startQuery(eq(HintResponse.class), anyString(), anyList(), any())) + .thenReturn(expectedResponse); + + HintResponse result = hintService.generateHintWithQuestion(input, courseId, currentUser); + + assertNotNull(result); + assertEquals(expectedHint, result.getHint()); + verify(semanticSearchService).semanticSearch(eq("What is Java?"), eq(courseId), eq(currentUser)); + verify(ollamaService).startQuery(eq(HintResponse.class), anyString(), anyList(), any()); + } + + @Test + void testGenerateHintWithQuestion_cloze_success() { + HintClozeInput clozeInput = HintClozeInput.builder() + .setText("Java is a ____ programming language") + .setBlanks(List.of("object-oriented", "functional", "procedural")) + .build(); + + HintGenerationInput input = HintGenerationInput.builder() + .setType(HintQuestionType.CLOZE) + .setCloze(clozeInput) + .build(); + + List searchResults = List.of( + SemanticSearchResult.builder() + .score(0.20) + .typename("DocumentRecordSegment") + .mediaRecordSegment(DocumentRecordSegment.builder() + .text("Java supports object-oriented programming principles.") + .page(2) + + .build()) + .build() + ); + + String semanticSearchQuery = "Java programming language paradigm"; + when(ollamaService.getTemplate(contains("question_prompt_CLOZE"))) + .thenReturn("Question: {questionText}\nOptions: {options}"); + when(ollamaService.fillTemplate(anyString(), anyList())) + .thenReturn("Filled cloze prompt"); + when(ollamaService.getTemplate(contains("generate_semantic_search_query_cloze"))) + .thenReturn("Generate search query for cloze"); + when(ollamaService.startQuery(eq(SemanticSearchQuery.class), anyString(), anyList(), any())) + .thenReturn(new SemanticSearchQuery(semanticSearchQuery)); + when(semanticSearchService.semanticSearch(eq(semanticSearchQuery), eq(courseId), eq(currentUser))) + .thenReturn(searchResults); + when(semanticSearchService.formatIntoNumberedListForPrompt(anyList())) + .thenReturn("1. Java supports object-oriented programming principles."); + when(ollamaService.getTemplate(contains("generate_hint"))) + .thenReturn("Generate hint template"); + when(userPlayerTypeService.getPrimaryPlayerType(currentUser.getId())) + .thenReturn(Optional.empty()); + when(ollamaService.startQuery(eq(HintResponse.class), anyString(), anyList(), any())) + .thenReturn(new HintResponse("Consider the programming paradigms Java supports.")); + + HintResponse result = hintService.generateHintWithQuestion(input, courseId, currentUser); + + assertNotNull(result); + assertEquals("Consider the programming paradigms Java supports.", result.getHint()); + verify(semanticSearchService).semanticSearch(eq(semanticSearchQuery), eq(courseId), eq(currentUser)); + } + + @Test + void testGenerateHintWithQuestion_association_success() { + HintAssociationInput associationInput = HintAssociationInput.builder() + .setText("Match the Java concepts") + .setPairs(List.of( + new AssociationPairInput("JVM", "Java Virtual Machine"), + new AssociationPairInput("JDK", "Java Development Kit") + )) + .build(); + + HintGenerationInput input = HintGenerationInput.builder() + .setType(HintQuestionType.ASSOCIATION) + .setAssociation(associationInput) + .build(); + + List searchResults = List.of( + SemanticSearchResult.builder() + .score(0.30) + .typename("DocumentRecordSegment") + .mediaRecordSegment(DocumentRecordSegment.builder() + .text("JVM is the Java Virtual Machine that executes Java bytecode.") + .page(3) + + .build()) + .build() + ); + + String semanticSearchQuery = "JVM JDK Java tools components"; + when(ollamaService.getTemplate(contains("question_prompt_ASSOCIATION"))) + .thenReturn("Question: {questionText}\nPairs: {options}"); + when(ollamaService.fillTemplate(anyString(), anyList())) + .thenReturn("Filled association prompt"); + when(ollamaService.getTemplate(contains("generate_semantic_search_query_association"))) + .thenReturn("Generate search query for association"); + when(semanticSearchService.formatIntoNumberedListForPrompt(anyList())) + .thenReturn("1. Left: JVM <-> Right: Java Virtual Machine\n2. Left: JDK <-> Right: Java Development Kit"); + when(ollamaService.startQuery(eq(SemanticSearchQuery.class), anyString(), anyList(), any())) + .thenReturn(new SemanticSearchQuery(semanticSearchQuery)); + when(semanticSearchService.semanticSearch(eq(semanticSearchQuery), eq(courseId), eq(currentUser))) + .thenReturn(searchResults); + when(ollamaService.getTemplate(contains("generate_hint"))) + .thenReturn("Generate hint template"); + when(userPlayerTypeService.getPrimaryPlayerType(currentUser.getId())) + .thenReturn(Optional.empty()); + when(ollamaService.startQuery(eq(HintResponse.class), anyString(), anyList(), any())) + .thenReturn(new HintResponse("Think about what each acronym stands for.")); + + HintResponse result = hintService.generateHintWithQuestion(input, courseId, currentUser); + + assertNotNull(result); + assertEquals("Think about what each acronym stands for.", result.getHint()); + verify(semanticSearchService).semanticSearch(eq(semanticSearchQuery), eq(courseId), eq(currentUser)); + } + + @Test + void testGenerateHintWithQuestion_noSearchResults() { + HintMultipleChoiceInput multipleChoiceInput = HintMultipleChoiceInput.builder() + .setText("What is Quantum Computing?") + .setAnswers(List.of("A type of computer", "A theory", "A field")) + .build(); + + HintGenerationInput input = HintGenerationInput.builder() + .setType(HintQuestionType.MULTIPLE_CHOICE) + .setMultipleChoice(multipleChoiceInput) + .build(); + + when(ollamaService.getTemplate(contains("question_prompt_MULTIPLE_CHOICE"))) + .thenReturn("Question: {questionText}"); + when(ollamaService.fillTemplate(anyString(), anyList())) + .thenReturn("Filled question prompt"); + when(semanticSearchService.semanticSearch(anyString(), eq(courseId), eq(currentUser))) + .thenReturn(List.of()); + + HintResponse result = hintService.generateHintWithQuestion(input, courseId, currentUser); + + assertNotNull(result); + assertEquals("No relevant content found in the lecture for this question", result.getHint()); + verify(ollamaService, never()).startQuery(eq(HintResponse.class), anyString(), anyList(), any()); + } + + @Test + void testGenerateHintWithQuestion_noDocumentSegments() { + HintMultipleChoiceInput multipleChoiceInput = HintMultipleChoiceInput.builder() + .setText("What is Python?") + .setAnswers(List.of("A language", "A snake", "Both")) + .build(); + + HintGenerationInput input = HintGenerationInput.builder() + .setType(HintQuestionType.MULTIPLE_CHOICE) + .setMultipleChoice(multipleChoiceInput) + .build(); + + // Search results with high scores (above threshold) + List searchResults = List.of( + SemanticSearchResult.builder() + .score(0.85) // Above threshold + .typename("VideoRecordSegment") + .mediaRecordSegment(VideoRecordSegment.builder() + .startTime(100) + + .build()) + .build() + ); + + when(ollamaService.getTemplate(contains("question_prompt_MULTIPLE_CHOICE"))) + .thenReturn("Question: {questionText}"); + when(ollamaService.fillTemplate(anyString(), anyList())) + .thenReturn("Filled question prompt"); + when(semanticSearchService.semanticSearch(anyString(), eq(courseId), eq(currentUser))) + .thenReturn(searchResults); + + HintResponse result = hintService.generateHintWithQuestion(input, courseId, currentUser); + + assertNotNull(result); + assertEquals("No relevant content found in the documents of this lecture for this question", result.getHint()); + } + + @Test + void testGenerateHintWithQuestion_withAchieverPlayerType() { + HintMultipleChoiceInput multipleChoiceInput = HintMultipleChoiceInput.builder() + .setText("What is polymorphism?") + .setAnswers(List.of("Multiple forms", "Single form", "No form")) + .build(); + + HintGenerationInput input = HintGenerationInput.builder() + .setType(HintQuestionType.MULTIPLE_CHOICE) + .setMultipleChoice(multipleChoiceInput) + .build(); + + List searchResults = List.of( + SemanticSearchResult.builder() + .score(0.15) + .typename("DocumentRecordSegment") + .mediaRecordSegment(DocumentRecordSegment.builder() + .text("Polymorphism allows objects to take multiple forms.") + .page(5) + + .build()) + .build() + ); + + when(ollamaService.getTemplate(contains("question_prompt_MULTIPLE_CHOICE"))) + .thenReturn("Question: {questionText}"); + when(ollamaService.fillTemplate(anyString(), anyList())) + .thenReturn("Filled question prompt"); + when(semanticSearchService.semanticSearch(anyString(), eq(courseId), eq(currentUser))) + .thenReturn(searchResults); + when(semanticSearchService.formatIntoNumberedListForPrompt(anyList())) + .thenReturn("1. Polymorphism allows objects to take multiple forms."); + when(ollamaService.getTemplate(contains("generate_hint"))) + .thenReturn("Generate hint template"); + when(userPlayerTypeService.getPrimaryPlayerType(currentUser.getId())) + .thenReturn(Optional.of(HexadPlayerType.ACHIEVER)); + when(ollamaService.startQuery(eq(HintResponse.class), anyString(), anyList(), any())) + .thenReturn(new HintResponse("Focus on mastering the concept of polymorphism.")); + + HintResponse result = hintService.generateHintWithQuestion(input, courseId, currentUser); + + assertNotNull(result); + assertEquals("Focus on mastering the concept of polymorphism.", result.getHint()); + verify(userPlayerTypeService).getPrimaryPlayerType(currentUser.getId()); + } + + @Test + void testGenerateHintWithQuestion_withPhilanthropistPlayerType() { + HintMultipleChoiceInput multipleChoiceInput = HintMultipleChoiceInput.builder() + .setText("What is inheritance?") + .setAnswers(List.of("Code reuse", "Code duplication", "Code removal")) + .build(); + + HintGenerationInput input = HintGenerationInput.builder() + .setType(HintQuestionType.MULTIPLE_CHOICE) + .setMultipleChoice(multipleChoiceInput) + .build(); + + List searchResults = List.of( + SemanticSearchResult.builder() + .score(0.18) + .typename("DocumentRecordSegment") + .mediaRecordSegment(DocumentRecordSegment.builder() + .text("Inheritance allows classes to inherit properties from parent classes.") + .page(6) + + .build()) + .build() + ); + + when(ollamaService.getTemplate(contains("question_prompt_MULTIPLE_CHOICE"))) + .thenReturn("Question: {questionText}"); + when(ollamaService.fillTemplate(anyString(), anyList())) + .thenReturn("Filled question prompt"); + when(semanticSearchService.semanticSearch(anyString(), eq(courseId), eq(currentUser))) + .thenReturn(searchResults); + when(semanticSearchService.formatIntoNumberedListForPrompt(anyList())) + .thenReturn("1. Inheritance allows classes to inherit properties from parent classes."); + when(ollamaService.getTemplate(contains("generate_hint"))) + .thenReturn("Generate hint template"); + when(userPlayerTypeService.getPrimaryPlayerType(currentUser.getId())) + .thenReturn(Optional.of(HexadPlayerType.PHILANTHROPIST)); + when(ollamaService.startQuery(eq(HintResponse.class), anyString(), anyList(), any())) + .thenReturn(new HintResponse("Consider how inheritance helps share knowledge between classes.")); + + HintResponse result = hintService.generateHintWithQuestion(input, courseId, currentUser); + + assertNotNull(result); + verify(userPlayerTypeService).getPrimaryPlayerType(currentUser.getId()); + } + + @Test + void testGenerateHintWithQuestion_withPlayerPlayerType() { + HintMultipleChoiceInput multipleChoiceInput = HintMultipleChoiceInput.builder() + .setText("What is encapsulation?") + .setAnswers(List.of("Data hiding", "Data exposure", "Data deletion")) + .build(); + + HintGenerationInput input = HintGenerationInput.builder() + .setType(HintQuestionType.MULTIPLE_CHOICE) + .setMultipleChoice(multipleChoiceInput) + .build(); + + List searchResults = List.of( + SemanticSearchResult.builder() + .score(0.22) + .typename("DocumentRecordSegment") + .mediaRecordSegment(DocumentRecordSegment.builder() + .text("Encapsulation is bundling data and methods together.") + .page(7) + + .build()) + .build() + ); + + when(ollamaService.getTemplate(contains("question_prompt_MULTIPLE_CHOICE"))) + .thenReturn("Question: {questionText}"); + when(ollamaService.fillTemplate(anyString(), anyList())) + .thenReturn("Filled question prompt"); + when(semanticSearchService.semanticSearch(anyString(), eq(courseId), eq(currentUser))) + .thenReturn(searchResults); + when(semanticSearchService.formatIntoNumberedListForPrompt(anyList())) + .thenReturn("1. Encapsulation is bundling data and methods together."); + when(ollamaService.getTemplate(contains("generate_hint"))) + .thenReturn("Generate hint template"); + when(userPlayerTypeService.getPrimaryPlayerType(currentUser.getId())) + .thenReturn(Optional.of(HexadPlayerType.PLAYER)); + when(ollamaService.startQuery(eq(HintResponse.class), anyString(), anyList(), any())) + .thenReturn(new HintResponse("Be careful not to reveal the answer directly!")); + + HintResponse result = hintService.generateHintWithQuestion(input, courseId, currentUser); + + assertNotNull(result); + verify(userPlayerTypeService).getPrimaryPlayerType(currentUser.getId()); + } + + @Test + void testGenerateHintWithQuestion_playerTypeFetchException() { + HintMultipleChoiceInput multipleChoiceInput = HintMultipleChoiceInput.builder() + .setText("What is abstraction?") + .setAnswers(List.of("Hiding complexity", "Adding complexity", "Removing code")) + .build(); + + HintGenerationInput input = HintGenerationInput.builder() + .setType(HintQuestionType.MULTIPLE_CHOICE) + .setMultipleChoice(multipleChoiceInput) + .build(); + + List searchResults = List.of( + SemanticSearchResult.builder() + .score(0.19) + .typename("DocumentRecordSegment") + .mediaRecordSegment(DocumentRecordSegment.builder() + .text("Abstraction hides implementation details.") + .page(8) + + .build()) + .build() + ); + + when(ollamaService.getTemplate(contains("question_prompt_MULTIPLE_CHOICE"))) + .thenReturn("Question: {questionText}"); + when(ollamaService.fillTemplate(anyString(), anyList())) + .thenReturn("Filled question prompt"); + when(semanticSearchService.semanticSearch(anyString(), eq(courseId), eq(currentUser))) + .thenReturn(searchResults); + when(semanticSearchService.formatIntoNumberedListForPrompt(anyList())) + .thenReturn("1. Abstraction hides implementation details."); + when(ollamaService.getTemplate(contains("generate_hint"))) + .thenReturn("Generate hint template"); + when(userPlayerTypeService.getPrimaryPlayerType(currentUser.getId())) + .thenThrow(new RuntimeException("Player type fetch error")); + when(ollamaService.startQuery(eq(HintResponse.class), anyString(), anyList(), any())) + .thenReturn(new HintResponse("Think about how abstraction simplifies complex systems.")); + + HintResponse result = hintService.generateHintWithQuestion(input, courseId, currentUser); + + assertNotNull(result); + assertEquals("Think about how abstraction simplifies complex systems.", result.getHint()); + } + + @Test + void testBuildMultipleChoiceData_invalidInput_nullInput() { + HintGenerationInput input = HintGenerationInput.builder() + .setType(HintQuestionType.MULTIPLE_CHOICE) + .setMultipleChoice(null) + .build(); + + assertThrows(IllegalArgumentException.class, () -> { + hintService.generateHintWithQuestion(input, courseId, currentUser); + }); + } + + @Test + void testBuildClozeData_invalidInput_emptyBlanks() { + HintClozeInput clozeInput = HintClozeInput.builder() + .setText("Java is a ____ language") + .setBlanks(List.of()) + .build(); + + HintGenerationInput input = HintGenerationInput.builder() + .setType(HintQuestionType.CLOZE) + .setCloze(clozeInput) + .build(); + + assertThrows(IllegalArgumentException.class, () -> { + hintService.generateHintWithQuestion(input, courseId, currentUser); + }); + } + + @Test + void testBuildAssociationData_invalidInput_emptyPairs() { + HintAssociationInput associationInput = HintAssociationInput.builder() + .setText("Match the concepts") + .setPairs(List.of()) + .build(); + + HintGenerationInput input = HintGenerationInput.builder() + .setType(HintQuestionType.ASSOCIATION) + .setAssociation(associationInput) + .build(); + + assertThrows(IllegalArgumentException.class, () -> { + hintService.generateHintWithQuestion(input, courseId, currentUser); + }); + } +} + + From 0ff0aa23cf1f7e571769223ab1cc6b1fc949d589 Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:40:10 +0100 Subject: [PATCH 21/24] added missing @Transactional annotation --- .../tutor_service/service/ConversationHistoryService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/ConversationHistoryService.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/ConversationHistoryService.java index 77cd8da..d24f9d1 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/ConversationHistoryService.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/service/ConversationHistoryService.java @@ -100,6 +100,7 @@ public void addConversationExchange(UUID userId, UUID courseId, String userMessa * @param courseId the course ID * @return formatted conversation history */ + @Transactional(readOnly = true) public String formatHistoryForPrompt(UUID userId, UUID courseId) { List history = getRecentHistory(userId, courseId); From 400abedb70eab4814901f885c4e399f37bc3860e Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:52:12 +0100 Subject: [PATCH 22/24] fixed sonar qube issue with unboxing --- .../controller/SubscriptionController.java | 59 +++++++++++-------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java index de79d6e..ae08fb0 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java @@ -106,25 +106,26 @@ public Mono onUserSkillLevelChangedEvent(@RequestBody CloudEvent onContentProgressedEvent(@RequestBody CloudEvent cloudEvent, @RequestHeader Map headers) { return Mono.fromRunnable(() -> { - try { - ContentProgressedEvent event = cloudEvent.getData(); - - log.info("Received ContentProgressedEvent for user: {}, content: {}, type: {}", - event.getUserId(), - event.getContentId(), - event.getContentType()); - - if (event.getContentType() == ContentProgressedEvent.ContentType.ASSIGNMENT || - event.getContentType() == ContentProgressedEvent.ContentType.QUIZ) { - try { - proactiveFeedbackService.generateFeedback(event); - } catch (Exception e) { - log.error("Failed to generate feedback for user {} on content {}: {}", - event.getUserId(), event.getContentId(), e.getMessage(), e); - } + ContentProgressedEvent event = cloudEvent.getData(); + + if (event == null) { + log.warn("Received ContentProgressedEvent with null data"); + return; + } + + log.info("Received ContentProgressedEvent for user: {}, content: {}, type: {}", + event.getUserId(), + event.getContentId(), + event.getContentType()); + + if (event.getContentType() == ContentProgressedEvent.ContentType.ASSIGNMENT || + event.getContentType() == ContentProgressedEvent.ContentType.QUIZ) { + try { + proactiveFeedbackService.generateFeedback(event); + } catch (Exception e) { + log.error("Failed to generate feedback for user {} on content {}: {}", + event.getUserId(), event.getContentId(), e.getMessage(), e); } - } catch (Exception e) { - log.error("Error processing ContentProgressedEvent: {}", e.getMessage(), e); } }); } @@ -143,14 +144,19 @@ public Mono onContentProgressedEvent(@RequestBody CloudEvent onStudentCodeSubmittedEvent(@RequestBody CloudEvent cloudEvent, @RequestHeader Map headers) { return Mono.fromRunnable(() -> { + StudentCodeSubmittedEvent event = cloudEvent.getData(); + + if (event == null) { + log.warn("Received StudentCodeSubmittedEvent with null data"); + return; + } + + log.info("Received StudentCodeSubmittedEvent for student: {}, assignment: {}, commit: {}", + event.getStudentId(), + event.getAssignmentId(), + event.getCommitSha()); + try { - StudentCodeSubmittedEvent event = cloudEvent.getData(); - - log.info("Received StudentCodeSubmittedEvent for student: {}, assignment: {}, commit: {}", - event.getStudentId(), - event.getAssignmentId(), - event.getCommitSha()); - studentCodeSubmissionService.saveCodeSubmission( event.getStudentId(), event.getAssignmentId(), @@ -162,7 +168,8 @@ public Mono onStudentCodeSubmittedEvent(@RequestBody CloudEvent Date: Tue, 16 Dec 2025 11:02:14 +0100 Subject: [PATCH 23/24] fixed sonar qube issue --- .../tutor_service/controller/SubscriptionController.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java index ae08fb0..68b9c65 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java @@ -56,9 +56,11 @@ public Mono onUserHexadPlayerTypeSetEvent(@RequestBody CloudEvent playerTypePercentages = new HashMap<>(); - event.getPlayerTypePercentages().forEach((type, percentage) -> - playerTypePercentages.put(type, percentage.doubleValue()) - ); + event.getPlayerTypePercentages().forEach((type, percentage) -> { + if (percentage != null) { + playerTypePercentages.put(type, percentage.doubleValue()); + } + }); // Save the player type information userPlayerTypeService.saveUserPlayerType( From 6a245ce824bfc27d7acf75efe9822fd1f8a15d60 Mon Sep 17 00:00:00 2001 From: mp-dg <57503145+MP-DG@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:10:35 +0100 Subject: [PATCH 24/24] changed percentage.doubleValue to double (percentage) --- .../tutor_service/controller/SubscriptionController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java index 68b9c65..117953a 100644 --- a/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java +++ b/src/main/java/de/unistuttgart/iste/meitrex/tutor_service/controller/SubscriptionController.java @@ -58,7 +58,7 @@ public Mono onUserHexadPlayerTypeSetEvent(@RequestBody CloudEvent playerTypePercentages = new HashMap<>(); event.getPlayerTypePercentages().forEach((type, percentage) -> { if (percentage != null) { - playerTypePercentages.put(type, percentage.doubleValue()); + playerTypePercentages.put(type, (double) percentage); } });