Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8d55bd6
implemented agent's memory + migrated to gpt-4o + integrated artemis …
Yhmidi Oct 11, 2025
8a9b451
fixed javadoc
Yhmidi Oct 11, 2025
ddb0ab1
Merge branch 'develop' into feature/agent-tools-integration
Yhmidi Oct 11, 2025
7bc18c4
adapted previous tests to implementation changes
Yhmidi Oct 11, 2025
ce7fdca
implemented tests
Yhmidi Oct 12, 2025
427d70e
coderabbit issue + style issue
Yhmidi Oct 12, 2025
98b8c7c
simplified the competency_modified refreshing logic by parsing the ch…
Yhmidi Oct 13, 2025
7b8385a
implemented sessionid logic to support multi-user chat : to have inde…
Yhmidi Oct 13, 2025
8ffc8e2
removed the agent cached memory logic as it is inconsistent and not p…
Yhmidi Oct 14, 2025
50481c2
removed debugging logs and fixed compile error
Yhmidi Oct 14, 2025
fda54a3
fixed client compile error
Yhmidi Oct 14, 2025
17fe7be
fixed client compile error
Yhmidi Oct 14, 2025
40da596
fixed client compile error
Yhmidi Oct 14, 2025
9509cf6
fixed client compile error
Yhmidi Oct 14, 2025
455a8d9
implemented more tests
Yhmidi Oct 14, 2025
c83dff8
added client tests
Yhmidi Oct 14, 2025
efcf434
added client tests
Yhmidi Oct 14, 2025
5d36cfb
resolved issues pointed in review + improved coverage
Yhmidi Oct 15, 2025
7be4264
improved coverage
Yhmidi Oct 15, 2025
a4fdeef
corrected client tests
Yhmidi Oct 15, 2025
568a89d
made changes based on review
Yhmidi Oct 16, 2025
a66035e
fixed test
Yhmidi Oct 16, 2025
f596e9c
Merge branch 'develop' into feature/agent-tools-integration
Yhmidi Oct 16, 2025
15f1feb
reverted taxonomy to using enum
Yhmidi Oct 16, 2025
0085fe4
specified type for options and specs
Yhmidi Oct 18, 2025
3c830a4
Merge branch 'develop' into feature/agent-tools-integration
MaximilianAnzinger Oct 20, 2025
e30f369
removed unnecessary autowired annotation
Yhmidi Oct 20, 2025
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package de.tum.cit.aet.artemis.atlas.config;

import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;

import de.tum.cit.aet.artemis.atlas.service.AtlasAgentToolsService;

