Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -192,9 +191,10 @@ public List<ProactiveFeedbackEntity> 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<String> getAndDeleteLatestFeedback(UUID userId) {
List<ProactiveFeedbackEntity> feedbackList = proactiveFeedbackRepository.findByUserIdOrderByCreatedAtDesc(userId);
Expand All @@ -204,6 +204,13 @@ public Optional<String> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ public class TutorService {
private static final List<String> 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<String> SKILL_LEVEL_PROMPT_TEMPLATES = List.of(
"Provide a clear and simple hint that gently guides the user toward the next step without overwhelming them.",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<de.unistuttgart.iste.meitrex.tutor_service.persistence.entity.StudentCodeSubmissionEntity> 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<String> 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<SemanticSearchResult> searchResults = semanticSearchService.semanticSearch(
searchQuery, courseId, currentUser);

List<SemanticSearchResult> segmentSearchResults = searchResults.stream()
.filter(result -> result.getMediaRecordSegment() != null)
.toList();

List<DocumentRecordSegment> 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<TemplateArgs> 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<Source> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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}}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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)
Expand All @@ -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}}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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<String> 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);
Expand Down
Loading
Loading