Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 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
2195bc2
added tools for atlasagenttoolservice
Yhmidi Oct 21, 2025
56fb2b2
removed test repository usage
Yhmidi Oct 21, 2025
802b12d
added tests for statements
Yhmidi Oct 21, 2025
5ca35b2
Merge branch 'develop' into feature/agent-tools-integration
Yhmidi Oct 21, 2025
73bef51
useless test removed
Yhmidi Oct 21, 2025
e42a223
added test for tools
Yhmidi Oct 21, 2025
61b856a
added tests for prompt template
Yhmidi Oct 21, 2025
1708b2f
Merge branch 'develop' into feature/agent-tools-integration
Yhmidi Oct 21, 2025
adfd0d4
fixed style test
Yhmidi Oct 21, 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,29 @@
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;

@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) {
return MethodToolCallbackProvider.builder().toolObjects(toolsService).build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,6 @@
* DTO for Atlas Agent chat responses.
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record AtlasAgentChatResponseDTO(

@NotBlank @Size(max = 10000) String message,

@NotNull String sessionId,

@NotNull ZonedDateTime timestamp,

boolean success

) {
public record AtlasAgentChatResponseDTO(@NotBlank @Size(max = 10000) String message, @NotNull String sessionId, @NotNull ZonedDateTime timestamp, boolean success,
boolean competenciesModified) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package de.tum.cit.aet.artemis.atlas.service;

import jakarta.validation.constraints.NotNull;

/**
* Internal result object for Atlas Agent chat processing.
* Contains the response message and whether competencies were modified.
*/
public record AgentChatResult(@NotNull String message, boolean competenciesModified) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
import java.util.Map;
import java.util.concurrent.CompletableFuture;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jakarta.annotation.Nullable;

import org.springframework.ai.azure.openai.AzureOpenAiChatOptions;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.ai.chat.client.ChatClient.ChatClientRequestSpec;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
Expand All @@ -23,43 +26,69 @@
@Conditional(AtlasEnabled.class)
public class AtlasAgentService {

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

private final ChatClient chatClient;

private final AtlasPromptTemplateService templateService;

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

private final ChatMemory chatMemory;

private final AtlasAgentToolsService atlasAgentToolsService;

public AtlasAgentService(@Nullable ChatClient chatClient, AtlasPromptTemplateService templateService, @Nullable ToolCallbackProvider toolCallbackProvider,
@Nullable ChatMemory chatMemory, @Nullable AtlasAgentToolsService atlasAgentToolsService) {
this.chatClient = chatClient;
this.templateService = templateService;
this.toolCallbackProvider = toolCallbackProvider;
this.chatMemory = chatMemory;
this.atlasAgentToolsService = atlasAgentToolsService;
}

/**
* 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.
* Uses request-scoped state tracking to detect competency modifications.
*
* @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 for chat memory
* @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();
AzureOpenAiChatOptions options = AzureOpenAiChatOptions.builder().deploymentName("gpt-4o").temperature(1.0).build();

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

// Add chat memory advisor
if (chatMemory != null) {
promptSpec = promptSpec.advisors(MessageChatMemoryAdvisor.builder(chatMemory).conversationId(sessionId).build());
}

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

// Execute the chat (tools are executed internally by Spring AI)
String response = promptSpec.call().content();

// Check if createCompetency was called by examining the service state
boolean competenciesModified = atlasAgentToolsService != null && atlasAgentToolsService.wasCompetencyCreated();

String finalResponse = response != null && !response.trim().isEmpty() ? response : "I apologize, but I couldn't generate a response.";

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.");
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.");
return CompletableFuture.completedFuture(new AgentChatResult("I apologize, but I'm having trouble processing your request right now. Please try again later.", false));
}
}

Expand All @@ -73,7 +102,6 @@ public boolean isAvailable() {
return chatClient != null;
}
catch (Exception e) {
log.warn("Atlas Agent service availability check failed: {}", e.getMessage());
return false;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
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.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 org.springframework.web.context.annotation.RequestScope;

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.
* Request-scoped to track tool calls per HTTP request.
*/
@RequestScope
@Lazy
@Service
@Conditional(AtlasEnabled.class)
public class AtlasAgentToolsService {

private final ObjectMapper objectMapper;

private final CompetencyRepository competencyRepository;

private final CourseRepository courseRepository;

private final ExerciseRepository exerciseRepository;

// Track which modification tools were called during this request
private boolean competencyCreated = false;

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) {
Optional<Course> courseOptional = courseRepository.findById(courseId);
if (courseOptional.isEmpty()) {
return toJson(Map.of("error", "Course not found with ID: " + courseId));
}

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

var competencyList = competencies.stream().map(competency -> {
Map<String, Object> competencyData = new LinkedHashMap<>();
competencyData.put("id", competency.getId());
competencyData.put("title", competency.getTitle());
competencyData.put("description", competency.getDescription());
competencyData.put("taxonomy", competency.getTaxonomy() != null ? competency.getTaxonomy().toString() : "");
return competencyData;
}).toList();

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

return toJson(response);
}

/**
* 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)") CompetencyTaxonomy taxonomyLevel) {
try {

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

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

Competency savedCompetency = competencyRepository.save(competency);

this.competencyCreated = true;
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) {
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) {
return courseRepository.findById(courseId).map(Course::getDescription).orElse("");
}

/**
* 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) {
Optional<Course> courseOptional = courseRepository.findById(courseId);
if (courseOptional.isEmpty()) {
return toJson(Map.of("error", "Course not found with ID: " + courseId));
}

Set<Exercise> exercises = exerciseRepository.findByCourseIds(Set.of(courseId));

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);
}

/**
* Check if any competency was created during this request.
*
* @return true if createCompetency was called during this request
*/
public boolean wasCompetencyCreated() {
return this.competencyCreated;
}

/**
* 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) {
return "{\"error\": \"Failed to serialize response\"}";
}
}
}
Loading
Loading