/**
* Configuration for Atlas Agent tools integration with Spring AI.
* This class registers the @Tool-annotated methods from AtlasAgentToolsService
* so that Spring AI can discover and use them for function calling.
*/
@Lazy
@Configuration
@Conditional(AtlasEnabled.class)
public class AtlasAgentToolConfig {

/**
* Registers the tools found on the AtlasAgentToolsService bean.
* MethodToolCallbackProvider discovers @Tool-annotated methods on the provided instances
* and makes them available for Spring AI's tool calling system.
*
* @param toolsService the service containing @Tool-annotated methods
* @return ToolCallbackProvider that exposes the tools to Spring AI
*/
@Bean
public ToolCallbackProvider atlasToolCallbackProvider(AtlasAgentToolsService toolsService) {
// MethodToolCallbackProvider discovers @Tool-annotated methods on the provided instances
return MethodToolCallbackProvider.builder().toolObjects(toolsService).build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ public record AtlasAgentChatResponseDTO(

@NotNull ZonedDateTime timestamp,

boolean success
Boolean success,

Boolean competenciesModified

) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package de.tum.cit.aet.artemis.atlas.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

import com.fasterxml.jackson.annotation.JsonInclude;

/**
* DTO for chat history messages retrieved from ChatMemory.
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record ChatHistoryMessageDTO(

@NotNull @NotBlank String role,

@NotNull @NotBlank String content

) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package de.tum.cit.aet.artemis.atlas.service;

/**
* Internal result object for Atlas Agent chat processing.
* Contains the response message and whether competencies were modified.
*/
public record AgentChatResult(String message, boolean competenciesModified) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.slf4j.LoggerFactory;
import org.springframework.ai.azure.openai.AzureOpenAiChatOptions;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Lazy;
Expand All @@ -29,37 +30,55 @@ public class AtlasAgentService {

private final AtlasPromptTemplateService templateService;

public AtlasAgentService(@Autowired(required = false) ChatClient chatClient, AtlasPromptTemplateService templateService) {
private final ToolCallbackProvider toolCallbackProvider;

public AtlasAgentService(@Autowired(required = false) ChatClient chatClient, AtlasPromptTemplateService templateService,
@Autowired(required = false) ToolCallbackProvider toolCallbackProvider) {
this.chatClient = chatClient;
this.templateService = templateService;
this.toolCallbackProvider = toolCallbackProvider;
}

/**
* Process a chat message for the given course and return AI response.
* Process a chat message for the given course and return AI response with modification status.
* Detects competency modifications by checking if the response contains specific keywords.
*
* @param message The user's message
* @param courseId The course ID for context
* @return AI response
* @param message The user's message
* @param courseId The course ID for context
* @param sessionId The session ID (TODO: will be used for another PR for Memory implementation including db migration)
* @return Result containing the AI response and competency modification flag
*/
public CompletableFuture<String> processChatMessage(String message, Long courseId) {
public CompletableFuture<AgentChatResult> processChatMessage(String message, Long courseId, String sessionId) {
try {
log.debug("Processing chat message for course {} (messageLength={} chars)", courseId, message.length());

// Load system prompt from external template
String resourcePath = "/prompts/atlas/agent_system_prompt.st";
Map<String, String> variables = Map.of(); // No variables needed for this template
String systemPrompt = templateService.render(resourcePath, variables);

String response = chatClient.prompt().system(systemPrompt).user(String.format("Course ID: %d\n\n%s", courseId, message))
.options(AzureOpenAiChatOptions.builder().temperature(1.0).build()).call().content();
var options = AzureOpenAiChatOptions.builder().deploymentName("gpt-4o").temperature(1.0).build();
log.info("Atlas Agent using deployment name: {} for course {} with session {}", options.getDeploymentName(), courseId, sessionId);

var promptSpec = chatClient.prompt().system(systemPrompt).user(String.format("Course ID: %d\n\n%s", courseId, message)).options(options);

// Add tools
if (toolCallbackProvider != null) {
promptSpec = promptSpec.toolCallbacks(toolCallbackProvider);
}

String response = promptSpec.call().content();

// if response mentions creation/modification, set flag
boolean competenciesModified = response != null && (response.toLowerCase().contains("created") || response.toLowerCase().contains("successfully created")
|| response.toLowerCase().contains("competency titled"));

log.info("Successfully processed chat message for course {}", courseId);
return CompletableFuture.completedFuture(response != null && !response.trim().isEmpty() ? response : "I apologize, but I couldn't generate a response.");
log.info("Successfully processed chat message for course {} with session {} (competenciesModified={})", courseId, sessionId, competenciesModified);
String finalResponse = response != null && !response.trim().isEmpty() ? response : "I apologize, but I couldn't generate a response.";
return CompletableFuture.completedFuture(new AgentChatResult(finalResponse, competenciesModified));

}
catch (Exception e) {
log.error("Error processing chat message for course {}: {}", courseId, e.getMessage(), e);
return CompletableFuture.completedFuture("I apologize, but I'm having trouble processing your request right now. Please try again later.");
log.error("Error processing chat message for course {} with session {}: {}", courseId, sessionId, e.getMessage(), e);
return CompletableFuture.completedFuture(new AgentChatResult("I apologize, but I'm having trouble processing your request right now. Please try again later.", false));
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package de.tum.cit.aet.artemis.atlas.service;

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import de.tum.cit.aet.artemis.atlas.config.AtlasEnabled;
import de.tum.cit.aet.artemis.atlas.domain.competency.Competency;
import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyTaxonomy;
import de.tum.cit.aet.artemis.atlas.repository.CompetencyRepository;
import de.tum.cit.aet.artemis.core.domain.Course;
import de.tum.cit.aet.artemis.core.repository.CourseRepository;
import de.tum.cit.aet.artemis.exercise.domain.Exercise;
import de.tum.cit.aet.artemis.exercise.repository.ExerciseRepository;

/**
* Service providing tools for the Atlas Agent using Spring AI's @Tool annotation.
*/
@Lazy
@Service
@Conditional(AtlasEnabled.class)
public class AtlasAgentToolsService {

private static final Logger log = LoggerFactory.getLogger(AtlasAgentToolsService.class);

private final ObjectMapper objectMapper;

private final CompetencyRepository competencyRepository;

private final CourseRepository courseRepository;

private final ExerciseRepository exerciseRepository;

public AtlasAgentToolsService(ObjectMapper objectMapper, CompetencyRepository competencyRepository, CourseRepository courseRepository, ExerciseRepository exerciseRepository) {
this.objectMapper = objectMapper;
this.competencyRepository = competencyRepository;
this.courseRepository = courseRepository;
this.exerciseRepository = exerciseRepository;
}

/**
* Tool for getting course competencies.
*
* @param courseId the course ID
* @return JSON representation of competencies
*/
@Tool(description = "Get all competencies for a course")
public String getCourseCompetencies(@ToolParam(description = "the ID of the course") Long courseId) {
try {
Optional<Course> courseOpt = courseRepository.findById(courseId);
if (courseOpt.isEmpty()) {
return toJson(Map.of("error", "Course not found with ID: " + courseId));
}

Set<Competency> competencies = competencyRepository.findAllByCourseId(courseId);

// Build competency list using Jackson
var competencyList = competencies.stream().map(competency -> {
Map<String, Object> compData = new LinkedHashMap<>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am generally not a fan of abbreviations. I think they are are not great for readability. I prefere camel case with full words. Just a personal opinion though. Do with this what you want :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolved

compData.put("id", competency.getId());
compData.put("title", competency.getTitle());
compData.put("description", competency.getDescription());
compData.put("taxonomy", competency.getTaxonomy() != null ? competency.getTaxonomy().toString() : "");
return compData;
}).toList();

Map<String, Object> response = new LinkedHashMap<>();
response.put("courseId", courseId);
response.put("competencies", competencyList);

return toJson(response);
}
catch (Exception e) {
log.error("Error getting course competencies for course {}: {}", courseId, e.getMessage(), e);
return toJson(Map.of("error", "Failed to retrieve competencies: " + e.getMessage()));
}
}

/**
* Tool for creating a new competency in a course.
*
* @param courseId the course ID
* @param title the competency title
* @param description the competency description
* @param taxonomyLevel the taxonomy level (REMEMBER, UNDERSTAND, APPLY, ANALYZE, EVALUATE, CREATE)
* @return JSON response indicating success or error
*/
@Tool(description = "Create a new competency for a course")
public String createCompetency(@ToolParam(description = "the ID of the course") Long courseId, @ToolParam(description = "the title of the competency") String title,
@ToolParam(description = "the description of the competency") String description,
@ToolParam(description = "the taxonomy level (REMEMBER, UNDERSTAND, APPLY, ANALYZE, EVALUATE, CREATE)") String taxonomyLevel) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be possible to work with an enum here or is that not supported for tools?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes I realized the LLM detects the enum possible values so no need to specify them in the description.

try {
Optional<Course> courseOpt = courseRepository.findById(courseId);
if (courseOpt.isEmpty()) {
return toJson(Map.of("error", "Course not found with ID: " + courseId));
}

Course course = courseOpt.get();
Competency competency = new Competency();
competency.setTitle(title);
competency.setDescription(description);
competency.setCourse(course);

if (taxonomyLevel != null && !taxonomyLevel.isEmpty()) {
try {
CompetencyTaxonomy taxonomy = CompetencyTaxonomy.valueOf(taxonomyLevel.toUpperCase());
competency.setTaxonomy(taxonomy);
}
catch (IllegalArgumentException e) {
log.warn("Invalid taxonomy level '{}', using default", taxonomyLevel);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to use a default here? Would there be a way to communicate an error to the agent such that he can react?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this whole part is indeed not needed. The LLM recognizes the possible values through the enum and won't give invalid input in the function call. (tested it). LLM will always try to find the closest match from instructor's input to the possible values and maps correctly.

}
}

Competency savedCompetency = competencyRepository.save(competency);

// Build response using Jackson
Map<String, Object> competencyData = new LinkedHashMap<>();
competencyData.put("id", savedCompetency.getId());
competencyData.put("title", savedCompetency.getTitle());
competencyData.put("description", savedCompetency.getDescription());
competencyData.put("taxonomy", savedCompetency.getTaxonomy() != null ? savedCompetency.getTaxonomy().toString() : "");
competencyData.put("courseId", courseId);

Map<String, Object> response = new LinkedHashMap<>();
response.put("success", true);
response.put("competency", competencyData);

return toJson(response);
}
catch (Exception e) {
log.error("Error creating competency for course {}: {}", courseId, e.getMessage(), e);
return toJson(Map.of("error", "Failed to create competency: " + e.getMessage()));
}
}

/**
* Tool for getting course description.
*
* @param courseId the course ID
* @return the course description or empty string if not found
*/
@Tool(description = "Get the description of a course")
public String getCourseDescription(@ToolParam(description = "the ID of the course") Long courseId) {
try {
Optional<Course> courseOpt = courseRepository.findById(courseId);
if (courseOpt.isEmpty()) {
return "";
}

Course course = courseOpt.get();
String description = course.getDescription();
return description != null ? description : "";
}
catch (Exception e) {
log.error("Error getting course description for course {}: {}", courseId, e.getMessage(), e);
return "";
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
try {
Optional<Course> courseOpt = courseRepository.findById(courseId);
if (courseOpt.isEmpty()) {
return "";
}
Course course = courseOpt.get();
String description = course.getDescription();
return description != null ? description : "";
}
catch (Exception e) {
log.error("Error getting course description for course {}: {}", courseId, e.getMessage(), e);
return "";
}
return courseRepository.findById(courseId).map(Course::getDescription).orElse("");

Could be written much more concisely.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense,I applied your suggestion.

}

/**
* Tool for getting exercises for a course.
*
* @param courseId the course ID
* @return JSON representation of exercises
*/
@Tool(description = "List exercises for a course")
public String getExercisesListed(@ToolParam(description = "the ID of the course") Long courseId) {
try {
Optional<Course> courseOpt = courseRepository.findById(courseId);
if (courseOpt.isEmpty()) {
return toJson(Map.of("error", "Course not found with ID: " + courseId));
}

// NOTE: adapt to your ExerciseRepository method
Set<Exercise> exercises = exerciseRepository.findByCourseIds(Set.of(courseId));

// Build exercise list using Jackson
var exerciseList = exercises.stream().map(exercise -> {
Map<String, Object> exerciseData = new LinkedHashMap<>();
exerciseData.put("id", exercise.getId());
exerciseData.put("title", exercise.getTitle());
exerciseData.put("type", exercise.getClass().getSimpleName());
exerciseData.put("maxPoints", exercise.getMaxPoints() != null ? exercise.getMaxPoints() : 0);
exerciseData.put("releaseDate", exercise.getReleaseDate() != null ? exercise.getReleaseDate().toString() : "");
exerciseData.put("dueDate", exercise.getDueDate() != null ? exercise.getDueDate().toString() : "");
return exerciseData;
}).toList();

Map<String, Object> response = new LinkedHashMap<>();
response.put("courseId", courseId);
response.put("exercises", exerciseList);

return toJson(response);
}
catch (Exception e) {
log.error("Error getting exercises for course {}: {}", courseId, e.getMessage(), e);
return toJson(Map.of("error", "Failed to retrieve exercises: " + e.getMessage()));
}
}

/**
* Convert object to JSON using Jackson ObjectMapper.
*
* @param object the object to serialize
* @return JSON string representation
*/
private String toJson(Object object) {
try {
return objectMapper.writeValueAsString(object);
}
catch (JsonProcessingException e) {
log.error("Failed to serialize object to JSON: {}", e.getMessage(), e);
return "{\"error\": \"Failed to serialize response\"}";
}
}
}
Loading
Loading