diff --git a/build.gradle b/build.gradle index 969d62f..eb29c1f 100644 --- a/build.gradle +++ b/build.gradle @@ -122,7 +122,6 @@ dependencies { 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' 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 40e7a3e..0b2094d 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 @@ -147,7 +147,6 @@ public String generateFeedback(ContentProgressedEvent event) { ProactiveFeedbackEntity savedEntity = proactiveFeedbackRepository.save(feedbackEntity); - // Convert to DTO and publish to subscribed clients ProactiveFeedback feedbackDto = ProactiveFeedback.builder() .setId(savedEntity.getId()) .setAssessmentId(savedEntity.getAssessmentId()) @@ -192,9 +191,10 @@ public List getAllFeedbackForUser(UUID userId) { /** * Retrieves the most recent feedback for a user and deletes it. * This is used when the frontend requests proactive feedback. + * Feedback older than 30 minutes is automatically discarded. * * @param userId the user ID - * @return optional feedback text, or empty if no feedback exists + * @return optional feedback text, or empty if no feedback exists or is older than 30 minutes */ public Optional getAndDeleteLatestFeedback(UUID userId) { List feedbackList = proactiveFeedbackRepository.findByUserIdOrderByCreatedAtDesc(userId); @@ -204,6 +204,13 @@ public Optional getAndDeleteLatestFeedback(UUID userId) { } ProactiveFeedbackEntity latestFeedback = feedbackList.get(0); + + OffsetDateTime thirtyMinutesAgo = OffsetDateTime.now().minusMinutes(30); + if (latestFeedback.getCreatedAt().isBefore(thirtyMinutesAgo)) { + proactiveFeedbackRepository.delete(latestFeedback); + return Optional.empty(); + } + String feedbackText = latestFeedback.getFeedbackText(); proactiveFeedbackRepository.delete(latestFeedback); 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 c3123a6..72adb34 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 @@ -55,7 +55,8 @@ public class TutorService { private static final List PROMPT_TEMPLATES = List.of( "categorize_message_prompt.txt", "answer_lecture_question_prompt.txt", - "answer_code_feedback_prompt.txt" + "answer_code_feedback_prompt.txt", + "answer_followup_question_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,9 +109,7 @@ public LectureQuestionResponse handleUserQuestion(String userQuestion, UUID cour return new LectureQuestionResponse(unrecognizable, List.of()); } if(category == TutorCategory.OTHER){ - String other = ("I'm currently unable to answer this type of message. " + - "However, I can still help you with questions about lecture materials or the MEITREX system :)"); - return new LectureQuestionResponse(other, List.of()); + return handleFollowUpQuestion(userQuestion, courseId, currentUser); } //Further process the question for the remaining categories @@ -196,6 +195,113 @@ private LectureQuestionResponse answerLectureQuestion(String question, UUID cour return new LectureQuestionResponse(response.getAnswer(), sources); } + /** + * Handles follow-up questions by performing semantic search on conversation history and the user's prompt. + * This method is used for questions categorized as OTHER, typically follow-ups to previous questions. + * + * @param question the follow-up question asked by the user + * @param courseId the ID of the course + * @param currentUser the currently logged-in user + * @return a response containing the answer based on conversation history + */ + private LectureQuestionResponse handleFollowUpQuestion(String question, UUID courseId, LoggedInUser currentUser) { + if (courseId == null) { + String response = "Something went wrong! If your question is a follow-up to previous questions, " + + "please navigate to the course it relates to. Thank you! :)"; + return new LectureQuestionResponse(response, List.of()); + } + + String conversationHistory = conversationHistoryService.formatHistoryForPrompt( + currentUser.getId(), courseId); + + if (conversationHistory.isEmpty()) { + String response = ("I'm currently unable to answer this type of message. " + + "However, I can still help you with questions about lecture materials or the MEITREX system :)"); + return new LectureQuestionResponse(response, List.of()); + } + + String codeContext = ""; + List submissions = + studentCodeSubmissionService.getCodeSubmissionsForStudent(currentUser.getId()); + + if (!submissions.isEmpty()) { + 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) { + Optional codeContextOpt = studentCodeSubmissionService.getCodeSubmissionContextForTutor( + currentUser.getId(), + mostRecentSubmission.getPrimaryKey().getAssignmentId()); + + if (codeContextOpt.isPresent()) { + codeContext = codeContextOpt.get(); + } + } + } + + StringBuilder searchQueryBuilder = new StringBuilder(question) + .append(" ") + .append(conversationHistory); + if (!codeContext.isEmpty()) { + searchQueryBuilder.append(" ").append(codeContext); + } + String searchQuery = searchQueryBuilder.toString(); + + List searchResults = semanticSearchService.semanticSearch( + searchQuery, courseId, currentUser); + + List segmentSearchResults = searchResults.stream() + .filter(result -> result.getMediaRecordSegment() != null) + .toList(); + + 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) + .toList(); + + double averageSkillLevel = getAverageSkillLevel(currentUser.getId()); + log.info("User {} average skill level: {}", currentUser.getId(), averageSkillLevel); + + String skillLevelPromotContent = getSkillBasedFeedbackStyle(averageSkillLevel); + + String prompt = ollamaService.getTemplate(PROMPT_TEMPLATES.get(3)); + String contentString = semanticSearchService.formatIntoNumberedListForPrompt( + documentSegments.stream().map(DocumentRecordSegment::getText).toList()); + + log.info("Processing follow-up question for user {} in course {}", + currentUser.getId(), courseId); + + 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("conversationHistory").argumentValue(conversationHistory).build(), + TemplateArgs.builder().argumentName("codeContext").argumentValue(codeContext).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) + .sorted(Comparator.comparingDouble(SemanticSearchResult::getScore).reversed()) + .limit(topSourceCount) + .map(this::generateSource) + .filter(Objects::nonNull) + .toList(); + + return new LectureQuestionResponse(response.getAnswer(), sources); + } + /** * preprocesses the question send by the user to be categorized into material or system question * @param userQuestion The question the user asked the AI Tutor diff --git a/src/main/resources/prompt_templates/answer_followup_question_prompt.txt b/src/main/resources/prompt_templates/answer_followup_question_prompt.txt new file mode 100644 index 0000000..e4a584f --- /dev/null +++ b/src/main/resources/prompt_templates/answer_followup_question_prompt.txt @@ -0,0 +1,44 @@ +You are an AI tutor helping a student understand lecture material. + +Below is a question the student has asked, along with excerpts from the lecture that are relevant to the question. +This might be a followup question to an earlier exchange. If this is not the case, answer that you can not help the student with that currently. + +If is it a question about the lecture, please use only the information in the provided lecture content to answer the question as accurately as possible. +If the information is insufficient to answer, say so and avoid making assumptions. + +If it is a question about code, then 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. + +Always answer in the same language as the student's question. +Do not translate or localize technical terms or vocabulary from the lecture content — keep them exactly as they appear. + +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.). +--- + +Question: +{{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}} + +--- + +STUDENT'S CODE SUBMISSION: +{{codeContext}} diff --git a/src/main/resources/prompt_templates/categorize_message_prompt.txt b/src/main/resources/prompt_templates/categorize_message_prompt.txt index 618d91b..dabcff2 100644 --- a/src/main/resources/prompt_templates/categorize_message_prompt.txt +++ b/src/main/resources/prompt_templates/categorize_message_prompt.txt @@ -6,7 +6,7 @@ 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. 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. +4. OTHER: Choose this if the question if it is neither a SYSTEM, nor a LECTURE nor a CODE_FEEDBACK question and if the question might be a follow up question. For example: "Can you explain it again?" or "Can you explain it differently?" or "Can you provide an example to clarify your previous answer?" 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. diff --git a/src/main/resources/prompt_templates/proactive_feedback_prompt.txt b/src/main/resources/prompt_templates/proactive_feedback_prompt.txt index 1ee742d..f27204f 100644 --- a/src/main/resources/prompt_templates/proactive_feedback_prompt.txt +++ b/src/main/resources/prompt_templates/proactive_feedback_prompt.txt @@ -1,4 +1,4 @@ -You are an encouraging AI tutor providing proactive, personalized feedback to a student who has just completed an assignment or quiz. +You are an encouraging AI tutor providing proactive, personalized feedback to a student who has just completed a quiz or submitted code. Your goal is to help the student reflect on their performance, feel motivated, and understand how to improve without overwhelming them. @@ -23,7 +23,7 @@ STUDENT'S CODE SUBMISSION (optional): --- Your Task: -Generate a personalized feedback message of 2 to 4 sentences that: +Generate a personalized feedback message of 2 to 3 sentences that: 1. Acknowledges the student’s effort and overall performance 2. Reflects their performance level accurately (celebrate success or normalize mistakes) @@ -38,5 +38,7 @@ If a code submission is provided: If no code submission is provided: - Focus on conceptual understanding, confidence, and next steps +Specify which action prompted this response: code submission or quiz completion + 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 index 84b099a..3a7d2bf 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 @@ -357,6 +357,49 @@ void testGetAndDeleteLatestFeedback_noFeedbackAvailable() { verify(proactiveFeedbackRepository, never()).delete(any()); } + @Test + void testGetAndDeleteLatestFeedback_feedbackOlderThan30Minutes() { + ProactiveFeedbackEntity oldFeedbackEntity = ProactiveFeedbackEntity.builder() + .id(UUID.randomUUID()) + .userId(userId) + .assessmentId(assignmentId) + .feedbackText("Old feedback that should be discarded") + .correctness(0.90) + .success(true) + .createdAt(OffsetDateTime.now().minusMinutes(31)) + .build(); + + when(proactiveFeedbackRepository.findByUserIdOrderByCreatedAtDesc(userId)) + .thenReturn(List.of(oldFeedbackEntity)); + + Optional result = proactiveFeedbackService.getAndDeleteLatestFeedback(userId); + + assertFalse(result.isPresent()); + verify(proactiveFeedbackRepository, times(1)).delete(oldFeedbackEntity); + } + + @Test + void testGetAndDeleteLatestFeedback_feedbackWithin30Minutes() { + ProactiveFeedbackEntity recentFeedbackEntity = ProactiveFeedbackEntity.builder() + .id(UUID.randomUUID()) + .userId(userId) + .assessmentId(assignmentId) + .feedbackText("Recent feedback") + .correctness(0.90) + .success(true) + .createdAt(OffsetDateTime.now().minusMinutes(5)) + .build(); + + when(proactiveFeedbackRepository.findByUserIdOrderByCreatedAtDesc(userId)) + .thenReturn(List.of(recentFeedbackEntity)); + + Optional result = proactiveFeedbackService.getAndDeleteLatestFeedback(userId); + + assertTrue(result.isPresent()); + assertEquals("Recent feedback", result.get()); + verify(proactiveFeedbackRepository, times(1)).delete(recentFeedbackEntity); + } + @Test void testProactiveFeedbackStream() { var publisher = proactiveFeedbackService.proactiveFeedbackStream(userId); 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 ee3ffc3..2ba81ba 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 @@ -63,8 +63,10 @@ void testHandleUserQuestion_withOtherCategory() { CategorizedQuestion categorizedQuestion = new CategorizedQuestion(question,TutorCategory.OTHER); when(ollamaService.startQuery(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(categorizedQuestion); when(ollamaService.getTemplate(Mockito.any())).thenReturn("Mocked Prompt"); + when(conversationHistoryService.formatHistoryForPrompt(Mockito.any(), Mockito.any())).thenReturn(""); + when(studentCodeSubmissionService.getCodeSubmissionsForStudent(Mockito.any())).thenReturn(List.of()); - LectureQuestionResponse response = tutorService.handleUserQuestion(question, null, loggedInUser); + LectureQuestionResponse response = tutorService.handleUserQuestion(question, courseId, loggedInUser); assertEquals("I'm currently unable to answer this type of message. " + "However, I can still help you with questions about lecture materials or the MEITREX system :)", response.getAnswer()); @@ -225,4 +227,77 @@ void testHandleUserQuestion_withProactiveFeedbackKeywordNoFeedback() { LectureQuestionResponse response = tutorService.handleUserQuestion("proactivefeedback", courseId, loggedInUser); assertEquals("No proactive feedback available at the moment.", response.getAnswer()); } + + @Test + void testHandleUserQuestion_withFollowUpQuestionWithCodeContext() { + String question = "Can you explain that better?"; + CategorizedQuestion categorizedQuestion = new CategorizedQuestion(question, TutorCategory.OTHER); + TutorAnswer tutorAnswer = new TutorAnswer("Here is a more detailed explanation based on your code."); + + de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.StudentCodeSubmissionEntity submission = + Mockito.mock(de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.StudentCodeSubmissionEntity.class); + de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.StudentCodeSubmissionEntity.PrimaryKey primaryKey = + Mockito.mock(de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.StudentCodeSubmissionEntity.PrimaryKey.class); + + when(primaryKey.getAssignmentId()).thenReturn(UUID.randomUUID()); + when(submission.getPrimaryKey()).thenReturn(primaryKey); + when(submission.getLastUpdated()).thenReturn(java.time.OffsetDateTime.now()); + + List dummyResults = List.of( + SemanticSearchResult.builder() + .score(0.15) + .typename("DocumentRecordSegment") + .mediaRecordSegment(DocumentRecordSegment.builder().page(2).text("Dummy content").build()) + .build() + ); + + when(ollamaService.startQuery(Mockito.eq(CategorizedQuestion.class), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(categorizedQuestion); + when(ollamaService.startQuery(Mockito.eq(TutorAnswer.class), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(tutorAnswer); + when(ollamaService.getTemplate(Mockito.any())).thenReturn("Mocked Prompt"); + when(conversationHistoryService.formatHistoryForPrompt(Mockito.any(), Mockito.any())) + .thenReturn("Previous conversation history"); + when(studentCodeSubmissionService.getCodeSubmissionsForStudent(Mockito.any())) + .thenReturn(List.of(submission)); + when(studentCodeSubmissionService.getCodeSubmissionContextForTutor(Mockito.any(), Mockito.any())) + .thenReturn(Optional.of("public class Example { }")); + when(semanticSearchService.semanticSearch(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(dummyResults); + when(userSkillLevelService.getAllSkillLevelsForUser(Mockito.any())).thenReturn(List.of()); + + LectureQuestionResponse response = tutorService.handleUserQuestion(question, courseId, loggedInUser); + assertEquals("Here is a more detailed explanation based on your code.", response.getAnswer()); + } + + @Test + void testHandleUserQuestion_withFollowUpQuestionNoCodeContext() { + String question = "Can you clarify that?"; + CategorizedQuestion categorizedQuestion = new CategorizedQuestion(question, TutorCategory.OTHER); + TutorAnswer tutorAnswer = new TutorAnswer("Here is a clarification."); + + List dummyResults = List.of( + SemanticSearchResult.builder() + .score(0.15) + .typename("DocumentRecordSegment") + .mediaRecordSegment(DocumentRecordSegment.builder().page(2).text("Dummy content").build()) + .build() + ); + + when(ollamaService.startQuery(Mockito.eq(CategorizedQuestion.class), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(categorizedQuestion); + when(ollamaService.startQuery(Mockito.eq(TutorAnswer.class), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(tutorAnswer); + when(ollamaService.getTemplate(Mockito.any())).thenReturn("Mocked Prompt"); + when(conversationHistoryService.formatHistoryForPrompt(Mockito.any(), Mockito.any())) + .thenReturn("Previous conversation history"); + when(studentCodeSubmissionService.getCodeSubmissionsForStudent(Mockito.any())) + .thenReturn(List.of()); + when(semanticSearchService.semanticSearch(Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(dummyResults); + when(userSkillLevelService.getAllSkillLevelsForUser(Mockito.any())).thenReturn(List.of()); + + LectureQuestionResponse response = tutorService.handleUserQuestion(question, courseId, loggedInUser); + assertEquals("Here is a clarification.", response.getAnswer()); + } }